feat: Implement unified Vue SPA with granular service control

Consolidate Reports and Data Entry apps into a single Vue.js SPA with:

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-24 19:06:23 +02:00
parent fed2e68fa2
commit d507a81b0a
112 changed files with 38382 additions and 2382 deletions

View File

@@ -0,0 +1,9 @@
<template>
<ErrorBoundary module-name="Rapoarte">
<router-view />
</ErrorBoundary>
</template>
<script setup>
import ErrorBoundary from '@shared/components/ErrorBoundary.vue'
</script>

View File

@@ -0,0 +1,551 @@
<template>
<div class="company-selector-mini" ref="dropdownContainer">
<div class="company-dropdown" ref="dropdown">
<button
class="company-trigger"
@click="toggleDropdown"
:aria-expanded="dropdownOpen"
aria-label="Select company"
title="Alt+Q to quick select"
>
<div class="company-info">
<span class="company-name">{{ selectedCompanyName }}</span>
<span class="company-code">{{ selectedCompanyCode }}</span>
</div>
<i
class="pi pi-chevron-down"
:class="{ 'rotate-180': dropdownOpen }"
></i>
</button>
<div
v-show="dropdownOpen"
class="company-dropdown-panel"
:class="{ 'panel-open': dropdownOpen }"
>
<div class="dropdown-search">
<div class="search-wrapper">
<i class="pi pi-search search-icon"></i>
<input
ref="searchInput"
type="text"
v-model="searchQuery"
placeholder="Search companies..."
class="search-input"
@keydown="handleKeyDown"
/>
</div>
</div>
<div class="company-list">
<div
v-for="(company, index) in filteredCompanies"
:key="company.id_firma || company.id"
class="company-item"
:class="{
active: company.id_firma === selectedCompany?.id_firma,
'keyboard-highlighted': isHighlighted(index),
}"
@click="selectCompany(company)"
@mouseenter="highlightedIndex = index"
>
<div class="company-details">
<div class="company-main-name">{{ company.name }}</div>
<div class="company-sub-info">
<span class="company-cui">CUI: {{ company.fiscal_code }}</span>
<span class="company-separator"></span>
<span class="company-status" :class="company.status">{{
company.status
}}</span>
</div>
</div>
<i
v-if="company.id_firma === selectedCompany?.id_firma"
class="pi pi-check company-selected-icon"
></i>
</div>
</div>
<div v-if="filteredCompanies.length === 0" class="no-results">
<i class="pi pi-info-circle"></i>
<span>No companies found</span>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
import { useCompanyStore } from "../../stores/companies";
export default {
name: "CompanySelectorMini",
props: {
modelValue: {
type: Object,
default: null,
},
},
emits: ["update:modelValue", "company-changed"],
setup(props, { emit }) {
const companiesStore = useCompanyStore();
const dropdown = ref(null);
const dropdownContainer = ref(null);
const searchInput = ref(null);
const dropdownOpen = ref(false);
const searchQuery = ref("");
const highlightedIndex = ref(-1);
const selectedCompany = computed({
get: () => props.modelValue || companiesStore.selectedCompany,
set: (value) => {
emit("update:modelValue", value);
companiesStore.setSelectedCompany(value);
},
});
const selectedCompanyName = computed(() => {
return selectedCompany.value?.name || "Select Company";
});
const selectedCompanyCode = computed(() => {
return selectedCompany.value?.fiscal_code
? `CUI: ${selectedCompany.value.fiscal_code}`
: "";
});
const filteredCompanies = computed(() => {
const companies = companiesStore.companies || [];
if (!searchQuery.value || searchQuery.value.trim() === "") {
return companies;
}
const query = searchQuery.value.toLowerCase().trim();
return companies.filter(
(company) =>
company.name?.toLowerCase().includes(query) ||
company.fiscal_code?.toLowerCase().includes(query),
);
});
const toggleDropdown = async () => {
dropdownOpen.value = !dropdownOpen.value;
if (dropdownOpen.value) {
searchQuery.value = "";
highlightedIndex.value = -1;
// Focus on search input after dropdown opens
await nextTick();
searchInput.value?.focus();
}
};
const closeDropdown = () => {
dropdownOpen.value = false;
searchQuery.value = "";
};
const selectCompany = (company) => {
selectedCompany.value = company;
emit("company-changed", company);
closeDropdown();
};
const scrollToHighlighted = () => {
nextTick(() => {
const highlightedElement = document.querySelector(
".company-item.keyboard-highlighted",
);
if (highlightedElement) {
highlightedElement.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
});
};
const handleKeyDown = (event) => {
if (!dropdownOpen.value || filteredCompanies.value.length === 0) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
highlightedIndex.value =
(highlightedIndex.value + 1) % filteredCompanies.value.length;
scrollToHighlighted();
break;
case "ArrowUp":
event.preventDefault();
if (highlightedIndex.value <= 0) {
highlightedIndex.value = filteredCompanies.value.length - 1;
} else {
highlightedIndex.value--;
}
scrollToHighlighted();
break;
case "Enter":
event.preventDefault();
if (
highlightedIndex.value >= 0 &&
highlightedIndex.value < filteredCompanies.value.length
) {
selectCompany(filteredCompanies.value[highlightedIndex.value]);
}
break;
case "Escape":
closeDropdown();
break;
}
};
const isHighlighted = (index) => {
return index === highlightedIndex.value;
};
const openWithShortcut = async () => {
// Scroll to selector
if (dropdownContainer.value) {
dropdownContainer.value.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
// Wait for scroll to complete
await new Promise((resolve) => setTimeout(resolve, 300));
// Open dropdown and focus
if (!dropdownOpen.value) {
dropdownOpen.value = true;
highlightedIndex.value = -1;
searchQuery.value = "";
await nextTick();
searchInput.value?.focus();
} else {
// If already open, just focus
searchInput.value?.focus();
}
};
const handleGlobalKeyDown = (event) => {
// Check for Alt+Q (left-hand shortcut)
if (event.altKey && event.key === "q") {
event.preventDefault();
openWithShortcut();
}
};
const handleClickOutside = (event) => {
if (dropdown.value && !dropdown.value.contains(event.target)) {
closeDropdown();
}
};
// Watch for search query changes and reset highlighted index
watch(searchQuery, () => {
highlightedIndex.value = -1;
});
onMounted(() => {
document.addEventListener("click", handleClickOutside);
document.addEventListener("keydown", handleGlobalKeyDown);
// Load companies if not already loaded
if (companiesStore.companies.length === 0) {
companiesStore.loadCompanies();
}
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
document.removeEventListener("keydown", handleGlobalKeyDown);
});
return {
dropdown,
dropdownContainer,
searchInput,
dropdownOpen,
searchQuery,
highlightedIndex,
selectedCompany,
selectedCompanyName,
selectedCompanyCode,
filteredCompanies,
toggleDropdown,
closeDropdown,
selectCompany,
handleKeyDown,
isHighlighted,
};
},
};
</script>
<style scoped>
.company-selector-mini {
position: relative;
max-width: 450px;
}
.company-dropdown {
position: relative;
}
.company-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
width: 100%;
text-align: left;
min-width: 300px;
}
.company-trigger:hover {
border-color: var(--color-primary);
background: var(--color-bg-secondary);
}
.company-info {
flex: 1;
min-width: 0;
}
.company-name {
display: block;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.company-code {
display: block;
font-size: var(--text-xs);
color: var(--color-text-secondary);
margin-top: 2px;
}
.pi-chevron-down {
transition: transform var(--transition-fast);
color: var(--color-text-secondary);
font-size: var(--text-xs);
}
.rotate-180 {
transform: rotate(180deg);
}
.company-dropdown-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: var(--z-dropdown);
max-height: 300px;
overflow: hidden;
opacity: 0;
transform: translateY(-10px);
transition: all var(--transition-fast);
}
.panel-open {
opacity: 1;
transform: translateY(0);
}
.dropdown-search {
padding: var(--space-sm);
border-bottom: 1px solid var(--color-border);
}
.search-wrapper {
position: relative;
}
.search-icon {
position: absolute;
left: var(--space-sm);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
font-size: var(--text-sm);
pointer-events: none;
}
.search-input {
width: 100%;
padding: var(--space-sm) var(--space-sm) var(--space-sm) var(--space-xl);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
background: var(--color-bg);
color: var(--color-text);
transition: border-color var(--transition-fast);
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
}
.company-list {
max-height: 200px;
overflow-y: auto;
}
.company-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md);
cursor: pointer;
transition: background-color var(--transition-fast);
border-bottom: 1px solid var(--color-border-light);
}
.company-item:last-child {
border-bottom: none;
}
.company-item:hover {
background: var(--color-bg-secondary);
}
.company-item.active {
background: var(--color-primary);
color: var(--color-text-inverse);
}
.company-item.keyboard-highlighted {
background: var(--color-bg-secondary);
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.company-item.active.keyboard-highlighted {
/* When both active and highlighted, outline with semi-transparent white */
outline: 2px solid rgba(255, 255, 255, 0.5);
}
.company-details {
flex: 1;
min-width: 0;
}
.company-main-name {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.company-sub-info {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-xs);
opacity: 0.8;
}
.company-separator {
opacity: 0.5;
}
.company-status.active {
color: var(--color-success);
}
.company-status.inactive {
color: var(--color-error);
}
.company-selected-icon {
color: inherit;
font-size: var(--text-sm);
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-xl);
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.company-selector-mini {
max-width: 200px;
width: auto;
}
.company-trigger {
min-width: auto;
max-width: 200px;
padding: var(--space-xs) var(--space-sm);
}
.company-info {
max-width: 140px;
}
.company-name {
font-size: var(--text-xs);
max-width: 140px;
}
.company-code {
font-size: 10px;
}
.company-dropdown-panel {
position: fixed;
left: 8px;
right: 8px;
top: 60px;
width: auto;
max-height: 70vh;
}
}
/* Extra small screens */
@media (max-width: 400px) {
.company-selector-mini {
max-width: 160px;
}
.company-trigger {
max-width: 160px;
}
.company-info {
max-width: 110px;
}
.company-name {
max-width: 110px;
}
}
</style>

View File

@@ -0,0 +1,738 @@
<template>
<div class="detailed-data-section">
<div class="section-header">
<h2 class="section-title">Date Detaliate</h2>
<div class="section-controls">
<!-- Selector tip date -->
<select
v-model="selectedType"
@change="loadData"
class="data-type-select"
>
<option value="clients">Clienți</option>
<option value="suppliers">Furnizori</option>
<option value="treasury">Trezorerie</option>
</select>
<!-- Căutare -->
<div class="search-wrapper">
<input
v-model="searchTerm"
@input="handleSearch"
type="text"
placeholder="Căutare..."
class="search-input"
/>
<i class="pi pi-search"></i>
</div>
<!-- Export buttons -->
<button @click="exportExcel" class="btn btn-sm btn-outline">
<i class="pi pi-file-excel"></i> Excel
</button>
<button @click="exportPDF" class="btn btn-sm btn-outline">
<i class="pi pi-file-pdf"></i> PDF
</button>
</div>
</div>
<!-- Tabel cu date -->
<div class="table-container">
<table class="detailed-table">
<thead>
<tr>
<th v-for="column in displayColumns" :key="column.field">
{{ column.header }}
</th>
</tr>
</thead>
<tbody v-if="selectedType === 'treasury'">
<!-- Treasury - normal table without grouping -->
<tr v-for="row in paginatedData" :key="row.id">
<td v-for="column in displayColumns" :key="column.field">
{{ formatValue(row[column.field], column.type) }}
</td>
</tr>
</tbody>
<tbody v-else>
<!-- Clients/Suppliers - grouped with expand/collapse -->
<template v-for="group in paginatedGroups" :key="group.name">
<!-- Single invoice: show direct row -->
<tr
v-if="group.facturi.length === 1"
class="single-invoice-row"
:class="{ 'row-restant': group.hasRestant }"
>
<td>
<strong>{{ group.name }}</strong>
</td>
<td>{{ group.facturi[0].numar_document }}</td>
<td>{{ formatValue(group.facturi[0].data_document, "date") }}</td>
<td>{{ formatValue(group.facturi[0].data_scadenta, "date") }}</td>
<td>{{ formatValue(group.facturi[0].facturat, "currency") }}</td>
<td>
{{
formatValue(
group.facturi[0][
selectedType === "clients" ? "incasat" : "achitat"
],
"currency",
)
}}
</td>
<td
:class="{
'sold-restant': group.facturi[0].status === 'Restant',
}"
>
{{ formatValue(group.facturi[0].sold, "currency") }}
</td>
</tr>
<!-- Multiple invoices: show expand/collapse -->
<template v-else>
<!-- Group row (client/supplier header with subtotal) -->
<tr
class="group-row"
:class="{ 'has-restant': group.hasRestant }"
@click="toggleClient(group.name)"
>
<td class="group-name-cell">
<strong>{{ group.name }}</strong>
<span class="facturi-count"
>({{ group.facturi.length }})</span
>
</td>
<td colspan="5"></td>
<td
class="subtotal-cell"
:class="{ 'sold-restant': group.hasRestant }"
>
<strong>{{
formatValue(group.totalSold, "currency")
}}</strong>
</td>
</tr>
<!-- Detail rows (invoices) - only if expanded -->
<template v-if="isExpanded(group.name)">
<tr
v-for="(factura, idx) in group.facturi"
:key="`${group.name}-${idx}`"
class="detail-row"
:class="getRowClass(factura)"
>
<td class="detail-name">
{{ factura.client || factura.furnizor || "" }}
</td>
<td>{{ factura.numar_document }}</td>
<td>{{ formatValue(factura.data_document, "date") }}</td>
<td>{{ formatValue(factura.data_scadenta, "date") }}</td>
<td>
{{
formatValue(
factura[
selectedType === "clients" ? "facturat" : "facturat"
],
"currency",
)
}}
</td>
<td>
{{
formatValue(
factura[
selectedType === "clients" ? "incasat" : "achitat"
],
"currency",
)
}}
</td>
<td :class="{ 'sold-restant': factura.status === 'Restant' }">
{{ formatValue(factura.sold, "currency") }}
</td>
</tr>
</template>
</template>
</template>
</tbody>
<tfoot>
<tr class="totals-row">
<td><strong>TOTAL</strong></td>
<td v-for="column in displayColumns.slice(1)" :key="column.field">
<strong v-if="column.showTotal">
{{ formatValue(calculateTotal(column.field), column.type) }}
</strong>
</td>
</tr>
</tfoot>
</table>
</div>
<!-- Paginare -->
<div class="pagination-wrapper">
<Paginator
:rows="rowsPerPage"
:totalRecords="totalRecords"
v-model:first="firstRow"
:rowsPerPageOptions="[10, 25, 50, 100]"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from "vue";
import { useDashboardStore } from "@/stores/dashboard";
import { useCompanyStore } from "@/stores/companies";
import { useToast } from "primevue/usetoast";
import Paginator from "primevue/paginator";
import * as XLSX from "xlsx";
import jsPDF from "jspdf";
import "jspdf-autotable";
const dashboardStore = useDashboardStore();
const companyStore = useCompanyStore();
const toast = useToast();
// State
const selectedType = ref("clients");
const searchTerm = ref("");
const data = ref([]);
const firstRow = ref(0);
const rowsPerPage = ref(25);
const expandedClients = ref(new Set());
// Columns configuration based on type
const columns = computed(() => {
switch (selectedType.value) {
case "clients":
return [
{ field: "client", header: "Client", type: "text" },
{ field: "numar_document", header: "Nr. Document", type: "text" },
{ field: "data_document", header: "Data Document", type: "date" },
{ field: "data_scadenta", header: "Data Scadență", type: "date" },
{
field: "facturat",
header: "Facturat",
type: "currency",
showTotal: true,
},
{
field: "incasat",
header: "Încasat",
type: "currency",
showTotal: true,
},
{ field: "sold", header: "Sold", type: "currency", showTotal: true },
];
case "suppliers":
return [
{ field: "furnizor", header: "Furnizor", type: "text" },
{ field: "numar_document", header: "Nr. Document", type: "text" },
{ field: "data_document", header: "Data Document", type: "date" },
{ field: "data_scadenta", header: "Data Scadență", type: "date" },
{
field: "facturat",
header: "Facturat",
type: "currency",
showTotal: true,
},
{
field: "achitat",
header: "Achitat",
type: "currency",
showTotal: true,
},
{ field: "sold", header: "Sold", type: "currency", showTotal: true },
];
case "treasury":
return [
{ field: "cont", header: "Cont", type: "text" },
{ field: "nume_cont", header: "Nume Cont", type: "text" },
{ field: "sold", header: "Sold", type: "currency", showTotal: true },
{ field: "valuta", header: "Valută", type: "text" },
{ field: "tip", header: "Tip", type: "text" },
];
default:
return [];
}
});
// Display columns for header (without first column for grouped tables)
const displayColumns = computed(() => {
if (selectedType.value === "treasury") {
return columns.value;
}
// For clients/suppliers, keep all columns in header
return columns.value;
});
// Filtered data based on search
const filteredData = computed(() => {
if (!searchTerm.value) return data.value;
return data.value.filter((row) => {
return Object.values(row).some((val) =>
String(val).toLowerCase().includes(searchTerm.value.toLowerCase()),
);
});
});
// Group data by client/supplier
const groupedData = computed(() => {
if (selectedType.value === "treasury") {
return [];
}
const groups = {};
const nameField = selectedType.value === "clients" ? "client" : "furnizor";
filteredData.value.forEach((row) => {
const clientName = row[nameField];
if (!clientName) return;
if (!groups[clientName]) {
groups[clientName] = {
name: clientName,
facturi: [],
totalSold: 0,
hasRestant: false,
};
}
groups[clientName].facturi.push(row);
groups[clientName].totalSold += row.sold || 0;
if (row.status === "Restant") {
groups[clientName].hasRestant = true;
}
});
return Object.values(groups);
});
// Paginated groups
const paginatedGroups = computed(() => {
if (selectedType.value === "treasury") {
return [];
}
const start = firstRow.value;
const end = start + rowsPerPage.value;
return groupedData.value.slice(start, end);
});
// Paginated data (for treasury)
const paginatedData = computed(() => {
if (selectedType.value !== "treasury") {
return [];
}
const end = firstRow.value + rowsPerPage.value;
return filteredData.value.slice(firstRow.value, end);
});
// Total records for paginator
const totalRecords = computed(() => {
if (selectedType.value === "treasury") {
return filteredData.value.length;
}
return groupedData.value.length;
});
// Expand/collapse functions
const toggleClient = (clientName) => {
if (expandedClients.value.has(clientName)) {
expandedClients.value.delete(clientName);
} else {
expandedClients.value.add(clientName);
}
};
const isExpanded = (clientName) => {
return expandedClients.value.has(clientName);
};
const getRowClass = (row) => {
if (row.status === "Restant") return "row-restant";
return "row-in-termen";
};
// Methods
const loadData = async () => {
try {
if (!companyStore.selectedCompany) {
toast.add({
severity: "warn",
summary: "Atenție",
detail: "Vă rugăm să selectați o companie",
life: 3000,
});
return;
}
const response = await dashboardStore.loadDetailedData(
selectedType.value,
companyStore.selectedCompany.id_firma,
);
data.value = response.data;
// Reset expanded state when loading new data
expandedClients.value.clear();
} catch (error) {
toast.add({
severity: "error",
summary: "Eroare",
detail: "Nu s-au putut încărca datele detaliate",
});
}
};
const formatValue = (value, type) => {
switch (type) {
case "currency":
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
}).format(value || 0);
case "date":
if (!value) return "-";
// Handle Oracle date format (YYYY-MM-DD or Date object)
const date = new Date(value);
if (isNaN(date.getTime())) return value; // Return original if invalid
return date.toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
case "badge":
return value;
default:
return value;
}
};
const calculateTotal = (field) => {
return filteredData.value.reduce((sum, row) => sum + (row[field] || 0), 0);
};
const handleSearch = () => {
firstRow.value = 0; // Reset pagination on search
expandedClients.value.clear(); // Reset expanded state on search
};
const exportExcel = () => {
const ws = XLSX.utils.json_to_sheet(filteredData.value);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, selectedType.value);
XLSX.writeFile(wb, `${selectedType.value}_${new Date().toISOString()}.xlsx`);
};
const exportPDF = () => {
const doc = new jsPDF();
const tableColumns = columns.value.map((c) => c.header);
const tableRows = filteredData.value.map((row) =>
columns.value.map((c) => formatValue(row[c.field], c.type)),
);
doc.autoTable({
head: [tableColumns],
body: tableRows,
theme: "grid",
});
doc.save(`${selectedType.value}_${new Date().toISOString()}.pdf`);
};
onMounted(() => {
loadData();
});
watch(selectedType, () => {
loadData();
});
// Watch for company changes to reload data
watch(
() => companyStore.selectedCompany,
(newCompany) => {
if (newCompany) {
loadData();
}
},
);
</script>
<style scoped>
.detailed-data-section {
margin-top: 2rem;
background: var(--color-bg);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.section-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text-primary);
}
.section-controls {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.data-type-select {
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text-primary);
min-width: 120px;
}
.search-wrapper {
position: relative;
display: inline-block;
}
.search-input {
padding: 0.5rem 2.5rem 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text-primary);
width: 200px;
}
.search-wrapper i {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
}
.btn {
padding: 0.5rem 1rem;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8rem;
}
.btn-outline {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-primary);
}
.btn-outline:hover {
background: var(--color-bg-muted);
border-color: var(--color-primary);
}
.table-container {
overflow-x: auto;
margin: 1rem 0;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
}
.detailed-table {
width: 100%;
border-collapse: collapse;
min-width: 600px;
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
font-size: 12px;
line-height: 1.4;
}
.detailed-table th,
.detailed-table td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid var(--color-border);
font-weight: 400;
}
.detailed-table th {
background: #ffffff;
font-weight: 500;
color: var(--color-text-primary);
position: sticky;
top: 0;
font-size: 12px;
letter-spacing: 0.025em;
border-bottom: 2px solid var(--color-border);
}
/* Group row styling */
.group-row {
background: #ffffff;
cursor: pointer;
font-weight: 500;
border-top: 1px solid var(--color-border);
transition: background 0.15s ease;
}
.group-row:hover {
background: #f8f9fa;
}
.group-row.has-restant:hover {
background: #f8f9fa;
}
/* Single invoice row styling */
.single-invoice-row {
background: #ffffff;
font-weight: 400;
transition: background 0.15s ease;
}
.single-invoice-row:hover {
background: #f8f9fa;
}
.single-invoice-row.row-restant:hover {
background: #f8f9fa;
}
.single-invoice-row td:first-child {
font-weight: 500;
}
.group-name-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.facturi-count {
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 400;
margin-left: 4px;
}
.subtotal-cell {
text-align: right;
font-weight: 600;
color: var(--color-primary);
}
/* Detail row styling */
.detail-row {
font-size: 11px;
transition: background 0.15s ease;
}
.detail-name {
padding-left: 1.5rem;
color: var(--color-text-secondary);
font-size: 11px;
}
.detail-row.row-restant:hover {
background: #f8f9fa;
}
.detail-row.row-in-termen:hover {
background: #f8f9fa;
}
/* Sold restant - only color the amount text */
.sold-restant {
color: rgb(239, 68, 68);
font-weight: 600;
}
.detailed-table tbody tr:hover {
background: #f8f9fa;
}
.totals-row {
background: #f8f9fa !important;
border-top: 2px solid var(--color-border) !important;
font-weight: 500;
}
.pagination-wrapper {
margin-top: 1rem;
display: flex;
justify-content: center;
}
/* Responsive */
@media (max-width: 768px) {
.section-header {
flex-direction: column;
align-items: stretch;
}
.section-controls {
justify-content: space-between;
}
.search-input {
width: 150px;
}
.table-container {
margin: 1rem -1rem;
border-radius: 0;
border-left: none;
border-right: none;
}
.detailed-table th,
.detailed-table td {
padding: 0.5rem;
font-size: 0.875rem;
white-space: nowrap;
}
}
@media (max-width: 480px) {
.section-controls {
flex-direction: column;
gap: 0.5rem;
}
.section-controls > * {
width: 100%;
}
.search-input {
width: 100%;
}
.btn {
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,384 @@
<template>
<div class="period-selector-mini" ref="dropdownContainer">
<div class="period-dropdown" ref="dropdown">
<button
class="period-trigger"
@click="toggleDropdown"
:disabled="!companyStore.selectedCompany"
:aria-expanded="dropdownOpen"
aria-label="Select accounting period"
>
<div class="period-info">
<span class="period-label">Perioada:</span>
<span class="period-name">{{ selectedPeriodDisplay }}</span>
</div>
<i
class="pi pi-chevron-down"
:class="{ 'rotate-180': dropdownOpen }"
></i>
</button>
<div
v-show="dropdownOpen"
class="period-dropdown-panel"
:class="{ 'panel-open': dropdownOpen }"
>
<div class="period-list">
<div
v-for="(period, index) in periodStore.periods"
:key="`${period.an}-${period.luna}`"
class="period-item"
:class="{
active: isSelected(period),
'keyboard-highlighted': isHighlighted(index),
}"
@click="selectPeriod(period)"
@mouseenter="highlightedIndex = index"
>
<div class="period-details">
{{ period.display_name }}
</div>
<i v-if="isSelected(period)" class="pi pi-check period-selected-icon"></i>
</div>
</div>
<div v-if="periodStore.periods.length === 0" class="no-results">
<i class="pi pi-info-circle"></i>
<span>Nu sunt perioade disponibile</span>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { useAccountingPeriodStore } from "../../stores/accountingPeriod";
import { useCompanyStore } from "../../stores/companies";
export default {
name: "PeriodSelectorMini",
emits: ["period-changed"],
setup(props, { emit }) {
const periodStore = useAccountingPeriodStore();
const companyStore = useCompanyStore();
const dropdown = ref(null);
const dropdownContainer = ref(null);
const dropdownOpen = ref(false);
const highlightedIndex = ref(-1);
const selectedPeriodDisplay = computed(() => {
return periodStore.selectedPeriod?.display_name || "Selectare perioada";
});
const isSelected = (period) => {
if (!periodStore.selectedPeriod) return false;
return (
period.an === periodStore.selectedPeriod.an &&
period.luna === periodStore.selectedPeriod.luna
);
};
const isHighlighted = (index) => {
return index === highlightedIndex.value;
};
const toggleDropdown = async () => {
if (!companyStore.selectedCompany) return;
dropdownOpen.value = !dropdownOpen.value;
if (dropdownOpen.value) {
highlightedIndex.value = -1;
}
};
const closeDropdown = () => {
dropdownOpen.value = false;
};
const selectPeriod = (period) => {
periodStore.setSelectedPeriod(period);
emit("period-changed", period);
closeDropdown();
};
const scrollToHighlighted = () => {
nextTick(() => {
const highlightedElement = document.querySelector(
".period-item.keyboard-highlighted"
);
if (highlightedElement) {
highlightedElement.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
});
};
const handleKeyDown = (event) => {
if (!dropdownOpen.value || periodStore.periods.length === 0) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
highlightedIndex.value =
(highlightedIndex.value + 1) % periodStore.periods.length;
scrollToHighlighted();
break;
case "ArrowUp":
event.preventDefault();
if (highlightedIndex.value <= 0) {
highlightedIndex.value = periodStore.periods.length - 1;
} else {
highlightedIndex.value--;
}
scrollToHighlighted();
break;
case "Enter":
event.preventDefault();
if (
highlightedIndex.value >= 0 &&
highlightedIndex.value < periodStore.periods.length
) {
selectPeriod(periodStore.periods[highlightedIndex.value]);
}
break;
case "Escape":
closeDropdown();
break;
}
};
const handleClickOutside = (event) => {
if (dropdown.value && !dropdown.value.contains(event.target)) {
closeDropdown();
}
};
// Watch for company changes - load periods and reset to latest
watch(
() => companyStore.selectedCompany,
async (newCompany) => {
if (newCompany) {
await periodStore.loadPeriods(newCompany.id_firma);
} else {
periodStore.reset();
}
},
{ immediate: true }
);
onMounted(() => {
document.addEventListener("click", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
});
return {
periodStore,
companyStore,
dropdown,
dropdownContainer,
dropdownOpen,
highlightedIndex,
selectedPeriodDisplay,
isSelected,
isHighlighted,
toggleDropdown,
closeDropdown,
selectPeriod,
};
},
};
</script>
<style scoped>
.period-selector-mini {
position: relative;
max-width: 220px;
}
.period-dropdown {
position: relative;
}
.period-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
width: 100%;
text-align: left;
min-width: 200px;
}
.period-trigger:hover:not(:disabled) {
border-color: var(--color-primary);
background: var(--color-bg-secondary);
}
.period-trigger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.period-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.period-label {
font-size: var(--text-xs);
color: var(--color-text-secondary);
}
.period-name {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pi-chevron-down {
transition: transform var(--transition-fast);
color: var(--color-text-secondary);
font-size: var(--text-xs);
}
.rotate-180 {
transform: rotate(180deg);
}
.period-dropdown-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: var(--z-dropdown);
max-height: 300px;
overflow: hidden;
opacity: 0;
transform: translateY(-10px);
transition: all var(--transition-fast);
}
.panel-open {
opacity: 1;
transform: translateY(0);
}
.period-list {
max-height: 280px;
overflow-y: auto;
}
.period-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-md);
cursor: pointer;
transition: background-color var(--transition-fast);
border-bottom: 1px solid var(--color-border-light);
}
.period-item:last-child {
border-bottom: none;
}
.period-item:hover {
background: var(--color-bg-secondary);
}
.period-item.active {
background: var(--color-primary);
color: var(--color-text-inverse);
}
.period-item.keyboard-highlighted {
background: var(--color-bg-secondary);
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.period-item.active.keyboard-highlighted {
outline: 2px solid rgba(255, 255, 255, 0.5);
}
.period-details {
flex: 1;
font-size: var(--text-sm);
}
.period-selected-icon {
color: inherit;
font-size: var(--text-sm);
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-xl);
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.period-selector-mini {
max-width: 140px;
width: auto;
}
.period-trigger {
min-width: auto;
padding: var(--space-xs) var(--space-sm);
}
.period-info {
flex-direction: row;
align-items: center;
gap: var(--space-xs);
}
.period-label {
display: none;
}
.period-name {
font-size: var(--text-xs);
}
.period-dropdown-panel {
position: fixed;
left: 8px;
right: 8px;
top: 60px;
width: auto;
max-height: 70vh;
}
}
</style>

View File

