Initial commit: ROA2WEB - FastAPI + Vue.js + Telegram Bot
Modern ERP Reports Application with microservices architecture Tech Stack: - Backend: FastAPI + python-oracledb (Oracle DB integration) - Frontend: Vue.js 3 + PrimeVue + Vite - Telegram Bot: python-telegram-bot + SQLite - Infrastructure: Shared database pool, JWT authentication, SSH tunnel Features: - FastAPI backend with async Oracle connection pool - Vue.js 3 responsive frontend with PrimeVue components - Telegram bot alternative interface - Microservices architecture with shared components - Complete deployment support (Linux Docker + Windows IIS) - Comprehensive testing (Playwright E2E + pytest) Repository Structure: - reports-app/ - Main application (backend, frontend, telegram-bot) - shared/ - Shared components (database pool, auth, utils) - deployment/ - Deployment scripts (Linux & Windows) - docs/ - Project documentation - security/ - Security scanning and git hooks
This commit is contained in:
221
reports-app/frontend/src/App.vue
Normal file
221
reports-app/frontend/src/App.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<!-- Navigation Bar -->
|
||||
<Menubar
|
||||
v-if="authStore.isAuthenticated"
|
||||
:model="menuItems"
|
||||
class="app-menubar"
|
||||
>
|
||||
<template #start>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-chart-bar text-primary text-2xl"></i>
|
||||
<span class="font-bold text-xl">ROA Reports</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #end>
|
||||
<div class="flex align-items-center gap-3">
|
||||
<Badge
|
||||
:value="selectedCompany?.name || 'Selectați firmă'"
|
||||
:severity="selectedCompany ? 'info' : 'warning'"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-sign-out"
|
||||
label="Deconectare"
|
||||
text
|
||||
@click="logout"
|
||||
class="p-button-text"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Menubar>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main
|
||||
class="main-content"
|
||||
:class="{ 'with-navbar': authStore.isAuthenticated }"
|
||||
>
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Global Toast Messages - positioned below header to avoid covering company selector -->
|
||||
<Toast position="top-center" :style="{ top: '80px' }" />
|
||||
|
||||
<!-- Global Confirmation Dialog -->
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
import { useCompanyStore } from "./stores/companies";
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const companyStore = useCompanyStore();
|
||||
|
||||
// Dashboard options
|
||||
const dashboardOptions = [
|
||||
{ label: 'Main Dashboard', value: '/dashboard' },
|
||||
{ label: 'New Dashboard', value: '/dashboard-new' },
|
||||
{ label: 'Ultra Minimal', value: '/dashboard-v1' },
|
||||
{ label: 'Compact Grid', value: '/dashboard-v2' },
|
||||
{ label: 'Data Tables', value: '/dashboard-v3' },
|
||||
{ label: 'Action Center', value: '/dashboard-v4' }
|
||||
];
|
||||
|
||||
// Menu items for navigation
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
label: "Dashboard",
|
||||
icon: "pi pi-home",
|
||||
items: dashboardOptions.map(option => ({
|
||||
label: option.label,
|
||||
command: () => router.push(option.value)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: "Facturi",
|
||||
icon: "pi pi-file-text",
|
||||
command: () => router.push("/invoices"),
|
||||
},
|
||||
{
|
||||
label: "Registru Casa si Banca",
|
||||
icon: "pi pi-wallet",
|
||||
command: () => router.push("/bank-cash-register"),
|
||||
},
|
||||
]);
|
||||
|
||||
// Get selected company
|
||||
const selectedCompany = computed(() => companyStore.selectedCompany);
|
||||
|
||||
// Logout function
|
||||
const logout = () => {
|
||||
authStore.logout();
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
// Initialize app
|
||||
onMounted(async () => {
|
||||
// Check authentication on app start
|
||||
if (authStore.isAuthenticated) {
|
||||
try {
|
||||
// Load companies if authenticated
|
||||
await companyStore.loadCompanies();
|
||||
} catch (error) {
|
||||
console.error("Failed to load companies:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
background-color: var(--surface-ground);
|
||||
}
|
||||
|
||||
.app-menubar {
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.main-content.with-navbar {
|
||||
margin-top: 0;
|
||||
min-height: calc(100vh - 70px);
|
||||
}
|
||||
|
||||
.main-content:not(.with-navbar) {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Global styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
"Inter",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
sans-serif;
|
||||
background-color: var(--surface-ground);
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 0.25rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.app-menubar .p-menubar-button {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.main-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
padding: 0;
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom PrimeVue overrides */
|
||||
.p-button {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.p-datatable .p-datatable-tbody > tr.invoice-paid {
|
||||
background-color: var(--green-50);
|
||||
color: var(--green-900);
|
||||
}
|
||||
|
||||
.p-datatable .p-datatable-tbody > tr.invoice-overdue {
|
||||
background-color: var(--red-50);
|
||||
color: var(--red-900);
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-paid {
|
||||
background-color: var(--green-100);
|
||||
color: var(--green-900);
|
||||
}
|
||||
|
||||
.status-overdue {
|
||||
background-color: var(--red-100);
|
||||
color: var(--red-900);
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background-color: var(--yellow-100);
|
||||
color: var(--yellow-900);
|
||||
}
|
||||
</style>
|
||||
430
reports-app/frontend/src/assets/css/components/buttons.css
Normal file
430
reports-app/frontend/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;
|
||||
}
|
||||
360
reports-app/frontend/src/assets/css/components/cards.css
Normal file
360
reports-app/frontend/src/assets/css/components/cards.css
Normal file
@@ -0,0 +1,360 @@
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
460
reports-app/frontend/src/assets/css/components/forms.css
Normal file
460
reports-app/frontend/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;
|
||||
}
|
||||
}
|
||||
448
reports-app/frontend/src/assets/css/components/stats.css
Normal file
448
reports-app/frontend/src/assets/css/components/stats.css
Normal file
@@ -0,0 +1,448 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
876
reports-app/frontend/src/assets/css/components/tables.css
Normal file
876
reports-app/frontend/src/assets/css/components/tables.css
Normal file
@@ -0,0 +1,876 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
126
reports-app/frontend/src/assets/css/core/reset.css
Normal file
126
reports-app/frontend/src/assets/css/core/reset.css
Normal file
@@ -0,0 +1,126 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
155
reports-app/frontend/src/assets/css/core/typography.css
Normal file
155
reports-app/frontend/src/assets/css/core/typography.css
Normal file
@@ -0,0 +1,155 @@
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
181
reports-app/frontend/src/assets/css/core/variables.css
Normal file
181
reports-app/frontend/src/assets/css/core/variables.css
Normal file
@@ -0,0 +1,181 @@
|
||||
/* 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
reports-app/frontend/src/assets/css/global.css
Normal file
686
reports-app/frontend/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);
|
||||
}
|
||||
}
|
||||
212
reports-app/frontend/src/assets/css/layout/containers.css
Normal file
212
reports-app/frontend/src/assets/css/layout/containers.css
Normal file
@@ -0,0 +1,212 @@
|
||||
/* 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;
|
||||
}
|
||||
159
reports-app/frontend/src/assets/css/layout/grid.css
Normal file
159
reports-app/frontend/src/assets/css/layout/grid.css
Normal file
@@ -0,0 +1,159 @@
|
||||
/* 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);
|
||||
}
|
||||
289
reports-app/frontend/src/assets/css/layout/navigation.css
Normal file
289
reports-app/frontend/src/assets/css/layout/navigation.css
Normal file
@@ -0,0 +1,289 @@
|
||||
/* 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: none;
|
||||
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;
|
||||
}
|
||||
|
||||
.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) {
|
||||
.hamburger-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
144
reports-app/frontend/src/assets/css/main.css
Normal file
144
reports-app/frontend/src/assets/css/main.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/* Main CSS Entry Point - ROA2WEB */
|
||||
|
||||
/* Import order is critical for proper CSS cascade */
|
||||
|
||||
/* 1. Core Foundation */
|
||||
@import './core/variables.css';
|
||||
@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. Utilities */
|
||||
@import './utilities/spacing.css';
|
||||
@import './utilities/display.css';
|
||||
@import './utilities/text.css';
|
||||
@import './utilities/flex.css';
|
||||
|
||||
/* 5. Existing 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;
|
||||
}
|
||||
}
|
||||
695
reports-app/frontend/src/assets/css/mobile.css
Normal file
695
reports-app/frontend/src/assets/css/mobile.css
Normal file
@@ -0,0 +1,695 @@
|
||||
/* Mobile-specific styles for ROA Reports */
|
||||
|
||||
/* Mobile Navigation Enhancements */
|
||||
@media (max-width: 768px) {
|
||||
/* Menubar mobile optimizations */
|
||||
.p-menubar {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.p-menubar .p-menubar-root-list {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background: var(--surface-overlay);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--overlay-shadow);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.p-menubar .p-menubar-root-list .p-menuitem {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.p-menubar .p-menubar-root-list .p-menuitem-link {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.p-menubar .p-menubar-button {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
/* Hide menu items by default on mobile */
|
||||
.p-menubar .p-menubar-root-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.p-menubar.p-menubar-mobile-active .p-menubar-root-list {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile DataTable Enhancements */
|
||||
@media (max-width: 768px) {
|
||||
.p-datatable .p-datatable-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.p-datatable .p-datatable-thead > tr > th {
|
||||
min-width: 120px;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.p-datatable .p-datatable-tbody > tr > td {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
/* Stack table content vertically on very small screens */
|
||||
.p-datatable.mobile-stack .p-datatable-thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.p-datatable.mobile-stack .p-datatable-tbody,
|
||||
.p-datatable.mobile-stack .p-datatable-tbody tr,
|
||||
.p-datatable.mobile-stack .p-datatable-tbody td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.p-datatable.mobile-stack .p-datatable-tbody tr {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.p-datatable.mobile-stack .p-datatable-tbody td {
|
||||
border: none;
|
||||
position: relative;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.p-datatable.mobile-stack .p-datatable-tbody td:before {
|
||||
content: attr(data-label) ": ";
|
||||
font-weight: 600;
|
||||
color: var(--text-color-secondary);
|
||||
display: inline-block;
|
||||
width: 40%;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Card Optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.p-card .p-card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.p-card .p-card-header {
|
||||
padding: 1rem 1rem 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.p-card .p-card-footer {
|
||||
padding: 0.5rem 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.p-card .p-card-title {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.p-card .p-card-subtitle {
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Button Enhancements */
|
||||
@media (max-width: 768px) {
|
||||
.p-button {
|
||||
min-height: 44px; /* Minimum touch target size */
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.p-button.p-button-sm {
|
||||
min-height: 36px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.p-button.p-button-lg {
|
||||
min-height: 52px;
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Full width buttons on mobile */
|
||||
.mobile-full-width .p-button {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Form Enhancements */
|
||||
@media (max-width: 768px) {
|
||||
.p-inputtext,
|
||||
.p-password input,
|
||||
.p-dropdown,
|
||||
.p-calendar input {
|
||||
min-height: 44px;
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.p-float-label > label {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.p-float-label > .p-invalid + label {
|
||||
color: var(--red-500);
|
||||
}
|
||||
|
||||
/* Stack form fields vertically */
|
||||
.mobile-form-stack .p-field {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mobile-form-stack .p-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Dialog Enhancements */
|
||||
@media (max-width: 768px) {
|
||||
.p-dialog {
|
||||
width: 95vw !important;
|
||||
max-width: none !important;
|
||||
margin: 0 !important;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.p-dialog .p-dialog-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.p-dialog .p-dialog-content {
|
||||
padding: 1rem;
|
||||
max-height: calc(90vh - 120px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.p-dialog .p-dialog-footer {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.p-dialog .p-dialog-footer .p-button {
|
||||
flex: 1;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Toast Enhancements */
|
||||
@media (max-width: 768px) {
|
||||
.p-toast {
|
||||
width: calc(100vw - 2rem) !important;
|
||||
left: 1rem !important;
|
||||
right: 1rem !important;
|
||||
}
|
||||
|
||||
.p-toast .p-toast-message {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toast positioning to avoid header conflicts */
|
||||
.p-toast {
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
|
||||
/* Ensure toast notifications don't interfere with header dropdowns */
|
||||
.p-toast[data-position="top-right"] {
|
||||
top: 80px !important; /* Move below header */
|
||||
}
|
||||
|
||||
/* Mobile Dropdown Enhancements */
|
||||
@media (max-width: 768px) {
|
||||
.p-dropdown-panel {
|
||||
max-height: 60vh;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.p-dropdown-item {
|
||||
padding: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Calendar Enhancements */
|
||||
@media (max-width: 768px) {
|
||||
.p-datepicker {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.p-datepicker table td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.p-datepicker table td > span {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-specific utility classes */
|
||||
@media (max-width: 640px) {
|
||||
.mobile-hide {
|
||||
display: none !important;
|
||||
}
|
||||
.mobile-show {
|
||||
display: block !important;
|
||||
}
|
||||
.mobile-flex {
|
||||
display: flex !important;
|
||||
}
|
||||
.mobile-grid {
|
||||
display: grid !important;
|
||||
}
|
||||
|
||||
.mobile-full-width {
|
||||
width: 100% !important;
|
||||
}
|
||||
.mobile-text-center {
|
||||
text-align: center !important;
|
||||
}
|
||||
.mobile-text-left {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.mobile-p-2 {
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
.mobile-p-4 {
|
||||
padding: 1rem !important;
|
||||
}
|
||||
.mobile-m-2 {
|
||||
margin: 0.5rem !important;
|
||||
}
|
||||
.mobile-m-4 {
|
||||
margin: 1rem !important;
|
||||
}
|
||||
|
||||
.mobile-stack {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.mobile-stack > * {
|
||||
width: 100% !important;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mobile-stack > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet-specific styles */
|
||||
@media (min-width: 641px) and (max-width: 1024px) {
|
||||
.tablet-hide {
|
||||
display: none !important;
|
||||
}
|
||||
.tablet-show {
|
||||
display: block !important;
|
||||
}
|
||||
.tablet-flex {
|
||||
display: flex !important;
|
||||
}
|
||||
.tablet-grid {
|
||||
display: grid !important;
|
||||
}
|
||||
|
||||
.tablet-full-width {
|
||||
width: 100% !important;
|
||||
}
|
||||
.tablet-half-width {
|
||||
width: 50% !important;
|
||||
}
|
||||
|
||||
.tablet-text-center {
|
||||
text-align: center !important;
|
||||
}
|
||||
.tablet-text-left {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.tablet-p-3 {
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
.tablet-m-3 {
|
||||
margin: 0.75rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch-friendly enhancements */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
/* Increase touch targets */
|
||||
.p-button,
|
||||
.p-inputtext,
|
||||
.p-dropdown,
|
||||
.p-calendar input {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Remove hover effects on touch devices */
|
||||
.p-button:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Add active states for better touch feedback */
|
||||
.p-button:active {
|
||||
transform: scale(0.98);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.p-datatable .p-datatable-tbody > tr:active {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility improvements for mobile */
|
||||
@media (max-width: 768px) {
|
||||
/* Ensure focus is visible */
|
||||
.p-button:focus,
|
||||
.p-inputtext:focus,
|
||||
.p-dropdown:focus,
|
||||
.p-calendar input:focus {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.p-button,
|
||||
.p-inputtext,
|
||||
.p-dropdown,
|
||||
.p-calendar input {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom mobile components */
|
||||
.mobile-nav-toggle {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-nav-toggle {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-card-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.mobile-card-stack {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-optimized stats cards */
|
||||
.mobile-stat-card {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mobile-stat-card .stat-icon {
|
||||
font-size: 2rem;
|
||||
color: var(--primary-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-stat-card .stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mobile-stat-card .stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.mobile-stat-card .stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
margin: 0.25rem 0 0 0;
|
||||
}
|
||||
|
||||
/* Mobile table alternative */
|
||||
.mobile-list-view .list-item {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mobile-list-view .list-item-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mobile-list-view .list-item-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mobile-list-view .list-item-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mobile-list-view .list-item-actions {
|
||||
flex-shrink: 0;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
/* Swipe gestures support (future enhancement) */
|
||||
.swipe-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.swipe-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -100px;
|
||||
height: 100%;
|
||||
width: 100px;
|
||||
background: var(--red-500);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
transition: right 0.3s ease;
|
||||
}
|
||||
|
||||
.swipe-item.swiped .swipe-actions {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Enhanced Responsive Tables - Prevent text shrinking and add horizontal scroll */
|
||||
@media (max-width: 768px) {
|
||||
/* Container cu scroll orizontal pentru tabele */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 0 -1rem; /* Extend to edges on mobile */
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Dimensiune minimă pentru tabele - Enhanced */
|
||||
.summary-table,
|
||||
.breakdown-table,
|
||||
.dashboard-table,
|
||||
.detailed-table,
|
||||
.p-datatable table {
|
||||
min-width: 600px !important; /* Prevent compression */
|
||||
font-size: 14px !important; /* Minimum readable size */
|
||||
}
|
||||
|
||||
/* Celule tabel - Enhanced */
|
||||
.summary-table td,
|
||||
.summary-table th,
|
||||
.breakdown-table td,
|
||||
.breakdown-table th,
|
||||
.dashboard-table td,
|
||||
.dashboard-table th,
|
||||
.detailed-table td,
|
||||
.detailed-table th {
|
||||
padding: 0.5rem;
|
||||
font-size: 14px !important;
|
||||
white-space: nowrap; /* Prevent text wrapping */
|
||||
min-width: 80px; /* Minimum column width */
|
||||
}
|
||||
|
||||
/* Amount cells should never shrink */
|
||||
.amount-cell {
|
||||
font-size: 14px !important;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Override PrimeVue table font sizes for mobile */
|
||||
.p-datatable .p-datatable-thead > tr > th,
|
||||
.p-datatable .p-datatable-tbody > tr > td {
|
||||
font-size: 14px !important;
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
|
||||
/* Stack controls vertically on mobile */
|
||||
.section-controls {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-controls > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Button groups on mobile */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-group .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Indicator de scroll */
|
||||
.table-container::after {
|
||||
content: '← Scroll orizontal pentru mai multe coloane →';
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: 12px;
|
||||
margin-top: 0.5rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.table-container.scrolled-full::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Ensure table wrappers don't compress */
|
||||
.table-wrapper,
|
||||
.data-table-wrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet-specific improvements */
|
||||
@media (min-width: 641px) and (max-width: 1024px) {
|
||||
.summary-table,
|
||||
.breakdown-table,
|
||||
.detailed-table {
|
||||
font-size: 14px !important; /* Slightly larger on tablet */
|
||||
}
|
||||
|
||||
.summary-table td,
|
||||
.summary-table th,
|
||||
.breakdown-table td,
|
||||
.breakdown-table th {
|
||||
font-size: 14px !important;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra small devices */
|
||||
@media (max-width: 480px) {
|
||||
/* Hide less important columns on very small screens */
|
||||
.breakdown-table th:nth-child(6),
|
||||
.breakdown-table td:nth-child(6),
|
||||
.breakdown-table th:nth-child(7),
|
||||
.breakdown-table td:nth-child(7) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Maintain readable font sizes but slightly smaller */
|
||||
.summary-table,
|
||||
.breakdown-table,
|
||||
.detailed-table {
|
||||
font-size: 13px !important;
|
||||
min-width: 500px !important; /* Slightly smaller minimum on very small screens */
|
||||
}
|
||||
|
||||
.summary-table td,
|
||||
.summary-table th,
|
||||
.breakdown-table td,
|
||||
.breakdown-table th {
|
||||
font-size: 13px !important;
|
||||
padding: 0.4rem;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
/* Stack controls vertically on mobile */
|
||||
.section-controls {
|
||||
flex-direction: column !important;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-controls > * {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Adjust search inputs for mobile */
|
||||
.search-input,
|
||||
.data-type-select {
|
||||
width: 100% !important;
|
||||
font-size: 16px !important; /* Prevent zoom on iOS */
|
||||
min-height: 44px; /* Touch-friendly height */
|
||||
}
|
||||
}
|
||||
259
reports-app/frontend/src/assets/css/utilities/display.css
Normal file
259
reports-app/frontend/src/assets/css/utilities/display.css
Normal file
@@ -0,0 +1,259 @@
|
||||
/* 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; }
|
||||
}
|
||||
135
reports-app/frontend/src/assets/css/utilities/flex.css
Normal file
135
reports-app/frontend/src/assets/css/utilities/flex.css
Normal file
@@ -0,0 +1,135 @@
|
||||
/* 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; }
|
||||
}
|
||||
206
reports-app/frontend/src/assets/css/utilities/spacing.css
Normal file
206
reports-app/frontend/src/assets/css/utilities/spacing.css
Normal file
@@ -0,0 +1,206 @@
|
||||
/* 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); }
|
||||
}
|
||||
137
reports-app/frontend/src/assets/css/utilities/text.css
Normal file
137
reports-app/frontend/src/assets/css/utilities/text.css
Normal file
@@ -0,0 +1,137 @@
|
||||
/* 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); }
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
<template>
|
||||
<div class="company-selector-mini" ref="dropdownContainer">
|
||||
<div class="company-dropdown" ref="dropdown">
|
||||
<button
|
||||
class="company-trigger"
|
||||
@click="toggleDropdown"
|
||||
:aria-expanded="dropdownOpen"
|
||||
aria-label="Select company"
|
||||
title="Alt+Q to quick select"
|
||||
>
|
||||
<div class="company-info">
|
||||
<span class="company-name">{{ selectedCompanyName }}</span>
|
||||
<span class="company-code">{{ selectedCompanyCode }}</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-down" :class="{ 'rotate-180': dropdownOpen }"></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-show="dropdownOpen"
|
||||
class="company-dropdown-panel"
|
||||
:class="{ 'panel-open': dropdownOpen }"
|
||||
>
|
||||
<div class="dropdown-search">
|
||||
<div class="search-wrapper">
|
||||
<i class="pi pi-search search-icon"></i>
|
||||
<input
|
||||
ref="searchInput"
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search companies..."
|
||||
class="search-input"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="company-list">
|
||||
<div
|
||||
v-for="(company, index) in filteredCompanies"
|
||||
:key="company.id_firma || company.id"
|
||||
class="company-item"
|
||||
:class="{
|
||||
active: company.id_firma === selectedCompany?.id_firma,
|
||||
'keyboard-highlighted': isHighlighted(index)
|
||||
}"
|
||||
@click="selectCompany(company)"
|
||||
@mouseenter="highlightedIndex = index"
|
||||
>
|
||||
<div class="company-details">
|
||||
<div class="company-main-name">{{ company.name }}</div>
|
||||
<div class="company-sub-info">
|
||||
<span class="company-cui">CUI: {{ company.fiscal_code }}</span>
|
||||
<span class="company-separator">•</span>
|
||||
<span class="company-status" :class="company.status">{{ company.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<i v-if="company.id_firma === selectedCompany?.id_firma" class="pi pi-check company-selected-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredCompanies.length === 0" class="no-results">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
<span>No companies found</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { useCompanyStore } from '../../stores/companies'
|
||||
|
||||
export default {
|
||||
name: 'CompanySelectorMini',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'company-changed'],
|
||||
setup(props, { emit }) {
|
||||
const companiesStore = useCompanyStore()
|
||||
const dropdown = ref(null)
|
||||
const dropdownContainer = ref(null)
|
||||
const searchInput = ref(null)
|
||||
const dropdownOpen = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const highlightedIndex = ref(-1)
|
||||
|
||||
const selectedCompany = computed({
|
||||
get: () => props.modelValue || companiesStore.selectedCompany,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value)
|
||||
companiesStore.setSelectedCompany(value)
|
||||
}
|
||||
})
|
||||
|
||||
const selectedCompanyName = computed(() => {
|
||||
return selectedCompany.value?.name || 'Select Company'
|
||||
})
|
||||
|
||||
const selectedCompanyCode = computed(() => {
|
||||
return selectedCompany.value?.fiscal_code ? `CUI: ${selectedCompany.value.fiscal_code}` : ''
|
||||
})
|
||||
|
||||
const filteredCompanies = computed(() => {
|
||||
const companies = companiesStore.companies || []
|
||||
if (!searchQuery.value || searchQuery.value.trim() === '') {
|
||||
return companies
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
return companies.filter(company =>
|
||||
company.name?.toLowerCase().includes(query) ||
|
||||
company.fiscal_code?.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
const toggleDropdown = async () => {
|
||||
dropdownOpen.value = !dropdownOpen.value
|
||||
if (dropdownOpen.value) {
|
||||
searchQuery.value = ''
|
||||
highlightedIndex.value = -1
|
||||
// Focus on search input after dropdown opens
|
||||
await nextTick()
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const closeDropdown = () => {
|
||||
dropdownOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
const selectCompany = (company) => {
|
||||
selectedCompany.value = company
|
||||
emit('company-changed', company)
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
const scrollToHighlighted = () => {
|
||||
nextTick(() => {
|
||||
const highlightedElement = document.querySelector('.company-item.keyboard-highlighted')
|
||||
if (highlightedElement) {
|
||||
highlightedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (!dropdownOpen.value || filteredCompanies.value.length === 0) return
|
||||
|
||||
switch(event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
highlightedIndex.value = (highlightedIndex.value + 1) % filteredCompanies.value.length
|
||||
scrollToHighlighted()
|
||||
break
|
||||
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
if (highlightedIndex.value <= 0) {
|
||||
highlightedIndex.value = filteredCompanies.value.length - 1
|
||||
} else {
|
||||
highlightedIndex.value--
|
||||
}
|
||||
scrollToHighlighted()
|
||||
break
|
||||
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
if (highlightedIndex.value >= 0 && highlightedIndex.value < filteredCompanies.value.length) {
|
||||
selectCompany(filteredCompanies.value[highlightedIndex.value])
|
||||
}
|
||||
break
|
||||
|
||||
case 'Escape':
|
||||
closeDropdown()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const isHighlighted = (index) => {
|
||||
return index === highlightedIndex.value
|
||||
}
|
||||
|
||||
const openWithShortcut = async () => {
|
||||
// Scroll to selector
|
||||
if (dropdownContainer.value) {
|
||||
dropdownContainer.value.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
// Wait for scroll to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
|
||||
// Open dropdown and focus
|
||||
if (!dropdownOpen.value) {
|
||||
dropdownOpen.value = true
|
||||
highlightedIndex.value = -1
|
||||
searchQuery.value = ''
|
||||
await nextTick()
|
||||
searchInput.value?.focus()
|
||||
} else {
|
||||
// If already open, just focus
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleGlobalKeyDown = (event) => {
|
||||
// Check for Alt+Q (left-hand shortcut)
|
||||
if (event.altKey && event.key === 'q') {
|
||||
event.preventDefault()
|
||||
openWithShortcut()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdown.value && !dropdown.value.contains(event.target)) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for search query changes and reset highlighted index
|
||||
watch(searchQuery, () => {
|
||||
highlightedIndex.value = -1
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('keydown', handleGlobalKeyDown)
|
||||
// Load companies if not already loaded
|
||||
if (companiesStore.companies.length === 0) {
|
||||
companiesStore.loadCompanies()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleGlobalKeyDown)
|
||||
})
|
||||
|
||||
return {
|
||||
dropdown,
|
||||
dropdownContainer,
|
||||
searchInput,
|
||||
dropdownOpen,
|
||||
searchQuery,
|
||||
highlightedIndex,
|
||||
selectedCompany,
|
||||
selectedCompanyName,
|
||||
selectedCompanyCode,
|
||||
filteredCompanies,
|
||||
toggleDropdown,
|
||||
closeDropdown,
|
||||
selectCompany,
|
||||
handleKeyDown,
|
||||
isHighlighted
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.company-selector-mini {
|
||||
position: relative;
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
.company-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.company-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.company-trigger:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.company-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.company-name {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.company-code {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.pi-chevron-down {
|
||||
transition: transform var(--transition-fast);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.company-dropdown-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: var(--z-dropdown);
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.panel-open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dropdown-search {
|
||||
padding: var(--space-sm);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: var(--space-sm);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-sm) var(--space-sm) var(--space-xl);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.company-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.company-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-md);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.company-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.company-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.company-item.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.company-item.keyboard-highlighted {
|
||||
background: var(--color-bg-secondary);
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.company-item.active.keyboard-highlighted {
|
||||
/* When both active and highlighted, outline with semi-transparent white */
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.company-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.company-main-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.company-sub-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-xs);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.company-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.company-status.active {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.company-status.inactive {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.company-selected-icon {
|
||||
color: inherit;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-xl);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.company-selector-mini {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.company-trigger {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.company-dropdown-panel {
|
||||
left: -16px;
|
||||
right: -16px;
|
||||
width: calc(100% + 32px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,658 @@
|
||||
<template>
|
||||
<div class="detailed-data-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Date Detaliate</h2>
|
||||
<div class="section-controls">
|
||||
<!-- Selector tip date -->
|
||||
<select v-model="selectedType" @change="loadData" class="data-type-select">
|
||||
<option value="clients">Clienți</option>
|
||||
<option value="suppliers">Furnizori</option>
|
||||
<option value="treasury">Trezorerie</option>
|
||||
</select>
|
||||
|
||||
<!-- Căutare -->
|
||||
<div class="search-wrapper">
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
@input="handleSearch"
|
||||
type="text"
|
||||
placeholder="Căutare..."
|
||||
class="search-input"
|
||||
>
|
||||
<i class="pi pi-search"></i>
|
||||
</div>
|
||||
|
||||
<!-- Export buttons -->
|
||||
<button @click="exportExcel" class="btn btn-sm btn-outline">
|
||||
<i class="pi pi-file-excel"></i> Excel
|
||||
</button>
|
||||
<button @click="exportPDF" class="btn btn-sm btn-outline">
|
||||
<i class="pi pi-file-pdf"></i> PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabel cu date -->
|
||||
<div class="table-container">
|
||||
<table class="detailed-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in displayColumns" :key="column.field">
|
||||
{{ column.header }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-if="selectedType === 'treasury'">
|
||||
<!-- Treasury - normal table without grouping -->
|
||||
<tr v-for="row in paginatedData" :key="row.id">
|
||||
<td v-for="column in displayColumns" :key="column.field">
|
||||
{{ formatValue(row[column.field], column.type) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody v-else>
|
||||
<!-- Clients/Suppliers - grouped with expand/collapse -->
|
||||
<template v-for="group in paginatedGroups" :key="group.name">
|
||||
<!-- Single invoice: show direct row -->
|
||||
<tr v-if="group.facturi.length === 1"
|
||||
class="single-invoice-row"
|
||||
:class="{ 'row-restant': group.hasRestant }">
|
||||
<td><strong>{{ group.name }}</strong></td>
|
||||
<td>{{ group.facturi[0].numar_document }}</td>
|
||||
<td>{{ formatValue(group.facturi[0].data_document, 'date') }}</td>
|
||||
<td>{{ formatValue(group.facturi[0].data_scadenta, 'date') }}</td>
|
||||
<td>{{ formatValue(group.facturi[0].facturat, 'currency') }}</td>
|
||||
<td>{{ formatValue(group.facturi[0][selectedType === 'clients' ? 'incasat' : 'achitat'], 'currency') }}</td>
|
||||
<td :class="{ 'sold-restant': group.facturi[0].status === 'Restant' }">{{ formatValue(group.facturi[0].sold, 'currency') }}</td>
|
||||
</tr>
|
||||
|
||||
<!-- Multiple invoices: show expand/collapse -->
|
||||
<template v-else>
|
||||
<!-- Group row (client/supplier header with subtotal) -->
|
||||
<tr
|
||||
class="group-row"
|
||||
:class="{ 'has-restant': group.hasRestant }"
|
||||
@click="toggleClient(group.name)"
|
||||
>
|
||||
<td class="group-name-cell">
|
||||
<strong>{{ group.name }}</strong>
|
||||
<span class="facturi-count">({{ group.facturi.length }})</span>
|
||||
</td>
|
||||
<td colspan="5"></td>
|
||||
<td class="subtotal-cell" :class="{ 'sold-restant': group.hasRestant }">
|
||||
<strong>{{ formatValue(group.totalSold, 'currency') }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Detail rows (invoices) - only if expanded -->
|
||||
<template v-if="isExpanded(group.name)">
|
||||
<tr
|
||||
v-for="(factura, idx) in group.facturi"
|
||||
:key="`${group.name}-${idx}`"
|
||||
class="detail-row"
|
||||
:class="getRowClass(factura)"
|
||||
>
|
||||
<td class="detail-name">{{ factura.client || factura.furnizor || '' }}</td>
|
||||
<td>{{ factura.numar_document }}</td>
|
||||
<td>{{ formatValue(factura.data_document, 'date') }}</td>
|
||||
<td>{{ formatValue(factura.data_scadenta, 'date') }}</td>
|
||||
<td>{{ formatValue(factura[selectedType === 'clients' ? 'facturat' : 'facturat'], 'currency') }}</td>
|
||||
<td>{{ formatValue(factura[selectedType === 'clients' ? 'incasat' : 'achitat'], 'currency') }}</td>
|
||||
<td :class="{ 'sold-restant': factura.status === 'Restant' }">{{ formatValue(factura.sold, 'currency') }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="totals-row">
|
||||
<td><strong>TOTAL</strong></td>
|
||||
<td v-for="column in displayColumns.slice(1)" :key="column.field">
|
||||
<strong v-if="column.showTotal">
|
||||
{{ formatValue(calculateTotal(column.field), column.type) }}
|
||||
</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Paginare -->
|
||||
<div class="pagination-wrapper">
|
||||
<Paginator
|
||||
:rows="rowsPerPage"
|
||||
:totalRecords="totalRecords"
|
||||
v-model:first="firstRow"
|
||||
:rowsPerPageOptions="[10, 25, 50, 100]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useCompanyStore } from '@/stores/companies'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import Paginator from 'primevue/paginator'
|
||||
import * as XLSX from 'xlsx'
|
||||
import jsPDF from 'jspdf'
|
||||
import 'jspdf-autotable'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const companyStore = useCompanyStore()
|
||||
const toast = useToast()
|
||||
|
||||
// State
|
||||
const selectedType = ref('clients')
|
||||
const searchTerm = ref('')
|
||||
const data = ref([])
|
||||
const firstRow = ref(0)
|
||||
const rowsPerPage = ref(25)
|
||||
const expandedClients = ref(new Set())
|
||||
|
||||
// Columns configuration based on type
|
||||
const columns = computed(() => {
|
||||
switch(selectedType.value) {
|
||||
case 'clients':
|
||||
return [
|
||||
{ field: 'client', header: 'Client', type: 'text' },
|
||||
{ field: 'numar_document', header: 'Nr. Document', type: 'text' },
|
||||
{ field: 'data_document', header: 'Data Document', type: 'date' },
|
||||
{ field: 'data_scadenta', header: 'Data Scadență', type: 'date' },
|
||||
{ field: 'facturat', header: 'Facturat', type: 'currency', showTotal: true },
|
||||
{ field: 'incasat', header: 'Încasat', type: 'currency', showTotal: true },
|
||||
{ field: 'sold', header: 'Sold', type: 'currency', showTotal: true }
|
||||
]
|
||||
case 'suppliers':
|
||||
return [
|
||||
{ field: 'furnizor', header: 'Furnizor', type: 'text' },
|
||||
{ field: 'numar_document', header: 'Nr. Document', type: 'text' },
|
||||
{ field: 'data_document', header: 'Data Document', type: 'date' },
|
||||
{ field: 'data_scadenta', header: 'Data Scadență', type: 'date' },
|
||||
{ field: 'facturat', header: 'Facturat', type: 'currency', showTotal: true },
|
||||
{ field: 'achitat', header: 'Achitat', type: 'currency', showTotal: true },
|
||||
{ field: 'sold', header: 'Sold', type: 'currency', showTotal: true }
|
||||
]
|
||||
case 'treasury':
|
||||
return [
|
||||
{ field: 'cont', header: 'Cont', type: 'text' },
|
||||
{ field: 'nume_cont', header: 'Nume Cont', type: 'text' },
|
||||
{ field: 'sold', header: 'Sold', type: 'currency', showTotal: true },
|
||||
{ field: 'valuta', header: 'Valută', type: 'text' },
|
||||
{ field: 'tip', header: 'Tip', type: 'text' }
|
||||
]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// Display columns for header (without first column for grouped tables)
|
||||
const displayColumns = computed(() => {
|
||||
if (selectedType.value === 'treasury') {
|
||||
return columns.value
|
||||
}
|
||||
// For clients/suppliers, keep all columns in header
|
||||
return columns.value
|
||||
})
|
||||
|
||||
// Filtered data based on search
|
||||
const filteredData = computed(() => {
|
||||
if (!searchTerm.value) return data.value
|
||||
|
||||
return data.value.filter(row => {
|
||||
return Object.values(row).some(val =>
|
||||
String(val).toLowerCase().includes(searchTerm.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Group data by client/supplier
|
||||
const groupedData = computed(() => {
|
||||
if (selectedType.value === 'treasury') {
|
||||
return []
|
||||
}
|
||||
|
||||
const groups = {}
|
||||
const nameField = selectedType.value === 'clients' ? 'client' : 'furnizor'
|
||||
|
||||
filteredData.value.forEach(row => {
|
||||
const clientName = row[nameField]
|
||||
if (!clientName) return
|
||||
|
||||
if (!groups[clientName]) {
|
||||
groups[clientName] = {
|
||||
name: clientName,
|
||||
facturi: [],
|
||||
totalSold: 0,
|
||||
hasRestant: false
|
||||
}
|
||||
}
|
||||
|
||||
groups[clientName].facturi.push(row)
|
||||
groups[clientName].totalSold += (row.sold || 0)
|
||||
if (row.status === 'Restant') {
|
||||
groups[clientName].hasRestant = true
|
||||
}
|
||||
})
|
||||
|
||||
return Object.values(groups)
|
||||
})
|
||||
|
||||
// Paginated groups
|
||||
const paginatedGroups = computed(() => {
|
||||
if (selectedType.value === 'treasury') {
|
||||
return []
|
||||
}
|
||||
const start = firstRow.value
|
||||
const end = start + rowsPerPage.value
|
||||
return groupedData.value.slice(start, end)
|
||||
})
|
||||
|
||||
// Paginated data (for treasury)
|
||||
const paginatedData = computed(() => {
|
||||
if (selectedType.value !== 'treasury') {
|
||||
return []
|
||||
}
|
||||
const end = firstRow.value + rowsPerPage.value
|
||||
return filteredData.value.slice(firstRow.value, end)
|
||||
})
|
||||
|
||||
// Total records for paginator
|
||||
const totalRecords = computed(() => {
|
||||
if (selectedType.value === 'treasury') {
|
||||
return filteredData.value.length
|
||||
}
|
||||
return groupedData.value.length
|
||||
})
|
||||
|
||||
// Expand/collapse functions
|
||||
const toggleClient = (clientName) => {
|
||||
if (expandedClients.value.has(clientName)) {
|
||||
expandedClients.value.delete(clientName)
|
||||
} else {
|
||||
expandedClients.value.add(clientName)
|
||||
}
|
||||
}
|
||||
|
||||
const isExpanded = (clientName) => {
|
||||
return expandedClients.value.has(clientName)
|
||||
}
|
||||
|
||||
const getRowClass = (row) => {
|
||||
if (row.status === 'Restant') return 'row-restant'
|
||||
return 'row-in-termen'
|
||||
}
|
||||
|
||||
// Methods
|
||||
const loadData = async () => {
|
||||
try {
|
||||
if (!companyStore.selectedCompany) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Atenție',
|
||||
detail: 'Vă rugăm să selectați o companie',
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const response = await dashboardStore.loadDetailedData(
|
||||
selectedType.value,
|
||||
companyStore.selectedCompany.id_firma
|
||||
)
|
||||
data.value = response.data
|
||||
// Reset expanded state when loading new data
|
||||
expandedClients.value.clear()
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: 'Nu s-au putut încărca datele detaliate'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formatValue = (value, type) => {
|
||||
switch(type) {
|
||||
case 'currency':
|
||||
return new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON'
|
||||
}).format(value || 0)
|
||||
case 'date':
|
||||
if (!value) return '-'
|
||||
// Handle Oracle date format (YYYY-MM-DD or Date object)
|
||||
const date = new Date(value)
|
||||
if (isNaN(date.getTime())) return value // Return original if invalid
|
||||
return date.toLocaleDateString('ro-RO', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
case 'badge':
|
||||
return value
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const calculateTotal = (field) => {
|
||||
return filteredData.value.reduce((sum, row) => sum + (row[field] || 0), 0)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
firstRow.value = 0 // Reset pagination on search
|
||||
expandedClients.value.clear() // Reset expanded state on search
|
||||
}
|
||||
|
||||
const exportExcel = () => {
|
||||
const ws = XLSX.utils.json_to_sheet(filteredData.value)
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, selectedType.value)
|
||||
XLSX.writeFile(wb, `${selectedType.value}_${new Date().toISOString()}.xlsx`)
|
||||
}
|
||||
|
||||
const exportPDF = () => {
|
||||
const doc = new jsPDF()
|
||||
const tableColumns = columns.value.map(c => c.header)
|
||||
const tableRows = filteredData.value.map(row =>
|
||||
columns.value.map(c => formatValue(row[c.field], c.type))
|
||||
)
|
||||
|
||||
doc.autoTable({
|
||||
head: [tableColumns],
|
||||
body: tableRows,
|
||||
theme: 'grid'
|
||||
})
|
||||
|
||||
doc.save(`${selectedType.value}_${new Date().toISOString()}.pdf`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
watch(selectedType, () => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
// Watch for company changes to reload data
|
||||
watch(() => companyStore.selectedCompany, (newCompany) => {
|
||||
if (newCompany) {
|
||||
loadData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detailed-data-section {
|
||||
margin-top: 2rem;
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.section-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.data-type-select {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-primary);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 0.5rem 2.5rem 0.5rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-primary);
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.search-wrapper i {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--color-bg-muted);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.detailed-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 600px;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.detailed-table th,
|
||||
.detailed-table td {
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.detailed-table th {
|
||||
background: #ffffff;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.025em;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Group row styling */
|
||||
.group-row {
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
border-top: 1px solid var(--color-border);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.group-row:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.group-row.has-restant:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Single invoice row styling */
|
||||
.single-invoice-row {
|
||||
background: #ffffff;
|
||||
font-weight: 400;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.single-invoice-row:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.single-invoice-row.row-restant:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.single-invoice-row td:first-child {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.group-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.facturi-count {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.subtotal-cell {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Detail row styling */
|
||||
.detail-row {
|
||||
font-size: 11px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
padding-left: 1.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.detail-row.row-restant:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.detail-row.row-in-termen:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Sold restant - only color the amount text */
|
||||
.sold-restant {
|
||||
color: rgb(239, 68, 68);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detailed-table tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.totals-row {
|
||||
background: #f8f9fa !important;
|
||||
border-top: 2px solid var(--color-border) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.section-controls {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin: 1rem -1rem;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.detailed-table th,
|
||||
.detailed-table td {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.section-controls {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-controls > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
316
reports-app/frontend/src/components/dashboard/TrendChart.vue
Normal file
316
reports-app/frontend/src/components/dashboard/TrendChart.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<div class="trend-chart">
|
||||
<canvas
|
||||
ref="chartCanvas"
|
||||
:width="width"
|
||||
:height="height"
|
||||
class="chart-canvas"
|
||||
></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import {
|
||||
Chart,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
BarElement,
|
||||
LineController,
|
||||
BarController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
BarElement,
|
||||
LineController,
|
||||
BarController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
)
|
||||
|
||||
// Props definition
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({
|
||||
labels: [],
|
||||
datasets: []
|
||||
})
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'line',
|
||||
validator: (value) => ['line', 'bar', 'area'].includes(value)
|
||||
},
|
||||
compare: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 400
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 200
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
// Refs
|
||||
const chartCanvas = ref(null)
|
||||
const chartInstance = ref(null)
|
||||
|
||||
// Romanian currency formatter
|
||||
const formatCurrency = (value) => {
|
||||
return new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
// Chart configuration
|
||||
const getChartConfig = () => {
|
||||
const chartType = props.type === 'area' ? 'line' : props.type
|
||||
|
||||
const config = {
|
||||
type: chartType,
|
||||
data: {
|
||||
labels: props.data.labels || [],
|
||||
datasets: (props.data.datasets || []).map((dataset, index) => {
|
||||
const baseConfig = {
|
||||
...dataset,
|
||||
borderWidth: props.type === 'line' || props.type === 'area' ? 2 : 0,
|
||||
pointBackgroundColor: dataset.borderColor || dataset.backgroundColor,
|
||||
pointBorderColor: dataset.borderColor || dataset.backgroundColor,
|
||||
pointRadius: props.type === 'line' || props.type === 'area' ? 4 : 0,
|
||||
pointHoverRadius: props.type === 'line' || props.type === 'area' ? 6 : 0
|
||||
}
|
||||
|
||||
// Area chart specific configuration
|
||||
if (props.type === 'area') {
|
||||
baseConfig.fill = true
|
||||
baseConfig.backgroundColor = dataset.backgroundColor ||
|
||||
(dataset.borderColor ? dataset.borderColor.replace('rgb', 'rgba').replace(')', ', 0.1)') : 'rgba(54, 162, 235, 0.1)')
|
||||
}
|
||||
|
||||
// Bar chart specific configuration
|
||||
if (props.type === 'bar') {
|
||||
baseConfig.borderRadius = 4
|
||||
baseConfig.borderSkipped = false
|
||||
}
|
||||
|
||||
return baseConfig
|
||||
})
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: props.compare,
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
let label = context.dataset.label || ''
|
||||
if (label) {
|
||||
label += ': '
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += formatCurrency(context.parsed.y)
|
||||
}
|
||||
return label
|
||||
}
|
||||
},
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
color: '#6b7280'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
color: '#6b7280',
|
||||
callback: function(value) {
|
||||
return formatCurrency(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
hover: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
// Merge with custom options
|
||||
...props.options
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// Create chart instance
|
||||
const createChart = () => {
|
||||
if (!chartCanvas.value) return
|
||||
|
||||
const config = getChartConfig()
|
||||
|
||||
// Deep clone the entire config to break Vue reactivity circular references
|
||||
const clonedConfig = JSON.parse(JSON.stringify(config))
|
||||
|
||||
chartInstance.value = new Chart(chartCanvas.value, clonedConfig)
|
||||
}
|
||||
|
||||
// Destroy chart instance
|
||||
const destroyChart = () => {
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.destroy()
|
||||
chartInstance.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Update chart data
|
||||
const updateChart = () => {
|
||||
if (!chartInstance.value) return
|
||||
|
||||
const config = getChartConfig()
|
||||
|
||||
// Deep clone the data to break Vue reactivity circular references
|
||||
const clonedData = JSON.parse(JSON.stringify(config.data))
|
||||
|
||||
// Update data
|
||||
chartInstance.value.data = clonedData
|
||||
|
||||
// Update options (clone options too to be safe)
|
||||
chartInstance.value.options = JSON.parse(JSON.stringify(config.options))
|
||||
|
||||
// Re-render
|
||||
chartInstance.value.update('none')
|
||||
}
|
||||
|
||||
// Recreate chart completely
|
||||
const recreateChart = async () => {
|
||||
destroyChart()
|
||||
await nextTick()
|
||||
createChart()
|
||||
}
|
||||
|
||||
// Watch for prop changes
|
||||
watch(
|
||||
() => [props.data, props.type, props.compare, props.options],
|
||||
async (newValues, oldValues) => {
|
||||
// Skip if chart is not initialized
|
||||
if (!chartInstance.value) return
|
||||
|
||||
// If chart type changed, recreate completely
|
||||
if (newValues[1] !== oldValues[1]) {
|
||||
await recreateChart()
|
||||
} else {
|
||||
// Otherwise just update
|
||||
updateChart()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
createChart()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroyChart()
|
||||
})
|
||||
|
||||
// Expose methods for parent components
|
||||
defineExpose({
|
||||
updateChart,
|
||||
recreateChart,
|
||||
chartInstance: () => chartInstance.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trend-chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.trend-chart {
|
||||
min-height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.trend-chart {
|
||||
min-height: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,742 @@
|
||||
<template>
|
||||
<div class="cashflow-card">
|
||||
<!-- Card Header -->
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<h3 class="card-title">📅 Cash Flow Previzionat</h3>
|
||||
<div class="period-selector">
|
||||
<select
|
||||
v-model="selectedPeriod"
|
||||
@change="handlePeriodChange"
|
||||
class="period-select"
|
||||
>
|
||||
<option value="7d">Următoarele 7 zile</option>
|
||||
<option value="1m">Următoarea lună</option>
|
||||
<option value="3m">Următoarele 3 luni</option>
|
||||
<option value="6m">Următoarele 6 luni</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Se încarcă previziunea cash flow...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<p>{{ error }}</p>
|
||||
<button @click="loadCashFlowData" class="retry-btn">Încearcă din nou</button>
|
||||
</div>
|
||||
|
||||
<!-- Cash Flow Content -->
|
||||
<div v-else class="cashflow-content">
|
||||
<!-- Chart Container -->
|
||||
<div class="cashflow-bars" v-if="chartData && chartData.periods.length > 0">
|
||||
<div class="chart-header">
|
||||
<div class="chart-legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-color inflow"></span>
|
||||
<span class="legend-label">Încasări</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color outflow"></span>
|
||||
<span class="legend-label">Plăți</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart.js Canvas -->
|
||||
<div class="chart-canvas-container">
|
||||
<canvas
|
||||
ref="cashflowChart"
|
||||
v-if="chartData?.periods?.length"
|
||||
width="400"
|
||||
height="200"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="empty-state">
|
||||
<div class="empty-icon">📊</div>
|
||||
<p>Nu există date de cash flow pentru perioada selectată</p>
|
||||
</div>
|
||||
|
||||
<!-- Cash Flow Summary -->
|
||||
<div v-if="chartData" class="cashflow-summary">
|
||||
<div class="summary-row">
|
||||
<div class="summary-item net-flow" :class="getNetFlowClass(chartData.netTotal)">
|
||||
<span class="summary-label">Net Total:</span>
|
||||
<span class="summary-value">{{ formatCurrency(chartData.netTotal) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Critical Days Warnings -->
|
||||
<div v-if="chartData.criticalDays && chartData.criticalDays.length > 0" class="warnings">
|
||||
<div class="warning-header">
|
||||
<span class="warning-icon">⚠️</span>
|
||||
<span class="warning-title">Zile Critice</span>
|
||||
</div>
|
||||
<div class="critical-days">
|
||||
<span
|
||||
v-for="day in chartData.criticalDays"
|
||||
:key="day"
|
||||
class="critical-day"
|
||||
>
|
||||
{{ day }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
import { useDashboardStore } from '../../../stores/dashboard'
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(...registerables)
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
companyId: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['periodChanged'])
|
||||
|
||||
// Store
|
||||
const dashboardStore = useDashboardStore()
|
||||
|
||||
// State
|
||||
const selectedPeriod = ref('7d')
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
const chartData = ref(null)
|
||||
const cashflowChart = ref(null)
|
||||
const chartInstance = ref(null)
|
||||
|
||||
// Computed
|
||||
const maxValue = computed(() => {
|
||||
if (!chartData.value) return 1
|
||||
|
||||
const allValues = [
|
||||
...chartData.value.inflows,
|
||||
...chartData.value.outflows.map(Math.abs)
|
||||
].filter(v => v > 0)
|
||||
|
||||
return Math.max(...allValues, 1)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount && amount !== 0) return '0,00 RON'
|
||||
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
|
||||
if (isNaN(numAmount)) return '0,00 RON'
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(numAmount)
|
||||
} catch (error) {
|
||||
return `${numAmount.toLocaleString('ro-RO', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} RON`
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrencyShort = (amount) => {
|
||||
if (!amount && amount !== 0) return '0'
|
||||
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
|
||||
if (isNaN(numAmount)) return '0'
|
||||
|
||||
const absAmount = Math.abs(numAmount)
|
||||
|
||||
if (absAmount >= 1000000) {
|
||||
return `${(numAmount / 1000000).toFixed(1)}M`
|
||||
} else if (absAmount >= 1000) {
|
||||
return `${(numAmount / 1000).toFixed(0)}k`
|
||||
}
|
||||
|
||||
return numAmount.toLocaleString('ro-RO', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
||||
}
|
||||
|
||||
const initializeChart = async () => {
|
||||
if (!cashflowChart.value || !chartData.value) return
|
||||
|
||||
// Destroy existing chart instance
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.destroy()
|
||||
chartInstance.value = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const ctx = cashflowChart.value.getContext('2d')
|
||||
|
||||
chartInstance.value = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: chartData.value.periods,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Încasări',
|
||||
data: chartData.value.inflows,
|
||||
borderColor: 'rgb(34, 197, 94)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: 'rgb(34, 197, 94)',
|
||||
pointBorderColor: '#ffffff',
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7
|
||||
},
|
||||
{
|
||||
label: 'Plăți',
|
||||
data: chartData.value.outflows.map(Math.abs),
|
||||
borderColor: 'rgb(239, 68, 68)',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: 'rgb(239, 68, 68)',
|
||||
pointBorderColor: '#ffffff',
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7
|
||||
},
|
||||
{
|
||||
label: 'Net Flow',
|
||||
data: chartData.value.netFlow,
|
||||
borderColor: 'rgb(99, 102, 241)',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: 'rgb(99, 102, 241)',
|
||||
pointBorderColor: '#ffffff',
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
font: {
|
||||
size: 12,
|
||||
weight: '500'
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 8,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.dataset.label
|
||||
const value = context.parsed.y
|
||||
return `${label}: ${formatCurrency(value)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
color: 'rgba(107, 114, 128, 0.8)'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
grid: {
|
||||
color: 'rgba(107, 114, 128, 0.1)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
color: 'rgba(107, 114, 128, 0.8)',
|
||||
callback: function(value) {
|
||||
return formatCurrencyShort(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
borderJoinStyle: 'round'
|
||||
},
|
||||
point: {
|
||||
hoverBorderWidth: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getNetFlowClass = (amount) => {
|
||||
if (!amount && amount !== 0) return 'neutral'
|
||||
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
|
||||
return numAmount > 0 ? 'positive' : numAmount < 0 ? 'negative' : 'neutral'
|
||||
}
|
||||
|
||||
const handlePeriodChange = () => {
|
||||
emit('periodChanged', selectedPeriod.value)
|
||||
loadCashFlowData()
|
||||
}
|
||||
|
||||
const loadCashFlowData = async () => {
|
||||
if (!props.companyId) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const result = await dashboardStore.loadCashFlowData(props.companyId, selectedPeriod.value)
|
||||
|
||||
if (result.success) {
|
||||
chartData.value = result.data
|
||||
await nextTick()
|
||||
initializeChart()
|
||||
} else {
|
||||
error.value = result.error || 'Nu s-au putut încărca datele'
|
||||
// Fallback to mock data for development
|
||||
chartData.value = generateMockData()
|
||||
await nextTick()
|
||||
initializeChart()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading cash flow data:', err)
|
||||
error.value = 'Eroare la încărcarea datelor'
|
||||
// Fallback to mock data for development
|
||||
chartData.value = generateMockData()
|
||||
await nextTick()
|
||||
initializeChart()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const generateMockData = () => {
|
||||
const periods = {
|
||||
'7d': ['Luni', 'Marți', 'Miercuri', 'Joi', 'Vineri', 'Sâmbătă', 'Duminică'],
|
||||
'1m': ['S1', 'S2', 'S3', 'S4'],
|
||||
'3m': ['Luna 1', 'Luna 2', 'Luna 3'],
|
||||
'6m': ['Trim 1', 'Trim 2']
|
||||
}
|
||||
|
||||
const periodLabels = periods[selectedPeriod.value] || periods['7d']
|
||||
const inflows = periodLabels.map(() => Math.random() * 500000 + 100000)
|
||||
const outflows = periodLabels.map(() => -(Math.random() * 400000 + 50000))
|
||||
const netFlow = inflows.map((inflow, i) => inflow + outflows[i])
|
||||
const cumulative = netFlow.reduce((acc, val, i) => {
|
||||
acc.push((acc[i - 1] || 0) + val)
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const criticalDays = netFlow
|
||||
.map((net, i) => net < -50000 ? periodLabels[i] : null)
|
||||
.filter(Boolean)
|
||||
|
||||
return {
|
||||
periods: periodLabels,
|
||||
inflows,
|
||||
outflows,
|
||||
netFlow,
|
||||
cumulative,
|
||||
criticalDays,
|
||||
netTotal: netFlow.reduce((sum, val) => sum + val, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(() => props.companyId, (newId) => {
|
||||
if (newId) {
|
||||
loadCashFlowData()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(chartData, (newData) => {
|
||||
if (newData) {
|
||||
nextTick(() => {
|
||||
initializeChart()
|
||||
})
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (props.companyId) {
|
||||
loadCashFlowData()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance.value) {
|
||||
chartInstance.value.destroy()
|
||||
chartInstance.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cashflow-card {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--card-radius);
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-fast);
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cashflow-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Card Header */
|
||||
.card-header {
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.period-select {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.period-select:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.period-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
/* Loading and Error States */
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xl);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.error-icon,
|
||||
.empty-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: background-color var(--transition-fast);
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
/* Cash Flow Content */
|
||||
.cashflow-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Chart */
|
||||
.cashflow-bars {
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.legend-color.inflow {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.legend-color.outflow {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Chart.js Container */
|
||||
.chart-canvas-container {
|
||||
position: relative;
|
||||
height: 250px;
|
||||
padding: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.chart-canvas-container canvas {
|
||||
max-width: 100%;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Cash Flow Summary */
|
||||
.cashflow-summary {
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
background: var(--color-bg-secondary);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.summary-item.positive {
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.summary-item.negative {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.summary-item.neutral {
|
||||
background: var(--color-bg-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Warnings */
|
||||
.warnings {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.warning-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.warning-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.critical-days {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.critical-day {
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-canvas-container {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cashflow-card {
|
||||
min-height: 350px;
|
||||
}
|
||||
|
||||
.card-header,
|
||||
.cashflow-bars,
|
||||
.cashflow-summary {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.chart-canvas-container {
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.chart-legend {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.chart-canvas-container {
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.critical-days {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,715 @@
|
||||
<template>
|
||||
<div class="cashflow-card">
|
||||
<!-- Main values section - Split layout (Încasări | Plăți) -->
|
||||
<div class="values-section">
|
||||
<!-- Încasări Section -->
|
||||
<div class="value-block inflows">
|
||||
<div class="value-label">Încasări</div>
|
||||
<div class="value-amount positive">
|
||||
{{ formatCurrency(inflowsValue) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Plăți Section -->
|
||||
<div class="value-block outflows">
|
||||
<div class="value-label">Plăți</div>
|
||||
<div class="value-amount negative">
|
||||
{{ formatCurrency(outflowsValue) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dual sparkline charts - stacked vertical -->
|
||||
<div class="sparkline-dual-container" v-if="hasSparklineData">
|
||||
<!-- Grafic Încasări -->
|
||||
<div class="sparkline-wrapper">
|
||||
<div class="sparkline-label">Încasări</div>
|
||||
<div class="sparkline-chart">
|
||||
<canvas ref="inflowsCanvas" class="sparkline-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grafic Plăți -->
|
||||
<div class="sparkline-wrapper">
|
||||
<div class="sparkline-label">Plăți</div>
|
||||
<div class="sparkline-chart">
|
||||
<canvas ref="outflowsCanvas" class="sparkline-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
const props = defineProps({
|
||||
inflowsValue: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
outflowsValue: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
inflowsTrend: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
outflowsTrend: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
inflowsSparkline: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
outflowsSparkline: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
inflowsPreviousSparkline: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
outflowsPreviousSparkline: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
sparklineLabels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
previousSparklineLabels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
// Refs pentru 2 canvas-uri separate
|
||||
const inflowsCanvas = ref(null)
|
||||
const outflowsCanvas = ref(null)
|
||||
let inflowsChartInstance = null
|
||||
let outflowsChartInstance = null
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount && amount !== 0) return '0 RON'
|
||||
return new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(Math.abs(amount))
|
||||
}
|
||||
|
||||
// Check if sparkline data exists
|
||||
const hasSparklineData = computed(() => {
|
||||
return props.inflowsSparkline.length > 0 && props.outflowsSparkline.length > 0
|
||||
})
|
||||
|
||||
// Initialize Încasări chart
|
||||
const initializeInflowsChart = async () => {
|
||||
if (!inflowsCanvas.value || props.inflowsSparkline.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Destroy existing chart
|
||||
if (inflowsChartInstance) {
|
||||
inflowsChartInstance.destroy()
|
||||
inflowsChartInstance = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const ctx = inflowsCanvas.value.getContext('2d')
|
||||
|
||||
// Generate labels
|
||||
const labels = props.sparklineLabels.length > 0
|
||||
? props.sparklineLabels
|
||||
: props.inflowsSparkline.map((_, i) => `L${i + 1}`)
|
||||
|
||||
// Prepare datasets
|
||||
const datasets = [{
|
||||
label: 'Încasări (curent)',
|
||||
data: props.inflowsSparkline,
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: '#10b981',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2
|
||||
}]
|
||||
|
||||
// Add previous year dataset if available
|
||||
if (props.inflowsPreviousSparkline && props.inflowsPreviousSparkline.length > 0) {
|
||||
datasets.push({
|
||||
label: 'Încasări (anul precedent)',
|
||||
data: props.inflowsPreviousSparkline,
|
||||
borderColor: 'rgba(16, 185, 129, 0.4)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.05)',
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: 'rgba(16, 185, 129, 0.4)',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate limits including both datasets
|
||||
const allDataPoints = [...props.inflowsSparkline]
|
||||
if (props.inflowsPreviousSparkline && props.inflowsPreviousSparkline.length > 0) {
|
||||
allDataPoints.push(...props.inflowsPreviousSparkline)
|
||||
}
|
||||
const dataMin = Math.min(...allDataPoints)
|
||||
const dataMax = Math.max(...allDataPoints)
|
||||
const dataRange = dataMax - dataMin
|
||||
const dataMean = allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length
|
||||
|
||||
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
|
||||
const minVisibleRange = dataMean * 0.25 // 25% din medie = range minim vizibil
|
||||
const center = (dataMin + dataMax) / 2
|
||||
const targetRange = Math.max(dataRange, minVisibleRange)
|
||||
|
||||
// Calculează limite simetric față de centru
|
||||
let calculatedMin = center - targetRange / 2
|
||||
let calculatedMax = center + targetRange / 2
|
||||
|
||||
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
|
||||
const paddingAmount = targetRange * 0.10 // 10% padding suplimentar
|
||||
|
||||
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
|
||||
const allPositive = dataMin >= 0
|
||||
const yMin = allPositive ? Math.max(0, calculatedMin - paddingAmount) : calculatedMin - paddingAmount
|
||||
const yMax = calculatedMax + paddingAmount
|
||||
|
||||
inflowsChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: datasets.length > 1,
|
||||
position: 'top',
|
||||
align: 'end',
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 10,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
color: 'rgba(107, 114, 128, 0.9)',
|
||||
usePointStyle: true,
|
||||
pointStyle: 'line'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
title: (context) => context[0].label || '',
|
||||
label: (context) => {
|
||||
const value = context.parsed.y
|
||||
const label = context.dataset.label || ''
|
||||
const formattedValue = new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value)
|
||||
return `${label}: ${formattedValue}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(107, 114, 128, 0.7)',
|
||||
font: {
|
||||
size: 10,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 6
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
grid: {
|
||||
color: 'rgba(107, 114, 128, 0.1)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#10b981',
|
||||
font: {
|
||||
size: 11,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxTicksLimit: 3,
|
||||
callback: function(value) {
|
||||
if (value >= 1000000) {
|
||||
return (value / 1000000).toFixed(1) + 'M'
|
||||
} else if (value >= 1000) {
|
||||
return (value / 1000).toFixed(0) + 'k'
|
||||
}
|
||||
return value.toFixed(0)
|
||||
}
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize Plăți chart
|
||||
const initializeOutflowsChart = async () => {
|
||||
if (!outflowsCanvas.value || props.outflowsSparkline.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Destroy existing chart
|
||||
if (outflowsChartInstance) {
|
||||
outflowsChartInstance.destroy()
|
||||
outflowsChartInstance = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const ctx = outflowsCanvas.value.getContext('2d')
|
||||
|
||||
// Generate labels
|
||||
const labels = props.sparklineLabels.length > 0
|
||||
? props.sparklineLabels
|
||||
: props.outflowsSparkline.map((_, i) => `L${i + 1}`)
|
||||
|
||||
// Prepare datasets
|
||||
const datasets = [{
|
||||
label: 'Plăți (curent)',
|
||||
data: props.outflowsSparkline,
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: '#ef4444',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2
|
||||
}]
|
||||
|
||||
// Add previous year dataset if available
|
||||
if (props.outflowsPreviousSparkline && props.outflowsPreviousSparkline.length > 0) {
|
||||
datasets.push({
|
||||
label: 'Plăți (anul precedent)',
|
||||
data: props.outflowsPreviousSparkline,
|
||||
borderColor: 'rgba(239, 68, 68, 0.4)',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.05)',
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: 'rgba(239, 68, 68, 0.4)',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate limits including both datasets
|
||||
const allDataPoints = [...props.outflowsSparkline]
|
||||
if (props.outflowsPreviousSparkline && props.outflowsPreviousSparkline.length > 0) {
|
||||
allDataPoints.push(...props.outflowsPreviousSparkline)
|
||||
}
|
||||
const dataMin = Math.min(...allDataPoints)
|
||||
const dataMax = Math.max(...allDataPoints)
|
||||
const dataRange = dataMax - dataMin
|
||||
const dataMean = allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length
|
||||
|
||||
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
|
||||
const minVisibleRange = dataMean * 0.25 // 25% din medie = range minim vizibil
|
||||
const center = (dataMin + dataMax) / 2
|
||||
const targetRange = Math.max(dataRange, minVisibleRange)
|
||||
|
||||
// Calculează limite simetric față de centru
|
||||
let calculatedMin = center - targetRange / 2
|
||||
let calculatedMax = center + targetRange / 2
|
||||
|
||||
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
|
||||
const paddingAmount = targetRange * 0.10 // 10% padding suplimentar
|
||||
|
||||
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
|
||||
const allPositive = dataMin >= 0
|
||||
const yMin = allPositive ? Math.max(0, calculatedMin - paddingAmount) : calculatedMin - paddingAmount
|
||||
const yMax = calculatedMax + paddingAmount
|
||||
|
||||
outflowsChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: datasets.length > 1,
|
||||
position: 'top',
|
||||
align: 'end',
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 10,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
color: 'rgba(107, 114, 128, 0.9)',
|
||||
usePointStyle: true,
|
||||
pointStyle: 'line'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
title: (context) => context[0].label || '',
|
||||
label: (context) => {
|
||||
const value = context.parsed.y
|
||||
const label = context.dataset.label || ''
|
||||
const formattedValue = new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value)
|
||||
return `${label}: ${formattedValue}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(107, 114, 128, 0.7)',
|
||||
font: {
|
||||
size: 10,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 6
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
grid: {
|
||||
color: 'rgba(107, 114, 128, 0.1)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#ef4444',
|
||||
font: {
|
||||
size: 11,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxTicksLimit: 3,
|
||||
callback: function(value) {
|
||||
if (value >= 1000000) {
|
||||
return (value / 1000000).toFixed(1) + 'M'
|
||||
} else if (value >= 1000) {
|
||||
return (value / 1000).toFixed(0) + 'k'
|
||||
}
|
||||
return value.toFixed(0)
|
||||
}
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for data changes
|
||||
watch(() => [
|
||||
props.inflowsSparkline,
|
||||
props.outflowsSparkline,
|
||||
props.sparklineLabels,
|
||||
props.inflowsPreviousSparkline,
|
||||
props.outflowsPreviousSparkline,
|
||||
props.previousSparklineLabels
|
||||
], async () => {
|
||||
await Promise.all([
|
||||
initializeInflowsChart(),
|
||||
initializeOutflowsChart()
|
||||
])
|
||||
}, { deep: true })
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
initializeInflowsChart(),
|
||||
initializeOutflowsChart()
|
||||
])
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (inflowsChartInstance) {
|
||||
inflowsChartInstance.destroy()
|
||||
inflowsChartInstance = null
|
||||
}
|
||||
if (outflowsChartInstance) {
|
||||
outflowsChartInstance.destroy()
|
||||
outflowsChartInstance = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* === TYPOGRAPHY TOKENS === */
|
||||
:root {
|
||||
--card-label-size: 0.875rem;
|
||||
--card-value-size: 1.5rem;
|
||||
--card-trend-size: 0.75rem;
|
||||
|
||||
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.cashflow-card {
|
||||
background: var(--color-bg, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--card-radius, 8px);
|
||||
padding: var(--space-lg, 1.5rem);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cashflow-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
/* Values section - Split layout */
|
||||
.values-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.value-block {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.value-label {
|
||||
font-size: var(--card-label-size);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.value-amount {
|
||||
font-size: var(--card-value-size);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.value-amount.positive {
|
||||
color: var(--color-success, #10b981);
|
||||
}
|
||||
|
||||
.value-amount.negative {
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.value-amount.neutral {
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: var(--color-border, #e5e7eb);
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* Dual sparkline container - stack vertical */
|
||||
.sparkline-dual-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.sparkline-wrapper {
|
||||
width: 100%;
|
||||
background: var(--color-bg-secondary, #f8fafc);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.sparkline-label {
|
||||
font-size: var(--card-label-size);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Culori distinctive pentru label-uri */
|
||||
.sparkline-wrapper:first-child .sparkline-label {
|
||||
color: #10b981; /* Verde pentru Încasări */
|
||||
}
|
||||
|
||||
.sparkline-wrapper:last-child .sparkline-label {
|
||||
color: #ef4444; /* Roșu pentru Plăți */
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sparkline-canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.cashflow-card {
|
||||
min-height: 380px;
|
||||
padding: var(--space-md, 1rem);
|
||||
}
|
||||
|
||||
.values-section {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.cashflow-card {
|
||||
min-height: 380px;
|
||||
padding: 0.5rem 0.25rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 150px; /* Minim 150px pentru a afișa 3 ticks pe axa Y */
|
||||
}
|
||||
|
||||
.sparkline-wrapper {
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.cashflow-card {
|
||||
--color-bg: #1f2937;
|
||||
--color-bg-secondary: #374151;
|
||||
--color-border: #4b5563;
|
||||
--color-text: #f9fafb;
|
||||
--color-text-secondary: #d1d5db;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,625 @@
|
||||
<template>
|
||||
<div class="clienti-balance-card">
|
||||
<!-- Main value section - NO HEADER -->
|
||||
<div class="value-section">
|
||||
<div class="value-label">Clienți</div>
|
||||
<div class="value-amount" :class="getBalanceClass(total)">
|
||||
{{ formatCurrency(total) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="value-trend" :class="getTrendClass(trend)" v-if="trend">
|
||||
<span class="trend-icon">{{ getTrendIcon(trend) }}</span>
|
||||
<span class="trend-value">{{ Math.round(Math.abs(trend.value)) }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- Sparkline chart -->
|
||||
<div class="sparkline-container" v-if="hasSparklineData">
|
||||
<div class="sparkline-chart">
|
||||
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown section -->
|
||||
<div class="breakdown-section" v-if="breakdown">
|
||||
<!-- În termen -->
|
||||
<div class="breakdown-item">
|
||||
<span class="breakdown-label">În termen</span>
|
||||
<span class="breakdown-value">{{ formatCurrency(breakdown.in_termen?.total || 0) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Restant cu sub-perioade -->
|
||||
<div class="breakdown-group">
|
||||
<div class="breakdown-header" @click="toggleRestantExpanded">
|
||||
<div class="breakdown-header-left">
|
||||
<span class="collapse-icon">{{ isRestantExpanded ? '▼' : '▶' }}</span>
|
||||
<span class="breakdown-label">Restant</span>
|
||||
</div>
|
||||
<span class="breakdown-value">{{ formatCurrency(breakdown.restant?.total || 0) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Perioade restante -->
|
||||
<div v-show="isRestantExpanded" class="breakdown-subitems">
|
||||
<div class="breakdown-subitem" v-for="(value, key) in breakdown.restant?.perioade" :key="key">
|
||||
<span class="breakdown-sublabel">{{ formatPeriodLabel(key) }}</span>
|
||||
<span class="breakdown-subvalue">{{ formatCurrency(value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
const props = defineProps({
|
||||
total: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
trend: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
sparklineData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
previousSparklineData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
sparklineLabels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
previousSparklineLabels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
breakdown: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// Refs
|
||||
const chartCanvas = ref(null)
|
||||
let chartInstance = null
|
||||
const isRestantExpanded = ref(false)
|
||||
|
||||
// Toggle functions
|
||||
const toggleRestantExpanded = () => {
|
||||
isRestantExpanded.value = !isRestantExpanded.value
|
||||
}
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount && amount !== 0) return '0 RON'
|
||||
return new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(Math.abs(amount))
|
||||
}
|
||||
|
||||
// Format period label
|
||||
const formatPeriodLabel = (key) => {
|
||||
const labelMap = {
|
||||
'7_zile': '7 zile',
|
||||
'14_zile': '14 zile',
|
||||
'30_zile': '30 zile',
|
||||
'60_zile': '60 zile',
|
||||
'90_zile': '90 zile',
|
||||
'peste_90_zile': 'Peste 90 zile'
|
||||
}
|
||||
return labelMap[key] || key
|
||||
}
|
||||
|
||||
// Balance class
|
||||
const getBalanceClass = (amount) => {
|
||||
if (!amount && amount !== 0) return 'neutral'
|
||||
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
|
||||
return numAmount > 0 ? 'positive' : numAmount < 0 ? 'negative' : 'neutral'
|
||||
}
|
||||
|
||||
// Trend class
|
||||
const getTrendClass = (trend) => {
|
||||
if (!trend) return ''
|
||||
return {
|
||||
'trend-up': trend.direction === 'up',
|
||||
'trend-down': trend.direction === 'down',
|
||||
'trend-neutral': trend.direction === 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
// Trend icon
|
||||
const getTrendIcon = (trend) => {
|
||||
if (!trend) return ''
|
||||
switch (trend.direction) {
|
||||
case 'up': return '▲'
|
||||
case 'down': return '▼'
|
||||
case 'neutral': return '▶'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Check if sparkline data exists
|
||||
const hasSparklineData = computed(() => {
|
||||
return props.sparklineData && props.sparklineData.length > 0
|
||||
})
|
||||
|
||||
// Initialize chart
|
||||
const initializeChart = async () => {
|
||||
if (!chartCanvas.value || !hasSparklineData.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Destroy existing chart
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
chartInstance = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const ctx = chartCanvas.value.getContext('2d')
|
||||
|
||||
// Generate labels
|
||||
const labels = props.sparklineLabels.length > 0
|
||||
? props.sparklineLabels
|
||||
: props.sparklineData.map((_, i) => `L${i + 1}`)
|
||||
|
||||
// Calculate limits including both datasets
|
||||
const allDataPoints = [...props.sparklineData]
|
||||
if (props.previousSparklineData && props.previousSparklineData.length > 0) {
|
||||
allDataPoints.push(...props.previousSparklineData)
|
||||
}
|
||||
const dataMin = Math.min(...allDataPoints)
|
||||
const dataMax = Math.max(...allDataPoints)
|
||||
const dataRange = dataMax - dataMin
|
||||
const dataMean = allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length
|
||||
|
||||
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
|
||||
const minVisibleRange = dataMean * 0.25 // 25% din medie = range minim vizibil
|
||||
const center = (dataMin + dataMax) / 2
|
||||
const targetRange = Math.max(dataRange, minVisibleRange)
|
||||
|
||||
// Calculează limite simetric față de centru
|
||||
let calculatedMin = center - targetRange / 2
|
||||
let calculatedMax = center + targetRange / 2
|
||||
|
||||
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
|
||||
const paddingAmount = targetRange * 0.10 // 10% padding suplimentar
|
||||
|
||||
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
|
||||
const allPositive = dataMin >= 0
|
||||
const yMin = allPositive ? Math.max(0, calculatedMin - paddingAmount) : calculatedMin - paddingAmount
|
||||
const yMax = calculatedMax + paddingAmount
|
||||
|
||||
// Prepare datasets
|
||||
const datasets = [{
|
||||
label: 'Clienți (curent)',
|
||||
data: props.sparklineData,
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: '#10b981',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2
|
||||
}]
|
||||
|
||||
// Add previous year dataset if available
|
||||
if (props.previousSparklineData && props.previousSparklineData.length > 0) {
|
||||
datasets.push({
|
||||
label: 'Clienți (anul precedent)',
|
||||
data: props.previousSparklineData,
|
||||
borderColor: 'rgba(16, 185, 129, 0.4)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.05)',
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: 'rgba(16, 185, 129, 0.6)',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2
|
||||
})
|
||||
}
|
||||
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
align: 'end',
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 10,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
color: 'rgba(107, 114, 128, 0.8)',
|
||||
usePointStyle: true
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
title: (context) => context[0].label || '',
|
||||
label: (context) => {
|
||||
const value = context.parsed.y
|
||||
const label = context.dataset.label || ''
|
||||
const formattedValue = new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value)
|
||||
return `${label}: ${formattedValue}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(107, 114, 128, 0.7)',
|
||||
font: {
|
||||
size: 10,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 6
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
grid: {
|
||||
color: 'rgba(107, 114, 128, 0.1)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#10b981',
|
||||
font: {
|
||||
size: 11,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxTicksLimit: 3,
|
||||
callback: function(value) {
|
||||
if (value >= 1000000) {
|
||||
return (value / 1000000).toFixed(1) + 'M'
|
||||
} else if (value >= 1000) {
|
||||
return (value / 1000).toFixed(0) + 'k'
|
||||
}
|
||||
return value.toFixed(0)
|
||||
}
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for data changes
|
||||
watch(() => [props.sparklineData, props.previousSparklineData, props.sparklineLabels, props.previousSparklineLabels], async () => {
|
||||
await initializeChart()
|
||||
}, { deep: true })
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(async () => {
|
||||
await initializeChart()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
chartInstance = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* === TYPOGRAPHY TOKENS === */
|
||||
:root {
|
||||
--card-label-size: 0.875rem;
|
||||
--card-value-size: 1.5rem;
|
||||
--card-trend-size: 0.75rem;
|
||||
--breakdown-label-size: 0.875rem;
|
||||
--breakdown-value-size: 0.9375rem;
|
||||
--breakdown-sub-label-size: 0.8125rem;
|
||||
--breakdown-sub-value-size: 0.8125rem;
|
||||
|
||||
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.clienti-balance-card {
|
||||
background: var(--color-bg, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--card-radius, 8px);
|
||||
padding: var(--space-lg, 1.5rem);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.clienti-balance-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
/* Value section */
|
||||
.value-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.value-label {
|
||||
font-size: var(--card-label-size);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.value-amount {
|
||||
font-size: var(--card-value-size);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.value-amount.positive {
|
||||
color: var(--color-success, #10b981);
|
||||
}
|
||||
|
||||
.value-amount.negative {
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.value-amount.neutral {
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.value-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: var(--card-trend-size);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.trend-up {
|
||||
color: var(--color-success, #10b981);
|
||||
}
|
||||
|
||||
.trend-down {
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.trend-neutral {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.trend-icon {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
/* Sparkline container */
|
||||
.sparkline-container {
|
||||
width: 100%;
|
||||
background: var(--color-bg-secondary, #f8fafc);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sparkline-canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Breakdown section */
|
||||
.breakdown-section {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.breakdown-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0;
|
||||
}
|
||||
|
||||
.breakdown-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.breakdown-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.breakdown-header:hover {
|
||||
background: var(--color-bg-secondary, #f8fafc);
|
||||
}
|
||||
|
||||
.breakdown-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
transition: transform 0.2s ease;
|
||||
display: inline-block;
|
||||
width: 0.875rem;
|
||||
}
|
||||
|
||||
.breakdown-label {
|
||||
font-size: var(--breakdown-label-size);
|
||||
color: var(--color-text, #111827);
|
||||
font-weight: 500;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.breakdown-value {
|
||||
font-size: var(--breakdown-value-size);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.breakdown-subitems {
|
||||
padding-left: 0;
|
||||
animation: slideDown 0.2s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.breakdown-subitem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem 0.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.breakdown-sublabel {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: var(--breakdown-sub-label-size);
|
||||
font-weight: 400;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.breakdown-subvalue {
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--breakdown-sub-value-size);
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.clienti-balance-card {
|
||||
min-height: 280px;
|
||||
padding: var(--space-md, 1rem);
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.clienti-balance-card {
|
||||
min-height: 280px;
|
||||
padding: 0.5rem 0.25rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 150px; /* Minim 150px pentru a afișa 3 ticks pe axa Y */
|
||||
}
|
||||
|
||||
.sparkline-container {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.clienti-balance-card {
|
||||
--color-bg: #1f2937;
|
||||
--color-bg-secondary: #374151;
|
||||
--color-border: #4b5563;
|
||||
--color-text: #f9fafb;
|
||||
--color-text-secondary: #d1d5db;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,969 @@
|
||||
<template>
|
||||
<div class="balance-dual-card">
|
||||
<!-- Header -->
|
||||
<div class="card-header">
|
||||
<span class="card-icon">💰</span>
|
||||
<span class="card-title">SOLD CLIENȚI / FURNIZORI</span>
|
||||
</div>
|
||||
|
||||
<!-- Main values section - Split layout -->
|
||||
<div class="values-section">
|
||||
<!-- Clienți Section -->
|
||||
<div class="value-block clienti">
|
||||
<div class="value-label">Clienți</div>
|
||||
<div class="value-amount" :class="getBalanceClass(clientiTotal)">
|
||||
{{ formatCurrency(clientiTotal) }}
|
||||
</div>
|
||||
<div class="value-trend" :class="getTrendClass(clientiTrend)" v-if="clientiTrend">
|
||||
<span class="trend-icon">{{ getTrendIcon(clientiTrend) }}</span>
|
||||
<span class="trend-value">{{ Math.round(Math.abs(clientiTrend.value)) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Furnizori Section -->
|
||||
<div class="value-block furnizori">
|
||||
<div class="value-label">Furnizori</div>
|
||||
<div class="value-amount" :class="getBalanceClass(furnizoriTotal)">
|
||||
{{ formatCurrency(furnizoriTotal) }}
|
||||
</div>
|
||||
<div class="value-trend" :class="getTrendClass(furnizoriTrend)" v-if="furnizoriTrend">
|
||||
<span class="trend-icon">{{ getTrendIcon(furnizoriTrend) }}</span>
|
||||
<span class="trend-value">{{ Math.round(Math.abs(furnizoriTrend.value)) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dual sparkline charts - stacked vertical -->
|
||||
<div class="sparkline-dual-container" v-if="hasSparklineData">
|
||||
<!-- Grafic Clienți -->
|
||||
<div class="sparkline-wrapper">
|
||||
<div class="sparkline-label">Clienți</div>
|
||||
<div class="sparkline-chart">
|
||||
<canvas ref="clientiCanvas" class="sparkline-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grafic Furnizori -->
|
||||
<div class="sparkline-wrapper">
|
||||
<div class="sparkline-label">Furnizori</div>
|
||||
<div class="sparkline-chart">
|
||||
<canvas ref="furnizoriCanvas" class="sparkline-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown section -->
|
||||
<div class="breakdown-section" v-if="breakdown">
|
||||
<!-- Clienți Breakdown -->
|
||||
<div class="breakdown-group">
|
||||
<div class="breakdown-header" @click="toggleClientiExpanded">
|
||||
<div class="breakdown-header-left">
|
||||
<span class="collapse-icon">{{ isClientiExpanded ? '▼' : '▶' }}</span>
|
||||
<span class="breakdown-label">Clienți - Detaliere</span>
|
||||
</div>
|
||||
<span class="breakdown-value">{{ formatCurrency(breakdown.clienti.total) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Clienți Sub-items -->
|
||||
<div v-show="isClientiExpanded" class="breakdown-subitems">
|
||||
<!-- În termen -->
|
||||
<div class="breakdown-subitem">
|
||||
<span class="breakdown-sublabel">În termen</span>
|
||||
<span class="breakdown-subvalue">{{ formatCurrency(breakdown.clienti.in_termen.total) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Restant cu sub-perioade -->
|
||||
<div class="breakdown-subitem-group">
|
||||
<div class="breakdown-subitem-header" @click="toggleClientiRestantExpanded">
|
||||
<div class="subitem-header-left">
|
||||
<span class="collapse-icon-small">{{ isClientiRestantExpanded ? '▼' : '▶' }}</span>
|
||||
<span class="breakdown-sublabel">Restant</span>
|
||||
</div>
|
||||
<span class="breakdown-subvalue">{{ formatCurrency(breakdown.clienti.restant.total) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Perioade restante -->
|
||||
<div v-show="isClientiRestantExpanded" class="breakdown-perioade">
|
||||
<div class="perioada-item" v-for="(value, key) in breakdown.clienti.restant.perioade" :key="key">
|
||||
<span class="perioada-label">{{ formatPeriodLabel(key) }}</span>
|
||||
<span class="perioada-value">{{ formatCurrency(value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Furnizori Breakdown -->
|
||||
<div class="breakdown-group">
|
||||
<div class="breakdown-header" @click="toggleFurnizoriExpanded">
|
||||
<div class="breakdown-header-left">
|
||||
<span class="collapse-icon">{{ isFurnizoriExpanded ? '▼' : '▶' }}</span>
|
||||
<span class="breakdown-label">Furnizori - Detaliere</span>
|
||||
</div>
|
||||
<span class="breakdown-value">{{ formatCurrency(breakdown.furnizori.total) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Furnizori Sub-items -->
|
||||
<div v-show="isFurnizoriExpanded" class="breakdown-subitems">
|
||||
<!-- În termen -->
|
||||
<div class="breakdown-subitem">
|
||||
<span class="breakdown-sublabel">În termen</span>
|
||||
<span class="breakdown-subvalue">{{ formatCurrency(breakdown.furnizori.in_termen.total) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Restant cu sub-perioade -->
|
||||
<div class="breakdown-subitem-group">
|
||||
<div class="breakdown-subitem-header" @click="toggleFurnizoriRestantExpanded">
|
||||
<div class="subitem-header-left">
|
||||
<span class="collapse-icon-small">{{ isFurnizoriRestantExpanded ? '▼' : '▶' }}</span>
|
||||
<span class="breakdown-sublabel">Restant</span>
|
||||
</div>
|
||||
<span class="breakdown-subvalue">{{ formatCurrency(breakdown.furnizori.restant.total) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Perioade restante -->
|
||||
<div v-show="isFurnizoriRestantExpanded" class="breakdown-perioade">
|
||||
<div class="perioada-item" v-for="(value, key) in breakdown.furnizori.restant.perioade" :key="key">
|
||||
<span class="perioada-label">{{ formatPeriodLabel(key) }}</span>
|
||||
<span class="perioada-value">{{ formatCurrency(value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
const props = defineProps({
|
||||
clientiTotal: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
furnizoriTotal: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
clientiTrend: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
furnizoriTrend: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
clientiSparklineData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
furnizoriSparklineData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
sparklineLabels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
breakdown: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// Refs pentru 2 canvas-uri separate
|
||||
const clientiCanvas = ref(null)
|
||||
const furnizoriCanvas = ref(null)
|
||||
let clientiChartInstance = null
|
||||
let furnizoriChartInstance = null
|
||||
const isClientiExpanded = ref(false)
|
||||
const isFurnizoriExpanded = ref(false)
|
||||
const isClientiRestantExpanded = ref(false)
|
||||
const isFurnizoriRestantExpanded = ref(false)
|
||||
|
||||
// Toggle functions
|
||||
const toggleClientiExpanded = () => {
|
||||
isClientiExpanded.value = !isClientiExpanded.value
|
||||
}
|
||||
|
||||
const toggleFurnizoriExpanded = () => {
|
||||
isFurnizoriExpanded.value = !isFurnizoriExpanded.value
|
||||
}
|
||||
|
||||
const toggleClientiRestantExpanded = () => {
|
||||
isClientiRestantExpanded.value = !isClientiRestantExpanded.value
|
||||
}
|
||||
|
||||
const toggleFurnizoriRestantExpanded = () => {
|
||||
isFurnizoriRestantExpanded.value = !isFurnizoriRestantExpanded.value
|
||||
}
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount && amount !== 0) return '0 RON'
|
||||
return new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(Math.abs(amount))
|
||||
}
|
||||
|
||||
// Format period label
|
||||
const formatPeriodLabel = (key) => {
|
||||
const labelMap = {
|
||||
'7_zile': '7 zile',
|
||||
'14_zile': '14 zile',
|
||||
'30_zile': '30 zile',
|
||||
'60_zile': '60 zile',
|
||||
'90_zile': '90 zile',
|
||||
'peste_90_zile': 'Peste 90 zile'
|
||||
}
|
||||
return labelMap[key] || key
|
||||
}
|
||||
|
||||
// Balance class
|
||||
const getBalanceClass = (amount) => {
|
||||
if (!amount && amount !== 0) return 'neutral'
|
||||
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
|
||||
return numAmount > 0 ? 'positive' : numAmount < 0 ? 'negative' : 'neutral'
|
||||
}
|
||||
|
||||
// Trend class
|
||||
const getTrendClass = (trend) => {
|
||||
if (!trend) return ''
|
||||
return {
|
||||
'trend-up': trend.direction === 'up',
|
||||
'trend-down': trend.direction === 'down',
|
||||
'trend-neutral': trend.direction === 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
// Trend icon
|
||||
const getTrendIcon = (trend) => {
|
||||
if (!trend) return ''
|
||||
switch (trend.direction) {
|
||||
case 'up': return '▲'
|
||||
case 'down': return '▼'
|
||||
case 'neutral': return '▶'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Check if sparkline data exists
|
||||
const hasSparklineData = computed(() => {
|
||||
return props.clientiSparklineData.length > 0 && props.furnizoriSparklineData.length > 0
|
||||
})
|
||||
|
||||
// Initialize Clienți chart
|
||||
const initializeClientiChart = async () => {
|
||||
if (!clientiCanvas.value || props.clientiSparklineData.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Destroy existing chart
|
||||
if (clientiChartInstance) {
|
||||
clientiChartInstance.destroy()
|
||||
clientiChartInstance = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const ctx = clientiCanvas.value.getContext('2d')
|
||||
|
||||
// Generate labels
|
||||
const labels = props.sparklineLabels.length > 0
|
||||
? props.sparklineLabels
|
||||
: props.clientiSparklineData.map((_, i) => `L${i + 1}`)
|
||||
|
||||
// Calculează limite pentru clienți
|
||||
const clientiMin = Math.min(...props.clientiSparklineData)
|
||||
const clientiMax = Math.max(...props.clientiSparklineData)
|
||||
const clientiRange = clientiMax - clientiMin
|
||||
const clientiPadding = clientiRange * 0.05
|
||||
|
||||
clientiChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Clienți',
|
||||
data: props.clientiSparklineData,
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0, // Ascunde punctele pentru a economisi spațiu
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: '#10b981',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false // Ascunde legenda - e clar din label că e "Clienți"
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
title: (context) => context[0].label || '',
|
||||
label: (context) => {
|
||||
const value = context.parsed.y
|
||||
const formatted = new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value)
|
||||
return formatted
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(107, 114, 128, 0.7)',
|
||||
font: {
|
||||
size: 10,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 6
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
min: clientiMin - clientiPadding,
|
||||
max: clientiMax + clientiPadding,
|
||||
grid: {
|
||||
color: 'rgba(107, 114, 128, 0.1)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#10b981',
|
||||
font: {
|
||||
size: 11,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxTicksLimit: 3,
|
||||
callback: function(value) {
|
||||
if (value >= 1000000) {
|
||||
return (value / 1000000).toFixed(1) + 'M'
|
||||
} else if (value >= 1000) {
|
||||
return (value / 1000).toFixed(0) + 'k'
|
||||
}
|
||||
return value.toFixed(0)
|
||||
}
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize Furnizori chart
|
||||
const initializeFurnizoriChart = async () => {
|
||||
if (!furnizoriCanvas.value || props.furnizoriSparklineData.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Destroy existing chart
|
||||
if (furnizoriChartInstance) {
|
||||
furnizoriChartInstance.destroy()
|
||||
furnizoriChartInstance = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const ctx = furnizoriCanvas.value.getContext('2d')
|
||||
|
||||
// Generate labels
|
||||
const labels = props.sparklineLabels.length > 0
|
||||
? props.sparklineLabels
|
||||
: props.furnizoriSparklineData.map((_, i) => `L${i + 1}`)
|
||||
|
||||
// Calculează limite pentru furnizori
|
||||
const furnizoriMin = Math.min(...props.furnizoriSparklineData)
|
||||
const furnizoriMax = Math.max(...props.furnizoriSparklineData)
|
||||
const furnizoriRange = furnizoriMax - furnizoriMin
|
||||
const furnizoriPadding = furnizoriRange * 0.05
|
||||
|
||||
furnizoriChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Furnizori',
|
||||
data: props.furnizoriSparklineData,
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: '#ef4444',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
title: (context) => context[0].label || '',
|
||||
label: (context) => {
|
||||
const value = context.parsed.y
|
||||
const formatted = new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value)
|
||||
return formatted
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(107, 114, 128, 0.7)',
|
||||
font: {
|
||||
size: 10,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 6
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
min: furnizoriMin - furnizoriPadding,
|
||||
max: furnizoriMax + furnizoriPadding,
|
||||
grid: {
|
||||
color: 'rgba(107, 114, 128, 0.1)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#ef4444',
|
||||
font: {
|
||||
size: 11,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxTicksLimit: 3,
|
||||
callback: function(value) {
|
||||
if (value >= 1000000) {
|
||||
return (value / 1000000).toFixed(1) + 'M'
|
||||
} else if (value >= 1000) {
|
||||
return (value / 1000).toFixed(0) + 'k'
|
||||
}
|
||||
return value.toFixed(0)
|
||||
}
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for data changes
|
||||
watch(() => [props.clientiSparklineData, props.furnizoriSparklineData, props.sparklineLabels], async () => {
|
||||
await Promise.all([
|
||||
initializeClientiChart(),
|
||||
initializeFurnizoriChart()
|
||||
])
|
||||
}, { deep: true })
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
initializeClientiChart(),
|
||||
initializeFurnizoriChart()
|
||||
])
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (clientiChartInstance) {
|
||||
clientiChartInstance.destroy()
|
||||
clientiChartInstance = null
|
||||
}
|
||||
if (furnizoriChartInstance) {
|
||||
furnizoriChartInstance.destroy()
|
||||
furnizoriChartInstance = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.balance-dual-card {
|
||||
background: var(--color-bg, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--card-radius, 8px);
|
||||
padding: var(--space-lg, 1.5rem);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.balance-dual-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background: var(--color-bg-secondary, #f8fafc);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
font-weight: var(--font-semibold, 600);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Values section - Split layout */
|
||||
.values-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.value-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.value-label {
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
font-weight: var(--font-medium, 500);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.value-amount {
|
||||
font-size: var(--text-xl, 1.5rem);
|
||||
font-weight: var(--font-bold, 700);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.value-amount.positive {
|
||||
color: var(--color-success, #10b981);
|
||||
}
|
||||
|
||||
.value-amount.negative {
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.value-amount.neutral {
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.value-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
font-weight: var(--font-medium, 500);
|
||||
}
|
||||
|
||||
.trend-up {
|
||||
color: var(--color-success, #10b981);
|
||||
}
|
||||
|
||||
.trend-down {
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.trend-neutral {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.trend-icon {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: var(--color-border, #e5e7eb);
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* Dual sparkline container - stack vertical */
|
||||
.sparkline-dual-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.sparkline-wrapper {
|
||||
width: 100%;
|
||||
background: var(--color-bg-secondary, #f8fafc);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.sparkline-label {
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
font-weight: var(--font-semibold, 600);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Culori distinctive pentru label-uri */
|
||||
.sparkline-wrapper:first-child .sparkline-label {
|
||||
color: #10b981; /* Verde pentru Clienți */
|
||||
}
|
||||
|
||||
.sparkline-wrapper:last-child .sparkline-label {
|
||||
color: #ef4444; /* Roșu pentru Furnizori */
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
width: 100%;
|
||||
height: 120px; /* Înălțime mărită pentru fiecare grafic individual */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sparkline-canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Breakdown section */
|
||||
.breakdown-section {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.breakdown-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.breakdown-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition: all 0.2s ease;
|
||||
font-weight: var(--font-semibold, 600);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.breakdown-header:hover {
|
||||
background: var(--color-bg-secondary, #f8fafc);
|
||||
}
|
||||
|
||||
.breakdown-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
transition: transform 0.2s ease;
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.breakdown-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.breakdown-value {
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-semibold, 600);
|
||||
font-family: monospace;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.breakdown-subitems {
|
||||
padding-left: 0;
|
||||
animation: slideDown 0.2s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.breakdown-subitem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.5rem;
|
||||
}
|
||||
|
||||
.breakdown-subitem-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.breakdown-subitem-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.breakdown-subitem-header:hover {
|
||||
background: var(--color-bg-secondary, #f8fafc);
|
||||
}
|
||||
|
||||
.subitem-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.collapse-icon-small {
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
transition: transform 0.2s ease;
|
||||
display: inline-block;
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
.breakdown-sublabel {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.breakdown-subvalue {
|
||||
font-weight: var(--font-medium, 500);
|
||||
font-family: monospace;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.breakdown-perioade {
|
||||
padding-left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
animation: slideDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
.perioada-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.perioada-label {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.perioada-value {
|
||||
font-family: monospace;
|
||||
font-weight: var(--font-medium, 500);
|
||||
color: var(--color-text, #111827);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.balance-dual-card {
|
||||
min-height: 380px;
|
||||
padding: var(--space-md, 1rem);
|
||||
}
|
||||
|
||||
.values-section {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.value-amount {
|
||||
font-size: var(--text-lg, 1.125rem);
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.balance-dual-card {
|
||||
min-height: 340px;
|
||||
padding: 0.5rem 0.25rem;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.value-amount {
|
||||
font-size: var(--text-base, 1rem);
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.sparkline-wrapper {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.values-section {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.balance-dual-card {
|
||||
--color-bg: #1f2937;
|
||||
--color-bg-secondary: #374151;
|
||||
--color-border: #4b5563;
|
||||
--color-text: #f9fafb;
|
||||
--color-text-secondary: #d1d5db;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,625 @@
|
||||
<template>
|
||||
<div class="furnizori-balance-card">
|
||||
<!-- Main value section - NO HEADER -->
|
||||
<div class="value-section">
|
||||
<div class="value-label">Furnizori</div>
|
||||
<div class="value-amount" :class="getBalanceClass(total)">
|
||||
{{ formatCurrency(total) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="value-trend" :class="getTrendClass(trend)" v-if="trend">
|
||||
<span class="trend-icon">{{ getTrendIcon(trend) }}</span>
|
||||
<span class="trend-value">{{ Math.round(Math.abs(trend.value)) }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- Sparkline chart -->
|
||||
<div class="sparkline-container" v-if="hasSparklineData">
|
||||
<div class="sparkline-chart">
|
||||
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown section -->
|
||||
<div class="breakdown-section" v-if="breakdown">
|
||||
<!-- În termen -->
|
||||
<div class="breakdown-item">
|
||||
<span class="breakdown-label">În termen</span>
|
||||
<span class="breakdown-value">{{ formatCurrency(breakdown.in_termen?.total || 0) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Restant cu sub-perioade -->
|
||||
<div class="breakdown-group">
|
||||
<div class="breakdown-header" @click="toggleRestantExpanded">
|
||||
<div class="breakdown-header-left">
|
||||
<span class="collapse-icon">{{ isRestantExpanded ? '▼' : '▶' }}</span>
|
||||
<span class="breakdown-label">Restant</span>
|
||||
</div>
|
||||
<span class="breakdown-value">{{ formatCurrency(breakdown.restant?.total || 0) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Perioade restante -->
|
||||
<div v-show="isRestantExpanded" class="breakdown-subitems">
|
||||
<div class="breakdown-subitem" v-for="(value, key) in breakdown.restant?.perioade" :key="key">
|
||||
<span class="breakdown-sublabel">{{ formatPeriodLabel(key) }}</span>
|
||||
<span class="breakdown-subvalue">{{ formatCurrency(value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
const props = defineProps({
|
||||
total: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
trend: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
sparklineData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
previousSparklineData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
sparklineLabels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
previousSparklineLabels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
breakdown: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// Refs
|
||||
const chartCanvas = ref(null)
|
||||
let chartInstance = null
|
||||
const isRestantExpanded = ref(false)
|
||||
|
||||
// Toggle functions
|
||||
const toggleRestantExpanded = () => {
|
||||
isRestantExpanded.value = !isRestantExpanded.value
|
||||
}
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount && amount !== 0) return '0 RON'
|
||||
return new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(Math.abs(amount))
|
||||
}
|
||||
|
||||
// Format period label
|
||||
const formatPeriodLabel = (key) => {
|
||||
const labelMap = {
|
||||
'7_zile': '7 zile',
|
||||
'14_zile': '14 zile',
|
||||
'30_zile': '30 zile',
|
||||
'60_zile': '60 zile',
|
||||
'90_zile': '90 zile',
|
||||
'peste_90_zile': 'Peste 90 zile'
|
||||
}
|
||||
return labelMap[key] || key
|
||||
}
|
||||
|
||||
// Balance class
|
||||
const getBalanceClass = (amount) => {
|
||||
if (!amount && amount !== 0) return 'neutral'
|
||||
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
|
||||
return numAmount > 0 ? 'positive' : numAmount < 0 ? 'negative' : 'neutral'
|
||||
}
|
||||
|
||||
// Trend class
|
||||
const getTrendClass = (trend) => {
|
||||
if (!trend) return ''
|
||||
return {
|
||||
'trend-up': trend.direction === 'up',
|
||||
'trend-down': trend.direction === 'down',
|
||||
'trend-neutral': trend.direction === 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
// Trend icon
|
||||
const getTrendIcon = (trend) => {
|
||||
if (!trend) return ''
|
||||
switch (trend.direction) {
|
||||
case 'up': return '▲'
|
||||
case 'down': return '▼'
|
||||
case 'neutral': return '▶'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Check if sparkline data exists
|
||||
const hasSparklineData = computed(() => {
|
||||
return props.sparklineData && props.sparklineData.length > 0
|
||||
})
|
||||
|
||||
// Initialize chart
|
||||
const initializeChart = async () => {
|
||||
if (!chartCanvas.value || !hasSparklineData.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Destroy existing chart
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
chartInstance = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const ctx = chartCanvas.value.getContext('2d')
|
||||
|
||||
// Generate labels
|
||||
const labels = props.sparklineLabels.length > 0
|
||||
? props.sparklineLabels
|
||||
: props.sparklineData.map((_, i) => `L${i + 1}`)
|
||||
|
||||
// Calculate limits including both datasets
|
||||
const allDataPoints = [...props.sparklineData]
|
||||
if (props.previousSparklineData && props.previousSparklineData.length > 0) {
|
||||
allDataPoints.push(...props.previousSparklineData)
|
||||
}
|
||||
const dataMin = Math.min(...allDataPoints)
|
||||
const dataMax = Math.max(...allDataPoints)
|
||||
const dataRange = dataMax - dataMin
|
||||
const dataMean = allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length
|
||||
|
||||
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
|
||||
const minVisibleRange = dataMean * 0.25 // 25% din medie = range minim vizibil
|
||||
const center = (dataMin + dataMax) / 2
|
||||
const targetRange = Math.max(dataRange, minVisibleRange)
|
||||
|
||||
// Calculează limite simetric față de centru
|
||||
let calculatedMin = center - targetRange / 2
|
||||
let calculatedMax = center + targetRange / 2
|
||||
|
||||
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
|
||||
const paddingAmount = targetRange * 0.10 // 10% padding suplimentar
|
||||
|
||||
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
|
||||
const allPositive = dataMin >= 0
|
||||
const yMin = allPositive ? Math.max(0, calculatedMin - paddingAmount) : calculatedMin - paddingAmount
|
||||
const yMax = calculatedMax + paddingAmount
|
||||
|
||||
// Prepare datasets
|
||||
const datasets = [{
|
||||
label: 'Furnizori (curent)',
|
||||
data: props.sparklineData,
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: '#ef4444',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2
|
||||
}]
|
||||
|
||||
// Add previous year dataset if available
|
||||
if (props.previousSparklineData && props.previousSparklineData.length > 0) {
|
||||
datasets.push({
|
||||
label: 'Furnizori (anul precedent)',
|
||||
data: props.previousSparklineData,
|
||||
borderColor: 'rgba(239, 68, 68, 0.4)',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.05)',
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: 'rgba(239, 68, 68, 0.6)',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2
|
||||
})
|
||||
}
|
||||
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
align: 'end',
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 10,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
color: 'rgba(107, 114, 128, 0.8)',
|
||||
usePointStyle: true
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
title: (context) => context[0].label || '',
|
||||
label: (context) => {
|
||||
const value = context.parsed.y
|
||||
const label = context.dataset.label || ''
|
||||
const formattedValue = new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value)
|
||||
return `${label}: ${formattedValue}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(107, 114, 128, 0.7)',
|
||||
font: {
|
||||
size: 10,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 6
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
grid: {
|
||||
color: 'rgba(107, 114, 128, 0.1)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#ef4444',
|
||||
font: {
|
||||
size: 11,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxTicksLimit: 3,
|
||||
callback: function(value) {
|
||||
if (value >= 1000000) {
|
||||
return (value / 1000000).toFixed(1) + 'M'
|
||||
} else if (value >= 1000) {
|
||||
return (value / 1000).toFixed(0) + 'k'
|
||||
}
|
||||
return value.toFixed(0)
|
||||
}
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for data changes
|
||||
watch(() => [props.sparklineData, props.previousSparklineData, props.sparklineLabels, props.previousSparklineLabels], async () => {
|
||||
await initializeChart()
|
||||
}, { deep: true })
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(async () => {
|
||||
await initializeChart()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
chartInstance = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* === TYPOGRAPHY TOKENS === */
|
||||
:root {
|
||||
--card-label-size: 0.875rem;
|
||||
--card-value-size: 1.5rem;
|
||||
--card-trend-size: 0.75rem;
|
||||
--breakdown-label-size: 0.875rem;
|
||||
--breakdown-value-size: 0.9375rem;
|
||||
--breakdown-sub-label-size: 0.8125rem;
|
||||
--breakdown-sub-value-size: 0.8125rem;
|
||||
|
||||
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.furnizori-balance-card {
|
||||
background: var(--color-bg, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--card-radius, 8px);
|
||||
padding: var(--space-lg, 1.5rem);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.furnizori-balance-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
/* Value section */
|
||||
.value-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.value-label {
|
||||
font-size: var(--card-label-size);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.value-amount {
|
||||
font-size: var(--card-value-size);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.value-amount.positive {
|
||||
color: var(--color-success, #10b981);
|
||||
}
|
||||
|
||||
.value-amount.negative {
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.value-amount.neutral {
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.value-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: var(--card-trend-size);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.trend-up {
|
||||
color: var(--color-success, #10b981);
|
||||
}
|
||||
|
||||
.trend-down {
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.trend-neutral {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.trend-icon {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
/* Sparkline container */
|
||||
.sparkline-container {
|
||||
width: 100%;
|
||||
background: var(--color-bg-secondary, #f8fafc);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sparkline-canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Breakdown section */
|
||||
.breakdown-section {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.breakdown-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0;
|
||||
}
|
||||
|
||||
.breakdown-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.breakdown-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.breakdown-header:hover {
|
||||
background: var(--color-bg-secondary, #f8fafc);
|
||||
}
|
||||
|
||||
.breakdown-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
transition: transform 0.2s ease;
|
||||
display: inline-block;
|
||||
width: 0.875rem;
|
||||
}
|
||||
|
||||
.breakdown-label {
|
||||
font-size: var(--breakdown-label-size);
|
||||
color: var(--color-text, #111827);
|
||||
font-weight: 500;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.breakdown-value {
|
||||
font-size: var(--breakdown-value-size);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.breakdown-subitems {
|
||||
padding-left: 0;
|
||||
animation: slideDown 0.2s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.breakdown-subitem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem 0.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.breakdown-sublabel {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: var(--breakdown-sub-label-size);
|
||||
font-weight: 400;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.breakdown-subvalue {
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--breakdown-sub-value-size);
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.furnizori-balance-card {
|
||||
min-height: 280px;
|
||||
padding: var(--space-md, 1rem);
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.furnizori-balance-card {
|
||||
min-height: 280px;
|
||||
padding: 0.5rem 0.25rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 150px; /* Minim 150px pentru a afișa 3 ticks pe axa Y */
|
||||
}
|
||||
|
||||
.sparkline-container {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.furnizori-balance-card {
|
||||
--color-bg: #1f2937;
|
||||
--color-bg-secondary: #374151;
|
||||
--color-border: #4b5563;
|
||||
--color-text: #f9fafb;
|
||||
--color-text-secondary: #d1d5db;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,775 @@
|
||||
<template>
|
||||
<div class="maturity-card">
|
||||
<div class="card-header">
|
||||
<h3>Analiză Comparativă Scadențe</h3>
|
||||
<select
|
||||
v-model="selectedPeriod"
|
||||
@change="handlePeriodChange"
|
||||
class="period-selector"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<option value="7d">7 zile</option>
|
||||
<option value="1m">1 lună</option>
|
||||
<option value="3m">3 luni</option>
|
||||
<option value="6m">6 luni</option>
|
||||
<option value="12m">12 luni</option>
|
||||
<option value="all">Toate</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Se încarcă analiza scadențelor...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
<div class="error-icon">!</div>
|
||||
<p>{{ error }}</p>
|
||||
<button @click="loadData" class="retry-btn">Încearcă din nou</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="maturity-comparison">
|
||||
<!-- Clients Side -->
|
||||
<div class="clients-side">
|
||||
<h4 class="side-title clients-title">
|
||||
Clienți - De încasat
|
||||
<span class="total-amount">{{ formatCurrency(clientsTotal) }}</span>
|
||||
</h4>
|
||||
<div class="maturity-list">
|
||||
<div
|
||||
v-for="(client, index) in clientsData"
|
||||
:key="`client-${index}`"
|
||||
class="maturity-item"
|
||||
:class="{ 'overdue': client.daysOverdue > 0, 'critical': client.daysOverdue > 30 }"
|
||||
>
|
||||
<div class="item-info">
|
||||
<span class="client-name">{{ client.name }}</span>
|
||||
<span class="due-info">
|
||||
<span v-if="client.daysOverdue > 0" class="overdue-days">
|
||||
Restant {{ client.daysOverdue }} zile
|
||||
</span>
|
||||
<span v-else class="due-date">
|
||||
Scadent în {{ Math.abs(client.daysOverdue) }} zile
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="amount-bar">
|
||||
<div class="bar-container">
|
||||
<div
|
||||
class="bar-fill clients-bar"
|
||||
:style="{ width: getBarWidth(client.amount, maxClientAmount) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="amount-value">{{ formatCurrency(client.amount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="clientsData.length === 0" class="empty-state">
|
||||
<p>Nu există facturi de încasat pentru această perioadă</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="comparison-divider"></div>
|
||||
|
||||
<!-- Suppliers Side -->
|
||||
<div class="suppliers-side">
|
||||
<h4 class="side-title suppliers-title">
|
||||
Furnizori - De plătit
|
||||
<span class="total-amount">{{ formatCurrency(suppliersTotal) }}</span>
|
||||
</h4>
|
||||
<div class="maturity-list">
|
||||
<div
|
||||
v-for="(supplier, index) in suppliersData"
|
||||
:key="`supplier-${index}`"
|
||||
class="maturity-item"
|
||||
:class="{ 'overdue': supplier.daysOverdue > 0, 'critical': supplier.daysOverdue > 30 }"
|
||||
>
|
||||
<div class="item-info">
|
||||
<span class="supplier-name">{{ supplier.name }}</span>
|
||||
<span class="due-info">
|
||||
<span v-if="supplier.daysOverdue > 0" class="overdue-days">
|
||||
Restant {{ supplier.daysOverdue }} zile
|
||||
</span>
|
||||
<span v-else class="due-date">
|
||||
Scadent în {{ Math.abs(supplier.daysOverdue) }} zile
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="amount-bar">
|
||||
<div class="bar-container">
|
||||
<div
|
||||
class="bar-fill suppliers-bar"
|
||||
:style="{ width: getBarWidth(supplier.amount, maxSupplierAmount) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="amount-value">{{ formatCurrency(supplier.amount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="suppliersData.length === 0" class="empty-state">
|
||||
<p>Nu există facturi de plătit pentru această perioadă</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Balance Indicator -->
|
||||
<div v-if="!isLoading && !error" class="balance-indicator">
|
||||
<div class="balance-content">
|
||||
<div class="balance-text">
|
||||
<span class="balance-label">{{ balanceLabel }}</span>
|
||||
<span class="balance-amount" :class="balanceClass">
|
||||
{{ formatCurrency(Math.abs(balance)) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="recommendations.length > 0" class="recommendations">
|
||||
<details>
|
||||
<summary>Recomandări</summary>
|
||||
<ul>
|
||||
<li v-for="(rec, index) in recommendations" :key="index">
|
||||
{{ rec }}
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with period info -->
|
||||
<div v-if="!isLoading && !error" class="card-footer">
|
||||
<div class="period-info">
|
||||
<span class="period-label">Perioada analizată:</span>
|
||||
<span class="period-value">{{ getPeriodLabel(selectedPeriod) }}</span>
|
||||
</div>
|
||||
<div class="last-updated">
|
||||
<span class="update-label">Actualizat:</span>
|
||||
<span class="update-time">{{ formatLastUpdated(lastUpdated) }}</span>
|
||||
<button @click="refreshData" class="refresh-btn" :disabled="isLoading" title="Reîmprospătează datele">
|
||||
<i class="pi pi-refresh refresh-icon" :class="{ 'spinning': isLoading }"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useDashboardStore } from '../../../stores/dashboard'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
companyId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['periodChanged'])
|
||||
|
||||
// Store
|
||||
const dashboardStore = useDashboardStore()
|
||||
|
||||
// Reactive state
|
||||
const selectedPeriod = ref('1m')
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
const lastUpdated = ref(null)
|
||||
|
||||
// Mock data structure - in production this would come from API
|
||||
const maturityData = ref({
|
||||
clients: [],
|
||||
suppliers: [],
|
||||
balance: 0,
|
||||
recommendations: []
|
||||
})
|
||||
|
||||
// Romanian currency formatter
|
||||
const formatCurrency = (value) => {
|
||||
if (value === null || value === undefined) return '0,00 RON'
|
||||
return new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
const clientsData = computed(() => maturityData.value.clients || [])
|
||||
const suppliersData = computed(() => maturityData.value.suppliers || [])
|
||||
const recommendations = computed(() => maturityData.value.recommendations || [])
|
||||
|
||||
const clientsTotal = computed(() =>
|
||||
clientsData.value.reduce((sum, client) => sum + (client.amount || 0), 0)
|
||||
)
|
||||
|
||||
const suppliersTotal = computed(() =>
|
||||
suppliersData.value.reduce((sum, supplier) => sum + (supplier.amount || 0), 0)
|
||||
)
|
||||
|
||||
const balance = computed(() => clientsTotal.value - suppliersTotal.value)
|
||||
|
||||
const balanceClass = computed(() =>
|
||||
balance.value < 0 ? 'deficit' : 'surplus'
|
||||
)
|
||||
|
||||
const balanceIcon = computed(() =>
|
||||
balance.value < 0 ? '📉' : '📈'
|
||||
)
|
||||
|
||||
const balanceLabel = computed(() =>
|
||||
balance.value < 0 ? 'Deficit estimat:' : 'Surplus estimat:'
|
||||
)
|
||||
|
||||
const maxClientAmount = computed(() =>
|
||||
Math.max(...clientsData.value.map(c => c.amount || 0), 1)
|
||||
)
|
||||
|
||||
const maxSupplierAmount = computed(() =>
|
||||
Math.max(...suppliersData.value.map(s => s.amount || 0), 1)
|
||||
)
|
||||
|
||||
// Methods
|
||||
const getBarWidth = (amount, maxAmount) => {
|
||||
return maxAmount > 0 ? Math.min((amount / maxAmount) * 100, 100) : 0
|
||||
}
|
||||
|
||||
const getPeriodLabel = (period) => {
|
||||
const labels = {
|
||||
'7d': 'Toate restanțele + următoarele 7 zile',
|
||||
'1m': 'Toate restanțele + următoarea lună',
|
||||
'3m': 'Toate restanțele + următoarele 3 luni',
|
||||
'6m': 'Toate restanțele + următoarele 6 luni',
|
||||
'12m': 'Toate restanțele + următorul an',
|
||||
'all': 'Toate soldurile (fără filtru)'
|
||||
}
|
||||
return labels[period] || period
|
||||
}
|
||||
|
||||
const formatLastUpdated = (timestamp) => {
|
||||
if (!timestamp) return 'Necunoscut'
|
||||
return new Date(timestamp).toLocaleString('ro-RO')
|
||||
}
|
||||
|
||||
const handlePeriodChange = () => {
|
||||
emit('periodChanged', selectedPeriod.value)
|
||||
loadData()
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
loadData(true)
|
||||
}
|
||||
|
||||
const loadData = async (forceRefresh = false) => {
|
||||
if (!props.companyId) {
|
||||
error.value = 'ID firmă necunoscut'
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Apelăm API-ul real pentru a obține datele de scadențe
|
||||
const response = await dashboardStore.loadMaturityData(props.companyId, selectedPeriod.value)
|
||||
|
||||
if (response && response.success) {
|
||||
maturityData.value = response.data
|
||||
lastUpdated.value = new Date()
|
||||
} else {
|
||||
throw new Error(response?.error || 'Eroare la încărcarea datelor')
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to load maturity data:', err)
|
||||
error.value = err.message || 'Eroare la încărcarea datelor. Vă rugăm încercați din nou.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(() => props.companyId, (newCompanyId) => {
|
||||
if (newCompanyId) {
|
||||
loadData()
|
||||
}
|
||||
}, { immediate: false })
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
if (props.companyId) {
|
||||
loadData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Base Card Styles */
|
||||
.maturity-card {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--card-radius, 8px);
|
||||
padding: 0;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all var(--transition-fast, 0.3s ease);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maturity-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Card Header */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-lg, 1rem);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg, 1.125rem);
|
||||
font-weight: var(--font-semibold, 600);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.period-selector:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.period-selector:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Loading and Error States */
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xl, 2rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top: 3px solid var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--space-md, 1rem);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: var(--space-sm, 0.5rem);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: var(--space-md, 1rem);
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
/* Comparison Layout */
|
||||
.maturity-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1px 1fr;
|
||||
gap: var(--space-lg, 1rem);
|
||||
padding: var(--space-lg, 1rem);
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.comparison-divider {
|
||||
background: var(--color-border);
|
||||
margin: var(--space-md, 1rem) 0;
|
||||
}
|
||||
|
||||
/* Side Headers */
|
||||
.side-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0 0 var(--space-md, 1rem) 0;
|
||||
font-size: var(--text-base, 1rem);
|
||||
font-weight: var(--font-semibold, 600);
|
||||
padding-bottom: var(--space-sm, 0.5rem);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.clients-title {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.suppliers-title {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
font-weight: var(--font-bold, 700);
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--color-bg-secondary, #f8f9fa);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
/* Maturity Lists */
|
||||
.maturity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm, 0.5rem);
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
padding-right: var(--space-xs, 0.25rem);
|
||||
}
|
||||
|
||||
.maturity-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.maturity-list::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary, #f8f9fa);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.maturity-list::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Maturity Items */
|
||||
.maturity-item {
|
||||
padding: var(--space-sm, 0.5rem);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.maturity-item:hover {
|
||||
background: var(--color-bg-secondary, #f8f9fa);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.maturity-item.overdue {
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.maturity-item.critical {
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-xs, 0.25rem);
|
||||
}
|
||||
|
||||
.client-name,
|
||||
.supplier-name {
|
||||
font-weight: var(--font-medium, 500);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
}
|
||||
|
||||
.due-info {
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.overdue-days {
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-medium, 500);
|
||||
}
|
||||
|
||||
.due-date {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Amount Bars */
|
||||
.amount-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 0.5rem);
|
||||
}
|
||||
|
||||
.bar-container {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--color-bg-secondary, #f8f9fa);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.clients-bar {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.suppliers-bar {
|
||||
background: var(--color-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
font-weight: var(--font-bold, 700);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xl, 2rem);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Balance Indicator */
|
||||
.balance-indicator {
|
||||
padding: var(--space-lg, 1rem) 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.balance-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 1rem);
|
||||
}
|
||||
|
||||
.balance-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs, 0.25rem);
|
||||
}
|
||||
|
||||
.balance-label {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.balance-amount {
|
||||
font-size: var(--text-lg, 1.125rem);
|
||||
font-weight: var(--font-bold, 700);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.balance-amount.surplus {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.balance-amount.deficit {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Recommendations */
|
||||
.recommendations {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.recommendations details {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.recommendations summary {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
color: var(--color-primary);
|
||||
list-style: none;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.recommendations summary:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.recommendations ul {
|
||||
margin: var(--space-sm, 0.5rem) 0 0 0;
|
||||
padding-left: var(--space-lg, 1rem);
|
||||
}
|
||||
|
||||
.recommendations li {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-xs, 0.25rem);
|
||||
}
|
||||
|
||||
/* Card Footer */
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-md, 0.75rem) var(--space-lg, 1rem);
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.period-info,
|
||||
.last-updated {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs, 0.25rem);
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
}
|
||||
|
||||
.period-label,
|
||||
.update-label {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.period-value,
|
||||
.update-time {
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-medium, 500);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
margin-left: var(--space-sm, 0.5rem);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: var(--color-bg-secondary, #f8f9fa);
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refresh-icon {
|
||||
display: inline-block;
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.refresh-icon.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.maturity-comparison {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-md, 1rem);
|
||||
}
|
||||
|
||||
.comparison-divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm, 0.5rem);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
text-align: center;
|
||||
font-size: var(--text-base, 1rem);
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.maturity-comparison {
|
||||
padding: var(--space-md, 0.75rem);
|
||||
}
|
||||
|
||||
.balance-content {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recommendations {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm, 0.5rem);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.side-title {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs, 0.25rem);
|
||||
}
|
||||
|
||||
.total-amount {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.maturity-card {
|
||||
margin: 0 -var(--space-sm, 0.5rem);
|
||||
}
|
||||
|
||||
.card-header,
|
||||
.maturity-comparison,
|
||||
.balance-indicator,
|
||||
.card-footer {
|
||||
padding: var(--space-md, 0.75rem);
|
||||
}
|
||||
|
||||
.maturity-list {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,708 @@
|
||||
<template>
|
||||
<div class="metric-card">
|
||||
<!-- Header with icon and title -->
|
||||
<div class="metric-header">
|
||||
<span class="metric-icon" :class="iconClass">{{ icon }}</span>
|
||||
<span class="metric-title">{{ title }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Main value display -->
|
||||
<div class="metric-value" :class="valueClass">
|
||||
{{ formatCurrency(value) }}
|
||||
</div>
|
||||
|
||||
<!-- Trend indicator -->
|
||||
<div class="metric-trend" :class="trendClass" v-if="trend">
|
||||
<span class="trend-icon">{{ trendIcon }}</span>
|
||||
<span class="trend-value">{{ Math.round(Math.abs(trend.value), 2) }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- Sparkline mini-chart - STACKED BELOW (Best Practice) -->
|
||||
<div class="metric-sparkline-container" v-if="sparklineData && sparklineData.length > 0">
|
||||
<canvas
|
||||
ref="sparklineCanvas"
|
||||
class="sparkline-canvas"
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown display section - Suport ierarhic -->
|
||||
<div class="metric-breakdown" v-if="breakdown">
|
||||
<div v-for="(value, key) in breakdown" :key="key" class="breakdown-section">
|
||||
|
||||
<!-- Valoare simplă (backward compatible) -->
|
||||
<div v-if="!isHierarchical(value)" class="breakdown-item">
|
||||
<span class="breakdown-label">{{ formatBreakdownLabel(key) }}:</span>
|
||||
<span class="breakdown-value">{{ formatCurrency(value) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Valoare ierarhică (cu sub-items) -->
|
||||
<div v-else class="breakdown-group">
|
||||
<div class="breakdown-item breakdown-header" @click="() => toggleExpanded(key)">
|
||||
<div class="breakdown-header-left">
|
||||
<span class="collapse-icon">{{ isItemExpanded(key) ? '▼' : '▶' }}</span>
|
||||
<span class="breakdown-label">{{ formatBreakdownLabel(key) }}:</span>
|
||||
</div>
|
||||
<span class="breakdown-value">{{ formatCurrency(value.total) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Sub-items (collapsible) -->
|
||||
<div v-if="value.items && value.items.length > 0" v-show="isItemExpanded(key)" class="breakdown-subitems">
|
||||
<div v-for="(item, idx) in value.items" :key="idx" class="breakdown-subitem">
|
||||
<span class="breakdown-sublabel">
|
||||
{{ item.nume }} <span v-if="item.cont" class="breakdown-cont">({{ item.cont }})</span>
|
||||
</span>
|
||||
<span class="breakdown-subvalue">{{ formatCurrency(item.sold) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(...registerables)
|
||||
|
||||
// Props definition with validation
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => value.length > 0
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => value.length > 0
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
trend: {
|
||||
type: Object,
|
||||
default: null,
|
||||
validator: (value) => {
|
||||
if (value === null) return true
|
||||
return typeof value.value === 'number' &&
|
||||
['up', 'down', 'neutral'].includes(value.direction)
|
||||
}
|
||||
},
|
||||
sparklineData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
validator: (value) => {
|
||||
return value.every(item => typeof item === 'number')
|
||||
}
|
||||
},
|
||||
sparklineLabels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
breakdown: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// Refs
|
||||
const sparklineCanvas = ref(null)
|
||||
let chartInstance = null
|
||||
const expandedStates = ref({})
|
||||
|
||||
// Toggle breakdown expansion for a specific key
|
||||
const toggleExpanded = (key) => {
|
||||
expandedStates.value[key] = !expandedStates.value[key]
|
||||
}
|
||||
|
||||
// Check if a specific breakdown item is expanded
|
||||
const isItemExpanded = (key) => {
|
||||
return !!expandedStates.value[key]
|
||||
}
|
||||
|
||||
// Format currency value
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount && amount !== 0) return '0 RON'
|
||||
|
||||
return new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(Math.abs(amount)).replace('RON', 'RON')
|
||||
}
|
||||
|
||||
// Format breakdown label
|
||||
const formatBreakdownLabel = (key) => {
|
||||
const labelMap = {
|
||||
'casa': 'Casă',
|
||||
'banca': 'Bancă',
|
||||
'clienti': 'Clienți',
|
||||
'furnizori': 'Furnizori',
|
||||
'clienti_in_termen': 'Clienți în termen',
|
||||
'clienti_restanti': 'Clienți restanți',
|
||||
'furnizori_termen': 'Furnizori în termen',
|
||||
'furnizori_scadent': 'Furnizori scadenți',
|
||||
'numerar': 'Numerar',
|
||||
'cont': 'Cont',
|
||||
'depozit': 'Depozit',
|
||||
'credit': 'Credit',
|
||||
'debit': 'Debit',
|
||||
'sold': 'Sold',
|
||||
'total': 'Total'
|
||||
}
|
||||
|
||||
return labelMap[key.toLowerCase()] || key.charAt(0).toUpperCase() + key.slice(1)
|
||||
}
|
||||
|
||||
// Check if value is hierarchical (has total and items)
|
||||
const isHierarchical = (value) => {
|
||||
return value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'total' in value &&
|
||||
'items' in value
|
||||
}
|
||||
|
||||
// Computed properties for styling
|
||||
const iconClass = computed(() => {
|
||||
return `icon-${props.title.toLowerCase().replace(/\s+/g, '-')}`
|
||||
})
|
||||
|
||||
const valueClass = computed(() => {
|
||||
if (!props.value && props.value !== 0) return ''
|
||||
return props.value < 0 ? 'negative' : 'positive'
|
||||
})
|
||||
|
||||
const trendClass = computed(() => {
|
||||
if (!props.trend) return ''
|
||||
|
||||
return {
|
||||
'trend-up': props.trend.direction === 'up',
|
||||
'trend-down': props.trend.direction === 'down',
|
||||
'trend-neutral': props.trend.direction === 'neutral'
|
||||
}
|
||||
})
|
||||
|
||||
const trendIcon = computed(() => {
|
||||
if (!props.trend) return ''
|
||||
|
||||
switch (props.trend.direction) {
|
||||
case 'up': return '▲'
|
||||
case 'down': return '▼'
|
||||
case 'neutral': return '▶'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
// Sparkline color based on trend
|
||||
const sparklineColor = computed(() => {
|
||||
if (!props.trend) {
|
||||
return '#3b82f6' // Primary blue
|
||||
}
|
||||
|
||||
switch (props.trend.direction) {
|
||||
case 'up':
|
||||
return '#10b981' // Success green
|
||||
case 'down':
|
||||
return '#ef4444' // Danger red
|
||||
default:
|
||||
return '#3b82f6' // Primary blue
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize Chart.js sparkline
|
||||
const initializeSparkline = async () => {
|
||||
if (!sparklineCanvas.value || !props.sparklineData || props.sparklineData.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Destroy existing chart instance
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
chartInstance = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const ctx = sparklineCanvas.value.getContext('2d')
|
||||
const color = sparklineColor.value
|
||||
|
||||
// Generate labels: use provided labels or generate generic ones
|
||||
const labels = props.sparklineLabels.length > 0
|
||||
? props.sparklineLabels
|
||||
: props.sparklineData.map((_, i) => `L${i + 1}`)
|
||||
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: props.sparklineData,
|
||||
borderColor: color,
|
||||
backgroundColor: `${color}20`, // 20 = 12.5% opacity in hex
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0, // Hide points by default
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: color,
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
// Show period label in tooltip
|
||||
return context[0].label || ''
|
||||
},
|
||||
label: (context) => {
|
||||
const value = context.parsed.y
|
||||
return new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(107, 114, 128, 0.7)',
|
||||
font: {
|
||||
size: 9,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 6
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
grid: {
|
||||
color: 'rgba(107, 114, 128, 0.1)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(107, 114, 128, 0.7)',
|
||||
font: {
|
||||
size: 9,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxTicksLimit: 4,
|
||||
callback: function(value) {
|
||||
// Format as compact currency
|
||||
if (value >= 1000000) {
|
||||
return (value / 1000000).toFixed(1) + 'M'
|
||||
} else if (value >= 1000) {
|
||||
return (value / 1000).toFixed(0) + 'k'
|
||||
}
|
||||
return value.toFixed(0)
|
||||
}
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
borderCapStyle: 'round',
|
||||
borderJoinStyle: 'round'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for data changes
|
||||
watch(() => props.sparklineData, async () => {
|
||||
await initializeSparkline()
|
||||
}, { deep: true })
|
||||
|
||||
watch(() => props.sparklineLabels, async () => {
|
||||
await initializeSparkline()
|
||||
}, { deep: true })
|
||||
|
||||
watch(() => props.trend, async () => {
|
||||
await initializeSparkline()
|
||||
}, { deep: true })
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(async () => {
|
||||
await initializeSparkline()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
chartInstance = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.metric-card {
|
||||
background: var(--color-bg, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--card-radius, 8px);
|
||||
padding: var(--space-lg, 1.5rem);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.metric-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
font-size: 1.25rem;
|
||||
display: inline-block;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-secondary, #f8fafc);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.metric-card:hover .metric-icon {
|
||||
background: var(--color-primary-light, #dbeafe);
|
||||
}
|
||||
|
||||
.metric-title {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
font-weight: var(--font-medium, 500);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Value */
|
||||
.metric-value {
|
||||
font-size: var(--text-2xl, 1.875rem);
|
||||
font-weight: var(--font-bold, 700);
|
||||
color: var(--color-text-primary, #111827);
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-value.positive {
|
||||
color: var(--color-success, #10b981);
|
||||
}
|
||||
|
||||
.metric-value.negative {
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
/* Trend - Positioned below value */
|
||||
.metric-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
font-weight: var(--font-medium, 500);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.trend-up {
|
||||
color: var(--color-success, #10b981);
|
||||
}
|
||||
|
||||
.trend-down {
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.trend-neutral {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.trend-icon {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.trend-value {
|
||||
font-weight: var(--font-semibold, 600);
|
||||
}
|
||||
|
||||
/* Sparkline Container - STACKED LAYOUT (Best Practice) */
|
||||
.metric-sparkline-container {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
background: var(--color-bg-secondary, #f8fafc);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.sparkline-canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.metric-card {
|
||||
min-height: 240px;
|
||||
padding: var(--space-md, 1rem);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: var(--text-xl, 1.5rem);
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.metric-trend {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-sparkline-container {
|
||||
height: 70px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.metric-card {
|
||||
min-height: 200px;
|
||||
padding: var(--space-sm, 0.75rem);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.metric-title {
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: var(--text-lg, 1.125rem);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.metric-trend {
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.metric-sparkline-container {
|
||||
height: 60px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* CSS Custom Properties fallbacks */
|
||||
:root {
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-secondary: #f8fafc;
|
||||
--color-border: #e5e7eb;
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-light: #dbeafe;
|
||||
--color-success: #10b981;
|
||||
--color-danger: #ef4444;
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #6b7280;
|
||||
--card-radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--space-sm: 0.75rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.5rem;
|
||||
--text-2xl: 1.875rem;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
}
|
||||
|
||||
/* Breakdown section */
|
||||
.metric-breakdown {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.breakdown-section {
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.breakdown-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.breakdown-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.breakdown-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.breakdown-label {
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-weight: var(--font-medium, 500);
|
||||
}
|
||||
|
||||
.breakdown-value {
|
||||
font-size: 1.125rem;
|
||||
color: var(--color-text-primary, #111827);
|
||||
font-weight: var(--font-semibold, 600);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Hierarchical breakdown styles */
|
||||
.breakdown-group {
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.breakdown-header {
|
||||
font-weight: var(--font-semibold, 600);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 0.625rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.breakdown-header:hover {
|
||||
background: var(--color-bg-secondary, #f8fafc);
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin: -0.25rem -0.5rem 0.625rem -0.5rem;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.breakdown-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
transition: transform 0.2s ease;
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.breakdown-subitems {
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
margin-top: 0.625rem;
|
||||
animation: slideDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.breakdown-subitem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.breakdown-sublabel {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.breakdown-cont {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.7;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.breakdown-subvalue {
|
||||
font-weight: var(--font-medium, 500);
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-primary, #111827);
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.metric-card {
|
||||
--color-bg: #1f2937;
|
||||
--color-bg-secondary: #374151;
|
||||
--color-border: #4b5563;
|
||||
--color-text-primary: #f9fafb;
|
||||
--color-text-secondary: #d1d5db;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,915 @@
|
||||
<template>
|
||||
<div class="performance-card card">
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<div class="header-title">
|
||||
<span class="card-icon">📊</span>
|
||||
<h3 class="card-title">Performanță & Cash Flow</h3>
|
||||
</div>
|
||||
<div class="period-selector">
|
||||
<select
|
||||
v-model="selectedPeriod"
|
||||
@change="handlePeriodChange"
|
||||
class="period-select"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<option value="7d">7 zile</option>
|
||||
<option value="1m">1 lună</option>
|
||||
<option value="3m">3 luni</option>
|
||||
<option value="6m">6 luni</option>
|
||||
<option value="ytd">YTD</option>
|
||||
<option value="12m">12 luni</option>
|
||||
</select>
|
||||
<i class="pi pi-chevron-down select-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<span class="loading-text">Se încarcă datele...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<i class="pi pi-exclamation-triangle error-icon"></i>
|
||||
<span class="error-text">{{ error }}</span>
|
||||
<button @click="retryLoad" class="retry-button">
|
||||
<i class="pi pi-refresh"></i>
|
||||
Reîncarcă
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<template v-else>
|
||||
<!-- Chart Container -->
|
||||
<div class="chart-container">
|
||||
<div class="chart-placeholder" v-if="!chartData?.labels?.length">
|
||||
<div class="placeholder-content">
|
||||
<i class="pi pi-chart-line placeholder-icon"></i>
|
||||
<span class="placeholder-text">Grafic încasări vs plăți</span>
|
||||
<small class="placeholder-subtitle">Datele vor fi afișate aici</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="chart-content">
|
||||
<div class="chart-legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-color income"></span>
|
||||
<span class="legend-label">Încasări</span>
|
||||
<span class="legend-value">{{ formatCurrency(totalIncome) }}</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color expenses"></span>
|
||||
<span class="legend-label">Plăți</span>
|
||||
<span class="legend-value">{{ formatCurrency(totalExpenses) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-canvas-container">
|
||||
<canvas
|
||||
ref="performanceChart"
|
||||
v-if="chartData?.labels?.length"
|
||||
width="400"
|
||||
height="200"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Indicators -->
|
||||
<div class="indicators-section">
|
||||
<div class="indicators-grid">
|
||||
<div class="indicator-card">
|
||||
<div class="indicator-icon">💰</div>
|
||||
<div class="indicator-content">
|
||||
<div class="indicator-label">Rata încasare</div>
|
||||
<div class="indicator-value" :class="getRateClass(performanceData.rataIncasare)">
|
||||
{{ performanceData.rataIncasare || 0 }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="indicator-card">
|
||||
<div class="indicator-icon">⏱️</div>
|
||||
<div class="indicator-content">
|
||||
<div class="indicator-label">Cash conversion</div>
|
||||
<div class="indicator-value">
|
||||
{{ performanceData.cashConversion || 0 }} zile
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="indicator-card">
|
||||
<div class="indicator-icon">📈</div>
|
||||
<div class="indicator-content">
|
||||
<div class="indicator-label">Trend</div>
|
||||
<div class="indicator-value" :class="getTrendClass(performanceData.trend)">
|
||||
<i :class="getTrendIcon(performanceData.trend)"></i>
|
||||
{{ getTrendText(performanceData.trend) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="indicator-card">
|
||||
<div class="indicator-icon">💼</div>
|
||||
<div class="indicator-content">
|
||||
<div class="indicator-label">Capital lucru</div>
|
||||
<div class="indicator-value" :class="getWorkingCapitalClass(performanceData.workingCapital)">
|
||||
{{ formatCurrency(performanceData.workingCapital || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
import { useDashboardStore } from '../../../stores/dashboard'
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(...registerables)
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
companyId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['periodChanged'])
|
||||
|
||||
// State
|
||||
const selectedPeriod = ref('7d')
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
const performanceChart = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
// Store
|
||||
const dashboardStore = useDashboardStore()
|
||||
|
||||
// Sample data (will be replaced with actual API data)
|
||||
const performanceData = ref({
|
||||
rataIncasare: 85.2,
|
||||
cashConversion: 45,
|
||||
trend: 'up',
|
||||
workingCapital: 125000
|
||||
})
|
||||
|
||||
const chartData = ref({
|
||||
labels: ['Lun', 'Mar', 'Mie', 'Joi', 'Vin', 'Sâm', 'Dum'],
|
||||
income: [12000, 15000, 8000, 22000, 18000, 14000, 16000],
|
||||
expenses: [8000, 12000, 15000, 16000, 14000, 11000, 13000]
|
||||
})
|
||||
|
||||
// Computed
|
||||
const totalIncome = computed(() => {
|
||||
return chartData.value.income?.reduce((sum, val) => sum + val, 0) || 0
|
||||
})
|
||||
|
||||
const totalExpenses = computed(() => {
|
||||
return chartData.value.expenses?.reduce((sum, val) => sum + val, 0) || 0
|
||||
})
|
||||
|
||||
const maxValue = computed(() => {
|
||||
const allValues = [...(chartData.value.income || []), ...(chartData.value.expenses || [])]
|
||||
return Math.max(...allValues, 0)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const handlePeriodChange = () => {
|
||||
emit('periodChanged', selectedPeriod.value)
|
||||
loadPerformanceData()
|
||||
}
|
||||
|
||||
const loadPerformanceData = async () => {
|
||||
if (!props.companyId) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// This will be replaced with actual API call
|
||||
// const result = await dashboardStore.loadPerformanceData(props.companyId, selectedPeriod.value)
|
||||
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// Mock data based on period
|
||||
const mockData = {
|
||||
'7d': {
|
||||
rataIncasare: 85.2,
|
||||
cashConversion: 45,
|
||||
trend: 'up',
|
||||
workingCapital: 125000,
|
||||
chartData: {
|
||||
labels: ['Lun', 'Mar', 'Mie', 'Joi', 'Vin', 'Sâm', 'Dum'],
|
||||
income: [12000, 15000, 8000, 22000, 18000, 14000, 16000],
|
||||
expenses: [8000, 12000, 15000, 16000, 14000, 11000, 13000]
|
||||
}
|
||||
},
|
||||
'1m': {
|
||||
rataIncasare: 78.5,
|
||||
cashConversion: 52,
|
||||
trend: 'stable',
|
||||
workingCapital: 89000,
|
||||
chartData: {
|
||||
labels: ['S1', 'S2', 'S3', 'S4'],
|
||||
income: [45000, 52000, 38000, 48000],
|
||||
expenses: [42000, 47000, 51000, 45000]
|
||||
}
|
||||
},
|
||||
'3m': {
|
||||
rataIncasare: 82.1,
|
||||
cashConversion: 38,
|
||||
trend: 'up',
|
||||
workingCapital: 156000,
|
||||
chartData: {
|
||||
labels: ['Ian', 'Feb', 'Mar'],
|
||||
income: [165000, 182000, 155000],
|
||||
expenses: [158000, 162000, 168000]
|
||||
}
|
||||
},
|
||||
'6m': {
|
||||
rataIncasare: 79.8,
|
||||
cashConversion: 41,
|
||||
trend: 'down',
|
||||
workingCapital: 98000,
|
||||
chartData: {
|
||||
labels: ['Oct', 'Noi', 'Dec', 'Ian', 'Feb', 'Mar'],
|
||||
income: [145000, 162000, 185000, 165000, 182000, 155000],
|
||||
expenses: [152000, 158000, 172000, 158000, 162000, 168000]
|
||||
}
|
||||
},
|
||||
'ytd': {
|
||||
rataIncasare: 81.3,
|
||||
cashConversion: 43,
|
||||
trend: 'stable',
|
||||
workingCapital: 142000,
|
||||
chartData: {
|
||||
labels: ['Q1', 'Q2', 'Q3'],
|
||||
income: [502000, 485000, 456000],
|
||||
expenses: [488000, 512000, 478000]
|
||||
}
|
||||
},
|
||||
'12m': {
|
||||
rataIncasare: 83.7,
|
||||
cashConversion: 39,
|
||||
trend: 'up',
|
||||
workingCapital: 178000,
|
||||
chartData: {
|
||||
labels: ['T1', 'T2', 'T3', 'T4'],
|
||||
income: [1456000, 1523000, 1387000, 1612000],
|
||||
expenses: [1423000, 1498000, 1456000, 1534000]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = mockData[selectedPeriod.value] || mockData['7d']
|
||||
performanceData.value = data
|
||||
chartData.value = data.chartData
|
||||
|
||||
// Initialize or update chart after data is loaded
|
||||
await nextTick()
|
||||
await updateChart()
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to load performance data:', err)
|
||||
error.value = 'Nu s-au putut încărca datele de performanță'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const retryLoad = () => {
|
||||
loadPerformanceData()
|
||||
}
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
if (value === null || value === undefined) return '0 RON'
|
||||
return new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
const initializeChart = async () => {
|
||||
if (!performanceChart.value || !chartData.value?.labels?.length) return
|
||||
|
||||
// Destroy existing chart instance
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
chartInstance = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const ctx = performanceChart.value.getContext('2d')
|
||||
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: chartData.value.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Încasări',
|
||||
data: chartData.value.income,
|
||||
borderColor: 'rgba(16, 185, 129, 1)', // var(--color-success)
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: 'rgba(16, 185, 129, 1)',
|
||||
pointBorderColor: '#ffffff',
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6
|
||||
},
|
||||
{
|
||||
label: 'Plăți',
|
||||
data: chartData.value.expenses,
|
||||
borderColor: 'rgba(239, 68, 68, 1)', // var(--color-error)
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: 'rgba(239, 68, 68, 1)',
|
||||
pointBorderColor: '#ffffff',
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false // We have our own custom legend
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 8,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const value = context.parsed.y
|
||||
const formattedValue = new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value)
|
||||
return `${context.dataset.label}: ${formattedValue}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(107, 114, 128, 0.8)',
|
||||
font: {
|
||||
size: 12,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(107, 114, 128, 0.1)',
|
||||
drawBorder: false
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(107, 114, 128, 0.8)',
|
||||
font: {
|
||||
size: 12,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
callback: function(value) {
|
||||
return new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
notation: 'compact'
|
||||
}).format(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
borderCapStyle: 'round',
|
||||
borderJoinStyle: 'round'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateChart = async () => {
|
||||
if (chartInstance && chartData.value?.labels?.length) {
|
||||
chartInstance.data.labels = chartData.value.labels
|
||||
chartInstance.data.datasets[0].data = chartData.value.income
|
||||
chartInstance.data.datasets[1].data = chartData.value.expenses
|
||||
chartInstance.update('active')
|
||||
} else {
|
||||
await initializeChart()
|
||||
}
|
||||
}
|
||||
|
||||
const getRateClass = (rate) => {
|
||||
if (rate >= 85) return 'rate-excellent'
|
||||
if (rate >= 75) return 'rate-good'
|
||||
if (rate >= 60) return 'rate-average'
|
||||
return 'rate-poor'
|
||||
}
|
||||
|
||||
const getTrendClass = (trend) => {
|
||||
switch (trend) {
|
||||
case 'up': return 'trend-up'
|
||||
case 'down': return 'trend-down'
|
||||
default: return 'trend-stable'
|
||||
}
|
||||
}
|
||||
|
||||
const getTrendIcon = (trend) => {
|
||||
switch (trend) {
|
||||
case 'up': return 'pi pi-arrow-up'
|
||||
case 'down': return 'pi pi-arrow-down'
|
||||
default: return 'pi pi-minus'
|
||||
}
|
||||
}
|
||||
|
||||
const getTrendText = (trend) => {
|
||||
switch (trend) {
|
||||
case 'up': return 'Crescător'
|
||||
case 'down': return 'Descrescător'
|
||||
default: return 'Stabil'
|
||||
}
|
||||
}
|
||||
|
||||
const getWorkingCapitalClass = (value) => {
|
||||
if (value > 100000) return 'capital-positive'
|
||||
if (value > 0) return 'capital-neutral'
|
||||
return 'capital-negative'
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(() => props.companyId, (newId) => {
|
||||
if (newId) {
|
||||
loadPerformanceData()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(chartData, async () => {
|
||||
if (chartData.value?.labels?.length) {
|
||||
await nextTick()
|
||||
await updateChart()
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
if (props.companyId) {
|
||||
await loadPerformanceData()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
chartInstance = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Performance Card Styles */
|
||||
.performance-card {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--card-radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all var(--transition-fast);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.performance-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.card-header {
|
||||
padding: var(--space-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Period Selector */
|
||||
.period-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.period-select {
|
||||
appearance: none;
|
||||
padding: var(--space-sm) var(--space-xl) var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.period-select:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.period-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.period-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.select-icon {
|
||||
position: absolute;
|
||||
right: var(--space-sm);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.card-body {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2xl);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2xl);
|
||||
gap: var(--space-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: var(--color-error);
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--color-error);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
/* Chart Container */
|
||||
.chart-container {
|
||||
margin-bottom: var(--space-xl);
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: var(--text-3xl);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.placeholder-subtitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Chart Content */
|
||||
.chart-content {
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: var(--space-md);
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.legend-color.income {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.legend-color.expenses {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.legend-value {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.chart-canvas-container {
|
||||
padding: var(--space-lg);
|
||||
height: 200px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-canvas-container canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Indicators */
|
||||
.indicators-section {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.indicators-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.indicator-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.indicator-card:hover {
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.indicator-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-lg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.indicator-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.indicator-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.indicator-value {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Indicator Value Colors */
|
||||
.rate-excellent { color: var(--color-success); }
|
||||
.rate-good { color: #10b981; }
|
||||
.rate-average { color: var(--color-warning); }
|
||||
.rate-poor { color: var(--color-error); }
|
||||
|
||||
.trend-up { color: var(--color-success); }
|
||||
.trend-down { color: var(--color-error); }
|
||||
.trend-stable { color: var(--color-secondary); }
|
||||
|
||||
.capital-positive { color: var(--color-success); }
|
||||
.capital-neutral { color: var(--color-warning); }
|
||||
.capital-negative { color: var(--color-error); }
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.indicators-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.indicator-card {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.indicator-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.chart-canvas-container {
|
||||
height: 160px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card-header,
|
||||
.card-body {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.indicator-value {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,858 @@
|
||||
<template>
|
||||
<div class="treasury-dual-card">
|
||||
<!-- Main values section - Split layout (Casa | Bancă) -->
|
||||
<div class="values-section">
|
||||
<!-- Casa Section -->
|
||||
<div class="value-block casa">
|
||||
<div class="value-label">Casa</div>
|
||||
<div class="value-amount positive">
|
||||
{{ formatCurrency(casaTotal) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Bancă Section -->
|
||||
<div class="value-block banca">
|
||||
<div class="value-label">Bancă</div>
|
||||
<div class="value-amount positive">
|
||||
{{ formatCurrency(bancaTotal) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dual sparkline charts - stacked vertical -->
|
||||
<div class="sparkline-dual-container" v-if="hasSparklineData">
|
||||
<!-- Grafic Casa -->
|
||||
<div class="sparkline-wrapper">
|
||||
<div class="sparkline-label">Casa</div>
|
||||
<div class="sparkline-chart">
|
||||
<canvas ref="casaCanvas" class="sparkline-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grafic Bancă -->
|
||||
<div class="sparkline-wrapper">
|
||||
<div class="sparkline-label">Bancă</div>
|
||||
<div class="sparkline-chart">
|
||||
<canvas ref="bancaCanvas" class="sparkline-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown section -->
|
||||
<div class="breakdown-section" v-if="casaItems.length > 0 || bancaItems.length > 0">
|
||||
<!-- Casa Breakdown -->
|
||||
<div class="breakdown-group" v-if="casaItems.length > 0">
|
||||
<div class="breakdown-header" @click="toggleCasaExpanded">
|
||||
<div class="breakdown-header-left">
|
||||
<span class="collapse-icon">{{ isCasaExpanded ? '▼' : '▶' }}</span>
|
||||
<span class="breakdown-label">Casa</span>
|
||||
</div>
|
||||
<span class="breakdown-value">{{ formatCurrency(casaTotal) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Casa Sub-items -->
|
||||
<div v-show="isCasaExpanded" class="breakdown-subitems">
|
||||
<div v-for="(item, idx) in casaItems" :key="idx" class="breakdown-subitem">
|
||||
<span class="breakdown-sublabel">
|
||||
{{ item.nume || `Cont ${item.cont}` }}
|
||||
<span v-if="item.cont" class="breakdown-cont">({{ item.cont }})</span>
|
||||
</span>
|
||||
<span class="breakdown-subvalue">{{ formatCurrency(item.sold) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bancă Breakdown -->
|
||||
<div class="breakdown-group" v-if="bancaItems.length > 0">
|
||||
<div class="breakdown-header" @click="toggleBancaExpanded">
|
||||
<div class="breakdown-header-left">
|
||||
<span class="collapse-icon">{{ isBancaExpanded ? '▼' : '▶' }}</span>
|
||||
<span class="breakdown-label">Bancă</span>
|
||||
</div>
|
||||
<span class="breakdown-value">{{ formatCurrency(bancaTotal) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Bancă Sub-items -->
|
||||
<div v-show="isBancaExpanded" class="breakdown-subitems">
|
||||
<div v-for="(item, idx) in bancaItems" :key="idx" class="breakdown-subitem">
|
||||
<span class="breakdown-sublabel">
|
||||
{{ item.nume || `Cont ${item.cont}` }}
|
||||
<span v-if="item.cont" class="breakdown-cont">({{ item.cont }})</span>
|
||||
</span>
|
||||
<span class="breakdown-subvalue">{{ formatCurrency(item.sold) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
const props = defineProps({
|
||||
casaTotal: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
bancaTotal: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
casaItems: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
bancaItems: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
casaSparklineData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
bancaSparklineData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
casaPreviousSparklineData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
bancaPreviousSparklineData: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
sparklineLabels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
previousSparklineLabels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
trend: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// Refs pentru 2 canvas-uri separate
|
||||
const casaCanvas = ref(null)
|
||||
const bancaCanvas = ref(null)
|
||||
let casaChartInstance = null
|
||||
let bancaChartInstance = null
|
||||
const isCasaExpanded = ref(false)
|
||||
const isBancaExpanded = ref(false)
|
||||
|
||||
// Toggle functions
|
||||
const toggleCasaExpanded = () => {
|
||||
isCasaExpanded.value = !isCasaExpanded.value
|
||||
}
|
||||
|
||||
const toggleBancaExpanded = () => {
|
||||
isBancaExpanded.value = !isBancaExpanded.value
|
||||
}
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount && amount !== 0) return '0 RON'
|
||||
return new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(Math.abs(amount))
|
||||
}
|
||||
|
||||
// Check if sparkline data exists
|
||||
const hasSparklineData = computed(() => {
|
||||
return props.casaSparklineData.length > 0 && props.bancaSparklineData.length > 0
|
||||
})
|
||||
|
||||
// Initialize Casa chart
|
||||
const initializeCasaChart = async () => {
|
||||
if (!casaCanvas.value || props.casaSparklineData.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Destroy existing chart
|
||||
if (casaChartInstance) {
|
||||
casaChartInstance.destroy()
|
||||
casaChartInstance = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const ctx = casaCanvas.value.getContext('2d')
|
||||
|
||||
// Generate labels
|
||||
const labels = props.sparklineLabels.length > 0
|
||||
? props.sparklineLabels
|
||||
: props.casaSparklineData.map((_, i) => `L${i + 1}`)
|
||||
|
||||
// Prepare datasets
|
||||
const datasets = [{
|
||||
label: 'Casa (curent)',
|
||||
data: props.casaSparklineData,
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: '#10b981',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2
|
||||
}]
|
||||
|
||||
// Add previous year dataset if available
|
||||
if (props.casaPreviousSparklineData && props.casaPreviousSparklineData.length > 0) {
|
||||
datasets.push({
|
||||
label: 'Casa (anul precedent)',
|
||||
data: props.casaPreviousSparklineData,
|
||||
borderColor: 'rgba(16, 185, 129, 0.4)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.05)',
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: 'rgba(16, 185, 129, 0.4)',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate limits including both datasets
|
||||
const allDataPoints = [...props.casaSparklineData]
|
||||
if (props.casaPreviousSparklineData && props.casaPreviousSparklineData.length > 0) {
|
||||
allDataPoints.push(...props.casaPreviousSparklineData)
|
||||
}
|
||||
const dataMin = Math.min(...allDataPoints)
|
||||
const dataMax = Math.max(...allDataPoints)
|
||||
const dataRange = dataMax - dataMin
|
||||
const dataPadding = dataRange * 0.05
|
||||
|
||||
casaChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: datasets.length > 1,
|
||||
position: 'top',
|
||||
align: 'end',
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 10,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
color: 'rgba(107, 114, 128, 0.9)',
|
||||
usePointStyle: true,
|
||||
pointStyle: 'line'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
title: (context) => context[0].label || '',
|
||||
label: (context) => {
|
||||
const value = context.parsed.y
|
||||
const label = context.dataset.label || ''
|
||||
const formattedValue = new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value)
|
||||
return `${label}: ${formattedValue}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(107, 114, 128, 0.7)',
|
||||
font: {
|
||||
size: 10,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 6
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
min: dataMin - dataPadding,
|
||||
max: dataMax + dataPadding,
|
||||
grid: {
|
||||
color: 'rgba(107, 114, 128, 0.1)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#10b981',
|
||||
font: {
|
||||
size: 11,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxTicksLimit: 3,
|
||||
callback: function(value) {
|
||||
if (value >= 1000000) {
|
||||
return (value / 1000000).toFixed(1) + 'M'
|
||||
} else if (value >= 1000) {
|
||||
return (value / 1000).toFixed(0) + 'k'
|
||||
}
|
||||
return value.toFixed(0)
|
||||
}
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize Bancă chart
|
||||
const initializeBancaChart = async () => {
|
||||
if (!bancaCanvas.value || props.bancaSparklineData.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Destroy existing chart
|
||||
if (bancaChartInstance) {
|
||||
bancaChartInstance.destroy()
|
||||
bancaChartInstance = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const ctx = bancaCanvas.value.getContext('2d')
|
||||
|
||||
// Generate labels
|
||||
const labels = props.sparklineLabels.length > 0
|
||||
? props.sparklineLabels
|
||||
: props.bancaSparklineData.map((_, i) => `L${i + 1}`)
|
||||
|
||||
// Prepare datasets
|
||||
const datasets = [{
|
||||
label: 'Bancă (curent)',
|
||||
data: props.bancaSparklineData,
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: '#3b82f6',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2
|
||||
}]
|
||||
|
||||
// Add previous year dataset if available
|
||||
if (props.bancaPreviousSparklineData && props.bancaPreviousSparklineData.length > 0) {
|
||||
datasets.push({
|
||||
label: 'Bancă (anul precedent)',
|
||||
data: props.bancaPreviousSparklineData,
|
||||
borderColor: 'rgba(59, 130, 246, 0.4)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.05)',
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: 'rgba(59, 130, 246, 0.4)',
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate limits including both datasets
|
||||
const allDataPoints = [...props.bancaSparklineData]
|
||||
if (props.bancaPreviousSparklineData && props.bancaPreviousSparklineData.length > 0) {
|
||||
allDataPoints.push(...props.bancaPreviousSparklineData)
|
||||
}
|
||||
const dataMin = Math.min(...allDataPoints)
|
||||
const dataMax = Math.max(...allDataPoints)
|
||||
const dataRange = dataMax - dataMin
|
||||
const dataPadding = dataRange * 0.05
|
||||
|
||||
bancaChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: datasets.length > 1,
|
||||
position: 'top',
|
||||
align: 'end',
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
font: {
|
||||
size: 10,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
color: 'rgba(107, 114, 128, 0.9)',
|
||||
usePointStyle: true,
|
||||
pointStyle: 'line'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
title: (context) => context[0].label || '',
|
||||
label: (context) => {
|
||||
const value = context.parsed.y
|
||||
const label = context.dataset.label || ''
|
||||
const formattedValue = new Intl.NumberFormat('ro-RO', {
|
||||
style: 'currency',
|
||||
currency: 'RON',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value)
|
||||
return `${label}: ${formattedValue}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(107, 114, 128, 0.7)',
|
||||
font: {
|
||||
size: 10,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 6
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
min: dataMin - dataPadding,
|
||||
max: dataMax + dataPadding,
|
||||
grid: {
|
||||
color: 'rgba(107, 114, 128, 0.1)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#3b82f6',
|
||||
font: {
|
||||
size: 11,
|
||||
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
|
||||
},
|
||||
maxTicksLimit: 3,
|
||||
callback: function(value) {
|
||||
if (value >= 1000000) {
|
||||
return (value / 1000000).toFixed(1) + 'M'
|
||||
} else if (value >= 1000) {
|
||||
return (value / 1000).toFixed(0) + 'k'
|
||||
}
|
||||
return value.toFixed(0)
|
||||
}
|
||||
},
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for data changes
|
||||
watch(() => [
|
||||
props.casaSparklineData,
|
||||
props.bancaSparklineData,
|
||||
props.sparklineLabels,
|
||||
props.casaPreviousSparklineData,
|
||||
props.bancaPreviousSparklineData,
|
||||
props.previousSparklineLabels
|
||||
], async () => {
|
||||
await Promise.all([
|
||||
initializeCasaChart(),
|
||||
initializeBancaChart()
|
||||
])
|
||||
}, { deep: true })
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
initializeCasaChart(),
|
||||
initializeBancaChart()
|
||||
])
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (casaChartInstance) {
|
||||
casaChartInstance.destroy()
|
||||
casaChartInstance = null
|
||||
}
|
||||
if (bancaChartInstance) {
|
||||
bancaChartInstance.destroy()
|
||||
bancaChartInstance = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* === TYPOGRAPHY TOKENS === */
|
||||
:root {
|
||||
--card-label-size: 0.875rem;
|
||||
--card-value-size: 1.5rem;
|
||||
--card-trend-size: 0.75rem;
|
||||
--breakdown-label-size: 0.875rem;
|
||||
--breakdown-value-size: 0.9375rem;
|
||||
--breakdown-sub-label-size: 0.8125rem;
|
||||
--breakdown-sub-value-size: 0.8125rem;
|
||||
|
||||
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.treasury-dual-card {
|
||||
background: var(--color-bg, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--card-radius, 8px);
|
||||
padding: var(--space-lg, 1.5rem);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.treasury-dual-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
/* Values section - Split layout */
|
||||
.values-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.value-block {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.value-label {
|
||||
font-size: var(--card-label-size);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.value-amount {
|
||||
font-size: var(--card-value-size);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.value-amount.positive {
|
||||
color: var(--color-success, #10b981);
|
||||
}
|
||||
|
||||
.value-amount.negative {
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.value-amount.neutral {
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: var(--color-border, #e5e7eb);
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* Dual sparkline container - stack vertical */
|
||||
.sparkline-dual-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.sparkline-wrapper {
|
||||
width: 100%;
|
||||
background: var(--color-bg-secondary, #f8fafc);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.sparkline-label {
|
||||
font-size: var(--card-label-size);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Culori distinctive pentru label-uri */
|
||||
.sparkline-wrapper:first-child .sparkline-label {
|
||||
color: #10b981; /* Verde pentru Casa */
|
||||
}
|
||||
|
||||
.sparkline-wrapper:last-child .sparkline-label {
|
||||
color: #3b82f6; /* Albastru pentru Bancă */
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sparkline-canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Breakdown section */
|
||||
.breakdown-section {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.breakdown-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.breakdown-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.breakdown-header:hover {
|
||||
background: var(--color-bg-secondary, #f8fafc);
|
||||
}
|
||||
|
||||
.breakdown-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
transition: transform 0.2s ease;
|
||||
display: inline-block;
|
||||
width: 0.875rem;
|
||||
}
|
||||
|
||||
.breakdown-label {
|
||||
font-size: var(--breakdown-label-size);
|
||||
color: var(--color-text, #111827);
|
||||
font-weight: 500;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.breakdown-value {
|
||||
font-size: var(--breakdown-value-size);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.breakdown-subitems {
|
||||
padding-left: 0;
|
||||
animation: slideDown 0.2s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.breakdown-subitem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem 0.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.breakdown-sublabel {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: var(--breakdown-sub-label-size);
|
||||
font-weight: 400;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.breakdown-cont {
|
||||
font-size: var(--breakdown-sub-label-size);
|
||||
opacity: 0.7;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.breakdown-subvalue {
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--breakdown-sub-value-size);
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.treasury-dual-card {
|
||||
min-height: 380px;
|
||||
padding: var(--space-md, 1rem);
|
||||
}
|
||||
|
||||
.values-section {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.treasury-dual-card {
|
||||
min-height: 340px;
|
||||
padding: 0.5rem 0.25rem;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.sparkline-wrapper {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.values-section {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.treasury-dual-card {
|
||||
--color-bg: #1f2937;
|
||||
--color-bg-secondary: #374151;
|
||||
--color-border: #4b5563;
|
||||
--color-text: #f9fafb;
|
||||
--color-text-secondary: #d1d5db;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
255
reports-app/frontend/src/components/layout/DashboardHeader.vue
Normal file
255
reports-app/frontend/src/components/layout/DashboardHeader.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<header class="header-container">
|
||||
<nav class="header-nav">
|
||||
<!-- Left side: Brand + Hamburger -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
class="hamburger-btn"
|
||||
:class="{ active: menuOpen }"
|
||||
@click="toggleMenu"
|
||||
aria-label="Toggle navigation menu"
|
||||
>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
</button>
|
||||
|
||||
<router-link to="/dashboard" class="header-brand">
|
||||
<span>ROA2WEB</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Company + User -->
|
||||
<div class="header-actions">
|
||||
<CompanySelectorMini
|
||||
v-model="selectedCompany"
|
||||
@company-changed="onCompanyChanged"
|
||||
/>
|
||||
<div class="user-menu-container">
|
||||
<div class="header-user" @click="toggleUserMenu">
|
||||
<i class="pi pi-user"></i>
|
||||
<span class="desktop-only">{{ currentUser?.username || 'User' }}</span>
|
||||
<i class="pi pi-chevron-down" :class="{ 'rotate-180': userMenuOpen }"></i>
|
||||
</div>
|
||||
|
||||
<!-- User Dropdown Menu -->
|
||||
<div v-if="userMenuOpen" class="user-dropdown">
|
||||
<div class="user-dropdown-header">
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ currentUser?.username || 'User' }}</div>
|
||||
<div class="user-email">{{ currentUser?.email || '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-dropdown-divider"></div>
|
||||
<button class="user-dropdown-item" @click="navigateToTelegram">
|
||||
<i class="pi pi-telegram"></i>
|
||||
<span>Telegram Bot</span>
|
||||
</button>
|
||||
<div class="user-dropdown-divider"></div>
|
||||
<button class="user-dropdown-item" @click="handleLogout">
|
||||
<i class="pi pi-sign-out"></i>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Overlay for user menu -->
|
||||
<div
|
||||
v-if="userMenuOpen"
|
||||
class="user-menu-overlay"
|
||||
@click="closeUserMenu"
|
||||
></div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CompanySelectorMini from '../dashboard/CompanySelectorMini.vue'
|
||||
import { useCompanyStore } from '../../stores/companies'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
export default {
|
||||
name: 'DashboardHeader',
|
||||
components: {
|
||||
CompanySelectorMini
|
||||
},
|
||||
emits: ['menu-toggle', 'company-changed'],
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const companiesStore = useCompanyStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const menuOpen = ref(false)
|
||||
const userMenuOpen = ref(false)
|
||||
|
||||
const selectedCompany = computed({
|
||||
get: () => companiesStore.selectedCompany,
|
||||
set: (value) => companiesStore.setSelectedCompany(value)
|
||||
})
|
||||
|
||||
const currentUser = computed(() => authStore.currentUser)
|
||||
|
||||
const toggleMenu = () => {
|
||||
menuOpen.value = !menuOpen.value
|
||||
emit('menu-toggle', menuOpen.value)
|
||||
}
|
||||
|
||||
const toggleUserMenu = () => {
|
||||
userMenuOpen.value = !userMenuOpen.value
|
||||
}
|
||||
|
||||
const closeUserMenu = () => {
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
|
||||
const onCompanyChanged = (company) => {
|
||||
emit('company-changed', company)
|
||||
}
|
||||
|
||||
const navigateToTelegram = async () => {
|
||||
try {
|
||||
closeUserMenu()
|
||||
await router.push('/telegram')
|
||||
} catch (error) {
|
||||
console.error('Navigation error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
authStore.logout()
|
||||
closeUserMenu()
|
||||
await router.push('/login')
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
menuOpen,
|
||||
userMenuOpen,
|
||||
selectedCompany,
|
||||
currentUser,
|
||||
toggleMenu,
|
||||
toggleUserMenu,
|
||||
closeUserMenu,
|
||||
onCompanyChanged,
|
||||
navigateToTelegram,
|
||||
handleLogout
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* User Menu Container */
|
||||
.user-menu-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* User Dropdown */
|
||||
.user-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: var(--z-dropdown);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-dropdown-header {
|
||||
padding: var(--space-md);
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.user-email {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.user-dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.user-dropdown-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.user-dropdown-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.user-dropdown-item:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
/* User Menu Overlay */
|
||||
.user-menu-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Chevron rotation animation */
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.pi-chevron-down {
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.user-dropdown {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.user-dropdown-header {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.user-dropdown-item {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
75
reports-app/frontend/src/components/layout/HamburgerMenu.vue
Normal file
75
reports-app/frontend/src/components/layout/HamburgerMenu.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Menu Overlay -->
|
||||
<div
|
||||
class="slide-menu-overlay"
|
||||
:class="{ open: isOpen }"
|
||||
@click="closeMenu"
|
||||
></div>
|
||||
|
||||
<!-- Slide Menu -->
|
||||
<nav class="slide-menu" :class="{ open: isOpen }">
|
||||
<!-- Navigation Section -->
|
||||
<div class="menu-section">
|
||||
<h3 class="menu-title">Navigation</h3>
|
||||
<ul class="menu-list">
|
||||
<li class="menu-item">
|
||||
<router-link
|
||||
to="/dashboard"
|
||||
class="menu-link"
|
||||
:class="{ active: $route.name === 'Dashboard' }"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<i class="menu-icon pi pi-home"></i>
|
||||
<span>Dashboard</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<router-link
|
||||
to="/invoices"
|
||||
class="menu-link"
|
||||
:class="{ active: $route.name === 'Invoices' }"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<i class="menu-icon pi pi-file-text"></i>
|
||||
<span>Invoices</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<router-link
|
||||
to="/bank-cash-register"
|
||||
class="menu-link"
|
||||
:class="{ active: $route.name === 'BankCashRegister' }"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<i class="menu-icon pi pi-money-bill"></i>
|
||||
<span>Bank & Cash</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'HamburgerMenu',
|
||||
props: {
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['close'],
|
||||
setup(props, { emit }) {
|
||||
const closeMenu = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
return {
|
||||
closeMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
7
reports-app/frontend/src/composables/index.js
Normal file
7
reports-app/frontend/src/composables/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
useResponsive,
|
||||
useMobileNav,
|
||||
useResponsiveTable,
|
||||
useResponsiveForm,
|
||||
useResponsiveGrid,
|
||||
} from "./useResponsive";
|
||||
311
reports-app/frontend/src/composables/useResponsive.js
Normal file
311
reports-app/frontend/src/composables/useResponsive.js
Normal file
@@ -0,0 +1,311 @@
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
|
||||
/**
|
||||
* Composable for responsive design utilities
|
||||
* Provides reactive breakpoint detection and mobile/desktop states
|
||||
*/
|
||||
export function useResponsive() {
|
||||
const windowWidth = ref(0);
|
||||
const windowHeight = ref(0);
|
||||
|
||||
// Breakpoint definitions (matching our CSS)
|
||||
const breakpoints = {
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
"2xl": 1536,
|
||||
};
|
||||
|
||||
// Update window dimensions
|
||||
const updateDimensions = () => {
|
||||
windowWidth.value = window.innerWidth;
|
||||
windowHeight.value = window.innerHeight;
|
||||
};
|
||||
|
||||
// Reactive breakpoint states
|
||||
const isMobile = computed(() => windowWidth.value < breakpoints.md);
|
||||
const isTablet = computed(
|
||||
() =>
|
||||
windowWidth.value >= breakpoints.md && windowWidth.value < breakpoints.lg,
|
||||
);
|
||||
const isDesktop = computed(() => windowWidth.value >= breakpoints.lg);
|
||||
const isSmallScreen = computed(() => windowWidth.value < breakpoints.sm);
|
||||
const isLargeScreen = computed(() => windowWidth.value >= breakpoints.xl);
|
||||
|
||||
// Specific breakpoint checks
|
||||
const isAbove = (breakpoint) =>
|
||||
computed(() => windowWidth.value >= breakpoints[breakpoint]);
|
||||
const isBelow = (breakpoint) =>
|
||||
computed(() => windowWidth.value < breakpoints[breakpoint]);
|
||||
const isBetween = (min, max) =>
|
||||
computed(
|
||||
() =>
|
||||
windowWidth.value >= breakpoints[min] &&
|
||||
windowWidth.value < breakpoints[max],
|
||||
);
|
||||
|
||||
// Device type detection
|
||||
const isTouchDevice = computed(
|
||||
() => "ontouchstart" in window || navigator.maxTouchPoints > 0,
|
||||
);
|
||||
const isPortrait = computed(() => window.innerHeight > window.innerWidth);
|
||||
const isLandscape = computed(() => window.innerWidth > window.innerHeight);
|
||||
|
||||
// Screen size categories
|
||||
const screenSize = computed(() => {
|
||||
if (windowWidth.value < breakpoints.sm) return "xs";
|
||||
if (windowWidth.value < breakpoints.md) return "sm";
|
||||
if (windowWidth.value < breakpoints.lg) return "md";
|
||||
if (windowWidth.value < breakpoints.xl) return "lg";
|
||||
if (windowWidth.value < breakpoints["2xl"]) return "xl";
|
||||
return "2xl";
|
||||
});
|
||||
|
||||
// Grid columns helper based on screen size
|
||||
const getGridCols = (config = {}) => {
|
||||
const defaultConfig = {
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 2,
|
||||
lg: 3,
|
||||
xl: 4,
|
||||
"2xl": 4,
|
||||
};
|
||||
const cols = { ...defaultConfig, ...config };
|
||||
return cols[screenSize.value] || cols.lg;
|
||||
};
|
||||
|
||||
// Table rows per page based on screen size
|
||||
const getTableRows = (config = {}) => {
|
||||
const defaultConfig = {
|
||||
xs: 10,
|
||||
sm: 15,
|
||||
md: 25,
|
||||
lg: 50,
|
||||
xl: 100,
|
||||
"2xl": 100,
|
||||
};
|
||||
const rows = { ...defaultConfig, ...config };
|
||||
return rows[screenSize.value] || rows.lg;
|
||||
};
|
||||
|
||||
// Component size variants
|
||||
const getComponentSize = () => {
|
||||
if (isMobile.value) return "small";
|
||||
if (isTablet.value) return "normal";
|
||||
return "large";
|
||||
};
|
||||
|
||||
// Padding/margin helpers
|
||||
const getSpacing = (config = {}) => {
|
||||
const defaultConfig = {
|
||||
xs: "0.5rem",
|
||||
sm: "0.75rem",
|
||||
md: "1rem",
|
||||
lg: "1.5rem",
|
||||
xl: "2rem",
|
||||
"2xl": "2rem",
|
||||
};
|
||||
const spacing = { ...defaultConfig, ...config };
|
||||
return spacing[screenSize.value] || spacing.lg;
|
||||
};
|
||||
|
||||
// Setup event listeners
|
||||
onMounted(() => {
|
||||
updateDimensions();
|
||||
window.addEventListener("resize", updateDimensions);
|
||||
window.addEventListener("orientationchange", () => {
|
||||
// Delay to ensure correct dimensions after orientation change
|
||||
setTimeout(updateDimensions, 100);
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", updateDimensions);
|
||||
window.removeEventListener("orientationchange", updateDimensions);
|
||||
});
|
||||
|
||||
return {
|
||||
// Dimensions
|
||||
windowWidth,
|
||||
windowHeight,
|
||||
|
||||
// Breakpoint states
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
isSmallScreen,
|
||||
isLargeScreen,
|
||||
|
||||
// Breakpoint utilities
|
||||
isAbove,
|
||||
isBelow,
|
||||
isBetween,
|
||||
|
||||
// Device detection
|
||||
isTouchDevice,
|
||||
isPortrait,
|
||||
isLandscape,
|
||||
|
||||
// Screen info
|
||||
screenSize,
|
||||
|
||||
// Helpers
|
||||
getGridCols,
|
||||
getTableRows,
|
||||
getComponentSize,
|
||||
getSpacing,
|
||||
|
||||
// Breakpoints reference
|
||||
breakpoints,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for mobile navigation
|
||||
*/
|
||||
export function useMobileNav() {
|
||||
const isMenuOpen = ref(false);
|
||||
const { isMobile } = useResponsive();
|
||||
|
||||
const toggleMenu = () => {
|
||||
isMenuOpen.value = !isMenuOpen.value;
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
isMenuOpen.value = false;
|
||||
};
|
||||
|
||||
const openMenu = () => {
|
||||
isMenuOpen.value = true;
|
||||
};
|
||||
|
||||
// Close menu when switching to desktop
|
||||
const handleResize = () => {
|
||||
if (!isMobile.value) {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("resize", handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
});
|
||||
|
||||
return {
|
||||
isMenuOpen,
|
||||
toggleMenu,
|
||||
closeMenu,
|
||||
openMenu,
|
||||
isMobile,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for responsive table behavior
|
||||
*/
|
||||
export function useResponsiveTable() {
|
||||
const { isMobile, isTablet, getTableRows } = useResponsive();
|
||||
|
||||
const shouldStackTable = computed(() => isMobile.value);
|
||||
const shouldShowPagination = computed(() => !isMobile.value);
|
||||
const defaultRows = computed(() => getTableRows());
|
||||
|
||||
// Mobile table item renderer
|
||||
const getMobileTableClass = () => {
|
||||
return shouldStackTable.value ? "mobile-stack" : "";
|
||||
};
|
||||
|
||||
// Get visible columns for mobile
|
||||
const getMobileColumns = (
|
||||
allColumns,
|
||||
priority = ["title", "amount", "status"],
|
||||
) => {
|
||||
if (!isMobile.value) return allColumns;
|
||||
return allColumns.filter((col) => priority.includes(col.key));
|
||||
};
|
||||
|
||||
return {
|
||||
shouldStackTable,
|
||||
shouldShowPagination,
|
||||
defaultRows,
|
||||
getMobileTableClass,
|
||||
getMobileColumns,
|
||||
isMobile,
|
||||
isTablet,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for responsive forms
|
||||
*/
|
||||
export function useResponsiveForm() {
|
||||
const { isMobile, getSpacing } = useResponsive();
|
||||
|
||||
const getFormLayout = () => {
|
||||
return isMobile.value ? "vertical" : "horizontal";
|
||||
};
|
||||
|
||||
const getFormSpacing = () => {
|
||||
return getSpacing({
|
||||
xs: "0.5rem",
|
||||
sm: "0.75rem",
|
||||
md: "1rem",
|
||||
lg: "1.5rem",
|
||||
});
|
||||
};
|
||||
|
||||
const shouldStackButtons = computed(() => isMobile.value);
|
||||
|
||||
const getFormClass = () => {
|
||||
return isMobile.value ? "mobile-form-stack" : "";
|
||||
};
|
||||
|
||||
const getButtonClass = () => {
|
||||
return isMobile.value ? "mobile-full-width" : "";
|
||||
};
|
||||
|
||||
return {
|
||||
getFormLayout,
|
||||
getFormSpacing,
|
||||
shouldStackButtons,
|
||||
getFormClass,
|
||||
getButtonClass,
|
||||
isMobile,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for responsive cards/grids
|
||||
*/
|
||||
export function useResponsiveGrid() {
|
||||
const { getGridCols, getSpacing, isMobile } = useResponsive();
|
||||
|
||||
const getGridColumns = (config) => {
|
||||
return getGridCols(config);
|
||||
};
|
||||
|
||||
const getGridGap = () => {
|
||||
return getSpacing({
|
||||
xs: "0.5rem",
|
||||
sm: "0.75rem",
|
||||
md: "1rem",
|
||||
lg: "1.5rem",
|
||||
});
|
||||
};
|
||||
|
||||
const getCardClass = () => {
|
||||
return isMobile.value ? "mobile-card-stack" : "";
|
||||
};
|
||||
|
||||
return {
|
||||
getGridColumns,
|
||||
getGridGap,
|
||||
getCardClass,
|
||||
isMobile,
|
||||
};
|
||||
}
|
||||
76
reports-app/frontend/src/main.js
Normal file
76
reports-app/frontend/src/main.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import PrimeVue from "primevue/config";
|
||||
// import Aura from '@primevue/themes/aura'
|
||||
import ToastService from "primevue/toastservice";
|
||||
import ConfirmationService from "primevue/confirmationservice";
|
||||
|
||||
// Core 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";
|
||||
|
||||
// PrimeVue CSS
|
||||
import "primevue/resources/themes/saga-blue/theme.css";
|
||||
import "primevue/resources/primevue.min.css";
|
||||
|
||||
// Icons
|
||||
import "primeicons/primeicons.css";
|
||||
|
||||
// ROA2WEB CSS System (replaces global.css)
|
||||
import "./assets/css/main.css";
|
||||
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
// Pinia store
|
||||
app.use(createPinia());
|
||||
|
||||
// Vue Router
|
||||
app.use(router);
|
||||
|
||||
// PrimeVue with default theme
|
||||
app.use(PrimeVue, {
|
||||
ripple: true,
|
||||
});
|
||||
|
||||
// PrimeVue services
|
||||
app.use(ToastService);
|
||||
app.use(ConfirmationService);
|
||||
|
||||
// Global PrimeVue components
|
||||
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.mount("#app");
|
||||
101
reports-app/frontend/src/router/index.js
Normal file
101
reports-app/frontend/src/router/index.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
|
||||
// Import views
|
||||
import LoginView from "../views/LoginView.vue";
|
||||
import DashboardView from "../views/DashboardView.vue";
|
||||
import InvoicesView from "../views/InvoicesView.vue";
|
||||
import BankCashRegisterView from "../views/BankCashRegisterView.vue";
|
||||
import TelegramView from "../views/TelegramView.vue";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: "/",
|
||||
redirect: "/dashboard",
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "Login",
|
||||
component: LoginView,
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: "Autentificare - ROA Reports",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/dashboard",
|
||||
name: "Dashboard",
|
||||
component: DashboardView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "Dashboard - ROA Reports",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/invoices",
|
||||
name: "Invoices",
|
||||
component: InvoicesView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "Facturi - ROA Reports",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/bank-cash-register",
|
||||
name: "BankCashRegister",
|
||||
component: BankCashRegisterView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "Registru Casa si Banca - ROA Reports",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/telegram",
|
||||
name: "Telegram",
|
||||
component: TelegramView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "Telegram Bot - ROA Reports",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "NotFound",
|
||||
redirect: "/dashboard",
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
linkActiveClass: "router-link-active",
|
||||
linkExactActiveClass: "router-link-exact-active",
|
||||
});
|
||||
|
||||
// Navigation guards
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Set page title
|
||||
if (to.meta.title) {
|
||||
document.title = to.meta.title;
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
if (to.meta.requiresAuth !== false && !authStore.isAuthenticated) {
|
||||
// Redirect to login if not authenticated
|
||||
next("/login");
|
||||
} else if (to.path === "/login" && authStore.isAuthenticated) {
|
||||
// Redirect to dashboard if already authenticated and trying to access login
|
||||
next("/dashboard");
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
// Scroll to top after navigation
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
|
||||
export default router;
|
||||
0
reports-app/frontend/src/services/__init__.py
Normal file
0
reports-app/frontend/src/services/__init__.py
Normal file
139
reports-app/frontend/src/services/api.js
Normal file
139
reports-app/frontend/src/services/api.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import axios from "axios";
|
||||
|
||||
// Create axios instance with base configuration
|
||||
const apiService = axios.create({
|
||||
baseURL: import.meta.env.BASE_URL + "api",
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
apiService.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// Response interceptor for handling errors and token refresh
|
||||
apiService.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Handle 401 Unauthorized errors
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
if (refreshToken) {
|
||||
const response = await axios.post(import.meta.env.BASE_URL + "api/auth/refresh", {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const { access_token } = response.data;
|
||||
localStorage.setItem("access_token", access_token);
|
||||
|
||||
// Update the authorization header
|
||||
apiService.defaults.headers.common["Authorization"] =
|
||||
`Bearer ${access_token}`;
|
||||
originalRequest.headers["Authorization"] = `Bearer ${access_token}`;
|
||||
|
||||
// Retry the original request
|
||||
return apiService(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, clear tokens and redirect to login
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
// Note: selected_company is now per-user (selected_company_${username})
|
||||
// and persists across sessions - not cleared on token expiry
|
||||
|
||||
// Redirect to login page
|
||||
const loginPath = import.meta.env.BASE_URL + "login";
|
||||
if (window.location.pathname !== loginPath) {
|
||||
window.location.href = loginPath;
|
||||
}
|
||||
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
if (error.response) {
|
||||
// Server responded with error status
|
||||
const message =
|
||||
error.response.data?.detail ||
|
||||
error.response.data?.message ||
|
||||
`Server error: ${error.response.status}`;
|
||||
|
||||
console.error("API Error:", {
|
||||
status: error.response.status,
|
||||
message: message,
|
||||
url: error.config.url,
|
||||
});
|
||||
} else if (error.request) {
|
||||
// Request was made but no response received
|
||||
console.error("Network Error:", error.message);
|
||||
} else {
|
||||
// Something else happened
|
||||
console.error("Request Error:", error.message);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// API service methods
|
||||
export { apiService };
|
||||
|
||||
// Specific API endpoints
|
||||
export const authAPI = {
|
||||
login: (credentials) => {
|
||||
return apiService.post("/auth/login", {
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
});
|
||||
},
|
||||
|
||||
refresh: (refreshToken) => {
|
||||
return apiService.post("/auth/refresh", {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
return apiService.post("/auth/logout");
|
||||
},
|
||||
};
|
||||
|
||||
export const companiesAPI = {
|
||||
getAll: () => {
|
||||
return apiService.get("/companies");
|
||||
},
|
||||
};
|
||||
|
||||
export const invoicesAPI = {
|
||||
getByCompany: (companyCode, params = {}) => {
|
||||
return apiService.get(`/invoices/${companyCode}`, { params });
|
||||
},
|
||||
|
||||
getById: (companyCode, invoiceId) => {
|
||||
return apiService.get(`/invoices/${companyCode}/${invoiceId}`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export default apiService;
|
||||
6
reports-app/frontend/src/services/index.js
Normal file
6
reports-app/frontend/src/services/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
apiService,
|
||||
authAPI,
|
||||
companiesAPI,
|
||||
invoicesAPI,
|
||||
} from "./api";
|
||||
188
reports-app/frontend/src/stores/companies.js
Normal file
188
reports-app/frontend/src/stores/companies.js
Normal file
@@ -0,0 +1,188 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { apiService } from "../services/api";
|
||||
import { useAuthStore } from "./auth";
|
||||
|
||||
export const useCompanyStore = defineStore("companies", () => {
|
||||
// Initialize from localStorage - per user
|
||||
const initializeSelectedCompany = () => {
|
||||
// Get current username from auth store
|
||||
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 user ${username}:`, company.name);
|
||||
return company;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved company', e);
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// State
|
||||
const companies = ref([]);
|
||||
const selectedCompany = ref(initializeSelectedCompany());
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Watch for auth user changes to restore selected company
|
||||
const authStore = useAuthStore();
|
||||
watch(
|
||||
() => authStore.user,
|
||||
(newUser) => {
|
||||
if (newUser && newUser.username && !selectedCompany.value) {
|
||||
console.log('[Companies] User became available, attempting to restore selected company');
|
||||
const restoredCompany = initializeSelectedCompany();
|
||||
if (restoredCompany) {
|
||||
selectedCompany.value = restoredCompany;
|
||||
console.log('[Companies] Successfully 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,
|
||||
);
|
||||
|
||||
// Computed property for formatted company list display
|
||||
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('[COMPANY STORE DEBUG] Loading companies...');
|
||||
const response = await apiService.get("/companies");
|
||||
console.log('[COMPANY STORE DEBUG] API Response:', response.data);
|
||||
companies.value = response.data.companies || [];
|
||||
console.log('[COMPANY STORE DEBUG] Companies array:', companies.value);
|
||||
|
||||
// Security validation: Check if saved company is accessible to current user
|
||||
if (selectedCompany.value) {
|
||||
const exists = companies.value.find(
|
||||
c => c.id_firma === selectedCompany.value.id_firma
|
||||
);
|
||||
if (!exists) {
|
||||
console.warn('[Companies][Security] Saved company not accessible to current user, clearing');
|
||||
clearSelectedCompany();
|
||||
} else {
|
||||
console.log('[Companies][Security] Saved company validated successfully');
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Get current username from auth store
|
||||
const authStore = useAuthStore();
|
||||
const username = authStore.user?.username;
|
||||
|
||||
if (!username) {
|
||||
console.warn('[Companies] Cannot save company - no username available');
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `selected_company_${username}`;
|
||||
if (company) {
|
||||
localStorage.setItem(key, JSON.stringify(company));
|
||||
console.log(`[Companies] Saved company for user ${username}:`, company.name);
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
console.log(`[Companies] Cleared company for user ${username}`);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelectedCompany = () => {
|
||||
selectedCompany.value = null;
|
||||
|
||||
// Get current username from auth store
|
||||
const authStore = useAuthStore();
|
||||
const username = authStore.user?.username;
|
||||
|
||||
if (username) {
|
||||
const key = `selected_company_${username}`;
|
||||
localStorage.removeItem(key);
|
||||
console.log(`[Companies] Cleared company for user ${username}`);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
// Clear saved company for current user
|
||||
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,
|
||||
};
|
||||
});
|
||||
373
reports-app/frontend/src/stores/dashboard.js
Normal file
373
reports-app/frontend/src/stores/dashboard.js
Normal file
@@ -0,0 +1,373 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { apiService } from "../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) => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await apiService.get('/dashboard/summary', {
|
||||
params: { company: companyId }
|
||||
});
|
||||
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') => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
console.log(`Loading trend data for company ${companyId}, period: ${period}`);
|
||||
|
||||
const response = await apiService.get('/dashboard/trends', {
|
||||
params: {
|
||||
company: companyId,
|
||||
period: period
|
||||
}
|
||||
});
|
||||
|
||||
// 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 = '') => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await apiService.get('/dashboard/detailed-data', {
|
||||
params: {
|
||||
company: companyId,
|
||||
data_type: dataType,
|
||||
page: page,
|
||||
page_size: pageSize,
|
||||
search: search
|
||||
}
|
||||
});
|
||||
|
||||
// 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 apiService.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 apiService.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') => {
|
||||
const cacheKey = `maturity-${companyId}-${period}`;
|
||||
|
||||
if (dataCache.has(cacheKey)) {
|
||||
maturityData.value[period] = dataCache.get(cacheKey);
|
||||
return { success: true, data: dataCache.get(cacheKey) };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiService.get('/dashboard/maturity', {
|
||||
params: { company: companyId, period }
|
||||
});
|
||||
|
||||
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 apiService.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
reports-app/frontend/src/stores/index.js
Normal file
5
reports-app/frontend/src/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";
|
||||
165
reports-app/frontend/src/stores/invoices.js
Normal file
165
reports-app/frontend/src/stores/invoices.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import { apiService } from "../services/api";
|
||||
|
||||
export const useInvoicesStore = defineStore("invoices", () => {
|
||||
// State
|
||||
const invoices = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
const filters = ref({
|
||||
company: null,
|
||||
type: "CLIENTI", // CLIENTI or FURNIZORI
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
searchTerm: "",
|
||||
});
|
||||
const pagination = ref({
|
||||
page: 0,
|
||||
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 + 1,
|
||||
size: pagination.value.rows,
|
||||
...options,
|
||||
};
|
||||
|
||||
if (filters.value.dateFrom) {
|
||||
params.date_from = filters.value.dateFrom;
|
||||
}
|
||||
if (filters.value.dateTo) {
|
||||
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 apiService.get(`/invoices/`, {
|
||||
params: {
|
||||
company: companyCode,
|
||||
...params
|
||||
}
|
||||
});
|
||||
|
||||
invoices.value = response.data.invoices || [];
|
||||
pagination.value.totalRecords = response.data.total_count || 0;
|
||||
|
||||
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;
|
||||
clearFilters();
|
||||
pagination.value = {
|
||||
page: 0,
|
||||
rows: 50,
|
||||
totalRecords: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const getInvoiceById = (id) => {
|
||||
return invoices.value.find((invoice) => invoice.id === id);
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
invoices,
|
||||
isLoading,
|
||||
error,
|
||||
filters,
|
||||
pagination,
|
||||
|
||||
// Getters
|
||||
invoiceList,
|
||||
hasInvoices,
|
||||
totalInvoices,
|
||||
paidInvoices,
|
||||
overdueInvoices,
|
||||
totalAmountPaid,
|
||||
totalAmountOverdue,
|
||||
|
||||
// Actions
|
||||
loadInvoices,
|
||||
setFilters,
|
||||
setPagination,
|
||||
setInvoiceType,
|
||||
clearFilters,
|
||||
clearError,
|
||||
reset,
|
||||
getInvoiceById,
|
||||
};
|
||||
});
|
||||
77
reports-app/frontend/src/stores/treasury.js
Normal file
77
reports-app/frontend/src/stores/treasury.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { apiService } from "../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
|
||||
});
|
||||
|
||||
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 apiService.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
|
||||
};
|
||||
|
||||
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;
|
||||
pagination.value = {
|
||||
page: 0,
|
||||
rows: 50,
|
||||
totalRecords: 0,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
registers,
|
||||
isLoading,
|
||||
error,
|
||||
pagination,
|
||||
totals,
|
||||
loadBankCashRegister,
|
||||
setPagination,
|
||||
reset
|
||||
};
|
||||
});
|
||||
0
reports-app/frontend/src/utils/__init__.py
Normal file
0
reports-app/frontend/src/utils/__init__.py
Normal file
221
reports-app/frontend/src/utils/exportUtils.js
Normal file
221
reports-app/frontend/src/utils/exportUtils.js
Normal file
@@ -0,0 +1,221 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import jsPDF from 'jspdf';
|
||||
import '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 };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Export data to PDF
|
||||
* @param {Array} data - Array of objects to export
|
||||
* @param {Array} columns - Column definitions [{field: 'key', header: 'Display Name', type: 'currency'}]
|
||||
* @param {String} filename - Name of the file (without extension)
|
||||
* @param {String} title - Title for the PDF document
|
||||
*/
|
||||
export const exportToPDF = (data, columns, filename, title) => {
|
||||
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');
|
||||
|
||||
// Add title
|
||||
doc.setFontSize(16);
|
||||
doc.text(title, 14, 15);
|
||||
|
||||
// Add generation date
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Generat: ${new Date().toLocaleString('ro-RO')}`, 14, 25);
|
||||
|
||||
// Prepare table data
|
||||
const tableColumns = columns.map(col => col.header);
|
||||
const tableRows = data.map(row =>
|
||||
columns.map(col => {
|
||||
const value = row[col.field];
|
||||
if (col.type === 'currency') {
|
||||
return formatCurrency(value);
|
||||
}
|
||||
return value || '-';
|
||||
})
|
||||
);
|
||||
|
||||
// Check if autoTable is available
|
||||
if (typeof doc.autoTable === 'function') {
|
||||
// Add table using autoTable
|
||||
doc.autoTable({
|
||||
head: [tableColumns],
|
||||
body: tableRows,
|
||||
startY: 30,
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 2,
|
||||
halign: 'center'
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [102, 126, 234],
|
||||
textColor: 255,
|
||||
fontStyle: 'bold'
|
||||
},
|
||||
alternateRowStyles: { fillColor: [245, 245, 245] },
|
||||
columnStyles: {
|
||||
// Right align currency columns
|
||||
...Object.fromEntries(
|
||||
columns.map((col, index) =>
|
||||
col.type === 'currency' ? [index, { halign: 'right' }] : null
|
||||
).filter(Boolean)
|
||||
)
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback: manual table creation
|
||||
let yPos = 40;
|
||||
|
||||
// Draw headers
|
||||
doc.setFontSize(10);
|
||||
doc.setFont(undefined, 'bold');
|
||||
tableColumns.forEach((header, index) => {
|
||||
doc.text(header, 14 + (index * 40), yPos);
|
||||
});
|
||||
|
||||
// Draw rows
|
||||
doc.setFont(undefined, 'normal');
|
||||
tableRows.forEach((row, rowIndex) => {
|
||||
yPos += 10;
|
||||
row.forEach((cell, cellIndex) => {
|
||||
doc.text(String(cell), 14 + (cellIndex * 40), yPos);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 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 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
reports-app/frontend/src/utils/index.js
Normal file
0
reports-app/frontend/src/utils/index.js
Normal file
376
reports-app/frontend/src/views/BankCashRegisterView.vue
Normal file
376
reports-app/frontend/src/views/BankCashRegisterView.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="register-view">
|
||||
<!-- Header -->
|
||||
<div class="view-header">
|
||||
<h1 class="page-title">
|
||||
<i class="pi pi-wallet"></i>
|
||||
Registrul de Casă și Bancă
|
||||
</h1>
|
||||
<p class="page-subtitle">
|
||||
Vizualizați toate mișcările din conturile de bancă și casă
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<Card class="filters-card">
|
||||
<template #content>
|
||||
<div class="filters-grid">
|
||||
<div class="filter-item">
|
||||
<label>Data început</label>
|
||||
<Calendar v-model="filters.dateFrom" dateFormat="dd/mm/yy" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label>Data sfârșit</label>
|
||||
<Calendar v-model="filters.dateTo" dateFormat="dd/mm/yy" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label>Căutare partener</label>
|
||||
<InputText v-model="filters.partnerName" placeholder="Nume partener..." />
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<Button
|
||||
label="Aplică Filtre"
|
||||
icon="pi pi-filter"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
<Button
|
||||
label="Resetează"
|
||||
icon="pi pi-times"
|
||||
class="p-button-secondary"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="summary-stats">
|
||||
<Card class="stat-card">
|
||||
<template #content>
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon green">
|
||||
<i class="pi pi-arrow-down"></i>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<h3 class="stat-value">{{ formatCurrency(treasuryStore.totals.total_incasari) }}</h3>
|
||||
<p class="stat-label">Total Încasări</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card class="stat-card">
|
||||
<template #content>
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon red">
|
||||
<i class="pi pi-arrow-up"></i>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<h3 class="stat-value">{{ formatCurrency(treasuryStore.totals.total_plati) }}</h3>
|
||||
<p class="stat-label">Total Plăți</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Data Table -->
|
||||
<Card class="data-card">
|
||||
<template #content>
|
||||
<DataTable
|
||||
:value="treasuryStore.registers"
|
||||
:loading="treasuryStore.isLoading"
|
||||
:paginator="true"
|
||||
:rows="pagination.rows"
|
||||
:total-records="treasuryStore.pagination.totalRecords"
|
||||
:lazy="true"
|
||||
@page="onPage"
|
||||
class="p-datatable-sm"
|
||||
:rowClass="getRowClass"
|
||||
>
|
||||
<Column field="dataact" header="Data">
|
||||
<template #body="slotProps">
|
||||
{{ formatDate(slotProps.data.dataact) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="nract" header="Nr. Act" style="width: 100px" />
|
||||
<Column field="nume" header="Partener" />
|
||||
<Column field="nume_cont_bancar" header="Cont" />
|
||||
<Column field="tip_registru" header="Tip">
|
||||
<template #body="slotProps">
|
||||
<Tag :value="slotProps.data.tip_registru" :severity="getRegisterSeverity(slotProps.data.tip_registru)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="incasari" header="Încasări">
|
||||
<template #body="slotProps">
|
||||
<span class="amount-green" v-if="slotProps.data.incasari > 0">
|
||||
{{ formatCurrency(slotProps.data.incasari, slotProps.data.valuta) }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="plati" header="Plăți">
|
||||
<template #body="slotProps">
|
||||
<span class="amount-red" v-if="slotProps.data.plati > 0">
|
||||
{{ formatCurrency(slotProps.data.plati, slotProps.data.valuta) }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="sold" header="Sold">
|
||||
<template #body="slotProps">
|
||||
<span :class="slotProps.data.sold >= 0 ? 'amount-green' : 'amount-red'">
|
||||
{{ formatCurrency(slotProps.data.sold, slotProps.data.valuta) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="explicatia" header="Explicație" />
|
||||
</DataTable>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useTreasuryStore } from "../stores/treasury";
|
||||
import { useCompanyStore } from "../stores/companies";
|
||||
import { format } from "date-fns";
|
||||
import { ro } from "date-fns/locale";
|
||||
|
||||
const treasuryStore = useTreasuryStore();
|
||||
const companyStore = useCompanyStore();
|
||||
|
||||
const filters = ref({
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
partnerName: ""
|
||||
});
|
||||
|
||||
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 formatDate = (dateString) => {
|
||||
if (!dateString) return "";
|
||||
return format(new Date(dateString), "dd MMM yyyy", { locale: ro });
|
||||
};
|
||||
|
||||
const getRowClass = (data) => {
|
||||
return data.tip_registru.includes('BANCA') ? 'bank-row' : 'cash-row';
|
||||
};
|
||||
|
||||
const getRegisterSeverity = (type) => {
|
||||
if (type.includes('BANCA')) return 'info';
|
||||
if (type.includes('CASA')) return 'warning';
|
||||
return null;
|
||||
};
|
||||
|
||||
const onPage = (event) => {
|
||||
pagination.value = event;
|
||||
loadData();
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
pagination.value.page = 0;
|
||||
loadData();
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.value = {
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
partnerName: ""
|
||||
};
|
||||
loadData();
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
if (!companyStore.selectedCompany) return;
|
||||
|
||||
treasuryStore.setPagination(pagination.value);
|
||||
|
||||
await treasuryStore.loadBankCashRegister(
|
||||
companyStore.selectedCompany.id_firma,
|
||||
{
|
||||
date_from: filters.value.dateFrom?.toISOString().split("T")[0],
|
||||
date_to: filters.value.dateTo?.toISOString().split("T")[0],
|
||||
partner_name: filters.value.partnerName
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (companyStore.selectedCompany) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
padding: 1.5rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: var(--primary-color);
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filters-card {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-item label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--surface-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-icon.green {
|
||||
background: linear-gradient(135deg, var(--green-500), var(--green-600));
|
||||
}
|
||||
|
||||
.stat-icon.red {
|
||||
background: linear-gradient(135deg, var(--red-500), var(--red-600));
|
||||
}
|
||||
|
||||
.stat-details h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.stat-details p {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
margin: 0.25rem 0 0 0;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.amount-green {
|
||||
color: var(--green-600);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amount-red {
|
||||
color: var(--red-600);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.bank-row) {
|
||||
background-color: var(--blue-50);
|
||||
}
|
||||
|
||||
:deep(.cash-row) {
|
||||
background-color: var(--yellow-50);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.filter-actions .p-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
2042
reports-app/frontend/src/views/DashboardView.vue
Normal file
2042
reports-app/frontend/src/views/DashboardView.vue
Normal file
File diff suppressed because it is too large
Load Diff
754
reports-app/frontend/src/views/InvoicesView.vue
Normal file
754
reports-app/frontend/src/views/InvoicesView.vue
Normal file
@@ -0,0 +1,754 @@
|
||||
<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>
|
||||
<p class="page-subtitle">
|
||||
Vizualizați și gestionați facturile pentru compania selectată
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<!-- Filters and Controls -->
|
||||
<Card v-if="companyStore.selectedCompany" class="filters-card">
|
||||
<template #content>
|
||||
<div class="filters-container">
|
||||
<div class="filters-row">
|
||||
<!-- Invoice Type -->
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Tip Factură</label>
|
||||
<Dropdown
|
||||
v-model="filters.type"
|
||||
:options="invoiceTypes"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Tip factură"
|
||||
@change="handleFilterChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date Range -->
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Data De</label>
|
||||
<Calendar
|
||||
v-model="filters.dateFrom"
|
||||
date-format="dd/mm/yy"
|
||||
placeholder="Selectați data"
|
||||
@date-select="handleFilterChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Data Până</label>
|
||||
<Calendar
|
||||
v-model="filters.dateTo"
|
||||
date-format="dd/mm/yy"
|
||||
placeholder="Selectați data"
|
||||
@date-select="handleFilterChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="filter-group search-group">
|
||||
<label class="filter-label">Căutare</label>
|
||||
<InputText
|
||||
v-model="filters.searchTerm"
|
||||
placeholder="Căutați după număr, partener..."
|
||||
@input="handleSearchChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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-refresh"
|
||||
label="Actualizează"
|
||||
:loading="invoicesStore.isLoading"
|
||||
@click="refreshData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Summary Statistics -->
|
||||
<div
|
||||
v-if="companyStore.selectedCompany && invoicesStore.hasInvoices"
|
||||
class="summary-stats"
|
||||
>
|
||||
<Card class="stat-card stat-total">
|
||||
<template #content>
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<i class="pi pi-file-text"></i>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<h3 class="stat-value">{{ invoicesStore.totalInvoices }}</h3>
|
||||
<p class="stat-label">Total Facturi</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="stat-card stat-paid">
|
||||
<template #content>
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<i class="pi pi-check-circle"></i>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<h3 class="stat-value">
|
||||
{{ invoicesStore.paidInvoices.length }}
|
||||
</h3>
|
||||
<p class="stat-label">Achitate</p>
|
||||
<small class="stat-amount">{{
|
||||
formatCurrency(invoicesStore.totalAmountPaid)
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="stat-card stat-overdue">
|
||||
<template #content>
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<h3 class="stat-value">
|
||||
{{ invoicesStore.overdueInvoices.length }}
|
||||
</h3>
|
||||
<p class="stat-label">Restante</p>
|
||||
<small class="stat-amount">{{
|
||||
formatCurrency(invoicesStore.totalAmountOverdue)
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Invoices Table -->
|
||||
<Card v-if="companyStore.selectedCompany" class="table-card">
|
||||
<template #content>
|
||||
<DataTable
|
||||
:value="invoicesStore.invoiceList"
|
||||
:loading="invoicesStore.isLoading"
|
||||
:paginator="true"
|
||||
:rows="pagination.rows"
|
||||
:total-records="invoicesStore.totalInvoices"
|
||||
:lazy="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"
|
||||
:row-class="getRowClass"
|
||||
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="numar_document" header="Număr Document" sortable>
|
||||
<template #body="slotProps">
|
||||
<strong>{{ slotProps.data.numar_document }}</strong>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="data_document" header="Data Document" sortable>
|
||||
<template #body="slotProps">
|
||||
{{ formatDate(slotProps.data.data_document) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="nume_partener" header="Partener" sortable>
|
||||
<template #body="slotProps">
|
||||
<div class="partner-info">
|
||||
<span class="partner-name">{{
|
||||
slotProps.data.nume_partener
|
||||
}}</span>
|
||||
<small
|
||||
v-if="slotProps.data.cod_partener"
|
||||
class="partner-code"
|
||||
>
|
||||
{{ slotProps.data.cod_partener }}
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="suma" header="Sumă" sortable>
|
||||
<template #body="slotProps">
|
||||
<span class="amount" :class="getAmountClass(slotProps.data)">
|
||||
{{ formatCurrency(slotProps.data.suma) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="css_class" header="Status">
|
||||
<template #body="slotProps">
|
||||
<Tag
|
||||
:value="getStatusText(slotProps.data.css_class)"
|
||||
:severity="getStatusSeverity(slotProps.data.css_class)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="data_scadenta" header="Data Scadență" sortable>
|
||||
<template #body="slotProps">
|
||||
{{ formatDate(slotProps.data.data_scadenta) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Acțiuni" :exportable="false">
|
||||
<template #body="slotProps">
|
||||
<div class="table-actions">
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
class="p-button-rounded p-button-text p-button-sm"
|
||||
v-tooltip="'Vezi detalii'"
|
||||
@click="viewInvoiceDetails(slotProps.data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Invoice Details Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="showDetailsDialog"
|
||||
:header="`Detalii Factură ${selectedInvoice?.numar_document}`"
|
||||
:modal="true"
|
||||
:style="{ width: '50vw' }"
|
||||
:breakpoints="{ '960px': '75vw', '641px': '90vw' }"
|
||||
>
|
||||
<div v-if="selectedInvoice" class="invoice-details">
|
||||
<div class="details-grid">
|
||||
<div class="detail-item">
|
||||
<label>Număr Document:</label>
|
||||
<span>{{ selectedInvoice.numar_document }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Data Document:</label>
|
||||
<span>{{ formatDate(selectedInvoice.data_document) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Partener:</label>
|
||||
<span>{{ selectedInvoice.nume_partener }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Cod Partener:</label>
|
||||
<span>{{ selectedInvoice.cod_partener || "-" }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Sumă:</label>
|
||||
<span class="amount">{{
|
||||
formatCurrency(selectedInvoice.suma)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Status:</label>
|
||||
<Tag
|
||||
:value="getStatusText(selectedInvoice.css_class)"
|
||||
:severity="getStatusSeverity(selectedInvoice.css_class)"
|
||||
/>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Data Scadență:</label>
|
||||
<span>{{ formatDate(selectedInvoice.data_scadenta) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { useCompanyStore } from "../stores/companies";
|
||||
import { useInvoicesStore } from "../stores/invoices";
|
||||
import { format } from "date-fns";
|
||||
import { ro } from "date-fns/locale";
|
||||
|
||||
const toast = useToast();
|
||||
const companyStore = useCompanyStore();
|
||||
const invoicesStore = useInvoicesStore();
|
||||
|
||||
// State
|
||||
const selectedCompanyId = ref(companyStore.selectedCompany?.id_firma || null);
|
||||
const showDetailsDialog = ref(false);
|
||||
const selectedInvoice = ref(null);
|
||||
|
||||
const filters = ref({
|
||||
type: "CLIENTI",
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
searchTerm: "",
|
||||
});
|
||||
|
||||
const pagination = ref({
|
||||
page: 0,
|
||||
rows: 50,
|
||||
});
|
||||
|
||||
// Options
|
||||
const invoiceTypes = [
|
||||
{ label: "Clienți", value: "CLIENTI" },
|
||||
{ label: "Furnizori", value: "FURNIZORI" },
|
||||
];
|
||||
|
||||
// Methods
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount) return "0,00 RON";
|
||||
return new Intl.NumberFormat("ro-RO", {
|
||||
style: "currency",
|
||||
currency: "RON",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return "";
|
||||
try {
|
||||
return format(new Date(dateString), "dd/MM/yyyy", { locale: ro });
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (cssClass) => {
|
||||
switch (cssClass) {
|
||||
case "invoice-paid":
|
||||
return "Achitat";
|
||||
case "invoice-overdue":
|
||||
return "Restant";
|
||||
default:
|
||||
return "Neutru";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusSeverity = (cssClass) => {
|
||||
switch (cssClass) {
|
||||
case "invoice-paid":
|
||||
return "success";
|
||||
case "invoice-overdue":
|
||||
return "danger";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
};
|
||||
|
||||
const getRowClass = (data) => {
|
||||
return data.css_class || "";
|
||||
};
|
||||
|
||||
const getAmountClass = (invoice) => {
|
||||
switch (invoice.css_class) {
|
||||
case "invoice-paid":
|
||||
return "amount-paid";
|
||||
case "invoice-overdue":
|
||||
return "amount-overdue";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
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 = 0;
|
||||
await loadInvoices();
|
||||
};
|
||||
|
||||
const handleSearchChange = (() => {
|
||||
let timeout;
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(async () => {
|
||||
pagination.value.page = 0;
|
||||
await loadInvoices();
|
||||
}, 500);
|
||||
};
|
||||
})();
|
||||
|
||||
const clearFilters = async () => {
|
||||
filters.value = {
|
||||
type: "CLIENTI",
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
searchTerm: "",
|
||||
};
|
||||
pagination.value.page = 0;
|
||||
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;
|
||||
|
||||
try {
|
||||
invoicesStore.setFilters(filters.value);
|
||||
invoicesStore.setPagination(pagination.value);
|
||||
|
||||
await invoicesStore.loadInvoices(companyStore.selectedCompany.id_firma, {
|
||||
tip: filters.value.type,
|
||||
date_from: filters.value.dateFrom?.toISOString().split("T")[0],
|
||||
date_to: filters.value.dateTo?.toISOString().split("T")[0],
|
||||
search: filters.value.searchTerm,
|
||||
page: pagination.value.page,
|
||||
size: pagination.value.rows,
|
||||
});
|
||||
} 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) => {
|
||||
pagination.value.page = event.page;
|
||||
pagination.value.rows = event.rows;
|
||||
await loadInvoices();
|
||||
};
|
||||
|
||||
const onSort = async (event) => {
|
||||
// Handle sorting if needed
|
||||
await loadInvoices();
|
||||
};
|
||||
|
||||
const viewInvoiceDetails = (invoice) => {
|
||||
selectedInvoice.value = invoice;
|
||||
showDetailsDialog.value = true;
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// Load companies if not loaded
|
||||
if (!companyStore.hasCompanies) {
|
||||
await companyStore.loadCompanies();
|
||||
}
|
||||
|
||||
// Load invoices if company is selected
|
||||
if (companyStore.selectedCompany) {
|
||||
await loadInvoices();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for company changes
|
||||
watch(
|
||||
() => companyStore.selectedCompany,
|
||||
async (newCompany) => {
|
||||
if (newCompany) {
|
||||
await loadInvoices();
|
||||
}
|
||||
},
|
||||
);
|
||||
</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 0 0.5rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-color-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.company-selection-card,
|
||||
.filters-card {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.filters-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search-group {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.filters-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.stat-card.stat-total {
|
||||
border-left-color: var(--blue-500);
|
||||
}
|
||||
|
||||
.stat-card.stat-paid {
|
||||
border-left-color: var(--green-500);
|
||||
}
|
||||
|
||||
.stat-card.stat-overdue {
|
||||
border-left-color: var(--red-500);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.stat-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.stat-amount {
|
||||
color: var(--text-color-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.partner-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.partner-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.partner-code {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amount-paid {
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.amount-overdue {
|
||||
color: var(--red-600);
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.invoice-details {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Row styling based on status */
|
||||
:deep(.p-datatable .p-datatable-tbody > tr.invoice-paid) {
|
||||
background-color: var(--green-50);
|
||||
}
|
||||
|
||||
:deep(.p-datatable .p-datatable-tbody > tr.invoice-overdue) {
|
||||
background-color: var(--red-50);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.invoices {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-group {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filters-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
394
reports-app/frontend/src/views/LoginView.vue
Normal file
394
reports-app/frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,394 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-wrapper">
|
||||
<Card class="login-card">
|
||||
<template #header>
|
||||
<div class="login-header">
|
||||
<i class="pi pi-chart-bar text-primary text-6xl"></i>
|
||||
<h1 class="login-title">ROA Reports</h1>
|
||||
<p class="login-subtitle">Rapoarte ERP - Facturi și Încasări</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<div class="field">
|
||||
<label for="username" class="field-label">Utilizator</label>
|
||||
<InputText
|
||||
id="username"
|
||||
v-model="credentials.username"
|
||||
placeholder="Introduceți numele de utilizator"
|
||||
:class="{ 'p-invalid': formErrors.username }"
|
||||
class="w-full"
|
||||
autocomplete="username"
|
||||
@blur="validateField('username')"
|
||||
/>
|
||||
<small v-if="formErrors.username" class="p-error">
|
||||
{{ formErrors.username }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password" class="field-label">Parolă</label>
|
||||
<Password
|
||||
id="password"
|
||||
v-model="credentials.password"
|
||||
placeholder="Introduceți parola"
|
||||
:class="{ 'p-invalid': formErrors.password }"
|
||||
class="w-full"
|
||||
:feedback="false"
|
||||
toggle-mask
|
||||
autocomplete="current-password"
|
||||
@blur="validateField('password')"
|
||||
/>
|
||||
<small v-if="formErrors.password" class="p-error">
|
||||
{{ formErrors.password }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div v-if="authStore.error" class="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";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 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 authStore.login(credentials.value);
|
||||
|
||||
if (result.success) {
|
||||
// Redirect to dashboard (removed welcome notification)
|
||||
router.push("/dashboard");
|
||||
} 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 = () => {
|
||||
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 scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 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;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.login-button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
background: #3b82f6 !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: #2563eb !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);
|
||||
}
|
||||
|
||||
/* Better input styling */
|
||||
:deep(.p-inputtext),
|
||||
:deep(.p-password input) {
|
||||
border: 2px solid #e5e7eb !important;
|
||||
padding: 12px !important;
|
||||
font-size: 16px !important;
|
||||
transition: all 0.3s ease !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
:deep(.p-inputtext:focus),
|
||||
:deep(.p-password input:focus) {
|
||||
border-color: #3b82f6 !important;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
:deep(.p-inputtext:hover),
|
||||
:deep(.p-password input:hover) {
|
||||
border-color: #9ca3af !important;
|
||||
}
|
||||
|
||||
.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 */
|
||||
.p-inputtext,
|
||||
.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: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
432
reports-app/frontend/src/views/TelegramView.vue
Normal file
432
reports-app/frontend/src/views/TelegramView.vue
Normal file
@@ -0,0 +1,432 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Dashboard Header -->
|
||||
<DashboardHeader @menu-toggle="handleMenuToggle" />
|
||||
|
||||
<!-- Hamburger Menu -->
|
||||
<HamburgerMenu :is-open="menuOpen" @close="handleMenuClose" />
|
||||
|
||||
<!-- Main Content -->
|
||||
<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="telegram-card">
|
||||
|
||||
<!-- Generate Button -->
|
||||
<div class="generate-section">
|
||||
<button
|
||||
@click="generateCode"
|
||||
:disabled="loading"
|
||||
class="generate-btn"
|
||||
>
|
||||
{{ 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="action-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>
|
||||
|
||||
<!-- Toast -->
|
||||
<Toast position="top-right" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import DashboardHeader from '../components/layout/DashboardHeader.vue'
|
||||
import HamburgerMenu from '../components/layout/HamburgerMenu.vue'
|
||||
import Button from 'primevue/button'
|
||||
import Toast from 'primevue/toast'
|
||||
import QRCodeVue from 'qrcode.vue'
|
||||
import { apiService } from '../services/api'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// State
|
||||
const menuOpen = ref(false)
|
||||
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 handleMenuToggle = (isOpen) => {
|
||||
menuOpen.value = isOpen
|
||||
}
|
||||
|
||||
const handleMenuClose = () => {
|
||||
menuOpen.value = false
|
||||
}
|
||||
|
||||
const generateCode = async () => {
|
||||
loading.value = true
|
||||
showQR.value = false
|
||||
|
||||
try {
|
||||
const response = await apiService.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>
|
||||
/* Layout - Consistent with Dashboard */
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
padding: var(--space-lg);
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Page Header */
|
||||
.page-header {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 var(--space-xs) 0;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 0;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-3xl);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Main Card */
|
||||
.telegram-card {
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--space-xl);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Generate Section */
|
||||
.generate-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-bottom: var(--space-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
min-width: 200px;
|
||||
padding: 12px 24px;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.generate-btn:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.generate-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.generate-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.primary-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
padding: 11px 20px;
|
||||
background: var(--primary-500, #6366f1);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.primary-action-btn:hover {
|
||||
background: var(--primary-600, #4f46e5);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.telegram-card {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.code-value {
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-btn,
|
||||
.primary-btn {
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<div class="company-selector-mini">
|
||||
<div class="company-dropdown" ref="dropdown">
|
||||
<button
|
||||
class="company-trigger"
|
||||
@click="toggleDropdown"
|
||||
:aria-expanded="dropdownOpen"
|
||||
aria-label="Select company"
|
||||
>
|
||||
<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
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search companies..."
|
||||
class="search-input"
|
||||
@keydown.escape="closeDropdown"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="company-list">
|
||||
<div
|
||||
v-for="company in filteredCompanies"
|
||||
:key="company.id"
|
||||
class="company-item"
|
||||
:class="{ active: company.id === selectedCompany?.id }"
|
||||
@click="selectCompany(company)"
|
||||
>
|
||||
<div class="company-details">
|
||||
<div class="company-main-name">{{ company.name }}</div>
|
||||
<div class="company-sub-info">
|
||||
<span class="company-cui">CUI: {{ company.cui }}</span>
|
||||
<span class="company-separator">•</span>
|
||||
<span class="company-status" :class="company.status">{{ company.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<i v-if="company.id === selectedCompany?.id" 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 } 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 dropdownOpen = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
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?.cui ? `CUI: ${selectedCompany.value.cui}` : ''
|
||||
})
|
||||
|
||||
const filteredCompanies = computed(() => {
|
||||
if (!searchQuery.value) {
|
||||
return companiesStore.companies
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return companiesStore.companies.filter(company =>
|
||||
company.name.toLowerCase().includes(query) ||
|
||||
company.cui.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
const toggleDropdown = () => {
|
||||
dropdownOpen.value = !dropdownOpen.value
|
||||
if (dropdownOpen.value) {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const closeDropdown = () => {
|
||||
dropdownOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
const selectCompany = (company) => {
|
||||
selectedCompany.value = company
|
||||
emit('company-changed', company)
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdown.value && !dropdown.value.contains(event.target)) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
// Load companies if not already loaded
|
||||
if (companiesStore.companies.length === 0) {
|
||||
companiesStore.fetchCompanies()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
return {
|
||||
dropdown,
|
||||
dropdownOpen,
|
||||
searchQuery,
|
||||
selectedCompany,
|
||||
selectedCompanyName,
|
||||
selectedCompanyCode,
|
||||
filteredCompanies,
|
||||
toggleDropdown,
|
||||
closeDropdown,
|
||||
selectCompany
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.company-selector-mini {
|
||||
position: relative;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.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: 200px;
|
||||
}
|
||||
|
||||
.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-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.company-main-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.company-sub-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-xs);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.company-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.company-status.active {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.company-status.inactive {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.company-selected-icon {
|
||||
color: inherit;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-xl);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.company-selector-mini {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.company-trigger {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.company-dropdown-panel {
|
||||
left: -16px;
|
||||
right: -16px;
|
||||
width: calc(100% + 32px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<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>
|
||||
|
||||
<!-- Center: Quick Actions -->
|
||||
<div class="quick-actions desktop-only">
|
||||
<button class="quick-action-btn" @click="refreshData" title="Refresh Data">
|
||||
<i class="pi pi-refresh"></i>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
<button class="quick-action-btn" @click="exportData" title="Export Data">
|
||||
<i class="pi pi-download"></i>
|
||||
<span>Export</span>
|
||||
</button>
|
||||
<button class="quick-action-btn" @click="searchData" title="Search">
|
||||
<i class="pi pi-search"></i>
|
||||
<span>Search</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Company + User -->
|
||||
<div class="header-actions">
|
||||
<CompanySelectorMini
|
||||
v-model="selectedCompany"
|
||||
@company-changed="onCompanyChanged"
|
||||
/>
|
||||
<div class="header-user" @click="toggleUserMenu">
|
||||
<i class="pi pi-user"></i>
|
||||
<span class="desktop-only">User</span>
|
||||
<i class="pi pi-chevron-down"></i>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CompanySelectorMini from '../dashboard/CompanySelectorMini.vue'
|
||||
import { useCompanyStore } from '../../stores/companies'
|
||||
|
||||
export default {
|
||||
name: 'DashboardHeader',
|
||||
components: {
|
||||
CompanySelectorMini
|
||||
},
|
||||
emits: ['menu-toggle', 'refresh', 'export', 'search', 'company-changed'],
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const companiesStore = useCompanyStore()
|
||||
|
||||
const menuOpen = ref(false)
|
||||
const selectedCompany = computed({
|
||||
get: () => companiesStore.selectedCompany,
|
||||
set: (value) => companiesStore.setSelectedCompany(value)
|
||||
})
|
||||
|
||||
const toggleMenu = () => {
|
||||
menuOpen.value = !menuOpen.value
|
||||
emit('menu-toggle', menuOpen.value)
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
const exportData = () => {
|
||||
emit('export')
|
||||
}
|
||||
|
||||
const searchData = () => {
|
||||
emit('search')
|
||||
}
|
||||
|
||||
const onCompanyChanged = (company) => {
|
||||
emit('company-changed', company)
|
||||
}
|
||||
|
||||
const toggleUserMenu = () => {
|
||||
// TODO: Implement user menu dropdown
|
||||
console.log('User menu clicked')
|
||||
}
|
||||
|
||||
return {
|
||||
menuOpen,
|
||||
selectedCompany,
|
||||
toggleMenu,
|
||||
refreshData,
|
||||
exportData,
|
||||
searchData,
|
||||
onCompanyChanged,
|
||||
toggleUserMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
1035
reports-app/frontend/src/views/backup_dashboards/DashboardView.vue
Normal file
1035
reports-app/frontend/src/views/backup_dashboards/DashboardView.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user