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

147
src/App.vue Normal file
View File

@@ -0,0 +1,147 @@
<template>
<div id="app">
<!-- Header -->
<AppHeader
v-if="authStore.isAuthenticated"
title="ROA2WEB"
brand-link="/reports/dashboard"
:menu-open="menuOpen"
:companies-store="companyStore"
:period-store="periodStore"
:current-user="authStore.currentUser"
:show-user="false"
@menu-toggle="menuOpen = !menuOpen"
@company-changed="handleCompanyChanged"
@period-changed="handlePeriodChanged"
/>
<!-- Slide Menu -->
<SlideMenu
v-if="authStore.isAuthenticated"
:is-open="menuOpen"
:menu-items="enabledMenuItems"
:current-user="authStore.currentUser"
@close="menuOpen = false"
@logout="handleLogout"
/>
<!-- Main Content -->
<main class="main-content" :class="{ 'with-navbar': authStore.isAuthenticated }">
<router-view />
</main>
<!-- Global Toast Messages -->
<Toast position="top-right" />
<ConfirmDialog />
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import AppHeader from '@shared/components/layout/AppHeader.vue'
import SlideMenu from '@shared/components/layout/SlideMenu.vue'
import Toast from 'primevue/toast'
import ConfirmDialog from 'primevue/confirmdialog'
import { createAuthStore } from '@shared/stores/auth.js'
import { createCompaniesStore } from '@shared/stores/companies.js'
import { createAccountingPeriodStore } from '@shared/stores/accountingPeriod.js'
import { menuSections } from '@/config/menu.js'
import { getEnabledMenuSections } from '@/config/features.js'
import axios from 'axios'
const router = useRouter()
// API service for auth (uses reports API)
const authApi = axios.create({
baseURL: '/api/reports',
headers: { 'Content-Type': 'application/json' }
})
// Add interceptor to inject auth token from localStorage
authApi.interceptors.request.use(config => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Store definitions (factories return store definitions)
const useAuthStore = createAuthStore(authApi)
const useCompanyStore = createCompaniesStore(authApi, useAuthStore)
const useAccountingPeriodStore = createAccountingPeriodStore(authApi)
// Store instances (invoke the definitions to get instances)
const authStore = useAuthStore()
const companyStore = useCompanyStore()
const periodStore = useAccountingPeriodStore()
// Menu state
const menuOpen = ref(false)
// Get enabled menu items based on feature flags
const enabledMenuItems = computed(() => {
return getEnabledMenuSections(menuSections)
})
// Watch for company selection changes and auto-load periods
watch(
() => companyStore.selectedCompany,
async (newCompany, oldCompany) => {
// Only load periods if company actually changed and is valid
if (newCompany && newCompany.id_firma && newCompany !== oldCompany) {
console.log('[App] Company changed via watch, loading periods for:', newCompany.id_firma)
await periodStore.loadPeriods(newCompany.id_firma)
console.log('[App] Periods auto-loaded successfully')
}
},
{ immediate: true } // Run immediately with current value
)
// Initialize auth and load companies on mount
onMounted(async () => {
console.log('[App] Mounted - initializing auth...')
await authStore.initializeAuth()
console.log('[App] Auth initialized, isAuthenticated:', authStore.isAuthenticated)
// If authenticated, load companies immediately
if (authStore.isAuthenticated) {
console.log('[App] Loading companies...')
await companyStore.loadCompanies()
console.log('[App] Companies loaded, selectedCompany:', companyStore.selectedCompany)
// Period loading will be triggered by the watcher above
} else {
console.log('[App] Not authenticated, skipping company/period loading')
}
})
// Event handlers
const handleCompanyChanged = async (company) => {
console.log('[App] Company changed:', company)
// Load periods for the selected company
if (company && company.id_firma) {
console.log('[App] Loading periods for company:', company.id_firma)
await periodStore.loadPeriods(company.id_firma)
}
}
const handlePeriodChanged = (period) => {
console.log('[App] Period changed:', period)
}
const handleLogout = async () => {
await authStore.logout()
router.push('/login')
}
</script>
<style>
/* Import shared styles */
@import '@shared/styles/layout/header.css';
@import '@shared/styles/layout/navigation.css';
/* Import global CSS system */
@import './assets/css/main.css';
</style>

View File

@@ -0,0 +1,430 @@
/* Button Components - ROA2WEB */
/* Base Button Styles */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
font-size: var(--text-sm);
font-weight: var(--font-medium);
line-height: var(--leading-normal);
border: 1px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
text-decoration: none;
transition: all var(--transition-fast);
user-select: none;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
/* Button Sizes */
.btn-xs {
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-xs);
gap: 2px;
}
.btn-sm {
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-sm);
}
.btn-md {
padding: var(--space-sm) var(--space-md);
font-size: var(--text-sm);
}
.btn-lg {
padding: var(--space-md) var(--space-lg);
font-size: var(--text-base);
}
.btn-xl {
padding: var(--space-lg) var(--space-xl);
font-size: var(--text-lg);
}
/* Button Variants */
.btn-primary {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
.btn-primary:hover {
background: var(--color-primary-dark);
border-color: var(--color-primary-dark);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-secondary {
background: var(--color-bg);
color: var(--color-text);
border-color: var(--color-border);
}
.btn-secondary:hover {
background: var(--color-bg-secondary);
border-color: var(--color-border-dark);
}
.btn-outline {
background: transparent;
color: var(--color-primary);
border-color: var(--color-primary);
}
.btn-outline:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
}
.btn-ghost {
background: transparent;
color: var(--color-text);
border-color: transparent;
}
.btn-ghost:hover {
background: var(--color-bg-secondary);
border-color: var(--color-border);
}
/* Status Button Variants */
.btn-success {
background: var(--color-success);
color: var(--color-text-inverse);
border-color: var(--color-success);
}
.btn-success:hover {
background: #047857;
border-color: #047857;
}
.btn-warning {
background: var(--color-warning);
color: var(--color-text-inverse);
border-color: var(--color-warning);
}
.btn-warning:hover {
background: #b45309;
border-color: #b45309;
}
.btn-error {
background: var(--color-error);
color: var(--color-text-inverse);
border-color: var(--color-error);
}
.btn-error:hover {
background: #b91c1c;
border-color: #b91c1c;
}
/* Button Shapes */
.btn-rounded {
border-radius: var(--radius-full);
}
.btn-square {
border-radius: 0;
}
.btn-circle {
border-radius: var(--radius-full);
width: 40px;
height: 40px;
padding: 0;
}
.btn-circle.btn-sm {
width: 32px;
height: 32px;
}
.btn-circle.btn-lg {
width: 48px;
height: 48px;
}
/* Icon Buttons */
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border-radius: var(--radius-md);
}
.btn-icon-sm {
width: 32px;
height: 32px;
}
.btn-icon-lg {
width: 48px;
height: 48px;
}
/* Button Groups */
.btn-group {
display: inline-flex;
align-items: center;
}
.btn-group .btn {
border-radius: 0;
border-right-width: 0;
}
.btn-group .btn:first-child {
border-top-left-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.btn-group .btn:last-child {
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
border-right-width: 1px;
}
.btn-group .btn:hover {
z-index: 1;
border-right-width: 1px;
}
/* Action Buttons for Dashboard V4 */
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-md);
padding: var(--space-xl);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
color: var(--color-text);
min-height: 120px;
}
.action-btn:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.action-btn-icon {
width: 32px;
height: 32px;
opacity: 0.8;
}
.action-btn:hover .action-btn-icon {
opacity: 1;
}
.action-btn-label {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
text-align: center;
}
/* Toggle Buttons */
.btn-toggle {
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
border-color: var(--color-border);
}
.btn-toggle.active,
.btn-toggle:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
/* Pill Buttons */
.btn-pill {
border-radius: var(--radius-full);
padding: var(--space-xs) var(--space-md);
font-size: var(--text-xs);
font-weight: var(--font-medium);
}
/* Loading State */
.btn-loading {
opacity: 0.7;
cursor: not-allowed;
}
.btn-loading::before {
content: "";
display: inline-block;
width: 16px;
height: 16px;
margin-right: var(--space-xs);
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: var(--radius-full);
animation: btn-spin 0.8s linear infinite;
}
@keyframes btn-spin {
to {
transform: rotate(360deg);
}
}
/* Mobile Button Adjustments */
@media (max-width: 768px) {
.btn {
padding: var(--space-md) var(--space-lg);
font-size: var(--text-base);
min-height: 44px;
}
.btn-sm {
padding: var(--space-sm) var(--space-md);
font-size: var(--text-sm);
min-height: 36px;
}
.btn-group {
flex-direction: column;
width: 100%;
}
.btn-group .btn {
border-radius: 0;
border-right-width: 1px;
border-bottom-width: 0;
width: 100%;
}
.btn-group .btn:first-child {
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
.btn-group .btn:last-child {
border-radius: 0 0 var(--radius-md) var(--radius-md);
border-bottom-width: 1px;
}
.action-btn {
padding: var(--space-lg);
min-height: 100px;
}
}
@media (max-width: 480px) {
.action-btn {
min-height: 80px;
padding: var(--space-md);
}
.action-btn-icon {
width: 24px;
height: 24px;
}
.action-btn-label {
font-size: var(--text-xs);
}
}
/* Focus States for Accessibility */
.btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Button Groups for Dashboard */
.button-group {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.button-group .btn {
border-radius: var(--radius-md);
}
/* Hide button text on small screens */
@media (max-width: 640px) {
.btn-text {
display: none;
}
.button-group {
width: 100%;
}
.button-group .btn {
flex: 1;
justify-content: center;
}
}
/* Stack buttons vertically on very small screens */
@media (max-width: 480px) {
.button-group {
flex-direction: column;
width: 100%;
}
.button-group .btn {
width: 100%;
}
.btn-text {
display: inline; /* Show text again when stacked */
}
}
/* Primary button style for exports */
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover {
background: var(--color-primary-dark);
border-color: var(--color-primary-dark);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-primary:active {
transform: translateY(0);
}
/* Utility Classes */
.btn-full-width {
width: 100%;
justify-content: center;
}
.btn-auto-width {
width: auto;
}

View File

@@ -0,0 +1,435 @@
/* Card Components - ROA2WEB */
/* Base Card Styles */
.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;
}
.card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-border-dark);
}
.card-header {
padding: var(--space-lg);
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.card-body {
padding: var(--space-lg);
}
.card-footer {
padding: var(--space-lg);
border-top: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
/* Card Variants */
.card-compact {
padding: var(--space-md);
}
.card-compact .card-header,
.card-compact .card-body,
.card-compact .card-footer {
padding: var(--space-md);
}
.card-minimal {
border: none;
box-shadow: none;
background: transparent;
}
.card-elevated {
box-shadow: var(--shadow-lg);
}
.card-elevated:hover {
box-shadow: var(--shadow-xl);
transform: translateY(-2px);
}
/* Stats Cards */
.stats-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-lg);
text-align: center;
transition: all var(--transition-fast);
}
.stats-card:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
}
.stats-card-mini {
padding: var(--space-md);
text-align: left;
}
.stats-card-large {
padding: var(--space-xl);
}
/* Stats Card Content */
.stats-value {
display: block;
font-size: var(--text-2xl);
font-weight: var(--font-bold);
color: var(--color-text);
line-height: var(--leading-tight);
margin-bottom: var(--space-xs);
}
.stats-value-large {
font-size: var(--text-4xl);
margin-bottom: var(--space-sm);
}
.stats-label {
display: block;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stats-change {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-sm);
font-weight: var(--font-medium);
margin-top: var(--space-xs);
}
.stats-change.positive {
color: var(--color-success);
}
.stats-change.negative {
color: var(--color-error);
}
/* KPI Cards */
.kpi-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-lg);
display: flex;
align-items: center;
gap: var(--space-md);
transition: all var(--transition-fast);
}
.kpi-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-primary);
}
.kpi-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: var(--color-text-inverse);
flex-shrink: 0;
}
.kpi-content {
flex: 1;
min-width: 0;
}
.kpi-value {
font-size: var(--text-xl);
font-weight: var(--font-bold);
color: var(--color-text);
line-height: var(--leading-tight);
}
.kpi-label {
font-size: var(--text-sm);
color: var(--color-text-secondary);
font-weight: var(--font-medium);
margin-top: var(--space-xs);
}
/* Action Cards */
.action-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-lg);
cursor: pointer;
transition: all var(--transition-fast);
text-align: center;
}
.action-card:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.action-icon {
width: 32px;
height: 32px;
margin: 0 auto var(--space-md);
opacity: 0.8;
}
.action-title {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
margin-bottom: var(--space-sm);
}
.action-description {
font-size: var(--text-sm);
opacity: 0.8;
}
/* Status Cards */
.status-card {
background: var(--color-bg);
border-left: 4px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-md);
box-shadow: var(--shadow-sm);
}
.status-card.success {
border-left-color: var(--color-success);
background: #f0fdf4;
}
.status-card.warning {
border-left-color: var(--color-warning);
background: #fffbeb;
}
.status-card.error {
border-left-color: var(--color-error);
background: #fef2f2;
}
.status-card.info {
border-left-color: var(--color-info);
background: #f0f9ff;
}
/* Company Banner Card */
.company-banner {
background: linear-gradient(
135deg,
var(--color-primary) 0%,
var(--color-primary-dark) 100%
);
color: var(--color-text-inverse);
border: none;
padding: var(--space-md);
margin-bottom: var(--space-lg);
}
.company-name {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
margin-bottom: var(--space-xs);
}
.company-info {
font-size: var(--text-sm);
opacity: 0.9;
}
/* Dashboard V2 Mini Cards */
.mini-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-sm);
text-align: center;
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.mini-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-primary);
}
.mini-card-icon {
width: 16px;
height: 16px;
margin-bottom: var(--space-xs);
opacity: 0.7;
}
.mini-card-value {
font-size: var(--text-lg);
font-weight: var(--font-bold);
line-height: var(--leading-tight);
}
.mini-card-label {
font-size: var(--text-xs);
color: var(--color-text-secondary);
margin-top: var(--space-xs);
}
/* Heatmap Colors for Mini Cards */
.mini-card.heat-low {
background: #f0fdf4;
border-color: var(--color-success);
}
.mini-card.heat-medium {
background: #fffbeb;
border-color: var(--color-warning);
}
.mini-card.heat-high {
background: #fef2f2;
border-color: var(--color-error);
}
/* Mobile Card Adjustments */
@media (max-width: 768px) {
.card-header,
.card-body,
.card-footer {
padding: var(--space-md);
}
.stats-card,
.kpi-card,
.action-card {
padding: var(--space-md);
}
.kpi-card {
flex-direction: column;
text-align: center;
}
.stats-value {
font-size: var(--text-xl);
}
.stats-value-large {
font-size: var(--text-3xl);
}
.company-banner {
padding: var(--space-sm);
}
}
@media (max-width: 480px) {
.card-header,
.card-body,
.card-footer,
.stats-card,
.kpi-card,
.action-card {
padding: var(--space-sm);
}
.mini-card {
padding: var(--space-xs);
}
.mini-card-value {
font-size: var(--text-base);
}
}
/* ===== Dashboard Metric Cards ===== */
.metric-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--card-padding, 1.5rem);
transition: all var(--transition-fast);
min-height: var(--card-min-height, 200px);
display: flex;
flex-direction: column;
gap: var(--card-gap, 1rem);
}
.metric-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(var(--hover-lift, -2px));
border-color: var(--color-primary);
}
/* Metric display patterns */
.metric-header {
display: flex;
align-items: center;
gap: var(--space-md);
margin-bottom: var(--space-sm);
}
.metric-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
font-size: var(--text-xl);
}
.metric-value {
font-size: var(--value-size, 2rem);
font-weight: var(--font-bold);
line-height: var(--leading-tight);
font-family: var(--font-mono, monospace);
color: var(--color-text);
}
.metric-value-lg {
font-size: var(--value-size-lg, 2.5rem);
}
.metric-label {
font-size: var(--label-size, 0.875rem);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-xs);
}
/* Responsive */
@media (max-width: 768px) {
.metric-card {
min-height: calc(var(--card-min-height, 200px) - 40px);
padding: var(--card-padding-sm, 1rem);
}
.metric-value {
font-size: 1.25rem;
}
}

View File

@@ -0,0 +1,460 @@
/* Form Components - ROA2WEB */
/* Base Form Styles */
.form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.form-row {
display: flex;
gap: var(--space-md);
align-items: end;
}
.form-col {
flex: 1;
}
/* Labels */
.form-label {
display: block;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text);
margin-bottom: var(--space-xs);
}
.form-label.required::after {
content: " *";
color: var(--color-error);
}
/* Input Base Styles */
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-base);
font-family: inherit;
color: var(--color-text);
background: var(--color-bg);
transition: all var(--transition-fast);
min-height: 44px;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-input:disabled,
.form-select:disabled,
.form-textarea:disabled {
background: var(--color-bg-muted);
color: var(--color-text-muted);
cursor: not-allowed;
opacity: 0.6;
}
/* Input Variants */
.form-input-sm {
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-sm);
min-height: 36px;
}
.form-input-lg {
padding: var(--space-md) var(--space-lg);
font-size: var(--text-lg);
min-height: 52px;
}
/* Textarea */
.form-textarea {
resize: vertical;
min-height: 100px;
line-height: var(--leading-normal);
}
.form-textarea-sm {
min-height: 80px;
}
.form-textarea-lg {
min-height: 120px;
}
/* Select */
.form-select {
cursor: pointer;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right var(--space-sm) center;
background-repeat: no-repeat;
background-size: 16px 16px;
padding-right: var(--space-xl);
appearance: none;
}
/* Input Groups */
.input-group {
display: flex;
align-items: stretch;
width: 100%;
}
.input-group .form-input {
border-radius: 0;
border-right: none;
}
.input-group .form-input:first-child {
border-top-left-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.input-group .form-input:last-child {
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
border-right: 1px solid var(--color-border);
}
.input-group-addon {
display: flex;
align-items: center;
padding: var(--space-sm) var(--space-md);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-size: var(--text-sm);
white-space: nowrap;
}
.input-group-addon:first-child {
border-top-left-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
border-right: none;
}
.input-group-addon:last-child {
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
border-left: none;
}
/* Floating Labels */
.form-floating {
position: relative;
}
.form-floating .form-input,
.form-floating .form-textarea {
padding-top: var(--space-lg);
padding-bottom: var(--space-xs);
}
.form-floating .form-label {
position: absolute;
top: 0;
left: var(--space-md);
padding: var(--space-sm) var(--space-xs);
background: var(--color-bg);
color: var(--color-text-muted);
font-size: var(--text-sm);
transition: all var(--transition-fast);
pointer-events: none;
transform-origin: left center;
z-index: 1;
}
.form-floating .form-input:focus + .form-label,
.form-floating .form-input:not(:placeholder-shown) + .form-label,
.form-floating .form-textarea:focus + .form-label,
.form-floating .form-textarea:not(:placeholder-shown) + .form-label {
transform: translateY(-50%) scale(0.85);
color: var(--color-primary);
}
/* Validation States */
.form-input.valid,
.form-select.valid,
.form-textarea.valid {
border-color: var(--color-success);
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%2316a34a' viewBox='0 0 16 16'%3E%3Cpath d='M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z'/%3E%3C/svg%3E");
background-position: right var(--space-sm) center;
background-repeat: no-repeat;
background-size: 16px 16px;
}
.form-input.invalid,
.form-select.invalid,
.form-textarea.invalid {
border-color: var(--color-error);
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23dc2626' viewBox='0 0 16 16'%3E%3Cpath d='M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E");
background-position: right var(--space-sm) center;
background-repeat: no-repeat;
background-size: 16px 16px;
}
/* Select with validation needs different padding */
.form-select.valid,
.form-select.invalid {
padding-right: calc(var(--space-xl) + var(--space-lg));
}
/* Help Text */
.form-help {
font-size: var(--text-sm);
color: var(--color-text-secondary);
margin-top: var(--space-xs);
}
.form-error {
font-size: var(--text-sm);
color: var(--color-error);
margin-top: var(--space-xs);
display: flex;
align-items: center;
gap: var(--space-xs);
}
.form-success {
font-size: var(--text-sm);
color: var(--color-success);
margin-top: var(--space-xs);
display: flex;
align-items: center;
gap: var(--space-xs);
}
/* Checkboxes and Radios */
.form-check {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.form-check-input {
width: 18px;
height: 18px;
border: 1px solid var(--color-border);
background: var(--color-bg);
cursor: pointer;
transition: all var(--transition-fast);
}
.form-check-input[type="checkbox"] {
border-radius: var(--radius-sm);
}
.form-check-input[type="radio"] {
border-radius: 50%;
}
.form-check-input:checked {
background: var(--color-primary);
border-color: var(--color-primary);
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' viewBox='0 0 16 16'%3E%3Cpath d='M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z'/%3E%3C/svg%3E");
background-position: center;
background-repeat: no-repeat;
background-size: 12px 12px;
}
.form-check-input[type="radio"]:checked {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='4'/%3E%3C/svg%3E");
background-size: 8px 8px;
}
.form-check-input:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-check-label {
font-size: var(--text-sm);
color: var(--color-text);
cursor: pointer;
user-select: none;
}
/* Form Actions */
.form-actions {
display: flex;
gap: var(--space-md);
justify-content: flex-end;
margin-top: var(--space-xl);
padding-top: var(--space-lg);
border-top: 1px solid var(--color-border);
}
.form-actions-center {
justify-content: center;
}
.form-actions-start {
justify-content: flex-start;
}
.form-actions-between {
justify-content: space-between;
}
/* Search Form */
.search-form {
display: flex;
gap: var(--space-sm);
align-items: end;
margin-bottom: var(--space-lg);
}
.search-input {
position: relative;
flex: 1;
}
.search-input .form-input {
padding-right: var(--space-3xl);
}
.search-icon {
position: absolute;
right: var(--space-md);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted);
font-size: var(--text-lg);
pointer-events: none;
}
/* Inline Forms */
.form-inline {
display: flex;
gap: var(--space-md);
align-items: end;
flex-wrap: wrap;
}
.form-inline .form-group {
flex: 1;
min-width: 150px;
}
/* File Upload */
.file-upload {
position: relative;
display: inline-block;
cursor: pointer;
width: 100%;
}
.file-upload-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.file-upload-label {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-lg);
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
min-height: 120px;
text-align: center;
}
.file-upload:hover .file-upload-label,
.file-upload-label.drag-over {
border-color: var(--color-primary);
background: rgba(37, 99, 235, 0.05);
color: var(--color-primary);
}
/* Mobile Form Styles */
@media (max-width: 768px) {
.form-row {
flex-direction: column;
gap: var(--space-md);
}
.form-inline {
flex-direction: column;
align-items: stretch;
}
.form-inline .form-group {
min-width: auto;
}
.form-actions {
flex-direction: column;
}
.form-actions-between {
justify-content: center;
flex-direction: column-reverse;
}
.search-form {
flex-direction: column;
}
/* Ensure mobile-friendly touch targets */
.form-input,
.form-select,
.form-textarea {
min-height: 44px;
font-size: 16px; /* Prevents zoom on iOS */
}
.form-check-input {
width: 20px;
height: 20px;
min-height: 20px;
}
}
/* Print Styles */
@media print {
.form-actions {
display: none;
}
.form-input,
.form-select,
.form-textarea {
border: none;
border-bottom: 1px solid #000;
border-radius: 0;
background: transparent;
padding: var(--space-xs) 0;
}
.form-label {
font-weight: bold;
}
}

View File