@@ -0,0 +1,322 @@
<template>
<div class="trend-chart">
<canvas
ref="chartCanvas"
:width="width"
:height="height"
class="chart-canvas"
></canvas>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
import {
Chart,
CategoryScale,
LinearScale,
LineElement,
PointElement,
BarElement,
LineController,
BarController,
Title,
Tooltip,
Legend,
Filler,
} from "chart.js";
// Register Chart.js components
Chart.register(
CategoryScale,
LinearScale,
LineElement,
PointElement,
BarElement,
LineController,
BarController,
Title,
Tooltip,
Legend,
Filler,
);
// Props definition
const props = defineProps({
data: {
type: Object,
required: true,
default: () => ({
labels: [],
datasets: [],
}),
},
type: {
type: String,
default: "line",
validator: (value) => ["line", "bar", "area"].includes(value),
},
compare: {
type: Boolean,
default: false,
},
width: {
type: Number,
default: 400,
},
height: {
type: Number,
default: 200,
},
options: {
type: Object,
default: () => ({}),
},
});
// Refs
const chartCanvas = ref(null);
const chartInstance = ref(null);
// Romanian currency formatter
const formatCurrency = (value) => {
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
// Chart configuration
const getChartConfig = () => {
const chartType = props.type === "area" ? "line" : props.type;
const config = {
type: chartType,
data: {
labels: props.data.labels || [],
datasets: (props.data.datasets || []).map((dataset, index) => {
const baseConfig = {
...dataset,
borderWidth: props.type === "line" || props.type === "area" ? 2 : 0,
pointBackgroundColor: dataset.borderColor || dataset.backgroundColor,
pointBorderColor: dataset.borderColor || dataset.backgroundColor,
pointRadius: props.type === "line" || props.type === "area" ? 4 : 0,
pointHoverRadius:
props.type === "line" || props.type === "area" ? 6 : 0,
};
// Area chart specific configuration
if (props.type === "area") {
baseConfig.fill = true;
baseConfig.backgroundColor =
dataset.backgroundColor ||
(dataset.borderColor
? dataset.borderColor
.replace("rgb", "rgba")
.replace(")", ", 0.1)")
: "rgba(54, 162, 235, 0.1)");
}
// Bar chart specific configuration
if (props.type === "bar") {
baseConfig.borderRadius = 4;
baseConfig.borderSkipped = false;
}
return baseConfig;
}),
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: props.compare,
position: "top",
labels: {
usePointStyle: true,
padding: 20,
font: {
size: 12,
},
},
},
tooltip: {
mode: "index",
intersect: false,
callbacks: {
label: function (context) {
let label = context.dataset.label || "";
if (label) {
label += ": ";
}
if (context.parsed.y !== null) {
label += formatCurrency(context.parsed.y);
}
return label;
},
},
backgroundColor: "rgba(0, 0, 0, 0.8)",
titleColor: "#fff",
bodyColor: "#fff",
borderColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
},
},
scales: {
x: {
display: true,
grid: {
display: false,
},
ticks: {
font: {
size: 11,
},
color: "#6b7280",
},
},
y: {
display: true,
beginAtZero: true,
grid: {
color: "rgba(0, 0, 0, 0.05)",
},
ticks: {
font: {
size: 11,
},
color: "#6b7280",
callback: function (value) {
return formatCurrency(value);
},
},
},
},
interaction: {
mode: "index",
intersect: false,
},
hover: {
mode: "index",
intersect: false,
},
// Merge with custom options
...props.options,
},
};
return config;
};
// Create chart instance
const createChart = () => {
if (!chartCanvas.value) return;
const config = getChartConfig();
// Deep clone the entire config to break Vue reactivity circular references
const clonedConfig = JSON.parse(JSON.stringify(config));
chartInstance.value = new Chart(chartCanvas.value, clonedConfig);
};
// Destroy chart instance
const destroyChart = () => {
if (chartInstance.value) {
chartInstance.value.destroy();
chartInstance.value = null;
}
};
// Update chart data
const updateChart = () => {
if (!chartInstance.value) return;
const config = getChartConfig();
// Deep clone the data to break Vue reactivity circular references
const clonedData = JSON.parse(JSON.stringify(config.data));
// Update data
chartInstance.value.data = clonedData;
// Update options (clone options too to be safe)
chartInstance.value.options = JSON.parse(JSON.stringify(config.options));
// Re-render
chartInstance.value.update("none");
};
// Recreate chart completely
const recreateChart = async () => {
destroyChart();
await nextTick();
createChart();
};
// Watch for prop changes
watch(
() => [props.data, props.type, props.compare, props.options],
async (newValues, oldValues) => {
// Skip if chart is not initialized
if (!chartInstance.value) return;
// If chart type changed, recreate completely
if (newValues[1] !== oldValues[1]) {
await recreateChart();
} else {
// Otherwise just update
updateChart();
}
},
{ deep: true },
);
// Lifecycle hooks
onMounted(() => {
nextTick(() => {
createChart();
});
});
onBeforeUnmount(() => {
destroyChart();
});
// Expose methods for parent components
defineExpose({
updateChart,
recreateChart,
chartInstance: () => chartInstance.value,
});
</script>
<style scoped>
.trend-chart {
position: relative;
width: 100%;
height: 100%;
min-height: 200px;
}
.chart-canvas {
width: 100% !important;
height: 100% !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.trend-chart {
min-height: 150px;
}
}
@media (max-width: 480px) {
.trend-chart {
min-height: 120px;
}
}
</style>

View File

