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