@@ -0,0 +1,502 @@
/* Stats Components - ROA2WEB Dashboard */
/* Stats Grid Layout */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-lg);
margin-bottom: var(--space-xl);
}
/* Stats Cards */
.stats-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-lg);
box-shadow: var(--shadow-sm);
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.stats-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-border-dark);
transform: translateY(-2px);
}
/* Stats Card Header */
.stats-card-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
padding-bottom: var(--space-sm);
border-bottom: 1px solid var(--color-border-light);
}
.stats-card-header i {
font-size: var(--text-xl);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
}
.stats-card-header h3 {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--color-text);
margin: 0;
}
/* Stats Details */
.stats-details {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--text-sm);
padding: var(--space-xs) 0;
min-height: 24px;
}
.stat-row span:first-child {
color: var(--color-text-secondary);
font-weight: var(--font-medium);
}
.stat-row span:last-child {
color: var(--color-text);
font-weight: var(--font-medium);
text-align: right;
}
.stat-highlight {
background: var(--color-bg-secondary);
padding: var(--space-sm);
border-radius: var(--radius-sm);
font-weight: var(--font-semibold);
margin: var(--space-sm) 0;
border-left: 3px solid var(--color-primary);
}
.stat-warning {
color: var(--color-error);
font-weight: var(--font-semibold);
}
.stat-warning span:first-child {
color: var(--color-error);
}
.stat-success {
color: var(--color-success);
font-weight: var(--font-semibold);
}
.stat-success span:first-child {
color: var(--color-success);
}
/* Treasury Specific Styling */
.treasury-content {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.treasury-section {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.treasury-section-title {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--color-text);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-xs);
padding-bottom: var(--space-xs);
border-bottom: 1px solid var(--color-border);
}
.account-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--text-xs);
padding: var(--space-xs) 0;
}
.account-name {
color: var(--color-text-secondary);
font-weight: var(--font-medium);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-balance {
color: var(--color-text);
font-weight: var(--font-semibold);
flex-shrink: 0;
margin-left: var(--space-sm);
}
.treasury-totals {
margin-top: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--color-border);
background: var(--color-bg-muted);
margin-left: calc(-1 * var(--space-lg));
margin-right: calc(-1 * var(--space-lg));
margin-bottom: calc(-1 * var(--space-lg));
padding-left: var(--space-lg);
padding-right: var(--space-lg);
padding-bottom: var(--space-lg);
}
.total-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--text-sm);
padding: var(--space-xs) 0;
color: var(--color-text);
font-weight: var(--font-semibold);
}
/* KPI Large Display */
.kpi-large-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-xl);
text-align: center;
box-shadow: var(--shadow-sm);
transition: all var(--transition-fast);
}
.kpi-large-card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-4px);
}
.kpi-large-value {
font-size: var(--text-4xl);
font-weight: var(--font-bold);
color: var(--color-text);
line-height: var(--leading-tight);
margin-bottom: var(--space-sm);
}
.kpi-large-label {
font-size: var(--text-base);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.kpi-large-change {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
font-size: var(--text-sm);
font-weight: var(--font-medium);
margin-top: var(--space-md);
}
.kpi-large-change.positive {
color: var(--color-success);
}
.kpi-large-change.negative {
color: var(--color-error);
}
/* Mini Stats for V2 Dashboard */
.mini-stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: var(--space-md);
}
.mini-stat-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-sm);
text-align: center;
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.mini-stat-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-primary);
transform: scale(1.02);
}
.mini-stat-icon {
width: 16px;
height: 16px;
margin-bottom: var(--space-xs);
opacity: 0.7;
}
.mini-stat-value {
font-size: var(--text-base);
font-weight: var(--font-bold);
line-height: var(--leading-tight);
color: var(--color-text);
margin-bottom: var(--space-xs);
}
.mini-stat-label {
font-size: var(--text-xs);
color: var(--color-text-secondary);
line-height: var(--leading-tight);
}
/* Heat Map Colors for Mini Cards */
.mini-stat-card.heat-low {
background: #f0fdf4;
border-color: var(--color-success);
}
.mini-stat-card.heat-low .mini-stat-value {
color: var(--color-success);
}
.mini-stat-card.heat-medium {
background: #fffbeb;
border-color: var(--color-warning);
}
.mini-stat-card.heat-medium .mini-stat-value {
color: var(--color-warning);
}
.mini-stat-card.heat-high {
background: #fef2f2;
border-color: var(--color-error);
}
.mini-stat-card.heat-high .mini-stat-value {
color: var(--color-error);
}
/* Quick Actions Grid */
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-md);
}
/* Loading Spinner for Stats */
.stats-loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-xl);
color: var(--color-text-secondary);
}
.stats-loading-spinner {
width: 24px;
height: 24px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: var(--space-sm);
}
/* Stats Card Variants */
.stats-card.clients {
border-left-color: #3b82f6;
}
.stats-card.clients .stats-card-header i {
color: #3b82f6;
background: #eff6ff;
}
.stats-card.suppliers {
border-left-color: #f59e0b;
}
.stats-card.suppliers .stats-card-header i {
color: #f59e0b;
background: #fffbeb;
}
.stats-card.treasury {
border-left-color: #10b981;
}
.stats-card.treasury .stats-card-header i {
color: #10b981;
background: #ecfdf5;
}
/* Responsive Adjustments */
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.mini-stats-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
gap: var(--space-md);
}
.stats-card {
padding: var(--space-md);
}
.treasury-totals {
margin-left: calc(-1 * var(--space-md));
margin-right: calc(-1 * var(--space-md));
margin-bottom: calc(-1 * var(--space-md));
padding-left: var(--space-md);
padding-right: var(--space-md);
padding-bottom: var(--space-md);
}
.mini-stats-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(6, 1fr);
}
.kpi-large-value {
font-size: var(--text-3xl);
}
.quick-actions-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.mini-stats-grid {
grid-template-columns: 1fr;
grid-template-rows: repeat(12, auto);
}
.mini-stat-card {
padding: var(--space-xs);
}
.mini-stat-value {
font-size: var(--text-sm);
}
.stats-card-header {
flex-direction: column;
text-align: center;
gap: var(--space-xs);
}
.stat-row {
flex-direction: column;
align-items: flex-start;
gap: var(--space-xs);
}
.stat-row span:last-child {
text-align: left;
}
}
/* ===== Summary Stats Inline ===== */
/* Compact horizontal stats display for page summaries */
.summary-stats-inline {
display: flex;
gap: var(--space-xl);
justify-content: flex-end;
margin-bottom: var(--space-md);
padding: var(--space-sm) var(--space-md);
background-color: var(--color-bg-secondary);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.summary-stats-inline .stat-item {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.summary-stats-inline .stat-label {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.summary-stats-inline .stat-value {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
font-variant-numeric: tabular-nums;
}
.summary-stats-inline .stat-value.positive,
.summary-stats-inline .stat-value.incasari {
color: var(--color-success);
}
.summary-stats-inline .stat-value.negative,
.summary-stats-inline .stat-value.plati {
color: var(--color-error);
}
/* Responsive: Stack on mobile */
@media (max-width: 768px) {
.summary-stats-inline {
flex-direction: column;
gap: var(--space-sm);
align-items: flex-end;
}
}
/* Print Styles */
@media print {
.stats-card {
break-inside: avoid;
box-shadow: none;
border: 1px solid #ccc;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.summary-stats-inline {
border: 1px solid #ccc;
background: #f5f5f5;
}
}

View File

@@ -0,0 +1,878 @@
/* Table Components - ROA2WEB */
/* Base Table Styles */
.table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-bg);
border-radius: var(--card-radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.table th {
background: var(--color-bg-muted);
padding: var(--space-sm) var(--space-md);
text-align: left;
border-bottom: 2px solid var(--color-border);
font-weight: var(--font-semibold);
color: var(--color-text);
font-size: var(--text-sm);
position: sticky;
top: 0;
z-index: 1;
}
.table td {
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--color-border-light);
vertical-align: middle;
}
.table tbody tr:hover {
background: var(--color-bg-secondary);
}
.table tbody tr:last-child td {
border-bottom: none;
}
/* Table Variants */
.table-striped tbody tr:nth-child(even) {
background: var(--color-bg-secondary);
}
.table-striped tbody tr:nth-child(even):hover {
background: var(--color-bg-muted);
}
.table-bordered {
border: 1px solid var(--color-border);
}
.table-bordered th,
.table-bordered td {
border: 1px solid var(--color-border);
}
.table-borderless th,
.table-borderless td {
border: none;
}
.table-sm th,
.table-sm td {
padding: var(--space-xs) var(--space-sm);
}
.table-lg th,
.table-lg td {
padding: var(--space-md) var(--space-lg);
}
/* Responsive Table */
.table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-responsive .table {
min-width: 600px;
}
/* Sortable Headers */
.table th.sortable {
cursor: pointer;
user-select: none;
position: relative;
padding-right: var(--space-xl);
}
.table th.sortable:hover {
background: var(--color-border);
}
.table th.sortable::after {
content: "↕";
position: absolute;
right: var(--space-sm);
top: 50%;
transform: translateY(-50%);
opacity: 0.5;
font-size: var(--text-xs);
}
.table th.sortable.sorted-asc::after {
content: "↑";
opacity: 1;
color: var(--color-primary);
}
.table th.sortable.sorted-desc::after {
content: "↓";
opacity: 1;
color: var(--color-primary);
}
/* Table Status Colors */
.table .cell-success,
.table .text-success {
color: var(--color-success);
font-weight: var(--font-medium);
}
.table .cell-warning,
.table .text-warning {
color: var(--color-warning);
font-weight: var(--font-medium);
}
.table .cell-error,
.table .text-error {
color: var(--color-error);
font-weight: var(--font-medium);
}
.table .cell-info,
.table .text-info {
color: var(--color-info);
font-weight: var(--font-medium);
}
.table .cell-muted,
.table .text-muted {
color: var(--color-text-muted);
}
/* Table Row States */
.table .row-selected {
background: rgba(37, 99, 235, 0.1);
border-color: var(--color-primary);
}
.table .row-active {
background: rgba(16, 185, 129, 0.1);
}
.table .row-warning {
background: rgba(245, 158, 11, 0.1);
}
.table .row-error {
background: rgba(239, 68, 68, 0.1);
}
/* Editable Cells */
.table .cell-editable {
cursor: pointer;
position: relative;
}
.table .cell-editable:hover {
background: rgba(37, 99, 235, 0.05);
}
.table .cell-input {
width: 100%;
padding: var(--space-xs);
border: 1px solid var(--color-primary);
border-radius: var(--radius-sm);
font-size: inherit;
font-family: inherit;
color: inherit;
background: var(--color-bg);
}
.table .cell-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
/* Action Buttons in Tables */
.table .table-actions {
display: flex;
gap: var(--space-xs);
justify-content: flex-end;
}
.table .table-action-btn {
padding: var(--space-xs);
border: 1px solid var(--color-border);
background: var(--color-bg);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
.table .table-action-btn:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
/* Table Pagination */
.table-pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md);
background: var(--color-bg-secondary);
border-top: 1px solid var(--color-border);
}
.pagination-info {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.pagination-controls {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.pagination-btn {
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
border-radius: var(--radius-sm);
font-size: var(--text-sm);
transition: all var(--transition-fast);
}
.pagination-btn:hover:not(:disabled) {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-current {
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-primary);
color: var(--color-text-inverse);
border-radius: var(--radius-sm);
}
/* Table Search and Filters */
.table-filters {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
gap: var(--space-md);
flex-wrap: wrap;
}
.table-search {
flex: 1;
min-width: 200px;
}
.table-filter-group {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.table-filter-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
white-space: nowrap;
}
/* Data Table Stats */
.table-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--space-md);
padding: var(--space-md);
background: var(--color-bg-muted);
border-bottom: 1px solid var(--color-border);
}
.table-stat {
text-align: center;
}
.table-stat-value {
font-size: var(--text-xl);
font-weight: var(--font-bold);
color: var(--color-text);
display: block;
margin-bottom: var(--space-xs);
}
.table-stat-label {
font-size: var(--text-xs);
color: var(--color-text-secondary);
font-weight: var(--font-medium);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Empty State */
.table-empty {
text-align: center;
padding: var(--space-3xl) var(--space-xl);
color: var(--color-text-secondary);
}
.table-empty-icon {
font-size: var(--text-4xl);
margin-bottom: var(--space-lg);
opacity: 0.5;
}
.table-empty-message {
font-size: var(--text-lg);
margin-bottom: var(--space-sm);
}
.table-empty-description {
font-size: var(--text-sm);
opacity: 0.8;
}
/* Trends Section Styling */
.trends-container {
padding: var(--space-xl);
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
.trend-placeholder {
text-align: center;
padding: var(--space-xl);
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
color: var(--color-text-secondary);
border: 2px dashed var(--color-border);
max-width: 500px;
}
.placeholder-icon {
font-size: 48px;
color: var(--color-primary);
margin-bottom: var(--space-lg);
opacity: 0.7;
}
.trend-placeholder h3 {
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--color-text);
margin: 0 0 var(--space-md) 0;
}
.trend-placeholder p {
font-size: var(--text-base);
margin: 0 0 var(--space-md) 0;
line-height: 1.6;
}
.trend-placeholder ul {
text-align: left;
display: inline-block;
margin: 0;
padding-left: var(--space-lg);
}
.trend-placeholder li {
margin-bottom: var(--space-xs);
line-height: 1.5;
color: var(--color-text-secondary);
}
/* Chart Container for future charts */
.chart-container {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
margin: var(--space-md) 0;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
/* Loading States */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-3xl);
color: var(--color-text-secondary);
text-align: center;
min-height: 200px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: var(--space-lg);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Enhanced Responsive Table Container */
.table-container {
overflow: auto;
max-height: 500px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
}
.table-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.table-container::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
}
.table-container::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: var(--radius-full);
}
.table-container::-webkit-scrollbar-thumb:hover {
background: var(--color-border-dark);
}
/* Mobile Table Styles */
@media (max-width: 768px) {
.table-mobile-stack {
display: block;
}
.table-mobile-stack thead {
display: none;
}
.table-mobile-stack tbody,
.table-mobile-stack tr,
.table-mobile-stack td {
display: block;
width: 100%;
}
.table-mobile-stack tr {
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-md);
margin-bottom: var(--space-md);
background: var(--color-bg);
box-shadow: var(--shadow-sm);
}
.table-mobile-stack td {
border: none;
position: relative;
padding: var(--space-sm) 0;
text-align: left;
}
.table-mobile-stack td::before {
content: attr(data-label) ": ";
font-weight: var(--font-semibold);
color: var(--color-text-secondary);
display: inline-block;
width: 40%;
margin-right: var(--space-sm);
}
.table-filters {
flex-direction: column;
align-items: stretch;
}
.table-filter-group {
justify-content: space-between;
}
.table-pagination {
flex-direction: column;
gap: var(--space-md);
text-align: center;
}
.table-stats {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.table-stats {
grid-template-columns: 1fr;
}
.table-mobile-stack td::before {
width: 100%;
display: block;
margin-bottom: var(--space-xs);
margin-right: 0;
}
/* Dashboard-specific mobile styles */
.dashboard-table {
font-size: var(--text-xs);
}
.dashboard-table th,
.dashboard-table td {
padding: var(--space-sm) var(--space-md);
}
.name-cell {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trends-container {
padding: var(--space-lg);
}
.placeholder-icon {
font-size: 36px;
}
.trend-placeholder h3 {
font-size: var(--text-lg);
}
}
/* Professional Dashboard Table Styles */
.dashboard-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-bg);
border-radius: var(--card-radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border-light);
}
.dashboard-table th {
background: var(--color-bg-muted);
padding: var(--space-md) var(--space-lg);
text-align: left;
border-bottom: 2px solid var(--color-border);
font-weight: var(--font-semibold);
color: var(--color-text);
font-size: var(--text-sm);
position: sticky;
top: 0;
z-index: 10;
text-transform: uppercase;
letter-spacing: 0.025em;
font-size: var(--text-xs);
}
.dashboard-table th.text-right {
text-align: right;
}
.dashboard-table td {
padding: var(--space-sm) var(--space-lg);
border-bottom: 1px solid var(--color-border-light);
vertical-align: middle;
transition: background-color var(--transition-fast);
}
.dashboard-table td.text-right {
text-align: right;
}
.dashboard-table tbody tr:hover {
background: var(--color-bg-secondary);
}
.dashboard-table tbody tr:last-child td {
border-bottom: none;
}
/* Enhanced Table Cell Types */
.dashboard-table .category-cell {
font-weight: var(--font-medium);
color: var(--color-text);
}
.dashboard-table .name-cell {
font-weight: var(--font-medium);
color: var(--color-text);
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-table .amount-cell {
font-family: var(--font-mono);
font-weight: var(--font-medium);
text-align: right;
white-space: nowrap;
}
.dashboard-table .status-cell {
text-align: center;
}
/* Enhanced Status Badge */
.status-badge {
display: inline-block;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--font-medium);
text-transform: uppercase;
letter-spacing: 0.05em;
border: 1px solid transparent;
}
.status-badge.activ {
background: var(--color-success-bg);
color: var(--color-success);
border-color: var(--color-success);
}
.status-badge.restant {
background: var(--color-warning-bg);
color: var(--color-warning);
border-color: var(--color-warning);
}
.status-badge.inactiv {
background: var(--color-error-bg);
color: var(--color-error);
border-color: var(--color-error);
}
/* Balance Color Classes */
.positive {
color: var(--color-success) !important;
font-weight: var(--font-semibold);
}
.negative {
color: var(--color-error) !important;
font-weight: var(--font-semibold);
}
.neutral {
color: var(--color-text) !important;
}
/* Grand Total Row Enhancement */
.grand-total-row {
background: var(--color-bg-muted);
font-weight: var(--font-semibold);
border-top: 2px solid var(--color-border);
border-bottom: 2px solid var(--color-border);
}
.grand-total-row td {
padding: var(--space-md) var(--space-lg);
color: var(--color-text);
font-size: var(--text-sm);
}
.grand-total-row:hover {
background: var(--color-bg-muted) !important;
}
/* Section Styling */
.dashboard-section {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
margin-bottom: var(--space-xl);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-lg) var(--space-xl);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
gap: var(--space-md);
}
.section-title {
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--color-text);
margin: 0;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.section-controls {
display: flex;
align-items: center;
gap: var(--space-md);
flex-wrap: wrap;
}
/* Control Groups */
.control-group {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.control-group label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
white-space: nowrap;
}
.detail-select,
.detail-input,
.trend-select {
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
min-width: 120px;
background: var(--color-bg);
color: var(--color-text);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.detail-select:focus,
.detail-input:focus,
.trend-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.2);
}
/* Enhanced Pagination */
.table-pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md) var(--space-xl);
background: var(--color-bg-secondary);
border-top: 1px solid var(--color-border);
flex-wrap: wrap;
gap: var(--space-md);
}
.pagination-info {
font-size: var(--text-sm);
color: var(--color-text-secondary);
font-weight: var(--font-medium);
}
.pagination-controls {
display: flex;
align-items: center;
gap: var(--space-md);
}
.page-info {
font-size: var(--text-sm);
color: var(--color-text);
font-weight: var(--font-medium);
padding: var(--space-xs) var(--space-sm);
background: var(--color-bg-muted);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
}
/* Print Styles */
@media print {
.table {
font-size: 12px;
}
.table-filters,
.table-pagination,
.table-actions {
display: none;
}
.table th,
.table td {
padding: 4px 8px;
}
.dashboard-table {
font-size: 10px;
box-shadow: none;
border: 1px solid #000 !important;
}
.dashboard-table th {
background: #f5f5f5 !important;
color: #000 !important;
border: 1px solid #000 !important;
padding: 4px 6px;
}
.dashboard-table td {
border: 1px solid #000 !important;
padding: 4px 6px;
background: white !important;
color: #000 !important;
}
.grand-total-row td {
background: #f0f0f0 !important;
font-weight: bold;
border: 2px solid #000 !important;
}
.section-header {
display: none;
}
.dashboard-section {
page-break-inside: avoid;
margin-bottom: 20px;
box-shadow: none;
}
}

View File

@@ -0,0 +1,137 @@
/* Modern CSS Reset - ROA2WEB */
/* Box sizing rules */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Remove default margin and padding */
* {
margin: 0;
padding: 0;
}
/* Remove list styles on ul, ol elements with a list role */
ul[role="list"],
ol[role="list"] {
list-style: none;
}
/* Set core root defaults */
html {
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
-moz-text-size-adjust: 100%;
text-size-adjust: 100%;
}
/* Set core body defaults */
body {
min-height: 100vh;
line-height: var(--leading-normal);
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
font-size: var(--text-base);
color: var(--color-text);
background-color: var(--color-bg);
text-rendering: optimizeSpeed;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Remove default styling from common elements */
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
}
p {
line-height: var(--leading-normal);
}
/* A elements that don't have a class get default styles */
a:not([class]) {
text-decoration-skip-ink: auto;
color: var(--color-primary);
}
/* Make images easier to work with */
img,
picture,
svg {
max-width: 100%;
height: auto;
display: block;
}
/* Inherit fonts for inputs and buttons */
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
/* Remove default button styles */
button {
border: none;
background: none;
cursor: pointer;
}
/* Make sure textareas without a rows attribute are not tiny */
textarea:not([rows]) {
min-height: 10em;
}
/* Remove default styling from fieldsets */
fieldset {
border: none;
padding: 0;
margin: 0;
}
/* Remove default styling from legends */
legend {
padding: 0;
}
/* Remove default outline on focused elements for better accessibility */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Remove all animations and transitions for people that prefer not to see them */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Ensure minimum font size on iOS to prevent zoom */
@media screen and (max-width: 480px) {
input,
textarea,
select {
font-size: 16px;
}
}

View File

@@ -0,0 +1,39 @@
/* Extended Design Tokens - ROA2WEB */
:root {
/* ===== Card Tokens ===== */
--card-padding: var(--space-lg);
--card-padding-sm: var(--space-md);
--card-padding-lg: var(--space-xl);
--card-gap: var(--space-md);
--card-min-height: 280px;
/* ===== Typography Tokens ===== */
--value-size: 1.5rem;
--value-size-lg: 2rem;
--label-size: 0.875rem;
--sublabel-size: 0.8125rem;
/* ===== Interactive Tokens ===== */
--hover-lift: -2px;
--active-lift: 0px;
--focus-ring: 0 0 0 3px rgba(var(--color-primary-rgb, 59, 130, 246), 0.1);
/* ===== Animation Durations ===== */
--duration-instant: 100ms;
--duration-fast: 150ms;
--duration-normal: 250ms;
--duration-slow: 350ms;
--duration-slower: 500ms;
/* ===== Component Sizing ===== */
--spinner-size: 40px;
--spinner-size-sm: 24px;
--spinner-size-lg: 56px;
--spinner-border: 4px;
/* ===== Dashboard Metrics ===== */
--metric-gap: 1rem;
--sparkline-height: 80px;
--sparkline-height-lg: 150px;
}

View File