@@ -0,0 +1,769 @@
<template>
<div class="cashflow-card">
<!-- Card Header -->
<div class="card-header">
<div class="header-content">
<h3 class="card-title">📅 Cash Flow Previzionat</h3>
<div class="period-selector">
<select
v-model="selectedPeriod"
@change="handlePeriodChange"
class="period-select"
>
<option value="7d">Următoarele 7 zile</option>
<option value="1m">Următoarea lună</option>
<option value="3m">Următoarele 3 luni</option>
<option value="6m">Următoarele 6 luni</option>
</select>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="loading-state">
<div class="loading-spinner"></div>
<p>Se încarcă previziunea cash flow...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="error-state">
<div class="error-icon"></div>
<p>{{ error }}</p>
<button @click="loadCashFlowData" class="retry-btn">
Încearcă din nou
</button>
</div>
<!-- Cash Flow Content -->
<div v-else class="cashflow-content">
<!-- Chart Container -->
<div
class="cashflow-bars"
v-if="chartData && chartData.periods.length > 0"
>
<div class="chart-header">
<div class="chart-legend">
<div class="legend-item">
<span class="legend-color inflow"></span>
<span class="legend-label">Încasări</span>
</div>
<div class="legend-item">
<span class="legend-color outflow"></span>
<span class="legend-label">Plăți</span>
</div>
</div>
</div>
<!-- Chart.js Canvas -->
<div class="chart-canvas-container">
<canvas
ref="cashflowChart"
v-if="chartData?.periods?.length"
width="400"
height="200"
></canvas>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state">
<div class="empty-icon">📊</div>
<p>Nu există date de cash flow pentru perioada selectată</p>
</div>
<!-- Cash Flow Summary -->
<div v-if="chartData" class="cashflow-summary">
<div class="summary-row">
<div
class="summary-item net-flow"
:class="getNetFlowClass(chartData.netTotal)"
>
<span class="summary-label">Net Total:</span>
<span class="summary-value">{{
formatCurrency(chartData.netTotal)
}}</span>
</div>
</div>
<!-- Critical Days Warnings -->
<div
v-if="chartData.criticalDays && chartData.criticalDays.length > 0"
class="warnings"
>
<div class="warning-header">
<span class="warning-icon"></span>
<span class="warning-title">Zile Critice</span>
</div>
<div class="critical-days">
<span
v-for="day in chartData.criticalDays"
:key="day"
class="critical-day"
>
{{ day }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { Chart, registerables } from "chart.js";
import { useDashboardStore } from "@reports/stores/dashboard";
// Register Chart.js components
Chart.register(...registerables);
// Props
const props = defineProps({
companyId: {
type: Number,
required: true,
},
});
// Emits
const emit = defineEmits(["periodChanged"]);
// Store
const dashboardStore = useDashboardStore();
// State
const selectedPeriod = ref("7d");
const isLoading = ref(false);
const error = ref(null);
const chartData = ref(null);
const cashflowChart = ref(null);
const chartInstance = ref(null);
// Computed
const maxValue = computed(() => {
if (!chartData.value) return 1;
const allValues = [
...chartData.value.inflows,
...chartData.value.outflows.map(Math.abs),
].filter((v) => v > 0);
return Math.max(...allValues, 1);
});
// Methods
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0,00 RON";
const numAmount = typeof amount === "string" ? parseFloat(amount) : amount;
if (isNaN(numAmount)) return "0,00 RON";
try {
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(numAmount);
} catch (error) {
return `${numAmount.toLocaleString("ro-RO", { minimumFractionDigits: 0, maximumFractionDigits: 0 })} RON`;
}
};
const formatCurrencyShort = (amount) => {
if (!amount && amount !== 0) return "0";
const numAmount = typeof amount === "string" ? parseFloat(amount) : amount;
if (isNaN(numAmount)) return "0";
const absAmount = Math.abs(numAmount);
if (absAmount >= 1000000) {
return `${(numAmount / 1000000).toFixed(1)}M`;
} else if (absAmount >= 1000) {
return `${(numAmount / 1000).toFixed(0)}k`;
}
return numAmount.toLocaleString("ro-RO", {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
};
const initializeChart = async () => {
if (!cashflowChart.value || !chartData.value) return;
// Destroy existing chart instance
if (chartInstance.value) {
chartInstance.value.destroy();
chartInstance.value = null;
}
await nextTick();
const ctx = cashflowChart.value.getContext("2d");
chartInstance.value = new Chart(ctx, {
type: "line",
data: {
labels: chartData.value.periods,
datasets: [
{
label: "Încasări",
data: chartData.value.inflows,
borderColor: "rgb(34, 197, 94)",
backgroundColor: "rgba(34, 197, 94, 0.1)",
borderWidth: 2,
fill: false,
tension: 0.4,
pointBackgroundColor: "rgb(34, 197, 94)",
pointBorderColor: "#ffffff",
pointBorderWidth: 2,
pointRadius: 5,
pointHoverRadius: 7,
},
{
label: "Plăți",
data: chartData.value.outflows.map(Math.abs),
borderColor: "rgb(239, 68, 68)",
backgroundColor: "rgba(239, 68, 68, 0.1)",
borderWidth: 2,
fill: false,
tension: 0.4,
pointBackgroundColor: "rgb(239, 68, 68)",
pointBorderColor: "#ffffff",
pointBorderWidth: 2,
pointRadius: 5,
pointHoverRadius: 7,
},
{
label: "Net Flow",
data: chartData.value.netFlow,
borderColor: "rgb(99, 102, 241)",
backgroundColor: "rgba(99, 102, 241, 0.1)",
borderWidth: 2,
fill: false,
tension: 0.4,
pointBackgroundColor: "rgb(99, 102, 241)",
pointBorderColor: "#ffffff",
pointBorderWidth: 2,
pointRadius: 5,
pointHoverRadius: 7,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index",
intersect: false,
},
plugins: {
legend: {
position: "top",
labels: {
usePointStyle: true,
padding: 20,
font: {
size: 12,
weight: "500",
},
},
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
titleColor: "#ffffff",
bodyColor: "#ffffff",
borderColor: "rgba(255, 255, 255, 0.2)",
borderWidth: 1,
cornerRadius: 8,
displayColors: true,
callbacks: {
label: function (context) {
const label = context.dataset.label;
const value = context.parsed.y;
return `${label}: ${formatCurrency(value)}`;
},
},
},
},
scales: {
x: {
display: true,
grid: {
display: false,
},
ticks: {
font: {
size: 11,
},
color: "rgba(107, 114, 128, 0.8)",
},
},
y: {
display: true,
grid: {
color: "rgba(107, 114, 128, 0.1)",
drawBorder: false,
},
ticks: {
font: {
size: 11,
},
color: "rgba(107, 114, 128, 0.8)",
callback: function (value) {
return formatCurrencyShort(value);
},
},
},
},
elements: {
line: {
borderJoinStyle: "round",
},
point: {
hoverBorderWidth: 3,
},
},
},
});
};
const getNetFlowClass = (amount) => {
if (!amount && amount !== 0) return "neutral";
const numAmount = typeof amount === "string" ? parseFloat(amount) : amount;
return numAmount > 0 ? "positive" : numAmount < 0 ? "negative" : "neutral";
};
const handlePeriodChange = () => {
emit("periodChanged", selectedPeriod.value);
loadCashFlowData();
};
const loadCashFlowData = async () => {
if (!props.companyId) return;
isLoading.value = true;
error.value = null;
try {
const result = await dashboardStore.loadCashFlowData(
props.companyId,
selectedPeriod.value,
);
if (result.success) {
chartData.value = result.data;
await nextTick();
initializeChart();
} else {
error.value = result.error || "Nu s-au putut încărca datele";
// Fallback to mock data for development
chartData.value = generateMockData();
await nextTick();
initializeChart();
}
} catch (err) {
console.error("Error loading cash flow data:", err);
error.value = "Eroare la încărcarea datelor";
// Fallback to mock data for development
chartData.value = generateMockData();
await nextTick();
initializeChart();
} finally {
isLoading.value = false;
}
};
const generateMockData = () => {
const periods = {
"7d": ["Luni", "Marți", "Miercuri", "Joi", "Vineri", "Sâmbătă", "Duminică"],
"1m": ["S1", "S2", "S3", "S4"],
"3m": ["Luna 1", "Luna 2", "Luna 3"],
"6m": ["Trim 1", "Trim 2"],
};
const periodLabels = periods[selectedPeriod.value] || periods["7d"];
const inflows = periodLabels.map(() => Math.random() * 500000 + 100000);
const outflows = periodLabels.map(() => -(Math.random() * 400000 + 50000));
const netFlow = inflows.map((inflow, i) => inflow + outflows[i]);
const cumulative = netFlow.reduce((acc, val, i) => {
acc.push((acc[i - 1] || 0) + val);
return acc;
}, []);
const criticalDays = netFlow
.map((net, i) => (net < -50000 ? periodLabels[i] : null))
.filter(Boolean);
return {
periods: periodLabels,
inflows,
outflows,
netFlow,
cumulative,
criticalDays,
netTotal: netFlow.reduce((sum, val) => sum + val, 0),
};
};
// Watchers
watch(
() => props.companyId,
(newId) => {
if (newId) {
loadCashFlowData();
}
},
{ immediate: true },
);
watch(
chartData,
(newData) => {
if (newData) {
nextTick(() => {
initializeChart();
});
}
},
{ deep: true },
);
// Lifecycle
onMounted(() => {
if (props.companyId) {
loadCashFlowData();
}
});
onUnmounted(() => {
if (chartInstance.value) {
chartInstance.value.destroy();
chartInstance.value = null;
}
});
</script>
<style scoped>
.cashflow-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
overflow: hidden;
transition: all var(--transition-fast);
min-height: 400px;
display: flex;
flex-direction: column;
}
.cashflow-card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
/* Card Header */
.card-header {
padding: var(--space-lg) var(--space-xl);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-md);
}
.card-title {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--color-text);
margin: 0;
}
.period-selector {
display: flex;
align-items: center;
}
.period-select {
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
transition: all var(--transition-fast);
}
.period-select:hover {
border-color: var(--color-primary);
}
.period-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.2);
}
/* Loading and Error States */
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl);
color: var(--color-text-secondary);
text-align: center;
flex: 1;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: var(--space-md);
}
.error-icon,
.empty-icon {
font-size: 2rem;
margin-bottom: var(--space-md);
}
.retry-btn {
background: var(--color-primary);
color: white;
border: none;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--text-sm);
transition: background-color var(--transition-fast);
margin-top: var(--space-md);
}
.retry-btn:hover {
background: var(--color-primary-dark);
}
/* Cash Flow Content */
.cashflow-content {
flex: 1;
display: flex;
flex-direction: column;
}
/* Chart */
.cashflow-bars {
padding: var(--space-lg) var(--space-xl);
flex: 1;
}
.chart-header {
display: flex;
justify-content: center;
margin-bottom: var(--space-lg);
}
.chart-legend {
display: flex;
gap: var(--space-lg);
}
.legend-item {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.legend-color {
width: 16px;
height: 16px;
border-radius: var(--radius-sm);
}
.legend-color.inflow {
background: var(--color-success);
}
.legend-color.outflow {
background: var(--color-error);
}
.legend-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
}
/* Chart.js Container */
.chart-canvas-container {
position: relative;
height: 250px;
padding: var(--space-md) 0;
}
.chart-canvas-container canvas {
max-width: 100%;
height: 100% !important;
}
/* Cash Flow Summary */
.cashflow-summary {
padding: var(--space-lg) var(--space-xl);
background: var(--color-bg-secondary);
border-top: 1px solid var(--color-border);
}
.summary-row {
display: flex;
justify-content: center;
margin-bottom: var(--space-md);
}
.summary-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
}
.summary-item.positive {
background: var(--color-success-bg);
color: var(--color-success);
}
.summary-item.negative {
background: var(--color-error-bg);
color: var(--color-error);
}
.summary-item.neutral {
background: var(--color-bg-muted);
color: var(--color-text);
}
.summary-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.summary-value {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
font-family: var(--font-mono);
}
/* Warnings */
.warnings {
margin-top: var(--space-md);
}
.warning-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.warning-icon {
font-size: var(--text-lg);
}
.warning-title {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--color-warning);
}
.critical-days {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.critical-day {
background: var(--color-warning-bg);
color: var(--color-warning);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: var(--font-medium);
}
/* Animations */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Responsive Design */
@media (max-width: 1024px) {
.header-content {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
}
.chart-legend {
justify-content: center;
flex-wrap: wrap;
}
.chart-canvas-container {
height: 200px;
}
}
@media (max-width: 768px) {
.cashflow-card {
min-height: 350px;
}
.card-header,
.cashflow-bars,
.cashflow-summary {
padding: var(--space-md);
}
.chart-canvas-container {
height: 180px;
}
.summary-item {
flex-direction: column;
text-align: center;
gap: var(--space-xs);
}
.summary-value {
font-size: var(--text-base);
}
}
@media (max-width: 480px) {
.chart-legend {
flex-direction: column;
align-items: center;
gap: var(--space-sm);
}
.chart-canvas-container {
height: 160px;
}
.critical-days {
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,659 @@
<template>
<div class="metric-card cashflow-card">
<!-- Main values section - Split layout (Încasări | Plăți) -->
<div class="values-section">
<!-- Încasări Section -->
<div class="value-block inflows">
<div class="metric-label">Încasări</div>
<div class="metric-value text-success">
{{ formatCurrency(inflowsValue) }}
</div>
</div>
<!-- Divider -->
<div class="divider"></div>
<!-- Plăți Section -->
<div class="value-block outflows">
<div class="metric-label">Plăți</div>
<div class="metric-value text-error">
{{ formatCurrency(outflowsValue) }}
</div>
</div>
</div>
<!-- Dual sparkline charts - stacked vertical -->
<div class="sparkline-dual-container" v-if="hasSparklineData">
<!-- Grafic Încasări -->
<div class="sparkline-wrapper">
<div class="sparkline-title text-success">Încasări</div>
<div class="sparkline-chart">
<canvas ref="inflowsCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
<!-- Grafic Plăți -->
<div class="sparkline-wrapper">
<div class="sparkline-title text-error">Plăți</div>
<div class="sparkline-chart">
<canvas ref="outflowsCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
ref,
computed,
watch,
onMounted,
onBeforeUnmount,
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
Chart.register(...registerables);
const props = defineProps({
inflowsValue: {
type: Number,
default: 0,
},
outflowsValue: {
type: Number,
default: 0,
},
inflowsTrend: {
type: Object,
default: null,
},
outflowsTrend: {
type: Object,
default: null,
},
inflowsSparkline: {
type: Array,
default: () => [],
},
outflowsSparkline: {
type: Array,
default: () => [],
},
inflowsPreviousSparkline: {
type: Array,
default: () => [],
},
outflowsPreviousSparkline: {
type: Array,
default: () => [],
},
sparklineLabels: {
type: Array,
default: () => [],
},
previousSparklineLabels: {
type: Array,
default: () => [],
},
});
// Refs pentru 2 canvas-uri separate
const inflowsCanvas = ref(null);
const outflowsCanvas = ref(null);
let inflowsChartInstance = null;
let outflowsChartInstance = null;
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(Math.abs(amount));
};
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return (
props.inflowsSparkline.length > 0 && props.outflowsSparkline.length > 0
);
});
// Initialize Încasări chart
const initializeInflowsChart = async () => {
if (!inflowsCanvas.value || props.inflowsSparkline.length === 0) {
return;
}
// Destroy existing chart
if (inflowsChartInstance) {
inflowsChartInstance.destroy();
inflowsChartInstance = null;
}
await nextTick();
const ctx = inflowsCanvas.value.getContext("2d");
// Generate labels
const labels =
props.sparklineLabels.length > 0
? props.sparklineLabels
: props.inflowsSparkline.map((_, i) => `L${i + 1}`);
// Prepare datasets
const datasets = [
{
label: "Încasări (curent)",
data: props.inflowsSparkline,
borderColor: "#10b981",
backgroundColor: "rgba(16, 185, 129, 0.1)",
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "#10b981",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
},
];
// Add previous year dataset if available
if (
props.inflowsPreviousSparkline &&
props.inflowsPreviousSparkline.length > 0
) {
datasets.push({
label: "Încasări (anul precedent)",
data: props.inflowsPreviousSparkline,
borderColor: "rgba(16, 185, 129, 0.4)",
backgroundColor: "rgba(16, 185, 129, 0.05)",
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "rgba(16, 185, 129, 0.4)",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
});
}
// Calculate limits including both datasets
const allDataPoints = [...props.inflowsSparkline];
if (
props.inflowsPreviousSparkline &&
props.inflowsPreviousSparkline.length > 0
) {
allDataPoints.push(...props.inflowsPreviousSparkline);
}
const dataMin = Math.min(...allDataPoints);
const dataMax = Math.max(...allDataPoints);
const dataRange = dataMax - dataMin;
const dataMean =
allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length;
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
const minVisibleRange = dataMean * 0.25; // 25% din medie = range minim vizibil
const center = (dataMin + dataMax) / 2;
const targetRange = Math.max(dataRange, minVisibleRange);
// Calculează limite simetric față de centru
let calculatedMin = center - targetRange / 2;
let calculatedMax = center + targetRange / 2;
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
const paddingAmount = targetRange * 0.1; // 10% padding suplimentar
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
const allPositive = dataMin >= 0;
const yMin = allPositive
? Math.max(0, calculatedMin - paddingAmount)
: calculatedMin - paddingAmount;
const yMax = calculatedMax + paddingAmount;
inflowsChartInstance = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
legend: {
display: datasets.length > 1,
position: "top",
align: "end",
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
color: "rgba(107, 114, 128, 0.9)",
usePointStyle: true,
pointStyle: "line",
},
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
titleColor: "#ffffff",
bodyColor: "#ffffff",
borderColor: "rgba(255, 255, 255, 0.2)",
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || "",
label: (context) => {
const value = context.parsed.y;
const label = context.dataset.label || "";
const formattedValue = new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
return `${label}: ${formattedValue}`;
},
},
},
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false,
},
ticks: {
color: "rgba(107, 114, 128, 0.7)",
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6,
},
border: {
display: false,
},
},
y: {
display: true,
min: yMin,
max: yMax,
grid: {
color: "rgba(107, 114, 128, 0.1)",
drawBorder: false,
},
ticks: {
color: "#10b981",
font: {
size: 11,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxTicksLimit: 3,
callback: function (value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + "M";
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + "k";
}
return value.toFixed(0);
},
},
border: {
display: false,
},
},
},
},
});
};
// Initialize Plăți chart
const initializeOutflowsChart = async () => {
if (!outflowsCanvas.value || props.outflowsSparkline.length === 0) {
return;
}
// Destroy existing chart
if (outflowsChartInstance) {
outflowsChartInstance.destroy();
outflowsChartInstance = null;
}
await nextTick();
const ctx = outflowsCanvas.value.getContext("2d");
// Generate labels
const labels =
props.sparklineLabels.length > 0
? props.sparklineLabels
: props.outflowsSparkline.map((_, i) => `L${i + 1}`);
// Prepare datasets
const datasets = [
{
label: "Plăți (curent)",
data: props.outflowsSparkline,
borderColor: "#ef4444",
backgroundColor: "rgba(239, 68, 68, 0.1)",
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "#ef4444",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
},
];
// Add previous year dataset if available
if (
props.outflowsPreviousSparkline &&
props.outflowsPreviousSparkline.length > 0
) {
datasets.push({
label: "Plăți (anul precedent)",
data: props.outflowsPreviousSparkline,
borderColor: "rgba(239, 68, 68, 0.4)",
backgroundColor: "rgba(239, 68, 68, 0.05)",
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "rgba(239, 68, 68, 0.4)",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
});
}
// Calculate limits including both datasets
const allDataPoints = [...props.outflowsSparkline];
if (
props.outflowsPreviousSparkline &&
props.outflowsPreviousSparkline.length > 0
) {
allDataPoints.push(...props.outflowsPreviousSparkline);
}
const dataMin = Math.min(...allDataPoints);
const dataMax = Math.max(...allDataPoints);
const dataRange = dataMax - dataMin;
const dataMean =
allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length;
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
const minVisibleRange = dataMean * 0.25; // 25% din medie = range minim vizibil
const center = (dataMin + dataMax) / 2;
const targetRange = Math.max(dataRange, minVisibleRange);
// Calculează limite simetric față de centru
let calculatedMin = center - targetRange / 2;
let calculatedMax = center + targetRange / 2;
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
const paddingAmount = targetRange * 0.1; // 10% padding suplimentar
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
const allPositive = dataMin >= 0;
const yMin = allPositive
? Math.max(0, calculatedMin - paddingAmount)
: calculatedMin - paddingAmount;
const yMax = calculatedMax + paddingAmount;
outflowsChartInstance = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
legend: {
display: datasets.length > 1,
position: "top",
align: "end",
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
color: "rgba(107, 114, 128, 0.9)",
usePointStyle: true,
pointStyle: "line",
},
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
titleColor: "#ffffff",
bodyColor: "#ffffff",
borderColor: "rgba(255, 255, 255, 0.2)",
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || "",
label: (context) => {
const value = context.parsed.y;
const label = context.dataset.label || "";
const formattedValue = new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
return `${label}: ${formattedValue}`;
},
},
},
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false,
},
ticks: {
color: "rgba(107, 114, 128, 0.7)",
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6,
},
border: {
display: false,
},
},
y: {
display: true,
min: yMin,
max: yMax,
grid: {
color: "rgba(107, 114, 128, 0.1)",
drawBorder: false,
},
ticks: {
color: "#ef4444",
font: {
size: 11,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxTicksLimit: 3,
callback: function (value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + "M";
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + "k";
}
return value.toFixed(0);
},
},
border: {
display: false,
},
},
},
},
});
};
// Watch for data changes
watch(
() => [
props.inflowsSparkline,
props.outflowsSparkline,
props.sparklineLabels,
props.inflowsPreviousSparkline,
props.outflowsPreviousSparkline,
props.previousSparklineLabels,
],
async () => {
await Promise.all([initializeInflowsChart(), initializeOutflowsChart()]);
},
{ deep: true },
);
// Lifecycle hooks
onMounted(async () => {
await Promise.all([initializeInflowsChart(), initializeOutflowsChart()]);
});
onBeforeUnmount(() => {
if (inflowsChartInstance) {
inflowsChartInstance.destroy();
inflowsChartInstance = null;
}
if (outflowsChartInstance) {
outflowsChartInstance.destroy();
outflowsChartInstance = null;
}
});
</script>
<style scoped>
/* Component-specific: Dual-chart layout for CashFlowMetricCard */
/* Override min-height for dual chart layout */
.cashflow-card {
min-height: 420px;
}
/* Split layout: Încasări | Divider | Plăți */
.values-section {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 1rem;
align-items: start;
}
.value-block {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.divider {
width: 1px;
height: 100%;
background: var(--color-border);
min-height: 60px;
}
/* Dual sparkline container (unique to this card) */
.sparkline-dual-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0.5rem 0;
}
.sparkline-wrapper {
width: 100%;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.5rem;
}
.sparkline-chart {
width: 100%;
height: 150px;
position: relative;
}
/* Chart.js canvas sizing (required for proper rendering) */
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Responsive: Stack vertically on mobile */
@media (max-width: 768px) {
.cashflow-card {
min-height: 380px;
}
.values-section {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.divider {
width: 100%;
height: 1px;
min-height: 1px;
}
.sparkline-chart {
height: 130px;
}
}
@media (max-width: 480px) {
.sparkline-chart {
height: 150px;
}
.sparkline-wrapper {
padding: 0.25rem;
}
}
</style>

View File

@@ -0,0 +1,466 @@
<template>
<div class="metric-card clienti-balance-card">
<!-- Main value section -->
<div class="value-section">
<div class="metric-label">Clienți</div>
<div class="metric-value" :class="getBalanceClass(total)">
{{ formatCurrency(total) }}
</div>
</div>
<div
class="value-trend trend-indicator"
:class="getTrendClass(trend)"
v-if="trend"
>
<span class="trend-icon">{{ getTrendIcon(trend) }}</span>
<span class="trend-value">{{ Math.round(Math.abs(trend.value)) }}%</span>
</div>
<!-- Sparkline chart -->
<div class="metric-sparkline" v-if="hasSparklineData">
<div class="sparkline-chart">
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
<!-- Breakdown section -->
<div class="breakdown-section" v-if="breakdown">
<!-- În termen -->
<div class="breakdown-item">
<span class="breakdown-label">În termen</span>
<span class="breakdown-value">{{
formatCurrency(breakdown.in_termen?.total || 0)
}}</span>
</div>
<!-- Restant cu sub-perioade -->
<div class="breakdown-group">
<div class="breakdown-header" @click="toggleRestantExpanded">
<div class="breakdown-header-left">
<i
class="pi pi-chevron-right breakdown-toggle"
:class="{ expanded: isRestantExpanded }"
></i>
<span class="breakdown-label">Restant</span>
</div>
<span class="breakdown-value">{{
formatCurrency(breakdown.restant?.total || 0)
}}</span>
</div>
<!-- Perioade restante -->
<div v-show="isRestantExpanded" class="breakdown-subitems slide-down">
<div
class="breakdown-subitem"
v-for="(value, key) in breakdown.restant?.perioade"
:key="key"
>
<span class="breakdown-sublabel">{{ formatPeriodLabel(key) }}</span>
<span class="breakdown-subvalue">{{ formatCurrency(value) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
ref,
computed,
watch,
onMounted,
onBeforeUnmount,
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
Chart.register(...registerables);
const props = defineProps({
total: {
type: Number,
required: true,
},
trend: {
type: Object,
default: null,
},
sparklineData: {
type: Array,
default: () => [],
},
previousSparklineData: {
type: Array,
default: () => [],
},
sparklineLabels: {
type: Array,
default: () => [],
},
previousSparklineLabels: {
type: Array,
default: () => [],
},
breakdown: {
type: Object,
default: null,
},
});
// Refs
const chartCanvas = ref(null);
let chartInstance = null;
const isRestantExpanded = ref(false);
// Toggle functions
const toggleRestantExpanded = () => {
isRestantExpanded.value = !isRestantExpanded.value;
};
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(Math.abs(amount));
};
// Format period label
const formatPeriodLabel = (key) => {
const labelMap = {
"7_zile": "7 zile",
"14_zile": "14 zile",
"30_zile": "30 zile",
"60_zile": "60 zile",
"90_zile": "90 zile",
peste_90_zile: "Peste 90 zile",
};
return labelMap[key] || key;
};
// Balance class
const getBalanceClass = (amount) => {
if (!amount && amount !== 0) return "neutral";
const numAmount = typeof amount === "string" ? parseFloat(amount) : amount;
return numAmount > 0 ? "positive" : numAmount < 0 ? "negative" : "neutral";
};
// Trend class
const getTrendClass = (trend) => {
if (!trend) return "";
return {
"trend-up": trend.direction === "up",
"trend-down": trend.direction === "down",
"trend-neutral": trend.direction === "neutral",
};
};
// Trend icon
const getTrendIcon = (trend) => {
if (!trend) return "";
switch (trend.direction) {
case "up":
return "▲";
case "down":
return "▼";
case "neutral":
return "▶";
default:
return "";
}
};
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return props.sparklineData && props.sparklineData.length > 0;
});
// Initialize chart
const initializeChart = async () => {
if (!chartCanvas.value || !hasSparklineData.value) {
return;
}
// Destroy existing chart
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
await nextTick();
const ctx = chartCanvas.value.getContext("2d");
// Generate labels
const labels =
props.sparklineLabels.length > 0
? props.sparklineLabels
: props.sparklineData.map((_, i) => `L${i + 1}`);
// Calculate limits including both datasets
const allDataPoints = [...props.sparklineData];
if (props.previousSparklineData && props.previousSparklineData.length > 0) {
allDataPoints.push(...props.previousSparklineData);
}
const dataMin = Math.min(...allDataPoints);
const dataMax = Math.max(...allDataPoints);
const dataRange = dataMax - dataMin;
const dataMean =
allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length;
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
const minVisibleRange = dataMean * 0.25; // 25% din medie = range minim vizibil
const center = (dataMin + dataMax) / 2;
const targetRange = Math.max(dataRange, minVisibleRange);
// Calculează limite simetric față de centru
let calculatedMin = center - targetRange / 2;
let calculatedMax = center + targetRange / 2;
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
const paddingAmount = targetRange * 0.1; // 10% padding suplimentar
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
const allPositive = dataMin >= 0;
const yMin = allPositive
? Math.max(0, calculatedMin - paddingAmount)
: calculatedMin - paddingAmount;
const yMax = calculatedMax + paddingAmount;
// Prepare datasets
const datasets = [
{
label: "Clienți (curent)",
data: props.sparklineData,
borderColor: "#10b981",
backgroundColor: "rgba(16, 185, 129, 0.1)",
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "#10b981",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
},
];
// Add previous year dataset if available
if (props.previousSparklineData && props.previousSparklineData.length > 0) {
datasets.push({
label: "Clienți (anul precedent)",
data: props.previousSparklineData,
borderColor: "rgba(16, 185, 129, 0.4)",
backgroundColor: "rgba(16, 185, 129, 0.05)",
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "rgba(16, 185, 129, 0.6)",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
});
}
chartInstance = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
legend: {
display: true,
position: "top",
align: "end",
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
color: "rgba(107, 114, 128, 0.8)",
usePointStyle: true,
},
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
titleColor: "#ffffff",
bodyColor: "#ffffff",
borderColor: "rgba(255, 255, 255, 0.2)",
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || "",
label: (context) => {
const value = context.parsed.y;
const label = context.dataset.label || "";
const formattedValue = new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
return `${label}: ${formattedValue}`;
},
},
},
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false,
},
ticks: {
color: "rgba(107, 114, 128, 0.7)",
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6,
},
border: {
display: false,
},
},
y: {
display: true,
min: yMin,
max: yMax,
grid: {
color: "rgba(107, 114, 128, 0.1)",
drawBorder: false,
},
ticks: {
color: "#10b981",
font: {
size: 11,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxTicksLimit: 3,
callback: function (value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + "M";
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + "k";
}
return value.toFixed(0);
},
},
border: {
display: false,
},
},
},
},
});
};
// Watch for data changes
watch(
() => [
props.sparklineData,
props.previousSparklineData,
props.sparklineLabels,
props.previousSparklineLabels,
],
async () => {
await initializeChart();
},
{ deep: true },
);
// Lifecycle hooks
onMounted(async () => {
await initializeChart();
});
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
});
</script>
<style scoped>
/* Component-specific: ClientiBalanceCard layout and breakdown */
/* Override min-height for balance card */
.clienti-balance-card {
min-height: 320px;
}
/* Value section: horizontal layout */
.value-section {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
/* Color classes for positive/negative/neutral (component-specific logic) */
.positive {
color: var(--color-success);
}
.negative {
color: var(--color-error);
}
.neutral {
color: var(--color-text);
}
/* Sparkline chart dimensions */
.sparkline-chart {
width: 100%;
height: 150px;
position: relative;
}
/* Chart.js canvas sizing (required for proper rendering) */
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Responsive */
@media (max-width: 768px) {
.clienti-balance-card {
min-height: 280px;
}
.sparkline-chart {
height: 130px;
}
}
@media (max-width: 480px) {
.sparkline-chart {
height: 150px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,466 @@
<template>
<div class="metric-card furnizori-balance-card">
<!-- Main value section -->
<div class="value-section">
<div class="metric-label">Furnizori</div>
<div class="metric-value" :class="getBalanceClass(total)">
{{ formatCurrency(total) }}
</div>
</div>
<div
class="value-trend trend-indicator"
:class="getTrendClass(trend)"
v-if="trend"
>
<span class="trend-icon">{{ getTrendIcon(trend) }}</span>
<span class="trend-value">{{ Math.round(Math.abs(trend.value)) }}%</span>
</div>
<!-- Sparkline chart -->
<div class="metric-sparkline" v-if="hasSparklineData">
<div class="sparkline-chart">
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
<!-- Breakdown section -->
<div class="breakdown-section" v-if="breakdown">
<!-- În termen -->
<div class="breakdown-item">
<span class="breakdown-label">În termen</span>
<span class="breakdown-value">{{
formatCurrency(breakdown.in_termen?.total || 0)
}}</span>
</div>
<!-- Restant cu sub-perioade -->
<div class="breakdown-group">
<div class="breakdown-header" @click="toggleRestantExpanded">
<div class="breakdown-header-left">
<i
class="pi pi-chevron-right breakdown-toggle"
:class="{ expanded: isRestantExpanded }"
></i>
<span class="breakdown-label">Restant</span>
</div>
<span class="breakdown-value">{{
formatCurrency(breakdown.restant?.total || 0)
}}</span>
</div>
<!-- Perioade restante -->
<div v-show="isRestantExpanded" class="breakdown-subitems slide-down">
<div
class="breakdown-subitem"
v-for="(value, key) in breakdown.restant?.perioade"
:key="key"
>
<span class="breakdown-sublabel">{{ formatPeriodLabel(key) }}</span>
<span class="breakdown-subvalue">{{ formatCurrency(value) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
ref,
computed,
watch,
onMounted,
onBeforeUnmount,
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
Chart.register(...registerables);
const props = defineProps({
total: {
type: Number,
required: true,
},
trend: {
type: Object,
default: null,
},
sparklineData: {
type: Array,
default: () => [],
},
previousSparklineData: {
type: Array,
default: () => [],
},
sparklineLabels: {
type: Array,
default: () => [],
},
previousSparklineLabels: {
type: Array,
default: () => [],
},
breakdown: {
type: Object,
default: null,
},
});
// Refs
const chartCanvas = ref(null);
let chartInstance = null;
const isRestantExpanded = ref(false);
// Toggle functions
const toggleRestantExpanded = () => {
isRestantExpanded.value = !isRestantExpanded.value;
};
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(Math.abs(amount));
};
// Format period label
const formatPeriodLabel = (key) => {
const labelMap = {
"7_zile": "7 zile",
"14_zile": "14 zile",
"30_zile": "30 zile",
"60_zile": "60 zile",
"90_zile": "90 zile",
peste_90_zile: "Peste 90 zile",
};
return labelMap[key] || key;
};
// Balance class
const getBalanceClass = (amount) => {
if (!amount && amount !== 0) return "neutral";
const numAmount = typeof amount === "string" ? parseFloat(amount) : amount;
return numAmount > 0 ? "positive" : numAmount < 0 ? "negative" : "neutral";
};
// Trend class
const getTrendClass = (trend) => {
if (!trend) return "";
return {
"trend-up": trend.direction === "up",
"trend-down": trend.direction === "down",
"trend-neutral": trend.direction === "neutral",
};
};
// Trend icon
const getTrendIcon = (trend) => {
if (!trend) return "";
switch (trend.direction) {
case "up":
return "▲";
case "down":
return "▼";
case "neutral":
return "▶";
default:
return "";
}
};
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return props.sparklineData && props.sparklineData.length > 0;
});
// Initialize chart
const initializeChart = async () => {
if (!chartCanvas.value || !hasSparklineData.value) {
return;
}
// Destroy existing chart
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
await nextTick();
const ctx = chartCanvas.value.getContext("2d");
// Generate labels
const labels =
props.sparklineLabels.length > 0
? props.sparklineLabels
: props.sparklineData.map((_, i) => `L${i + 1}`);
// Calculate limits including both datasets
const allDataPoints = [...props.sparklineData];
if (props.previousSparklineData && props.previousSparklineData.length > 0) {
allDataPoints.push(...props.previousSparklineData);
}
const dataMin = Math.min(...allDataPoints);
const dataMax = Math.max(...allDataPoints);
const dataRange = dataMax - dataMin;
const dataMean =
allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length;
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
const minVisibleRange = dataMean * 0.25; // 25% din medie = range minim vizibil
const center = (dataMin + dataMax) / 2;
const targetRange = Math.max(dataRange, minVisibleRange);
// Calculează limite simetric față de centru
let calculatedMin = center - targetRange / 2;
let calculatedMax = center + targetRange / 2;
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
const paddingAmount = targetRange * 0.1; // 10% padding suplimentar
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
const allPositive = dataMin >= 0;
const yMin = allPositive
? Math.max(0, calculatedMin - paddingAmount)
: calculatedMin - paddingAmount;
const yMax = calculatedMax + paddingAmount;
// Prepare datasets
const datasets = [
{
label: "Furnizori (curent)",
data: props.sparklineData,
borderColor: "#ef4444",
backgroundColor: "rgba(239, 68, 68, 0.1)",
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "#ef4444",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
},
];
// Add previous year dataset if available
if (props.previousSparklineData && props.previousSparklineData.length > 0) {
datasets.push({
label: "Furnizori (anul precedent)",
data: props.previousSparklineData,
borderColor: "rgba(239, 68, 68, 0.4)",
backgroundColor: "rgba(239, 68, 68, 0.05)",
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "rgba(239, 68, 68, 0.6)",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
});
}
chartInstance = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
legend: {
display: true,
position: "top",
align: "end",
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
color: "rgba(107, 114, 128, 0.8)",
usePointStyle: true,
},
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
titleColor: "#ffffff",
bodyColor: "#ffffff",
borderColor: "rgba(255, 255, 255, 0.2)",
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || "",
label: (context) => {
const value = context.parsed.y;
const label = context.dataset.label || "";
const formattedValue = new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
return `${label}: ${formattedValue}`;
},
},
},
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false,
},
ticks: {
color: "rgba(107, 114, 128, 0.7)",
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6,
},
border: {
display: false,
},
},
y: {
display: true,
min: yMin,
max: yMax,
grid: {
color: "rgba(107, 114, 128, 0.1)",
drawBorder: false,
},
ticks: {
color: "#ef4444",
font: {
size: 11,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxTicksLimit: 3,
callback: function (value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + "M";
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + "k";
}
return value.toFixed(0);
},
},
border: {
display: false,
},
},
},
},
});
};
// Watch for data changes
watch(
() => [
props.sparklineData,
props.previousSparklineData,
props.sparklineLabels,
props.previousSparklineLabels,
],
async () => {
await initializeChart();
},
{ deep: true },
);
// Lifecycle hooks
onMounted(async () => {
await initializeChart();
});
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
});
</script>
<style scoped>
/* Component-specific: FurnizoriBalanceCard layout and breakdown */
/* Override min-height for balance card */
.furnizori-balance-card {
min-height: 320px;
}
/* Value section: horizontal layout */
.value-section {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
/* Color classes for positive/negative/neutral (component-specific logic) */
.positive {
color: var(--color-success);
}
.negative {
color: var(--color-error);
}
.neutral {
color: var(--color-text);
}
/* Sparkline chart dimensions */
.sparkline-chart {
width: 100%;
height: 150px;
position: relative;
}
/* Chart.js canvas sizing (required for proper rendering) */
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Responsive */
@media (max-width: 768px) {
.furnizori-balance-card {
min-height: 280px;
}
.sparkline-chart {
height: 130px;
}
}
@media (max-width: 480px) {
.sparkline-chart {
height: 150px;
}
}
</style>

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,517 @@
<template>
<div class="metric-card">
<!-- Header with icon and title -->
<div class="metric-header">
<span class="metric-icon bg-primary-light text-primary">{{ icon }}</span>
<span class="metric-label">{{ title }}</span>
</div>
<!-- Main value display -->
<div class="metric-value" :class="valueClass">
{{ formatCurrency(value) }}
</div>
<!-- Trend indicator -->
<div class="trend-indicator" :class="trendClass" v-if="trend">
<span class="trend-icon">{{ trendIcon }}</span>
<span class="trend-value"
>{{ Math.round(Math.abs(trend.value), 2) }}%</span
>
</div>
<!-- Sparkline mini-chart - STACKED BELOW (Best Practice) -->
<div
class="sparkline-container"
v-if="sparklineData && sparklineData.length > 0"
>
<canvas ref="sparklineCanvas" class="sparkline-canvas"></canvas>
</div>
<!-- Breakdown display section - Suport ierarhic -->
<div class="metric-breakdown" v-if="breakdown">
<div
v-for="(value, key) in breakdown"
:key="key"
class="breakdown-section"
>
<!-- Valoare simplă (backward compatible) -->
<div v-if="!isHierarchical(value)" class="breakdown-item">
<span class="breakdown-label">{{ formatBreakdownLabel(key) }}:</span>
<span class="breakdown-value">{{ formatCurrency(value) }}</span>
</div>
<!-- Valoare ierarhică (cu sub-items) -->
<div v-else class="breakdown-group">
<div class="breakdown-header" @click="() => toggleExpanded(key)">
<div class="breakdown-header-left">
<i
class="pi pi-chevron-right breakdown-toggle"
:class="{ expanded: isItemExpanded(key) }"
></i>
<span class="breakdown-label"
>{{ formatBreakdownLabel(key) }}:</span
>
</div>
<span class="breakdown-value">{{
formatCurrency(value.total)
}}</span>
</div>
<!-- Sub-items (collapsible) -->
<div
v-if="value.items && value.items.length > 0"
v-show="isItemExpanded(key)"
class="breakdown-subitems slide-down"
>
<div
v-for="(item, idx) in value.items"
:key="idx"
class="breakdown-subitem"
>
<span class="breakdown-sublabel">
{{ item.nume }}
<span v-if="item.cont" class="breakdown-cont"
>({{ item.cont }})</span
>
</span>
<span class="breakdown-subvalue">{{
formatCurrency(item.sold)
}}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
computed,
ref,
watch,
onMounted,
onBeforeUnmount,
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
// Register Chart.js components
Chart.register(...registerables);
// Props definition with validation
const props = defineProps({
icon: {
type: String,
required: true,
validator: (value) => value.length > 0,
},
title: {
type: String,
required: true,
validator: (value) => value.length > 0,
},
value: {
type: Number,
required: true,
},
trend: {
type: Object,
default: null,
validator: (value) => {
if (value === null) return true;
return (
typeof value.value === "number" &&
["up", "down", "neutral"].includes(value.direction)
);
},
},
sparklineData: {
type: Array,
default: () => [],
validator: (value) => {
return value.every((item) => typeof item === "number");
},
},
sparklineLabels: {
type: Array,
default: () => [],
},
breakdown: {
type: Object,
required: false,
default: null,
},
});
// Refs
const sparklineCanvas = ref(null);
let chartInstance = null;
const expandedStates = ref({});
// Toggle breakdown expansion for a specific key
const toggleExpanded = (key) => {
expandedStates.value[key] = !expandedStates.value[key];
};
// Check if a specific breakdown item is expanded
const isItemExpanded = (key) => {
return !!expandedStates.value[key];
};
// Format currency value
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})
.format(Math.abs(amount))
.replace("RON", "RON");
};
// Format breakdown label
const formatBreakdownLabel = (key) => {
const labelMap = {
casa: "Casă",
banca: "Bancă",
clienti: "Clienți",
furnizori: "Furnizori",
clienti_in_termen: "Clienți în termen",
clienti_restanti: "Clienți restanți",
furnizori_termen: "Furnizori în termen",
furnizori_scadent: "Furnizori scadenți",
numerar: "Numerar",
cont: "Cont",
depozit: "Depozit",
credit: "Credit",
debit: "Debit",
sold: "Sold",
total: "Total",
};
return (
labelMap[key.toLowerCase()] || key.charAt(0).toUpperCase() + key.slice(1)
);
};
// Check if value is hierarchical (has total and items)
const isHierarchical = (value) => {
return (
value !== null &&
typeof value === "object" &&
"total" in value &&
"items" in value
);
};
// Computed properties for styling
const iconClass = computed(() => {
return `icon-${props.title.toLowerCase().replace(/\s+/g, "-")}`;
});
const valueClass = computed(() => {
if (!props.value && props.value !== 0) return "";
return props.value < 0 ? "negative" : "positive";
});
const trendClass = computed(() => {
if (!props.trend) return "";
return {
"trend-up": props.trend.direction === "up",
"trend-down": props.trend.direction === "down",
"trend-neutral": props.trend.direction === "neutral",
};
});
const trendIcon = computed(() => {
if (!props.trend) return "";
switch (props.trend.direction) {
case "up":
return "▲";
case "down":
return "▼";
case "neutral":
return "▶";
default:
return "";
}
});
// Sparkline color based on trend
const sparklineColor = computed(() => {
if (!props.trend) {
return "#3b82f6"; // Primary blue
}
switch (props.trend.direction) {
case "up":
return "#10b981"; // Success green
case "down":
return "#ef4444"; // Danger red
default:
return "#3b82f6"; // Primary blue
}
});
// Initialize Chart.js sparkline
const initializeSparkline = async () => {
if (
!sparklineCanvas.value ||
!props.sparklineData ||
props.sparklineData.length === 0
) {
return;
}
// Destroy existing chart instance
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
await nextTick();
const ctx = sparklineCanvas.value.getContext("2d");
const color = sparklineColor.value;
// Generate labels: use provided labels or generate generic ones
const labels =
props.sparklineLabels.length > 0
? props.sparklineLabels
: props.sparklineData.map((_, i) => `L${i + 1}`);
chartInstance = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: [
{
data: props.sparklineData,
borderColor: color,
backgroundColor: `${color}20`, // 20 = 12.5% opacity in hex
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0, // Hide points by default
pointHoverRadius: 4,
pointHoverBackgroundColor: color,
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
titleColor: "#ffffff",
bodyColor: "#ffffff",
borderColor: "rgba(255, 255, 255, 0.2)",
borderWidth: 1,
cornerRadius: 6,
displayColors: false,
callbacks: {
title: (context) => {
// Show period label in tooltip
return context[0].label || "";
},
label: (context) => {
const value = context.parsed.y;
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
},
},
},
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false,
},
ticks: {
color: "rgba(107, 114, 128, 0.7)",
font: {
size: 9,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6,
},
border: {
display: false,
},
},
y: {
display: true,
grid: {
color: "rgba(107, 114, 128, 0.1)",
drawBorder: false,
},
ticks: {
color: "rgba(107, 114, 128, 0.7)",
font: {
size: 9,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxTicksLimit: 4,
callback: function (value) {
// Format as compact currency
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + "M";
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + "k";
}
return value.toFixed(0);
},
},
border: {
display: false,
},
},
},
elements: {
line: {
borderCapStyle: "round",
borderJoinStyle: "round",
},
},
},
});
};
// Watch for data changes
watch(
() => props.sparklineData,
async () => {
await initializeSparkline();
},
{ deep: true },
);
watch(
() => props.sparklineLabels,
async () => {
await initializeSparkline();
},
{ deep: true },
);
watch(
() => props.trend,
async () => {
await initializeSparkline();
},
{ deep: true },
);
// Lifecycle hooks
onMounted(async () => {
await initializeSparkline();
});
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
});
</script>
<style scoped>
/* Component-specific styles only - Base patterns now in global CSS */
/* Trend indicator styles - Component specific */
.trend-indicator {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.trend-up {
color: var(--color-success);
}
.trend-down {
color: var(--color-error);
}
.trend-neutral {
color: var(--color-text-secondary);
}
.trend-icon {
font-size: 0.75rem;
}
.trend-value {
font-weight: var(--font-semibold);
}
/* Value styling - Component specific colors */
.metric-value.positive {
color: var(--color-success);
}
.metric-value.negative {
color: var(--color-error);
}
/* Breakdown section - Component specific layout */
.metric-breakdown {
margin-top: var(--space-lg);
padding-top: var(--space-md);
border-top: 1px solid var(--color-border);
}
.breakdown-section {
margin-bottom: var(--space-sm);
}
.breakdown-section:last-child {
margin-bottom: 0;
}
.breakdown-group {
margin-bottom: var(--space-sm);
}
/* Breakdown item - Component specific styles */
.breakdown-item {
margin-bottom: var(--space-xs);
}
.breakdown-item:last-child {
margin-bottom: 0;
}
/* Breakdown cont label - Component specific */
.breakdown-cont {
font-size: var(--text-sm);
opacity: 0.7;
margin-left: var(--space-xs);
}
</style>

View File

@@ -0,0 +1,980 @@
<template>
<div class="performance-card card">
<div class="card-header">
<div class="header-content">
<div class="header-title">
<span class="card-icon">📊</span>
<h3 class="card-title">Performanță & Cash Flow</h3>
</div>
<div class="period-selector">
<select
v-model="selectedPeriod"
@change="handlePeriodChange"
class="period-select"
:disabled="isLoading"
>
<option value="7d">7 zile</option>
<option value="1m">1 lună</option>
<option value="3m">3 luni</option>
<option value="6m">6 luni</option>
<option value="ytd">YTD</option>
<option value="12m">12 luni</option>
</select>
<i class="pi pi-chevron-down select-icon"></i>
</div>
</div>
</div>
<div class="card-body">
<!-- Loading State -->
<div v-if="isLoading" class="loading-state">
<div class="loading-spinner"></div>
<span class="loading-text">Se încarcă datele...</span>
</div>
<!-- Error State -->
<div v-else-if="error" class="error-state">
<i class="pi pi-exclamation-triangle error-icon"></i>
<span class="error-text">{{ error }}</span>
<button @click="retryLoad" class="retry-button">
<i class="pi pi-refresh"></i>
Reîncarcă
</button>
</div>
<!-- Content -->
<template v-else>
<!-- Chart Container -->
<div class="chart-container">
<div class="chart-placeholder" v-if="!chartData?.labels?.length">
<div class="placeholder-content">
<i class="pi pi-chart-line placeholder-icon"></i>
<span class="placeholder-text">Grafic încasări vs plăți</span>
<small class="placeholder-subtitle"
>Datele vor fi afișate aici</small
>
</div>
</div>
<div v-else class="chart-content">
<div class="chart-legend">
<div class="legend-item">
<span class="legend-color income"></span>
<span class="legend-label">Încasări</span>
<span class="legend-value">{{
formatCurrency(totalIncome)
}}</span>
</div>
<div class="legend-item">
<span class="legend-color expenses"></span>
<span class="legend-label">Plăți</span>
<span class="legend-value">{{
formatCurrency(totalExpenses)
}}</span>
</div>
</div>
<div class="chart-canvas-container">
<canvas
ref="performanceChart"
v-if="chartData?.labels?.length"
width="400"
height="200"
></canvas>
</div>
</div>
</div>
<!-- Performance Indicators -->
<div class="indicators-section">
<div class="indicators-grid">
<div class="indicator-card">
<div class="indicator-icon">💰</div>
<div class="indicator-content">
<div class="indicator-label">Rata încasare</div>
<div
class="indicator-value"
:class="getRateClass(performanceData.rataIncasare)"
>
{{ performanceData.rataIncasare || 0 }}%
</div>
</div>
</div>
<div class="indicator-card">
<div class="indicator-icon"></div>
<div class="indicator-content">
<div class="indicator-label">Cash conversion</div>
<div class="indicator-value">
{{ performanceData.cashConversion || 0 }} zile
</div>
</div>
</div>
<div class="indicator-card">
<div class="indicator-icon">📈</div>
<div class="indicator-content">
<div class="indicator-label">Trend</div>
<div
class="indicator-value"
:class="getTrendClass(performanceData.trend)"
>
<i :class="getTrendIcon(performanceData.trend)"></i>
{{ getTrendText(performanceData.trend) }}
</div>
</div>
</div>
<div class="indicator-card">
<div class="indicator-icon">💼</div>
<div class="indicator-content">
<div class="indicator-label">Capital lucru</div>
<div
class="indicator-value"
:class="
getWorkingCapitalClass(performanceData.workingCapital)
"
>
{{ formatCurrency(performanceData.workingCapital || 0) }}
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import {
ref,
computed,
watch,
onMounted,
onBeforeUnmount,
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
import { useDashboardStore } from "@reports/stores/dashboard";
// Register Chart.js components
Chart.register(...registerables);
// Props
const props = defineProps({
companyId: {
type: [Number, String],
required: true,
},
});
// Emits
const emit = defineEmits(["periodChanged"]);
// State
const selectedPeriod = ref("7d");
const isLoading = ref(false);
const error = ref(null);
const performanceChart = ref(null);
let chartInstance = null;
// Store
const dashboardStore = useDashboardStore();
// Sample data (will be replaced with actual API data)
const performanceData = ref({
rataIncasare: 85.2,
cashConversion: 45,
trend: "up",
workingCapital: 125000,
});
const chartData = ref({
labels: ["Lun", "Mar", "Mie", "Joi", "Vin", "Sâm", "Dum"],
income: [12000, 15000, 8000, 22000, 18000, 14000, 16000],
expenses: [8000, 12000, 15000, 16000, 14000, 11000, 13000],
});
// Computed
const totalIncome = computed(() => {
return chartData.value.income?.reduce((sum, val) => sum + val, 0) || 0;
});
const totalExpenses = computed(() => {
return chartData.value.expenses?.reduce((sum, val) => sum + val, 0) || 0;
});
const maxValue = computed(() => {
const allValues = [
...(chartData.value.income || []),
...(chartData.value.expenses || []),
];
return Math.max(...allValues, 0);
});
// Methods
const handlePeriodChange = () => {
emit("periodChanged", selectedPeriod.value);
loadPerformanceData();
};
const loadPerformanceData = async () => {
if (!props.companyId) return;
isLoading.value = true;
error.value = null;
try {
// This will be replaced with actual API call
// const result = await dashboardStore.loadPerformanceData(props.companyId, selectedPeriod.value)
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
// Mock data based on period
const mockData = {
"7d": {
rataIncasare: 85.2,
cashConversion: 45,
trend: "up",
workingCapital: 125000,
chartData: {
labels: ["Lun", "Mar", "Mie", "Joi", "Vin", "Sâm", "Dum"],
income: [12000, 15000, 8000, 22000, 18000, 14000, 16000],
expenses: [8000, 12000, 15000, 16000, 14000, 11000, 13000],
},
},
"1m": {
rataIncasare: 78.5,
cashConversion: 52,
trend: "stable",
workingCapital: 89000,
chartData: {
labels: ["S1", "S2", "S3", "S4"],
income: [45000, 52000, 38000, 48000],
expenses: [42000, 47000, 51000, 45000],
},
},
"3m": {
rataIncasare: 82.1,
cashConversion: 38,
trend: "up",
workingCapital: 156000,
chartData: {
labels: ["Ian", "Feb", "Mar"],
income: [165000, 182000, 155000],
expenses: [158000, 162000, 168000],
},
},
"6m": {
rataIncasare: 79.8,
cashConversion: 41,
trend: "down",
workingCapital: 98000,
chartData: {
labels: ["Oct", "Noi", "Dec", "Ian", "Feb", "Mar"],
income: [145000, 162000, 185000, 165000, 182000, 155000],
expenses: [152000, 158000, 172000, 158000, 162000, 168000],
},
},
ytd: {
rataIncasare: 81.3,
cashConversion: 43,
trend: "stable",
workingCapital: 142000,
chartData: {
labels: ["Q1", "Q2", "Q3"],
income: [502000, 485000, 456000],
expenses: [488000, 512000, 478000],
},
},
"12m": {
rataIncasare: 83.7,
cashConversion: 39,
trend: "up",
workingCapital: 178000,
chartData: {
labels: ["T1", "T2", "T3", "T4"],
income: [1456000, 1523000, 1387000, 1612000],
expenses: [1423000, 1498000, 1456000, 1534000],
},
},
};
const data = mockData[selectedPeriod.value] || mockData["7d"];
performanceData.value = data;
chartData.value = data.chartData;
// Initialize or update chart after data is loaded
await nextTick();
await updateChart();
} catch (err) {
console.error("Failed to load performance data:", err);
error.value = "Nu s-au putut încărca datele de performanță";
} finally {
isLoading.value = false;
}
};
const retryLoad = () => {
loadPerformanceData();
};
const formatCurrency = (value) => {
if (value === null || value === undefined) return "0 RON";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const initializeChart = async () => {
if (!performanceChart.value || !chartData.value?.labels?.length) return;
// Destroy existing chart instance
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
await nextTick();
const ctx = performanceChart.value.getContext("2d");
chartInstance = new Chart(ctx, {
type: "line",
data: {
labels: chartData.value.labels,
datasets: [
{
label: "Încasări",
data: chartData.value.income,
borderColor: "rgba(16, 185, 129, 1)", // var(--color-success)
backgroundColor: "rgba(16, 185, 129, 0.1)",
borderWidth: 2,
fill: true,
tension: 0.4,
pointBackgroundColor: "rgba(16, 185, 129, 1)",
pointBorderColor: "#ffffff",
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
},
{
label: "Plăți",
data: chartData.value.expenses,
borderColor: "rgba(239, 68, 68, 1)", // var(--color-error)
backgroundColor: "rgba(239, 68, 68, 0.1)",
borderWidth: 2,
fill: true,
tension: 0.4,
pointBackgroundColor: "rgba(239, 68, 68, 1)",
pointBorderColor: "#ffffff",
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
legend: {
display: false, // We have our own custom legend
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
titleColor: "#ffffff",
bodyColor: "#ffffff",
borderColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
cornerRadius: 8,
displayColors: true,
callbacks: {
label: function (context) {
const value = context.parsed.y;
const formattedValue = new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
return `${context.dataset.label}: ${formattedValue}`;
},
},
},
},
scales: {
x: {
grid: {
display: false,
},
border: {
display: false,
},
ticks: {
color: "rgba(107, 114, 128, 0.8)",
font: {
size: 12,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
},
},
y: {
beginAtZero: true,
grid: {
color: "rgba(107, 114, 128, 0.1)",
drawBorder: false,
},
border: {
display: false,
},
ticks: {
color: "rgba(107, 114, 128, 0.8)",
font: {
size: 12,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
callback: function (value) {
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
notation: "compact",
}).format(value);
},
},
},
},
elements: {
line: {
borderCapStyle: "round",
borderJoinStyle: "round",
},
},
},
});
};
const updateChart = async () => {
if (chartInstance && chartData.value?.labels?.length) {
chartInstance.data.labels = chartData.value.labels;
chartInstance.data.datasets[0].data = chartData.value.income;
chartInstance.data.datasets[1].data = chartData.value.expenses;
chartInstance.update("active");
} else {
await initializeChart();
}
};
const getRateClass = (rate) => {
if (rate >= 85) return "rate-excellent";
if (rate >= 75) return "rate-good";
if (rate >= 60) return "rate-average";
return "rate-poor";
};
const getTrendClass = (trend) => {
switch (trend) {
case "up":
return "trend-up";
case "down":
return "trend-down";
default:
return "trend-stable";
}
};
const getTrendIcon = (trend) => {
switch (trend) {
case "up":
return "pi pi-arrow-up";
case "down":
return "pi pi-arrow-down";
default:
return "pi pi-minus";
}
};
const getTrendText = (trend) => {
switch (trend) {
case "up":
return "Crescător";
case "down":
return "Descrescător";
default:
return "Stabil";
}
};
const getWorkingCapitalClass = (value) => {
if (value > 100000) return "capital-positive";
if (value > 0) return "capital-neutral";
return "capital-negative";
};
// Watchers
watch(
() => props.companyId,
(newId) => {
if (newId) {
loadPerformanceData();
}
},
{ immediate: true },
);
watch(
chartData,
async () => {
if (chartData.value?.labels?.length) {
await nextTick();
await updateChart();
}
},
{ deep: true },
);
// Lifecycle
onMounted(async () => {
if (props.companyId) {
await loadPerformanceData();
}
});
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
});
</script>
<style scoped>
/* Performance Card Styles */
.performance-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
box-shadow: var(--shadow-sm);
transition: all var(--transition-fast);
overflow: hidden;
}
.performance-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
/* Header */
.card-header {
padding: var(--space-lg);
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.header-title {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.card-icon {
font-size: var(--text-lg);
}
.card-title {
margin: 0;
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--color-text);
}
/* Period Selector */
.period-selector {
position: relative;
}
.period-select {
appearance: none;
padding: var(--space-sm) var(--space-xl) var(--space-sm) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm);
font-weight: var(--font-medium);
cursor: pointer;
transition: all var(--transition-fast);
min-width: 100px;
}
.period-select:hover {
border-color: var(--color-primary);
}
.period-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.1);
}
.period-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.select-icon {
position: absolute;
right: var(--space-sm);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
font-size: var(--text-xs);
pointer-events: none;
}
/* Body */
.card-body {
padding: var(--space-lg);
}
/* Loading State */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-2xl);
gap: var(--space-md);
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Error State */
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-2xl);
gap: var(--space-md);
text-align: center;
}
.error-icon {
color: var(--color-error);
font-size: var(--text-2xl);
}
.error-text {
color: var(--color-error);
font-size: var(--text-sm);
margin-bottom: var(--space-sm);
}
.retry-button {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
background: var(--color-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--text-sm);
transition: background-color var(--transition-fast);
}
.retry-button:hover {
background: var(--color-primary-dark);
}
/* Chart Container */
.chart-container {
margin-bottom: var(--space-xl);
min-height: 200px;
}
.chart-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
}
.placeholder-content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
text-align: center;
}
.placeholder-icon {
font-size: var(--text-3xl);
color: var(--color-text-muted);
}
.placeholder-text {
font-size: var(--text-base);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
}
.placeholder-subtitle {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
/* Chart Content */
.chart-content {
background: var(--color-bg);
border-radius: var(--radius-md);
overflow: hidden;
}
.chart-legend {
display: flex;
justify-content: space-around;
padding: var(--space-md);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
.legend-item {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-sm);
}
.legend-color {
width: 12px;
height: 12px;
border-radius: var(--radius-sm);
}
.legend-color.income {
background: var(--color-success);
}
.legend-color.expenses {
background: var(--color-error);
}
.legend-label {
font-weight: var(--font-medium);
color: var(--color-text-secondary);
}
.legend-value {
font-weight: var(--font-semibold);
color: var(--color-text);
}
.chart-canvas-container {
padding: var(--space-lg);
height: 200px;
position: relative;
}
.chart-canvas-container canvas {
width: 100% !important;
height: 100% !important;
}
/* Indicators */
.indicators-section {
border-top: 1px solid var(--color-border);
padding-top: var(--space-lg);
}
.indicators-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-md);
}
.indicator-card {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.indicator-card:hover {
background: var(--color-bg-muted);
}
.indicator-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
border-radius: var(--radius-lg);
font-size: var(--text-lg);
flex-shrink: 0;
}
.indicator-content {
flex: 1;
min-width: 0;
}
.indicator-label {
font-size: var(--text-xs);
color: var(--color-text-secondary);
font-weight: var(--font-medium);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-xs);
}
.indicator-value {
font-size: var(--text-lg);
font-weight: var(--font-bold);
color: var(--color-text);
display: flex;
align-items: center;
gap: var(--space-xs);
}
/* Indicator Value Colors */
.rate-excellent {
color: var(--color-success);
}
.rate-good {
color: #10b981;
}
.rate-average {
color: var(--color-warning);
}
.rate-poor {
color: var(--color-error);
}
.trend-up {
color: var(--color-success);
}
.trend-down {
color: var(--color-error);
}
.trend-stable {
color: var(--color-secondary);
}
.capital-positive {
color: var(--color-success);
}
.capital-neutral {
color: var(--color-warning);
}
.capital-negative {
color: var(--color-error);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.card-header {
padding: var(--space-md);
}
.card-body {
padding: var(--space-md);
}
.header-content {
flex-direction: column;
align-items: flex-start;
gap: var(--space-sm);
}
.indicators-grid {
grid-template-columns: 1fr;
gap: var(--space-sm);
}
.indicator-card {
padding: var(--space-sm);
}
.indicator-icon {
width: 32px;
height: 32px;
font-size: var(--text-base);
}
.chart-canvas-container {
height: 160px;
padding: var(--space-md);
}
.chart-legend {
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm);
}
}
@media (max-width: 480px) {
.card-header,
.card-body {
padding: var(--space-sm);
}
.card-title {
font-size: var(--text-base);
}
.indicator-value {
font-size: var(--text-base);
}
.loading-state,
.error-state {
padding: var(--space-lg);
}
}
</style>