@@ -0,0 +1,224 @@
/* Typography System - ROA2WEB */
/* Heading Styles */
.text-4xl,
.h1 {
font-size: var(--text-4xl);
font-weight: var(--font-bold);
line-height: var(--leading-tight);
letter-spacing: -0.025em;
}
.text-3xl,
.h2 {
font-size: var(--text-3xl);
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
letter-spacing: -0.025em;
}
.text-2xl,
.h3 {
font-size: var(--text-2xl);
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
}
.text-xl,
.h4 {
font-size: var(--text-xl);
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
}
.text-lg,
.h5 {
font-size: var(--text-lg);
font-weight: var(--font-medium);
line-height: var(--leading-normal);
}
.text-base,
.h6 {
font-size: var(--text-base);
font-weight: var(--font-medium);
line-height: var(--leading-normal);
}
/* Body Text Sizes */
.text-sm {
font-size: var(--text-sm);
line-height: var(--leading-normal);
}
.text-xs {
font-size: var(--text-xs);
line-height: var(--leading-normal);
}
/* Font Weights */
.font-light {
font-weight: var(--font-light);
}
.font-normal {
font-weight: var(--font-normal);
}
.font-medium {
font-weight: var(--font-medium);
}
.font-semibold {
font-weight: var(--font-semibold);
}
.font-bold {
font-weight: var(--font-bold);
}
/* Text Colors */
.text-primary {
color: var(--color-primary);
}
.text-secondary {
color: var(--color-text-secondary);
}
.text-muted {
color: var(--color-text-muted);
}
.text-inverse {
color: var(--color-text-inverse);
}
.text-success {
color: var(--color-success);
}
.text-warning {
color: var(--color-warning);
}
.text-error {
color: var(--color-error);
}
.text-info {
color: var(--color-info);
}
/* Text Alignment */
.text-left {
text-align: left;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
/* Line Heights */
.leading-tight {
line-height: var(--leading-tight);
}
.leading-normal {
line-height: var(--leading-normal);
}
.leading-loose {
line-height: var(--leading-loose);
}
/* Letter Spacing */
.tracking-tight {
letter-spacing: -0.025em;
}
.tracking-normal {
letter-spacing: 0;
}
.tracking-wide {
letter-spacing: 0.025em;
}
/* Text Transform */
.uppercase {
text-transform: uppercase;
}
.lowercase {
text-transform: lowercase;
}
.capitalize {
text-transform: capitalize;
}
/* Text Decoration */
.underline {
text-decoration: underline;
}
.no-underline {
text-decoration: none;
}
/* Page Title Styles */
.page-title {
color: var(--color-primary);
font-size: var(--text-3xl);
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
margin-bottom: var(--space-md);
}
.page-subtitle {
color: var(--color-text-secondary);
font-size: var(--text-lg);
font-weight: var(--font-normal);
line-height: var(--leading-normal);
margin-bottom: var(--space-lg);
}
/* Section Title Styles */
.section-title {
color: var(--color-text);
font-size: var(--text-xl);
font-weight: var(--font-medium);
line-height: var(--leading-tight);
margin-bottom: var(--space-sm);
}
/* KPI Display Typography */
.kpi-value {
font-size: var(--text-2xl);
font-weight: var(--font-bold);
line-height: var(--leading-tight);
color: var(--color-text);
}
.kpi-large {
font-size: var(--text-4xl);
font-weight: var(--font-bold);
line-height: var(--leading-tight);
}
.kpi-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Mobile Typography Adjustments */
@media (max-width: 480px) {
.text-4xl,
.h1 {
font-size: var(--text-3xl);
}
.text-3xl,
.h2 {
font-size: var(--text-2xl);
}
.text-2xl,
.h3 {
font-size: var(--text-xl);
}
.page-title {
font-size: var(--text-2xl);
}
.kpi-large {
font-size: var(--text-3xl);
}
}

View File

@@ -0,0 +1,184 @@
/* CSS Variables - ROA2WEB Design System */
:root {
/* Spacing System */
--space-xs: 0.25rem; /* 4px */
--space-sm: 0.5rem; /* 8px */
--space-md: 1rem; /* 16px */
--space-lg: 1.5rem; /* 24px */
--space-xl: 2rem; /* 32px */
--space-2xl: 3rem; /* 48px */
--space-3xl: 4rem; /* 64px */
/* Typography Scale */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 2rem; /* 32px */
--text-4xl: 2.5rem; /* 40px */
/* Font Weights */
--font-light: 300;
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
/* Line Heights */
--leading-tight: 1.2;
--leading-normal: 1.5;
--leading-loose: 1.75;
/* Colors - Minimal Professional Palette */
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;
--color-primary-light: #3b82f6;
--color-secondary: #64748b;
--color-secondary-dark: #475569;
--color-secondary-light: #94a3b8;
--color-success: #059669;
--color-warning: #d97706;
--color-error: #dc2626;
--color-info: #0891b2;
--color-text: #111827;
--color-text-secondary: #6b7280;
--color-text-muted: #9ca3af;
--color-text-inverse: #ffffff;
--color-bg: #ffffff;
--color-bg-secondary: #f9fafb;
--color-bg-muted: #f3f4f6;
--color-bg-dark: #111827;
--color-border: #e5e7eb;
--color-border-light: #f3f4f6;
--color-border-dark: #d1d5db;
/* Surface colors for PrimeVue compatibility */
--surface-0: #ffffff;
--surface-50: #f8fafc;
--surface-100: #f1f5f9;
--surface-200: #e2e8f0;
--surface-300: #cbd5e1;
--surface-400: #94a3b8;
--surface-500: #64748b;
--surface-600: #475569;
--surface-700: #334155;
--surface-800: #1e293b;
--surface-900: #0f172a;
--surface-950: #020617;
/* Red color palette for errors */
--red-50: #fef2f2;
--red-100: #fee2e2;
--red-200: #fecaca;
--red-300: #fca5a5;
--red-400: #f87171;
--red-500: #ef4444;
--red-600: #dc2626;
--red-700: #b91c1c;
--red-800: #991b1b;
--red-900: #7f1d1d;
--red-950: #450a0a;
/* Compatibility aliases for old variable names */
--primary-color: var(--color-primary);
--primary-color-dark: var(--color-primary-dark);
--primary-color-light: var(--color-primary-light);
--text-color: var(--color-text);
--text-color-secondary: var(--color-text-secondary);
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg:
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl:
0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
/* Border Radius */
--radius-sm: 0.25rem; /* 4px */
--radius-md: 0.5rem; /* 8px */
--radius-lg: 0.75rem; /* 12px */
--radius-xl: 1rem; /* 16px */
--radius-full: 9999px;
/* Layout Specific */
--header-height: 56px;
--sidebar-width: 240px;
--card-radius: var(--radius-md);
--container-max-width: 1400px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-slow: 350ms ease;
/* Additional Status Colors */
--color-success-bg: rgba(5, 150, 105, 0.1);
--color-warning-bg: rgba(217, 119, 6, 0.1);
--color-error-bg: rgba(220, 38, 38, 0.1);
--color-info-bg: rgba(8, 145, 178, 0.1);
/* Color RGB values for opacity usage */
--color-primary-rgb: 37, 99, 235;
--color-success-rgb: 5, 150, 105;
--color-warning-rgb: 217, 119, 6;
--color-error-rgb: 220, 38, 38;
/* Monospace font for numbers */
--font-mono:
"SF Mono", Consolas, "Liberation Mono", Menlo, Courier, monospace;
/* Z-Index Scale */
--z-dropdown: 1200;
--z-sticky: 1020;
--z-fixed: 1030;
--z-modal-backdrop: 1040;
--z-modal: 1050;
--z-popover: 1060;
--z-tooltip: 1070;
/* Breakpoints (for reference in media queries) */
--breakpoint-mobile: 480px;
--breakpoint-tablet: 768px;
--breakpoint-desktop: 1024px;
--breakpoint-wide: 1400px;
}
/* Dark mode support (for future enhancement) */
@media (prefers-color-scheme: dark) {
:root {
--color-text: #f9fafb;
--color-text-secondary: #d1d5db;
--color-text-muted: #9ca3af;
--color-bg: #111827;
--color-bg-secondary: #1f2937;
--color-bg-muted: #374151;
--color-border: #374151;
--color-border-light: #4b5563;
--color-border-dark: #6b7280;
/* Surface colors for dark mode */
--surface-0: #ffffff;
--surface-50: #020617;
--surface-100: #0f172a;
--surface-200: #1e293b;
--surface-300: #334155;
--surface-400: #475569;
--surface-500: #64748b;
--surface-600: #94a3b8;
--surface-700: #cbd5e1;
--surface-800: #e2e8f0;
--surface-900: #f1f5f9;
--surface-950: #f8fafc;
/* Red colors remain the same in dark mode */
}
}

686
src/assets/css/global.css Normal file
View File

@@ -0,0 +1,686 @@
/* Global CSS for ROA Reports */
/* CSS Custom Properties for consistent theming */
:root {
/* Primary Colors */
--roa-primary: #2563eb;
--roa-primary-hover: #1d4ed8;
--roa-primary-light: #dbeafe;
/* Success Colors */
--roa-success: #16a34a;
--roa-success-light: #dcfce7;
/* Warning Colors */
--roa-warning: #ca8a04;
--roa-warning-light: #fef3c7;
/* Danger Colors */
--roa-danger: #dc2626;
--roa-danger-light: #fee2e2;
/* Neutral Colors */
--roa-gray-50: #f9fafb;
--roa-gray-100: #f3f4f6;
--roa-gray-200: #e5e7eb;
--roa-gray-300: #d1d5db;
--roa-gray-400: #9ca3af;
--roa-gray-500: #6b7280;
--roa-gray-600: #4b5563;
--roa-gray-700: #374151;
--roa-gray-800: #1f2937;
--roa-gray-900: #111827;
/* Spacing */
--roa-spacing-xs: 0.25rem;
--roa-spacing-sm: 0.5rem;
--roa-spacing-md: 1rem;
--roa-spacing-lg: 1.5rem;
--roa-spacing-xl: 2rem;
--roa-spacing-2xl: 3rem;
/* Border Radius */
--roa-radius-sm: 0.375rem;
--roa-radius-md: 0.5rem;
--roa-radius-lg: 0.75rem;
--roa-radius-xl: 1rem;
/* Shadows */
--roa-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--roa-shadow-md:
0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--roa-shadow-lg:
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--roa-shadow-xl:
0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
/* Transitions */
--roa-transition-fast: 150ms ease-in-out;
--roa-transition-normal: 300ms ease-in-out;
--roa-transition-slow: 500ms ease-in-out;
}
/* Reset and Base Styles */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
"Noto Sans",
sans-serif;
}
body {
margin: 0;
background-color: var(--surface-ground, var(--roa-gray-50));
color: var(--text-color, var(--roa-gray-900));
font-feature-settings: "kern" 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Utility Classes */
/* Spacing Utilities */
.m-0 {
margin: 0;
}
.m-1 {
margin: var(--roa-spacing-xs);
}
.m-2 {
margin: var(--roa-spacing-sm);
}
.m-3 {
margin: var(--roa-spacing-md);
}
.m-4 {
margin: var(--roa-spacing-lg);
}
.m-5 {
margin: var(--roa-spacing-xl);
}
.p-0 {
padding: 0;
}
.p-1 {
padding: var(--roa-spacing-xs);
}
.p-2 {
padding: var(--roa-spacing-sm);
}
.p-3 {
padding: var(--roa-spacing-md);
}
.p-4 {
padding: var(--roa-spacing-lg);
}
.p-5 {
padding: var(--roa-spacing-xl);
}
.mt-0 {
margin-top: 0;
}
.mt-1 {
margin-top: var(--roa-spacing-xs);
}
.mt-2 {
margin-top: var(--roa-spacing-sm);
}
.mt-3 {
margin-top: var(--roa-spacing-md);
}
.mt-4 {
margin-top: var(--roa-spacing-lg);
}
.mt-5 {
margin-top: var(--roa-spacing-xl);
}
.mb-0 {
margin-bottom: 0;
}
.mb-1 {
margin-bottom: var(--roa-spacing-xs);
}
.mb-2 {
margin-bottom: var(--roa-spacing-sm);
}
.mb-3 {
margin-bottom: var(--roa-spacing-md);
}
.mb-4 {
margin-bottom: var(--roa-spacing-lg);
}
.mb-5 {
margin-bottom: var(--roa-spacing-xl);
}
/* Text Utilities */
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.text-base {
font-size: 1rem;
line-height: 1.5rem;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem;
}
.text-2xl {
font-size: 1.5rem;
line-height: 2rem;
}
.text-3xl {
font-size: 1.875rem;
line-height: 2.25rem;
}
.font-normal {
font-weight: 400;
}
.font-medium {
font-weight: 500;
}
.font-semibold {
font-weight: 600;
}
.font-bold {
font-weight: 700;
}
.text-left {
text-align: left;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-primary {
color: var(--primary-color, var(--roa-primary));
}
.text-success {
color: var(--green-600, var(--roa-success));
}
.text-warning {
color: var(--yellow-600, var(--roa-warning));
}
.text-danger {
color: var(--red-600, var(--roa-danger));
}
.text-muted {
color: var(--text-color-secondary, var(--roa-gray-500));
}
/* Display Utilities */
.hidden {
display: none;
}
.block {
display: block;
}
.inline {
display: inline;
}
.inline-block {
display: inline-block;
}
.flex {
display: flex;
}
.inline-flex {
display: inline-flex;
}
.grid {
display: grid;
}
/* Flexbox Utilities */
.flex-row {
flex-direction: row;
}
.flex-col {
flex-direction: column;
}
.items-start {
align-items: flex-start;
}
.items-center {
align-items: center;
}
.items-end {
align-items: flex-end;
}
.justify-start {
justify-content: flex-start;
}
.justify-center {
justify-content: center;
}
.justify-end {
justify-content: flex-end;
}
.justify-between {
justify-content: space-between;
}
.justify-around {
justify-content: space-around;
}
.flex-1 {
flex: 1 1 0%;
}
.flex-auto {
flex: 1 1 auto;
}
.flex-none {
flex: none;
}
.gap-1 {
gap: var(--roa-spacing-xs);
}
.gap-2 {
gap: var(--roa-spacing-sm);
}
.gap-3 {
gap: var(--roa-spacing-md);
}
.gap-4 {
gap: var(--roa-spacing-lg);
}
.gap-5 {
gap: var(--roa-spacing-xl);
}
/* Width and Height Utilities */
.w-full {
width: 100%;
}
.w-auto {
width: auto;
}
.h-full {
height: 100%;
}
.h-auto {
height: auto;
}
/* Border Utilities */
.border {
border: 1px solid var(--surface-border, var(--roa-gray-200));
}
.border-0 {
border: 0;
}
.border-t {
border-top: 1px solid var(--surface-border, var(--roa-gray-200));
}
.border-b {
border-bottom: 1px solid var(--surface-border, var(--roa-gray-200));
}
.border-l {
border-left: 1px solid var(--surface-border, var(--roa-gray-200));
}
.border-r {
border-right: 1px solid var(--surface-border, var(--roa-gray-200));
}
.rounded {
border-radius: var(--roa-radius-md);
}
.rounded-sm {
border-radius: var(--roa-radius-sm);
}
.rounded-lg {
border-radius: var(--roa-radius-lg);
}
.rounded-xl {
border-radius: var(--roa-radius-xl);
}
.rounded-full {
border-radius: 9999px;
}
/* Shadow Utilities */
.shadow-sm {
box-shadow: var(--roa-shadow-sm);
}
.shadow-md {
box-shadow: var(--roa-shadow-md);
}
.shadow-lg {
box-shadow: var(--roa-shadow-lg);
}
.shadow-xl {
box-shadow: var(--roa-shadow-xl);
}
.shadow-none {
box-shadow: none;
}
/* Background Utilities */
.bg-white {
background-color: #ffffff;
}
.bg-gray-50 {
background-color: var(--roa-gray-50);
}
.bg-gray-100 {
background-color: var(--roa-gray-100);
}
.bg-primary {
background-color: var(--primary-color, var(--roa-primary));
}
.bg-success {
background-color: var(--green-100, var(--roa-success-light));
}
.bg-warning {
background-color: var(--yellow-100, var(--roa-warning-light));
}
.bg-danger {
background-color: var(--red-100, var(--roa-danger-light));
}
/* Hover Effects */
.hover-lift {
transition:
transform var(--roa-transition-fast),
box-shadow var(--roa-transition-fast);
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow: var(--roa-shadow-lg);
}
/* Focus States */
.focus-ring:focus {
outline: 2px solid var(--primary-color, var(--roa-primary));
outline-offset: 2px;
}
/* Animation Utilities */
.transition-all {
transition: all var(--roa-transition-normal);
}
.transition-colors {
transition:
color var(--roa-transition-normal),
background-color var(--roa-transition-normal),
border-color var(--roa-transition-normal);
}
.transition-opacity {
transition: opacity var(--roa-transition-normal);
}
.transition-transform {
transition: transform var(--roa-transition-normal);
}
/* Custom ROA Classes */
.roa-card {
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, var(--roa-gray-200));
border-radius: var(--roa-radius-lg);
box-shadow: var(--roa-shadow-sm);
padding: var(--roa-spacing-lg);
}
.roa-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--roa-spacing-sm);
padding: var(--roa-spacing-sm) var(--roa-spacing-lg);
border: none;
border-radius: var(--roa-radius-md);
font-weight: 500;
font-size: 0.875rem;
line-height: 1.25rem;
cursor: pointer;
transition: all var(--roa-transition-fast);
background-color: var(--primary-color, var(--roa-primary));
color: white;
}
.roa-button:hover {
background-color: var(--primary-color-dark, var(--roa-primary-hover));
transform: translateY(-1px);
box-shadow: var(--roa-shadow-md);
}
.roa-input {
width: 100%;
padding: var(--roa-spacing-sm) var(--roa-spacing-md);
border: 1px solid var(--surface-border, var(--roa-gray-200));
border-radius: var(--roa-radius-md);
font-size: 0.875rem;
line-height: 1.25rem;
transition:
border-color var(--roa-transition-fast),
box-shadow var(--roa-transition-fast);
}
.roa-input:focus {
outline: none;
border-color: var(--primary-color, var(--roa-primary));
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Invoice Status Classes */
.status-paid {
background-color: var(--green-100, var(--roa-success-light));
color: var(--green-800, var(--roa-success));
padding: var(--roa-spacing-xs) var(--roa-spacing-sm);
border-radius: var(--roa-radius-sm);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-overdue {
background-color: var(--red-100, var(--roa-danger-light));
color: var(--red-800, var(--roa-danger));
padding: var(--roa-spacing-xs) var(--roa-spacing-sm);
border-radius: var(--roa-radius-sm);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-pending {
background-color: var(--yellow-100, var(--roa-warning-light));
color: var(--yellow-800, var(--roa-warning));
padding: var(--roa-spacing-xs) var(--roa-spacing-sm);
border-radius: var(--roa-radius-sm);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Responsive Design */
@media (max-width: 640px) {
.sm\:hidden {
display: none;
}
.sm\:block {
display: block;
}
.sm\:flex {
display: flex;
}
.sm\:grid {
display: grid;
}
.sm\:flex-col {
flex-direction: column;
}
.sm\:items-center {
align-items: center;
}
.sm\:justify-center {
justify-content: center;
}
.sm\:text-center {
text-align: center;
}
.sm\:text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.sm\:p-2 {
padding: var(--roa-spacing-sm);
}
.sm\:m-2 {
margin: var(--roa-spacing-sm);
}
}
@media (max-width: 768px) {
.md\:hidden {
display: none;
}
.md\:block {
display: block;
}
.md\:flex {
display: flex;
}
.md\:grid {
display: grid;
}
.md\:flex-col {
flex-direction: column;
}
.md\:items-center {
align-items: center;
}
.md\:justify-center {
justify-content: center;
}
.md\:text-center {
text-align: center;
}
.md\:text-base {
font-size: 1rem;
line-height: 1.5rem;
}
.md\:p-3 {
padding: var(--roa-spacing-md);
}
.md\:m-3 {
margin: var(--roa-spacing-md);
}
}
@media (max-width: 1024px) {
.lg\:hidden {
display: none;
}
.lg\:block {
display: block;
}
.lg\:flex {
display: flex;
}
.lg\:grid {
display: grid;
}
.lg\:flex-row {
flex-direction: row;
}
.lg\:items-start {
align-items: flex-start;
}
.lg\:justify-start {
justify-content: flex-start;
}
.lg\:text-left {
text-align: left;
}
.lg\:text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.lg\:p-4 {
padding: var(--roa-spacing-lg);
}
.lg\:m-4 {
margin: var(--roa-spacing-lg);
}
}
/* Print Styles */
@media print {
.print\:hidden {
display: none !important;
}
.print\:block {
display: block !important;
}
* {
color-adjust: exact;
-webkit-print-color-adjust: exact;
}
}
/* Dark Mode Support (if implemented in the future) */
@media (prefers-color-scheme: dark) {
.dark\:bg-gray-800 {
background-color: var(--roa-gray-800);
}
.dark\:text-white {
color: #ffffff;
}
.dark\:border-gray-600 {
border-color: var(--roa-gray-600);
}
}

View File

@@ -0,0 +1,216 @@
/* Container System - ROA2WEB */
/* Main App Container */
.app-container {
max-width: var(--container-max-width);
margin: 0 auto;
padding: var(--space-lg);
min-height: calc(100vh - var(--header-height));
}
/* Page Container */
.page-container {
width: 100%;
max-width: var(--container-max-width);
margin: 0 auto;
padding: 0 var(--space-lg);
}
/* Section Container */
.section-container {
margin-bottom: var(--space-xl);
}
/* Content Container */
.content-container {
background: var(--color-bg);
border-radius: var(--card-radius);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
/* Header Container */
.header-container {
width: 100%;
height: var(--header-height);
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: var(--z-fixed);
display: flex;
align-items: center;
padding: 0 var(--space-lg);
}
/* Main Content with Header Offset */
.main-content {
margin-top: var(--header-height);
padding: var(--space-lg);
}
/* Dashboard Container */
.dashboard-container {
display: flex;
flex-direction: column;
gap: var(--space-xl);
padding: var(--space-lg);
}
/* Card Container */
.card-container {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-lg);
box-shadow: var(--shadow-sm);
transition: box-shadow var(--transition-fast);
}
.card-container:hover {
box-shadow: var(--shadow-md);
}
/* Compact Card Container */
.card-compact {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-md);
box-shadow: var(--shadow-sm);
}
/* Mini Card Container */
.card-mini {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-sm);
box-shadow: var(--shadow-sm);
}
/* Stats Container */
.stats-container {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-lg);
}
.stats-container-horizontal {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md);
}
/* Table Container */
.table-container {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
overflow: hidden;
}
/* Form Container */
.form-container {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-xl);
}
/* Toolbar Container */
.toolbar-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md) var(--space-lg);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
/* Action Bar Container */
.action-bar {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
background: var(--color-bg-secondary);
border-radius: var(--card-radius);
margin-bottom: var(--space-lg);
}
/* Mobile Container Adjustments */
@media (max-width: 768px) {
.app-container,
.main-content,
.page-container {
padding: var(--space-md);
}
.header-container {
padding: 0 var(--space-md);
}
.dashboard-container {
gap: var(--space-lg);
padding: var(--space-md);
}
.card-container {
padding: var(--space-md);
}
.toolbar-container {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
padding: var(--space-md);
}
.action-bar {
flex-direction: column;
align-items: stretch;
}
}
@media (max-width: 480px) {
.app-container,
.main-content,
.page-container,
.dashboard-container {
padding: var(--space-sm);
}
.header-container {
padding: 0 var(--space-sm);
}
.card-container {
padding: var(--space-sm);
}
.stats-container-horizontal {
flex-direction: column;
gap: var(--space-sm);
text-align: center;
}
}
/* Utility Container Classes */
.container-fluid {
width: 100%;
}
.container-full-height {
min-height: 100vh;
}
.container-centered {
display: flex;
align-items: center;
justify-content: center;
min-height: 50vh;
}

View File

@@ -0,0 +1,271 @@
/* Grid System - ROA2WEB */
/* Flexbox Grid System */
.flex {
display: flex;
}
.inline-flex {
display: inline-flex;
}
/* Flex Direction */
.flex-row {
flex-direction: row;
}
.flex-col {
flex-direction: column;
}
.flex-row-reverse {
flex-direction: row-reverse;
}
.flex-col-reverse {
flex-direction: column-reverse;
}
/* Flex Wrap */
.flex-wrap {
flex-wrap: wrap;
}
.flex-nowrap {
flex-wrap: nowrap;
}
/* Flex Grow/Shrink */
.flex-1 {
flex: 1 1 0%;
}
.flex-auto {
flex: 1 1 auto;
}
.flex-none {
flex: none;
}
/* Justify Content */
.justify-start {
justify-content: flex-start;
}
.justify-center {
justify-content: center;
}
.justify-end {
justify-content: flex-end;
}
.justify-between {
justify-content: space-between;
}
.justify-around {
justify-content: space-around;
}
.justify-evenly {
justify-content: space-evenly;
}
/* Align Items */
.items-start {
align-items: flex-start;
}
.items-center {
align-items: center;
}
.items-end {
align-items: flex-end;
}
.items-stretch {
align-items: stretch;
}
.items-baseline {
align-items: baseline;
}
/* CSS Grid */
.grid {
display: grid;
}
.inline-grid {
display: inline-grid;
}
/* Grid Template Columns */
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.grid-cols-6 {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.grid-cols-12 {
grid-template-columns: repeat(12, minmax(0, 1fr));
}
/* Grid Column Span */
.col-span-1 {
grid-column: span 1 / span 1;
}
.col-span-2 {
grid-column: span 2 / span 2;
}
.col-span-3 {
grid-column: span 3 / span 3;
}
.col-span-4 {
grid-column: span 4 / span 4;
}
.col-span-6 {
grid-column: span 6 / span 6;
}
.col-span-12 {
grid-column: span 12 / span 12;
}
.col-span-full {
grid-column: 1 / -1;
}
/* Grid Gap */
.gap-0 {
gap: 0;
}
.gap-1 {
gap: var(--space-xs);
}
.gap-2 {
gap: var(--space-sm);
}
.gap-4 {
gap: var(--space-md);
}
.gap-6 {
gap: var(--space-lg);
}
.gap-8 {
gap: var(--space-xl);
}
.gap-x-0 {
column-gap: 0;
}
.gap-x-1 {
column-gap: var(--space-xs);
}
.gap-x-2 {
column-gap: var(--space-sm);
}
.gap-x-4 {
column-gap: var(--space-md);
}
.gap-x-6 {
column-gap: var(--space-lg);
}
.gap-x-8 {
column-gap: var(--space-xl);
}
.gap-y-0 {
row-gap: 0;
}
.gap-y-1 {
row-gap: var(--space-xs);
}
.gap-y-2 {
row-gap: var(--space-sm);
}
.gap-y-4 {
row-gap: var(--space-md);
}
.gap-y-6 {
row-gap: var(--space-lg);
}
.gap-y-8 {
row-gap: var(--space-xl);
}
/* Dashboard Specific Grids */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-lg);
}
.dashboard-v2-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: var(--space-md);
}
.dashboard-v3-layout {
display: grid;
grid-template-columns: 1fr 300px;
gap: var(--space-xl);
}
.dashboard-v4-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-lg);
}
/* Responsive Grid Adjustments */
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.dashboard-v2-grid {
grid-template-columns: repeat(3, 1fr);
}
.dashboard-v3-layout {
grid-template-columns: 1fr;
}
.dashboard-v4-actions {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.dashboard-v2-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(6, 1fr);
}
.dashboard-v4-actions {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.dashboard-v2-grid {
grid-template-columns: 1fr;
grid-template-rows: repeat(12, auto);
}
}
/* Auto-fit and Auto-fill Grids */
.grid-auto-fit {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--space-lg);
}
.grid-auto-fill {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-md);
}

View File

@@ -0,0 +1,288 @@
/* Navigation System - ROA2WEB */
/* Header Navigation */
.header-nav {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
}
.header-brand {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--color-primary);
text-decoration: none;
}
.header-actions {
display: flex;
align-items: center;
gap: var(--space-md);
}
.header-user {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.header-user:hover {
background-color: var(--color-bg-secondary);
}
/* Hamburger Menu */
.hamburger-btn {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 24px;
height: 18px;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.hamburger-line {
width: 100%;
height: 2px;
background-color: var(--color-text);
border-radius: 1px;
transition: all var(--transition-fast);
}
.hamburger-btn.active .hamburger-line:nth-child(1) {
transform: rotate(45deg) translate(5px, 5px);
}
.hamburger-btn.active .hamburger-line:nth-child(2) {
opacity: 0;
}
.hamburger-btn.active .hamburger-line:nth-child(3) {
transform: rotate(-45deg) translate(5px, -5px);
}
/* Slide-out Menu */
.slide-menu {
position: fixed;
top: var(--header-height);
left: 0;
width: var(--sidebar-width);
height: calc(100vh - var(--header-height));
background: var(--color-bg);
border-right: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
transform: translateX(-100%);
transition: transform var(--transition-normal);
z-index: var(--z-modal);
overflow-y: auto;
/* Flex container for profile section at bottom */
display: flex;
flex-direction: column;
}
.slide-menu.open {
transform: translateX(0);
}
.slide-menu-overlay {
position: fixed;
top: var(--header-height);
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: all var(--transition-normal);
z-index: var(--z-modal-backdrop);
}
.slide-menu-overlay.open {
opacity: 1;
visibility: visible;
}
/* Menu Content */
.menu-section {
padding: var(--space-lg);
border-bottom: 1px solid var(--color-border);
}
.menu-section:last-child {
border-bottom: none;
}
.menu-title {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-md);
}
.menu-list {
list-style: none;
}
.menu-item {
margin-bottom: var(--space-xs);
}
.menu-link {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
color: var(--color-text);
text-decoration: none;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.menu-link:hover,
.menu-link.active {
background-color: var(--color-bg-secondary);
color: var(--color-primary);
}
.menu-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
/* Dashboard Switcher */
.dashboard-switcher {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.dashboard-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-md);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.dashboard-option:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
.dashboard-option.active {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
.dashboard-label {
font-weight: var(--font-medium);
}
.dashboard-description {
font-size: var(--text-xs);
opacity: 0.8;
}
/* Breadcrumb Navigation */
.breadcrumb {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
font-size: var(--text-sm);
}
.breadcrumb-item {
color: var(--color-text-secondary);
}
.breadcrumb-item:last-child {
color: var(--color-text);
font-weight: var(--font-medium);
}
.breadcrumb-separator {
color: var(--color-text-muted);
}
/* Quick Actions Toolbar */
.quick-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.quick-action-btn {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
text-decoration: none;
font-size: var(--text-sm);
transition: all var(--transition-fast);
}
.quick-action-btn:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
/* Mobile Navigation */
@media (max-width: 768px) {
.header-actions {
gap: var(--space-sm);
}
.quick-actions {
display: none;
}
.slide-menu {
width: 280px;
}
.menu-section {
padding: var(--space-md);
}
.quick-action-btn {
justify-content: center;
padding: var(--space-md);
}
}
@media (max-width: 480px) {
.header-brand {
font-size: var(--text-base);
}
.slide-menu {
width: 100vw;
max-width: 320px;
}
}

164
src/assets/css/main.css Normal file
View File

@@ -0,0 +1,164 @@
/* Main CSS Entry Point - ROA2WEB */
/* Import order is critical for proper CSS cascade */
/* 0. Shared Layout Styles (imported via App.vue, commented out here to avoid duplicates) */
/* @import '../../../../../shared/frontend/styles/layout/header.css'; */
/* @import '../../../../../shared/frontend/styles/layout/navigation.css'; */
/* 1. Core Foundation */
@import "./core/variables.css";
@import "./core/tokens.css"; /* NEW - Extended design tokens */
@import "./core/reset.css";
@import "./core/typography.css";
/* 2. Layout System */
@import "./layout/grid.css";
@import "./layout/containers.css";
@import "./layout/navigation.css";
/* 3. Component Library */
@import "./components/cards.css";
@import "./components/buttons.css";
@import "./components/tables.css";
@import "./components/forms.css";
@import "./components/stats.css";
/* 4. Patterns - NEW */
@import "./patterns/interactive.css"; /* Loading spinners, trends, collapse */
@import "./patterns/dashboard.css"; /* Page headers, metrics, breakdowns */
@import "./patterns/animations.css"; /* Transitions and animations */
/* 5. Utilities */
@import "./utilities/spacing.css";
@import "./utilities/display.css";
@import "./utilities/text.css";
@import "./utilities/flex.css";
@import "./utilities/colors.css";
/* 6. Vendor Overrides - NEW */
@import "./vendor/primevue-overrides.css"; /* Centralized PrimeVue customization */
/* 7. Mobile Optimizations */
@import "./mobile.css";
/* Global Application Styles */
html {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
line-height: var(--leading-normal);
color: var(--color-text);
background-color: var(--color-bg);
}
body {
margin: 0;
padding: 0;
min-height: 100vh;
overflow-x: hidden;
}
/* Vue App Wrapper */
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Remove default router-link styles */
.router-link-active,
.router-link-exact-active {
text-decoration: none;
}
/* Smooth scrolling behavior */
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
/* Focus management */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Loading states */
.loading {
opacity: 0.6;
pointer-events: none;
}
.loading::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
margin: -10px 0 0 -10px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Error states */
.error {
color: var(--color-error);
border-color: var(--color-error);
}
.success {
color: var(--color-success);
border-color: var(--color-success);
}
.warning {
color: var(--color-warning);
border-color: var(--color-warning);
}
/* Print styles */
@media print {
.no-print {
display: none !important;
}
.print-only {
display: block !important;
}
* {
background: white !important;
color: black !important;
box-shadow: none !important;
}
.card,
.stats-card,
.kpi-card {
border: 1px solid #ccc !important;
break-inside: avoid;
margin-bottom: 1rem;
}
}

1109
src/assets/css/mobile.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
/* Animations - ROA2WEB */
/* Slide Down Animation */
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.slide-down {
animation: slideDown var(--duration-fast) ease-out;
}
/* Fade In Animation */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fadeIn var(--duration-normal) ease-in;
}
/* Slide In From Right */
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.slide-in-right {
animation: slideInRight var(--duration-normal) ease-out;
}
/* Pulse Animation */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.pulse {
animation: pulse var(--duration-slower) ease-in-out infinite;
}

View File

@@ -0,0 +1,190 @@
/* Dashboard Patterns - ROA2WEB */
/* ===== Page Headers ===== */
.page-header {
margin-bottom: var(--space-xl);
text-align: center;
}
.page-title {
margin: 0 0 var(--space-sm) 0;
font-size: var(--text-3xl);
font-weight: var(--font-bold);
color: var(--color-text);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-md);
}
.page-subtitle {
margin: 0;
font-size: var(--text-base);
color: var(--color-text-secondary);
}
/* ===== Section Structure ===== */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-lg);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
gap: var(--space-md);
}
.section-title {
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--color-text);
margin: 0;
}
.section-controls {
display: flex;
align-items: center;
gap: var(--space-md);
flex-wrap: wrap;
}
/* ===== Metrics Grid ===== */
.metrics-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-lg);
margin-bottom: var(--space-xl);
}
@media (max-width: 1024px) {
.metrics-row {
grid-template-columns: 1fr;
}
}
/* ===== Breakdown Patterns ===== */
.breakdown-section {
padding-top: var(--space-lg);
border-top: 1px solid var(--color-border);
margin-top: var(--space-lg);
}
.breakdown-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) 0;
}
.breakdown-label {
font-size: var(--text-sm);
color: var(--color-text-secondary);
font-weight: var(--font-medium);
}
.breakdown-value {
font-size: var(--text-base);
color: var(--color-text);
font-weight: var(--font-semibold);
font-family: var(--font-mono, monospace);
}
.breakdown-subitems {
padding-left: var(--space-lg);
margin-top: var(--space-sm);
}
.breakdown-subitem {
display: flex;
justify-content: space-between;
padding: var(--space-xs) 0;
}
.breakdown-sublabel {
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
.breakdown-subvalue {
font-weight: var(--font-medium);
font-family: var(--font-mono, monospace);
font-size: var(--text-sm);
}
/* ===== Enhanced Sparkline Patterns ===== */
.metric-sparkline {
margin: var(--space-md) 0;
}
.sparkline-container {
width: 100%;
height: 60px;
position: relative;
}
.sparkline-canvas {
width: 100%;
height: 100%;
}
.sparkline-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-xs);
}
.sparkline-title {
font-size: var(--text-xs);
color: var(--color-text-secondary);
text-transform: uppercase;
font-weight: var(--font-medium);
}
.sparkline-value {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
font-family: var(--font-mono, monospace);
}
/* ===== Enhanced Breakdown Patterns ===== */
.breakdown-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: var(--space-sm);
border-radius: var(--radius-sm);
transition: background-color var(--transition-fast);
margin-bottom: var(--space-xs);
}
.breakdown-header:hover {
background: var(--color-bg-secondary);
}
.breakdown-header-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.breakdown-toggle {
color: var(--color-text-secondary);
font-size: 0.625rem;
transition: transform var(--transition-fast);
}
.breakdown-toggle.expanded {
transform: rotate(90deg);
}
.breakdown-divider {
height: 1px;
background: var(--color-border);
margin: var(--space-md) 0;
}

View File

@@ -0,0 +1,116 @@
/* Interactive Patterns - ROA2WEB */
/* ===== Loading Spinners ===== */
.loading-spinner {
width: var(--spinner-size, 40px);
height: var(--spinner-size, 40px);
border: 4px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
}
.loading-spinner-sm {
--spinner-size: 24px;
border-width: 3px;
}
.loading-spinner-lg {
--spinner-size: 56px;
border-width: 5px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ===== Trend Indicators ===== */
.trend {
display: inline-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;
}
/* ===== Collapse/Expand Patterns ===== */
.collapsible-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: var(--space-sm);
border-radius: var(--radius-sm);
transition: background-color var(--transition-fast);
}
.collapsible-header:hover {
background: var(--color-bg-secondary);
}
.collapse-icon {
font-size: 0.625rem;
color: var(--color-text-secondary);
transition: transform var(--transition-fast);
display: inline-block;
width: 1rem;
}
.collapse-icon.expanded {
transform: rotate(90deg);
}
/* ===== Card Hover Effects ===== */
.card-hover {
transition: all var(--transition-fast);
}
.card-hover:hover {
box-shadow: var(--shadow-md);
transform: translateY(var(--hover-lift, -2px));
border-color: var(--color-primary);
}
/* ===== Sparkline Containers ===== */
.sparkline-container {
width: 100%;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-sm);
}
.sparkline-chart {
width: 100%;
height: var(--sparkline-height, 80px);
position: relative;
}
.sparkline-chart-lg {
--sparkline-height: 150px;
}
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}

View File

@@ -0,0 +1,102 @@
/* Color Utilities - ROA2WEB */
/* ===== Background Colors ===== */
.bg-primary {
background-color: var(--color-primary);
color: var(--color-text-inverse);
}
.bg-success {
background-color: var(--color-success);
color: var(--color-text-inverse);
}
.bg-warning {
background-color: var(--color-warning);
color: var(--color-text-inverse);
}
.bg-error {
background-color: var(--color-error);
color: var(--color-text-inverse);
}
.bg-info {
background-color: var(--color-info);
color: var(--color-text-inverse);
}
/* ===== Light Background Colors (10% opacity) ===== */
.bg-primary-light {
background-color: rgba(37, 99, 235, 0.1);
color: var(--color-primary);
}
.bg-success-light {
background-color: rgba(5, 150, 105, 0.1);
color: var(--color-success);
}
.bg-warning-light {
background-color: rgba(217, 119, 6, 0.1);
color: var(--color-warning);
}
.bg-error-light {
background-color: rgba(220, 38, 38, 0.1);
color: var(--color-error);
}
.bg-info-light {
background-color: rgba(8, 145, 178, 0.1);
color: var(--color-info);
}
/* ===== Text Colors ===== */
.text-primary {
color: var(--color-primary);
}
.text-success {
color: var(--color-success);
}
.text-warning {
color: var(--color-warning);
}
.text-error {
color: var(--color-error);
}
.text-info {
color: var(--color-info);
}
.text-muted {
color: var(--color-text-muted);
}
.text-secondary {
color: var(--color-text-secondary);
}
/* ===== Icon Background Utilities ===== */
.icon-bg {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
}
.icon-bg-sm {
width: 32px;
height: 32px;
}
.icon-bg-lg {
width: 48px;
height: 48px;
}

View File

@@ -0,0 +1,613 @@
/* Display Utilities - ROA2WEB */
/* Display Types */
.block {
display: block;
}
.inline-block {
display: inline-block;
}
.inline {
display: inline;
}
.flex {
display: flex;
}
.inline-flex {
display: inline-flex;
}
.grid {
display: grid;
}
.inline-grid {
display: inline-grid;
}
.table {
display: table;
}
.table-cell {
display: table-cell;
}
.table-row {
display: table-row;
}
.hidden {
display: none;
}
/* Visibility */
.visible {
visibility: visible;
}
.invisible {
visibility: hidden;
}
/* Position */
.static {
position: static;
}
.relative {
position: relative;
}
.absolute {
position: absolute;
}
.fixed {
position: fixed;
}
.sticky {
position: sticky;
}
/* Position Values */
.top-0 {
top: 0;
}
.top-1 {
top: var(--space-xs);
}
.top-2 {
top: var(--space-sm);
}
.top-4 {
top: var(--space-md);
}
.top-auto {
top: auto;
}
.right-0 {
right: 0;
}
.right-1 {
right: var(--space-xs);
}
.right-2 {
right: var(--space-sm);
}
.right-4 {
right: var(--space-md);
}
.right-auto {
right: auto;
}
.bottom-0 {
bottom: 0;
}
.bottom-1 {
bottom: var(--space-xs);
}
.bottom-2 {
bottom: var(--space-sm);
}
.bottom-4 {
bottom: var(--space-md);
}
.bottom-auto {
bottom: auto;
}
.left-0 {
left: 0;
}
.left-1 {
left: var(--space-xs);
}
.left-2 {
left: var(--space-sm);
}
.left-4 {
left: var(--space-md);
}
.left-auto {
left: auto;
}
.inset-0 {
top: 0;
right: 0;
bottom: 0;
left: 0;
}
/* Z-Index */
.z-0 {
z-index: 0;
}
.z-10 {
z-index: 10;
}
.z-20 {
z-index: 20;
}
.z-30 {
z-index: 30;
}
.z-40 {
z-index: 40;
}
.z-50 {
z-index: 50;
}
.z-auto {
z-index: auto;
}
.z-dropdown {
z-index: var(--z-dropdown);
}
.z-sticky {
z-index: var(--z-sticky);
}
.z-fixed {
z-index: var(--z-fixed);
}
.z-modal {
z-index: var(--z-modal);
}
/* Float */
.float-left {
float: left;
}
.float-right {
float: right;
}
.float-none {
float: none;
}
.clearfix::after {
content: "";
display: table;
clear: both;
}
/* Overflow */
.overflow-auto {
overflow: auto;
}
.overflow-hidden {
overflow: hidden;
}
.overflow-visible {
overflow: visible;
}
.overflow-scroll {
overflow: scroll;
}
.overflow-x-auto {
overflow-x: auto;
}
.overflow-x-hidden {
overflow-x: hidden;
}
.overflow-x-visible {
overflow-x: visible;
}
.overflow-x-scroll {
overflow-x: scroll;
}
.overflow-y-auto {
overflow-y: auto;
}
.overflow-y-hidden {
overflow-y: hidden;
}
.overflow-y-visible {
overflow-y: visible;
}
.overflow-y-scroll {
overflow-y: scroll;
}
/* Object Fit */
.object-contain {
object-fit: contain;
}
.object-cover {
object-fit: cover;
}
.object-fill {
object-fit: fill;
}
.object-none {
object-fit: none;
}
.object-scale-down {
object-fit: scale-down;
}
/* Object Position */
.object-bottom {
object-position: bottom;
}
.object-center {
object-position: center;
}
.object-left {
object-position: left;
}
.object-right {
object-position: right;
}
.object-top {
object-position: top;
}
/* Width */
.w-auto {
width: auto;
}
.w-full {
width: 100%;
}
.w-screen {
width: 100vw;
}
.w-min {
width: min-content;
}
.w-max {
width: max-content;
}
.w-fit {
width: fit-content;
}
.w-0 {
width: 0;
}
.w-1 {
width: var(--space-xs);
}
.w-2 {
width: var(--space-sm);
}
.w-4 {
width: var(--space-md);
}
.w-6 {
width: var(--space-lg);
}
.w-8 {
width: var(--space-xl);
}
.w-1\/2 {
width: 50%;
}
.w-1\/3 {
width: 33.333333%;
}
.w-2\/3 {
width: 66.666667%;
}
.w-1\/4 {
width: 25%;
}
.w-3\/4 {
width: 75%;
}
.w-1\/5 {
width: 20%;
}
.w-2\/5 {
width: 40%;
}
.w-3\/5 {
width: 60%;
}
.w-4\/5 {
width: 80%;
}
/* Max Width */
.max-w-none {
max-width: none;
}
.max-w-full {
max-width: 100%;
}
.max-w-screen {
max-width: 100vw;
}
.max-w-xs {
max-width: 20rem;
}
.max-w-sm {
max-width: 24rem;
}
.max-w-md {
max-width: 28rem;
}
.max-w-lg {
max-width: 32rem;
}
.max-w-xl {
max-width: 36rem;
}
.max-w-2xl {
max-width: 42rem;
}
.max-w-3xl {
max-width: 48rem;
}
.max-w-4xl {
max-width: 56rem;
}
.max-w-5xl {
max-width: 64rem;
}
.max-w-6xl {
max-width: 72rem;
}
.max-w-7xl {
max-width: 80rem;
}
/* Min Width */
.min-w-0 {
min-width: 0;
}
.min-w-full {
min-width: 100%;
}
.min-w-min {
min-width: min-content;
}
.min-w-max {
min-width: max-content;
}
.min-w-fit {
min-width: fit-content;
}
/* Height */
.h-auto {
height: auto;
}
.h-full {
height: 100%;
}
.h-screen {
height: 100vh;
}
.h-min {
height: min-content;
}
.h-max {
height: max-content;
}
.h-fit {
height: fit-content;
}
.h-0 {
height: 0;
}
.h-1 {
height: var(--space-xs);
}
.h-2 {
height: var(--space-sm);
}
.h-4 {
height: var(--space-md);
}
.h-6 {
height: var(--space-lg);
}
.h-8 {
height: var(--space-xl);
}
.h-10 {
height: 2.5rem;
}
.h-12 {
height: var(--space-3xl);
}
.h-16 {
height: 4rem;
}
.h-20 {
height: 5rem;
}
.h-24 {
height: 6rem;
}
.h-32 {
height: 8rem;
}
.h-40 {
height: 10rem;
}
.h-48 {
height: 12rem;
}
.h-56 {
height: 14rem;
}
.h-64 {
height: 16rem;
}
/* Max Height */
.max-h-full {
max-height: 100%;
}
.max-h-screen {
max-height: 100vh;
}
.max-h-none {
max-height: none;
}
/* Min Height */
.min-h-0 {
min-height: 0;
}
.min-h-full {
min-height: 100%;
}
.min-h-screen {
min-height: 100vh;
}
/* Aspect Ratio */
.aspect-auto {
aspect-ratio: auto;
}
.aspect-square {
aspect-ratio: 1 / 1;
}
.aspect-video {
aspect-ratio: 16 / 9;
}
/* Box Sizing */
.box-border {
box-sizing: border-box;
}
.box-content {
box-sizing: content-box;
}
/* Cursor */
.cursor-auto {
cursor: auto;
}
.cursor-default {
cursor: default;
}
.cursor-pointer {
cursor: pointer;
}
.cursor-wait {
cursor: wait;
}
.cursor-text {
cursor: text;
}
.cursor-move {
cursor: move;
}
.cursor-help {
cursor: help;
}
.cursor-not-allowed {
cursor: not-allowed;
}
/* User Select */
.select-none {
user-select: none;
}
.select-text {
user-select: text;
}
.select-all {
user-select: all;
}
.select-auto {
user-select: auto;
}
/* Pointer Events */
.pointer-events-none {
pointer-events: none;
}
.pointer-events-auto {
pointer-events: auto;
}
/* Resize */
.resize-none {
resize: none;
}
.resize {
resize: both;
}
.resize-y {
resize: vertical;
}
.resize-x {
resize: horizontal;
}
/* Responsive Utilities */
@media (max-width: 480px) {
.mobile-hidden {
display: none !important;
}
.mobile-block {
display: block !important;
}
.mobile-flex {
display: flex !important;
}
.mobile-grid {
display: grid !important;
}
}
@media (min-width: 481px) {
.mobile-only {
display: none !important;
}
}
@media (max-width: 768px) {
.tablet-hidden {
display: none !important;
}
.tablet-block {
display: block !important;
}
.tablet-flex {
display: flex !important;
}
.tablet-grid {
display: grid !important;
}
}
@media (min-width: 769px) {
.tablet-only {
display: none !important;
}
}
@media (min-width: 1024px) {
.desktop-only {
display: block !important;
}
}
@media (max-width: 1023px) {
.desktop-hidden {
display: none !important;
}
}

View File