View File

@@ -0,0 +1,722 @@
<template>
<div class="metric-card treasury-dual-card">
<!-- Main values section - Split layout (Casa | Bancă) -->
<div class="values-section">
<!-- Casa Section -->
<div class="value-block casa">
<div class="metric-label">Casa</div>
<div class="metric-value text-success">
{{ formatCurrency(casaTotal) }}
</div>
</div>
<!-- Divider -->
<div class="divider"></div>
<!-- Bancă Section -->
<div class="value-block banca">
<div class="metric-label">Bancă</div>
<div class="metric-value text-primary">
{{ formatCurrency(bancaTotal) }}
</div>
</div>
</div>
<!-- Dual sparkline charts - stacked vertical -->
<div class="sparkline-dual-container" v-if="hasSparklineData">
<!-- Grafic Casa -->
<div class="sparkline-wrapper">
<div class="sparkline-title text-success">Casa</div>
<div class="sparkline-chart">
<canvas ref="casaCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
<!-- Grafic Bancă -->
<div class="sparkline-wrapper">
<div class="sparkline-title text-primary">Bancă</div>
<div class="sparkline-chart">
<canvas ref="bancaCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
</div>
<!-- Breakdown section -->
<div
class="breakdown-section"
v-if="casaItems.length > 0 || bancaItems.length > 0"
>
<!-- Casa Breakdown -->
<div class="breakdown-group" v-if="casaItems.length > 0">
<div class="breakdown-header" @click="toggleCasaExpanded">
<div class="breakdown-header-left">
<i
class="pi pi-chevron-right breakdown-toggle"
:class="{ expanded: isCasaExpanded }"
></i>
<span class="breakdown-label">Casa</span>
</div>
<span class="breakdown-value">{{ formatCurrency(casaTotal) }}</span>
</div>
<!-- Casa Sub-items -->
<div v-show="isCasaExpanded" class="breakdown-subitems slide-down">
<div
v-for="(item, idx) in casaItems"
:key="idx"
class="breakdown-subitem"
>
<span class="breakdown-sublabel">
{{ item.nume || `Cont ${item.cont}` }}
<span v-if="item.cont" class="breakdown-cont"
>({{ item.cont }})</span
>
</span>
<span class="breakdown-subvalue">{{
formatCurrency(item.sold)
}}</span>
</div>
</div>
</div>
<!-- Bancă Breakdown -->
<div class="breakdown-group" v-if="bancaItems.length > 0">
<div class="breakdown-header" @click="toggleBancaExpanded">
<div class="breakdown-header-left">
<i
class="pi pi-chevron-right breakdown-toggle"
:class="{ expanded: isBancaExpanded }"
></i>
<span class="breakdown-label">Bancă</span>
</div>
<span class="breakdown-value">{{ formatCurrency(bancaTotal) }}</span>
</div>
<!-- Bancă Sub-items -->
<div v-show="isBancaExpanded" class="breakdown-subitems slide-down">
<div
v-for="(item, idx) in bancaItems"
:key="idx"
class="breakdown-subitem"
>
<span class="breakdown-sublabel">
{{ item.nume || `Cont ${item.cont}` }}
<span v-if="item.cont" class="breakdown-cont"
>({{ item.cont }})</span
>
</span>
<span class="breakdown-subvalue">{{
formatCurrency(item.sold)
}}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
ref,
computed,
watch,
onMounted,
onBeforeUnmount,
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
Chart.register(...registerables);
const props = defineProps({
casaTotal: {
type: Number,
default: 0,
},
bancaTotal: {
type: Number,
default: 0,
},
casaItems: {
type: Array,
default: () => [],
},
bancaItems: {
type: Array,
default: () => [],
},
casaSparklineData: {
type: Array,
default: () => [],
},
bancaSparklineData: {
type: Array,
default: () => [],
},
casaPreviousSparklineData: {
type: Array,
default: () => [],
},
bancaPreviousSparklineData: {
type: Array,
default: () => [],
},
sparklineLabels: {
type: Array,
default: () => [],
},
previousSparklineLabels: {
type: Array,
default: () => [],
},
trend: {
type: Object,
default: null,
},
});
// Refs pentru 2 canvas-uri separate
const casaCanvas = ref(null);
const bancaCanvas = ref(null);
let casaChartInstance = null;
let bancaChartInstance = null;
const isCasaExpanded = ref(false);
const isBancaExpanded = ref(false);
// Toggle functions
const toggleCasaExpanded = () => {
isCasaExpanded.value = !isCasaExpanded.value;
};
const toggleBancaExpanded = () => {
isBancaExpanded.value = !isBancaExpanded.value;
};
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(Math.abs(amount));
};
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return (
props.casaSparklineData.length > 0 && props.bancaSparklineData.length > 0
);
});
// Initialize Casa chart
const initializeCasaChart = async () => {
if (!casaCanvas.value || props.casaSparklineData.length === 0) {
return;
}
// Destroy existing chart
if (casaChartInstance) {
casaChartInstance.destroy();
casaChartInstance = null;
}
await nextTick();
const ctx = casaCanvas.value.getContext("2d");
// Generate labels
const labels =
props.sparklineLabels.length > 0
? props.sparklineLabels
: props.casaSparklineData.map((_, i) => `L${i + 1}`);
// Prepare datasets
const datasets = [
{
label: "Casa (curent)",
data: props.casaSparklineData,
borderColor: "#10b981",
backgroundColor: "rgba(16, 185, 129, 0.1)",
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "#10b981",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
},
];
// Add previous year dataset if available
if (
props.casaPreviousSparklineData &&
props.casaPreviousSparklineData.length > 0
) {
datasets.push({
label: "Casa (anul precedent)",
data: props.casaPreviousSparklineData,
borderColor: "rgba(16, 185, 129, 0.4)",
backgroundColor: "rgba(16, 185, 129, 0.05)",
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "rgba(16, 185, 129, 0.4)",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
});
}
// Calculate limits including both datasets
const allDataPoints = [...props.casaSparklineData];
if (
props.casaPreviousSparklineData &&
props.casaPreviousSparklineData.length > 0
) {
allDataPoints.push(...props.casaPreviousSparklineData);
}
const dataMin = Math.min(...allDataPoints);
const dataMax = Math.max(...allDataPoints);
const dataRange = dataMax - dataMin;
const dataPadding = dataRange * 0.05;
casaChartInstance = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
legend: {
display: datasets.length > 1,
position: "top",
align: "end",
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
color: "rgba(107, 114, 128, 0.9)",
usePointStyle: true,
pointStyle: "line",
},
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
titleColor: "#ffffff",
bodyColor: "#ffffff",
borderColor: "rgba(255, 255, 255, 0.2)",
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || "",
label: (context) => {
const value = context.parsed.y;
const label = context.dataset.label || "";
const formattedValue = new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
return `${label}: ${formattedValue}`;
},
},
},
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false,
},
ticks: {
color: "rgba(107, 114, 128, 0.7)",
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6,
},
border: {
display: false,
},
},
y: {
display: true,
min: dataMin - dataPadding,
max: dataMax + dataPadding,
grid: {
color: "rgba(107, 114, 128, 0.1)",
drawBorder: false,
},
ticks: {
color: "#10b981",
font: {
size: 11,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxTicksLimit: 3,
callback: function (value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + "M";
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + "k";
}
return value.toFixed(0);
},
},
border: {
display: false,
},
},
},
},
});
};
// Initialize Bancă chart
const initializeBancaChart = async () => {
if (!bancaCanvas.value || props.bancaSparklineData.length === 0) {
return;
}
// Destroy existing chart
if (bancaChartInstance) {
bancaChartInstance.destroy();
bancaChartInstance = null;
}
await nextTick();
const ctx = bancaCanvas.value.getContext("2d");
// Generate labels
const labels =
props.sparklineLabels.length > 0
? props.sparklineLabels
: props.bancaSparklineData.map((_, i) => `L${i + 1}`);
// Prepare datasets
const datasets = [
{
label: "Bancă (curent)",
data: props.bancaSparklineData,
borderColor: "#3b82f6",
backgroundColor: "rgba(59, 130, 246, 0.1)",
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "#3b82f6",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
},
];
// Add previous year dataset if available
if (
props.bancaPreviousSparklineData &&
props.bancaPreviousSparklineData.length > 0
) {
datasets.push({
label: "Bancă (anul precedent)",
data: props.bancaPreviousSparklineData,
borderColor: "rgba(59, 130, 246, 0.4)",
backgroundColor: "rgba(59, 130, 246, 0.05)",
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: "rgba(59, 130, 246, 0.4)",
pointHoverBorderColor: "#ffffff",
pointHoverBorderWidth: 2,
});
}
// Calculate limits including both datasets
const allDataPoints = [...props.bancaSparklineData];
if (
props.bancaPreviousSparklineData &&
props.bancaPreviousSparklineData.length > 0
) {
allDataPoints.push(...props.bancaPreviousSparklineData);
}
const dataMin = Math.min(...allDataPoints);
const dataMax = Math.max(...allDataPoints);
const dataRange = dataMax - dataMin;
const dataPadding = dataRange * 0.05;
bancaChartInstance = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
legend: {
display: datasets.length > 1,
position: "top",
align: "end",
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
color: "rgba(107, 114, 128, 0.9)",
usePointStyle: true,
pointStyle: "line",
},
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
titleColor: "#ffffff",
bodyColor: "#ffffff",
borderColor: "rgba(255, 255, 255, 0.2)",
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || "",
label: (context) => {
const value = context.parsed.y;
const label = context.dataset.label || "";
const formattedValue = new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
return `${label}: ${formattedValue}`;
},
},
},
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false,
},
ticks: {
color: "rgba(107, 114, 128, 0.7)",
font: {
size: 10,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6,
},
border: {
display: false,
},
},
y: {
display: true,
min: dataMin - dataPadding,
max: dataMax + dataPadding,
grid: {
color: "rgba(107, 114, 128, 0.1)",
drawBorder: false,
},
ticks: {
color: "#3b82f6",
font: {
size: 11,
family: "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
},
maxTicksLimit: 3,
callback: function (value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + "M";
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + "k";
}
return value.toFixed(0);
},
},
border: {
display: false,
},
},
},
},
});
};
// Watch for data changes
watch(
() => [
props.casaSparklineData,
props.bancaSparklineData,
props.sparklineLabels,
props.casaPreviousSparklineData,
props.bancaPreviousSparklineData,
props.previousSparklineLabels,
],
async () => {
await Promise.all([initializeCasaChart(), initializeBancaChart()]);
},
{ deep: true },
);
// Lifecycle hooks
onMounted(async () => {
await Promise.all([initializeCasaChart(), initializeBancaChart()]);
});
onBeforeUnmount(() => {
if (casaChartInstance) {
casaChartInstance.destroy();
casaChartInstance = null;
}
if (bancaChartInstance) {
bancaChartInstance.destroy();
bancaChartInstance = null;
}
});
</script>
<style scoped>
/* Component-specific: Dual-layout for TreasuryDualCard (Casa | Bancă) */
/* Override min-height for dual chart layout */
.treasury-dual-card {
min-height: 420px;
}
/* Split layout: Casa | Divider | Bancă */
.values-section {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 1rem;
align-items: start;
}
.value-block {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.divider {
width: 1px;
height: 100%;
background: var(--color-border);
min-height: 60px;
}
/* Dual sparkline container (unique to this card) */
.sparkline-dual-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0.5rem 0;
}
.sparkline-wrapper {
width: 100%;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.5rem;
}
.sparkline-chart {
width: 100%;
height: 150px;
position: relative;
}
/* Chart.js canvas sizing (required for proper rendering) */
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Component-specific: Account number display in breakdown */
.breakdown-cont {
font-size: 0.8125rem;
opacity: 0.7;
margin-left: 0.25rem;
}
/* Responsive: Stack vertically on mobile */
@media (max-width: 768px) {
.treasury-dual-card {
min-height: 380px;
}
.values-section {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.divider {
width: 100%;
height: 1px;
min-height: 1px;
}
.sparkline-chart {
height: 130px;
}
}
@media (max-width: 480px) {
.treasury-dual-card {
min-height: 340px;
}
.sparkline-chart {
height: 120px;
}
.sparkline-wrapper {
padding: 0;
border: none;
}
.values-section {
gap: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,320 @@
<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: Period + Company + User -->
<div class="header-actions">
<PeriodSelectorMini
v-if="selectedCompany"
@period-changed="onPeriodChanged"
/>
<CompanySelectorMini
v-model="selectedCompany"
@company-changed="onCompanyChanged"
/>
<div class="user-menu-container mobile-hide">
<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 PeriodSelectorMini from "../dashboard/PeriodSelectorMini.vue";
import { useCompanyStore } from "../../stores/companies";
import { useAuthStore } from "../../stores/auth";
export default {
name: "DashboardHeader",
components: {
CompanySelectorMini,
PeriodSelectorMini,
},
props: {
menuOpen: {
type: Boolean,
default: false,
},
},
emits: ["menu-toggle", "company-changed", "period-changed"],
setup(props, { emit }) {
const router = useRouter();
const companiesStore = useCompanyStore();
const authStore = useAuthStore();
const userMenuOpen = ref(false);
const selectedCompany = computed({
get: () => companiesStore.selectedCompany,
set: (value) => companiesStore.setSelectedCompany(value),
});
const currentUser = computed(() => authStore.currentUser);
const toggleMenu = () => {
emit("menu-toggle");
};
const toggleUserMenu = () => {
userMenuOpen.value = !userMenuOpen.value;
};
const closeUserMenu = () => {
userMenuOpen.value = false;
};
const onCompanyChanged = (company) => {
emit("company-changed", company);
};
const onPeriodChanged = (period) => {
emit("period-changed", period);
};
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 {
userMenuOpen,
selectedCompany,
currentUser,
toggleMenu,
toggleUserMenu,
closeUserMenu,
onCompanyChanged,
onPeriodChanged,
navigateToTelegram,
handleLogout,
};
},
};
</script>
<style scoped>
/* Hamburger Button */
.hamburger-btn {
display: flex;
flex-direction: column;
justify-content: space-around;
width: 32px;
height: 32px;
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
z-index: 10;
transition: all 0.3s ease;
}
.hamburger-btn:hover {
opacity: 0.7;
}
.hamburger-line {
width: 100%;
height: 3px;
background-color: var(--color-primary, #4361ee);
border-radius: 2px;
transition: all 0.3s ease;
}
.hamburger-btn.active .hamburger-line:nth-child(1) {
transform: translateY(9px) rotate(45deg);
}
.hamburger-btn.active .hamburger-line:nth-child(2) {
opacity: 0;
}
.hamburger-btn.active .hamburger-line:nth-child(3) {
transform: translateY(-9px) rotate(-45deg);
}
/* 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);
}
/* Hide profile menu on mobile - use hamburger menu instead */
.mobile-hide {
display: none !important;
}
}
</style>

View File

@@ -0,0 +1,152 @@
<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">Navigare</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"></i>
<span>Facturi</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>Casa și Banca</span>
</router-link>
</li>
<li class="menu-item">
<router-link
to="/trial-balance"
class="menu-link"
:class="{ active: $route.name === 'TrialBalance' }"
@click="closeMenu"
>
<i class="menu-icon pi pi-calculator"></i>
<span>Balanță de Verificare</span>
</router-link>
</li>
</ul>
</div>
<!-- System Section -->
<div class="menu-section">
<h3 class="menu-title">Sistem</h3>
<ul class="menu-list">
<li class="menu-item">
<router-link
to="/cache-stats"
class="menu-link"
:class="{ active: $route.name === 'CacheStats' }"
@click="closeMenu"
>
<i class="menu-icon pi pi-chart-bar"></i>
<span>Statistici cache</span>
</router-link>
</li>
</ul>
</div>
<!-- Profile Section (at bottom) -->
<div class="menu-section menu-profile">
<div class="profile-info">
<i class="pi pi-user"></i>
<span>{{ currentUser?.username || 'Utilizator' }}</span>
</div>
<ul class="menu-list">
<li class="menu-item">
<router-link
to="/telegram"
class="menu-link"
:class="{ active: $route.name === 'Telegram' }"
@click="closeMenu"
>
<i class="menu-icon pi pi-telegram"></i>
<span>Telegram Bot</span>
</router-link>
</li>
<li class="menu-item">
<a href="#" class="menu-link" @click.prevent="handleLogout">
<i class="menu-icon pi pi-sign-out"></i>
<span>Deconectare</span>
</a>
</li>
</ul>
</div>
</nav>
</div>
</template>
<script>
import { computed } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../../stores/auth";
export default {
name: "HamburgerMenu",
props: {
isOpen: {
type: Boolean,
default: false,
},
},
emits: ["close"],
setup(props, { emit }) {
const router = useRouter();
const authStore = useAuthStore();
const currentUser = computed(() => authStore.currentUser);
const closeMenu = () => {
emit("close");
};
const handleLogout = async () => {
try {
authStore.logout();
closeMenu();
await router.push("/login");
} catch (error) {
console.error("Logout error:", error);
}
};
return {
currentUser,
closeMenu,
handleLogout,
};
},
};
</script>

View File

@@ -0,0 +1,32 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api/reports',
headers: { 'Content-Type': 'application/json' }
})
// Request interceptor for auth token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expired or invalid - redirect to login
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api

View File

@@ -0,0 +1,159 @@
/**
* Pinia Store pentru Cache Management
*/
import { defineStore } from "pinia";
import api from "@reports/services/api";
export const useCacheStore = defineStore("cache", {
state: () => ({
stats: null,
loading: false,
error: null,
}),
getters: {
isLoading: (state) => state.loading,
hasError: (state) => state.error !== null,
cacheEnabled: (state) => state.stats?.enabled ?? false,
hitRate: (state) => state.stats?.hit_rate ?? 0,
queriesSaved: (state) =>
state.stats?.queries_saved ?? { today: 0, week: 0, total: 0 },
responseTimes: (state) => state.stats?.response_times ?? {},
cacheSize: (state) => state.stats?.cache_size ?? { memory: 0, sqlite: 0 },
},
actions: {
/**
* Get cache statistics
*/
async getStats() {
this.loading = true;
this.error = null;
try {
const response = await api.get("/cache/stats");
this.stats = response.data;
return response.data;
} catch (error) {
this.error = error.response?.data?.detail || error.message;
throw error;
} finally {
this.loading = false;
}
},
/**
* Invalidate cache
* @param {number|null} companyId - Optional company ID to invalidate
* @param {string|null} cacheType - Optional cache type to invalidate
*/
async invalidateCache(companyId = null, cacheType = null) {
this.loading = true;
this.error = null;
try {
const response = await api.post("/cache/invalidate", {
company_id: companyId,
cache_type: cacheType,
});
return response.data;
} catch (error) {
this.error = error.response?.data?.detail || error.message;
throw error;
} finally {
this.loading = false;
}
},
/**
* Toggle user cache setting
* @param {boolean} enabled - Enable or disable cache for current user
*/
async toggleUserCache(enabled) {
this.loading = true;
this.error = null;
try {
const response = await api.post("/cache/toggle-user", {
enabled,
});
// Update local stats
if (this.stats) {
this.stats.user_enabled = enabled;
}
return response.data;
} catch (error) {
this.error = error.response?.data?.detail || error.message;
throw error;
} finally {
this.loading = false;
}
},
/**
* Toggle global cache (admin only)
* @param {boolean} enabled - Enable or disable cache globally
*/
async toggleGlobalCache(enabled) {
this.loading = true;
this.error = null;
try {
const response = await api.post("/cache/toggle-global", {
enabled,
});
// Update local stats
if (this.stats) {
this.stats.global_enabled = enabled;
this.stats.enabled = enabled;
}
return response.data;
} catch (error) {
this.error = error.response?.data?.detail || error.message;
throw error;
} finally {
this.loading = false;
}
},
/**
* Toggle auto-invalidation monitoring
* @param {boolean} enabled - Enable or disable auto-invalidation
*/
async toggleAutoInvalidate(enabled) {
this.loading = true;
this.error = null;
try {
const response = await api.post(
"/cache/toggle-auto-invalidate",
{ enabled },
);
// Update local stats
if (this.stats) {
this.stats.auto_invalidate = enabled;
}
return response.data;
} catch (error) {
this.error = error.response?.data?.detail || error.message;
throw error;
} finally {
this.loading = false;
}
},
/**
* Clear error state
*/
clearError() {
this.error = null;
},
},
});

View File

@@ -0,0 +1,520 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import api from "@reports/services/api";
export const useDashboardStore = defineStore("dashboard", () => {
// State existent
const summary = ref(null);
const trends = ref(null);
const isLoading = ref(false);
const error = ref(null);
// State nou pentru carduri
const performanceData = ref({});
const cashflowData = ref({});
const maturityData = ref({});
const currentPeriod = ref(null);
// State pentru detailed data pagination
const detailedDataTotal = ref(0);
// Cache pentru date
const dataCache = new Map();
const loadDashboardSummary = async (companyId, luna = null, an = null) => {
isLoading.value = true;
error.value = null;
try {
const params = { company: companyId };
if (luna !== null) params.luna = luna;
if (an !== null) params.an = an;
const response = await api.get("/dashboard/summary", { params });
summary.value = response.data;
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load dashboard";
console.error("Failed to load dashboard:", err);
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const loadTrendData = async (
companyId,
period = "12m",
chartType = "line",
luna = null,
an = null,
) => {
isLoading.value = true;
error.value = null;
try {
console.log(
`Loading trend data for company ${companyId}, period: ${period}, luna: ${luna}, an: ${an}`,
);
const params = {
company: companyId,
period: period,
};
if (luna !== null) params.luna = luna;
if (an !== null) params.an = an;
const response = await api.get("/dashboard/trends", { params });
// Validate response structure
if (!response.data) {
throw new Error("Empty response from trends API");
}
console.log("Raw trends response:", response.data);
// Transform backend response to Chart.js format
const backendData = response.data;
const transformedData = transformTrendsData(backendData);
if (!transformedData) {
throw new Error("Failed to transform trends data - invalid format");
}
trends.value = transformedData;
console.log("Transformed trends data:", transformedData);
return { success: true, data: transformedData };
} catch (err) {
const errorMessage =
err.response?.data?.detail ||
err.message ||
"Failed to load trend data";
error.value = errorMessage;
console.error("Failed to load trend data:", err);
console.error("Error details:", {
status: err.response?.status,
statusText: err.response?.statusText,
data: err.response?.data,
});
// Clear trends data and return error - no more mock data
trends.value = null;
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
// Transform backend trends data to Chart.js format AND preserve raw data
const transformTrendsData = (backendData) => {
if (
!backendData ||
!backendData.periods ||
!Array.isArray(backendData.periods) ||
backendData.periods.length === 0
) {
console.warn("Invalid trends data received:", backendData);
return null;
}
// Validate that we have all required data
const requiredFields = [
"trezorerie_sold",
"clienti_sold",
"furnizori_sold",
"clienti_incasat",
"furnizori_achitat",
];
for (const field of requiredFields) {
if (!backendData[field] || !Array.isArray(backendData[field])) {
console.warn(`Missing ${field} data`);
return null;
}
}
// Data is already in ASC order from backend
const periods = [...backendData.periods];
// Format labels for monthly data (YYYY-MM -> MM/YYYY)
const formattedPeriods = periods.map((period) => {
const [year, month] = period.split("-");
const date = new Date(year, month - 1);
return date.toLocaleDateString("ro-RO", {
month: "2-digit",
year: "numeric",
});
});
// Preserve all raw data from backend for card calculations
return {
labels: formattedPeriods,
raw: {
// Current period data
periods: backendData.periods,
clienti_facturat: backendData.clienti_facturat || [],
clienti_incasat: backendData.clienti_incasat || [],
clienti_sold: backendData.clienti_sold || [],
furnizori_facturat: backendData.furnizori_facturat || [],
furnizori_achitat: backendData.furnizori_achitat || [],
furnizori_sold: backendData.furnizori_sold || [],
trezorerie_sold: backendData.trezorerie_sold || [],
// Previous period data (year-over-year comparison)
previous_periods: backendData.previous_periods || [],
clienti_facturat_prev: backendData.clienti_facturat_prev || [],
clienti_incasat_prev: backendData.clienti_incasat_prev || [],
clienti_sold_prev: backendData.clienti_sold_prev || [],
furnizori_facturat_prev: backendData.furnizori_facturat_prev || [],
furnizori_achitat_prev: backendData.furnizori_achitat_prev || [],
furnizori_sold_prev: backendData.furnizori_sold_prev || [],
trezorerie_sold_prev: backendData.trezorerie_sold_prev || [],
},
datasets: [
{
label: "Trezorerie - Sold Net",
data: [...backendData.trezorerie_sold].map((val) => Number(val) || 0),
borderColor: "rgb(59, 130, 246)",
backgroundColor: "rgba(59, 130, 246, 0.1)",
tension: 0.4,
fill: false,
pointBackgroundColor: "rgb(59, 130, 246)",
pointBorderColor: "#ffffff",
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
},
],
};
};
const loadDetailedData = async (
dataType,
companyId,
page = 1,
pageSize = 25,
search = "",
luna = null,
an = null,
) => {
isLoading.value = true;
error.value = null;
try {
const params = {
company: companyId,
data_type: dataType,
page: page,
page_size: pageSize,
search: search,
};
if (luna !== null) params.luna = luna;
if (an !== null) params.an = an;
const response = await api.get("/dashboard/detailed-data", { params });
// Store total for pagination
detailedDataTotal.value = response.data.total || 0;
return {
success: true,
data: response.data.data || [], // Backend returns 'data' not 'items'
total: response.data.total || 0,
page: response.data.page || 1,
};
} catch (err) {
error.value =
err.response?.data?.detail || "Failed to load detailed data";
console.error("Failed to load detailed data:", err);
// Return mock data structure for testing
const mockData = generateMockDetailedData(dataType);
detailedDataTotal.value = mockData.length;
return {
success: false,
error: error.value,
data: mockData,
total: mockData.length,
page: 1,
};
} finally {
isLoading.value = false;
}
};
// Generate mock data for testing until backend endpoint is implemented
const generateMockDetailedData = (dataType) => {
switch (dataType) {
case "clients":
return [
{
id: 1,
client: "SC ALPHA SRL",
facturat: 15000,
incasat: 12000,
sold: 3000,
status: "Activ",
},
{
id: 2,
client: "SC BETA SRL",
facturat: 8500,
incasat: 8500,
sold: 0,
status: "Activ",
},
{
id: 3,
client: "SC GAMMA SRL",
facturat: 22000,
incasat: 15000,
sold: 7000,
status: "Activ",
},
{
id: 4,
client: "SC DELTA SRL",
facturat: 5500,
incasat: 2000,
sold: 3500,
status: "Întârziere",
},
{
id: 5,
client: "SC EPSILON SRL",
facturat: 18000,
incasat: 18000,
sold: 0,
status: "Activ",
},
];
case "suppliers":
return [
{
id: 1,
furnizor: "SC SUPPLIER A SRL",
facturat: 12000,
achitat: 10000,
sold: 2000,
status: "Activ",
},
{
id: 2,
furnizor: "SC SUPPLIER B SRL",
facturat: 7500,
achitat: 7500,
sold: 0,
status: "Activ",
},
{
id: 3,
furnizor: "SC SUPPLIER C SRL",
facturat: 19000,
achitat: 12000,
sold: 7000,
status: "Pendente",
},
{
id: 4,
furnizor: "SC SUPPLIER D SRL",
facturat: 4200,
achitat: 4200,
sold: 0,
status: "Activ",
},
{
id: 5,
furnizor: "SC SUPPLIER E SRL",
facturat: 16800,
achitat: 8000,
sold: 8800,
status: "Pendente",
},
];
case "treasury":
return [
{
id: 1,
cont: "5121",
nume_cont: "Cont curent BCR",
sold: 45000,
valuta: "RON",
tip: "Bancă",
},
{
id: 2,
cont: "5311",
nume_cont: "Casa RON",
sold: 2500,
valuta: "RON",
tip: "Numerar",
},
{
id: 3,
cont: "5124",
nume_cont: "Cont curent BRD EUR",
sold: 8500,
valuta: "EUR",
tip: "Bancă",
},
{
id: 4,
cont: "5125",
nume_cont: "Cont economii ING",
sold: 125000,
valuta: "RON",
tip: "Economii",
},
{
id: 5,
cont: "5312",
nume_cont: "Casa valută",
sold: 500,
valuta: "EUR",
tip: "Numerar",
},
];
default:
return [];
}
};
// Funcții noi pentru carduri
const loadPerformanceData = async (companyId, period = "7d") => {
const cacheKey = `performance-${companyId}-${period}`;
// Check cache
if (dataCache.has(cacheKey)) {
performanceData.value[period] = dataCache.get(cacheKey);
return { success: true, data: dataCache.get(cacheKey) };
}
try {
const response = await api.get("/dashboard/performance", {
params: { company: companyId, period },
});
performanceData.value[period] = response.data;
dataCache.set(cacheKey, response.data);
return { success: true, data: response.data };
} catch (err) {
console.error("Failed to load performance data:", err);
return { success: false, error: err.message };
}
};
const loadCashFlowData = async (companyId, period = "7d") => {
const cacheKey = `cashflow-${companyId}-${period}`;
if (dataCache.has(cacheKey)) {
cashflowData.value[period] = dataCache.get(cacheKey);
return { success: true, data: dataCache.get(cacheKey) };
}
try {
const response = await api.get("/dashboard/cashflow", {
params: { company: companyId, period },
});
cashflowData.value[period] = response.data;
dataCache.set(cacheKey, response.data);
return { success: true, data: response.data };
} catch (err) {
console.error("Failed to load cashflow data:", err);
return { success: false, error: err.message };
}
};
const loadMaturityData = async (companyId, period = "7d", luna = null, an = null) => {
const cacheKey = `maturity-${companyId}-${period}-${luna}-${an}`;
if (dataCache.has(cacheKey)) {
maturityData.value[period] = dataCache.get(cacheKey);
return { success: true, data: dataCache.get(cacheKey) };
}
try {
const params = { company: companyId, period };
if (luna !== null) params.luna = luna;
if (an !== null) params.an = an;
const response = await api.get("/dashboard/maturity", { params });
maturityData.value[period] = response.data;
dataCache.set(cacheKey, response.data);
return { success: true, data: response.data };
} catch (err) {
console.error("Failed to load maturity data:", err);
return { success: false, error: err.message };
}
};
const loadCurrentPeriod = async (companyId) => {
try {
const response = await api.get("/dashboard/current-period", {
params: { company: companyId },
});
currentPeriod.value = response.data;
return { success: true, data: response.data };
} catch (err) {
console.error("Failed to load current period:", err);
// Fallback to current date if API fails
const now = new Date();
const fallbackPeriod = {
year: now.getFullYear(),
month: now.getMonth() + 1,
period: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`,
};
currentPeriod.value = fallbackPeriod;
return { success: false, error: err.message, data: fallbackPeriod };
}
};
// Clear cache
const clearCache = () => {
dataCache.clear();
};
const reset = () => {
summary.value = null;
trends.value = null;
isLoading.value = false;
error.value = null;
// Clear new data as well
performanceData.value = {};
cashflowData.value = {};
maturityData.value = {};
currentPeriod.value = null;
clearCache();
};
return {
// Existing
summary,
trends,
isLoading,
error,
loadDashboardSummary,
loadTrendData,
loadDetailedData,
reset,
// New
performanceData,
cashflowData,
maturityData,
currentPeriod,
loadPerformanceData,
loadCashFlowData,
loadMaturityData,
loadCurrentPeriod,
clearCache,
// Detailed data pagination
detailedDataTotal,
};
});

View File

@@ -0,0 +1,5 @@
export { useAuthStore } from "./auth";
export { useCompanyStore } from "./companies";
export { useInvoicesStore } from "./invoices";
export { useDashboardStore } from "./dashboard";
export { useTreasuryStore } from "./treasury";

View File

@@ -0,0 +1,202 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import api from "@reports/services/api";
export const useInvoicesStore = defineStore("invoices", () => {
// State
const invoices = ref([]);
const isLoading = ref(false);
const error = ref(null);
const accountingPeriod = ref({ an: null, luna: null });
// Total sold din TOATE facturile filtrate (nu doar pagina curentă)
const totalSoldAll = ref(0);
const filters = ref({
company: null,
type: "CLIENTI", // CLIENTI or FURNIZORI
dateFrom: null,
dateTo: null,
searchTerm: "",
});
const pagination = ref({
page: 1,
rows: 50,
totalRecords: 0,
});
// Getters
const invoiceList = computed(() => invoices.value);
const hasInvoices = computed(() => invoices.value.length > 0);
const totalInvoices = computed(() => pagination.value.totalRecords);
const paidInvoices = computed(() =>
invoices.value.filter((invoice) => invoice.css_class === "invoice-paid"),
);
const overdueInvoices = computed(() =>
invoices.value.filter((invoice) => invoice.css_class === "invoice-overdue"),
);
const totalAmountPaid = computed(() =>
paidInvoices.value.reduce((sum, invoice) => sum + (invoice.suma || 0), 0),
);
const totalAmountOverdue = computed(() =>
overdueInvoices.value.reduce(
(sum, invoice) => sum + (invoice.suma || 0),
0,
),
);
// Actions
const loadInvoices = async (companyCode, options = {}) => {
if (!companyCode) {
error.value = "Company code is required";
return { success: false, error: error.value };
}
isLoading.value = true;
error.value = null;
try {
const params = {
partner_type: filters.value.type,
page: pagination.value.page,
page_size: pagination.value.rows,
...options,
};
if (filters.value.dateFrom) {
// Convert Date object to YYYY-MM-DD string format (LOCAL date, not UTC)
if (filters.value.dateFrom instanceof Date) {
const year = filters.value.dateFrom.getFullYear();
const month = String(filters.value.dateFrom.getMonth() + 1).padStart(
2,
"0",
);
const day = String(filters.value.dateFrom.getDate()).padStart(2, "0");
params.date_from = `${year}-${month}-${day}`;
} else {
params.date_from = filters.value.dateFrom;
}
}
if (filters.value.dateTo) {
// Convert Date object to YYYY-MM-DD string format (LOCAL date, not UTC)
if (filters.value.dateTo instanceof Date) {
const year = filters.value.dateTo.getFullYear();
const month = String(filters.value.dateTo.getMonth() + 1).padStart(
2,
"0",
);
const day = String(filters.value.dateTo.getDate()).padStart(2, "0");
params.date_to = `${year}-${month}-${day}`;
} else {
params.date_to = filters.value.dateTo;
}
}
if (filters.value.searchTerm) {
params.search = filters.value.searchTerm;
}
// Fixed: Use company as query parameter instead of path parameter
const response = await api.get(`/invoices/`, {
params: {
company: companyCode,
...params,
},
});
invoices.value = response.data.invoices || [];
pagination.value.totalRecords = response.data.total_count || 0;
// Store total sold from ALL filtered invoices (not just current page)
totalSoldAll.value = response.data.total_sold_all || 0;
// Store accounting period if available
if (response.data.accounting_period) {
accountingPeriod.value = response.data.accounting_period;
}
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load invoices";
console.error("Failed to load invoices:", err);
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const setFilters = (newFilters) => {
filters.value = { ...filters.value, ...newFilters };
};
const setPagination = (newPagination) => {
pagination.value = { ...pagination.value, ...newPagination };
};
const setInvoiceType = (type) => {
filters.value.type = type;
};
const clearFilters = () => {
filters.value = {
company: null,
type: "CLIENTI",
dateFrom: null,
dateTo: null,
searchTerm: "",
};
};
const clearError = () => {
error.value = null;
};
const reset = () => {
invoices.value = [];
isLoading.value = false;
error.value = null;
accountingPeriod.value = { an: null, luna: null };
totalSoldAll.value = 0;
clearFilters();
pagination.value = {
page: 1,
rows: 50,
totalRecords: 0,
};
};
const getInvoiceById = (id) => {
return invoices.value.find((invoice) => invoice.id === id);
};
return {
// State
invoices,
isLoading,
error,
accountingPeriod,
totalSoldAll,
filters,
pagination,
// Getters
invoiceList,
hasInvoices,
totalInvoices,
paidInvoices,
overdueInvoices,
totalAmountPaid,
totalAmountOverdue,
// Actions
loadInvoices,
setFilters,
setPagination,
setInvoiceType,
clearFilters,
clearError,
reset,
getInvoiceById,
};
});

View File

@@ -0,0 +1,20 @@
/**
* Reports Module - Shared Store Instances
*
* This file instantiates the shared stores (auth, companies, accountingPeriod)
* with the Reports module's API service.
*/
import { createAuthStore } from '@shared/stores/auth'
import { createCompaniesStore } from '@shared/stores/companies'
import { createAccountingPeriodStore } from '@shared/stores/accountingPeriod'
import api from '@reports/services/api'
// Create auth store
export const useAuthStore = createAuthStore(api)
// Create companies store (needs auth store reference)
export const useCompanyStore = createCompaniesStore(api, useAuthStore)
// Create accounting period store
export const useAccountingPeriodStore = createAccountingPeriodStore(api)

View File

@@ -0,0 +1,95 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import api from "@reports/services/api";
export const useTreasuryStore = defineStore("treasury", () => {
const registers = ref([]);
const isLoading = ref(false);
const error = ref(null);
const pagination = ref({
page: 0,
rows: 50,
totalRecords: 0,
});
const totals = ref({
total_incasari: 0,
total_plati: 0,
// Totaluri din TOATE înregistrările filtrate (nu doar pagina curentă)
sold_precedent_all: 0,
total_incasari_all: 0,
total_plati_all: 0,
sold_final_all: 0,
});
const accountingPeriod = ref({ an: null, luna: null });
const loadBankCashRegister = async (companyId, filters = {}) => {
isLoading.value = true;
error.value = null;
try {
const params = {
company: companyId,
page: pagination.value.page + 1,
page_size: pagination.value.rows,
...filters,
};
const response = await api.get("/treasury/bank-cash-register", {
params,
});
registers.value = response.data.registers || [];
pagination.value.totalRecords = response.data.total_count || 0;
totals.value = {
total_incasari: response.data.total_incasari,
total_plati: response.data.total_plati,
// Totaluri din TOATE înregistrările filtrate (nu doar pagina curentă)
sold_precedent_all: response.data.sold_precedent_all || 0,
total_incasari_all: response.data.total_incasari_all || 0,
total_plati_all: response.data.total_plati_all || 0,
sold_final_all: response.data.sold_final_all || 0,
};
// Store accounting period if available
if (response.data.accounting_period) {
accountingPeriod.value = response.data.accounting_period;
}
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load register";
console.error("Failed to load register:", err);
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const setPagination = (newPagination) => {
pagination.value = { ...pagination.value, ...newPagination };
};
const reset = () => {
registers.value = [];
isLoading.value = false;
error.value = null;
accountingPeriod.value = { an: null, luna: null };
pagination.value = {
page: 0,
rows: 50,
totalRecords: 0,
};
};
return {
registers,
isLoading,
error,
pagination,
totals,
accountingPeriod,
loadBankCashRegister,
setPagination,
reset,
};
});

View File

@@ -0,0 +1,215 @@
/**
* Pinia Store for Trial Balance (Balanță de Verificare)
*/
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import api from "@reports/services/api";
export const useTrialBalanceStore = defineStore("trialBalance", () => {
// State
const trialBalanceData = ref([]);
const isLoading = ref(false);
const error = ref(null);
// Totaluri din TOATE înregistrările filtrate (nu doar pagina curentă)
const totals = ref({
total_sold_precedent_debit: 0,
total_sold_precedent_credit: 0,
total_rulaj_lunar_debit: 0,
total_rulaj_lunar_credit: 0,
total_sold_final_debit: 0,
total_sold_final_credit: 0,
});
const filters = ref({
luna: new Date().getMonth() + 1, // Current month (1-12)
an: new Date().getFullYear(), // Current year
cont: "",
denumire: "",
});
const pagination = ref({
currentPage: 1,
pageSize: 50,
totalItems: 0,
totalPages: 0,
});
const sorting = ref({
sortBy: "CONT",
sortOrder: "asc",
});
// Getters
const hasData = computed(() => trialBalanceData.value.length > 0);
const currentPeriod = computed(() => {
return {
luna: filters.value.luna,
an: filters.value.an,
};
});
// Actions
const fetchTrialBalance = async (companyCode) => {
if (!companyCode) {
error.value = "Company code is required";
return { success: false, error: error.value };
}
isLoading.value = true;
error.value = null;
try {
const params = {
company: companyCode,
luna: filters.value.luna,
an: filters.value.an,
page: pagination.value.currentPage,
page_size: pagination.value.pageSize,
sort_by: sorting.value.sortBy,
sort_order: sorting.value.sortOrder,
};
// Add optional filters
if (filters.value.cont) {
params.cont_filter = filters.value.cont;
}
if (filters.value.denumire) {
params.denumire_filter = filters.value.denumire;
}
const response = await api.get("/trial-balance/", { params });
if (response.data.success) {
trialBalanceData.value = response.data.data.items || [];
// Update pagination
const paginationData = response.data.data.pagination;
pagination.value = {
currentPage: paginationData.current_page,
pageSize: paginationData.page_size,
totalItems: paginationData.total_items,
totalPages: paginationData.total_pages,
};
// Store totals from ALL filtered records (not just current page)
if (response.data.data.totals) {
totals.value = response.data.data.totals;
}
return { success: true };
} else {
throw new Error("Invalid response format");
}
} catch (err) {
error.value =
err.response?.data?.detail || "Failed to load trial balance data";
console.error("Failed to load trial balance:", err);
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const applyFilters = async (newFilters, companyCode) => {
filters.value = { ...filters.value, ...newFilters };
pagination.value.currentPage = 1; // Reset to first page when filtering
await fetchTrialBalance(companyCode);
};
const clearFilters = async (companyCode) => {
filters.value = {
luna: new Date().getMonth() + 1,
an: new Date().getFullYear(),
cont: "",
denumire: "",
};
pagination.value.currentPage = 1;
await fetchTrialBalance(companyCode);
};
const changePage = async (page, companyCode) => {
pagination.value.currentPage = page;
await fetchTrialBalance(companyCode);
};
const changePageSize = async (pageSize, companyCode) => {
pagination.value.pageSize = pageSize;
pagination.value.currentPage = 1; // Reset to first page
await fetchTrialBalance(companyCode);
};
const sort = async (sortBy, sortOrder, companyCode) => {
sorting.value = { sortBy, sortOrder };
pagination.value.currentPage = 1; // Reset to first page when sorting
await fetchTrialBalance(companyCode);
};
const changePeriod = async (luna, an, companyCode) => {
filters.value.luna = luna;
filters.value.an = an;
pagination.value.currentPage = 1;
await fetchTrialBalance(companyCode);
};
const clearError = () => {
error.value = null;
};
const reset = () => {
trialBalanceData.value = [];
isLoading.value = false;
error.value = null;
totals.value = {
total_sold_precedent_debit: 0,
total_sold_precedent_credit: 0,
total_rulaj_lunar_debit: 0,
total_rulaj_lunar_credit: 0,
total_sold_final_debit: 0,
total_sold_final_credit: 0,
};
filters.value = {
luna: new Date().getMonth() + 1,
an: new Date().getFullYear(),
cont: "",
denumire: "",
};
pagination.value = {
currentPage: 1,
pageSize: 50,
totalItems: 0,
totalPages: 0,
};
sorting.value = {
sortBy: "CONT",
sortOrder: "asc",
};
};
return {
// State
trialBalanceData,
isLoading,
error,
totals,
filters,
pagination,
sorting,
// Getters
hasData,
currentPeriod,
// Actions
fetchTrialBalance,
applyFilters,
clearFilters,
changePage,
changePageSize,
sort,
changePeriod,
clearError,
reset,
};
});

View File

View File

@@ -0,0 +1,861 @@
import * as XLSX from "xlsx";
import { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
/**
* Format currency values for export
*/
const formatCurrency = (value) => {
if (value == null || value === "-") return "-";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
/**
* Export data to Excel
* @param {Array} data - Array of objects to export
* @param {String} filename - Name of the file (without extension)
* @param {String} sheetName - Name of the Excel sheet
*/
export const exportToExcel = (data, filename, sheetName = "Sheet1") => {
try {
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
XLSX.writeFile(
wb,
`${filename}_${new Date().toISOString().split("T")[0]}.xlsx`,
);
return { success: true };
} catch (error) {
console.error("Excel export failed:", error);
return { success: false, error };
}
};
/**
* Format number for PDF export
*/
const formatNumberForPDF = (value) => {
if (value == null || value === "" || value === "-") return "-";
const num = parseFloat(value);
if (isNaN(num)) return "-";
return new Intl.NumberFormat("ro-RO", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(num);
};
/**
* Export data to PDF
* @param {Array} data - Array of objects to export
* @param {Array} columns - Column definitions [{field: 'key', header: 'Display Name', type: 'text|number|currency', width: 30}]
* @param {String} filename - Name of the file (without extension)
* @param {Object} header - Header configuration {companyName: '', title: '', period: '', subtitle2: '', initialBalances: [], totalInitialBalance: 0}
*/
export const exportToPDF = (data, columns, filename, header) => {
try {
// Check if data exists
if (!data || data.length === 0) {
console.error("No data to export");
return { success: false, error: "No data available" };
}
// Check if jsPDF is properly imported
if (typeof jsPDF === "undefined") {
console.error("jsPDF not properly imported");
return { success: false, error: "PDF library not available" };
}
const doc = new jsPDF("landscape", "mm", "a4");
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const marginLeft = 8;
const marginRight = 8;
const contentWidth = pageWidth - marginLeft - marginRight;
// Check if there are initial balances to display
const hasInitialBalances = header.initialBalances && header.initialBalances.length > 0;
// Function to add header (called for each page)
const addHeader = () => {
// Line 1: Company name (left aligned, bold, larger font)
doc.setFontSize(13);
doc.setFont(undefined, "bold");
const companyName = header.companyName || "N/A";
doc.text(companyName, marginLeft, 15);
// Line 2: Title "Balanta de Verificare" (centered)
doc.setFontSize(14);
doc.setFont(undefined, "bold");
const titleWidth = doc.getTextWidth(header.title || "");
const titleX = marginLeft + (contentWidth - titleWidth) / 2;
doc.text(header.title || "", titleX, 24);
// Line 3: Period (centered, below title)
doc.setFontSize(11);
doc.setFont(undefined, "normal");
const periodText = header.period || "";
const periodWidth = doc.getTextWidth(periodText);
const periodX = marginLeft + (contentWidth - periodWidth) / 2;
doc.text(periodText, periodX, 32);
// Line 4: Subtitle2 - filters (left aligned, below period) - optional
let currentY = 32;
if (header.subtitle2) {
currentY = 39;
doc.setFontSize(10);
doc.setFont(undefined, "normal");
doc.text(header.subtitle2, marginLeft, currentY);
}
// Initial Balances section - rendered just before table, closer to it
// This is handled in didDrawPage for first page only
};
// Prepare table data and track total rows
const tableColumns = columns.map((col) => col.header);
const totalRowIndices = new Set(); // Track which rows are totals
const grandTotalRowIndices = new Set(); // Track grand total rows
const tableRows = data.map((row, rowIndex) => {
// Track total rows for special styling
if (row._isTotal) {
totalRowIndices.add(rowIndex);
}
if (row._isGrandTotal) {
grandTotalRowIndices.add(rowIndex);
}
return columns.map((col) => {
const value = row[col.field];
if (col.type === "currency") {
return formatCurrency(value);
} else if (col.type === "number") {
return formatNumberForPDF(value);
}
return value || "-";
});
});
// Function to add footer (called for each page)
const addFooter = (pageNum, totalPages) => {
const footerY = pageHeight - 10; // 10mm from bottom
// Left side: Generation date
doc.setFontSize(8);
doc.setFont(undefined, "normal");
doc.text(
`Generat: ${new Date().toLocaleString("ro-RO")}`,
marginLeft,
footerY,
);
// Right side: Page numbers
const pageText = `Pagina ${pageNum} din ${totalPages}`;
const pageTextWidth = doc.getTextWidth(pageText);
doc.text(pageText, pageWidth - marginRight - pageTextWidth, footerY);
};
// Check if autoTable is available
if (typeof autoTable === "function") {
// Build column styles - jspdf-autotable uses numeric keys
const columnStyles = {};
// Calculate optimal column widths
// Total usable width: pageWidth - marginLeft - marginRight
const totalWidth = pageWidth - marginLeft - marginRight; // ~281mm for A4 landscape
// Define width allocation (proportional) - support custom widths from columns
const widthAllocations = {};
columns.forEach((col, index) => {
// Use custom width if provided, otherwise auto
if (col.width && typeof col.width === "number") {
widthAllocations[index] = totalWidth * col.width;
} else if (col.width === "auto") {
widthAllocations[index] = "auto";
} else {
// Default width allocation for Trial Balance (8 columns)
const defaultWidths = {
0: totalWidth * 0.07, // Cont: ~20mm
1: totalWidth * 0.33, // Denumire: ~93mm
2: totalWidth * 0.1, // Sume Prec D: ~28mm
3: totalWidth * 0.1, // Sume Prec C: ~28mm
4: totalWidth * 0.1, // Rulaj D: ~28mm
5: totalWidth * 0.1, // Rulaj C: ~28mm
6: totalWidth * 0.1, // Sold Final D: ~28mm
7: totalWidth * 0.1, // Sold Final C: ~28mm
};
widthAllocations[index] = defaultWidths[index] || "auto";
}
});
columns.forEach((col, index) => {
columnStyles[index] = {
cellWidth: widthAllocations[index],
};
// Set alignment based on type
if (col.type === "number" || col.type === "currency") {
columnStyles[index].halign = "right";
} else if (col.type === "text") {
// All text columns aligned left (including Cont)
columnStyles[index].halign = "left";
}
});
// Start table lower based on header content
let tableStartY = 36;
if (header.subtitle2) tableStartY = 43;
if (hasInitialBalances) {
// Initial balances rendered close to table (just 3mm above table header)
const balancesCount = header.initialBalances.length;
const hasTotal = balancesCount > 1;
const balancesHeight = (balancesCount * 5) + (hasTotal ? 7 : 0);
// Base position after header content
const baseY = header.subtitle2 ? 43 : 36;
tableStartY = baseY + balancesHeight + 5; // balances + small gap before table
}
// Function to draw initial balances (called only on first page)
const drawInitialBalances = (tableY) => {
if (!hasInitialBalances) return;
const valueRightEdge = pageWidth - marginRight;
const balancesCount = header.initialBalances.length;
const hasTotal = balancesCount > 1;
const balancesHeight = (balancesCount * 5) + (hasTotal ? 7 : 0);
// Start position: just above table header (3mm gap)
let y = tableY - 3 - (hasTotal ? 7 : 0) - (balancesCount * 5);
doc.setFont(undefined, "normal");
doc.setFontSize(9);
// Draw each balance line: "AccountName sold precedent: VALUE"
header.initialBalances.forEach((item) => {
const value = formatNumberForPDF(item.sold);
const valueWidth = doc.getTextWidth(value);
const label = `${item.accountName} sold precedent:`;
doc.text(label, valueRightEdge - valueWidth - doc.getTextWidth(" sold precedent:") - doc.getTextWidth(item.accountName) - 2, y);
doc.text(value, valueRightEdge - valueWidth, y);
y += 5;
});
// Total only if multiple accounts
if (hasTotal) {
// Separator line
doc.setDrawColor(150, 150, 150);
doc.line(valueRightEdge - 40, y - 2, valueRightEdge, y - 2);
// Total line
doc.setFont(undefined, "bold");
const totalValue = formatNumberForPDF(header.totalInitialBalance || 0);
const totalValueWidth = doc.getTextWidth(totalValue);
const totalLabel = "TOTAL sold precedent:";
const totalLabelWidth = doc.getTextWidth(totalLabel);
doc.text(totalLabel, valueRightEdge - totalValueWidth - 3 - totalLabelWidth, y + 2);
doc.text(totalValue, valueRightEdge - totalValueWidth, y + 2);
}
};
let isFirstPage = true;
// Add table using autoTable (call as function, not method)
autoTable(doc, {
head: [tableColumns],
body: tableRows,
startY: tableStartY,
styles: {
fontSize: 9,
cellPadding: 2.5,
valign: "middle",
lineColor: [200, 200, 200],
lineWidth: 0.1,
overflow: "linebreak",
},
headStyles: {
fillColor: [41, 128, 185],
textColor: 255,
fontStyle: "bold",
halign: "center",
fontSize: 9,
cellPadding: 2.5,
},
alternateRowStyles: {
fillColor: [248, 248, 248],
},
columnStyles: columnStyles,
margin: {
left: marginLeft,
right: marginRight,
top: tableStartY,
bottom: 15,
},
tableWidth: pageWidth - marginLeft - marginRight, // Use full page width
theme: "grid",
didDrawPage: function () {
// Add header to each page
addHeader();
// Draw initial balances only on first page
if (isFirstPage && hasInitialBalances) {
drawInitialBalances(tableStartY);
isFirstPage = false;
}
},
didParseCell: function (data) {
// Force alignment based on column type (body cells only)
if (data.section === "body") {
const rowIndex = data.row.index;
const colIndex = data.column.index;
const column = columns[colIndex];
// Style grand total rows (bold, darker gray background)
if (grandTotalRowIndices.has(rowIndex)) {
data.cell.styles.fontStyle = "bold";
data.cell.styles.fillColor = [200, 200, 200]; // Darker gray
data.cell.styles.fontSize = 10;
}
// Style class total rows (bold, light gray background)
else if (totalRowIndices.has(rowIndex)) {
data.cell.styles.fontStyle = "bold";
data.cell.styles.fillColor = [235, 235, 235]; // Light gray
}
if (column) {
if (column.type === "number" || column.type === "currency") {
data.cell.styles.halign = "right";
} else if (column.type === "text") {
if (colIndex === 0) {
data.cell.styles.halign = "center";
} else {
data.cell.styles.halign = "left";
}
}
}
}
},
willDrawCell: function (data) {
// Draw double line above grand total row
if (data.section === "body" && grandTotalRowIndices.has(data.row.index)) {
const doc = data.doc;
doc.setDrawColor(100, 100, 100);
doc.setLineWidth(0.5);
doc.line(data.cell.x, data.cell.y, data.cell.x + data.cell.width, data.cell.y);
}
},
});
// Add footer to all pages AFTER table generation
const totalPages = doc.internal.getNumberOfPages();
for (let i = 1; i <= totalPages; i++) {
doc.setPage(i);
addFooter(i, totalPages);
}
} else {
// Fallback mode (autoTable NOT available)
// Add header on first page
addHeader();
// Fallback: manual table creation
let yPos = 45;
// Draw headers
doc.setFontSize(8);
doc.setFont(undefined, "bold");
tableColumns.forEach((header, index) => {
doc.text(header, 14 + index * 35, yPos);
});
// Draw rows
doc.setFont(undefined, "normal");
doc.setFontSize(7);
tableRows.forEach((row, rowIndex) => {
yPos += 7;
row.forEach((cell, cellIndex) => {
doc.text(String(cell), 14 + cellIndex * 35, yPos);
});
});
// Add footer in fallback mode
addFooter(1, 1);
}
// Save PDF
doc.save(`${filename}_${new Date().toISOString().split("T")[0]}.pdf`);
return { success: true };
} catch (error) {
console.error("PDF export error details:", error);
return { success: false, error: error.message || "PDF generation failed" };
}
};
/**
* Export General Totals table
*/
export const exportGeneralTotals = (summaryData) => {
const data = [
{
Tip: "Clienți",
"Total Facturat": summaryData?.clienti_total_facturat || 0,
"Total Încasat": summaryData?.clienti_total_incasat || 0,
"Sold Net": summaryData?.clienti_sold_total || 0,
"Sold În Termen": summaryData?.clienti_sold_in_termen || 0,
"Sold Restant": summaryData?.clienti_sold_restant || 0,
},
{
Tip: "Furnizori",
"Total Facturat": summaryData?.furnizori_total_facturat || 0,
"Total Achitat": summaryData?.furnizori_total_achitat || 0,
"Sold Net": summaryData?.furnizori_sold_total || 0,
"Sold În Termen": summaryData?.furnizori_sold_in_termen || 0,
"Sold Restant": summaryData?.furnizori_sold_restant || 0,
},
{
Tip: "Trezorerie",
"Total Facturat": "-",
"Total Încasat/Achitat": "-",
"Sold Net": summaryData?.trezorerie_sold || 0,
"Sold În Termen": "-",
"Sold Restant": "-",
},
];
return data;
};
/**
* Export Sold Net Breakdown table
*/
export const exportSoldNetBreakdown = (summaryData) => {
const data = [
{
Categorie: "Clienți - Restant",
TOTAL: summaryData?.clienti_sold_restant || 0,
"7 zile": summaryData?.clienti_restant_7 || 0,
"14 zile": summaryData?.clienti_restant_14 || 0,
"30 zile": summaryData?.clienti_restant_30 || 0,
"60 zile": summaryData?.clienti_restant_60 || 0,
"90 zile": summaryData?.clienti_restant_90 || 0,
"90+ zile": summaryData?.clienti_restant_over_90 || 0,
},
{
Categorie: "Furnizori - Restant",
TOTAL: summaryData?.furnizori_sold_restant || 0,
"7 zile": summaryData?.furnizori_restant_7 || 0,
"14 zile": summaryData?.furnizori_restant_14 || 0,
"30 zile": summaryData?.furnizori_restant_30 || 0,
"60 zile": summaryData?.furnizori_restant_60 || 0,
"90 zile": summaryData?.furnizori_restant_90 || 0,
"90+ zile": summaryData?.furnizori_restant_over_90 || 0,
},
];
return data;
};
/**
* Export Bank Cash Register to PDF with grouped format
* Matches the Romanian standard format with:
* - Bank name + Sold precedent on same line
* - Daily totals (Total zi)
* - Cumulative totals (Total cumulat)
*
* @param {Array} data - Array of register entries
* @param {Object} header - Header configuration
* @param {String} filename - Output filename
*/
export const exportBankCashRegisterPDF = (data, header, filename) => {
try {
if (!data || data.length === 0) {
console.error("No data to export");
return { success: false, error: "No data available" };
}
const doc = new jsPDF("landscape", "mm", "a4");
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const marginLeft = 8;
const marginRight = 8;
const contentWidth = pageWidth - marginLeft - marginRight;
// Remove diacritics helper
const removeDiacritics = (text) => {
if (!text) return "";
return text
.replace(/[ăâ]/gi, (m) => (m === m.toLowerCase() ? "a" : "A"))
.replace(/[î]/gi, (m) => (m === m.toLowerCase() ? "i" : "I"))
.replace(/[ș]/gi, (m) => (m === m.toLowerCase() ? "s" : "S"))
.replace(/[ț]/gi, (m) => (m === m.toLowerCase() ? "t" : "T"));
};
// Truncate text helper (limit explicatia to 100 chars)
const truncateText = (text, maxLength = 100) => {
if (!text) return "";
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + "...";
};
// Group data by bank account (bancasa)
const groupedByBank = {};
const initialBalances = {};
data.forEach((row) => {
const bankName = row.nume_cont_bancar || "Necunoscut";
if (!groupedByBank[bankName]) {
groupedByBank[bankName] = [];
initialBalances[bankName] = 0;
}
if (!row.dataact) {
// Initial balance row (null date) - sold precedent
initialBalances[bankName] = parseFloat(row.sold) || 0;
} else {
// Transaction row with date
groupedByBank[bankName].push(row);
}
});
// Table columns definition
const tableColumns = [
"Data act",
"Nr.act",
"Explicatia",
"Incasari",
"Plati",
"Sold",
];
const columnWidths = {
0: contentWidth * 0.10, // Data act
1: contentWidth * 0.08, // Nr.act
2: contentWidth * 0.42, // Explicatia
3: contentWidth * 0.13, // Incasari
4: contentWidth * 0.13, // Plati
5: contentWidth * 0.14, // Sold
};
const columnStyles = {};
Object.keys(columnWidths).forEach((idx) => {
columnStyles[idx] = { cellWidth: columnWidths[idx] };
if (idx >= 3) {
columnStyles[idx].halign = "right";
}
});
let currentY = 15;
let pageNum = 1;
// Function to add page header
const addPageHeader = () => {
// Company name (left)
doc.setFontSize(12);
doc.setFont(undefined, "bold");
doc.text(removeDiacritics(header.companyName || ""), marginLeft, 12);
// Luna: MM / YYYY (right)
doc.setFontSize(10);
doc.setFont(undefined, "normal");
const lunaText = `Luna: ${header.luna || ""} / ${header.an || ""}`;
const lunaWidth = doc.getTextWidth(lunaText);
doc.text(lunaText, pageWidth - marginRight - lunaWidth, 12);
// Title centered
doc.setFontSize(13);
doc.setFont(undefined, "bold");
const titleWidth = doc.getTextWidth(header.title || "");
doc.text(header.title || "", marginLeft + (contentWidth - titleWidth) / 2, 20);
};
// Function to check if we need a new page (for tables spanning multiple pages within a bank)
const checkNewPage = (neededHeight = 20) => {
if (currentY + neededHeight > pageHeight - 15) {
doc.addPage();
pageNum++;
addPageHeader();
currentY = 28;
return true;
}
return false;
};
// Process each bank account - each on a new page (sorted alphabetically)
const bankNames = Object.keys(groupedByBank).sort((a, b) => a.localeCompare(b, 'ro'));
bankNames.forEach((bankName, bankIndex) => {
const bankRows = groupedByBank[bankName];
const soldPrecedent = initialBalances[bankName] || 0;
// Start each bank/casa on a new page (except first one which is already on page 1)
if (bankIndex > 0) {
doc.addPage();
pageNum++;
}
// Add full page header (company, title, luna/an)
addPageHeader();
currentY = 28;
// Bank/Casa header: "Banca: NAME" (left) + "Sold precedent: VALUE" (right)
doc.setFontSize(10);
doc.setFont(undefined, "bold");
const bankLabel = header.isBanca ? "Banca:" : "Casa:";
const bankHeaderText = `${bankLabel} ${removeDiacritics(bankName)}`;
doc.text(bankHeaderText, marginLeft, currentY);
const soldPrecedentText = `Sold precedent: ${formatNumberForPDF(soldPrecedent)}`;
const soldPrecedentWidth = doc.getTextWidth(soldPrecedentText);
doc.text(soldPrecedentText, pageWidth - marginRight - soldPrecedentWidth, currentY);
currentY += 6;
// Handle case when there are no transactions (only initial balance)
if (bankRows.length === 0) {
// Draw empty table with header only
autoTable(doc, {
head: [tableColumns],
body: [],
startY: currentY,
styles: {
fontSize: 8,
cellPadding: 1.5,
lineColor: [200, 200, 200],
lineWidth: 0.1,
},
headStyles: {
fillColor: [41, 128, 185],
textColor: 255,
fontStyle: "bold",
halign: "center",
fontSize: 8,
},
columnStyles: columnStyles,
margin: { left: marginLeft, right: marginRight },
tableWidth: contentWidth,
theme: "grid",
});
currentY = doc.lastAutoTable.finalY;
// Show total with sold precedent (no transactions)
const totalRows = [
[
"",
"",
"Total:",
formatNumberForPDF(0),
formatNumberForPDF(0),
formatNumberForPDF(soldPrecedent),
],
];
const totalsStartY = currentY;
autoTable(doc, {
body: totalRows,
startY: currentY,
styles: {
fontSize: 8,
cellPadding: 1.5,
fontStyle: "bold",
lineWidth: 0,
},
columnStyles: columnStyles,
margin: { left: marginLeft, right: marginRight },
tableWidth: contentWidth,
theme: "plain",
});
// Draw outer border for totals box
const totalsEndY = doc.lastAutoTable.finalY;
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.1);
doc.rect(marginLeft, totalsStartY, contentWidth, totalsEndY - totalsStartY);
currentY = doc.lastAutoTable.finalY + 3;
} else {
// Group bank rows by date
const groupedByDate = {};
bankRows.forEach((row) => {
const dateKey = row.dataact;
if (!groupedByDate[dateKey]) {
groupedByDate[dateKey] = [];
}
groupedByDate[dateKey].push(row);
});
// Cumulative totals for the bank
let cumulativeIncasari = 0;
let cumulativePlati = 0;
let lastSold = soldPrecedent;
const dates = Object.keys(groupedByDate).sort();
dates.forEach((dateKey, dateIndex) => {
const dateRows = groupedByDate[dateKey];
const dateFormatted = dateKey
? new Date(dateKey).toLocaleDateString("ro-RO")
: "";
checkNewPage(30);
// Prepare rows for this date
const tableRows = [];
let dailyIncasari = 0;
let dailyPlati = 0;
dateRows.forEach((row) => {
const incasari = parseFloat(row.incasari) || 0;
const plati = parseFloat(row.plati) || 0;
dailyIncasari += incasari;
dailyPlati += plati;
lastSold = parseFloat(row.sold) || lastSold;
tableRows.push([
dateFormatted,
row.nract || "",
truncateText(removeDiacritics(row.explicatia || row.nume || ""), 100),
formatNumberForPDF(incasari),
formatNumberForPDF(plati),
formatNumberForPDF(row.sold),
]);
});
cumulativeIncasari += dailyIncasari;
cumulativePlati += dailyPlati;
// Draw table for this date group
autoTable(doc, {
head: dateIndex === 0 ? [tableColumns] : [],
body: tableRows,
startY: currentY,
styles: {
fontSize: 8,
cellPadding: 1.5,
lineColor: [200, 200, 200],
lineWidth: 0.1,
overflow: "linebreak",
},
headStyles: {
fillColor: [41, 128, 185],
textColor: 255,
fontStyle: "bold",
halign: "center",
fontSize: 8,
},
columnStyles: columnStyles,
margin: { left: marginLeft, right: marginRight },
tableWidth: contentWidth,
theme: "grid",
showHead: dateIndex === 0 ? "firstPage" : "never",
});
currentY = doc.lastAutoTable.finalY;
// Daily total + Cumulative total rows in same box
checkNewPage(16);
const totalRows = [
[
"",
"",
`Total zi: ${dateFormatted}`,
formatNumberForPDF(dailyIncasari),
formatNumberForPDF(dailyPlati),
"Sold",
],
[
"",
"",
"Total cumulat:",
formatNumberForPDF(cumulativeIncasari),
formatNumberForPDF(cumulativePlati),
formatNumberForPDF(lastSold),
],
];
const totalsStartY = currentY;
autoTable(doc, {
body: totalRows,
startY: currentY,
styles: {
fontSize: 8,
cellPadding: 1.5,
fontStyle: "bold",
lineWidth: 0,
},
columnStyles: columnStyles,
margin: { left: marginLeft, right: marginRight },
tableWidth: contentWidth,
theme: "plain",
});
// Draw outer border for totals box (no internal lines)
const totalsEndY = doc.lastAutoTable.finalY;
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.1);
doc.rect(marginLeft, totalsStartY, contentWidth, totalsEndY - totalsStartY);
currentY = doc.lastAutoTable.finalY + 3;
});
}
});
// Add footer to all pages (Generat: DATE on left, Pagina X din Y on right)
const totalPages = doc.internal.getNumberOfPages();
const generatedText = `Generat: ${new Date().toLocaleString("ro-RO")}`;
for (let i = 1; i <= totalPages; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setFont(undefined, "normal");
const footerY = pageHeight - 8;
// Left: Generated date
doc.text(generatedText, marginLeft, footerY);
// Right: Page number
const pageText = `Pagina ${i} din ${totalPages}`;
const pageTextWidth = doc.getTextWidth(pageText);
doc.text(pageText, pageWidth - marginRight - pageTextWidth, footerY);
}
doc.save(`${filename}_${new Date().toISOString().split("T")[0]}.pdf`);
return { success: true };
} catch (error) {
console.error("Bank Cash Register PDF export error:", error);
return { success: false, error: error.message || "PDF generation failed" };
}
};
/**
* Export Trend Data
*/
export const exportTrendData = (trendsData, period, chartType) => {
if (!trendsData || !trendsData.labels || !trendsData.datasets) {
return [];
}
const data = trendsData.labels.map((label, index) => {
const row = { Perioada: label };
trendsData.datasets.forEach((dataset) => {
const value = dataset.data[index];
row[dataset.label] = value || 0;
});
return row;
});
return data;
};

View File

View File

@@ -0,0 +1,943 @@
<template>
<div class="app-container">
<div class="register-view">
<!-- Header -->
<div class="page-header">
<h1 class="page-title">
<i class="pi pi-wallet"></i>
Registru Casă / Bancă
</h1>
</div>
<!-- Company Selection (when no company selected) -->
<Card v-if="!companyStore.selectedCompany" class="company-selection-card">
<template #content>
<div class="company-selection">
<p class="text-color-secondary mb-3">
Selectați o companie pentru a vizualiza registrul de casă și
bancă:
</p>
<Dropdown
v-model="selectedCompanyId"
:options="companyStore.companyListFormatted"
option-label="displayName"
option-value="id_firma"
placeholder="Alegeți compania"
class="w-full"
@change="handleCompanyChange"
/>
</div>
</template>
</Card>
<!-- Mobile: Two-row toolbar -->
<div v-if="isMobile && companyStore.selectedCompany" class="mobile-toolbar-container">
<!-- Row 1: Icon-only action buttons -->
<div class="mobile-toolbar-buttons">
<Button
icon="pi pi-filter"
:class="{ 'filter-active': hasActiveFilters }"
class="p-button-text"
@click="showFilters = !showFilters"
v-tooltip.bottom="'Filtre'"
/>
<Button
icon="pi pi-filter-slash"
class="p-button-text"
@click="resetFilters"
v-tooltip.bottom="'Resetează'"
/>
<Button
icon="pi pi-file-excel"
class="p-button-text p-button-success"
@click="exportExcel"
:disabled="!hasData"
v-tooltip.bottom="'Excel'"
/>
<Button
icon="pi pi-file-pdf"
class="p-button-text p-button-danger"
@click="exportPDF"
:disabled="!hasData"
v-tooltip.bottom="'PDF'"
/>
<Button
icon="pi pi-refresh"
class="p-button-text"
:loading="treasuryStore.isLoading"
@click="refreshData"
v-tooltip.bottom="'Actualizează'"
/>
</div>
<!-- Row 2: Totals grid -->
<div class="mobile-toolbar-totals">
<div class="mobile-totals-grid">
<div class="total-item">
<span class="total-label">Sold Prec:</span>
<span class="total-value">{{ formatCompact(treasuryStore.totals.sold_precedent_all) }}</span>
</div>
<div class="total-item">
<span class="total-label">Încasări:</span>
<span class="total-value incasari">{{ formatCompact(treasuryStore.totals.total_incasari_all) }}</span>
</div>
<div class="total-item">
<span class="total-label">Plăți:</span>
<span class="total-value plati">{{ formatCompact(treasuryStore.totals.total_plati_all) }}</span>
</div>
<div class="total-item">
<span class="total-label">Sold Final:</span>
<span class="total-value">{{ formatCompact(treasuryStore.totals.sold_final_all) }}</span>
</div>
</div>
</div>
</div>
<!-- Filters -->
<Card v-if="companyStore.selectedCompany && (!isMobile || showFilters)" class="filters-card">
<template #content>
<div class="form">
<div class="form-row">
<div class="form-col">
<div class="form-group">
<label class="form-label">Tip Registru</label>
<Dropdown
v-model="filters.registerType"
:options="registerTypeOptions"
option-label="label"
option-value="value"
placeholder="Selectați tipul"
class="w-full"
@change="handleFilterChange"
/>
</div>
</div>
<div class="form-col">
<div class="form-group">
<label class="form-label">{{ contColumnHeader }}</label>
<Dropdown
v-model="filters.bankAccount"
:options="bankAccountOptions"
:placeholder="`Toate ${isBancaType ? 'băncile' : 'casele'}`"
:showClear="true"
class="w-full"
@change="handleFilterChange"
:disabled="
!filters.registerType || bankAccountOptions.length === 0
"
/>
</div>
</div>
<div class="form-col">
<div class="form-group">
<label class="form-label">Căutare Partener</label>
<InputText
v-model="filters.partnerName"
placeholder="Nume partener..."
class="w-full"
@input="handleSearchChange"
/>
</div>
</div>
</div>
<!-- Desktop: Action buttons -->
<div v-if="!isMobile" class="form-actions">
<Button
icon="pi pi-filter-slash"
label="Resetează Filtre"
class="p-button-outlined p-button-secondary"
@click="resetFilters"
/>
<Button
icon="pi pi-file-excel"
label="Export Excel"
class="p-button-outlined p-button-success"
@click="exportExcel"
:disabled="!hasData"
/>
<Button
icon="pi pi-file-pdf"
label="Export PDF"
class="p-button-outlined p-button-danger"
@click="exportPDF"
:disabled="!hasData"
/>
<Button
icon="pi pi-refresh"
label="Actualizează"
:loading="treasuryStore.isLoading"
@click="refreshData"
/>
</div>
</div>
</template>
</Card>
<!-- Summary Stats - Compact, right aligned (hidden on mobile - only Sold Final in toolbar) -->
<!-- Folosește totaluri din TOATE înregistrările (backend) nu doar pagina curentă -->
<div v-if="!isMobile && companyStore.selectedCompany" class="summary-stats-inline">
<div class="stat-item">
<span class="stat-label">Sold Precedent:</span>
<span
class="stat-value"
:class="treasuryStore.totals.sold_precedent_all >= 0 ? 'incasari' : 'plati'"
>{{ formatCurrency(treasuryStore.totals.sold_precedent_all) }}</span
>
</div>
<div class="stat-item">
<span class="stat-label">Încasări:</span>
<span class="stat-value incasari">{{
formatCurrency(treasuryStore.totals.total_incasari_all)
}}</span>
</div>
<div class="stat-item">
<span class="stat-label">Plăți:</span>
<span class="stat-value plati">{{
formatCurrency(treasuryStore.totals.total_plati_all)
}}</span>
</div>
<div class="stat-item">
<span class="stat-label">Sold Final:</span>
<span
class="stat-value"
:class="treasuryStore.totals.sold_final_all >= 0 ? 'incasari' : 'plati'"
>{{ formatCurrency(treasuryStore.totals.sold_final_all) }}</span
>
</div>
</div>
<!-- Data Table -->
<Card v-if="companyStore.selectedCompany" class="data-card">
<template #content>
<!-- Mobile: Card Layout -->
<div v-if="isMobile" class="mobile-card-list">
<div
v-for="reg in treasuryStore.registers"
:key="`${reg.dataact}-${reg.nract}`"
class="mobile-data-card"
>
<div class="card-header">{{ reg.nume || 'Fără partener' }}</div>
<div class="card-row">
<span class="card-meta">{{ formatDateShort(reg.dataact) }} · {{ reg.nume_cont_bancar }}</span>
<span
class="card-amount"
:class="reg.incasari > 0 ? 'positive' : (reg.plati > 0 ? 'negative' : '')"
>
<template v-if="reg.incasari > 0">+{{ formatNumber(reg.incasari) }}</template>
<template v-else-if="reg.plati > 0">-{{ formatNumber(reg.plati) }}</template>
<template v-else>{{ formatNumber(0) }}</template>
</span>
</div>
</div>
<div v-if="treasuryStore.registers.length === 0" class="mobile-empty">
<i class="pi pi-info-circle"></i>
<p>Nu au fost găsite înregistrări</p>
</div>
</div>
<!-- Desktop: DataTable -->
<DataTable
v-if="!isMobile"
:value="treasuryStore.registers"
:loading="treasuryStore.isLoading"
:paginator="true"
:rows="pagination.rows"
:total-records="treasuryStore.pagination.totalRecords"
:lazy="true"
:striped-rows="true"
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rows-per-page-options="[25, 50, 100]"
current-page-report-template="Afișare {first} - {last} din {totalRecords} înregistrări"
responsive-layout="scroll"
@page="onPage"
class="p-datatable-sm"
:rowClass="getRowClass"
>
<template #empty>
<div class="table-empty">
<i class="pi pi-info-circle table-empty-icon"></i>
<p class="table-empty-message">
Nu au fost găsite înregistrări
</p>
</div>
</template>
<template #loading>
<div class="loading-state">
<ProgressSpinner />
<p>Se încarcă registrul...</p>
</div>
</template>
<Column field="dataact" header="Data" sortable class="col-data">
<template #body="slotProps">
{{ formatDate(slotProps.data.dataact) }}
</template>
</Column>
<Column field="nract" header="Nr." sortable class="col-nr" />
<Column
field="nume_cont_bancar"
:header="contColumnHeader"
sortable
class="col-cont"
/>
<Column
field="nume"
header="Partener"
sortable
class="col-partener"
/>
<Column
v-if="isValutaType"
field="valuta"
header="Valuta"
sortable
class="col-valuta"
/>
<Column
field="incasari"
header="Încasări"
sortable
class="col-numeric"
>
<template #body="slotProps">
<span class="numeric-value" v-if="slotProps.data.incasari > 0">
{{ formatNumber(slotProps.data.incasari) }}
</span>
<span class="numeric-value zero" v-else>0,00</span>
</template>
</Column>
<Column field="plati" header="Plăți" sortable class="col-numeric">
<template #body="slotProps">
<span class="numeric-value" v-if="slotProps.data.plati > 0">
{{ formatNumber(slotProps.data.plati) }}
</span>
<span class="numeric-value zero" v-else>0,00</span>
</template>
</Column>
<Column
field="sold"
header="Sold Cumulat"
sortable
class="col-numeric col-sold"
>
<template #body="slotProps">
<span
class="numeric-value"
:class="{ negative: slotProps.data.sold < 0 }"
>
{{ formatNumber(slotProps.data.sold) }}
</span>
</template>
</Column>
<Column
field="explicatia"
header="Explicație"
class="col-explicatie"
>
<template #body="slotProps">
{{ truncateText(slotProps.data.explicatia, 100) }}
</template>
</Column>
</DataTable>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useToast } from "primevue/usetoast";
import { useTreasuryStore } from "@reports/stores/treasury";
import { useCompanyStore } from "@reports/stores/sharedStores";
import { useAccountingPeriodStore } from "@reports/stores/sharedStores";
import { format } from "date-fns";
import { exportToExcel, exportBankCashRegisterPDF } from "@reports/utils/exportUtils";
const toast = useToast();
const treasuryStore = useTreasuryStore();
const companyStore = useCompanyStore();
const periodStore = useAccountingPeriodStore();
// State for company selection
const selectedCompanyId = ref(companyStore.selectedCompany?.id_firma || null);
// Mobile state
const isMobile = ref(window.innerWidth < 768);
const showFilters = ref(false);
const actionsMenu = ref(null);
// Handle window resize
const handleResize = () => {
isMobile.value = window.innerWidth < 768;
if (!isMobile.value) {
showFilters.value = false; // Reset when switching to desktop
}
};
// Register type options for dropdown - doar cele 4 tipuri, fără "Toate"
const registerTypeOptions = [
{ label: "Casă LEI", value: "CASA_LEI" },
{ label: "Casă Valută", value: "CASA_VALUTA" },
{ label: "Bancă LEI", value: "BANCA_LEI" },
{ label: "Bancă Valută", value: "BANCA_VALUTA" },
];
const filters = ref({
registerType: "BANCA_LEI", // Default: Registrul de Banca Lei
partnerName: "",
bankAccount: null, // Filter for specific bank/cash account
});
// Bank/cash account options for dropdown
const bankAccountOptions = ref([]);
const pagination = ref({
page: 0,
rows: 50,
});
const formatCurrency = (amount, currency = "RON") => {
if (!amount) return "0,00 " + currency;
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: currency,
}).format(amount);
};
const formatNumber = (amount) => {
if (amount === null || amount === undefined) return "";
return new Intl.NumberFormat("ro-RO", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
const formatDate = (dateString) => {
if (!dateString) return "";
return format(new Date(dateString), "dd.MM.yyyy");
};
// Short date format for mobile cards (DD/MM)
const formatDateShort = (dateString) => {
if (!dateString) return "";
const date = new Date(dateString);
return `${String(date.getDate()).padStart(2, "0")}/${String(date.getMonth() + 1).padStart(2, "0")}`;
};
// Compact number format (no decimals for large numbers)
const formatCompact = (amount) => {
if (!amount) return "0";
if (Math.abs(amount) >= 10000) {
return new Intl.NumberFormat("ro-RO", {
maximumFractionDigits: 0,
}).format(amount);
}
return new Intl.NumberFormat("ro-RO", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
// Truncate text to maxLength characters
const truncateText = (text, maxLength = 100) => {
if (!text) return "";
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + "...";
};
// Check if current filter is a VALUTA type (to show Valuta column)
const isValutaType = computed(() => {
return (
filters.value.registerType === "CASA_VALUTA" ||
filters.value.registerType === "BANCA_VALUTA"
);
});
// Check if current filter is BANCA type (for dynamic column header)
const isBancaType = computed(() => {
return (
filters.value.registerType === "BANCA_LEI" ||
filters.value.registerType === "BANCA_VALUTA"
);
});
// Dynamic column header for Casa/Banca
const contColumnHeader = computed(() => {
return isBancaType.value ? "Banca" : "Casa";
});
// Accounting period text for PDF export
const accountingPeriodText = computed(() => {
// Use the global period store
return periodStore.selectedPeriod?.display_name || "";
});
// Helper to remove diacritics from text
const removeDiacritics = (text) => {
if (!text) return "";
return text
.replace(/[ăâ]/gi, (match) => (match === match.toLowerCase() ? "a" : "A"))
.replace(/[îâ]/gi, (match) => (match === match.toLowerCase() ? "i" : "I"))
.replace(/[ș]/gi, (match) => (match === match.toLowerCase() ? "s" : "S"))
.replace(/[ț]/gi, (match) => (match === match.toLowerCase() ? "t" : "T"))
.replace(/[Ă]/g, "A")
.replace(/[Â]/g, "A")
.replace(/[Î]/g, "I")
.replace(/[Ș]/g, "S")
.replace(/[Ț]/g, "T");
};
// Get register type label for PDF (no diacritics)
const getRegisterTypeLabel = (type) => {
const labels = {
CASA_LEI: "Casa LEI",
CASA_VALUTA: "Casa Valuta",
BANCA_LEI: "Banca LEI",
BANCA_VALUTA: "Banca Valuta",
};
return labels[type] || type;
};
// Get PDF title based on register type
const getPdfTitle = (type) => {
const titles = {
CASA_LEI: "Registrul de Casa LEI",
CASA_VALUTA: "Registrul de Casa Valuta",
BANCA_LEI: "Registrul de Banca LEI",
BANCA_VALUTA: "Registrul de Banca Valuta",
};
return titles[type] || "Registrul de Casa si Banca";
};
// Load bank/cash accounts for dropdown when register type changes
const loadBankAccounts = async () => {
if (!companyStore.selectedCompany || !filters.value.registerType) {
bankAccountOptions.value = [];
return;
}
try {
const apiService = (await import("../services/api")).apiService;
const response = await api.get("/treasury/bank-cash-accounts", {
params: {
company: companyStore.selectedCompany.id_firma,
register_type: filters.value.registerType,
},
});
bankAccountOptions.value = response.data || [];
} catch (error) {
console.error("Failed to load bank accounts:", error);
bankAccountOptions.value = [];
}
};
// Watch for register type changes to reload bank accounts
watch(
() => filters.value.registerType,
async () => {
// Reset bank account selection when register type changes
filters.value.bankAccount = null;
await loadBankAccounts();
},
);
const getRowClass = (data) => {
return data.tip_registru.includes("BANCA") ? "bank-row" : "cash-row";
};
const onPage = async (event) => {
// PrimeVue pagination is 0-indexed for page
pagination.value.page = event.page;
pagination.value.rows = event.rows;
await loadData();
};
const resetFilters = async () => {
filters.value = {
registerType: "BANCA_LEI", // Reset la default: Registrul de Banca Lei
partnerName: "",
bankAccount: null, // Reset bank account filter
};
pagination.value.page = 0;
await loadBankAccounts(); // Reload bank accounts for default register type
await loadData();
};
// Computed
const hasData = computed(() => treasuryStore.registers.length > 0);
// Mobile: Check if any filter is active (non-default value)
const hasActiveFilters = computed(() => {
return (
filters.value.registerType !== "BANCA_LEI" ||
filters.value.partnerName !== "" ||
filters.value.bankAccount !== null
);
});
// Mobile: Actions menu items
const actionMenuItems = computed(() => [
{
label: "Resetează Filtre",
icon: "pi pi-filter-slash",
command: resetFilters,
},
{
label: "Export Excel",
icon: "pi pi-file-excel",
command: exportExcel,
disabled: !hasData.value,
},
{
label: "Export PDF",
icon: "pi pi-file-pdf",
command: exportPDF,
disabled: !hasData.value,
},
{ separator: true },
{
label: "Actualizează",
icon: "pi pi-refresh",
command: refreshData,
},
]);
// Handle company change from dropdown
const handleCompanyChange = async () => {
if (!selectedCompanyId.value) return;
const company = companyStore.getCompanyById(selectedCompanyId.value);
if (company) {
companyStore.setSelectedCompany(company);
await loadData();
}
};
// Handle filter change
const handleFilterChange = async () => {
pagination.value.page = 0;
await loadData();
};
// Debounced search handler
const handleSearchChange = (() => {
let timeout;
return () => {
clearTimeout(timeout);
timeout = setTimeout(async () => {
pagination.value.page = 0;
await loadData();
}, 500);
};
})();
// Refresh data with toast notification
const refreshData = async () => {
await loadData();
toast.add({
severity: "success",
summary: "Actualizare reușită",
detail: "Registrul a fost actualizat cu succes",
life: 3000,
});
};
// Fetch ALL data for export (not just current page)
const fetchAllData = async () => {
if (!companyStore.selectedCompany) return [];
if (!periodStore.selectedPeriod) return [];
try {
// Get luna/an from period store
const { luna, an } = periodStore.selectedPeriod;
const params = {
company: companyStore.selectedCompany.id_firma,
page: 1,
page_size: 999999, // Get all data
luna: luna,
an: an,
};
// Add register_type filter
if (filters.value.registerType) {
params.register_type = filters.value.registerType;
}
if (filters.value.partnerName) {
params.partner_name = filters.value.partnerName;
}
if (filters.value.bankAccount) {
params.bank_account = filters.value.bankAccount;
}
const apiService = (await import("../services/api")).apiService;
const response = await api.get("/treasury/bank-cash-register", {
params,
});
return response.data.registers || [];
} catch (error) {
console.error("Failed to fetch all data:", error);
return [];
}
};
// Export to Excel
const exportExcel = async () => {
if (!hasData.value) {
toast.add({
severity: "warn",
summary: "Nu există date",
detail: "Nu există înregistrări de exportat",
life: 3000,
});
return;
}
toast.add({
severity: "info",
summary: "Se pregătește exportul",
detail: "Se încarcă toate datele...",
life: 2000,
});
const allData = await fetchAllData();
if (allData.length === 0) {
toast.add({
severity: "error",
summary: "Eroare",
detail: "Nu s-au putut prelua datele pentru export",
life: 3000,
});
return;
}
// Prepare data for export - conditionally include Valuta column only for VALUTA types
// Column order: Data, Nr., Casă/Bancă, Partener, [Valuta], Încasări, Plăți, Sold Cumulat, Explicație
const exportData = allData.map((row) => {
const baseData = {
Data: row.dataact ? formatDate(row.dataact) : "",
"Nr.": row.nract || "",
};
// Use dynamic column name (Casă/Bancă) - BEFORE Partener
baseData[contColumnHeader.value] = row.nume_cont_bancar || "";
baseData["Partener"] = row.nume || "";
// Add Valuta column only for VALUTA register types
if (isValutaType.value) {
baseData["Valuta"] = row.valuta || "";
}
// Add numeric columns
baseData["Încasări"] = parseFloat(row.incasari) || 0;
baseData["Plăți"] = parseFloat(row.plati) || 0;
baseData["Sold Cumulat"] = parseFloat(row.sold) || 0;
baseData["Explicație"] = truncateText(row.explicatia, 100);
return baseData;
});
const result = exportToExcel(
exportData,
`registru_casa_banca_${companyStore.selectedCompany.name.replace(/\s+/g, "_")}`,
"Registru Casă și Bancă",
);
if (result.success) {
toast.add({
severity: "success",
summary: "Export reușit",
detail: `${allData.length} înregistrări exportate cu succes`,
life: 3000,
});
} else {
toast.add({
severity: "error",
summary: "Eroare la export",
detail: "Nu s-a putut genera fișierul Excel",
life: 3000,
});
}
};
// Export to PDF
const exportPDF = async () => {
if (!hasData.value) {
toast.add({
severity: "warn",
summary: "Nu există date",
detail: "Nu există înregistrări de exportat",
life: 3000,
});
return;
}
toast.add({
severity: "info",
summary: "Se pregătește exportul",
detail: "Se încarcă toate datele...",
life: 2000,
});
const allData = await fetchAllData();
if (allData.length === 0) {
toast.add({
severity: "error",
summary: "Eroare",
detail: "Nu s-au putut prelua datele pentru export",
life: 3000,
});
return;
}
// Dynamic title based on register type
const pdfTitle = getPdfTitle(filters.value.registerType);
// Use the specialized Bank Cash Register PDF export
const result = exportBankCashRegisterPDF(
allData,
{
companyName: removeDiacritics(companyStore.selectedCompany?.name || ""),
title: pdfTitle,
luna: treasuryStore.accountingPeriod.luna,
an: treasuryStore.accountingPeriod.an,
isBanca: isBancaType.value,
},
`registru-casa-banca-${companyStore.selectedCompany.name.replace(/\s+/g, "-")}`,
);
if (result.success) {
toast.add({
severity: "success",
summary: "Export reușit",
detail: `${allData.length} înregistrări exportate cu succes`,
life: 3000,
});
} else {
toast.add({
severity: "error",
summary: "Eroare la export",
detail: "Nu s-a putut genera fișierul PDF",
life: 3000,
});
}
};
const loadData = async () => {
if (!companyStore.selectedCompany) return;
if (!periodStore.selectedPeriod) return; // Wait for period to be loaded
treasuryStore.setPagination(pagination.value);
// Get luna/an from period store
const { luna, an } = periodStore.selectedPeriod;
// Build filter params with luna/an instead of date_from/date_to
const filterParams = {
partner_name: filters.value.partnerName || undefined,
register_type: filters.value.registerType || undefined,
bank_account: filters.value.bankAccount || undefined,
luna: luna,
an: an,
};
await treasuryStore.loadBankCashRegister(
companyStore.selectedCompany.id_firma,
filterParams,
);
};
onMounted(async () => {
// Add resize listener for mobile detection
window.addEventListener("resize", handleResize);
// Load companies if not loaded
if (!companyStore.hasCompanies) {
await companyStore.loadCompanies();
}
// Load bank accounts for initial register type if company is selected
if (companyStore.selectedCompany) {
await loadBankAccounts();
}
// Don't load data here - let period watch handle it with immediate: true
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
});
// Watch for company changes
watch(
() => companyStore.selectedCompany,
async (newCompany) => {
if (newCompany && periodStore.selectedPeriod) {
await loadBankAccounts();
await loadData();
}
},
);
// Watch for period changes - reload data when period changes
watch(
() => periodStore.selectedPeriod,
async (newPeriod) => {
if (newPeriod && companyStore.selectedCompany) {
await loadData();
}
},
{ immediate: true },
);
</script>
<style scoped>
/* ===== Page-Specific Styles Only ===== */
/* Uses shared CSS: dashboard.css (.page-header, .page-title, .page-subtitle) */
/* Uses shared CSS: forms.css (.form-actions) */
/* Uses shared CSS: tables.css (.table-empty, .loading-state, .negative) */
/* Uses shared CSS: stats.css (.summary-stats-inline) */
/* Uses shared CSS: primevue-overrides.css (DataTable striped rows, hover, compact) */
/* Page Container */
.register-view {
max-width: 1400px;
margin: 0 auto;
padding: var(--space-xl);
}
/* Card Spacing */
.company-selection-card,
.filters-card,
.data-card {
margin-bottom: var(--space-xl);
}
/* Numeric Values - Page-specific formatting */
.numeric-value {
display: block;
text-align: right;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono, "Roboto Mono", "Consolas", monospace);
}
.numeric-value.zero {
color: var(--color-text-muted);
}
.numeric-value.negative {
color: var(--color-error);
}
/* Responsive */
@media (max-width: 768px) {
.register-view {
padding: var(--space-md);
}
}
</style>

View File

@@ -0,0 +1,449 @@
<template>
<div class="cache-stats-view">
<div class="stats-header">
<h1>Cache Statistics</h1>
<div class="actions">
<Button
label="Clear Cache"
icon="pi pi-trash"
severity="danger"
@click="showClearDialog = true"
:loading="loading"
/>
<Button
label="Refresh"
icon="pi pi-refresh"
@click="loadStats"
:loading="loading"
/>
</div>
</div>
<Message v-if="error" severity="error" :closable="true" @close="clearError">
{{ error }}
</Message>
<div v-if="!loading && stats" class="stats-grid">
<!-- Cache Status -->
<Card class="status-card">
<template #title>Cache Status</template>
<template #content>
<div class="status-content">
<div class="status-item">
<label>Global Status:</label>
<Tag
:value="stats.global_enabled ? 'ENABLED' : 'DISABLED'"
:severity="stats.global_enabled ? 'success' : 'danger'"
/>
</div>
<div class="status-item">
<label>Your Setting:</label>
<InputSwitch
v-model="userCacheEnabled"
@change="toggleUserCache"
/>
<span>{{ userCacheEnabled ? "ON" : "OFF" }}</span>
</div>
<div class="status-item">
<label>Auto-Invalidation:</label>
<Tag
:value="stats.auto_invalidate ? 'ENABLED' : 'DISABLED'"
:severity="stats.auto_invalidate ? 'success' : 'warning'"
/>
</div>
</div>
</template>
</Card>
<!-- Performance Metrics -->
<Card class="metrics-card">
<template #title>Performance Metrics</template>
<template #content>
<div class="hit-rate">
<h3>Hit Rate: {{ stats.hit_rate?.toFixed(1) }}%</h3>
<p>
{{ stats.total_hits }} hits /
{{ stats.total_hits + stats.total_misses }} total requests
</p>
<ProgressBar :value="stats.hit_rate" />
</div>
</template>
</Card>
<!-- Queries Saved -->
<Card class="queries-card">
<template #title>Queries Saved</template>
<template #content>
<ul class="queries-list">
<li>
Today:
<strong>{{
stats.queries_saved?.today?.toLocaleString()
}}</strong>
queries avoided
</li>
<li>
This week:
<strong>{{ stats.queries_saved?.week?.toLocaleString() }}</strong>
queries avoided
</li>
<li>
All time:
<strong>{{
stats.queries_saved?.total?.toLocaleString()
}}</strong>
queries avoided
</li>
</ul>
</template>
</Card>
<!-- Response Times -->
<Card class="response-times-card">
<template #title>Response Time Comparison</template>
<template #content>
<DataTable :value="responseTimesTable" class="p-datatable-sm">
<Column field="endpoint" header="Endpoint" />
<Column field="cached" header="With Cache">
<template #body="{ data }">{{ data.cached }} ms</template>
</Column>
<Column field="oracle" header="Without Cache">
<template #body="{ data }">{{ data.oracle }} ms</template>
</Column>
<Column field="improvement" header="Improvement">
<template #body="{ data }">
<Tag :value="`${data.improvement}% ↓`" severity="success" />
</template>
</Column>
</DataTable>
<div v-if="overallAvg" class="average-row">
<strong>Overall Average:</strong>
{{ overallAvg.cached }} ms vs {{ overallAvg.oracle }} ms ({{
overallAvg.improvement
}}% faster)
</div>
</template>
</Card>
<!-- Cache Details -->
<Card class="details-card">
<template #title>Cache Details</template>
<template #content>
<ul class="details-list">
<li>
Memory entries:
<strong>{{ stats.cache_size?.memory?.toLocaleString() }}</strong>
</li>
<li>
SQLite entries:
<strong>{{ stats.cache_size?.sqlite?.toLocaleString() }}</strong>
</li>
<li>
Cache type: <strong>{{ stats.cache_type }}</strong>
</li>
</ul>
</template>
</Card>
</div>
<!-- Clear Cache Dialog -->
<Dialog
v-model:visible="showClearDialog"
header="Clear Cache"
:modal="true"
:style="{ width: '450px' }"
>
<p>Are you sure you want to clear the cache?</p>
<div class="clear-options">
<div class="p-field-radiobutton">
<RadioButton id="clear_all" v-model="clearScope" value="all" />
<label for="clear_all">All companies</label>
</div>
<div class="p-field-radiobutton">
<RadioButton
id="clear_current"
v-model="clearScope"
value="current"
/>
<label for="clear_current">Current company only</label>
</div>
</div>
<template #footer>
<Button label="Cancel" text @click="showClearDialog = false" />
<Button
label="Clear"
severity="danger"
@click="clearCache"
:loading="loading"
/>
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { useCacheStore } from "@reports/stores/cacheStore";
import { useCompanyStore } from "@reports/stores/sharedStores";
import { useToast } from "primevue/usetoast";
import Button from "primevue/button";
import Card from "primevue/card";
import DataTable from "primevue/datatable";
import Column from "primevue/column";
import Tag from "primevue/tag";
import ProgressBar from "primevue/progressbar";
import InputSwitch from "primevue/inputswitch";
import Dialog from "primevue/dialog";
import RadioButton from "primevue/radiobutton";
import Message from "primevue/message";
const cacheStore = useCacheStore();
const companyStore = useCompanyStore();
const toast = useToast();
const loading = computed(() => cacheStore.isLoading);
const error = computed(() => cacheStore.error);
const stats = computed(() => cacheStore.stats);
const userCacheEnabled = ref(true);
const showClearDialog = ref(false);
const clearScope = ref("current");
const responseTimesTable = computed(() => {
if (!stats.value?.response_times) return [];
return Object.entries(stats.value.response_times).map(([key, data]) => ({
endpoint: formatEndpointName(key),
cached: data.cached,
oracle: data.oracle,
improvement: data.improvement,
}));
});
const overallAvg = computed(() => {
const times = Object.values(stats.value?.response_times || {});
if (times.length === 0) return null;
const avgCached = times.reduce((sum, t) => sum + t.cached, 0) / times.length;
const avgOracle = times.reduce((sum, t) => sum + t.oracle, 0) / times.length;
const improvement = (((avgOracle - avgCached) / avgOracle) * 100).toFixed(0);
return {
cached: avgCached.toFixed(0),
oracle: avgOracle.toFixed(0),
improvement,
};
});
async function loadStats() {
try {
await cacheStore.getStats();
userCacheEnabled.value = stats.value?.user_enabled ?? true;
} catch (error) {
toast.add({
severity: "error",
summary: "Error",
detail: "Failed to load cache statistics",
life: 3000,
});
}
}
async function toggleUserCache() {
try {
await cacheStore.toggleUserCache(userCacheEnabled.value);
toast.add({
severity: "success",
summary: "Success",
detail: `Cache ${userCacheEnabled.value ? "enabled" : "disabled"} for you`,
life: 3000,
});
} catch (error) {
toast.add({
severity: "error",
summary: "Error",
detail: "Failed to toggle cache",
life: 3000,
});
// Revert toggle
userCacheEnabled.value = !userCacheEnabled.value;
}
}
async function clearCache() {
try {
const companyId =
clearScope.value === "current"
? companyStore.currentCompany?.id_firma
: null;
await cacheStore.invalidateCache(companyId, null);
toast.add({
severity: "success",
summary: "Success",
detail: "Cache cleared successfully",
life: 3000,
});
showClearDialog.value = false;
await loadStats();
} catch (error) {
toast.add({
severity: "error",
summary: "Error",
detail: "Failed to clear cache",
life: 3000,
});
}
}
function formatEndpointName(key) {
const names = {
schema: "Schema Lookup",
dashboard_summary: "Dashboard",
dashboard_trends: "Dashboard Trends",
companies: "Companies List",
invoices: "Invoices",
treasury: "Treasury",
};
return names[key] || key;
}
function clearError() {
cacheStore.clearError();
}
onMounted(() => {
loadStats();
});
</script>
<style scoped>
/* Container - Uses global .app-container pattern */
.cache-stats-view {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
/* Header - Uses global .page-header pattern */
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.stats-header h1 {
margin: 0;
color: var(--text-color);
}
.actions {
display: flex;
gap: 0.5rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 1.5rem;
}
.status-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.status-item {
display: flex;
align-items: center;
gap: 1rem;
}
.status-item label {
font-weight: 600;
min-width: 140px;
}
/* Hit Rate - Uses global metric patterns */
.hit-rate {
text-align: center;
}
.hit-rate h3 {
margin: 0 0 0.5rem 0;
color: var(--primary-color);
}
.hit-rate p {
margin: 0 0 1rem 0;
color: var(--text-color-secondary);
font-size: 0.9rem;
}
.queries-list {
list-style: none;
padding: 0;
margin: 0;
}
.queries-list li {
padding: 0.5rem 0;
border-bottom: 1px solid var(--surface-border);
}
.queries-list li:last-child {
border-bottom: none;
}
.average-row {
margin-top: 1rem;
padding-top: 1rem;
border-top: 2px solid var(--surface-border);
text-align: center;
}
.details-list {
list-style: none;
padding: 0;
margin: 0;
}
.details-list li {
padding: 0.5rem 0;
display: flex;
justify-content: space-between;
}
.clear-options {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
.p-field-radiobutton {
display: flex;
align-items: center;
gap: 0.5rem;
}
.response-times-card {
grid-column: 1 / -1;
}
/* Responsive - Cache stats specific adjustments */
@media (max-width: 768px) {
.stats-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,917 @@
<template>
<div class="app-container">
<div class="invoices">
<!-- Header Section -->
<div class="page-header">
<h1 class="page-title">
<i class="pi pi-file-text"></i>
Facturi
</h1>
</div>
<!-- Company Selection -->
<Card v-if="!companyStore.selectedCompany" class="company-selection-card">
<template #content>
<div class="company-selection">
<p class="text-color-secondary mb-3">
Selectați o companie pentru a vizualiza facturile:
</p>
<Dropdown
v-model="selectedCompanyId"
:options="companyStore.companyListFormatted"
option-label="displayName"
option-value="id_firma"
placeholder="Alegeți compania"
class="w-full"
@change="handleCompanyChange"
/>
</div>
</template>
</Card>
<!-- Mobile: Two-row toolbar -->
<div v-if="isMobile && companyStore.selectedCompany" class="mobile-toolbar-container">
<!-- Row 1: Icon-only action buttons -->
<div class="mobile-toolbar-buttons">
<Button
icon="pi pi-filter"
:class="{ 'filter-active': hasActiveFilters }"
class="p-button-text"
@click="showFilters = !showFilters"
v-tooltip.bottom="'Filtre'"
/>
<Button
icon="pi pi-filter-slash"
class="p-button-text"
@click="clearFilters"
v-tooltip.bottom="'Resetează'"
/>
<Button
icon="pi pi-file-excel"
class="p-button-text p-button-success"
@click="exportExcel"
:disabled="!invoicesStore.hasInvoices"
v-tooltip.bottom="'Excel'"
/>
<Button
icon="pi pi-file-pdf"
class="p-button-text p-button-danger"
@click="exportPDF"
:disabled="!invoicesStore.hasInvoices"
v-tooltip.bottom="'PDF'"
/>
<Button
icon="pi pi-refresh"
class="p-button-text"
:loading="invoicesStore.isLoading"
@click="refreshData"
v-tooltip.bottom="'Actualizează'"
/>
</div>
<!-- Row 2: Totals (unified grid format) -->
<div class="mobile-toolbar-totals">
<div class="mobile-totals-grid single-total">
<div class="total-item">
<span class="total-label">Sold Total:</span>
<span class="total-value" :class="invoicesStore.totalSoldAll > 0 ? 'positive' : 'negative'">
{{ formatCompact(invoicesStore.totalSoldAll) }}
</span>
</div>
</div>
</div>
</div>
<!-- Filters and Controls -->
<Card v-if="companyStore.selectedCompany && (!isMobile || showFilters)" class="filters-card">
<template #content>
<div class="form">
<div class="form-row">
<!-- Invoice Type -->
<div class="form-col">
<div class="form-group">
<label class="form-label">Tip Factură</label>
<Dropdown
v-model="filters.type"
:options="invoiceTypes"
option-label="label"
option-value="value"
placeholder="Tip factură"
class="w-full"
@change="handleFilterChange"
/>
</div>
</div>
<!-- Payment Status -->
<div class="form-col">
<div class="form-group">
<label class="form-label">Status Plată</label>
<Dropdown
v-model="filters.paymentStatus"
:options="paymentStatusOptions"
option-label="label"
option-value="value"
placeholder="Status plată"
class="w-full"
@change="handleFilterChange"
/>
</div>
</div>
<!-- Search -->
<div class="form-col">
<div class="form-group">
<label class="form-label">Căutare</label>
<InputText
v-model="filters.searchTerm"
placeholder="Căutați după număr, partener..."
class="w-full"
@input="handleSearchChange"
/>
</div>
</div>
<!-- Cont Filter -->
<div class="form-col">
<div class="form-group">
<label class="form-label">Cont</label>
<InputText
v-model="filters.cont"
placeholder="Filtru cont (ex: 4111)"
class="w-full"
@input="handleSearchChange"
/>
</div>
</div>
</div>
<!-- Desktop: Action buttons -->
<div v-if="!isMobile" class="filters-actions">
<Button
icon="pi pi-filter-slash"
label="Resetează Filtre"
class="p-button-outlined p-button-secondary"
@click="clearFilters"
/>
<Button
icon="pi pi-file-excel"
label="Export Excel"
class="p-button-outlined p-button-success"
@click="exportExcel"
:disabled="!invoicesStore.hasInvoices"
/>
<Button
icon="pi pi-file-pdf"
label="Export PDF"
class="p-button-outlined p-button-danger"
@click="exportPDF"
:disabled="!invoicesStore.hasInvoices"
/>
<Button
icon="pi pi-refresh"
label="Actualizează"
:loading="invoicesStore.isLoading"
@click="refreshData"
/>
</div>
</div>
</template>
</Card>
<!-- Summary Stats - Compact, right aligned (hidden on mobile - shown in toolbar) -->
<!-- Total sold din TOATE facturile filtrate (nu doar pagina curentă) -->
<div v-if="!isMobile && companyStore.selectedCompany && invoicesStore.hasInvoices" class="summary-stats-inline">
<div class="stat-item">
<span class="stat-label">Total Sold:</span>
<span class="stat-value" :class="invoicesStore.totalSoldAll > 0 ? 'plati' : 'incasari'">
{{ formatCurrency(invoicesStore.totalSoldAll) }}
</span>
</div>
</div>
<!-- Invoices Table -->
<Card v-if="companyStore.selectedCompany" class="table-card">
<template #content>
<!-- Mobile: Card Layout -->
<div v-if="isMobile" class="mobile-card-list">
<div
v-for="invoice in invoicesStore.invoiceList"
:key="invoice.nract"
class="mobile-data-card"
>
<div class="card-header">{{ invoice.nume }}</div>
<div class="card-row">
<span>{{ formatDate(invoice.dataact) }} · {{ invoice.nract }}</span>
<span
class="card-amount"
:class="{ positive: invoice.soldfinal > 0 }"
>
{{ formatNumber(invoice.soldfinal) }}
</span>
</div>
</div>
<div v-if="invoicesStore.invoiceList.length === 0" class="mobile-empty">
<i class="pi pi-info-circle"></i>
<p>Nu au fost găsite facturi</p>
</div>
</div>
<!-- Desktop: DataTable -->
<DataTable
v-if="!isMobile"
:value="invoicesStore.invoiceList"
:loading="invoicesStore.isLoading"
:paginator="true"
:rows="pagination.rows"
:total-records="invoicesStore.totalInvoices"
:lazy="true"
:striped-rows="true"
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rows-per-page-options="[25, 50, 100]"
current-page-report-template="Afișare {first} - {last} din {totalRecords} înregistrări"
responsive-layout="scroll"
@page="onPageChange"
@sort="onSort"
>
<template #empty>
<div class="no-data">
<i class="pi pi-info-circle"></i>
<p>Nu au fost găsite facturi</p>
</div>
</template>
<template #loading>
<div class="loading-table">
<ProgressSpinner />
<p>Se încarcă facturile...</p>
</div>
</template>
<Column field="cont" header="Cont" sortable>
<template #body="slotProps">
{{ slotProps.data.cont || "-" }}
</template>
</Column>
<Column field="nract" header="Numar Doc." sortable>
<template #body="slotProps">
{{ slotProps.data.nract }}
</template>
</Column>
<Column field="dataact" header="Data Doc." sortable>
<template #body="slotProps">
{{ formatDate(slotProps.data.dataact) }}
</template>
</Column>
<Column field="datascad" header="Data Scadenta" sortable>
<template #body="slotProps">
{{ formatDate(slotProps.data.datascad) }}
</template>
</Column>
<Column field="nume" header="Partener" sortable>
<template #body="slotProps">
{{ slotProps.data.nume }}
</template>
</Column>
<Column field="totctva" header="Facturat" sortable>
<template #body="slotProps">
<div class="text-right">
{{ formatNumber(slotProps.data.totctva) }}
</div>
</template>
</Column>
<Column field="achitat" header="Achitat" sortable>
<template #body="slotProps">
<div class="text-right">
{{ formatNumber(slotProps.data.achitat) }}
</div>
</template>
</Column>
<Column field="soldfinal" header="Sold" sortable>
<template #body="slotProps">
<div class="text-right">
{{ formatNumber(slotProps.data.soldfinal) }}
</div>
</template>
</Column>
<Column
field="valuta"
header="Valuta"
sortable
:style="{ width: '8%' }"
>
<template #body="slotProps">
<div class="text-center">
{{ slotProps.data.valuta || "RON" }}
</div>
</template>
</Column>
</DataTable>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useToast } from "primevue/usetoast";
import { useCompanyStore } from "@reports/stores/sharedStores";
import { useInvoicesStore } from "@reports/stores/invoices";
import { useAccountingPeriodStore } from "@reports/stores/sharedStores";
import { format } from "date-fns";
import { ro } from "date-fns/locale";
import { exportToExcel, exportToPDF } from "@reports/utils/exportUtils";
const toast = useToast();
const companyStore = useCompanyStore();
const invoicesStore = useInvoicesStore();
const periodStore = useAccountingPeriodStore();
// State
const selectedCompanyId = ref(companyStore.selectedCompany?.id_firma || null);
// Mobile state
const isMobile = ref(window.innerWidth < 768);
const showFilters = ref(false);
const actionsMenu = ref(null);
// Handle window resize
const handleResize = () => {
isMobile.value = window.innerWidth < 768;
if (!isMobile.value) {
showFilters.value = false; // Reset when switching to desktop
}
};
const filters = ref({
type: "CLIENTI",
paymentStatus: "neachitate", // Default to unpaid invoices
searchTerm: "",
cont: "",
});
const pagination = ref({
page: 1,
rows: 100, // Changed from 50 to 100
});
// Computed
const accountingPeriodText = computed(() => {
// Use the global period store
return periodStore.selectedPeriod?.display_name || "";
});
// Mobile: Check if any filter is active (non-default value)
const hasActiveFilters = computed(() => {
return (
filters.value.type !== "CLIENTI" ||
filters.value.paymentStatus !== "neachitate" ||
filters.value.searchTerm !== "" ||
filters.value.cont !== ""
);
});
// Mobile: Actions menu items
const actionMenuItems = computed(() => [
{
label: "Resetează Filtre",
icon: "pi pi-filter-slash",
command: clearFilters,
},
{
label: "Export Excel",
icon: "pi pi-file-excel",
command: exportExcel,
disabled: !invoicesStore.hasInvoices,
},
{
label: "Export PDF",
icon: "pi pi-file-pdf",
command: exportPDF,
disabled: !invoicesStore.hasInvoices,
},
{ separator: true },
{
label: "Actualizează",
icon: "pi pi-refresh",
command: refreshData,
},
]);
// Options
const invoiceTypes = [
{ label: "Clienți", value: "CLIENTI" },
{ label: "Furnizori", value: "FURNIZORI" },
];
const paymentStatusOptions = [
{ label: "Neachitate", value: "neachitate" },
{ label: "Toate", value: "toate" },
];
// Methods
const formatCurrency = (amount) => {
if (!amount) return "0,00 RON";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
}).format(amount);
};
const formatNumber = (amount) => {
if (!amount || amount === 0) return "0,00";
return new Intl.NumberFormat("ro-RO", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
// Compact format for mobile totals (e.g., "34.922" instead of "34.922,02 RON")
const formatCompact = (amount) => {
if (!amount || amount === 0) return "0";
const absAmount = Math.abs(amount);
if (absAmount >= 1000000) {
return new Intl.NumberFormat("ro-RO", {
maximumFractionDigits: 1,
}).format(amount / 1000000) + "M";
}
return new Intl.NumberFormat("ro-RO", {
maximumFractionDigits: 0,
}).format(amount);
};
const formatDate = (dateString) => {
if (!dateString) return "";
try {
return format(new Date(dateString), "dd/MM/yyyy", { locale: ro });
} catch (error) {
return dateString;
}
};
const handleCompanyChange = async () => {
if (!selectedCompanyId.value) return;
const company = companyStore.getCompanyById(selectedCompanyId.value);
if (company) {
companyStore.setSelectedCompany(company);
await loadInvoices();
}
};
const handleFilterChange = async () => {
pagination.value.page = 1;
await loadInvoices();
};
const handleSearchChange = (() => {
let timeout;
return () => {
clearTimeout(timeout);
timeout = setTimeout(async () => {
pagination.value.page = 1;
await loadInvoices();
}, 500);
};
})();
const clearFilters = async () => {
filters.value = {
type: "CLIENTI",
paymentStatus: "neachitate",
searchTerm: "",
cont: "",
};
pagination.value.page = 1;
await loadInvoices();
};
const refreshData = async () => {
await loadInvoices();
toast.add({
severity: "success",
summary: "Actualizare reușită",
detail: "Facturile au fost actualizate cu succes",
life: 3000,
});
};
const loadInvoices = async () => {
if (!companyStore.selectedCompany) return;
if (!periodStore.selectedPeriod) return; // Wait for period to be loaded
try {
// Set filters in store FIRST
invoicesStore.setFilters(filters.value);
invoicesStore.setPagination(pagination.value);
// Use luna/an from period store directly
const { luna, an } = periodStore.selectedPeriod;
const params = {
partner_type: filters.value.type,
page: pagination.value.page,
page_size: pagination.value.rows,
only_unpaid: filters.value.paymentStatus === "neachitate",
luna: luna,
an: an,
};
if (filters.value.searchTerm) {
params.partner_name = filters.value.searchTerm;
}
if (filters.value.cont) {
params.cont = filters.value.cont;
}
await invoicesStore.loadInvoices(
companyStore.selectedCompany.id_firma,
params,
);
} catch (error) {
console.error("Failed to load invoices:", error);
toast.add({
severity: "error",
summary: "Eroare",
detail: "Nu s-au putut încărca facturile",
life: 5000,
});
}
};
const onPageChange = async (event) => {
// PrimeVue pagination is 0-indexed, backend expects 1-indexed
pagination.value.page = event.page + 1;
pagination.value.rows = event.rows;
await loadInvoices();
};
const onSort = async (event) => {
// Handle sorting if needed
await loadInvoices();
};
// Export methods - Fetch ALL data (not just current page)
const fetchAllInvoicesData = async () => {
if (!companyStore.selectedCompany) return [];
if (!periodStore.selectedPeriod) return [];
try {
// Use luna/an from period store
const { luna, an } = periodStore.selectedPeriod;
const params = {
company: companyStore.selectedCompany.id_firma,
partner_type: filters.value.type,
page: 1,
page_size: 999999, // Get all data
only_unpaid: filters.value.paymentStatus === "neachitate",
luna: luna,
an: an,
};
if (filters.value.searchTerm) {
params.partner_name = filters.value.searchTerm;
}
if (filters.value.cont) {
params.cont = filters.value.cont;
}
const apiService = (await import("../services/api")).apiService;
const response = await api.get("/invoices/", { params });
return response.data.invoices || [];
} catch (error) {
console.error("Failed to fetch all invoices data:", error);
return [];
}
};
const exportExcel = async () => {
if (!invoicesStore.hasInvoices) {
toast.add({
severity: "warn",
summary: "Nu există date",
detail: "Nu există facturi de exportat",
life: 3000,
});
return;
}
// Fetch ALL data for export (not just current page)
toast.add({
severity: "info",
summary: "Se pregătește exportul",
detail: "Se încarcă toate datele...",
life: 2000,
});
const allData = await fetchAllInvoicesData();
if (allData.length === 0) {
toast.add({
severity: "error",
summary: "Eroare",
detail: "Nu s-au putut prelua datele pentru export",
life: 3000,
});
return;
}
// Prepare data for export - Format dates as strings for Excel
const exportData = allData.map((row) => ({
Cont: row.cont || "",
"Numar Doc.": row.nract,
"Data Doc.": row.dataact ? formatDate(row.dataact) : "",
"Data Scadenta": row.datascad ? formatDate(row.datascad) : "",
Partener: row.nume,
Facturat: parseFloat(row.totctva) || 0,
Achitat: parseFloat(row.achitat) || 0,
Sold: parseFloat(row.soldfinal) || 0,
Valuta: row.valuta || "RON",
}));
const invoiceType =
filters.value.type === "CLIENTI" ? "Clienti" : "Furnizori";
const result = exportToExcel(
exportData,
`facturi_${invoiceType}_${companyStore.selectedCompany.name.replace(/\s+/g, "_")}`,
`Facturi ${invoiceType}`,
);
if (result.success) {
toast.add({
severity: "success",
summary: "Export reușit",
detail: `${allData.length} facturi exportate cu succes`,
life: 3000,
});
} else {
toast.add({
severity: "error",
summary: "Eroare la export",
detail: "Nu s-a putut genera fișierul Excel",
life: 3000,
});
}
};
const exportPDF = async () => {
if (!invoicesStore.hasInvoices) {
toast.add({
severity: "warn",
summary: "Nu există date",
detail: "Nu există facturi de exportat",
life: 3000,
});
return;
}
// Fetch ALL data for export (not just current page)
toast.add({
severity: "info",
summary: "Se pregătește exportul",
detail: "Se încarcă toate datele...",
life: 2000,
});
const allData = await fetchAllInvoicesData();
if (allData.length === 0) {
toast.add({
severity: "error",
summary: "Eroare",
detail: "Nu s-au putut prelua datele pentru export",
life: 3000,
});
return;
}
// Prepare data for export - Format dates as strings for PDF
const exportData = allData.map((row) => ({
cont: row.cont || "",
nract: row.nract,
dataact: row.dataact ? formatDate(row.dataact) : "",
datascad: row.datascad ? formatDate(row.datascad) : "",
nume: row.nume,
totctva: row.totctva,
achitat: row.achitat,
soldfinal: row.soldfinal,
valuta: row.valuta || "RON",
}));
// Define columns for PDF with optimized widths (proportional percentages)
// Compact numeric columns, more space for Partener (company names)
const columns = [
{ field: "cont", header: "Cont", type: "text", width: 0.06 }, // 6% - Compact account numbers
{ field: "nract", header: "Numar Doc.", type: "text", width: 0.08 }, // 8% - Document numbers
{ field: "dataact", header: "Data Doc.", type: "text", width: 0.08 }, // 8% - Dates
{ field: "datascad", header: "Data Scadenta", type: "text", width: 0.09 }, // 9% - Due dates
{ field: "nume", header: "Partener", type: "text", width: 0.37 }, // 37% - MORE SPACE for company names
{ field: "totctva", header: "Facturat", type: "number", width: 0.09 }, // 9% - Compact numbers
{ field: "achitat", header: "Achitat", type: "number", width: 0.09 }, // 9% - Compact numbers
{ field: "soldfinal", header: "Sold", type: "number", width: 0.09 }, // 9% - Compact numbers
{ field: "valuta", header: "Valuta", type: "text", width: 0.05 }, // 5% - Very compact (just "RON")
];
const invoiceType =
filters.value.type === "CLIENTI" ? "Clienti" : "Furnizori";
// Build period string - ALWAYS show accounting period (like Trial Balance)
let periodText = accountingPeriodText.value || "";
// Optionally add date filter range if applied
if (filters.value.dateFrom || filters.value.dateTo) {
const fromDate = filters.value.dateFrom
? formatDate(filters.value.dateFrom)
: "început";
const toDate = filters.value.dateTo
? formatDate(filters.value.dateTo)
: "prezent";
periodText += periodText
? ` | Filtru dată: ${fromDate} - ${toDate}`
: `Filtru dată: ${fromDate} - ${toDate}`;
}
const result = exportToPDF(
exportData,
columns,
`facturi-${invoiceType.toLowerCase()}-${companyStore.selectedCompany.name.replace(/\s+/g, "-")}`,
{
companyName: companyStore.selectedCompany?.name || "",
title: `Facturi ${invoiceType}`,
period: periodText,
},
);
if (result.success) {
toast.add({
severity: "success",
summary: "Export reușit",
detail: `${allData.length} facturi exportate cu succes`,
life: 3000,
});
} else {
toast.add({
severity: "error",
summary: "Eroare la export",
detail: "Nu s-a putut genera fișierul PDF",
life: 3000,
});
}
};
// Lifecycle
onMounted(async () => {
// Add resize listener for mobile detection
window.addEventListener("resize", handleResize);
// Load companies if not loaded
if (!companyStore.hasCompanies) {
await companyStore.loadCompanies();
}
// Don't load here - let period watch handle it with immediate: true
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
});
// Watch for company changes
watch(
() => companyStore.selectedCompany,
async (newCompany) => {
if (newCompany && periodStore.selectedPeriod) {
await loadInvoices();
}
},
);
// Watch for period changes - reload data when period changes
watch(
() => periodStore.selectedPeriod,
async (newPeriod) => {
if (newPeriod && companyStore.selectedCompany) {
await loadInvoices();
}
},
{ immediate: true },
);
</script>
<style scoped>
.invoices {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.page-header {
margin-bottom: 2rem;
}
.page-title {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-color);
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.company-selection-card,
.filters-card {
margin-bottom: 2rem;
}
.search-col {
grid-column: span 2;
}
.filters-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
padding-top: 1rem;
border-top: 1px solid var(--surface-border);
}
.table-card {
margin-bottom: 2rem;
}
.no-data,
.loading-table {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-color-secondary);
}
.no-data i,
.loading-table i {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.text-right {
text-align: right;
width: 100%;
display: block;
}
.text-center {
text-align: center;
width: 100%;
display: block;
}
/* Enhanced striped rows with better contrast - same as Trial Balance */
.table-card :deep(.p-datatable .p-datatable-tbody > tr) {
transition: background-color 0.2s ease;
}
.table-card :deep(.p-datatable .p-datatable-tbody > tr:nth-child(odd)) {
background-color: #ffffff;
}
.table-card :deep(.p-datatable .p-datatable-tbody > tr:nth-child(even)) {
background-color: #f8f9fa;
}
.table-card :deep(.p-datatable .p-datatable-tbody > tr:hover) {
background-color: #e3f2fd !important;
cursor: pointer;
}
/* Responsive design */
@media (max-width: 768px) {
.invoices {
padding: 1rem;
}
.page-title {
font-size: 2rem;
}
.search-col {
grid-column: span 1;
}
.filters-actions {
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,291 @@
<template>
<main class="main-content">
<div class="app-container">
<!-- Page Header -->
<div class="page-header">
<h1 class="page-title">Telegram Bot</h1>
<p class="page-subtitle">
Conectează-ți contul pentru acces rapid din Telegram
</p>
</div>
<!-- Loading State -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>Se generează codul...</p>
</div>
<!-- Main Card -->
<div v-else class="card">
<!-- Generate Button -->
<div class="generate-section">
<button
@click="generateCode"
:disabled="loading"
class="btn btn-primary btn-lg"
>
{{ loading ? "Se generează..." : "Generează Cod" }}
</button>
</div>
<!-- Code Display & Actions -->
<div v-if="linkingCode" class="code-section">
<!-- Code Display -->
<div class="code-display">
<div class="code-header">
<span class="code-label">Cod</span>
<span class="code-timer">{{ formatTime(timeRemaining) }}</span>
</div>
<div class="code-value">{{ linkingCode }}</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<a
:href="telegramDeepLink"
target="_blank"
rel="noopener noreferrer"
class="btn btn-primary action-btn"
>
Deschide Telegram
</a>
<Button
:label="showQR ? 'Ascunde QR' : 'Arată QR'"
@click="showQR = !showQR"
class="action-btn"
outlined
/>
<Button
label="Copiază Cod"
@click="copyCode"
class="action-btn"
outlined
/>
</div>
<!-- QR Code Display -->
<div v-if="showQR" class="qr-section">
<QRCodeVue :value="telegramDeepLink" :size="200" level="H" />
</div>
</div>
</div>
</div>
</main>
</template>
<script setup>
import { ref, computed, onUnmounted } from "vue";
import { useToast } from "primevue/usetoast";
import Button from "primevue/button";
import Toast from "primevue/toast";
import QRCodeVue from "qrcode.vue";
import api from "@reports/services/api";
const toast = useToast();
// State
const linkingCode = ref("");
const timeRemaining = ref(0);
const loading = ref(false);
const showQR = ref(false);
let countdownInterval = null;
// Config
const BOT_USERNAME =
import.meta.env.VITE_TELEGRAM_BOT_USERNAME || "roa2web_bot";
// Computed
const telegramDeepLink = computed(() => {
if (!linkingCode.value) return "";
return `https://t.me/${BOT_USERNAME}?start=${linkingCode.value}`;
});
// Methods
const generateCode = async () => {
loading.value = true;
showQR.value = false;
try {
const response = await api.post("/telegram/auth/generate-code");
linkingCode.value = response.data.linking_code;
timeRemaining.value = response.data.expires_in_minutes * 60;
toast.add({
severity: "success",
summary: "Cod Generat",
detail: "Alege o metodă de conectare",
life: 3000,
});
startCountdown();
} catch (error) {
console.error("Error generating code:", error);
toast.add({
severity: "error",
summary: "Eroare",
detail: error.response?.data?.detail || "Nu am putut genera codul",
life: 5000,
});
} finally {
loading.value = false;
}
};
const startCountdown = () => {
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(() => {
if (timeRemaining.value > 0) {
timeRemaining.value--;
} else {
clearInterval(countdownInterval);
linkingCode.value = "";
toast.add({
severity: "warn",
summary: "Cod Expirat",
detail: "Generează un cod nou",
life: 4000,
});
}
}, 1000);
};
const formatTime = (seconds) => {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}:${secs.toString().padStart(2, "0")}`;
};
const copyCode = async () => {
try {
await navigator.clipboard.writeText(linkingCode.value);
toast.add({
severity: "success",
summary: "Copiat",
detail: "Cod copiat în clipboard",
life: 2000,
});
} catch (error) {
const tempInput = document.createElement("input");
tempInput.value = linkingCode.value;
document.body.appendChild(tempInput);
tempInput.select();
document.execCommand("copy");
document.body.removeChild(tempInput);
toast.add({
severity: "success",
summary: "Copiat",
life: 2000,
});
}
};
onUnmounted(() => {
if (countdownInterval) clearInterval(countdownInterval);
});
</script>
<style scoped>
/* Page Header - Uses global .page-header pattern */
/* Loading - Uses global .loading-spinner pattern */
/* Card - Uses global .card pattern */
/* Generate Section */
.generate-section {
display: flex;
justify-content: center;
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--color-border);
}
/* Generate button - Uses global .btn .btn-primary pattern */
/* Code Section */
.code-section {
margin-top: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
/* Code Display */
.code-display {
background: linear-gradient(
135deg,
rgba(67, 97, 238, 0.08),
rgba(67, 97, 238, 0.02)
);
border: 2px solid var(--color-primary);
border-radius: var(--radius-md);
padding: var(--space-md);
text-align: center;
}
.code-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-xs);
font-size: var(--text-sm);
}
.code-label {
color: var(--color-text-secondary);
font-weight: var(--font-semibold);
}
.code-timer {
color: var(--color-primary);
font-weight: var(--font-bold);
font-family: "Courier New", monospace;
}
.code-value {
font-size: 2rem;
font-weight: var(--font-bold);
color: var(--color-primary);
letter-spacing: 0.3em;
font-family: "Courier New", monospace;
}
/* Action Buttons - Use global .btn patterns */
.action-buttons {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.action-btn {
flex: 1;
min-width: 160px;
justify-content: center;
}
/* QR Section */
.qr-section {
display: flex;
justify-content: center;
padding: var(--space-lg);
background: white;
border-radius: var(--radius-md);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
/* Responsive - Telegram-specific adjustments */
@media (max-width: 768px) {
.code-value {
font-size: 1.5rem;
letter-spacing: 0.2em;
}
.action-buttons {
flex-direction: column;
}
.action-btn {
width: 100%;
min-width: unset;
}
}
</style>

View File

@@ -0,0 +1,956 @@
<template>
<div class="app-container">
<div class="trial-balance">
<!-- Page Header -->
<div class="page-header">
<h1 class="page-title">
<i class="pi pi-calculator"></i>
Balanță de Verificare
</h1>
</div>
<!-- Company Selection -->
<Card v-if="!companyStore.selectedCompany" class="company-selection-card">
<template #content>
<div class="company-selection">
<p class="text-color-secondary mb-3">
Selectați o companie pentru a vizualiza balanța de verificare:
</p>
<Dropdown
v-model="selectedCompanyId"
:options="companyStore.companyListFormatted"
option-label="displayName"
option-value="id_firma"
placeholder="Alegeți compania"
class="w-full"
@change="handleCompanyChange"
/>
</div>
</template>
</Card>
<!-- Mobile: Two-row toolbar -->
<div v-if="isMobile && companyStore.selectedCompany" class="mobile-toolbar-container">
<!-- Row 1: Icon-only action buttons -->
<div class="mobile-toolbar-buttons">
<Button
icon="pi pi-filter"
:class="{ 'filter-active': hasActiveFilters }"
class="p-button-text"
@click="showFilters = !showFilters"
v-tooltip.bottom="'Filtre'"
/>
<Button
icon="pi pi-filter-slash"
class="p-button-text"
@click="clearFilters"
v-tooltip.bottom="'Resetează'"
/>
<Button
icon="pi pi-file-excel"
class="p-button-text p-button-success"
@click="exportExcel"
:disabled="!trialBalanceStore.hasData"
v-tooltip.bottom="'Excel'"
/>
<Button
icon="pi pi-file-pdf"
class="p-button-text p-button-danger"
@click="exportPDF"
:disabled="!trialBalanceStore.hasData"
v-tooltip.bottom="'PDF'"
/>
<Button
icon="pi pi-refresh"
class="p-button-text"
:loading="trialBalanceStore.isLoading"
@click="refreshData"
v-tooltip.bottom="'Actualizează'"
/>
</div>
<!-- Row 2: Totals (unified grid format) -->
<div class="mobile-toolbar-totals">
<div class="mobile-totals-grid two-totals">
<div class="total-item">
<span class="total-label">Sold D:</span>
<span class="total-value">{{ formatCompact(trialBalanceStore.totals.total_sold_final_debit) }}</span>
</div>
<div class="total-item">
<span class="total-label">Sold C:</span>
<span class="total-value">{{ formatCompact(trialBalanceStore.totals.total_sold_final_credit) }}</span>
</div>
</div>
</div>
</div>
<!-- Filters Section -->
<Card v-if="companyStore.selectedCompany && (!isMobile || showFilters)" class="filters-card">
<template #content>
<div class="form">
<div class="form-row">
<!-- Cont Filter -->
<div class="form-col">
<div class="form-group">
<label class="form-label">Număr Cont</label>
<InputText
v-model="localFilters.cont"
placeholder="Ex: 512, 4111"
class="w-full"
@input="handleFilterChange"
/>
</div>
</div>
<!-- Denumire Filter -->
<div class="form-col search-col">
<div class="form-group">
<label class="form-label">Denumire Cont</label>
<InputText
v-model="localFilters.denumire"
placeholder="Căutare după denumire..."
class="w-full"
@input="handleSearchChange"
/>
</div>
</div>
</div>
<!-- Desktop: Action buttons -->
<div v-if="!isMobile" class="form-actions">
<Button
icon="pi pi-filter-slash"
label="Resetează Filtre"
class="p-button-outlined p-button-secondary"
@click="clearFilters"
/>
<Button
icon="pi pi-file-excel"
label="Export Excel"
class="p-button-outlined p-button-success"
@click="exportExcel"
:disabled="!trialBalanceStore.hasData"
/>
<Button
icon="pi pi-file-pdf"
label="Export PDF"
class="p-button-outlined p-button-danger"
@click="exportPDF"
:disabled="!trialBalanceStore.hasData"
/>
<Button
icon="pi pi-refresh"
label="Actualizează"
:loading="trialBalanceStore.isLoading"
@click="refreshData"
/>
</div>
</div>
</template>
</Card>
<!-- Summary Totals - Uses shared stats.css (hidden on mobile - compact in toolbar) -->
<!-- Totaluri din TOATE înregistrările filtrate (nu doar pagina curentă) -->
<div v-if="!isMobile && companyStore.selectedCompany && trialBalanceStore.hasData" class="summary-stats-inline">
<div class="stat-item">
<span class="stat-label">Sume Prec. D:</span>
<span class="stat-value">{{ formatCurrency(trialBalanceStore.totals.total_sold_precedent_debit) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Sume Prec. C:</span>
<span class="stat-value">{{ formatCurrency(trialBalanceStore.totals.total_sold_precedent_credit) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Rulaj D:</span>
<span class="stat-value">{{ formatCurrency(trialBalanceStore.totals.total_rulaj_lunar_debit) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Rulaj C:</span>
<span class="stat-value">{{ formatCurrency(trialBalanceStore.totals.total_rulaj_lunar_credit) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Sold Final D:</span>
<span class="stat-value">{{ formatCurrency(trialBalanceStore.totals.total_sold_final_debit) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Sold Final C:</span>
<span class="stat-value">{{ formatCurrency(trialBalanceStore.totals.total_sold_final_credit) }}</span>
</div>
</div>
<!-- Trial Balance Table -->
<Card v-if="companyStore.selectedCompany" class="table-card">
<template #content>
<!-- Mobile: Card Layout -->
<div v-if="isMobile" class="mobile-card-list">
<div
v-for="account in trialBalanceStore.trialBalanceData.filter(a => a.sold_final_debit > 0 || a.sold_final_credit > 0)"
:key="account.cont"
class="mobile-data-card"
>
<div class="card-header">
<strong>{{ account.cont }}</strong>&nbsp;&nbsp;{{ truncate(account.denumire, 30) }}
</div>
<div class="card-row">
<span></span>
<span class="card-amount">
{{ account.sold_final_debit > 0
? formatCurrency(account.sold_final_debit) + ' D'
: formatCurrency(account.sold_final_credit) + ' C' }}
</span>
</div>
</div>
<div v-if="trialBalanceStore.trialBalanceData.filter(a => a.sold_final_debit > 0 || a.sold_final_credit > 0).length === 0" class="mobile-empty">
<i class="pi pi-info-circle"></i>
<p>Nu au fost găsite date</p>
</div>
</div>
<!-- Desktop: DataTable -->
<DataTable
v-if="!isMobile"
:value="trialBalanceStore.trialBalanceData"
:loading="trialBalanceStore.isLoading"
:paginator="true"
:rows="trialBalanceStore.pagination.pageSize"
:total-records="trialBalanceStore.pagination.totalItems"
:lazy="true"
:striped-rows="true"
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rows-per-page-options="[25, 50, 100]"
current-page-report-template="Afișare {first} - {last} din {totalRecords} înregistrări"
responsive-layout="scroll"
@page="onPageChange"
@sort="onSort"
>
<template #empty>
<div class="table-empty">
<i class="pi pi-info-circle table-empty-icon"></i>
<p class="table-empty-message">
Nu au fost găsite date pentru perioada selectată
</p>
</div>
</template>
<template #loading>
<div class="loading-state">
<ProgressSpinner />
<p>Se încarcă balanța de verificare...</p>
</div>
</template>
<Column
field="cont"
header="Cont"
sortable
:style="{ width: '8%' }"
>
<template #body="slotProps">
<strong>{{ slotProps.data.cont }}</strong>
</template>
</Column>
<Column
field="denumire"
header="Denumire Cont"
sortable
:style="{ width: '20%' }"
/>
<Column
field="sold_precedent_debit"
header="Sume Prec. D"
sortable
:style="{ width: '10%' }"
>
<template #body="slotProps">
<div class="text-right">
{{ formatCurrency(slotProps.data.sold_precedent_debit) }}
</div>
</template>
</Column>
<Column
field="sold_precedent_credit"
header="Sume Prec. C"
sortable
:style="{ width: '10%' }"
>
<template #body="slotProps">
<div class="text-right">
{{ formatCurrency(slotProps.data.sold_precedent_credit) }}
</div>
</template>
</Column>
<Column
field="rulaj_lunar_debit"
header="Rulaj D"
sortable
:style="{ width: '10%' }"
>
<template #body="slotProps">
<div class="text-right">
{{ formatCurrency(slotProps.data.rulaj_lunar_debit) }}
</div>
</template>
</Column>
<Column
field="rulaj_lunar_credit"
header="Rulaj C"
sortable
:style="{ width: '10%' }"
>
<template #body="slotProps">
<div class="text-right">
{{ formatCurrency(slotProps.data.rulaj_lunar_credit) }}
</div>
</template>
</Column>
<Column
field="sold_final_debit"
header="Sold Final D"
sortable
:style="{ width: '11%' }"
>
<template #body="slotProps">
<div class="text-right">
{{ formatCurrency(slotProps.data.sold_final_debit) }}
</div>
</template>
</Column>
<Column
field="sold_final_credit"
header="Sold Final C"
sortable
:style="{ width: '11%' }"
>
<template #body="slotProps">
<div class="text-right">
{{ formatCurrency(slotProps.data.sold_final_credit) }}
</div>
</template>
</Column>
</DataTable>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useToast } from "primevue/usetoast";
import { useCompanyStore } from "@reports/stores/sharedStores";
import { useTrialBalanceStore } from "@reports/stores/trialBalance";
import { useAccountingPeriodStore } from "@reports/stores/sharedStores";
import { exportToExcel, exportToPDF } from "@reports/utils/exportUtils";
const toast = useToast();
const companyStore = useCompanyStore();
const trialBalanceStore = useTrialBalanceStore();
const periodStore = useAccountingPeriodStore();
// State
const selectedCompanyId = ref(companyStore.selectedCompany?.id_firma || null);
// Mobile state
const isMobile = ref(window.innerWidth < 768);
const showFilters = ref(false);
const actionsMenu = ref(null);
// Handle window resize
const handleResize = () => {
isMobile.value = window.innerWidth < 768;
if (!isMobile.value) {
showFilters.value = false; // Reset when switching to desktop
}
};
const localFilters = ref({
cont: "",
denumire: "",
});
// Computed
const currentPeriodText = computed(() => {
// Use the global period store
return periodStore.selectedPeriod?.display_name || "";
});
// Mobile: Check if any filter is active (non-default value)
const hasActiveFilters = computed(() => {
return localFilters.value.cont !== "" || localFilters.value.denumire !== "";
});
// Mobile: Actions menu items
const actionMenuItems = computed(() => [
{
label: "Resetează Filtre",
icon: "pi pi-filter-slash",
command: clearFilters,
},
{
label: "Export Excel",
icon: "pi pi-file-excel",
command: exportExcel,
disabled: !trialBalanceStore.hasData,
},
{
label: "Export PDF",
icon: "pi pi-file-pdf",
command: exportPDF,
disabled: !trialBalanceStore.hasData,
},
{ separator: true },
{
label: "Actualizează",
icon: "pi pi-refresh",
command: refreshData,
},
]);
// Methods
const formatCurrency = (amount) => {
if (!amount || amount === 0) return "0,00";
return new Intl.NumberFormat("ro-RO", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
// Compact format for mobile totals (e.g., "449.881" instead of "449.881,12")
const formatCompact = (amount) => {
if (!amount || amount === 0) return "0";
const absAmount = Math.abs(amount);
if (absAmount >= 1000000) {
return new Intl.NumberFormat("ro-RO", {
maximumFractionDigits: 1,
}).format(amount / 1000000) + "M";
}
return new Intl.NumberFormat("ro-RO", {
maximumFractionDigits: 0,
}).format(amount);
};
// Truncate text for mobile cards
const truncate = (text, maxLength) => {
if (!text || text.length <= maxLength) return text;
return text.substring(0, maxLength) + "...";
};
const handleCompanyChange = async () => {
if (!selectedCompanyId.value) return;
const company = companyStore.getCompanyById(selectedCompanyId.value);
if (company) {
companyStore.setSelectedCompany(company);
await loadTrialBalance();
}
};
const handleFilterChange = async () => {
await applyFilters();
};
const handleSearchChange = (() => {
let timeout;
return () => {
clearTimeout(timeout);
timeout = setTimeout(async () => {
await applyFilters();
}, 500);
};
})();
const applyFilters = async () => {
if (!companyStore.selectedCompany) return;
await trialBalanceStore.applyFilters(
{
cont: localFilters.value.cont,
denumire: localFilters.value.denumire,
},
companyStore.selectedCompany.id_firma,
);
};
const clearFilters = async () => {
localFilters.value = {
cont: "",
denumire: "",
};
await trialBalanceStore.clearFilters(companyStore.selectedCompany.id_firma);
};
const refreshData = async () => {
await loadTrialBalance();
toast.add({
severity: "success",
summary: "Actualizare reușită",
detail: "Balanța de verificare a fost actualizată cu succes",
life: 3000,
});
};
const loadTrialBalance = async () => {
if (!companyStore.selectedCompany) return;
try {
await trialBalanceStore.fetchTrialBalance(
companyStore.selectedCompany.id_firma,
);
} catch (error) {
console.error("Failed to load trial balance:", error);
toast.add({
severity: "error",
summary: "Eroare",
detail: "Nu s-a putut încărca balanța de verificare",
life: 5000,
});
}
};
const onPageChange = async (event) => {
if (!companyStore.selectedCompany) return;
await trialBalanceStore.changePage(
event.page + 1,
companyStore.selectedCompany.id_firma,
);
};
const onSort = async (event) => {
if (!companyStore.selectedCompany) return;
const sortBy = event.sortField?.toUpperCase() || "CONT";
const sortOrder = event.sortOrder === 1 ? "asc" : "desc";
await trialBalanceStore.sort(
sortBy,
sortOrder,
companyStore.selectedCompany.id_firma,
);
};
// Export methods - Fetch ALL data (not just current page)
const fetchAllTrialBalanceData = async () => {
if (!companyStore.selectedCompany) return [];
try {
const params = {
company: companyStore.selectedCompany.id_firma,
luna: trialBalanceStore.filters.luna,
an: trialBalanceStore.filters.an,
page: 1,
page_size: 999999, // Get all data
sort_by: trialBalanceStore.sorting.sortBy,
sort_order: trialBalanceStore.sorting.sortOrder,
};
// Add optional filters
if (trialBalanceStore.filters.cont) {
params.cont_filter = trialBalanceStore.filters.cont;
}
if (trialBalanceStore.filters.denumire) {
params.denumire_filter = trialBalanceStore.filters.denumire;
}
const apiService = (await import("../services/api")).apiService;
const response = await api.get("/trial-balance/", { params });
if (response.data.success) {
return response.data.data.items || [];
}
return [];
} catch (error) {
console.error("Failed to fetch all trial balance data:", error);
return [];
}
};
const exportExcel = async () => {
if (!trialBalanceStore.hasData) {
toast.add({
severity: "warn",
summary: "Nu există date",
detail: "Nu există date de exportat",
life: 3000,
});
return;
}
// Fetch ALL data for export (not just current page)
toast.add({
severity: "info",
summary: "Se pregătește exportul",
detail: "Se încarcă toate datele...",
life: 2000,
});
const allData = await fetchAllTrialBalanceData();
if (allData.length === 0) {
toast.add({
severity: "error",
summary: "Eroare",
detail: "Nu s-au putut prelua datele pentru export",
life: 3000,
});
return;
}
// Prepare data for export - Use raw numbers (not formatted) so Excel treats them as numbers
const exportData = allData.map((row) => ({
Cont: row.cont,
Denumire: row.denumire,
"Sume Prec. D": parseFloat(row.sold_precedent_debit) || 0,
"Sume Prec. C": parseFloat(row.sold_precedent_credit) || 0,
"Rulaj Lunar D": parseFloat(row.rulaj_lunar_debit) || 0,
"Rulaj Lunar C": parseFloat(row.rulaj_lunar_credit) || 0,
"Sold Final D": parseFloat(row.sold_final_debit) || 0,
"Sold Final C": parseFloat(row.sold_final_credit) || 0,
}));
const result = exportToExcel(
exportData,
`balanta_verificare_${currentPeriodText.value.replace(/\s+/g, "_")}`,
"Balanță de Verificare",
);
if (result.success) {
toast.add({
severity: "success",
summary: "Export reușit",
detail: `${allData.length} înregistrări exportate cu succes`,
life: 3000,
});
} else {
toast.add({
severity: "error",
summary: "Eroare la export",
detail: "Nu s-a putut genera fișierul Excel",
life: 3000,
});
}
};
const exportPDF = async () => {
if (!trialBalanceStore.hasData) {
toast.add({
severity: "warn",
summary: "Nu există date",
detail: "Nu există date de exportat",
life: 3000,
});
return;
}
// Fetch ALL data for export (not just current page)
toast.add({
severity: "info",
summary: "Se pregătește exportul",
detail: "Se încarcă toate datele...",
life: 2000,
});
const allData = await fetchAllTrialBalanceData();
if (allData.length === 0) {
toast.add({
severity: "error",
summary: "Eroare",
detail: "Nu s-au putut prelua datele pentru export",
life: 3000,
});
return;
}
// Group data by account class (first digit) and add class totals + grand total
const groupDataWithTotals = (data) => {
// Sort by account number
const sortedData = [...data].sort((a, b) =>
String(a.cont).localeCompare(String(b.cont))
);
const result = [];
const classTotals = {}; // { '1': {sume_prec_d, sume_prec_c, ...}, '2': {...}, ... }
const grandTotal = {
sold_precedent_debit: 0,
sold_precedent_credit: 0,
rulaj_lunar_debit: 0,
rulaj_lunar_credit: 0,
sold_final_debit: 0,
sold_final_credit: 0,
};
let currentClass = null;
sortedData.forEach((row) => {
const accountClass = String(row.cont).charAt(0);
// Initialize class totals if new class
if (!classTotals[accountClass]) {
classTotals[accountClass] = {
sold_precedent_debit: 0,
sold_precedent_credit: 0,
rulaj_lunar_debit: 0,
rulaj_lunar_credit: 0,
sold_final_debit: 0,
sold_final_credit: 0,
};
}
// If class changed and we have a previous class, add its total row
if (currentClass !== null && currentClass !== accountClass) {
result.push({
cont: "",
denumire: `TOTAL CLASA ${currentClass}`,
sold_precedent_debit: classTotals[currentClass].sold_precedent_debit,
sold_precedent_credit: classTotals[currentClass].sold_precedent_credit,
rulaj_lunar_debit: classTotals[currentClass].rulaj_lunar_debit,
rulaj_lunar_credit: classTotals[currentClass].rulaj_lunar_credit,
sold_final_debit: classTotals[currentClass].sold_final_debit,
sold_final_credit: classTotals[currentClass].sold_final_credit,
_isTotal: true,
});
}
currentClass = accountClass;
// Add the regular row
result.push({
cont: row.cont,
denumire: row.denumire,
sold_precedent_debit: row.sold_precedent_debit,
sold_precedent_credit: row.sold_precedent_credit,
rulaj_lunar_debit: row.rulaj_lunar_debit,
rulaj_lunar_credit: row.rulaj_lunar_credit,
sold_final_debit: row.sold_final_debit,
sold_final_credit: row.sold_final_credit,
});
// Accumulate class totals
classTotals[accountClass].sold_precedent_debit += parseFloat(row.sold_precedent_debit) || 0;
classTotals[accountClass].sold_precedent_credit += parseFloat(row.sold_precedent_credit) || 0;
classTotals[accountClass].rulaj_lunar_debit += parseFloat(row.rulaj_lunar_debit) || 0;
classTotals[accountClass].rulaj_lunar_credit += parseFloat(row.rulaj_lunar_credit) || 0;
classTotals[accountClass].sold_final_debit += parseFloat(row.sold_final_debit) || 0;
classTotals[accountClass].sold_final_credit += parseFloat(row.sold_final_credit) || 0;
// Accumulate grand total
grandTotal.sold_precedent_debit += parseFloat(row.sold_precedent_debit) || 0;
grandTotal.sold_precedent_credit += parseFloat(row.sold_precedent_credit) || 0;
grandTotal.rulaj_lunar_debit += parseFloat(row.rulaj_lunar_debit) || 0;
grandTotal.rulaj_lunar_credit += parseFloat(row.rulaj_lunar_credit) || 0;
grandTotal.sold_final_debit += parseFloat(row.sold_final_debit) || 0;
grandTotal.sold_final_credit += parseFloat(row.sold_final_credit) || 0;
});
// Add last class total
if (currentClass !== null) {
result.push({
cont: "",
denumire: `TOTAL CLASA ${currentClass}`,
sold_precedent_debit: classTotals[currentClass].sold_precedent_debit,
sold_precedent_credit: classTotals[currentClass].sold_precedent_credit,
rulaj_lunar_debit: classTotals[currentClass].rulaj_lunar_debit,
rulaj_lunar_credit: classTotals[currentClass].rulaj_lunar_credit,
sold_final_debit: classTotals[currentClass].sold_final_debit,
sold_final_credit: classTotals[currentClass].sold_final_credit,
_isTotal: true,
});
}
// Add grand total row
result.push({
cont: "",
denumire: "TOTAL GENERAL",
sold_precedent_debit: grandTotal.sold_precedent_debit,
sold_precedent_credit: grandTotal.sold_precedent_credit,
rulaj_lunar_debit: grandTotal.rulaj_lunar_debit,
rulaj_lunar_credit: grandTotal.rulaj_lunar_credit,
sold_final_debit: grandTotal.sold_final_debit,
sold_final_credit: grandTotal.sold_final_credit,
_isTotal: true,
_isGrandTotal: true,
});
return result;
};
// Prepare data for export with class totals and grand total
const exportData = groupDataWithTotals(allData);
// Define columns for PDF with proper configuration
// A4 landscape width: ~297mm total, margins 8mm left+right = 281mm usable
// Use 'auto' width to fill entire page width
const columns = [
{ field: "cont", header: "Cont", type: "text", width: "auto" },
{ field: "denumire", header: "Denumire Cont", type: "text", width: "auto" },
{
field: "sold_precedent_debit",
header: "Sume Prec. D",
type: "number",
width: "auto",
},
{
field: "sold_precedent_credit",
header: "Sume Prec. C",
type: "number",
width: "auto",
},
{
field: "rulaj_lunar_debit",
header: "Rulaj D",
type: "number",
width: "auto",
},
{
field: "rulaj_lunar_credit",
header: "Rulaj C",
type: "number",
width: "auto",
},
{
field: "sold_final_debit",
header: "Sold Final D",
type: "number",
width: "auto",
},
{
field: "sold_final_credit",
header: "Sold Final C",
type: "number",
width: "auto",
},
];
const result = exportToPDF(
exportData,
columns,
`balanta-verificare-${currentPeriodText.value.replace(/\s+/g, "-")}`,
{
companyName: companyStore.selectedCompany?.name || "",
title: "Balanta de Verificare",
period: currentPeriodText.value,
},
);
if (result.success) {
toast.add({
severity: "success",
summary: "Export reușit",
detail: `${allData.length} înregistrări exportate cu succes`,
life: 3000,
});
} else {
toast.add({
severity: "error",
summary: "Eroare la export",
detail: "Nu s-a putut genera fișierul PDF",
life: 3000,
});
}
};
// Lifecycle
onMounted(async () => {
// Add resize listener for mobile detection
window.addEventListener("resize", handleResize);
// Load companies if not loaded
if (!companyStore.hasCompanies) {
await companyStore.loadCompanies();
}
// FIX: Sync period from global periodStore BEFORE loading data
// This ensures Trial Balance shows the correct period when navigating
// from other views (e.g., Invoices with November selected)
if (periodStore.selectedPeriod) {
trialBalanceStore.filters.luna = periodStore.selectedPeriod.luna;
trialBalanceStore.filters.an = periodStore.selectedPeriod.an;
}
// Load trial balance if company is selected
if (companyStore.selectedCompany) {
await loadTrialBalance();
}
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
});
// Watch for company changes
watch(
() => companyStore.selectedCompany,
async (newCompany) => {
if (newCompany) {
await loadTrialBalance();
}
},
);
// Watch for period changes - sync luna/an with trial balance store
watch(
() => periodStore.selectedPeriod,
async (newPeriod) => {
if (newPeriod && companyStore.selectedCompany) {
await trialBalanceStore.changePeriod(
newPeriod.luna,
newPeriod.an,
companyStore.selectedCompany.id_firma
);
}
},
);
</script>
<style scoped>
/* ===== Page-Specific Styles Only ===== */
/* Uses shared CSS: dashboard.css (.page-header, .page-title, .page-subtitle) */
/* Uses shared CSS: forms.css (.form-actions) */
/* Uses shared CSS: tables.css (.table-empty, .loading-state) */
/* Uses shared CSS: primevue-overrides.css (DataTable striped rows, hover) */
/* Page Container */
.trial-balance {
max-width: 1400px;
margin: 0 auto;
padding: var(--space-xl);
}
/* Card Spacing */
.company-selection-card,
.filters-card,
.table-card {
margin-bottom: var(--space-xl);
}
/* Search field takes 2 columns in form grid */
.search-col {
grid-column: span 2;
}
/* Text alignment utility - page specific */
.text-right {
text-align: right;
}
/* Uses shared CSS: stats.css (.summary-stats-inline, .stat-item, .stat-label, .stat-value) */
/* Responsive */
@media (max-width: 768px) {
.trial-balance {
padding: var(--space-md);
}
.search-col {
grid-column: span 1;
}
}
</style>