@@ -0,0 +1,331 @@
/* Flex Utilities - ROA2WEB */
/* Flex Display */
.flex {
display: flex;
}
.inline-flex {
display: inline-flex;
}
/* Flex Direction */
.flex-row {
flex-direction: row;
}
.flex-row-reverse {
flex-direction: row-reverse;
}
.flex-col {
flex-direction: column;
}
.flex-col-reverse {
flex-direction: column-reverse;
}
/* Flex Wrap */
.flex-wrap {
flex-wrap: wrap;
}
.flex-nowrap {
flex-wrap: nowrap;
}
.flex-wrap-reverse {
flex-wrap: wrap-reverse;
}
/* Flex */
.flex-1 {
flex: 1 1 0%;
}
.flex-auto {
flex: 1 1 auto;
}
.flex-initial {
flex: 0 1 auto;
}
.flex-none {
flex: none;
}
/* Flex Grow */
.flex-grow-0 {
flex-grow: 0;
}
.flex-grow {
flex-grow: 1;
}
/* Flex Shrink */
.flex-shrink-0 {
flex-shrink: 0;
}
.flex-shrink {
flex-shrink: 1;
}
/* Justify Content */
.justify-start {
justify-content: flex-start;
}
.justify-end {
justify-content: flex-end;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.justify-around {
justify-content: space-around;
}
.justify-evenly {
justify-content: space-evenly;
}
/* Align Items */
.items-start {
align-items: flex-start;
}
.items-end {
align-items: flex-end;
}
.items-center {
align-items: center;
}
.items-baseline {
align-items: baseline;
}
.items-stretch {
align-items: stretch;
}
/* Align Content */
.content-start {
align-content: flex-start;
}
.content-end {
align-content: flex-end;
}
.content-center {
align-content: center;
}
.content-between {
align-content: space-between;
}
.content-around {
align-content: space-around;
}
.content-evenly {
align-content: space-evenly;
}
/* Align Self */
.self-auto {
align-self: auto;
}
.self-start {
align-self: flex-start;
}
.self-end {
align-self: flex-end;
}
.self-center {
align-self: center;
}
.self-stretch {
align-self: stretch;
}
.self-baseline {
align-self: baseline;
}
/* Gap */
.gap-0 {
gap: 0;
}
.gap-1 {
gap: var(--space-xs);
}
.gap-2 {
gap: var(--space-sm);
}
.gap-3 {
gap: 0.75rem;
}
.gap-4 {
gap: var(--space-md);
}
.gap-5 {
gap: 1.25rem;
}
.gap-6 {
gap: var(--space-lg);
}
.gap-8 {
gap: var(--space-xl);
}
.gap-x-0 {
column-gap: 0;
}
.gap-x-1 {
column-gap: var(--space-xs);
}
.gap-x-2 {
column-gap: var(--space-sm);
}
.gap-x-3 {
column-gap: 0.75rem;
}
.gap-x-4 {
column-gap: var(--space-md);
}
.gap-x-6 {
column-gap: var(--space-lg);
}
.gap-x-8 {
column-gap: var(--space-xl);
}
.gap-y-0 {
row-gap: 0;
}
.gap-y-1 {
row-gap: var(--space-xs);
}
.gap-y-2 {
row-gap: var(--space-sm);
}
.gap-y-3 {
row-gap: 0.75rem;
}
.gap-y-4 {
row-gap: var(--space-md);
}
.gap-y-6 {
row-gap: var(--space-lg);
}
.gap-y-8 {
row-gap: var(--space-xl);
}
/* Order */
.order-1 {
order: 1;
}
.order-2 {
order: 2;
}
.order-3 {
order: 3;
}
.order-4 {
order: 4;
}
.order-5 {
order: 5;
}
.order-6 {
order: 6;
}
.order-7 {
order: 7;
}
.order-8 {
order: 8;
}
.order-9 {
order: 9;
}
.order-10 {
order: 10;
}
.order-11 {
order: 11;
}
.order-12 {
order: 12;
}
.order-first {
order: -9999;
}
.order-last {
order: 9999;
}
.order-none {
order: 0;
}
/* Responsive Flex Utilities */
@media (max-width: 480px) {
.mobile-flex {
display: flex;
}
.mobile-flex-col {
flex-direction: column;
}
.mobile-flex-wrap {
flex-wrap: wrap;
}
.mobile-items-center {
align-items: center;
}
.mobile-items-start {
align-items: flex-start;
}
.mobile-items-stretch {
align-items: stretch;
}
.mobile-justify-center {
justify-content: center;
}
.mobile-justify-between {
justify-content: space-between;
}
}
@media (max-width: 768px) {
.tablet-flex {
display: flex;
}
.tablet-flex-col {
flex-direction: column;
}
.tablet-flex-wrap {
flex-wrap: wrap;
}
.tablet-items-center {
align-items: center;
}
.tablet-items-start {
align-items: flex-start;
}
.tablet-items-stretch {
align-items: stretch;
}
.tablet-justify-center {
justify-content: center;
}
.tablet-justify-between {
justify-content: space-between;
}
}
@media (min-width: 1024px) {
.desktop-flex {
display: flex;
}
.desktop-flex-row {
flex-direction: row;
}
.desktop-flex-nowrap {
flex-wrap: nowrap;
}
.desktop-items-center {
align-items: center;
}
.desktop-justify-start {
justify-content: flex-start;
}
}

View File

@@ -0,0 +1,578 @@
/* Spacing Utilities - ROA2WEB */
/* Margin Utilities */
.m-0 {
margin: 0;
}
.m-1 {
margin: var(--space-xs);
}
.m-2 {
margin: var(--space-sm);
}
.m-3 {
margin: 0.75rem;
}
.m-4 {
margin: var(--space-md);
}
.m-5 {
margin: 1.25rem;
}
.m-6 {
margin: var(--space-lg);
}
.m-8 {
margin: var(--space-xl);
}
.m-10 {
margin: 2.5rem;
}
.m-12 {
margin: var(--space-3xl);
}
.m-auto {
margin: auto;
}
/* Margin Top */
.mt-0 {
margin-top: 0;
}
.mt-1 {
margin-top: var(--space-xs);
}
.mt-2 {
margin-top: var(--space-sm);
}
.mt-3 {
margin-top: 0.75rem;
}
.mt-4 {
margin-top: var(--space-md);
}
.mt-5 {
margin-top: 1.25rem;
}
.mt-6 {
margin-top: var(--space-lg);
}
.mt-8 {
margin-top: var(--space-xl);
}
.mt-10 {
margin-top: 2.5rem;
}
.mt-12 {
margin-top: var(--space-3xl);
}
.mt-auto {
margin-top: auto;
}
/* Margin Right */
.mr-0 {
margin-right: 0;
}
.mr-1 {
margin-right: var(--space-xs);
}
.mr-2 {
margin-right: var(--space-sm);
}
.mr-3 {
margin-right: 0.75rem;
}
.mr-4 {
margin-right: var(--space-md);
}
.mr-5 {
margin-right: 1.25rem;
}
.mr-6 {
margin-right: var(--space-lg);
}
.mr-8 {
margin-right: var(--space-xl);
}
.mr-10 {
margin-right: 2.5rem;
}
.mr-12 {
margin-right: var(--space-3xl);
}
.mr-auto {
margin-right: auto;
}
/* Margin Bottom */
.mb-0 {
margin-bottom: 0;
}
.mb-1 {
margin-bottom: var(--space-xs);
}
.mb-2 {
margin-bottom: var(--space-sm);
}
.mb-3 {
margin-bottom: 0.75rem;
}
.mb-4 {
margin-bottom: var(--space-md);
}
.mb-5 {
margin-bottom: 1.25rem;
}
.mb-6 {
margin-bottom: var(--space-lg);
}
.mb-8 {
margin-bottom: var(--space-xl);
}
.mb-10 {
margin-bottom: 2.5rem;
}
.mb-12 {
margin-bottom: var(--space-3xl);
}
.mb-auto {
margin-bottom: auto;
}
/* Margin Left */
.ml-0 {
margin-left: 0;
}
.ml-1 {
margin-left: var(--space-xs);
}
.ml-2 {
margin-left: var(--space-sm);
}
.ml-3 {
margin-left: 0.75rem;
}
.ml-4 {
margin-left: var(--space-md);
}
.ml-5 {
margin-left: 1.25rem;
}
.ml-6 {
margin-left: var(--space-lg);
}
.ml-8 {
margin-left: var(--space-xl);
}
.ml-10 {
margin-left: 2.5rem;
}
.ml-12 {
margin-left: var(--space-3xl);
}
.ml-auto {
margin-left: auto;
}
/* Margin X (horizontal) */
.mx-0 {
margin-left: 0;
margin-right: 0;
}
.mx-1 {
margin-left: var(--space-xs);
margin-right: var(--space-xs);
}
.mx-2 {
margin-left: var(--space-sm);
margin-right: var(--space-sm);
}
.mx-3 {
margin-left: 0.75rem;
margin-right: 0.75rem;
}
.mx-4 {
margin-left: var(--space-md);
margin-right: var(--space-md);
}
.mx-5 {
margin-left: 1.25rem;
margin-right: 1.25rem;
}
.mx-6 {
margin-left: var(--space-lg);
margin-right: var(--space-lg);
}
.mx-8 {
margin-left: var(--space-xl);
margin-right: var(--space-xl);
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
/* Margin Y (vertical) */
.my-0 {
margin-top: 0;
margin-bottom: 0;
}
.my-1 {
margin-top: var(--space-xs);
margin-bottom: var(--space-xs);
}
.my-2 {
margin-top: var(--space-sm);
margin-bottom: var(--space-sm);
}
.my-3 {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
}
.my-4 {
margin-top: var(--space-md);
margin-bottom: var(--space-md);
}
.my-5 {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
}
.my-6 {
margin-top: var(--space-lg);
margin-bottom: var(--space-lg);
}
.my-8 {
margin-top: var(--space-xl);
margin-bottom: var(--space-xl);
}
.my-auto {
margin-top: auto;
margin-bottom: auto;
}
/* Padding Utilities */
.p-0 {
padding: 0;
}
.p-1 {
padding: var(--space-xs);
}
.p-2 {
padding: var(--space-sm);
}
.p-3 {
padding: 0.75rem;
}
.p-4 {
padding: var(--space-md);
}
.p-5 {
padding: 1.25rem;
}
.p-6 {
padding: var(--space-lg);
}
.p-8 {
padding: var(--space-xl);
}
.p-10 {
padding: 2.5rem;
}
.p-12 {
padding: var(--space-3xl);
}
/* Padding Top */
.pt-0 {
padding-top: 0;
}
.pt-1 {
padding-top: var(--space-xs);
}
.pt-2 {
padding-top: var(--space-sm);
}
.pt-3 {
padding-top: 0.75rem;
}
.pt-4 {
padding-top: var(--space-md);
}
.pt-5 {
padding-top: 1.25rem;
}
.pt-6 {
padding-top: var(--space-lg);
}
.pt-8 {
padding-top: var(--space-xl);
}
.pt-10 {
padding-top: 2.5rem;
}
.pt-12 {
padding-top: var(--space-3xl);
}
/* Padding Right */
.pr-0 {
padding-right: 0;
}
.pr-1 {
padding-right: var(--space-xs);
}
.pr-2 {
padding-right: var(--space-sm);
}
.pr-3 {
padding-right: 0.75rem;
}
.pr-4 {
padding-right: var(--space-md);
}
.pr-5 {
padding-right: 1.25rem;
}
.pr-6 {
padding-right: var(--space-lg);
}
.pr-8 {
padding-right: var(--space-xl);
}
.pr-10 {
padding-right: 2.5rem;
}
.pr-12 {
padding-right: var(--space-3xl);
}
/* Padding Bottom */
.pb-0 {
padding-bottom: 0;
}
.pb-1 {
padding-bottom: var(--space-xs);
}
.pb-2 {
padding-bottom: var(--space-sm);
}
.pb-3 {
padding-bottom: 0.75rem;
}
.pb-4 {
padding-bottom: var(--space-md);
}
.pb-5 {
padding-bottom: 1.25rem;
}
.pb-6 {
padding-bottom: var(--space-lg);
}
.pb-8 {
padding-bottom: var(--space-xl);
}
.pb-10 {
padding-bottom: 2.5rem;
}
.pb-12 {
padding-bottom: var(--space-3xl);
}
/* Padding Left */
.pl-0 {
padding-left: 0;
}
.pl-1 {
padding-left: var(--space-xs);
}
.pl-2 {
padding-left: var(--space-sm);
}
.pl-3 {
padding-left: 0.75rem;
}
.pl-4 {
padding-left: var(--space-md);
}
.pl-5 {
padding-left: 1.25rem;
}
.pl-6 {
padding-left: var(--space-lg);
}
.pl-8 {
padding-left: var(--space-xl);
}
.pl-10 {
padding-left: 2.5rem;
}
.pl-12 {
padding-left: var(--space-3xl);
}
/* Padding X (horizontal) */
.px-0 {
padding-left: 0;
padding-right: 0;
}
.px-1 {
padding-left: var(--space-xs);
padding-right: var(--space-xs);
}
.px-2 {
padding-left: var(--space-sm);
padding-right: var(--space-sm);
}
.px-3 {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.px-4 {
padding-left: var(--space-md);
padding-right: var(--space-md);
}
.px-5 {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.px-6 {
padding-left: var(--space-lg);
padding-right: var(--space-lg);
}
.px-8 {
padding-left: var(--space-xl);
padding-right: var(--space-xl);
}
/* Padding Y (vertical) */
.py-0 {
padding-top: 0;
padding-bottom: 0;
}
.py-1 {
padding-top: var(--space-xs);
padding-bottom: var(--space-xs);
}
.py-2 {
padding-top: var(--space-sm);
padding-bottom: var(--space-sm);
}
.py-3 {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
.py-4 {
padding-top: var(--space-md);
padding-bottom: var(--space-md);
}
.py-5 {
padding-top: 1.25rem;
padding-bottom: 1.25rem;
}
.py-6 {
padding-top: var(--space-lg);
padding-bottom: var(--space-lg);
}
.py-8 {
padding-top: var(--space-xl);
padding-bottom: var(--space-xl);
}
/* Space Between (for flex containers) */
.space-x-1 > * + * {
margin-left: var(--space-xs);
}
.space-x-2 > * + * {
margin-left: var(--space-sm);
}
.space-x-3 > * + * {
margin-left: 0.75rem;
}
.space-x-4 > * + * {
margin-left: var(--space-md);
}
.space-x-6 > * + * {
margin-left: var(--space-lg);
}
.space-x-8 > * + * {
margin-left: var(--space-xl);
}
.space-y-1 > * + * {
margin-top: var(--space-xs);
}
.space-y-2 > * + * {
margin-top: var(--space-sm);
}
.space-y-3 > * + * {
margin-top: 0.75rem;
}
.space-y-4 > * + * {
margin-top: var(--space-md);
}
.space-y-6 > * + * {
margin-top: var(--space-lg);
}
.space-y-8 > * + * {
margin-top: var(--space-xl);
}
/* Mobile Spacing Adjustments */
@media (max-width: 768px) {
.m-4 {
margin: var(--space-sm);
}
.p-4 {
padding: var(--space-sm);
}
.mt-4 {
margin-top: var(--space-sm);
}
.mb-4 {
margin-bottom: var(--space-sm);
}
.pt-4 {
padding-top: var(--space-sm);
}
.pb-4 {
padding-bottom: var(--space-sm);
}
.px-4 {
padding-left: var(--space-sm);
padding-right: var(--space-sm);
}
.py-4 {
padding-top: var(--space-sm);
padding-bottom: var(--space-sm);
}
}
@media (max-width: 480px) {
.m-6 {
margin: var(--space-md);
}
.p-6 {
padding: var(--space-md);
}
.mt-6 {
margin-top: var(--space-md);
}
.mb-6 {
margin-bottom: var(--space-md);
}
.pt-6 {
padding-top: var(--space-md);
}
.pb-6 {
padding-bottom: var(--space-md);
}
.px-6 {
padding-left: var(--space-md);
padding-right: var(--space-md);
}
.py-6 {
padding-top: var(--space-md);
padding-bottom: var(--space-md);
}
}

View File

@@ -0,0 +1,275 @@
/* Text Utilities - ROA2WEB */
/* Text Alignment */
.text-left {
text-align: left;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-justify {
text-align: justify;
}
/* Text Transform */
.uppercase {
text-transform: uppercase;
}
.lowercase {
text-transform: lowercase;
}
.capitalize {
text-transform: capitalize;
}
.normal-case {
text-transform: none;
}
/* Font Weight */
.font-thin {
font-weight: 100;
}
.font-extralight {
font-weight: 200;
}
.font-light {
font-weight: var(--font-light);
}
.font-normal {
font-weight: var(--font-normal);
}
.font-medium {
font-weight: var(--font-medium);
}
.font-semibold {
font-weight: var(--font-semibold);
}
.font-bold {
font-weight: var(--font-bold);
}
.font-extrabold {
font-weight: 800;
}
.font-black {
font-weight: 900;
}
/* Font Size */
.text-xs {
font-size: var(--text-xs);
}
.text-sm {
font-size: var(--text-sm);
}
.text-base {
font-size: var(--text-base);
}
.text-lg {
font-size: var(--text-lg);
}
.text-xl {
font-size: var(--text-xl);
}
.text-2xl {
font-size: var(--text-2xl);
}
.text-3xl {
font-size: var(--text-3xl);
}
.text-4xl {
font-size: var(--text-4xl);
}
/* Line Height */
.leading-none {
line-height: 1;
}
.leading-tight {
line-height: var(--leading-tight);
}
.leading-snug {
line-height: 1.375;
}
.leading-normal {
line-height: var(--leading-normal);
}
.leading-relaxed {
line-height: 1.625;
}
.leading-loose {
line-height: var(--leading-loose);
}
/* Letter Spacing */
.tracking-tighter {
letter-spacing: -0.05em;
}
.tracking-tight {
letter-spacing: -0.025em;
}
.tracking-normal {
letter-spacing: 0em;
}
.tracking-wide {
letter-spacing: 0.025em;
}
.tracking-wider {
letter-spacing: 0.05em;
}
.tracking-widest {
letter-spacing: 0.1em;
}
/* Text Color */
.text-inherit {
color: inherit;
}
.text-current {
color: currentColor;
}
.text-transparent {
color: transparent;
}
.text-primary {
color: var(--color-primary);
}
.text-secondary {
color: var(--color-secondary);
}
.text-success {
color: var(--color-success);
}
.text-warning {
color: var(--color-warning);
}
.text-error {
color: var(--color-error);
}
.text-info {
color: var(--color-info);
}
.text-muted {
color: var(--color-text-muted);
}
/* Text Decoration */
.underline {
text-decoration: underline;
}
.line-through {
text-decoration: line-through;
}
.no-underline {
text-decoration: none;
}
/* Text Overflow */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-ellipsis {
text-overflow: ellipsis;
}
.text-clip {
text-overflow: clip;
}
/* White Space */
.whitespace-normal {
white-space: normal;
}
.whitespace-nowrap {
white-space: nowrap;
}
.whitespace-pre {
white-space: pre;
}
.whitespace-pre-line {
white-space: pre-line;
}
.whitespace-pre-wrap {
white-space: pre-wrap;
}
/* Word Break */
.break-normal {
overflow-wrap: normal;
word-break: normal;
}
.break-words {
overflow-wrap: break-word;
}
.break-all {
word-break: break-all;
}
/* Font Family */
.font-sans {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
}
.font-serif {
font-family: Georgia, Cambria, "Times New Roman", Times, serif;
}
.font-mono {
font-family: var(--font-mono);
}
/* Responsive Text Utilities */
@media (max-width: 480px) {
.mobile-text-xs {
font-size: var(--text-xs);
}
.mobile-text-sm {
font-size: var(--text-sm);
}
.mobile-text-base {
font-size: var(--text-base);
}
.mobile-text-center {
text-align: center;
}
.mobile-text-left {
text-align: left;
}
}
@media (max-width: 768px) {
.tablet-text-xs {
font-size: var(--text-xs);
}
.tablet-text-sm {
font-size: var(--text-sm);
}
.tablet-text-center {
text-align: center;
}
.tablet-text-left {
text-align: left;
}
}
@media (min-width: 1024px) {
.desktop-text-lg {
font-size: var(--text-lg);
}
.desktop-text-xl {
font-size: var(--text-xl);
}
}

View File

@@ -0,0 +1,138 @@
/* PrimeVue Component Overrides - ROA2WEB */
/* Global customization of PrimeVue saga-blue theme */
/* ===== Input Components ===== */
.p-inputtext,
.p-password input,
.p-dropdown,
.p-calendar input,
.p-autocomplete input {
border: 2px solid var(--color-border) !important;
border-radius: var(--radius-md) !important;
padding: var(--space-sm) var(--space-md) !important;
font-size: var(--text-base) !important;
font-family: inherit !important;
color: var(--color-text) !important;
background: var(--color-bg) !important;
transition: all var(--transition-fast) !important;
min-height: 44px !important;
}
/* ===== Focus States ===== */
.p-inputtext:focus,
.p-password input:focus,
.p-dropdown:focus,
.p-calendar input:focus,
.p-autocomplete input:focus {
outline: none !important;
border-color: var(--color-primary) !important;
box-shadow: var(--focus-ring) !important;
}
/* ===== Hover States ===== */
.p-inputtext:hover:not(:disabled),
.p-password input:hover:not(:disabled),
.p-dropdown:hover:not(:disabled) {
border-color: var(--color-border-dark, #d1d5db) !important;
}
/* ===== Disabled States ===== */
.p-inputtext:disabled,
.p-password input:disabled,
.p-dropdown:disabled {
background: var(--color-bg-muted, #f3f4f6) !important;
color: var(--color-text-muted, #9ca3af) !important;
opacity: 0.6 !important;
cursor: not-allowed !important;
}
/* ===== Validation States ===== */
.p-invalid.p-component,
.p-inputtext.p-invalid,
.p-password.p-invalid input {
border-color: var(--color-error, #ef4444) !important;
}
/* ===== Button Overrides ===== */
.p-button {
padding: var(--space-sm) var(--space-md) !important;
font-size: var(--text-sm) !important;
font-weight: var(--font-medium) !important;
border-radius: var(--radius-md) !important;
transition: all var(--transition-fast) !important;
}
.p-button:hover {
transform: translateY(-1px) !important;
box-shadow: var(--shadow-md) !important;
}
/* ===== DataTable ===== */
.p-datatable .p-datatable-thead > tr > th {
background: var(--color-bg-muted, #f9fafb) !important;
color: var(--color-text) !important;
font-weight: var(--font-semibold) !important;
border-bottom: 2px solid var(--color-border) !important;
padding: var(--space-md) var(--space-lg) !important;
}
.p-datatable .p-datatable-tbody > tr {
transition: background-color var(--transition-fast) !important;
}
/* DataTable Striped Rows - Global Pattern */
.p-datatable .p-datatable-tbody > tr:nth-child(odd) {
background-color: #ffffff !important;
}
.p-datatable .p-datatable-tbody > tr:nth-child(even) {
background-color: #f8f9fa !important;
}
.p-datatable .p-datatable-tbody > tr:hover {
background-color: #e3f2fd !important;
cursor: pointer;
}
/* Compact DataTable variant (p-datatable-sm) */
.p-datatable-sm .p-datatable-thead > tr > th {
padding: 0.5rem 0.75rem !important;
font-weight: 600 !important;
white-space: nowrap !important;
}
.p-datatable-sm .p-datatable-tbody > tr > td {
padding: 0.4rem 0.75rem !important;
}
/* DataTable font size for compact tables */
.p-datatable-sm {
font-size: 0.875rem !important;
}
/* ===== Card ===== */
.p-card {
box-shadow: var(--shadow-sm) !important;
border: 1px solid var(--color-border) !important;
border-radius: var(--card-radius, 8px) !important;
}
.p-card .p-card-header {
background: var(--color-bg-secondary) !important;
border-bottom: 1px solid var(--color-border) !important;
padding: var(--space-lg) !important;
}
.p-card .p-card-body {
padding: var(--space-lg) !important;
}
/* ===== Mobile Optimizations ===== */
@media (max-width: 768px) {
.p-inputtext,
.p-password input,
.p-dropdown,
.p-calendar input {
font-size: 16px !important; /* Prevent iOS zoom */
}
}

34
src/config/features.js Normal file
View File

@@ -0,0 +1,34 @@
export const features = {
reports: {
enabled: import.meta.env.VITE_FEATURE_REPORTS !== 'false',
modules: {
dashboard: true,
invoices: true,
bankCash: true,
trialBalance: true,
telegram: true,
cacheStats: true
}
},
dataEntry: {
enabled: import.meta.env.VITE_FEATURE_DATA_ENTRY !== 'false',
modules: {
receipts: true,
ocr: true
}
}
}
export function isFeatureEnabled(module, subModule = null) {
if (!features[module]?.enabled) return false
if (subModule && !features[module]?.modules?.[subModule]) return false
return true
}
export function getEnabledMenuSections(menuSections) {
return menuSections.filter(section => {
if (section.title === 'Rapoarte') return features.reports.enabled
if (section.title === 'Introduceri Date') return features.dataEntry.enabled
return true // System section always visible
})
}

25
src/config/menu.js Normal file
View File

@@ -0,0 +1,25 @@
export const menuSections = [
{
title: 'Rapoarte',
items: [
{ to: '/reports/dashboard', icon: 'pi pi-home', label: 'Dashboard' },
{ to: '/reports/invoices', icon: 'pi pi-file', label: 'Facturi' },
{ to: '/reports/bank-cash', icon: 'pi pi-money-bill', label: 'Casa și Banca' },
{ to: '/reports/trial-balance', icon: 'pi pi-calculator', label: 'Balanță de Verificare' }
]
},
{
title: 'Introduceri Date',
items: [
{ to: '/data-entry', icon: 'pi pi-list', label: 'Lista Bonuri' },
{ to: '/data-entry/create', icon: 'pi pi-plus', label: 'Bon Nou' }
]
},
{
title: 'Sistem',
items: [
{ to: '/reports/telegram', icon: 'pi pi-telegram', label: 'Telegram Bot' },
{ to: '/reports/cache-stats', icon: 'pi pi-chart-bar', label: 'Statistici Cache' }
]
}
]

88
src/main.js Normal file
View File

@@ -0,0 +1,88 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
import App from './App.vue'
import router from './router'
// PrimeVue Components
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Card from 'primevue/card'
import Toast from 'primevue/toast'
import ConfirmDialog from 'primevue/confirmdialog'
import Menu from 'primevue/menu'
import Menubar from 'primevue/menubar'
import Badge from 'primevue/badge'
import Tag from 'primevue/tag'
import Dropdown from 'primevue/dropdown'
import AutoComplete from 'primevue/autocomplete'
import Calendar from 'primevue/calendar'
import ProgressSpinner from 'primevue/progressspinner'
import Dialog from 'primevue/dialog'
import InputNumber from 'primevue/inputnumber'
import Textarea from 'primevue/textarea'
import FileUpload from 'primevue/fileupload'
import Image from 'primevue/image'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import Checkbox from 'primevue/checkbox'
import RadioButton from 'primevue/radiobutton'
import Toolbar from 'primevue/toolbar'
import Divider from 'primevue/divider'
import Message from 'primevue/message'
// PrimeVue CSS (saga-blue theme)
import 'primevue/resources/themes/saga-blue/theme.css'
import 'primevue/resources/primevue.min.css'
import 'primeicons/primeicons.css'
const app = createApp(App)
// Pinia store
app.use(createPinia())
// Router
app.use(router)
// PrimeVue with saga-blue theme
app.use(PrimeVue, { ripple: true })
app.use(ToastService)
app.use(ConfirmationService)
// Register PrimeVue components globally
app.component('Button', Button)
app.component('InputText', InputText)
app.component('Password', Password)
app.component('DataTable', DataTable)
app.component('Column', Column)
app.component('Card', Card)
app.component('Toast', Toast)
app.component('ConfirmDialog', ConfirmDialog)
app.component('Menu', Menu)
app.component('Menubar', Menubar)
app.component('Badge', Badge)
app.component('Tag', Tag)
app.component('Dropdown', Dropdown)
app.component('AutoComplete', AutoComplete)
app.component('Calendar', Calendar)
app.component('ProgressSpinner', ProgressSpinner)
app.component('Dialog', Dialog)
app.component('InputNumber', InputNumber)
app.component('Textarea', Textarea)
app.component('FileUpload', FileUpload)
app.component('Image', Image)
app.component('TabView', TabView)
app.component('TabPanel', TabPanel)
app.component('Checkbox', Checkbox)
app.component('RadioButton', RadioButton)
app.component('Toolbar', Toolbar)
app.component('Divider', Divider)
app.component('Message', Message)
app.mount('#app')

View File

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

View File

@@ -0,0 +1,125 @@
<template>
<span
class="confidence-indicator"
:class="confidenceClass"
:title="tooltipText"
>
<i :class="iconClass"></i>
<span v-if="showPercentage" class="percentage">{{ percentageText }}</span>
</span>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
confidence: {
type: Number,
required: true,
validator: (value) => value >= 0 && value <= 1
},
showPercentage: {
type: Boolean,
default: false
},
size: {
type: String,
default: 'normal',
validator: (value) => ['small', 'normal', 'large'].includes(value)
}
})
const percentageText = computed(() => {
return Math.round(props.confidence * 100) + '%'
})
const confidenceClass = computed(() => {
const classes = [`size-${props.size}`]
if (props.confidence >= 0.85) {
classes.push('high')
} else if (props.confidence >= 0.6) {
classes.push('medium')
} else {
classes.push('low')
}
return classes
})
const iconClass = computed(() => {
if (props.confidence >= 0.85) {
return 'pi pi-check-circle'
} else if (props.confidence >= 0.6) {
return 'pi pi-exclamation-circle'
} else {
return 'pi pi-question-circle'
}
})
const tooltipText = computed(() => {
const percent = Math.round(props.confidence * 100)
if (props.confidence >= 0.85) {
return `Incredere ridicata: ${percent}%`
} else if (props.confidence >= 0.6) {
return `Incredere medie: ${percent}% - verifica valoarea`
} else {
return `Incredere scazuta: ${percent}% - completeaza manual`
}
})
</script>
<style scoped>
.confidence-indicator {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
/* Sizes */
.size-small {
font-size: 0.7rem;
padding: 0.1rem 0.35rem;
}
.size-small i {
font-size: 0.75rem;
}
.size-normal i {
font-size: 0.85rem;
}
.size-large {
font-size: 0.85rem;
padding: 0.2rem 0.6rem;
}
.size-large i {
font-size: 1rem;
}
/* Confidence levels */
.high {
background: #dcfce7;
color: #166534;
}
.medium {
background: #fef9c3;
color: #854d0e;
}
.low {
background: #fee2e2;
color: #991b1b;
}
.percentage {
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -0,0 +1,699 @@
<template>
<div class="ocr-preview">
<div class="preview-header">
<div class="header-left">
<i class="pi pi-check-circle" style="color: #22c55e; font-size: 1.25rem;"></i>
<span class="title">Date extrase din imagine</span>
</div>
<div class="header-right">
<span class="overall-confidence">
<OCRConfidenceIndicator
:confidence="data.overall_confidence"
:show-percentage="true"
size="normal"
/>
</span>
<Button
icon="pi pi-minus"
text
rounded
size="small"
@click="$emit('collapse')"
v-tooltip="'Minimizeaza'"
class="collapse-btn"
/>
</div>
</div>
<div class="preview-content">
<!-- SECTION: FURNIZOR -->
<div class="ocr-section" v-if="data.partner_name || data.cui || data.address">
<div class="ocr-section-title">FURNIZOR</div>
<div class="ocr-section-content">
<div class="vendor-name" v-if="data.partner_name">
{{ data.partner_name }}
<OCRConfidenceIndicator :confidence="data.confidence_vendor" size="small" />
</div>
<div class="vendor-cui" v-if="data.cui">CUI: {{ data.cui }}</div>
<div class="vendor-address" v-if="data.address">{{ data.address }}</div>
</div>
</div>
<!-- SECTION: CLIENT (always visible) -->
<div class="ocr-section">
<div class="ocr-section-title">CLIENT</div>
<div class="ocr-section-content">
<template v-if="data.client_name || data.client_cui || data.client_address">
<div class="client-name" v-if="data.client_name">
{{ data.client_name }}
<OCRConfidenceIndicator v-if="data.confidence_client" :confidence="data.confidence_client" size="small" />
</div>
<div class="client-cui" v-if="data.client_cui">CUI: {{ data.client_cui }}</div>
<div class="client-address" v-if="data.client_address">{{ data.client_address }}</div>
</template>
<div v-else class="no-data">-</div>
</div>
</div>
<!-- SECTION: DOCUMENT -->
<div class="ocr-section" v-if="data.receipt_type || data.receipt_number || data.receipt_date">
<div class="ocr-section-title">DOCUMENT</div>
<div class="ocr-section-content">
<div class="document-row">
<Tag
v-if="data.receipt_type"
:value="data.receipt_type === 'bon_fiscal' ? 'Bon Fiscal' : 'Chitanta'"
:severity="data.receipt_type === 'bon_fiscal' ? 'info' : 'success'"
/>
<span v-if="data.receipt_number" class="doc-number">
Nr: {{ data.receipt_series ? data.receipt_series + ' ' : '' }}{{ data.receipt_number }}
</span>
<span v-if="data.receipt_date" class="doc-date">
<i class="pi pi-calendar"></i>
{{ formatDate(data.receipt_date) }}
<OCRConfidenceIndicator :confidence="data.confidence_date" size="small" />
</span>
</div>
</div>
</div>
<!-- SECTION: TOTAL + PLATA + TVA (unified flat layout) -->
<div class="ocr-section" v-if="data.amount || data.tva_entries?.length > 0 || paymentSum > 0">
<div class="ocr-values-table">
<!-- TOTAL row - when extracted -->
<div class="value-row" v-if="data.amount">
<span class="value-label">TOTAL</span>
<span class="value-amount">
{{ formatAmount(data.amount) }} LEI
<OCRConfidenceIndicator :confidence="data.confidence_amount" size="small" class="confidence-inline" />
</span>
</div>
<!-- TOTAL row - warning when not found but can be calculated from payments -->
<div class="value-row warning-row" v-else-if="paymentSum > 0">
<span class="value-label">
<i class="pi pi-exclamation-triangle warning-icon"></i>
TOTAL (calculat)
</span>
<span class="value-amount calculated">
{{ formatAmount(paymentSum) }} LEI
<span class="hint">(din plati)</span>
</span>
</div>
<!-- Validation warning: total payment sum -->
<div class="validation-warning" v-if="totalMismatchPayment">
<i class="pi pi-exclamation-triangle"></i>
Total ({{ formatAmount(data.amount) }}) Suma plati ({{ formatAmount(paymentSum) }})
</div>
<!-- Validation info: TVA-implied total mismatch -->
<div class="validation-info" v-if="totalMismatchTva">
<i class="pi pi-info-circle"></i>
Total din TVA: {{ formatAmount(tvaImpliedTotal) }} LEI
</div>
<!-- Payment methods as plain text rows -->
<div class="value-row" v-for="(pm, idx) in data.payment_methods" :key="'pm-'+idx">
<span class="value-label">{{ pm.method }}</span>
<span class="value-amount">{{ formatAmount(pm.amount) }} LEI</span>
</div>
<!-- TVA entries -->
<div class="value-row" v-for="(entry, idx) in data.tva_entries" :key="'tva-'+idx">
<span class="value-label">TVA {{ entry.code }} ({{ entry.percent }}%)</span>
<span class="value-amount">{{ formatAmount(entry.amount) }} LEI</span>
</div>
<!-- TVA Total -->
<div class="value-row total-row" v-if="computedTvaTotal > 0">
<span class="value-label">Total TVA</span>
<span class="value-amount">{{ formatAmount(computedTvaTotal) }} LEI</span>
</div>
<!-- Items count -->
<div v-if="data.items_count" class="items-count-inline">
{{ data.items_count }} articole
</div>
</div>
</div>
<!-- Raw Text Toggle -->
<div class="raw-text-section" v-if="data.raw_text">
<div class="raw-text-header">
<Button
:label="showRawText ? 'Ascunde text OCR' : 'Arata text OCR'"
:icon="showRawText ? 'pi pi-eye-slash' : 'pi pi-eye'"
severity="secondary"
size="small"
text
@click="showRawText = !showRawText"
/>
<span v-if="data.ocr_engine" class="ocr-engine-badge" :class="getEngineClass(data.ocr_engine)">
<i :class="getEngineIcon(data.ocr_engine)"></i>
{{ getEngineLabel(data.ocr_engine) }}
</span>
<span v-if="data._ocr_message" class="ocr-message-badge" :class="getMessageClass(data._ocr_message)">
{{ data._ocr_message }}
</span>
<span v-if="data.processing_time_ms" class="ocr-time-badge">
<i class="pi pi-clock"></i>
{{ formatProcessingTime(data.processing_time_ms) }}
</span>
</div>
<div v-if="showRawText" class="raw-text">
<pre>{{ data.raw_text }}</pre>
</div>
</div>
</div>
<div class="preview-actions">
<Button
label="Ignora"
icon="pi pi-times"
severity="secondary"
@click="$emit('dismiss')"
/>
<Button
label="Aplica datele in formular"
icon="pi pi-check"
@click="$emit('apply', data)"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import OCRConfidenceIndicator from './OCRConfidenceIndicator.vue'
import Tag from 'primevue/tag'
import Button from 'primevue/button'
const props = defineProps({
data: {
type: Object,
required: true
}
})
defineEmits(['apply', 'dismiss', 'collapse'])
const showRawText = ref(false)
// Computed TVA total from entries
const computedTvaTotal = computed(() => {
if (props.data.tva_total) return parseFloat(props.data.tva_total)
if (!props.data.tva_entries?.length) return 0
return props.data.tva_entries.reduce((sum, e) => sum + parseFloat(e.amount || 0), 0)
})
// Cross-validation computed properties
const paymentSum = computed(() => {
if (!props.data.payment_methods?.length) return 0
return props.data.payment_methods.reduce((sum, pm) => sum + parseFloat(pm.amount || 0), 0)
})
const tvaImpliedTotal = computed(() => {
// Calculate total from TVA: total = tva * (100 + rate) / rate
if (!props.data.tva_entries?.length) return 0
const mainEntry = props.data.tva_entries[0]
const rate = mainEntry.percent || 19
const tvaAmount = parseFloat(mainEntry.amount || 0)
if (tvaAmount === 0 || rate === 0) return 0
return tvaAmount * (100 + rate) / rate
})
const totalMismatchPayment = computed(() => {
if (!props.data.amount || paymentSum.value === 0) return false
const total = parseFloat(props.data.amount)
return Math.abs(total - paymentSum.value) > 0.02 // Tolerance 2 bani
})
const totalMismatchTva = computed(() => {
if (!props.data.amount || tvaImpliedTotal.value === 0) return false
const total = parseFloat(props.data.amount)
return Math.abs(total - tvaImpliedTotal.value) > 0.50 // Tolerance 50 bani (TVA calc has rounding)
})
const getSuggestedPaymentLabel = (mode) => {
const labels = {
'casa': 'Casa (numerar firma)',
'banca': 'Banca (virament/POS)',
'avans_decontare': 'Avans Decontare'
}
return labels[mode] || mode
}
const formatAmount = (amount) => {
const num = parseFloat(amount)
return num.toLocaleString('ro-RO', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('ro-RO', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
const getEngineClass = (engine) => {
if (!engine) return ''
if (engine === 'paddle-light') return 'fast'
if (engine === 'paddle-adaptive') return 'adaptive'
if (engine === 'adaptive-full') return 'full'
if (engine.includes('paddle')) return 'paddleocr'
if (engine.includes('tesseract')) return 'tesseract'
return ''
}
const getEngineIcon = (engine) => {
if (!engine) return 'pi pi-cog'
if (engine === 'paddle-light') return 'pi pi-bolt' // Fast/lightning
if (engine === 'adaptive-full') return 'pi pi-cog' // Full pipeline
return 'pi pi-cog'
}
const getEngineLabel = (engine) => {
if (!engine) return ''
if (engine === 'paddle-light') return 'Fast Mode (PaddleOCR)'
if (engine === 'paddle-adaptive') return 'Adaptive (Paddle dual)'
if (engine === 'adaptive-full') return 'Full Pipeline'
if (engine.includes('paddle')) return 'PaddleOCR'
if (engine.includes('tesseract')) return 'Tesseract'
return engine
}
const getMessageClass = (message) => {
if (!message) return ''
if (message.includes('fast mode')) return 'fast-mode'
if (message.includes('full pipeline')) return 'full-pipeline'
return ''
}
const formatProcessingTime = (ms) => {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
</script>
<style scoped>
.ocr-preview {
background: #f0fdf4;
border: 1px solid #86efac;
border-radius: 12px;
margin: 1rem 0;
overflow: hidden;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #dcfce7;
border-bottom: 1px solid #86efac;
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.title {
font-weight: 600;
color: #166534;
}
.header-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.overall-confidence {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #166534;
}
.collapse-btn {
color: #166534 !important;
}
.preview-content {
padding: 0.75rem 1rem;
}
/* Section-based layout (bon fiscal style) - clearer separation */
.ocr-section {
padding: 0.75rem;
margin-bottom: 0.5rem;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
border: 1px solid #bbf7d0;
}
.ocr-section:last-of-type {
margin-bottom: 0;
}
.ocr-section-title {
font-size: 0.7rem;
font-weight: 700;
color: #166534;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.5rem;
padding-bottom: 0.35rem;
border-bottom: 1px dashed #86efac;
}
.ocr-section-content {
color: #1e293b;
}
/* FURNIZOR section */
.vendor-name {
font-weight: 600;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.vendor-cui {
font-size: 0.85rem;
color: #475569;
margin-top: 0.15rem;
}
.vendor-address {
font-size: 0.8rem;
color: #64748b;
margin-top: 0.15rem;
}
/* CLIENT section */
.client-name {
font-weight: 600;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.client-cui {
font-size: 0.85rem;
color: #475569;
margin-top: 0.15rem;
}
.client-address {
font-size: 0.8rem;
color: #64748b;
margin-top: 0.15rem;
}
/* Placeholder when no data extracted */
.no-data {
color: #9ca3af;
font-style: italic;
}
/* DOCUMENT section */
.document-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.doc-number {
font-weight: 500;
color: #334155;
}
.doc-date {
display: flex;
align-items: center;
gap: 0.35rem;
color: #475569;
font-size: 0.9rem;
}
.doc-date .pi-calendar {
font-size: 0.85rem;
color: #64748b;
}
/* Unified values table (TOTAL + Payment + TVA) */
.ocr-values-table {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.value-row {
display: flex;
align-items: center;
font-size: 0.9rem;
padding: 0.25rem 0;
}
.value-label {
font-weight: 500;
color: #374151;
}
.value-amount {
font-weight: 600;
margin-left: auto;
color: #1f2937;
display: flex;
align-items: center;
gap: 0.5rem;
}
.value-row.total-row {
margin-top: 0.35rem;
padding-top: 0.5rem;
border-top: 1px dashed #86efac;
}
/* Items count - subtle, at bottom of values section */
.items-count-inline {
font-size: 0.8rem;
color: #64748b;
text-align: right;
padding-top: 0.35rem;
}
/* Confidence indicator inline */
.confidence-inline {
margin-left: 0.25rem;
}
/* Warning row for calculated total */
.value-row.warning-row {
background: #fef3c7;
border-radius: 6px;
padding: 0.5rem 0.75rem;
margin: 0.25rem 0;
}
.value-row.warning-row .value-label {
display: flex;
align-items: center;
gap: 0.5rem;
color: #92400e;
}
.warning-icon {
color: #f59e0b;
font-size: 0.9rem;
}
.value-amount.calculated {
color: #92400e;
}
.value-amount .hint {
font-size: 0.75rem;
font-weight: 400;
color: #a3a3a3;
}
/* Validation warnings */
.validation-warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
margin: 0.25rem 0;
background: #fee2e2;
border-radius: 6px;
font-size: 0.8rem;
color: #991b1b;
}
.validation-warning i {
color: #dc2626;
font-size: 0.9rem;
}
/* Validation info */
.validation-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
margin: 0.25rem 0;
background: #dbeafe;
border-radius: 6px;
font-size: 0.8rem;
color: #1e40af;
}
.validation-info i {
color: #2563eb;
font-size: 0.9rem;
}
.raw-text-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px dashed #86efac;
}
.raw-text-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.ocr-engine-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.ocr-engine-badge.paddleocr {
background: #dbeafe;
color: #1e40af;
}
.ocr-engine-badge.tesseract {
background: #fef3c7;
color: #92400e;
}
.ocr-engine-badge.fast {
background: #dcfce7;
color: #166534;
}
.ocr-engine-badge.adaptive {
background: #dbeafe;
color: #1e40af;
}
.ocr-engine-badge.full {
background: #fef3c7;
color: #92400e;
}
.ocr-message-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
background: #f1f5f9;
color: #475569;
}
.ocr-message-badge.fast-mode {
background: #dcfce7;
color: #166534;
}
.ocr-message-badge.full-pipeline {
background: #fef3c7;
color: #92400e;
}
.ocr-time-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
background: #e0e7ff;
color: #3730a3;
}
.raw-text {
margin-top: 0.5rem;
padding: 0.75rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
max-height: 200px;
overflow: auto;
}
.raw-text pre {
margin: 0;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
color: #475569;
}
.preview-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
}
@media (max-width: 640px) {
.preview-header {
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.preview-grid {
grid-template-columns: 1fr;
}
.preview-actions {
flex-direction: column;
}
.preview-actions :deep(.p-button) {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,281 @@
<template>
<div class="ocr-upload-zone">
<div
class="upload-dropzone"
:class="{ 'dragging': isDragging, 'processing': processing }"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
@click="triggerFileInput"
>
<input
ref="fileInput"
type="file"
accept="image/*,application/pdf"
class="hidden-input"
@change="onFileSelected"
/>
<div v-if="processing" class="processing-state">
<ProgressSpinner
style="width: 50px; height: 50px"
strokeWidth="4"
/>
<p class="processing-text">Se proceseaza imaginea...</p>
<p class="processing-subtext">Acest proces poate dura cateva secunde</p>
</div>
<div v-else-if="selectedFile" class="file-selected-state">
<i class="pi pi-check-circle" style="font-size: 1.75rem; color: #22c55e;"></i>
<p class="file-name">{{ selectedFile.name }}</p>
<p class="file-size">{{ formatFileSize(selectedFile.size) }}</p>
<div class="file-actions">
<Button
label="Schimba"
icon="pi pi-refresh"
severity="secondary"
size="small"
@click.stop="triggerFileInput"
/>
<Button
label="Proceseaza OCR"
icon="pi pi-cog"
size="small"
@click.stop="processOCR"
/>
</div>
</div>
<div v-else class="empty-state">
<i class="pi pi-camera" style="font-size: 2rem; color: #667eea;"></i>
<p class="main-text">
<span v-if="isDragging">Elibereaza pentru a incarca</span>
<span v-else>Trage poza bonului aici sau click pentru a selecta</span>
</p>
<p class="sub-text">
JPG, PNG, PDF (max 10MB) &bull; OCR extrage automat datele
</p>
</div>
</div>
<!-- OCR Error Message -->
<Message v-if="error" severity="error" :closable="true" @close="error = null">
{{ error }}
</Message>
</div>
</template>
<script setup>
import { ref } from 'vue'
import api from '@data-entry/services/api'
const emit = defineEmits(['ocr-result', 'file-selected', 'error'])
const fileInput = ref(null)
const selectedFile = ref(null)
const isDragging = ref(false)
const processing = ref(false)
const error = ref(null)
const onDragOver = () => {
isDragging.value = true
}
const onDragLeave = () => {
isDragging.value = false
}
const onDrop = (event) => {
isDragging.value = false
const files = event.dataTransfer?.files
if (files?.length > 0) {
handleFile(files[0])
}
}
const triggerFileInput = () => {
fileInput.value?.click()
}
const onFileSelected = (event) => {
const files = event.target?.files
if (files?.length > 0) {
handleFile(files[0])
}
}
const handleFile = (file) => {
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
if (!allowedTypes.includes(file.type)) {
error.value = 'Tip de fisier invalid. Sunt acceptate doar: JPG, PNG, PDF'
return
}
// Validate file size (10MB)
if (file.size > 10 * 1024 * 1024) {
error.value = 'Fisierul este prea mare. Dimensiunea maxima este 10MB.'
return
}
error.value = null
selectedFile.value = file
emit('file-selected', file)
}
const processOCR = async () => {
if (!selectedFile.value) return
processing.value = true
error.value = null
try {
const formData = new FormData()
formData.append('file', selectedFile.value)
const response = await api.post('/ocr/extract', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000, // 60 second timeout for OCR
})
if (response.data.success) {
// Include the OCR message in the data for debugging
const resultData = {
...response.data.data,
_ocr_message: response.data.message
}
emit('ocr-result', resultData)
} else {
error.value = response.data.message || 'OCR processing failed'
emit('error', error.value)
}
} catch (err) {
const message = err.response?.data?.detail || err.message || 'Eroare la procesarea OCR'
error.value = message
emit('error', message)
} finally {
processing.value = false
}
}
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const reset = () => {
selectedFile.value = null
error.value = null
if (fileInput.value) {
fileInput.value.value = ''
}
}
// Expose methods for parent components
defineExpose({ reset, processOCR })
</script>
<style scoped>
.ocr-upload-zone {
margin-bottom: 1rem;
}
.upload-dropzone {
border: 2px dashed #cbd5e1;
border-radius: 10px;
padding: 1rem 1.25rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: #f8fafc;
}
.upload-dropzone:hover {
border-color: #667eea;
background: #f1f5f9;
}
.upload-dropzone.dragging {
border-color: #667eea;
background: #eef2ff;
transform: scale(1.02);
}
.upload-dropzone.processing {
cursor: default;
background: #fefefe;
}
.hidden-input {
display: none;
}
/* Empty state - compact */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.main-text {
font-size: 0.9rem;
color: #475569;
margin: 0.25rem 0;
}
.sub-text {
font-size: 0.8rem;
color: #94a3b8;
margin: 0;
}
/* File selected state - compact */
.file-selected-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.file-name {
font-weight: 600;
font-size: 0.9rem;
color: #1e293b;
margin: 0.25rem 0 0 0;
word-break: break-all;
}
.file-size {
font-size: 0.8rem;
color: #64748b;
margin: 0;
}
.file-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
/* Processing state */
.processing-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.processing-text {
font-size: 1rem;
color: #475569;
margin: 0.5rem 0 0 0;
}
.processing-subtext {
font-size: 0.85rem;
color: #94a3b8;
margin: 0;
}
</style>

View File

@@ -0,0 +1,40 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api/data-entry',
headers: { 'Content-Type': 'application/json' }
})
// Request interceptor for auth token and company header
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// Add selected company header if available
const user = JSON.parse(localStorage.getItem('user') || '{}')
const selectedCompanyId = localStorage.getItem('selectedCompanyId') || user.companies?.[0]?.id
if (selectedCompanyId) {
config.headers['X-Selected-Company'] = selectedCompanyId
}
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,445 @@
import { defineStore } from 'pinia'
import apiClient from '@data-entry/services/api'
// Create receipts-specific API wrapper
const api = {
get: (url, config) => apiClient.get(`/receipts${url}`, config),
post: (url, data, config) => apiClient.post(`/receipts${url}`, data, config),
put: (url, data, config) => apiClient.put(`/receipts${url}`, data, config),
delete: (url, config) => apiClient.delete(`/receipts${url}`, config),
}
export const useReceiptsStore = defineStore('receipts', {
state: () => ({
receipts: [],
currentReceipt: null,
pendingReceipts: [],
stats: null,
loading: false,
error: null,
pagination: {
page: 1,
pageSize: 20,
total: 0,
pages: 1,
},
filters: {
status: null,
search: '',
direction: null,
dateFrom: null,
dateTo: null,
},
// Nomenclatures
partners: [],
accounts: [],
cashRegisters: [],
expenseTypes: [],
}),
getters: {
hasReceipts: (state) => state.receipts.length > 0,
hasPendingReceipts: (state) => state.pendingReceipts.length > 0,
pendingCount: (state) => state.pendingReceipts.length,
},
actions: {
// ============ Receipts CRUD ============
async fetchReceipts() {
this.loading = true
this.error = null
try {
const params = {
page: this.pagination.page,
page_size: this.pagination.pageSize,
}
if (this.filters.status) {
params.status = this.filters.status
}
if (this.filters.search) {
params.search = this.filters.search
}
if (this.filters.direction) {
params.direction = this.filters.direction
}
if (this.filters.dateFrom) {
params.date_from = this.filters.dateFrom
}
if (this.filters.dateTo) {
params.date_to = this.filters.dateTo
}
const response = await api.get('/', { params })
this.receipts = response.data.items
this.pagination.total = response.data.total
this.pagination.pages = response.data.pages
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to fetch receipts'
throw error
} finally {
this.loading = false
}
},
async fetchReceiptById(id) {
this.loading = true
this.error = null
try {
const response = await api.get(`/${id}`)
this.currentReceipt = response.data
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to fetch receipt'
throw error
} finally {
this.loading = false
}
},
async createReceipt(data) {
this.loading = true
this.error = null
try {
const response = await api.post('/', data)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to create receipt'
throw error
} finally {
this.loading = false
}
},
async updateReceipt(id, data) {
this.loading = true
this.error = null
try {
const response = await api.put(`/${id}`, data)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to update receipt'
throw error
} finally {
this.loading = false
}
},
async deleteReceipt(id) {
this.loading = true
this.error = null
try {
await api.delete(`/${id}`)
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to delete receipt'
throw error
} finally {
this.loading = false
}
},
// ============ Workflow Actions ============
async submitReceipt(id) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/submit`)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to submit receipt'
throw error
} finally {
this.loading = false
}
},
async approveReceipt(id) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/approve`)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to approve receipt'
throw error
} finally {
this.loading = false
}
},
async rejectReceipt(id, reason) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/reject`, { reason })
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to reject receipt'
throw error
} finally {
this.loading = false
}
},
async resubmitReceipt(id) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/resubmit`)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to resubmit receipt'
throw error
} finally {
this.loading = false
}
},
async unapproveReceipt(id) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/unapprove`)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to unapprove receipt'
throw error
} finally {
this.loading = false
}
},
// ============ Pending Receipts ============
async fetchPendingReceipts() {
this.loading = true
this.error = null
try {
const response = await api.get('/pending')
this.pendingReceipts = response.data
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to fetch pending receipts'
throw error
} finally {
this.loading = false
}
},
// ============ Attachments ============
async uploadAttachment(receiptId, file) {
const formData = new FormData()
formData.append('file', file)
try {
const response = await api.post(`/${receiptId}/attachments`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to upload attachment')
}
},
async deleteAttachment(attachmentId) {
try {
await api.delete(`/attachments/${attachmentId}`)
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to delete attachment')
}
},
getAttachmentUrl(attachmentId) {
return `/api/receipts/attachments/${attachmentId}/download`
},
async fetchAttachmentBlob(attachmentId) {
try {
const response = await api.get(`/receipts/attachments/${attachmentId}/download`, {
responseType: 'blob',
})
return URL.createObjectURL(response.data)
} catch (error) {
console.error('Failed to fetch attachment:', error)
return null
}
},
async downloadAttachment(attachmentId, filename) {
try {
const response = await api.get(`/receipts/attachments/${attachmentId}/download`, {
responseType: 'blob',
})
// Create download link
const url = URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = filename || 'attachment'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
return true
} catch (error) {
console.error('Failed to download attachment:', error)
throw new Error(error.response?.data?.detail || 'Failed to download attachment')
}
},
// ============ Accounting Entries ============
async fetchEntries(receiptId) {
try {
const response = await api.get(`/${receiptId}/entries`)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to fetch entries')
}
},
async updateEntries(receiptId, entries) {
try {
const response = await api.put(`/${receiptId}/entries`, { entries })
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to update entries')
}
},
async regenerateEntries(receiptId) {
try {
const response = await api.post(`/${receiptId}/entries/regenerate`)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to regenerate entries')
}
},
// ============ Nomenclatures ============
async fetchPartners(search = '') {
try {
const response = await api.get('/nomenclature/partners', {
params: { search },
})
this.partners = response.data
return response.data
} catch (error) {
console.error('Failed to fetch partners:', error)
return []
}
},
async fetchAccounts(prefix = '') {
try {
const response = await api.get('/nomenclature/accounts', {
params: { prefix },
})
this.accounts = response.data
return response.data
} catch (error) {
console.error('Failed to fetch accounts:', error)
return []
}
},
async fetchCashRegisters() {
try {
const response = await api.get('/nomenclature/cash-registers')
this.cashRegisters = response.data
return response.data
} catch (error) {
console.error('Failed to fetch cash registers:', error)
return []
}
},
async fetchExpenseTypes() {
try {
const response = await api.get('/nomenclature/expense-types')
this.expenseTypes = response.data
return response.data
} catch (error) {
console.error('Failed to fetch expense types:', error)
return []
}
},
async fetchAllNomenclatures() {
await Promise.all([
this.fetchPartners(),
this.fetchCashRegisters(),
this.fetchExpenseTypes(),
])
},
async searchSupplier(fiscalCode) {
try {
const response = await api.get('/nomenclature/suppliers/search', {
params: { fiscal_code: fiscalCode },
})
return response.data
} catch (error) {
console.error('Supplier search failed:', error)
return { found: false, source: 'error' }
}
},
async createLocalSupplier(data) {
try {
const response = await api.post('/nomenclature/suppliers/local', data)
// Add to local partners list
this.partners.push({
id: response.data.id,
name: response.data.name,
code: response.data.fiscal_code,
})
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to create supplier')
}
},
// ============ Stats ============
async fetchStats() {
try {
const response = await api.get('/stats')
this.stats = response.data
return response.data
} catch (error) {
console.error('Failed to fetch stats:', error)
return null
}
},
// ============ Filters & Pagination ============
setFilters(filters) {
this.filters = { ...this.filters, ...filters }
this.pagination.page = 1
},
clearFilters() {
this.filters = {
status: null,
search: '',
direction: null,
dateFrom: null,
dateTo: null,
}
this.pagination.page = 1
},
setPage(page) {
this.pagination.page = page
},
clearCurrentReceipt() {
this.currentReceipt = null
},
},
})

View File

@@ -0,0 +1,20 @@
/**
* Data Entry Module - Shared Store Instances
*
* This file instantiates the shared stores (auth, companies, accountingPeriod)
* with the Data Entry 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 '@data-entry/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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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>

118
src/router/index.js Normal file
View File

@@ -0,0 +1,118 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/LoginWrapper.vue'),
meta: { requiresAuth: false, title: 'Autentificare - ROA2WEB' }
},
{
path: '/reports',
component: () => import('@/modules/reports/ReportsLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@reports/views/DashboardView.vue'),
meta: { requiresAuth: true, title: 'Dashboard - ROA2WEB' }
},
{
path: 'invoices',
name: 'Invoices',
component: () => import('@reports/views/InvoicesView.vue'),
meta: { requiresAuth: true, title: 'Facturi - ROA2WEB' }
},
{
path: 'bank-cash',
name: 'BankCash',
component: () => import('@reports/views/BankCashRegisterView.vue'),
meta: { requiresAuth: true, title: 'Casa și Banca - ROA2WEB' }
},
{
path: 'trial-balance',
name: 'TrialBalance',
component: () => import('@reports/views/TrialBalanceView.vue'),
meta: { requiresAuth: true, title: 'Balanță de Verificare - ROA2WEB' }
},
{
path: 'telegram',
name: 'Telegram',
component: () => import('@reports/views/TelegramView.vue'),
meta: { requiresAuth: true, title: 'Telegram Bot - ROA2WEB' }
},
{
path: 'cache-stats',
name: 'CacheStats',
component: () => import('@reports/views/CacheStatsView.vue'),
meta: { requiresAuth: true, title: 'Statistici Cache - ROA2WEB' }
}
]
},
{
path: '/data-entry',
component: () => import('@/modules/data-entry/DataEntryLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'ReceiptsList',
component: () => import('@data-entry/views/receipts/ReceiptsListView.vue'),
meta: { requiresAuth: true, title: 'Lista Bonuri - ROA2WEB' }
},
{
path: 'create',
name: 'ReceiptCreate',
component: () => import('@data-entry/views/receipts/ReceiptCreateView.vue'),
meta: { requiresAuth: true, title: 'Bon Nou - ROA2WEB' }
},
{
path: ':id',
name: 'ReceiptDetail',
component: () => import('@data-entry/views/receipts/ReceiptCreateView.vue'),
meta: { requiresAuth: true, title: 'Detalii Bon - ROA2WEB' }
},
{
path: ':id/edit',
name: 'ReceiptEdit',
component: () => import('@data-entry/views/receipts/ReceiptCreateView.vue'),
meta: { requiresAuth: true, title: 'Editare Bon - ROA2WEB' }
}
]
},
{
path: '/',
redirect: '/reports/dashboard'
},
{
path: '/:pathMatch(.*)*',
redirect: '/reports/dashboard'
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
// Navigation guard for authentication
router.beforeEach((to, from, next) => {
const isAuthenticated = !!localStorage.getItem('access_token')
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login')
} else if (to.path === '/login' && isAuthenticated) {
next('/reports/dashboard')
} else {
next()
}
})
// Set page title after navigation
router.afterEach((to) => {
document.title = to.meta.title || 'ROA2WEB'
window.scrollTo(0, 0)
})
export default router

View File

@@ -0,0 +1,577 @@
<template>
<div :class="selectorClass" ref="dropdownContainer">
<div class="company-dropdown" ref="dropdown">
<button
class="company-trigger"
@click="toggleDropdown"
:aria-expanded="dropdownOpen"
aria-label="Selectare firma"
title="Alt+Q pentru selectare rapida"
>
<div class="company-info">
<span class="company-name">{{ selectedCompanyName }}</span>
<span v-if="showFiscalCode" 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="Cauta firma..."
class="search-input"
@keydown="handleKeyDown"
/>
</div>
</div>
<div class="company-list">
<div
v-for="(company, index) in filteredCompanies"
:key="company.id_firma"
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 v-if="showFiscalCode" class="company-sub-info">
<span class="company-cui">CUI: {{ company.fiscal_code || '-' }}</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>Nu s-au gasit firme</span>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
export default {
name: "CompanySelector",
props: {
// The companies store instance
companiesStore: {
type: Object,
required: true,
},
// Optional v-model binding
modelValue: {
type: Object,
default: null,
},
// Show fiscal code in display
showFiscalCode: {
type: Boolean,
default: true,
},
// Variant: 'default' (white background) or 'header' (transparent for dark headers)
variant: {
type: String,
default: "default",
validator: (value) => ['default', 'header'].includes(value),
},
},
emits: ["update:modelValue", "company-changed"],
setup(props, { emit }) {
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 || props.companiesStore.selectedCompany,
set: (value) => {
emit("update:modelValue", value);
props.companiesStore.setSelectedCompany(value);
},
});
const selectedCompanyName = computed(() => {
return selectedCompany.value?.name || "Selectare firma";
});
const selectedCompanyCode = computed(() => {
return selectedCompany.value?.fiscal_code
? `CUI: ${selectedCompany.value.fiscal_code}`
: "";
});
const selectorClass = computed(() => ({
'company-selector': true,
'company-selector--header': props.variant === 'header'
}));
const filteredCompanies = computed(() => {
const companies = props.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;
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 () => {
if (dropdownContainer.value) {
dropdownContainer.value.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
await new Promise((resolve) => setTimeout(resolve, 300));
if (!dropdownOpen.value) {
dropdownOpen.value = true;
highlightedIndex.value = -1;
searchQuery.value = "";
await nextTick();
searchInput.value?.focus();
} else {
searchInput.value?.focus();
}
};
const handleGlobalKeyDown = (event) => {
if (event.altKey && event.key === "q") {
event.preventDefault();
openWithShortcut();
}
};
const handleClickOutside = (event) => {
if (dropdown.value && !dropdown.value.contains(event.target)) {
closeDropdown();
}
};
watch(searchQuery, () => {
highlightedIndex.value = -1;
});
onMounted(() => {
document.addEventListener("click", handleClickOutside);
document.addEventListener("keydown", handleGlobalKeyDown);
// Load companies if not already loaded
if (props.companiesStore.companies.length === 0) {
props.companiesStore.loadCompanies();
}
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
document.removeEventListener("keydown", handleGlobalKeyDown);
});
return {
dropdown,
dropdownContainer,
searchInput,
dropdownOpen,
searchQuery,
highlightedIndex,
selectedCompany,
selectedCompanyName,
selectedCompanyCode,
selectorClass,
filteredCompanies,
toggleDropdown,
closeDropdown,
selectCompany,
handleKeyDown,
isHighlighted,
};
},
};
</script>
<style scoped>
.company-selector {
position: relative;
max-width: 450px;
}
.company-dropdown {
position: relative;
}
.company-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 6px);
cursor: pointer;
transition: all 0.15s ease;
width: 100%;
text-align: left;
min-width: 300px;
}
.company-trigger:hover {
border-color: var(--color-primary, #2563eb);
background: var(--color-bg-secondary, #f9fafb);
}
.company-info {
flex: 1;
min-width: 0;
}
.company-name {
display: block;
font-size: var(--text-sm, 14px);
font-weight: 500;
color: var(--color-text, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.company-code {
display: block;
font-size: var(--text-xs, 12px);
color: var(--color-text-secondary, #6b7280);
margin-top: 2px;
}
.pi-chevron-down {
transition: transform 0.15s ease;
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-xs, 12px);
}
.rotate-180 {
transform: rotate(180deg);
}
.company-dropdown-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 6px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
z-index: 1000;
max-height: 300px;
overflow: hidden;
opacity: 0;
transform: translateY(-10px);
transition: all 0.15s ease;
}
.panel-open {
opacity: 1;
transform: translateY(0);
}
.dropdown-search {
padding: var(--space-sm, 8px);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.search-wrapper {
position: relative;
}
.search-icon {
position: absolute;
left: var(--space-sm, 8px);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-sm, 14px);
pointer-events: none;
}
.search-input {
width: 100%;
padding: var(--space-sm, 8px) var(--space-sm, 8px) var(--space-sm, 8px) var(--space-xl, 32px);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-sm, 4px);
font-size: var(--text-sm, 14px);
background: var(--color-bg, #fff);
color: var(--color-text, #111827);
transition: border-color 0.15s ease;
}
.search-input:focus {
outline: none;
border-color: var(--color-primary, #2563eb);
}
.company-list {
max-height: 200px;
overflow-y: auto;
}
.company-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md, 12px);
cursor: pointer;
transition: background-color 0.15s ease;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
}
.company-item:last-child {
border-bottom: none;
}
.company-item:hover {
background: var(--color-bg-secondary, #f9fafb);
}
.company-item.active {
background: var(--color-primary, #2563eb);
color: #fff;
}
.company-item.keyboard-highlighted {
background: var(--color-bg-secondary, #f9fafb);
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: -2px;
}
.company-item.active.keyboard-highlighted {
outline: 2px solid rgba(255, 255, 255, 0.5);
}
.company-details {
flex: 1;
min-width: 0;
}
.company-main-name {
font-size: var(--text-sm, 14px);
font-weight: 500;
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, 4px);
font-size: var(--text-xs, 12px);
opacity: 0.8;
}
.company-selected-icon {
color: inherit;
font-size: var(--text-sm, 14px);
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm, 8px);
padding: var(--space-xl, 24px);
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-sm, 14px);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.company-selector {
max-width: 200px;
width: auto;
}
.company-trigger {
min-width: auto;
max-width: 200px;
padding: var(--space-xs, 4px) var(--space-sm, 8px);
}
.company-info {
max-width: 140px;
}
.company-name {
font-size: var(--text-xs, 12px);
max-width: 140px;
}
.company-code {
font-size: 10px;
}
.company-dropdown-panel {
position: fixed;
left: 8px;
right: 8px;
top: 60px;
width: auto;
max-height: 70vh;
}
}
/* Header variant - transparent background for header integration */
.company-selector--header .company-trigger {
background: transparent;
border-color: var(--color-border, rgba(0, 0, 0, 0.2));
}
.company-selector--header .company-trigger:hover {
background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05));
border-color: var(--color-primary, #2563eb);
}
.company-selector--header .company-name {
color: var(--color-text, #111827);
}
.company-selector--header .company-code {
color: var(--color-text-secondary, #6b7280);
}
.company-selector--header .pi-chevron-down {
color: var(--color-text-secondary, #6b7280);
}
/* Gradient header variant - white text for dark/gradient headers */
.header-container--gradient .company-selector--header .company-trigger {
border-color: rgba(255, 255, 255, 0.3);
}
.header-container--gradient .company-selector--header .company-trigger:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
.header-container--gradient .company-selector--header .company-name {
color: white;
}
.header-container--gradient .company-selector--header .company-code {
color: rgba(255, 255, 255, 0.8);
}
.header-container--gradient .company-selector--header .pi-chevron-down {
color: rgba(255, 255, 255, 0.8);
}
/* Dropdown panel keeps default styling (white background) */
</style>

View File

@@ -0,0 +1,77 @@
<template>
<div v-if="error" class="module-error">
<div class="error-icon">
<i class="pi pi-exclamation-triangle" style="font-size: 4rem; color: var(--color-danger);"></i>
</div>
<h3>{{ moduleName }} a întâmpinat o eroare</h3>
<p class="error-message">{{ error.message }}</p>
<div class="error-actions">
<Button label="Reîncearcă" icon="pi pi-refresh" @click="retry" />
<Button label="Mergi la Dashboard" icon="pi pi-home" severity="secondary" @click="goHome" />
</div>
</div>
<slot v-else />
</template>
<script setup>
import { ref, onErrorCaptured } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
moduleName: { type: String, required: true }
})
const router = useRouter()
const error = ref(null)
onErrorCaptured((err, instance, info) => {
error.value = err
console.error(`[${props.moduleName}] Error caught:`, err, info)
return false // Prevent error from propagating
})
const retry = () => {
error.value = null
window.location.reload()
}
const goHome = () => {
error.value = null
router.push('/reports/dashboard')
}
</script>
<style scoped>
.module-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 2rem;
text-align: center;
}
.error-icon {
margin-bottom: 1.5rem;
}
.module-error h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: var(--color-text-primary, #2c3e50);
}
.error-message {
color: var(--color-text-secondary, #6c757d);
margin-bottom: 2rem;
max-width: 500px;
}
.error-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,212 @@
<template>
<div class="login-container">
<div class="login-wrapper">
<Card class="login-card">
<template #header>
<div class="login-header">
<i :class="['pi', appIcon, 'text-primary', 'text-6xl']"></i>
<h1 class="login-title">{{ appTitle }}</h1>
<p class="login-subtitle">{{ appSubtitle }}</p>
</div>
</template>
<template #content>
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<label for="username" class="form-label required">Utilizator</label>
<InputText
id="username"
v-model="credentials.username"
placeholder="Introduceți numele de utilizator"
:class="{ invalid: formErrors.username }"
class="w-full"
autocomplete="username"
@blur="validateField('username')"
/>
<span v-if="formErrors.username" class="form-error">
{{ formErrors.username }}
</span>
</div>
<div class="form-group">
<label for="password" class="form-label required">Parolă</label>
<Password
id="password"
v-model="credentials.password"
placeholder="Introduceți parola"
:class="{ invalid: formErrors.password }"
class="w-full"
:feedback="false"
toggle-mask
autocomplete="current-password"
@blur="validateField('password')"
/>
<span v-if="formErrors.password" class="form-error">
{{ formErrors.password }}
</span>
</div>
<div v-if="authStore.error" class="login-error-message">
<i class="pi pi-exclamation-triangle"></i>
<span>{{ authStore.error }}</span>
</div>
<Button
type="submit"
label="Conectare"
class="w-full login-button"
:loading="authStore.isLoading"
:disabled="!isFormValid"
/>
</form>
</template>
<template #footer>
<div class="login-footer">
<small class="text-color-secondary">
ROA2WEB © {{ currentYear }} - Toate drepturile rezervate
</small>
</div>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { useToast } from "primevue/usetoast";
// Props for app-specific customization
const props = defineProps({
appTitle: {
type: String,
required: true,
},
appSubtitle: {
type: String,
required: true,
},
appIcon: {
type: String,
required: true,
},
redirectPath: {
type: String,
default: "/",
},
authStore: {
type: Object,
required: true,
},
});
const router = useRouter();
const toast = useToast();
// Form data
const credentials = ref({
username: "",
password: "",
});
const formErrors = ref({
username: "",
password: "",
});
// Computed properties
const currentYear = computed(() => new Date().getFullYear());
const isFormValid = computed(() => {
return (
credentials.value.username.trim() !== "" &&
credentials.value.password.trim() !== "" &&
!formErrors.value.username &&
!formErrors.value.password
);
});
// Methods
const validateField = (field) => {
switch (field) {
case "username":
formErrors.value.username =
credentials.value.username.trim() === ""
? "Numele de utilizator este obligatoriu"
: "";
break;
case "password":
formErrors.value.password =
credentials.value.password.trim() === ""
? "Parola este obligatorie"
: "";
break;
}
};
const validateForm = () => {
validateField("username");
validateField("password");
return isFormValid.value;
};
const handleLogin = async () => {
if (!validateForm()) {
return;
}
try {
const result = await props.authStore.login(credentials.value);
if (result.success) {
router.push(props.redirectPath);
} else {
toast.add({
severity: "error",
summary: "Eroare de conectare",
detail: result.error || "Date de conectare incorecte",
life: 5000,
});
}
} catch (error) {
console.error("Login error:", error);
toast.add({
severity: "error",
summary: "Eroare",
detail: "A apărut o eroare neașteptată",
life: 5000,
});
}
};
// Clear errors when user starts typing
const clearErrors = () => {
props.authStore.clearError();
formErrors.value = {
username: "",
password: "",
};
};
// Lifecycle hooks
onMounted(() => {
// Clear any previous errors
clearErrors();
// Focus on username field
const usernameInput = document.getElementById("username");
if (usernameInput) {
usernameInput.focus();
}
});
onUnmounted(() => {
clearErrors();
});
</script>
<style>
@import "../styles/login.css";
</style>

View File

@@ -0,0 +1,467 @@
<template>
<div :class="selectorClass" ref="dropdownContainer">
<div class="period-dropdown" ref="dropdown">
<button
class="period-trigger"
@click="toggleDropdown"
:disabled="!hasSelectedCompany"
:aria-expanded="dropdownOpen"
aria-label="Selectare perioada contabila"
>
<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 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="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";
export default {
name: "PeriodSelector",
props: {
// The accounting period store instance
periodStore: {
type: Object,
required: true,
},
// The company store instance (to check if company is selected)
companiesStore: {
type: Object,
required: true,
},
// Variant: 'default' (white background) or 'header' (transparent for dark headers)
variant: {
type: String,
default: "default",
validator: (value) => ['default', 'header'].includes(value),
},
},
emits: ["period-changed"],
setup(props, { emit }) {
const dropdown = ref(null);
const dropdownContainer = ref(null);
const dropdownOpen = ref(false);
const highlightedIndex = ref(-1);
const hasSelectedCompany = computed(() => {
return !!props.companiesStore.selectedCompany;
});
const periods = computed(() => {
return props.periodStore.periods || [];
});
const selectedPeriodDisplay = computed(() => {
return props.periodStore.selectedPeriod?.display_name || "Selectare perioada";
});
const selectorClass = computed(() => ({
'period-selector': true,
'period-selector--header': props.variant === 'header'
}));
const isSelected = (period) => {
if (!props.periodStore.selectedPeriod) return false;
return (
period.an === props.periodStore.selectedPeriod.an &&
period.luna === props.periodStore.selectedPeriod.luna
);
};
const isHighlighted = (index) => {
return index === highlightedIndex.value;
};
const toggleDropdown = async () => {
if (!hasSelectedCompany.value) return;
dropdownOpen.value = !dropdownOpen.value;
if (dropdownOpen.value) {
highlightedIndex.value = -1;
}
};
const closeDropdown = () => {
dropdownOpen.value = false;
};
const selectPeriod = (period) => {
props.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 || periods.value.length === 0) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
highlightedIndex.value =
(highlightedIndex.value + 1) % periods.value.length;
scrollToHighlighted();
break;
case "ArrowUp":
event.preventDefault();
if (highlightedIndex.value <= 0) {
highlightedIndex.value = periods.value.length - 1;
} else {
highlightedIndex.value--;
}
scrollToHighlighted();
break;
case "Enter":
event.preventDefault();
if (
highlightedIndex.value >= 0 &&
highlightedIndex.value < periods.value.length
) {
selectPeriod(periods.value[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
watch(
() => props.companiesStore.selectedCompany,
async (newCompany) => {
if (newCompany) {
await props.periodStore.loadPeriods(newCompany.id_firma);
} else {
props.periodStore.reset();
}
},
{ immediate: true }
);
onMounted(() => {
document.addEventListener("click", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
});
return {
dropdown,
dropdownContainer,
dropdownOpen,
highlightedIndex,
hasSelectedCompany,
periods,
selectedPeriodDisplay,
selectorClass,
isSelected,
isHighlighted,
toggleDropdown,
closeDropdown,
selectPeriod,
};
},
};
</script>
<style scoped>
.period-selector {
position: relative;
max-width: 220px;
}
.period-dropdown {
position: relative;
}
.period-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 6px);
cursor: pointer;
transition: all 0.15s ease;
width: 100%;
text-align: left;
min-width: 200px;
}
.period-trigger:hover:not(:disabled) {
border-color: var(--color-primary, #2563eb);
background: var(--color-bg-secondary, #f9fafb);
}
.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, 12px);
color: var(--color-text-secondary, #6b7280);
}
.period-name {
font-size: var(--text-sm, 14px);
font-weight: 500;
color: var(--color-text, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pi-chevron-down {
transition: transform 0.15s ease;
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-xs, 12px);
}
.rotate-180 {
transform: rotate(180deg);
}
.period-dropdown-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 6px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
z-index: 1000;
max-height: 300px;
overflow: hidden;
opacity: 0;
transform: translateY(-10px);
transition: all 0.15s ease;
}
.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, 8px) var(--space-md, 12px);
cursor: pointer;
transition: background-color 0.15s ease;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
}
.period-item:last-child {
border-bottom: none;
}
.period-item:hover {
background: var(--color-bg-secondary, #f9fafb);
}
.period-item.active {
background: var(--color-primary, #2563eb);
color: #fff;
}
.period-item.keyboard-highlighted {
background: var(--color-bg-secondary, #f9fafb);
outline: 2px solid var(--color-primary, #2563eb);
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, 14px);
}
.period-selected-icon {
color: inherit;
font-size: var(--text-sm, 14px);
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm, 8px);
padding: var(--space-xl, 24px);
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-sm, 14px);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.period-selector {
max-width: 140px;
width: auto;
}
.period-trigger {
min-width: auto;
padding: var(--space-xs, 4px) var(--space-sm, 8px);
}
.period-info {
flex-direction: row;
align-items: center;
gap: var(--space-xs, 4px);
}
.period-label {
display: none;
}
.period-name {
font-size: var(--text-xs, 12px);
}
.period-dropdown-panel {
position: fixed;
left: 8px;
right: 8px;
top: 60px;
width: auto;
max-height: 70vh;
}
}
/* Header variant - transparent background for header integration */
.period-selector--header .period-trigger {
background: transparent;
border-color: var(--color-border, rgba(0, 0, 0, 0.2));
}
.period-selector--header .period-trigger:hover:not(:disabled) {
background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05));
border-color: var(--color-primary, #2563eb);
}
.period-selector--header .period-trigger:disabled {
border-color: var(--color-border, rgba(0, 0, 0, 0.1));
}
.period-selector--header .period-label {
color: var(--color-text-secondary, #6b7280);
}
.period-selector--header .period-name {
color: var(--color-text, #111827);
}
.period-selector--header .pi-chevron-down {
color: var(--color-text-secondary, #6b7280);
}
/* Gradient header variant - white text for dark/gradient headers */
.header-container--gradient .period-selector--header .period-trigger {
border-color: rgba(255, 255, 255, 0.3);
}
.header-container--gradient .period-selector--header .period-trigger:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
.header-container--gradient .period-selector--header .period-trigger:disabled {
border-color: rgba(255, 255, 255, 0.15);
}
.header-container--gradient .period-selector--header .period-label {
color: rgba(255, 255, 255, 0.7);
}
.header-container--gradient .period-selector--header .period-name {
color: white;
}
.header-container--gradient .period-selector--header .pi-chevron-down {
color: rgba(255, 255, 255, 0.8);
}
/* Dropdown panel keeps default styling (white background) */
</style>

View File

@@ -0,0 +1,135 @@
<template>
<header class="header-container" :class="headerClass">
<nav class="header-nav">
<!-- Left side: Hamburger + Brand -->
<div class="header-left">
<button
class="hamburger-btn"
:class="{ active: menuOpen }"
@click="$emit('menu-toggle')"
aria-label="Toggle navigation menu"
>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
<router-link :to="brandLink" class="header-brand">
<slot name="brand">
<span>{{ title }}</span>
</slot>
</router-link>
</div>
<!-- Right side: Period + Company + User -->
<div class="header-actions">
<PeriodSelector
v-if="showPeriod && selectedCompany"
:period-store="periodStore"
:companies-store="companiesStore"
variant="header"
@period-changed="onPeriodChanged"
/>
<CompanySelector
v-if="showCompany"
:companies-store="companiesStore"
:show-fiscal-code="true"
variant="header"
@company-changed="onCompanyChanged"
/>
<!-- User menu slot (only rendered if showUser is true) -->
<div v-if="showUser">
<slot name="user-menu">
<div v-if="currentUser" class="header-user" @click="$emit('user-menu-toggle')">
<i class="pi pi-user"></i>
<span class="desktop-only">{{ currentUser?.username || 'User' }}</span>
</div>
</slot>
</div>
</div>
</nav>
</header>
</template>
<script>
import { computed } from "vue";
import CompanySelector from "../CompanySelector.vue";
import PeriodSelector from "../PeriodSelector.vue";
export default {
name: "AppHeader",
components: {
CompanySelector,
PeriodSelector,
},
props: {
// Header title/brand text
title: {
type: String,
default: "ROA2WEB",
},
// Router link for brand click
brandLink: {
type: String,
default: "/",
},
// Additional CSS class for header (e.g., 'header-container--gradient')
headerClass: {
type: String,
default: "",
},
// Is hamburger menu open?
menuOpen: {
type: Boolean,
default: false,
},
// Companies store instance (required for selectors)
companiesStore: {
type: Object,
required: true,
},
// Period store instance (required for period selector)
periodStore: {
type: Object,
required: true,
},
// Current user object for display
currentUser: {
type: Object,
default: null,
},
// Show/hide period selector
showPeriod: {
type: Boolean,
default: true,
},
// Show/hide company selector
showCompany: {
type: Boolean,
default: true,
},
// Show/hide user info
showUser: {
type: Boolean,
default: true,
},
},
emits: ["menu-toggle", "company-changed", "period-changed", "user-menu-toggle"],
setup(props, { emit }) {
const selectedCompany = computed(() => props.companiesStore.selectedCompany);
const onCompanyChanged = (company) => {
emit("company-changed", company);
};
const onPeriodChanged = (period) => {
emit("period-changed", period);
};
return {
selectedCompany,
onCompanyChanged,
onPeriodChanged,
};
},
};
</script>

View File

@@ -0,0 +1,101 @@
<template>
<div>
<!-- Menu Overlay -->
<div
class="slide-menu-overlay"
:class="{ open: isOpen }"
@click="$emit('close')"
></div>
<!-- Slide Menu -->
<nav class="slide-menu" :class="{ open: isOpen }">
<!-- Dynamic Menu Sections -->
<div
v-for="section in menuItems"
:key="section.title"
class="menu-section"
>
<h3 class="menu-title">{{ section.title }}</h3>
<ul class="menu-list">
<li
v-for="item in section.items"
:key="item.to"
class="menu-item"
>
<router-link
:to="item.to"
class="menu-link"
:class="{ active: isRouteActive(item.to) }"
@click="$emit('close')"
>
<i :class="['menu-icon', item.icon]"></i>
<span>{{ item.label }}</span>
<span v-if="item.badge" class="menu-badge">{{ item.badge }}</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">
<slot name="profile-items"></slot>
<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 { useRoute } from "vue-router";
export default {
name: "SlideMenu",
props: {
// Is menu open?
isOpen: {
type: Boolean,
default: false,
},
// Menu items configuration
// Format: [{ title: 'Section', items: [{ to: '/path', icon: 'pi pi-icon', label: 'Label', badge: null }] }]
menuItems: {
type: Array,
default: () => [],
},
// Current user object
currentUser: {
type: Object,
default: null,
},
},
emits: ["close", "logout"],
setup(props, { emit }) {
const route = useRoute();
const isRouteActive = (path) => {
return route.path === path;
};
const handleLogout = () => {
emit("logout");
emit("close");
};
return {
isRouteActive,
handleLogout,
};
},
};
</script>

View File

@@ -0,0 +1,182 @@
/**
* Shared Accounting Period Store Factory
*
* Creates a Pinia store for accounting period selection that can be used by any ROA2WEB application.
* Each app passes its own apiService and store references.
*
* Usage:
* import { createAccountingPeriodStore } from '@shared/frontend/stores/accountingPeriod';
* import { apiService } from '../services/api';
* import { useAuthStore } from './auth';
* import { useCompanyStore } from './companies';
* export const useAccountingPeriodStore = createAccountingPeriodStore(apiService, useAuthStore, useCompanyStore);
*/
import { defineStore } from "pinia";
import { ref, computed } from "vue";
/**
* Factory function to create an accounting period store
* @param {Object} apiService - Axios instance configured for the app's API
* @param {Function} useAuthStore - Reference to the auth store function
* @param {Function} useCompanyStore - Reference to the company store function
* @returns {Function} Pinia store definition
*/
export function createAccountingPeriodStore(apiService, useAuthStore, useCompanyStore) {
return defineStore("accountingPeriod", () => {
// State
const periods = ref([]);
const selectedPeriod = ref(null);
const isLoading = ref(false);
const error = ref(null);
// Getters
const hasPeriods = computed(() => periods.value.length > 0);
const currentPeriod = computed(() => selectedPeriod.value);
// Computed date range for current period (first/last day of month)
const dateRange = computed(() => {
if (!selectedPeriod.value) return { dateFrom: null, dateTo: null };
const { an, luna } = selectedPeriod.value;
const firstDay = new Date(an, luna - 1, 1);
const lastDay = new Date(an, luna, 0);
return {
dateFrom: firstDay,
dateTo: lastDay,
};
});
// localStorage helpers - lazy instantiate stores only when needed
const getStorageKey = () => {
try {
const authStore = useAuthStore();
const companyStore = useCompanyStore();
const username = authStore.user?.username;
const companyId = companyStore.selectedCompany?.id_firma;
if (!username || !companyId) return null;
return `selected_period_${username}_${companyId}`;
} catch (e) {
// Stores not yet initialized, skip localStorage
return null;
}
};
const initializeSelectedPeriod = () => {
const key = getStorageKey();
if (!key) return null;
const saved = localStorage.getItem(key);
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
localStorage.removeItem(key);
}
}
return null;
};
const persistSelectedPeriod = (period) => {
const key = getStorageKey();
if (key && period) {
localStorage.setItem(key, JSON.stringify(period));
}
};
// Actions
const loadPeriods = async (companyId) => {
if (!companyId) {
console.warn('[Period] loadPeriods called without companyId');
return { success: false };
}
isLoading.value = true;
error.value = null;
try {
console.log('[Period] Loading periods for company:', companyId);
const response = await apiService.get("/calendar/periods", {
params: { company: companyId },
});
periods.value = response.data.periods || [];
console.log('[Period] Loaded', periods.value.length, 'periods');
console.log('[Period] Backend current_period:', response.data.current_period);
// Try to restore saved period or use most recent
const saved = initializeSelectedPeriod();
console.log('[Period] Saved period from localStorage:', saved);
if (saved) {
const exists = periods.value.find(
(p) => p.an === saved.an && p.luna === saved.luna
);
if (exists) {
console.log('[Period] Restoring saved period:', exists);
selectedPeriod.value = exists;
} else if (response.data.current_period) {
console.log('[Period] Saved period not found, using current:', response.data.current_period);
setSelectedPeriod(response.data.current_period);
}
} else if (response.data.current_period) {
console.log('[Period] No saved period, auto-selecting current:', response.data.current_period);
setSelectedPeriod(response.data.current_period);
} else {
console.warn('[Period] No saved period and no current_period from backend!');
}
console.log('[Period] Final selectedPeriod:', selectedPeriod.value);
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load periods";
console.error("[Period] Failed to load periods - Full error:", err);
console.error("[Period] Error response:", err.response);
console.error("[Period] Error message:", err.message);
console.error("[Period] Error status:", err.response?.status);
console.error("[Period] Error data:", err.response?.data);
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const setSelectedPeriod = (period) => {
selectedPeriod.value = period;
persistSelectedPeriod(period);
};
const resetToLatest = () => {
if (periods.value.length > 0) {
setSelectedPeriod(periods.value[0]);
}
};
const reset = () => {
periods.value = [];
selectedPeriod.value = null;
isLoading.value = false;
error.value = null;
};
return {
// State
periods,
selectedPeriod,
isLoading,
error,
// Getters
hasPeriods,
currentPeriod,
dateRange,
// Actions
loadPeriods,
setSelectedPeriod,
resetToLatest,
reset,
};
});
}

133
src/shared/stores/auth.js Normal file
View File

@@ -0,0 +1,133 @@
/**
* Shared Auth Store Factory
*
* Creates a Pinia auth store that can be used by any ROA2WEB application.
* Each app passes its own apiService instance configured with the correct baseURL.
*
* Usage:
* import { createAuthStore } from '@shared/frontend/stores/auth';
* import { apiService } from '../services/api';
* export const useAuthStore = createAuthStore(apiService);
*/
import { defineStore } from "pinia";
import { ref, computed } from "vue";
/**
* Factory function to create an auth store with the provided API service
* @param {Object} apiService - Axios instance configured for the app's API
* @returns {Function} Pinia store definition
*/
export function createAuthStore(apiService) {
return defineStore("auth", () => {
// State
const accessToken = ref(localStorage.getItem("access_token"));
const refreshToken = ref(localStorage.getItem("refresh_token"));
const user = ref(JSON.parse(localStorage.getItem("user") || "null"));
const isLoading = ref(false);
const error = ref(null);
// Getters
const isAuthenticated = computed(() => !!accessToken.value);
const currentUser = computed(() => user.value);
// Actions
const login = async (credentials) => {
isLoading.value = true;
error.value = null;
try {
const response = await apiService.post("/auth/login", {
username: credentials.username,
password: credentials.password,
});
const { access_token, refresh_token, user: userData } = response.data;
accessToken.value = access_token;
refreshToken.value = refresh_token;
user.value = userData;
localStorage.setItem("access_token", access_token);
localStorage.setItem("refresh_token", refresh_token);
localStorage.setItem("user", JSON.stringify(userData));
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Login failed";
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const logout = () => {
accessToken.value = null;
refreshToken.value = null;
user.value = null;
error.value = null;
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
delete apiService.defaults.headers.common["Authorization"];
};
const refreshAccessToken = async () => {
if (!refreshToken.value) {
logout();
return false;
}
try {
const response = await apiService.post("/auth/refresh", {
refresh_token: refreshToken.value,
});
const { access_token } = response.data;
accessToken.value = access_token;
localStorage.setItem("access_token", access_token);
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
return true;
} catch (err) {
console.error("Token refresh failed:", err);
logout();
return false;
}
};
const initializeAuth = () => {
if (accessToken.value) {
apiService.defaults.headers.common["Authorization"] = `Bearer ${accessToken.value}`;
}
};
const clearError = () => {
error.value = null;
};
// Initialize on store creation
initializeAuth();
return {
// State
accessToken,
refreshToken,
user,
isLoading,
error,
// Getters
isAuthenticated,
currentUser,
// Actions
login,
logout,
refreshAccessToken,
initializeAuth,
clearError,
};
});
}

View File

@@ -0,0 +1,203 @@
/**
* Shared Companies Store Factory
*
* Creates a Pinia store for company selection that can be used by any ROA2WEB application.
* Each app passes its own apiService and auth store instances.
*
* Usage:
* import { createCompaniesStore } from '@shared/frontend/stores/companies';
* import { apiService } from '../services/api';
* import { useAuthStore } from './auth';
* export const useCompanyStore = createCompaniesStore(apiService, useAuthStore);
*/
import { defineStore } from "pinia";
import { ref, computed, watch } from "vue";
/**
* Factory function to create a companies store
* @param {Object} apiService - Axios instance configured for the app's API
* @param {Function} useAuthStore - Reference to the auth store function
* @returns {Function} Pinia store definition
*/
export function createCompaniesStore(apiService, useAuthStore) {
return defineStore("companies", () => {
// State
const companies = ref([]);
const selectedCompany = ref(null);
const isLoading = ref(false);
const error = ref(null);
// Initialize from localStorage - per user
const initializeSelectedCompany = () => {
const authStore = useAuthStore();
const username = authStore.user?.username;
if (!username) {
console.log("[Companies] No username available for initialization");
return null;
}
const key = `selected_company_${username}`;
const saved = localStorage.getItem(key);
if (saved) {
try {
const company = JSON.parse(saved);
console.log(`[Companies] Loaded saved company for ${username}:`, company.name);
return company;
} catch (e) {
console.error("Failed to parse saved company", e);
localStorage.removeItem(key);
}
}
return null;
};
// Watch for auth user changes to restore selected company
const authStore = useAuthStore();
watch(
() => authStore.user,
(newUser) => {
if (newUser && newUser.username && !selectedCompany.value) {
const restoredCompany = initializeSelectedCompany();
if (restoredCompany) {
selectedCompany.value = restoredCompany;
console.log("[Companies] Restored selected company:", restoredCompany.name);
}
}
},
{ immediate: true }
);
// Getters
const companyList = computed(() => companies.value);
const hasCompanies = computed(() => companies.value.length > 0);
const selectedCompanyId = computed(() => selectedCompany.value?.id_firma || null);
const companyListFormatted = computed(() => {
return companies.value.map((company) => ({
...company,
displayName: company.fiscal_code
? `${company.name} (${company.fiscal_code})`
: company.name,
}));
});
// Actions
const loadCompanies = async () => {
isLoading.value = true;
error.value = null;
try {
console.log("[Companies] Loading companies...");
const response = await apiService.get("/companies");
companies.value = response.data.companies || [];
console.log("[Companies] Loaded", companies.value.length, "companies");
// Validate saved company is still accessible
if (selectedCompany.value) {
const exists = companies.value.find(
(c) => c.id_firma === selectedCompany.value.id_firma
);
if (!exists) {
console.warn("[Companies] Saved company not accessible, clearing");
clearSelectedCompany();
}
}
// Auto-select first company if none selected
if (!selectedCompany.value && companies.value.length > 0) {
const firstCompany = companies.value[0];
console.log("[Companies] Auto-selecting first company:", firstCompany.name);
setSelectedCompany(firstCompany);
}
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load companies";
console.error("Failed to load companies:", err);
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const setSelectedCompany = (company) => {
selectedCompany.value = company;
const authStore = useAuthStore();
const username = authStore.user?.username;
if (!username) {
console.warn("[Companies] Cannot save - no username");
return;
}
const key = `selected_company_${username}`;
if (company) {
localStorage.setItem(key, JSON.stringify(company));
console.log(`[Companies] Saved company for ${username}:`, company.name);
} else {
localStorage.removeItem(key);
}
};
const clearSelectedCompany = () => {
selectedCompany.value = null;
const authStore = useAuthStore();
const username = authStore.user?.username;
if (username) {
const key = `selected_company_${username}`;
localStorage.removeItem(key);
}
};
const getCompanyById = (id_firma) => {
return companies.value.find(
(company) => company.id_firma === parseInt(id_firma)
);
};
const clearError = () => {
error.value = null;
};
const reset = () => {
companies.value = [];
selectedCompany.value = null;
isLoading.value = false;
error.value = null;
const authStore = useAuthStore();
const username = authStore.user?.username;
if (username) {
const key = `selected_company_${username}`;
localStorage.removeItem(key);
}
};
return {
// State
companies,
selectedCompany,
isLoading,
error,
// Getters
companyList,
companyListFormatted,
hasCompanies,
selectedCompanyId,
// Actions
loadCompanies,
setSelectedCompany,
clearSelectedCompany,
getCompanyById,
clearError,
reset,
};
});
}

View File

@@ -0,0 +1,167 @@
/* Shared Header Styles - ROA2WEB */
/* Header Container */
.header-container {
position: sticky;
top: 0;
z-index: var(--z-header, 100);
background: var(--color-bg, #fff);
border-bottom: 1px solid var(--color-border, #e5e7eb);
height: var(--header-height, 60px);
padding: 0 var(--space-lg, 24px);
}
/* Gradient Header Variant */
.header-container--gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-bottom: none;
}
.header-container--gradient .header-brand {
color: white;
}
.header-container--gradient .hamburger-line {
background-color: white;
}
/* Header Navigation */
.header-nav {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
max-width: 1600px;
margin: 0 auto;
}
/* Header Left Section */
.header-left {
display: flex;
align-items: center;
gap: var(--space-md, 16px);
}
/* Brand/Logo */
.header-brand {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
font-size: var(--text-lg, 18px);
font-weight: var(--font-semibold, 600);
color: var(--color-primary, #2563eb);
text-decoration: none;
white-space: nowrap;
}
.header-brand:hover {
opacity: 0.9;
}
/* Header Actions (right side) */
.header-actions {
display: flex;
align-items: center;
gap: var(--space-md, 16px);
}
/* 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, #2563eb);
border-radius: 2px;
transition: all 0.3s ease;
transform-origin: center;
}
/* Hamburger Animation - X state */
.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);
}
/* Header User Menu */
.header-user {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px);
border-radius: var(--radius-md, 6px);
cursor: pointer;
transition: background-color 0.15s ease;
color: var(--color-text, #111827);
}
.header-user:hover {
background-color: var(--color-bg-secondary, #f9fafb);
}
/* Gradient header user menu */
.header-container--gradient .header-user {
color: white;
}
.header-container--gradient .header-user:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.header-container {
padding: 0 var(--space-md, 12px);
}
.header-left {
gap: var(--space-sm, 8px);
}
.header-actions {
gap: var(--space-sm, 8px);
}
.header-brand {
font-size: var(--text-base, 16px);
}
/* Hide text-only elements on mobile */
.desktop-only {
display: none;
}
}
@media (max-width: 480px) {
.header-brand span {
display: none;
}
.header-brand i {
font-size: 1.5rem;
}
}

View File

@@ -0,0 +1,151 @@
/* Shared Navigation Styles - ROA2WEB */
/* Slide-out Menu */
.slide-menu {
position: fixed;
top: var(--header-height, 60px);
left: 0;
width: var(--sidebar-width, 280px);
height: calc(100vh - var(--header-height, 60px));
background: var(--color-bg, #fff);
border-right: 1px solid var(--color-border, #e5e7eb);
box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1));
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: var(--z-modal, 1000);
overflow-y: auto;
/* Flex container for profile section at bottom */
display: flex;
flex-direction: column;
}
.slide-menu.open {
transform: translateX(0);
}
/* Menu Overlay */
.slide-menu-overlay {
position: fixed;
top: var(--header-height, 60px);
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
z-index: var(--z-modal-backdrop, 999);
}
.slide-menu-overlay.open {
opacity: 1;
visibility: visible;
}
/* Menu Sections */
.menu-section {
padding: var(--space-lg, 24px);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.menu-section:last-child {
border-bottom: none;
}
/* Profile section at bottom */
.menu-section.menu-profile {
margin-top: auto;
border-top: 1px solid var(--color-border, #e5e7eb);
border-bottom: none;
}
.menu-title {
font-size: var(--text-sm, 14px);
font-weight: var(--font-semibold, 600);
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-md, 12px);
}
.menu-list {
list-style: none;
margin: 0;
padding: 0;
}
.menu-item {
margin-bottom: var(--space-xs, 4px);
}
.menu-link {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
color: var(--color-text, #111827);
text-decoration: none;
border-radius: var(--radius-md, 6px);
transition: all 0.15s ease;
font-size: var(--text-sm, 14px);
}
.menu-link:hover,
.menu-link.active {
background-color: var(--color-bg-secondary, #f9fafb);
color: var(--color-primary, #2563eb);
}
.menu-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
font-size: 16px;
}
/* Profile Info */
.profile-info {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
margin-bottom: var(--space-sm, 8px);
font-weight: var(--font-medium, 500);
color: var(--color-text, #111827);
}
.profile-info i {
font-size: 1.25rem;
color: var(--color-primary, #2563eb);
}
/* Badge for menu items */
.menu-badge {
margin-left: auto;
background: var(--color-danger, #ef4444);
color: white;
font-size: var(--text-xs, 12px);
font-weight: var(--font-semibold, 600);
padding: 2px 6px;
border-radius: var(--radius-full, 9999px);
min-width: 20px;
text-align: center;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.slide-menu {
width: 280px;
}
.menu-section {
padding: var(--space-md, 12px);
}
}
@media (max-width: 480px) {
.slide-menu {
width: 100vw;
max-width: 320px;
}
}

177
src/shared/styles/login.css Normal file
View File

@@ -0,0 +1,177 @@
/* Shared Login Page Styles */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
var(--color-primary-light) 0%,
var(--color-primary) 100%
);
padding: 1rem;
}
.login-wrapper {
width: 100%;
max-width: 400px;
}
.login-card {
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.login-header {
text-align: center;
padding: 2rem 2rem 1rem 2rem;
background: white;
}
.login-title {
margin: 1rem 0 0.5rem 0;
color: var(--primary-color);
font-size: 2rem;
font-weight: 700;
}
.login-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.95rem;
}
.login-form {
padding: 0 2rem 2rem 2rem;
}
.login-button {
margin-top: 1rem;
padding: 0.75rem;
font-size: 1.1rem;
font-weight: 600;
background: var(--color-primary-light) !important;
color: white !important;
border: none !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.login-button:hover {
background: var(--color-primary) !important;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.login-button:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.login-error-message {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
margin-bottom: 1rem;
background-color: var(--red-50);
color: var(--red-800);
border: 1px solid var(--red-200);
border-radius: 6px;
font-size: 0.9rem;
}
.login-footer {
text-align: center;
padding: 1rem 2rem;
background-color: var(--surface-50);
border-top: 1px solid var(--surface-200);
}
/* Responsive design */
@media (max-width: 768px) {
.login-container {
padding: 0.5rem;
}
.login-wrapper {
max-width: 100%;
padding: 0 1rem;
}
.login-card {
border-radius: 8px;
}
.login-header {
padding: 1.5rem 1rem;
}
.login-title {
font-size: 1.5rem;
}
.login-form {
padding: 0 1rem 1.5rem 1rem;
}
/* Ensure inputs are touch-friendly */
.login-container .p-inputtext,
.login-container .p-password input {
min-height: 44px;
font-size: 16px; /* Prevents zoom on iOS */
}
.login-footer {
padding: 1rem;
}
}
@media (max-width: 480px) {
.login-container {
padding: 0.25rem;
}
.login-card {
margin: 0;
}
.login-header {
padding: 1rem 0.5rem;
}
.login-title {
font-size: 1.25rem;
}
.login-subtitle {
font-size: 0.875rem;
}
.login-form {
padding: 0 0.5rem 1rem 0.5rem;
}
.login-footer {
padding: 0.75rem 0.5rem;
}
}
/* Animation for smooth transitions */
.login-card {
animation: loginFadeInUp 0.6s ease-out;
}
@keyframes loginFadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

2
src/test.js Normal file
View File

@@ -0,0 +1,2 @@
console.log('test')
export default {}

View File

@@ -0,0 +1,25 @@
<template>
<LoginView
app-title="ROA2WEB"
app-subtitle="Sistem Unificat - Rapoarte & Introduceri Date"
app-icon="pi-chart-bar"
redirect-path="/reports/dashboard"
:auth-store="authStore"
/>
</template>
<script setup>
import LoginView from '@shared/components/LoginView.vue'
import { createAuthStore } from '@shared/stores/auth.js'
import axios from 'axios'
// API service for auth (uses reports API)
const authApi = axios.create({
baseURL: '/api/reports',
headers: { 'Content-Type': 'application/json' }
})
// Create and instantiate auth store
const useAuthStore = createAuthStore(authApi)
const authStore = useAuthStore()
</script>