feat(dashboard): Complete dashboard desktop cleanup and improvements

User Stories Completed:
- US-001: Eliminare SolduriCompactCard de pe Desktop
- US-002: Eliminare Icoane din Header-ul CollapsibleCard
- US-003: Reorganizare TreasuryDualCard - Text Înainte de Grafice
- US-004: Reorganizare ClientiBalanceCard - Text Înainte de Grafice
- US-005: Reorganizare FurnizoriBalanceCard - Text Înainte de Grafice
- US-006: Grafice Colapsabile în TreasuryDualCard
- US-007: Grafice Colapsabile în ClientiBalanceCard
- US-008: Grafice Colapsabile în FurnizoriBalanceCard
- US-009: Grafice Colapsabile în CashFlowMetricCard

Additional Improvements:
- Add cache metadata display (CacheFooter component) for all dashboard cards
- Add @cached decorators to get_monthly_flows and get_indicators_with_sparklines
- Fix financial indicators calculations and sparkline sync
- Add state reset on company change to prevent stale data
- New shared components: CacheFooter.vue, authRedirect.js
- Enhanced FinancialIndicatorsCard with sparklines and period selection

Squashed from branch: ralph/dashboard-desktop-cleanup (11 commits)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-01-22 07:27:27 +00:00
parent 69683b2d65
commit 1b9ebf1d8f
23 changed files with 4034 additions and 1396 deletions

View File

@@ -0,0 +1,69 @@
# Handover: Curățare Cod ROA2WEB
## Context
Am analizat arhitectura ROA2WEB cu 3 agenți de explorare și am identificat 4 acțiuni de curățare cu randament mare și risc minim.
**Plan detaliat**: `/home/claude/.claude/plans/immutable-chasing-flute.md`
## Prompt pentru /ralph:prd
```
Implementează curățarea codului ROA2WEB conform planului din /home/claude/.claude/plans/immutable-chasing-flute.md
Obiective:
1. Elimină store duplication - șterge src/modules/reports/stores/sharedStores.js și src/modules/data-entry/stores/sharedStores.js, mută instantierea în App.vue
2. Creează factory pentru API services - src/shared/services/createApiService.js și simplifică api.js din module
3. Fă dependențele OCR opționale - adaugă variabile în .env (OCR_ENABLE_PADDLEOCR, OCR_ENABLE_TESSERACT), implementează lazy loading în OCR service, mută deps în requirements opțional
4. Consolidează CSS design tokens - unifică variables.css, tokens.css, md3-tokens.css într-un singur design-tokens.css
Constrângeri:
- NU schimba arhitectura (layered architecture rămâne)
- NU modifica auth middleware, cache decorator, oracle pool
- Testează după fiecare modificare: app pornește, login funcționează, dark mode arată corect
Ordinea execuției:
1. [15 min] Store duplication
2. [30 min] API factory
3. [30 min] OCR dependencies opționale (lazy loading via .env)
4. [1 oră] CSS tokens
Impact așteptat: -150 linii cod duplicat, OCR deps opționale via .env, -2 fișiere CSS, startup mai rapid
```
## Fișiere Cheie de Referință
### De Șters
- `src/modules/reports/stores/sharedStores.js`
- `src/modules/data-entry/stores/sharedStores.js`
### De Creat
- `src/shared/services/createApiService.js`
- `src/assets/css/core/design-tokens.css` (consolidat)
### De Modificat
- `src/App.vue` - adaugă instantiere stores
- `src/modules/reports/services/api.js` - simplifică cu factory
- `src/modules/data-entry/services/api.js` - simplifică cu factory
- `backend/requirements.txt` - mută paddleocr/tesseract în secțiune opțională (comentate)
- `backend/.env` - adaugă `OCR_ENABLE_PADDLEOCR=false` și `OCR_ENABLE_TESSERACT=false`
- `backend/modules/data_entry/services/ocr_service.py` - implementează lazy loading pentru OCR engines
- `src/assets/css/main.css` - actualizează importuri CSS
## Verificare Finală
După implementare, verifică:
- [ ] `./start-prod.sh` pornește fără erori
- [ ] Login funcționează
- [ ] Un raport se încarcă corect
- [ ] O chitanță se poate crea
- [ ] Dark mode arată corect pe toate paginile
- [ ] `pip install -r backend/requirements.txt` reușește
- [ ] Cu `OCR_ENABLE_PADDLEOCR=false` - app pornește fără PaddleOCR instalat
- [ ] Cu `OCR_ENABLE_PADDLEOCR=true` - OCR fallback funcționează (dacă e instalat)
## Note
- Arhitectura actuală (Layered) este potrivită pentru echipa de 1-2 developeri
- Nu este nevoie de Vertical Slice sau Feature-Sliced Design
- Acțiunile opționale (split receiptStore, simplificare cache) pot fi făcute ulterior

View File

@@ -0,0 +1,253 @@
# Plan: Curățare Cod ROA2WEB (High ROI, Low Risk)
## Sumar Executiv
Arhitectura ROA2WEB este **solidă** pentru o echipă de 1-2 developeri. Nu necesită schimbări arhitecturale.
Acest plan conține doar **optimizări tactice sigure** cu randament mare.
---
## Acțiuni de Curățare (Prioritizate)
### 1. Elimină Store Duplication ✅
**ROI**: ⭐⭐⭐⭐⭐ | **Risc**: Foarte Scăzut | **Efort**: 15 min
**Problema**: `sharedStores.js` identic în 2 module (42 linii duplicate)
**Fișiere de șters**:
- `src/modules/reports/stores/sharedStores.js`
- `src/modules/data-entry/stores/sharedStores.js`
**Soluție**: Instantiază stores direct în `App.vue`
```javascript
// App.vue
import { createAuthStore } from '@shared/stores/auth'
import { createCompaniesStore } from '@shared/stores/companies'
import { createAccountingPeriodStore } from '@shared/stores/accountingPeriod'
import authApi from '@shared/services/authApi'
const useAuthStore = createAuthStore(authApi)
const useCompanyStore = createCompaniesStore(authApi, useAuthStore)
const useAccountingPeriodStore = createAccountingPeriodStore(authApi)
```
**Verificare**: App funcționează normal, stores disponibile în componente
---
### 2. Factory pentru API Services ✅
**ROI**: ⭐⭐⭐⭐ | **Risc**: Scăzut | **Efort**: 30 min
**Problema**: `api.js` duplicat în module (70% cod identic, 156 linii total)
**Fișier de creat**: `src/shared/services/createApiService.js`
```javascript
// createApiService.js (~50 linii)
import axios from 'axios'
export function createApiService(basePath, options = {}) {
const api = axios.create({
baseURL: import.meta.env.BASE_URL + `api/${basePath}`,
headers: { 'Content-Type': 'application/json' }
})
api.interceptors.request.use(config => {
const token = localStorage.getItem('access_token')
if (token) config.headers.Authorization = `Bearer ${token}`
if (options.injectCompany) {
// Logic pentru X-Selected-Company header
}
if (config.data instanceof FormData) {
delete config.headers['Content-Type']
}
return config
})
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('access_token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
return api
}
```
**Fișiere simplificate** (5 linii fiecare):
```javascript
// src/modules/reports/services/api.js
import { createApiService } from '@shared/services/createApiService'
export default createApiService('reports')
// src/modules/data-entry/services/api.js
import { createApiService } from '@shared/services/createApiService'
export default createApiService('data-entry', { injectCompany: true })
```
**Verificare**: Login funcționează, API calls returnează date
---
### 3. Dependențe OCR Opționale (Lazy Loading) ✅
**ROI**: ⭐⭐⭐⭐ | **Risc**: Foarte Scăzut | **Efort**: 30 min
**Problema**: PaddleOCR + pytesseract se instalează și încarcă mereu, chiar dacă nu sunt folosite
**Soluție**: Fă-le opționale via `.env` și lazy loading
**Fișier `.env`** - adaugă:
```env
# OCR Engines (true = instalează și încarcă, false = skip)
OCR_ENABLE_PADDLEOCR=false
OCR_ENABLE_TESSERACT=false
```
**Fișier `backend/requirements.txt`** - mută în secțiune opțională:
```
# Required OCR
python-doctr[torch]>=0.8.0
# Optional OCR (install only if needed)
# paddleocr>=2.7.0 # Uncomment if OCR_ENABLE_PADDLEOCR=true
# paddlepaddle>=2.5.0 # Uncomment if OCR_ENABLE_PADDLEOCR=true
# pytesseract>=0.3.10 # Uncomment if OCR_ENABLE_TESSERACT=true
```
**SAU** creează `requirements-ocr-optional.txt`:
```
paddleocr>=2.7.0
paddlepaddle>=2.5.0
pytesseract>=0.3.10
```
**Fișier OCR service** - lazy import:
```python
# backend/modules/data_entry/services/ocr_service.py
import os
from functools import lru_cache
@lru_cache()
def get_paddleocr():
if os.getenv('OCR_ENABLE_PADDLEOCR', 'false').lower() == 'true':
from paddleocr import PaddleOCR
return PaddleOCR(use_angle_cls=True, lang='ro')
return None
@lru_cache()
def get_tesseract():
if os.getenv('OCR_ENABLE_TESSERACT', 'false').lower() == 'true':
import pytesseract
return pytesseract
return None
```
**Beneficiu**:
- Păstrează alternativele pentru viitor
- Nu se instalează/încarcă dacă nu sunt necesare
- Startup mai rapid când sunt dezactivate
- ~500MB saved când `false`
**Verificare**:
- Cu `false`: App pornește fără PaddleOCR/Tesseract instalate
- Cu `true`: OCR fallback funcționează
---
### 4. Consolidare Design Tokens CSS ✅
**ROI**: ⭐⭐⭐ | **Risc**: Scăzut | **Efort**: 1 oră
**Problema**: 3 fișiere cu tokens overlap: `variables.css`, `tokens.css`, `md3-tokens.css`
**Soluție**: Consolidează în `src/assets/css/core/design-tokens.css`
**Pași**:
1. Creează `design-tokens.css` unificat
2. Migrează variabilele din cele 3 fișiere
3. Actualizează importurile în `main.css`
4. Șterge fișierele vechi
**Verificare**: Testează light mode ȘI dark mode pe toate paginile
---
## Acțiuni Opționale (Dacă Ai Timp)
### 5. Split receiptStore (Opțional)
**ROI**: ⭐⭐⭐ | **Risc**: Mediu | **Efort**: 2-3 ore
**Problema**: 606 linii într-un singur store
**Soluție**: Split în 3 stores (receipts, workflow, nomenclatures)
**De făcut doar dacă**: Lucrezi frecvent pe data-entry module
---
### 6. Simplificare Cache (Opțional)
**ROI**: ⭐⭐ | **Risc**: Mediu | **Efort**: 2-3 ore
**Problema**: 7 fișiere cache, unele nefolosite
**Soluție**: Reduce la 3 fișiere
**De făcut doar dacă**: Ai nevoie să modifici cache logic
---
## Impact Total (Acțiuni 1-4)
| Metrică | Înainte | După | Diferență |
|---------|---------|------|-----------|
| Linii cod duplicat | ~200 | ~50 | **-150** |
| OCR deps obligatorii | 3 | 0 | **opționale via .env** |
| Fișiere CSS tokens | 3 | 1 | **-2** |
| Store duplication | 42 linii | 0 | **-42** |
| Startup time (OCR off) | ~5s | ~2s | **-3s** |
| **Timp total** | - | - | **~2.5 ore** |
---
## Ordinea Recomandată
1. **[15 min]** Elimină store duplication
2. **[30 min]** Factory pentru API services
3. **[30 min]** OCR dependencies opționale (lazy loading)
4. **[1 oră]** Consolidare CSS tokens
**Total**: ~2.5 ore pentru toate 4 acțiunile principale
---
## Verificare Finală
După fiecare acțiune:
- [ ] App pornește fără erori
- [ ] Login funcționează
- [ ] Un raport se încarcă
- [ ] O chitanță se creează
- [ ] Dark mode arată corect
---
## Ce NU Schimbăm
- ✅ Arhitectura Layered - potrivită pentru echipa de 1-2 devs
- ✅ Module isolation (reports, data-entry, telegram)
- ✅ Router/Store Factory patterns
- ✅ Auth middleware
- ✅ Cache decorator `@cached`
- ✅ Oracle pool singleton
---
*Plan simplificat: doar curățare cod cu randament mare și risc minim.*

View File

@@ -0,0 +1,96 @@
# Claude Rules: Financial Indicators
<!-- paths: backend/modules/reports/services/financial_indicators_service.py, backend/modules/reports/services/dashboard_service.py, backend/modules/reports/models/financial_indicators.py -->
## Surse de Date - OBLIGATORIU
### Preferință: VBAL (Balanța de Verificare)
**ÎNTOTDEAUNA** folosește tabelul `VBAL` pentru calculul indicatorilor financiari, NU `vbalanta_parteneri`.
| Indicator | Tabel | Conturi | Coloane |
|-----------|-------|---------|---------|
| Vânzări/Cifra Afaceri | VBAL | 70x | TOTCRED - TOTDEB(709) |
| Cheltuieli | VBAL | 6x | TOTDEB - TOTCRED(609) |
| Încasări clienți | VBAL | 4111, 461 | RULCRED (lunar), TOTCRED (YTD) |
| Plăți furnizori | VBAL | 401, 404, 462 | RULDEB (lunar), TOTDEB (YTD) |
| Solduri bilanțiere | VBAL | diverse | SOLDDEB, SOLDCRED |
### Excepție: ACT (Registru Jurnal)
Pentru **Achiziții YTD** se folosește tabelul `ACT` deoarece captează și achizițiile directe pe cheltuieli (6x = 4x):
```sql
-- Achiziții totale (stocuri + cheltuieli directe)
SELECT SUM(CASE WHEN (SCD LIKE '3%' OR SCD LIKE '6%')
AND (SCC LIKE '4%' OR SCC LIKE '46%')
THEN SUMA ELSE 0 END)
FROM ACT WHERE STERS = 0
```
## Structura VBAL
```sql
SELECT
cont, -- Cont contabil (ex: '4111', '701')
solddeb1, -- Sold debitor la 1 ianuarie
soldcred1, -- Sold creditor la 1 ianuarie
ruldeb, -- Rulaj DEBIT luna curentă
rulcred, -- Rulaj CREDIT luna curentă
totdeb, -- Total DEBIT YTD (de la 1 ianuarie)
totcred, -- Total CREDIT YTD (de la 1 ianuarie)
solddeb, -- Sold final debitor
soldcred -- Sold final creditor
FROM {schema}.VBAL
WHERE AN = :an AND LUNA = :luna
```
### Când să folosești fiecare coloană:
| Nevoie | Coloană | Exemplu |
|--------|---------|---------|
| Valoare lunară | `RULDEB`, `RULCRED` | Încasări luna curentă |
| Valoare YTD | `TOTDEB`, `TOTCRED` | Cifra de Afaceri YTD |
| Sold curent | `SOLDDEB`, `SOLDCRED` | Sold clienți |
| Sold început an | `SOLDDEB1`, `SOLDCRED1` | Active la 1 ianuarie |
## TVA în Indicatori
### Cu TVA (fluxuri de numerar reale):
- ✅ Cash Flow (încasări/plăți)
- ✅ Solduri clienți/furnizori
- ✅ DSO, DPO (zile încasare/plată)
### Fără TVA (indicatori economici):
- ✅ Cifra de Afaceri (Clasa 7)
- ✅ Cheltuieli (Clasa 6)
- ✅ Profit Brut
- ✅ Achiziții (din ACT)
## Pattern-uri de Cod
### Query VBAL cu agregare pe conturi:
```python
query = f"""
SELECT
NVL(SUM(CASE WHEN CONT LIKE '70%' THEN TOTCRED ELSE 0 END), 0) -
NVL(SUM(CASE WHEN CONT = '709' THEN TOTDEB ELSE 0 END), 0) as cifra_afaceri
FROM {schema}.VBAL
WHERE AN = :an AND LUNA = :luna
"""
```
### Cache decorator:
```python
@staticmethod
@cached(cache_type='fin_indicator_name', key_params=['company_id', 'luna', 'an'])
async def get_indicator(...):
...
```
## NU folosi
-`vbalanta_parteneri` pentru calcule agregate (doar pentru rapoarte pe parteneri)
-`SOLDDEB`/`SOLDCRED` pentru Clasa 6/7 (conturile se închid lunar, sold=0)
- ❌ Hardcodare valori TVA (19%, 9%) în formule

View File

@@ -1,627 +0,0 @@
# Frontend Style Audit - Handover Document
**Data:** 2025-01-06
**Sesiune:** Audit CSS cu Playwright
**Status:** ✅ COMPLETAT (2025-01-06)
## ✅ REZUMAT FINAL
### Ce s-a implementat:
1. **Theme Toggle Button** - Buton în header cu 3 stări (auto/light/dark)
2. **CSS Dark Mode Variables** - `variables.css` cu `[data-theme="dark"]` și media query
3. **PrimeVue Overrides Centralizate** - Toate `:deep()` mutate în `vendor/primevue-overrides.css`
4. **Design Tokens** - Înlocuite ~500+ culori hardcodate cu variabile CSS
### Fișiere modificate:
- `src/shared/components/layout/AppHeader.vue` - Theme toggle button
- `src/assets/css/core/variables.css` - Dark mode variables
- `src/shared/styles/layout/header.css` - Theme toggle styles
- `src/assets/css/vendor/primevue-overrides.css` - PrimeVue centralized overrides
- `src/modules/data-entry/views/receipts/ReceiptsListView.vue`
- `src/modules/data-entry/views/receipts/ReceiptCreateView.vue`
- `src/modules/data-entry/components/ocr/OCRPreview.vue`
- `src/modules/data-entry/components/ocr/OCRUploadZone.vue`
- `src/modules/reports/views/InvoicesView.vue`
- `src/modules/data-entry/views/OCRMetricsView.vue`
- `src/modules/reports/components/dashboard/DetailedDataTable.vue`
- `src/modules/reports/views/ServerLogsView.vue`
- `src/modules/reports/views/TelegramView.vue`
### Teste Playwright (screenshot-uri în `.playwright-mcp/`):
- `test-dashboard-light-mode.png`
- `test-dashboard-dark-mode.png`
- `test-receipts-list-dark-mode.png`
- `test-receipt-create-dark-mode.png`
---
---
## Context
Utilizatorul a raportat probleme de stil pe diverse pagini:
- Pagini care nu respectă tema sistemului
- Fundal alb cu text gri/alb care nu se vede
- Header-ul nu este vizibil din cauza schemei de culori
- Pagini neunitare ca stil pe mobil vs desktop
Am folosit **Playwright MCP** pentru a testa vizual toate paginile la rezoluții desktop (1920x1080) și mobil (375x812), apoi am analizat codul sursă pentru a identifica problemele CSS.
**Credențiale folosite pentru testare:**
- Username: `MARIUS M`
- Password: `PAROLA9911`
- Firma: `ROMFAST SRL`
---
## Rezultate Audit Vizual (Playwright)
### Screenshot-uri salvate în `.playwright-mcp/`:
- `desktop-home-1920x1080.png` - Dashboard desktop
- `desktop-invoices-1920x1080.png` - Facturi desktop
- `desktop-trial-balance-1920x1080.png` - Balanță verificare desktop
- `desktop-data-entry-list-1920x1080.png` - Lista bonuri desktop
- `desktop-data-entry-create-1920x1080.png` - Formular bon desktop
- `mobile-dashboard-375x812.png` - Dashboard mobil
- `mobile-invoices-375x812.png` - Facturi mobil
- `mobile-data-entry-list-375x812.png` - Lista bonuri mobil
- `mobile-data-entry-create-375x812.png` - Formular bon mobil
### Concluzii vizuale:
- ✅ Header-ul este vizibil pe toate paginile (desktop și mobil)
- ✅ Textul este lizibil - nu am găsit probleme majore de contrast
- ✅ Meniul hamburger funcționează pe mobil
- ✅ Formularele și tabelele sunt vizibile
- ⚠️ Stilurile sunt inconsistente (culori hardcodate vs design tokens)
---
## 🌓 Analiză Dark Mode / Light Mode
### Status Actual Dark Mode
| Aspect | Status | Detalii |
|--------|--------|---------|
| **CSS Variables** | ✅ Parțial implementat | `variables.css` are `@media (prefers-color-scheme: dark)` |
| **PrimeVue Theme** | ❌ Light Only | Folosește `saga-blue` (doar light mode) |
| **Componente Custom** | ❌ Nu suportă | Culori hardcodate nu se adaptează |
### Configurație Dark Mode în Cod
**Fișier:** `src/assets/css/core/variables.css` (liniile 156-184)
```css
@media (prefers-color-scheme: dark) {
:root {
--color-text: #f9fafb;
--color-bg: #111827;
--color-bg-secondary: #1f2937;
--surface-50: #020617;
/* ... alte variabile */
}
}
```
**PrimeVue Theme:** `src/main.js` (linia 42)
```javascript
import 'primevue/resources/themes/saga-blue/theme.css' // LIGHT ONLY!
```
### Screenshot-uri Dark Mode (Playwright Test)
Salvate în `.playwright-mcp/`:
- `dark-mode-dashboard.png` - ✅ Dashboard arată bine
- `dark-mode-receipts-list.png` - ✅ Lista bonuri OK (cu eroare API)
- `dark-mode-receipt-create.png` - ❌ **PROBLEMĂ CRITICĂ** - Formular cu fundal ALB!
- `dark-mode-invoices.png` - ⚠️ Card cu fundal mai deschis
### 🔴 PROBLEME CRITICE DARK MODE
#### 1. Formularul Bon Nou - Fundal Alb pe Dark Mode
**Fișier:** `src/modules/data-entry/views/receipts/ReceiptCreateView.vue`
**Problema:** Card-ul formularului are `background: white` hardcodat care NU se adaptează la dark mode.
**Screenshot:** Vezi `dark-mode-receipt-create.png` - formularul are fundal complet alb pe pagină dark.
**Corecție necesară:**
```css
/* ❌ GREȘIT */
.form-card {
background: white;
}
/* ✅ CORECT */
.form-card {
background: var(--surface-card, var(--color-bg));
}
```
#### 2. OCR Upload Zone - Fundal Hardcodat
**Fișier:** `src/modules/data-entry/components/ocr/OCRUploadZone.vue`
**Problema:** Zona de upload are culori hardcodate (#f8fafc, #cbd5e1).
#### 3. PrimeVue Components - Nu Suportă Dark Mode
**Cauza:** Tema `saga-blue` este DOAR pentru light mode.
**Soluții posibile:**
1. **Schimbă tema** la `lara-dark-blue` sau `aura-dark-blue` pentru dark mode
2. **Folosește theme switching** cu două teme (light + dark)
3. **Override PrimeVue variables** în `primevue-overrides.css`
### Componente Afectate de Dark Mode
| Componentă | Problemă | Severitate |
|------------|----------|------------|
| `ReceiptCreateView.vue` | Fundal alb hardcodat | 🔴 CRITIC |
| `OCRUploadZone.vue` | Culori hardcodate pe dropzone | 🔴 CRITIC |
| `OCRPreview.vue` | Fundal alb pe card results | 🔴 CRITIC |
| `ReceiptsListView.vue` | Card-uri cu fundal alb | 🟡 MEDIU |
| `InvoicesView.vue` | Table background hardcodat | 🟡 MEDIU |
| `DetailedDataTable.vue` | `background: #ffffff` | 🟡 MEDIU |
### Strategia Recomandată pentru Dark Mode
**Opțiunea 1 - Quick Fix (Recomandat pentru acum):**
1. Înlocuiește toate `background: white` / `#ffffff` cu `var(--surface-card)`
2. Înlocuiește culori text hardcodate cu `var(--text-color)`
3. CSS variables se vor adapta automat la `prefers-color-scheme: dark`
**Opțiunea 2 - Full Dark Mode Support (Viitor):**
1. Adaugă theme switcher în UI
2. Schimbă PrimeVue theme dinamic (`saga-blue``lara-dark-blue`)
3. Salvează preferința user în localStorage
### Testare Dark Mode cu Playwright
Pentru a testa dark mode, folosește:
```javascript
// Activează dark mode în Playwright
await page.emulateMedia({ colorScheme: 'dark' });
// Ia screenshot
await page.screenshot({ path: 'dark-mode-test.png' });
```
Sau în tool MCP:
```
mcp__plugin_playwright_playwright__browser_run_code
code: "async (page) => { await page.emulateMedia({ colorScheme: 'dark' }); }"
```
---
## Probleme Identificate în Cod
### 1. Culori Hardcodate (~500+ instanțe) - CRITIC
| Fișier | Probleme | Severitate |
|--------|----------|------------|
| `src/modules/data-entry/components/ocr/OCRPreview.vue` | ~80 culori hardcodate | 🔴 CRITIC |
| `src/modules/data-entry/views/receipts/ReceiptCreateView.vue` | ~100+ culori hardcodate | 🔴 CRITIC |
| `src/modules/data-entry/views/receipts/ReceiptsListView.vue` | ~50+ culori hardcodate | 🔴 CRITIC |
| `src/modules/data-entry/views/OCRMetricsView.vue` | ~70+ culori hardcodate | 🔴 CRITIC |
| `src/modules/reports/components/dashboard/cards/MaturityAndDetailsCard.vue` | ~100+ culori | 🟡 MEDIU |
| `src/modules/reports/components/dashboard/cards/ClientsFurnizoriBalanceCard.vue` | ~50+ culori | 🟡 MEDIU |
| `src/modules/data-entry/components/ocr/OCRUploadZone.vue` | ~30+ culori | 🟡 MEDIU |
| `src/modules/data-entry/components/ocr/OCRConfidenceIndicator.vue` | ~10 culori | 🟢 MINOR |
**Exemple de cod problematic:**
```css
/* ❌ GREȘIT - culori hardcodate */
background: #f8fafc;
color: #64748b;
border: 1px solid #e2e8f0;
background: #dcfce7;
color: #166534;
/* ✅ CORECT - design tokens */
background: var(--surface-ground);
color: var(--text-color-secondary);
border: 1px solid var(--surface-border);
background: var(--green-100);
color: var(--green-800);
```
### 2. Utilizare `:deep()` - ÎNCĂLCARE REGULI CSS (22 instanțe)
Conform `docs/CSS_PATTERNS.md`, `:deep()` nu trebuie folosit în componente. Override-urile PrimeVue trebuie mutate în `src/assets/css/vendor/`.
| Fișier | Linii cu `:deep()` |
|--------|-------------------|
| `src/modules/reports/views/InvoicesView.vue` | 882, 886, 890, 894 |
| `src/modules/data-entry/views/receipts/ReceiptCreateView.vue` | 2715, 2720, 2724, 2729, 2738, 2743, 2775, 2779, 2962-2964 |
| `src/modules/data-entry/views/receipts/ReceiptsListView.vue` | 1071, 1076, 1081, 1386 |
| `src/modules/data-entry/components/ocr/OCRUploadZone.vue` | 525, 531 |
| `src/modules/data-entry/components/ocr/OCRPreview.vue` | 726 |
### 3. `background: white` / `#ffffff` hardcodat (12 instanțe)
```
src/modules/reports/views/ServerLogsView.vue:346 - background: #ffffff
src/modules/reports/views/TelegramView.vue:285 - background: white
src/modules/reports/components/dashboard/DetailedDataTable.vue - 3 instanțe (#ffffff)
src/modules/data-entry/components/ocr/OCRPreview.vue:687 - background: white
src/modules/data-entry/views/OCRMetricsView.vue - 3 instanțe (white)
src/modules/data-entry/views/receipts/ReceiptCreateView.vue - 2 instanțe (#fff3e0)
src/modules/data-entry/views/receipts/ReceiptsListView.vue:1100 - background: white
```
---
## Mapping Culori Hardcodate → Design Tokens
| Hardcoded | Design Token | Utilizare |
|-----------|--------------|-----------|
| `#ffffff`, `white` | `var(--surface-card)` | Fundal carduri |
| `#f8fafc`, `#f8f9fa` | `var(--surface-ground)` | Fundal pagină |
| `#f1f5f9` | `var(--surface-hover)` | Hover state |
| `#e2e8f0`, `#e5e7eb` | `var(--surface-border)` | Borduri |
| `#64748b`, `#6b7280` | `var(--text-color-secondary)` | Text secundar |
| `#1e293b`, `#111827`, `#334155` | `var(--text-color)` | Text principal |
| `#94a3b8`, `#9ca3af` | `var(--text-color-secondary)` | Text muted |
| `#22c55e`, `#16a34a` | `var(--green-500)`, `var(--green-600)` | Success |
| `#dcfce7`, `#d1fae5` | `var(--green-100)` | Success background |
| `#166534`, `#15803d` | `var(--green-800)`, `var(--green-700)` | Success dark |
| `#ef4444`, `#dc2626` | `var(--red-500)`, `var(--red-600)` | Error/Danger |
| `#fee2e2`, `#fecaca` | `var(--red-100)` | Error background |
| `#991b1b`, `#b91c1c` | `var(--red-800)`, `var(--red-700)` | Error dark |
| `#f59e0b`, `#d97706` | `var(--yellow-500)`, `var(--yellow-600)` | Warning |
| `#fef3c7`, `#fef9c3` | `var(--yellow-100)` | Warning background |
| `#92400e`, `#854d0e` | `var(--yellow-800)` | Warning dark |
| `#3b82f6`, `#2563eb` | `var(--primary-500)`, `var(--primary-600)` | Primary |
| `#dbeafe`, `#e0e7ff` | `var(--primary-100)` | Primary background |
| `#1e40af`, `#1d4ed8` | `var(--primary-800)`, `var(--primary-700)` | Primary dark |
| `#667eea` | `var(--primary-400)` | Primary light |
| `#6366f1` | `var(--primary-500)` | Indigo/Primary |
---
## Plan de Acțiune
### ✅ COMPLETAT - Theme Toggle & Dark Mode Infrastructure
| Task | Status | Detalii |
|------|--------|---------|
| Theme Toggle Button | ✅ DONE | `AppHeader.vue:39-47` - buton 3 stări (auto/light/dark) |
| Dark Mode CSS Variables | ✅ DONE | `variables.css:223-373` - `[data-theme="dark"]` + media query |
| Theme Toggle Styles | ✅ DONE | `header.css:135-169` - stiluri + gradient variant |
### Prioritate 1 - CRITICĂ (Impact mare, folosite frecvent)
#### 1.1. `ReceiptsListView.vue` (~50 corecții)
- [x] Înlocuiește `background: white``var(--surface-card)`
- [x] Înlocuiește `#f8fafc``var(--surface-ground)`
- [x] Înlocuiește `#e2e8f0``var(--surface-border)`
- [x] Înlocuiește `#64748b``var(--text-color-secondary)`
- [x] Înlocuiește `#1e293b``var(--text-color)`
- [x] Mută `:deep()` CSS în `vendor/primevue-overrides.css`
#### 1.2. `ReceiptCreateView.vue` (~100 corecții)
- [x] Înlocuiește toate culorile hardcodate conform mapping-ului ✅
- [x] Mută `:deep()` CSS (12 instanțe) în `vendor/primevue-overrides.css`
#### 1.3. `OCRPreview.vue` (~80 corecții)
- [x] Înlocuiește toate culorile green (#f0fdf4, #dcfce7, #86efac, #166534) ✅
- [x] Înlocuiește toate culorile slate (#f8fafc, #e2e8f0, #64748b) ✅
- [x] Mută `:deep()` (1 instanță) în `vendor/primevue-overrides.css`
### Prioritate 2 - MEDIE (Impact moderat)
#### 2.1. `OCRMetricsView.vue` (~70 corecții)
- [x] Fix 3x `background: white``var(--surface-card)`
#### 2.2. `OCRUploadZone.vue` (~30 corecții)
- [x] Înlocuiește culorile pentru drop zone și states ✅
- [x] Mută `:deep()` (2 instanțe) în `vendor/primevue-overrides.css`
#### 2.3. `InvoicesView.vue` (4 corecții)
- [x] Mută `:deep()` în `vendor/primevue-overrides.css`
### Prioritate 3 - JOASĂ (Dashboard cards)
- [x] `DetailedDataTable.vue` - 3x `background: white``var(--surface-card)`
- [x] `ServerLogsView.vue` - 1x `background: white``var(--surface-card)`
- [x] `TelegramView.vue` - 1x `background: white``var(--surface-card)`
### ⏳ Rămase pentru viitor (P3 - opțional)
- [ ] `MaturityAndDetailsCard.vue` - audit culori hardcodate
- [ ] `ClientsFurnizoriBalanceCard.vue` - audit culori hardcodate
- [ ] Alte carduri din `src/modules/reports/components/dashboard/cards/`
---
## Testare cu Playwright MCP
### Pregătire Mediu
```bash
# 1. Pornește aplicația
./start-prod.sh
# 2. Așteaptă până serviciile sunt active
# - Backend: http://localhost:8000
# - Frontend: http://localhost:3000
```
### Workflow Testare după Corecții
După fiecare fișier corectat, execută următorii pași cu Playwright MCP:
#### Pas 1: Login și Navigare
```
# Folosește tool-urile MCP în ordine:
1. mcp__plugin_playwright_playwright__browser_navigate
url: "http://localhost:3000"
2. mcp__plugin_playwright_playwright__browser_snapshot
# Verifică că pagina login s-a încărcat
3. mcp__plugin_playwright_playwright__browser_type
element: "Username input field"
ref: [ref din snapshot pentru input username]
text: "MARIUS M"
4. mcp__plugin_playwright_playwright__browser_type
element: "Password input field"
ref: [ref din snapshot pentru input password]
text: "PAROLA9911"
submit: true
5. mcp__plugin_playwright_playwright__browser_wait_for
time: 2 # Așteaptă 2 secunde pentru login
6. mcp__plugin_playwright_playwright__browser_snapshot
# Verifică că ești logat (ar trebui să vezi Dashboard)
```
#### Pas 2: Testare Desktop (1920x1080)
```
1. mcp__plugin_playwright_playwright__browser_resize
width: 1920
height: 1080
2. # Pentru fiecare pagină modificată:
# Lista Bonuri
mcp__plugin_playwright_playwright__browser_navigate
url: "http://localhost:3000/data-entry"
mcp__plugin_playwright_playwright__browser_take_screenshot
filename: "test-receipts-list-desktop.png"
# Formular Bon Nou
mcp__plugin_playwright_playwright__browser_navigate
url: "http://localhost:3000/data-entry/create"
mcp__plugin_playwright_playwright__browser_take_screenshot
filename: "test-receipt-create-desktop.png"
# Dashboard
mcp__plugin_playwright_playwright__browser_navigate
url: "http://localhost:3000/reports/dashboard"
mcp__plugin_playwright_playwright__browser_take_screenshot
filename: "test-dashboard-desktop.png"
# Facturi
mcp__plugin_playwright_playwright__browser_navigate
url: "http://localhost:3000/reports/invoices"
mcp__plugin_playwright_playwright__browser_take_screenshot
filename: "test-invoices-desktop.png"
```
#### Pas 3: Testare Mobile (375x812)
```
1. mcp__plugin_playwright_playwright__browser_resize
width: 375
height: 812
2. # Repetă pentru aceleași pagini:
mcp__plugin_playwright_playwright__browser_navigate
url: "http://localhost:3000/data-entry"
mcp__plugin_playwright_playwright__browser_take_screenshot
filename: "test-receipts-list-mobile.png"
mcp__plugin_playwright_playwright__browser_navigate
url: "http://localhost:3000/data-entry/create"
mcp__plugin_playwright_playwright__browser_take_screenshot
filename: "test-receipt-create-mobile.png"
# etc.
```
#### Pas 4: Verificare Vizuală
După capturarea screenshot-urilor, citește-le cu tool-ul Read:
```
Read file_path: "/workspace/roa2web/.playwright-mcp/test-receipts-list-desktop.png"
Read file_path: "/workspace/roa2web/.playwright-mcp/test-receipts-list-mobile.png"
```
### Checklist Verificare Vizuală
Pentru fiecare screenshot, verifică:
| Element | Ce să verifici | ✓/✗ |
|---------|----------------|-----|
| **Header** | Vizibil, logo ROA2WEB citibil, meniu hamburger pe mobil | |
| **Text principal** | Contrast suficient, citibil pe fundal | |
| **Text secundar** | Vizibil dar mai subtle decât cel principal | |
| **Butoane** | Culori corecte (primary=albastru, success=verde, danger=roșu) | |
| **Carduri** | Fundal diferențiat de background pagină | |
| **Borduri** | Vizibile dar subtile | |
| **Badge-uri status** | Culori distincte per status (CIORNĂ=albastru, APROBAT=verde, etc.) | |
| **Formulare** | Input-uri vizibile, labels citibile | |
| **Tabele** | Alternare rânduri vizibilă, header distinct | |
### Pagini de Testat
| URL | Fișiere Afectate | Prioritate |
|-----|------------------|------------|
| `/data-entry` | ReceiptsListView.vue | P1 |
| `/data-entry/create` | ReceiptCreateView.vue, OCRPreview.vue, OCRUploadZone.vue | P1 |
| `/data-entry/edit/:id` | ReceiptCreateView.vue (same component) | P1 |
| `/reports/dashboard` | Dashboard cards | P3 |
| `/reports/invoices` | InvoicesView.vue | P2 |
| `/reports/trial-balance` | TrialBalanceView.vue | P3 |
| `/reports/bank-cash` | BankCashView.vue | P3 |
### Comparare Before/After
Screenshot-urile BEFORE sunt deja salvate în `.playwright-mcp/`:
- `desktop-data-entry-list-1920x1080.png`
- `desktop-data-entry-create-1920x1080.png`
- `mobile-data-entry-list-375x812.png`
- `mobile-data-entry-create-375x812.png`
După corecții, compară vizual cu cele noi pentru a te asigura că:
1. Nu s-au pierdut stiluri
2. Contrastul s-a îmbunătățit sau a rămas la fel
3. Layout-ul este identic
### Script Automatizat Testare
Pentru testare rapidă, poți folosi acest flow:
```javascript
// Pseudo-cod pentru testare completă
async function testAllPages() {
// Login
await navigate('http://localhost:3000')
await login('MARIUS M', 'PAROLA9911')
const pages = [
'/data-entry',
'/data-entry/create',
'/reports/dashboard',
'/reports/invoices',
'/reports/trial-balance'
]
// Desktop
await resize(1920, 1080)
for (const page of pages) {
await navigate(`http://localhost:3000${page}`)
await screenshot(`after-desktop-${page.replace(/\//g, '-')}.png`)
}
// Mobile
await resize(375, 812)
for (const page of pages) {
await navigate(`http://localhost:3000${page}`)
await screenshot(`after-mobile-${page.replace(/\//g, '-')}.png`)
}
}
```
### Troubleshooting Playwright
| Problemă | Soluție |
|----------|---------|
| Browser not installed | `mcp__plugin_playwright_playwright__browser_install` |
| Timeout la navigate | Crește timeout sau verifică că serverul rulează |
| Element not found | Fă `browser_snapshot` și verifică ref-urile disponibile |
| Screenshot gol/alb | Așteaptă cu `browser_wait_for` înainte de screenshot |
---
## Documentație Relevantă
Înainte de a începe corecțiile, citește:
1. `docs/CSS_PATTERNS.md` - Toate pattern-urile CSS aprobate
2. `docs/DESIGN_TOKENS.md` - Lista completă de design tokens
3. `docs/ONBOARDING_CSS.md` - Quick start pentru CSS system
---
## Comenzi Utile
```bash
# Pornește mediul de dezvoltare
./start-prod.sh
# Verifică modificările vizual
# Navighează la http://localhost:3000
# Găsește toate culorile hardcodate într-un fișier
grep -n "#[0-9a-fA-F]\{6\}" src/modules/data-entry/views/receipts/ReceiptsListView.vue
# Găsește toate utilizările :deep()
grep -rn ":deep(" src/modules/
# Verifică că nu ai introdus noi hardcodate colors
grep -rn "#[0-9a-fA-F]\{6\}" src/modules/ --include="*.vue" | wc -l
```
---
## Note pentru Sesiunea Următoare
1. **Nu modifica logica JavaScript** - doar CSS/stiluri
2. **Testează pe mobil după fiecare fișier** - folosește Playwright sau DevTools
3. **Commitează incremental** - un fișier per commit pentru ușurință la review
4. **Verifică dark mode** dacă există - design tokens sunt compatibile
---
## Estimare Efort
| Prioritate | Fișiere | Corecții | Testare Playwright | Total |
|------------|---------|----------|-------------------|-------|
| P1 - Critică | 3 fișiere | ~2-3 ore | ~30 min | ~3 ore |
| P2 - Medie | 3 fișiere | ~1-2 ore | ~20 min | ~2 ore |
| P3 - Joasă | 5+ fișiere | ~2-3 ore | ~30 min | ~3 ore |
| **TOTAL** | ~11 fișiere | ~5-8 ore | ~1.5 ore | **~7-10 ore** |
---
## Quick Start pentru Sesiunea Următoare
```bash
# 1. Citește acest document
cat .claude/sessions/2025-01-06_frontend-style-audit.md
# 2. Pornește mediul
./start-prod.sh
# 3. Începe cu primul fișier din P1
# Fișier: src/modules/data-entry/views/receipts/ReceiptsListView.vue
# 4. După corecții, testează cu Playwright MCP
# - Navigare: http://localhost:3000/data-entry
# - Screenshot desktop (1920x1080)
# - Screenshot mobile (375x812)
# - Compară cu before screenshots din .playwright-mcp/
# 5. Commit incremental
git add src/modules/data-entry/views/receipts/ReceiptsListView.vue
git commit -m "refactor(css): Replace hardcoded colors with design tokens in ReceiptsListView"
```
---
## Fișiere Relevante
```
.claude/sessions/2025-01-06_frontend-style-audit.md # Acest document
.playwright-mcp/ # Screenshot-uri BEFORE
docs/CSS_PATTERNS.md # Pattern-uri CSS aprobate
docs/DESIGN_TOKENS.md # Design tokens disponibile
src/assets/css/vendor/ # Locație pentru :deep() overrides
```
---
**Creat de:** Claude Code (Opus 4.5)
**Data:** 2025-01-06
**Pentru:** Handover către sesiune nouă

View File

@@ -89,6 +89,18 @@ class BalanceSheetAggregates(BaseModel):
default=Decimal('0'),
description="Cheltuieli financiare (Clasa 66 - dobânzi, diferențe curs)"
)
capital_social_strict: Decimal = Field(
default=Decimal('0'),
description="Capital Social strict (doar contul 101 - subscris și vărsat)"
)
cifra_afaceri: Decimal = Field(
default=Decimal('0'),
description="Cifra de afaceri (doar 70x FĂRĂ TVA, fără 71x-75x)"
)
achizitii_stocuri: Decimal = Field(
default=Decimal('0'),
description="Achiziții stocuri YTD (Clasa 3 TOTDEB, FĂRĂ TVA)"
)
# Computed properties pentru calculele ulterioare
@property
@@ -220,6 +232,12 @@ class LiquidityIndicators(BaseModel):
lichiditate_vedere: Cash Ratio = disponibilități / datorii_curente
- Măsoară capacitatea de plată imediată doar din numerar
- Good: >= 0.2, Warning: 0.1-0.2, Danger: < 0.1
Sub-indicatori pentru verificare:
- active_curente: Active Curente = Stocuri + Creanțe + Disponibilități
- disponibilitati: Disponibilități (bancă + casă)
- creante: Creanțe comerciale
- datorii_curente: Datorii pe termen scurt
"""
lichiditate_curenta: IndicatorResult = Field(
description="Current Ratio = active_curente / datorii_curente"
@@ -230,6 +248,23 @@ class LiquidityIndicators(BaseModel):
lichiditate_vedere: IndicatorResult = Field(
description="Cash Ratio = disponibilități / datorii_curente"
)
# Sub-indicatori pentru verificare manuală în balanță
active_curente: Optional[IndicatorResult] = Field(
default=None,
description="Active Curente = Stocuri + Creanțe + Disponibilități (RON)"
)
disponibilitati: Optional[IndicatorResult] = Field(
default=None,
description="Disponibilități = Bancă (512x) + Casă (531x) (RON)"
)
creante: Optional[IndicatorResult] = Field(
default=None,
description="Creanțe comerciale = Clienți (411x) + Debitori (461x) (RON)"
)
datorii_curente: Optional[IndicatorResult] = Field(
default=None,
description="Datorii pe termen scurt = Furnizori + TVA + Salarii etc. (RON)"
)
class Config:
json_schema_extra = {
@@ -301,6 +336,31 @@ class EfficiencyIndicators(BaseModel):
rata_plata: IndicatorResult = Field(
description="Rata de plată = plati / achizitii * 100"
)
# Sub-indicatori pentru verificare manuală
sold_clienti: Optional[IndicatorResult] = Field(
default=None,
description="Sold Clienți la final de lună (RON)"
)
facturari_lunare: Optional[IndicatorResult] = Field(
default=None,
description="Total facturări în luna curentă (RON)"
)
sold_furnizori: Optional[IndicatorResult] = Field(
default=None,
description="Sold Furnizori la final de lună (RON)"
)
achizitii_lunare: Optional[IndicatorResult] = Field(
default=None,
description="Total achiziții în luna curentă (RON)"
)
incasari_luna: Optional[IndicatorResult] = Field(
default=None,
description="Încasări efectuate în luna curentă (RON)"
)
plati_luna: Optional[IndicatorResult] = Field(
default=None,
description="Plăți efectuate în luna curentă (RON)"
)
class Config:
json_schema_extra = {
@@ -379,6 +439,31 @@ class RiskIndicators(BaseModel):
raport_datorii_trezorerie: IndicatorResult = Field(
description="Raport datorii/trezorerie = furnizori_sold_total / trezorerie"
)
# Sub-indicatori pentru verificare manuală
total_clienti: Optional[IndicatorResult] = Field(
default=None,
description="Sold total clienți (411x) (RON)"
)
clienti_restanti: Optional[IndicatorResult] = Field(
default=None,
description="Sold clienți cu facturi restante (RON)"
)
clienti_90plus: Optional[IndicatorResult] = Field(
default=None,
description="Sold clienți cu facturi >90 zile restant (RON)"
)
total_furnizori: Optional[IndicatorResult] = Field(
default=None,
description="Sold total furnizori (401x) (RON)"
)
furnizori_restanti: Optional[IndicatorResult] = Field(
default=None,
description="Sold furnizori cu facturi restante (RON)"
)
trezorerie: Optional[IndicatorResult] = Field(
default=None,
description="Disponibilități (512x + 531x) (RON)"
)
class Config:
json_schema_extra = {
@@ -447,6 +532,23 @@ class CashFlowIndicators(BaseModel):
acoperire_cash_flow: IndicatorResult = Field(
description="Acoperire datorii = cash_flow_ytd / datorii_restante"
)
# Sub-indicatori pentru verificare manuală
incasari_luna: Optional[IndicatorResult] = Field(
default=None,
description="Încasări luna curentă (RON)"
)
plati_luna: Optional[IndicatorResult] = Field(
default=None,
description="Plăți luna curentă (RON)"
)
cf_an_precedent: Optional[IndicatorResult] = Field(
default=None,
description="Cash Flow aceeași perioadă an precedent (RON)"
)
datorii_restante: Optional[IndicatorResult] = Field(
default=None,
description="Total datorii cu scadență depășită (RON)"
)
class Config:
json_schema_extra = {
@@ -488,19 +590,24 @@ class DynamicsIndicators(BaseModel):
Arată dacă afacerea crește sau scade prin comparație YoY (Year-over-Year).
SURSE DE DATE (toate FĂRĂ TVA):
- Vânzări: Cifra de Afaceri din VBAL (Clasa 7 - conturile 70x)
- Achiziții: Registru Jurnal ACT (stocuri 3x=4x + cheltuieli directe 6x=4x)
Attributes:
crestere_vanzari_yoy: Creșterea procentuală a vânzărilor față de anul anterior
- Formula: (facturari_curent - facturari_anterior) / facturari_anterior * 100
- Măsoară dinamica vânzărilor - creștere sau scădere
crestere_vanzari_yoy: Creșterea procentuală a Cifrei de Afaceri față de anul anterior
- Formula: (CA_curent - CA_anterior) / CA_anterior * 100
- Sursa: VBAL TOTCRED(70x) - TOTDEB(709)
- Good: > 5%, Warning: 0-5%, Danger: < 0%
crestere_achizitii_yoy: Creșterea procentuală a achizițiilor față de anul anterior
crestere_achizitii_yoy: Creșterea procentuală a achizițiilor totale față de anul anterior
- Formula: (achizitii_curent - achizitii_anterior) / achizitii_anterior * 100
- Sursa: ACT (stocuri 3x=4x + cheltuieli directe 6x=4x, fără TVA)
- Creșterea achizițiilor poate indica expansiune sau costuri mai mari
marja_implicita: Marja implicită din diferența facturări - achiziții
- Formula: (facturari - achizitii) / facturari * 100
- Arată ce procent din vânzări rămâne după achiziții
marja_implicita: Marja implicită din diferența CA - achiziții totale
- Formula: (CA - achizitii_totale) / CA * 100
- Arată ce procent din Cifra de Afaceri rămâne după achiziții
- Good: > 20%, Warning: 10-20%, Danger: < 10%
"""
crestere_vanzari_yoy: IndicatorResult = Field(
@@ -512,6 +619,23 @@ class DynamicsIndicators(BaseModel):
marja_implicita: IndicatorResult = Field(
description="Marja implicită = (facturari - achizitii) / facturari * 100"
)
# Sub-indicatori pentru verificare manuală
vanzari_an_curent: Optional[IndicatorResult] = Field(
default=None,
description="Total vânzări YTD an curent (RON)"
)
vanzari_an_precedent: Optional[IndicatorResult] = Field(
default=None,
description="Total vânzări YTD an precedent (RON)"
)
achizitii_an_curent: Optional[IndicatorResult] = Field(
default=None,
description="Total achiziții YTD an curent (stocuri + cheltuieli directe, fără TVA)"
)
achizitii_an_precedent: Optional[IndicatorResult] = Field(
default=None,
description="Total achiziții YTD an precedent (stocuri + cheltuieli directe, fără TVA)"
)
class Config:
json_schema_extra = {
@@ -713,6 +837,11 @@ class ProfitabilityIndicators(BaseModel):
profit_brut: IndicatorResult = Field(
description="Profit brut (EBIT) = Venituri - Cheltuieli operaționale"
)
# Sub-indicator pentru verificare EBIT
venituri: Optional[IndicatorResult] = Field(
default=None,
description="Total venituri (Clasa 7) - pentru verificare calcul EBIT (RON)"
)
marja_profit_brut: IndicatorResult = Field(
description="Marja de profit = Profit brut / Cifra afaceri * 100"
)
@@ -776,6 +905,91 @@ class ProfitabilityIndicators(BaseModel):
}
class SolvabilityIndicators(BaseModel):
"""
Indicatori de solvabilitate pentru evaluarea capacității firmei
de a-și acoperi datoriile pe termen lung.
Conform articolului UniversulFiscal despre Activul Net Contabil (ANC):
- ANC = Total Active - Total Datorii
- Implicații legale (din 1 ianuarie 2026): Sub 50% din capital social
→ restricții dividende, restituire împrumuturi, acordare împrumuturi noi
Attributes:
activ_net_contabil: Activul Net Contabil (ANC) în RON
- Formula: Total Active - Total Datorii
- Good: > 0 (firma are avere netă pozitivă)
- Danger: <= 0 (firma este insolvabilă tehnic)
rata_anc_capital: Rata ANC / Capital Social în %
- Formula: (ANC / Capital Social) × 100
- Good: >= 100% (ANC acoperă integral capitalul social)
- Warning: 50-100% (ANC sub capital, dar peste pragul legal)
- Danger: < 50% (sub pragul legal - restricții aplicabile)
total_active: Total Active - valoare de verificare
total_datorii: Total Datorii - valoare de verificare
capital_social: Capital Social - valoare de verificare
"""
activ_net_contabil: IndicatorResult = Field(
description="Activ Net Contabil = Total Active - Total Datorii (RON)"
)
rata_anc_capital: IndicatorResult = Field(
description="Rata ANC/Capital = (ANC / Capital Social) × 100 (%)"
)
# Valori de bază pentru verificare manuală în balanță
total_active: IndicatorResult = Field(
description="Total Active - bază calcul ANC"
)
total_datorii: IndicatorResult = Field(
description="Total Datorii - bază calcul ANC"
)
capital_social: IndicatorResult = Field(
description="Capital Social - bază calcul Rata ANC"
)
class Config:
json_schema_extra = {
"example": {
"activ_net_contabil": {
"value": 850000.00,
"status": "good",
"threshold_min": 0,
"threshold_max": None,
"message": "Activ net pozitiv - firma solvabilă"
},
"rata_anc_capital": {
"value": 125.5,
"status": "good",
"threshold_min": 100.0,
"threshold_max": None,
"message": "ANC peste capitalul social - situație sănătoasă"
},
"total_active": {
"value": 1800000.00,
"status": "good",
"threshold_min": None,
"threshold_max": None,
"message": "Active Imobilizate + Active Curente"
},
"total_datorii": {
"value": 950000.00,
"status": "good",
"threshold_min": None,
"threshold_max": None,
"message": "Datorii Curente + Datorii Termen Lung"
},
"capital_social": {
"value": 680000.00,
"status": "good",
"threshold_min": None,
"threshold_max": None,
"message": "Capital subscris și vărsat"
}
}
}
class FinancialIndicatorsResponse(BaseModel):
"""
Răspunsul complet al endpoint-ului /api/reports/dashboard/financial-indicators.
@@ -791,6 +1005,7 @@ class FinancialIndicatorsResponse(BaseModel):
dinamica: Indicatori de dinamică (creștere vânzări/achiziții YoY, marjă)
altman_zscore: Scorul Altman Z-Score și componentele X1-X4
profitabilitate: Indicatori de profitabilitate (ROA, ROE, marjă profit)
solvabilitate: Indicatori de solvabilitate (ANC, rata ANC/Capital Social)
Usage:
GET /api/reports/dashboard/financial-indicators?company=123&luna=12&an=2024
@@ -803,7 +1018,8 @@ class FinancialIndicatorsResponse(BaseModel):
"cash_flow": { ... },
"dinamica": { ... },
"altman_zscore": { ... },
"profitabilitate": { ... }
"profitabilitate": { ... },
"solvabilitate": { ... }
}
"""
lichiditate: LiquidityIndicators = Field(
@@ -827,6 +1043,9 @@ class FinancialIndicatorsResponse(BaseModel):
profitabilitate: ProfitabilityIndicators = Field(
description="Indicatori de profitabilitate: ROA, ROE, marja de profit"
)
solvabilitate: SolvabilityIndicators = Field(
description="Indicatori de solvabilitate: ANC, rata ANC/Capital Social"
)
class Config:
json_schema_extra = {
@@ -868,6 +1087,13 @@ class FinancialIndicatorsResponse(BaseModel):
"x4": {"value": 1.80, "status": "good"},
"working_capital": 450000.00,
"total_assets": 1800000.00
},
"solvabilitate": {
"activ_net_contabil": {"value": 850000.00, "status": "good", "threshold_min": 0},
"rata_anc_capital": {"value": 125.5, "status": "good", "threshold_min": 100.0},
"total_active": {"value": 1800000.00, "status": "good"},
"total_datorii": {"value": 950000.00, "status": "good"},
"capital_social": {"value": 680000.00, "status": "good"}
}
}
}

View File

@@ -1,6 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from typing import Optional
# import sys # Removed - no longer needed
import os
from shared.auth.dependencies import get_current_user
@@ -290,6 +289,7 @@ async def get_maturity_analysis(
@router.get("/monthly-flows")
async def get_monthly_flows(
request: Request,
company: int = Query(..., description="ID-ul firmei"),
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
an: Optional[int] = Query(None, ge=2000, le=2100, description="Anul contabil"),
@@ -301,15 +301,31 @@ async def get_monthly_flows(
- Necesită autentificare JWT
- Returnează date pentru analiza fluxurilor lunare
- luna/an: perioada contabilă de referință (dacă nu sunt specificate, folosește ultima perioadă)
- Include metadata cache pentru Telegram Bot (X-Include-Cache-Metadata header)
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if str(company) not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
result = await DashboardService.get_monthly_flows(company, luna=luna, an=an)
return result
# Apelăm serviciul cu request pentru cache metadata
result = await DashboardService.get_monthly_flows(company, luna=luna, an=an, request=request)
# Convert to dict if needed
result_dict = result.dict() if hasattr(result, 'dict') else result
# Add cache metadata if requested (for Telegram Bot / Dashboard)
include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true'
if include_metadata:
cache_hit = getattr(request.state, 'cache_hit', False)
response_time = getattr(request.state, 'response_time_ms', 0)
cache_source = getattr(request.state, 'cache_source', None)
result_dict['cache_hit'] = cache_hit
result_dict['response_time_ms'] = response_time
result_dict['cache_source'] = cache_source
return result_dict
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -435,7 +451,6 @@ async def get_current_period(
@router.get(
"/financial-indicators",
response_model=FinancialIndicatorsResponse,
tags=["dashboard"]
)
async def get_financial_indicators(
@@ -445,7 +460,7 @@ async def get_financial_indicators(
an: Optional[int] = Query(None, ge=2000, le=2100, description="Anul contabil"),
include_sparklines: bool = Query(True, description="Include date istorice pentru sparklines (12 luni)"),
current_user: CurrentUser = Depends(get_current_user)
) -> FinancialIndicatorsResponse:
):
"""
Returnează toți indicatorii financiari calculați pentru firma selectată.
@@ -504,15 +519,25 @@ async def get_financial_indicators(
# Dacă include_sparklines este True, folosim metoda care include datele istorice
if include_sparklines:
response = await FinancialIndicatorsService.get_indicators_with_sparklines(
company, resolved_luna, resolved_an, months=12
company, resolved_luna, resolved_an, months=12, request=request
)
logger.info(
f"Financial indicators with sparklines for company {company}, "
f"luna={resolved_luna}, an={resolved_an}: "
f"Z-Score={response.altman_zscore.zscore.value} ({response.altman_zscore.zscore.status})"
f"Z-Score={response.altman_zscore.zscore.value} ({response.altman_zscore.zscore.status}), "
f"cache_hit={getattr(request.state, 'cache_hit', False)}, "
f"response_time={getattr(request.state, 'response_time_ms', 0):.1f}ms"
)
# Add cache metadata if requested (for Telegram Bot / Dashboard)
include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true'
if include_metadata:
result_dict = response.dict() if hasattr(response, 'dict') else response
result_dict['cache_hit'] = getattr(request.state, 'cache_hit', False)
result_dict['response_time_ms'] = getattr(request.state, 'response_time_ms', 0)
result_dict['cache_source'] = getattr(request.state, 'cache_source', None)
return result_dict
return response
# Dacă include_sparklines este False, calculăm doar indicatorii curenți
@@ -537,6 +562,12 @@ async def get_financial_indicators(
altman_task = FinancialIndicatorsService.calculate_altman_zscore(
company, resolved_luna, resolved_an
)
profitabilitate_task = FinancialIndicatorsService.calculate_profitability_indicators(
company, resolved_luna, resolved_an
)
solvabilitate_task = FinancialIndicatorsService.calculate_solvability_indicators(
company, resolved_luna, resolved_an
)
# Executăm toate calculele în paralel pentru performanță
(
@@ -545,14 +576,18 @@ async def get_financial_indicators(
risc,
cash_flow,
dinamica,
altman_zscore
altman_zscore,
profitabilitate,
solvabilitate
) = await asyncio.gather(
lichiditate_task,
eficienta_task,
risc_task,
cash_flow_task,
dinamica_task,
altman_task
altman_task,
profitabilitate_task,
solvabilitate_task
)
# Construim răspunsul
@@ -562,7 +597,9 @@ async def get_financial_indicators(
risc=risc,
cash_flow=cash_flow,
dinamica=dinamica,
altman_zscore=altman_zscore
altman_zscore=altman_zscore,
profitabilitate=profitabilitate,
solvabilitate=solvabilitate
)
logger.info(
@@ -570,6 +607,14 @@ async def get_financial_indicators(
f"Z-Score={altman_zscore.zscore.value} ({altman_zscore.zscore.status})"
)
# Add cache metadata if requested (for Telegram Bot / Dashboard)
include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true'
if include_metadata:
result_dict = response.dict() if hasattr(response, 'dict') else response
result_dict['cache_hit'] = getattr(request.state, 'cache_hit', False)
result_dict['response_time_ms'] = getattr(request.state, 'response_time_ms', 0)
result_dict['cache_source'] = getattr(request.state, 'cache_source', None)
return result_dict
return response
except ValueError as e:

View File

@@ -1546,14 +1546,16 @@ class DashboardService:
raise
@staticmethod
async def get_monthly_flows(company: int, luna: Optional[int] = None, an: Optional[int] = None) -> Dict[str, Any]:
@cached(cache_type='monthly_flows', key_params=['company', 'luna', 'an'])
async def get_monthly_flows(company: int, luna: Optional[int] = None, an: Optional[int] = None, request: Optional[Request] = None) -> Dict[str, Any]:
"""
Obține fluxurile lunare de intrare și ieșire pentru luna curentă
Obține fluxurile lunare de intrare și ieșire pentru luna curentă (CACHED 30 min)
Args:
company: ID-ul firmei
luna: Luna contabilă (1-12), opțional
an: Anul contabil, opțional
request: Request object pentru cache metadata
"""
try:
async with oracle_pool.get_connection() as connection:

View File

@@ -48,6 +48,7 @@ import { createCompaniesStore } from '@shared/stores/companies.js'
import { createAccountingPeriodStore } from '@shared/stores/accountingPeriod.js'
import { menuSections } from '@/config/menu.js'
import { getEnabledMenuSections } from '@/config/features.js'
import { handleUnauthorized, isAuthRedirectInProgress } from '@/shared/utils/authRedirect'
import axios from 'axios'
const router = useRouter()
@@ -68,6 +69,14 @@ const authApi = axios.create({
// Add interceptor to inject auth token from localStorage
authApi.interceptors.request.use(config => {
// Skip requests if we're already redirecting to login
if (isAuthRedirectInProgress()) {
const controller = new AbortController()
controller.abort()
config.signal = controller.signal
return config
}
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
@@ -75,6 +84,18 @@ authApi.interceptors.request.use(config => {
return config
})
// Response interceptor to handle 401 errors
authApi.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
// Use shared handler to prevent race conditions
handleUnauthorized()
}
return Promise.reject(error)
}
)
// Store definitions (factories return store definitions)
const useAuthStore = createAuthStore(authApi)
const useCompanyStore = createCompaniesStore(authApi, useAuthStore)

View File

@@ -1,4 +1,5 @@
import axios from 'axios'
import { handleUnauthorized, isAuthRedirectInProgress } from '@/shared/utils/authRedirect'
// Use relative path - works with both Vite dev proxy and IIS production proxy
const baseURL = import.meta.env.BASE_URL + 'api/data-entry'
@@ -10,6 +11,14 @@ const api = axios.create({
// Request interceptor for auth token and company header
api.interceptors.request.use((config) => {
// Skip requests if we're already redirecting to login
if (isAuthRedirectInProgress()) {
const controller = new AbortController()
controller.abort()
config.signal = controller.signal
return config
}
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
@@ -57,18 +66,18 @@ api.interceptors.response.use(
return response
},
(error) => {
console.error('❌ API Error:', {
url: error.config?.url,
method: error.config?.method,
code: error.code,
message: error.message
})
// Skip logging for aborted requests (happens during auth redirect)
if (error.code !== 'ERR_CANCELED') {
console.error('❌ API Error:', {
url: error.config?.url,
method: error.config?.method,
code: error.code,
message: error.message
})
}
if (error.response?.status === 401) {
// Token expired or invalid - redirect to login
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
window.location.href = import.meta.env.BASE_URL + 'login'
// Use shared handler to prevent race conditions
handleUnauthorized()
}
return Promise.reject(error)
}

View File

@@ -22,8 +22,22 @@
</div>
</div>
<!-- Dual sparkline charts - stacked vertical -->
<div class="sparkline-dual-container" v-if="hasSparklineData">
<!-- Charts toggle header -->
<div
v-if="hasSparklineData"
class="charts-toggle-header"
@click="toggleChartsExpanded"
>
<span>Grafice evoluție</span>
<i
class="pi pi-chevron-right"
:class="{ expanded: chartsExpanded }"
></i>
</div>
<!-- Dual sparkline charts - stacked vertical (collapsible) -->
<div v-show="chartsExpanded" class="charts-content">
<div class="sparkline-dual-container" v-if="hasSparklineData">
<!-- Grafic Încasări -->
<div class="sparkline-wrapper">
<div class="sparkline-title text-success">Încasări</div>
@@ -40,6 +54,14 @@
</div>
</div>
</div>
</div>
<!-- Cache Footer -->
<CacheFooter
:cache-hit="cacheInfo?.hit"
:response-time-ms="cacheInfo?.time"
:cache-source="cacheInfo?.source"
/>
</div>
</template>
@@ -53,6 +75,7 @@ import {
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
import CacheFooter from "@/shared/components/CacheFooter.vue";
Chart.register(...registerables);
@@ -97,6 +120,10 @@ const props = defineProps({
type: Array,
default: () => [],
},
cacheInfo: {
type: Object,
default: () => ({ hit: false, time: 0, source: null }),
},
});
// Refs pentru 2 canvas-uri separate
@@ -105,6 +132,13 @@ const outflowsCanvas = ref(null);
let inflowsChartInstance = null;
let outflowsChartInstance = null;
// Charts collapsible state
const chartsExpanded = ref(false);
const toggleChartsExpanded = () => {
chartsExpanded.value = !chartsExpanded.value;
};
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
@@ -607,6 +641,39 @@ onBeforeUnmount(() => {
min-height: 60px;
}
/* Charts toggle header */
.charts-toggle-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) var(--space-md);
margin-top: var(--space-sm);
background: var(--surface-hover);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
transition: background-color var(--transition-fast);
}
.charts-toggle-header:hover {
background: var(--surface-border);
}
.charts-toggle-header i {
transition: transform var(--transition-fast);
}
.charts-toggle-header i.expanded {
transform: rotate(90deg);
}
/* Charts content wrapper */
.charts-content {
margin-top: var(--space-sm);
}
/* Dual sparkline container (unique to this card) */
.sparkline-dual-container {
width: 100%;

View File

@@ -1,25 +1,23 @@
<template>
<div class="metric-card clienti-balance-card">
<!-- Main value section -->
<div class="value-section">
<div class="metric-label">Clienți</div>
<div class="metric-value" :class="getBalanceClass(total)">
{{ formatCurrency(total) }}
<!-- Header cu total și trend -->
<div class="card-header-mobile">
<div class="header-left">
<span class="header-dot clienti"></span>
<span class="header-label">Clienți</span>
</div>
</div>
<div
class="value-trend trend-indicator"
:class="getTrendClass(trend)"
v-if="trend"
>
<span class="trend-icon">{{ getTrendIcon(trend) }}</span>
<span class="trend-value">{{ Math.round(Math.abs(trend.value)) }}%</span>
</div>
<!-- Sparkline chart -->
<div class="metric-sparkline" v-if="hasSparklineData">
<div class="sparkline-chart">
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
<div class="header-values">
<span class="header-total" :class="getBalanceClass(total)">
{{ formatCurrency(total) }}
</span>
<div
class="header-trend"
:class="getTrendClass(trend)"
v-if="trend"
>
<i :class="getTrendIconClass(trend)"></i>
<span>{{ Math.round(Math.abs(trend.value)) }}%</span>
</div>
</div>
</div>
@@ -61,6 +59,35 @@
</div>
</div>
</div>
<!-- Charts toggle header -->
<div
v-if="hasSparklineData"
class="charts-toggle-header"
@click="toggleChartsExpanded"
>
<span>Grafice evoluție</span>
<i
class="pi pi-chevron-right"
:class="{ expanded: chartsExpanded }"
></i>
</div>
<!-- Sparkline chart (collapsible) -->
<div v-show="chartsExpanded" class="charts-content">
<div class="metric-sparkline" v-if="hasSparklineData">
<div class="sparkline-chart">
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
</div>
<!-- Cache Footer -->
<CacheFooter
:cache-hit="cacheInfo?.hit"
:response-time-ms="cacheInfo?.time"
:cache-source="cacheInfo?.source"
/>
</div>
</template>
@@ -74,6 +101,7 @@ import {
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
import CacheFooter from "@/shared/components/CacheFooter.vue";
Chart.register(...registerables);
@@ -106,18 +134,27 @@ const props = defineProps({
type: Object,
default: null,
},
cacheInfo: {
type: Object,
default: () => ({ hit: false, time: 0, source: null }),
},
});
// Refs
const chartCanvas = ref(null);
let chartInstance = null;
const isRestantExpanded = ref(false);
const chartsExpanded = ref(false);
// Toggle functions
const toggleRestantExpanded = () => {
isRestantExpanded.value = !isRestantExpanded.value;
};
const toggleChartsExpanded = () => {
chartsExpanded.value = !chartsExpanded.value;
};
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
@@ -159,7 +196,7 @@ const getTrendClass = (trend) => {
};
};
// Trend icon
// Trend icon (text version)
const getTrendIcon = (trend) => {
if (!trend) return "";
switch (trend.direction) {
@@ -174,6 +211,21 @@ const getTrendIcon = (trend) => {
}
};
// Trend icon class (PrimeIcons version)
const getTrendIconClass = (trend) => {
if (!trend) return "pi pi-minus";
switch (trend.direction) {
case "up":
return "pi pi-arrow-up";
case "down":
return "pi pi-arrow-down";
case "neutral":
return "pi pi-minus";
default:
return "pi pi-minus";
}
};
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return props.sparklineData && props.sparklineData.length > 0;
@@ -414,29 +466,114 @@ onBeforeUnmount(() => {
/* Override min-height for balance card */
.clienti-balance-card {
min-height: 320px;
min-height: 280px;
}
/* Value section: horizontal layout */
.value-section {
/* Mobile header with total and trend */
.card-header-mobile {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: var(--space-sm) 0;
margin-bottom: var(--space-sm);
border-bottom: 1px solid var(--surface-border);
}
/* Color classes for positive/negative/neutral (component-specific logic) */
.header-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.header-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.header-dot.clienti {
background: var(--green-500);
}
.header-label {
font-size: var(--text-base);
font-weight: var(--font-semibold);
color: var(--text-color);
}
.header-values {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.header-total {
font-size: var(--text-xl);
font-weight: var(--font-bold);
font-family: var(--font-mono, monospace);
}
.header-total.positive {
color: var(--green-600);
}
.header-total.negative {
color: var(--red-600);
}
.header-total.neutral {
color: var(--text-color);
}
.header-trend {
display: flex;
align-items: center;
gap: 2px;
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.header-trend i {
font-size: var(--text-xs);
}
/* Color classes for positive/negative/neutral */
.positive {
color: var(--color-success);
color: var(--green-600);
}
.negative {
color: var(--color-error);
color: var(--red-600);
}
.neutral {
color: var(--color-text);
color: var(--text-color);
}
/* Trend colors */
.trend-up {
color: var(--green-600);
}
.trend-down {
color: var(--red-600);
}
.trend-neutral {
color: var(--text-color-secondary);
}
/* Dark mode */
[data-theme="dark"] .header-total.positive,
[data-theme="dark"] .positive,
[data-theme="dark"] .trend-up {
color: var(--green-400);
}
[data-theme="dark"] .header-total.negative,
[data-theme="dark"] .negative,
[data-theme="dark"] .trend-down {
color: var(--red-400);
}
/* Sparkline chart dimensions */
@@ -453,6 +590,39 @@ onBeforeUnmount(() => {
display: block;
}
/* Charts toggle header */
.charts-toggle-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) var(--space-md);
margin-top: var(--space-sm);
background: var(--surface-hover);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
transition: background-color var(--transition-fast);
}
.charts-toggle-header:hover {
background: var(--surface-border);
}
.charts-toggle-header i {
transition: transform var(--transition-fast);
}
.charts-toggle-header i.expanded {
transform: rotate(90deg);
}
/* Charts content wrapper */
.charts-content {
margin-top: var(--space-sm);
}
/* Responsive */
@media (max-width: 768px) {
.clienti-balance-card {

View File

@@ -1,49 +1,65 @@
<template>
<div class="balance-dual-card">
<!-- Header -->
<!-- Header cu sold net -->
<div class="card-header">
<span class="card-icon">💰</span>
<span class="card-title">SOLD CLIENȚI / FURNIZORI</span>
<div class="header-left">
<span class="card-icon">💰</span>
<span class="card-title">SOLD CLIENȚI / FURNIZORI</span>
</div>
<div class="header-right">
<span class="header-total" :class="getBalanceClass(netBalance)">
{{ formatCurrency(netBalance) }}
</span>
<div
class="header-trend"
:class="getTrendClass(netTrend)"
v-if="netTrend"
>
<i :class="getTrendIconClass(netTrend)"></i>
<span>{{ Math.round(Math.abs(netTrend.value)) }}%</span>
</div>
</div>
</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) }}
<!-- Detailed values section - Clienți și Furnizori -->
<div class="balance-items">
<!-- Clienți -->
<div class="balance-row">
<div class="balance-label">
<span class="balance-dot clienti"></span>
Clienți
</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 class="balance-values">
<span class="balance-amount" :class="getBalanceClass(clientiTotal)">
{{ formatCurrency(clientiTotal) }}
</span>
<span
class="balance-trend"
:class="getTrendClass(clientiTrend)"
v-if="clientiTrend"
>
{{ getTrendIcon(clientiTrend) }}{{ 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) }}
<!-- Furnizori -->
<div class="balance-row">
<div class="balance-label">
<span class="balance-dot furnizori"></span>
Furnizori
</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 class="balance-values">
<span class="balance-amount" :class="getBalanceClass(-furnizoriTotal)">
{{ formatCurrency(furnizoriTotal) }}
</span>
<span
class="balance-trend"
:class="getTrendClass(furnizoriTrend)"
v-if="furnizoriTrend"
>
{{ getTrendIcon(furnizoriTrend) }}{{ Math.round(Math.abs(furnizoriTrend.value)) }}%
</span>
</div>
</div>
</div>
@@ -300,7 +316,7 @@ const getTrendClass = (trend) => {
};
};
// Trend icon
// Trend icon (text version)
const getTrendIcon = (trend) => {
if (!trend) return "";
switch (trend.direction) {
@@ -315,6 +331,43 @@ const getTrendIcon = (trend) => {
}
};
// Trend icon class (PrimeIcons version)
const getTrendIconClass = (trend) => {
if (!trend) return "pi pi-minus";
switch (trend.direction) {
case "up":
return "pi pi-arrow-up";
case "down":
return "pi pi-arrow-down";
case "neutral":
return "pi pi-minus";
default:
return "pi pi-minus";
}
};
// Computed: Net balance (Clienți - Furnizori)
const netBalance = computed(() => {
return props.clientiTotal - props.furnizoriTotal;
});
// Computed: Net trend (average of both trends or dominant)
const netTrend = computed(() => {
if (!props.clientiTrend && !props.furnizoriTrend) return null;
// Use clienti trend as primary if available
if (props.clientiTrend && !props.furnizoriTrend) return props.clientiTrend;
if (!props.clientiTrend && props.furnizoriTrend) return props.furnizoriTrend;
// Calculate combined trend based on net balance change
const avgValue = (props.clientiTrend.value - props.furnizoriTrend.value) / 2;
let direction = "neutral";
if (avgValue > 2) direction = "up";
else if (avgValue < -2) direction = "down";
return { value: Math.abs(avgValue), direction };
});
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return (
@@ -647,12 +700,20 @@ onBeforeUnmount(() => {
border-color: var(--color-primary, #3b82f6);
}
/* Header */
/* Header cu sold net */
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
margin-bottom: var(--space-md);
flex-wrap: wrap;
gap: var(--space-sm);
}
.header-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.card-icon {
@@ -662,87 +723,147 @@ onBeforeUnmount(() => {
justify-content: center;
width: 2rem;
height: 2rem;
background: var(--color-bg-secondary, #f8fafc);
border-radius: var(--radius-sm, 4px);
background: var(--surface-hover);
border-radius: var(--radius-sm);
}
.card-title {
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-semibold, 600);
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--text-color-secondary);
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 {
.header-right {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-medium, 500);
gap: var(--space-sm);
}
.header-total {
font-size: var(--text-xl);
font-weight: var(--font-bold);
font-family: var(--font-mono, monospace);
}
.header-total.positive {
color: var(--green-600);
}
.header-total.negative {
color: var(--red-600);
}
.header-total.neutral {
color: var(--text-color);
}
.header-trend {
display: flex;
align-items: center;
gap: 2px;
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.header-trend i {
font-size: var(--text-xs);
}
/* Balance items - Clienți și Furnizori */
.balance-items {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-sm) 0;
border-top: 1px solid var(--surface-border);
border-bottom: 1px solid var(--surface-border);
}
.balance-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-xs) 0;
}
.balance-label {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-color-secondary);
}
.balance-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.balance-dot.clienti {
background: var(--green-500);
}
.balance-dot.furnizori {
background: var(--red-500);
}
.balance-values {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.balance-amount {
font-size: var(--text-base);
font-weight: var(--font-semibold);
font-family: var(--font-mono, monospace);
}
.balance-amount.positive {
color: var(--green-600);
}
.balance-amount.negative {
color: var(--red-600);
}
.balance-amount.neutral {
color: var(--text-color);
}
.balance-trend {
font-size: var(--text-xs);
font-weight: var(--font-medium);
}
/* Trend colors */
.trend-up {
color: var(--color-success, #10b981);
color: var(--green-600);
}
.trend-down {
color: var(--color-danger, #ef4444);
color: var(--red-600);
}
.trend-neutral {
color: var(--color-text-secondary, #6b7280);
color: var(--text-color-secondary);
}
.trend-icon {
font-size: 0.75rem;
/* Dark mode */
[data-theme="dark"] .header-total.positive,
[data-theme="dark"] .balance-amount.positive,
[data-theme="dark"] .trend-up {
color: var(--green-400);
}
.divider {
width: 1px;
height: 100%;
background: var(--color-border, #e5e7eb);
min-height: 60px;
[data-theme="dark"] .header-total.negative,
[data-theme="dark"] .balance-amount.negative,
[data-theme="dark"] .trend-down {
color: var(--red-400);
}
/* Dual sparkline container - stack vertical */
@@ -955,23 +1076,27 @@ onBeforeUnmount(() => {
/* Responsive */
@media (max-width: 768px) {
.balance-dual-card {
min-height: 380px;
padding: var(--space-md, 1rem);
min-height: 320px;
padding: var(--space-md);
}
.values-section {
grid-template-columns: 1fr;
gap: 0.75rem;
.card-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-xs);
}
.divider {
.header-right {
width: 100%;
height: 1px;
min-height: 1px;
justify-content: space-between;
}
.value-amount {
font-size: var(--text-lg, 1.125rem);
.header-total {
font-size: var(--text-lg);
}
.balance-amount {
font-size: var(--text-sm);
}
.sparkline-chart {
@@ -981,15 +1106,15 @@ onBeforeUnmount(() => {
@media (max-width: 480px) {
.balance-dual-card {
min-height: 340px;
padding: 0.5rem 0.25rem;
gap: 0.5rem;
min-height: 280px;
padding: var(--space-sm);
gap: var(--space-sm);
border-radius: 0;
}
.card-header {
padding: 0.25rem;
margin-bottom: 0.25rem;
padding: var(--space-xs);
margin-bottom: var(--space-xs);
}
.card-icon {
@@ -999,11 +1124,15 @@ onBeforeUnmount(() => {
}
.card-title {
font-size: 0.625rem;
font-size: var(--text-xs);
}
.value-amount {
font-size: var(--text-base, 1rem);
.header-total {
font-size: var(--text-base);
}
.balance-amount {
font-size: var(--text-xs);
}
.sparkline-chart {
@@ -1014,10 +1143,6 @@ onBeforeUnmount(() => {
padding: 0;
border: none;
}
.values-section {
gap: 0.5rem;
}
}
/* Dark mode support */

View File

@@ -1,25 +1,23 @@
<template>
<div class="metric-card furnizori-balance-card">
<!-- Main value section -->
<div class="value-section">
<div class="metric-label">Furnizori</div>
<div class="metric-value" :class="getBalanceClass(total)">
{{ formatCurrency(total) }}
<!-- Header cu total și trend -->
<div class="card-header-mobile">
<div class="header-left">
<span class="header-dot furnizori"></span>
<span class="header-label">Furnizori</span>
</div>
</div>
<div
class="value-trend trend-indicator"
:class="getTrendClass(trend)"
v-if="trend"
>
<span class="trend-icon">{{ getTrendIcon(trend) }}</span>
<span class="trend-value">{{ Math.round(Math.abs(trend.value)) }}%</span>
</div>
<!-- Sparkline chart -->
<div class="metric-sparkline" v-if="hasSparklineData">
<div class="sparkline-chart">
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
<div class="header-values">
<span class="header-total" :class="getBalanceClass(total)">
{{ formatCurrency(total) }}
</span>
<div
class="header-trend"
:class="getTrendClass(trend)"
v-if="trend"
>
<i :class="getTrendIconClass(trend)"></i>
<span>{{ Math.round(Math.abs(trend.value)) }}%</span>
</div>
</div>
</div>
@@ -61,6 +59,35 @@
</div>
</div>
</div>
<!-- Charts toggle header -->
<div
v-if="hasSparklineData"
class="charts-toggle-header"
@click="toggleChartsExpanded"
>
<span>Grafice evoluție</span>
<i
class="pi pi-chevron-right"
:class="{ expanded: chartsExpanded }"
></i>
</div>
<!-- Sparkline chart (collapsible) -->
<div v-show="chartsExpanded" class="charts-content">
<div class="metric-sparkline" v-if="hasSparklineData">
<div class="sparkline-chart">
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
</div>
<!-- Cache Footer -->
<CacheFooter
:cache-hit="cacheInfo?.hit"
:response-time-ms="cacheInfo?.time"
:cache-source="cacheInfo?.source"
/>
</div>
</template>
@@ -74,6 +101,7 @@ import {
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
import CacheFooter from "@/shared/components/CacheFooter.vue";
Chart.register(...registerables);
@@ -106,18 +134,27 @@ const props = defineProps({
type: Object,
default: null,
},
cacheInfo: {
type: Object,
default: () => ({ hit: false, time: 0, source: null }),
},
});
// Refs
const chartCanvas = ref(null);
let chartInstance = null;
const isRestantExpanded = ref(false);
const chartsExpanded = ref(false);
// Toggle functions
const toggleRestantExpanded = () => {
isRestantExpanded.value = !isRestantExpanded.value;
};
const toggleChartsExpanded = () => {
chartsExpanded.value = !chartsExpanded.value;
};
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
@@ -159,7 +196,7 @@ const getTrendClass = (trend) => {
};
};
// Trend icon
// Trend icon (text version)
const getTrendIcon = (trend) => {
if (!trend) return "";
switch (trend.direction) {
@@ -174,6 +211,21 @@ const getTrendIcon = (trend) => {
}
};
// Trend icon class (PrimeIcons version)
const getTrendIconClass = (trend) => {
if (!trend) return "pi pi-minus";
switch (trend.direction) {
case "up":
return "pi pi-arrow-up";
case "down":
return "pi pi-arrow-down";
case "neutral":
return "pi pi-minus";
default:
return "pi pi-minus";
}
};
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return props.sparklineData && props.sparklineData.length > 0;
@@ -414,29 +466,114 @@ onBeforeUnmount(() => {
/* Override min-height for balance card */
.furnizori-balance-card {
min-height: 320px;
min-height: 280px;
}
/* Value section: horizontal layout */
.value-section {
/* Mobile header with total and trend */
.card-header-mobile {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: var(--space-sm) 0;
margin-bottom: var(--space-sm);
border-bottom: 1px solid var(--surface-border);
}
/* Color classes for positive/negative/neutral (component-specific logic) */
.header-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.header-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.header-dot.furnizori {
background: var(--red-500);
}
.header-label {
font-size: var(--text-base);
font-weight: var(--font-semibold);
color: var(--text-color);
}
.header-values {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.header-total {
font-size: var(--text-xl);
font-weight: var(--font-bold);
font-family: var(--font-mono, monospace);
}
.header-total.positive {
color: var(--green-600);
}
.header-total.negative {
color: var(--red-600);
}
.header-total.neutral {
color: var(--text-color);
}
.header-trend {
display: flex;
align-items: center;
gap: 2px;
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.header-trend i {
font-size: var(--text-xs);
}
/* Color classes for positive/negative/neutral */
.positive {
color: var(--color-success);
color: var(--green-600);
}
.negative {
color: var(--color-error);
color: var(--red-600);
}
.neutral {
color: var(--color-text);
color: var(--text-color);
}
/* Trend colors */
.trend-up {
color: var(--green-600);
}
.trend-down {
color: var(--red-600);
}
.trend-neutral {
color: var(--text-color-secondary);
}
/* Dark mode */
[data-theme="dark"] .header-total.positive,
[data-theme="dark"] .positive,
[data-theme="dark"] .trend-up {
color: var(--green-400);
}
[data-theme="dark"] .header-total.negative,
[data-theme="dark"] .negative,
[data-theme="dark"] .trend-down {
color: var(--red-400);
}
/* Sparkline chart dimensions */
@@ -453,6 +590,39 @@ onBeforeUnmount(() => {
display: block;
}
/* Charts toggle header */
.charts-toggle-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) var(--space-md);
margin-top: var(--space-sm);
background: var(--surface-hover);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
transition: background-color var(--transition-fast);
}
.charts-toggle-header:hover {
background: var(--surface-border);
}
.charts-toggle-header i {
transition: transform var(--transition-fast);
}
.charts-toggle-header i.expanded {
transform: rotate(90deg);
}
/* Charts content wrapper */
.charts-content {
margin-top: var(--space-sm);
}
/* Responsive */
@media (max-width: 768px) {
.furnizori-balance-card {

View File

@@ -1,10 +1,21 @@
<template>
<div class="indicator-item" :class="statusClass">
<!-- Label (top) -->
<div class="indicator-label">{{ label }}</div>
<!-- Label (top) cu toggle pentru descriere -->
<div class="indicator-label">
{{ label }}
<i
v-if="description"
class="pi desc-toggle"
:class="descExpanded ? 'pi-chevron-up' : 'pi-chevron-down'"
@click.stop="toggleDescription"
title="Toggle descriere"
></i>
</div>
<!-- Description (optional) -->
<div v-if="description" class="indicator-description">{{ description }}</div>
<!-- Description (collapsible) -->
<div v-if="description && descExpanded" class="indicator-description slide-down">
{{ description }}
</div>
<!-- Main content: Value centered + Status icon on right -->
<div class="indicator-main">
@@ -62,6 +73,17 @@
</div>
</div>
<!-- YoY Trend indicator (shows variation from first to last sparkline value) -->
<div
v-if="hasSparklineData && trendInfo.text !== '-'"
class="yoy-trend"
:class="trendInfo.class"
>
<i :class="trendInfo.icon"></i>
<span class="trend-value">{{ trendInfo.text }}</span>
<span class="trend-label">vs 12 luni</span>
</div>
<!-- Threshold info -->
<div v-if="thresholdText" class="indicator-threshold">
{{ thresholdText }}
@@ -70,7 +92,14 @@
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed } from 'vue'
// Description toggle state
const descExpanded = ref(false)
const toggleDescription = () => {
descExpanded.value = !descExpanded.value
}
const props = defineProps({
label: {
@@ -264,6 +293,39 @@ const thresholdText = computed(() => {
return parts.join(' | ')
})
// Computed: YoY Trend information (comparing first and last sparkline values)
const trendInfo = computed(() => {
if (!props.sparklineData || props.sparklineData.length < 2) {
return { text: '-', icon: 'pi pi-minus', class: 'trend-neutral' }
}
const validData = props.sparklineData.filter(v => v !== null && v !== undefined)
if (validData.length < 2) {
return { text: '-', icon: 'pi pi-minus', class: 'trend-neutral' }
}
const first = validData[0]
const last = validData[validData.length - 1]
// Handle division by zero
if (first === 0) {
if (last > 0) return { text: '+∞', icon: 'pi pi-arrow-up', class: 'trend-up' }
if (last < 0) return { text: '-∞', icon: 'pi pi-arrow-down', class: 'trend-down' }
return { text: '0%', icon: 'pi pi-minus', class: 'trend-neutral' }
}
const change = ((last - first) / Math.abs(first)) * 100
const sign = change > 0 ? '+' : ''
const text = `${sign}${change.toFixed(1)}%`
if (change > 0) {
return { text, icon: 'pi pi-arrow-up', class: 'trend-up' }
} else if (change < 0) {
return { text, icon: 'pi pi-arrow-down', class: 'trend-down' }
}
return { text: '0%', icon: 'pi pi-minus', class: 'trend-neutral' }
})
// Methods
const handleMouseMove = (event) => {
if (!pointPositions.value.length || !sparklineContainer.value) return
@@ -312,22 +374,62 @@ const handleMouseLeave = () => {
border-color: var(--surface-hover);
}
/* Label (top) */
/* Label (top) with toggle */
.indicator-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-color-secondary);
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
}
/* Description (optional subtitle) */
/* Description toggle icon */
.desc-toggle {
font-size: var(--text-xs);
color: var(--text-color-secondary);
cursor: pointer;
padding: 2px;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
opacity: 0.6;
}
.desc-toggle:hover {
color: var(--primary-color);
opacity: 1;
background: var(--surface-hover);
}
/* Description (collapsible) */
.indicator-description {
font-size: var(--text-xs);
color: var(--text-color-secondary);
text-align: center;
opacity: 0.8;
line-height: 1.3;
margin-top: calc(var(--space-xs) * -1);
padding: var(--space-xs);
background: var(--surface-hover);
border-radius: var(--radius-sm);
margin-bottom: var(--space-xs);
}
/* Slide down animation */
.slide-down {
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Main section: Value + Status Icon */
@@ -450,6 +552,72 @@ const handleMouseLeave = () => {
font-family: var(--font-mono);
}
/* YoY Trend indicator */
.yoy-trend {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
font-size: var(--text-xs);
font-weight: var(--font-medium);
margin-top: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
background: var(--surface-hover);
}
.yoy-trend i {
font-size: var(--text-sm);
}
.yoy-trend .trend-value {
font-family: var(--font-mono);
font-weight: var(--font-semibold);
}
.yoy-trend .trend-label {
color: var(--text-color-secondary);
font-size: var(--text-2xs);
}
/* Trend colors */
.yoy-trend.trend-up {
color: var(--green-600);
background: var(--green-50);
}
.yoy-trend.trend-down {
color: var(--red-600);
background: var(--red-50);
}
.yoy-trend.trend-neutral {
color: var(--text-color-secondary);
}
/* Dark mode for YoY trend */
[data-theme="dark"] .yoy-trend.trend-up {
color: var(--green-400);
background: rgba(34, 197, 94, 0.15);
}
[data-theme="dark"] .yoy-trend.trend-down {
color: var(--red-400);
background: rgba(239, 68, 68, 0.15);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .yoy-trend.trend-up {
color: var(--green-400);
background: rgba(34, 197, 94, 0.15);
}
:root:not([data-theme]) .yoy-trend.trend-down {
color: var(--red-400);
background: rgba(239, 68, 68, 0.15);
}
}
/* Threshold info */
.indicator-threshold {
font-size: var(--text-xs);

View File

@@ -1,29 +1,81 @@
<template>
<div class="metric-card treasury-dual-card">
<!-- Main values section - Split layout (Casa | Bancă) -->
<div class="values-section">
<!-- Casa Section -->
<div class="value-block casa">
<div class="metric-label">Casa</div>
<div class="metric-value text-success">
{{ formatCurrency(casaTotal) }}
<!-- Treasury items - Casa și Bancă stacked vertical cu expand individual -->
<div class="treasury-items">
<!-- Casa -->
<div class="treasury-group" v-if="casaItems.length > 0 || casaTotal > 0">
<div class="treasury-header" @click="toggleCasaExpanded">
<div class="treasury-header-left">
<i
class="pi pi-chevron-right treasury-toggle"
:class="{ expanded: isCasaExpanded }"
></i>
<span class="treasury-label">Casa</span>
</div>
<span class="treasury-value text-success">{{ formatCurrency(casaTotal) }}</span>
</div>
<!-- Casa Sub-items -->
<div v-show="isCasaExpanded && casaItems.length > 0" class="treasury-subitems slide-down">
<div
v-for="(item, idx) in casaItems"
:key="idx"
class="treasury-subitem"
>
<span class="treasury-sublabel">
{{ item.nume || `Cont ${item.cont}` }}
<span v-if="item.cont" class="treasury-cont">({{ item.cont }})</span>
</span>
<span class="treasury-subvalue">{{ formatCurrency(item.sold) }}</span>
</div>
</div>
</div>
<!-- Divider -->
<div class="divider"></div>
<!-- Bancă -->
<div class="treasury-group" v-if="bancaItems.length > 0 || bancaTotal > 0">
<div class="treasury-header" @click="toggleBancaExpanded">
<div class="treasury-header-left">
<i
class="pi pi-chevron-right treasury-toggle"
:class="{ expanded: isBancaExpanded }"
></i>
<span class="treasury-label">Bancă</span>
</div>
<span class="treasury-value text-primary">{{ formatCurrency(bancaTotal) }}</span>
</div>
<!-- Bancă Section -->
<div class="value-block banca">
<div class="metric-label">Bancă</div>
<div class="metric-value text-primary">
{{ formatCurrency(bancaTotal) }}
<!-- Bancă Sub-items -->
<div v-show="isBancaExpanded && bancaItems.length > 0" class="treasury-subitems slide-down">
<div
v-for="(item, idx) in bancaItems"
:key="idx"
class="treasury-subitem"
>
<span class="treasury-sublabel">
{{ item.nume || `Cont ${item.cont}` }}
<span v-if="item.cont" class="treasury-cont">({{ item.cont }})</span>
</span>
<span class="treasury-subvalue">{{ formatCurrency(item.sold) }}</span>
</div>
</div>
</div>
</div>
<!-- Dual sparkline charts - stacked vertical -->
<div class="sparkline-dual-container" v-if="hasSparklineData">
<!-- Charts toggle header -->
<div
v-if="hasSparklineData"
class="charts-toggle-header"
@click="toggleChartsExpanded"
>
<span>Grafice evoluție</span>
<i
class="pi pi-chevron-right"
:class="{ expanded: chartsExpanded }"
></i>
</div>
<!-- Dual sparkline charts - stacked vertical (at the end) -->
<div v-show="chartsExpanded" class="charts-content sparkline-dual-container">
<!-- Grafic Casa -->
<div class="sparkline-wrapper">
<div class="sparkline-title text-success">Casa</div>
@@ -41,77 +93,12 @@
</div>
</div>
<!-- Breakdown section -->
<div
class="breakdown-section"
v-if="casaItems.length > 0 || bancaItems.length > 0"
>
<!-- Casa Breakdown -->
<div class="breakdown-group" v-if="casaItems.length > 0">
<div class="breakdown-header" @click="toggleCasaExpanded">
<div class="breakdown-header-left">
<i
class="pi pi-chevron-right breakdown-toggle"
:class="{ expanded: isCasaExpanded }"
></i>
<span class="breakdown-label">Casa</span>
</div>
<span class="breakdown-value">{{ formatCurrency(casaTotal) }}</span>
</div>
<!-- Casa Sub-items -->
<div v-show="isCasaExpanded" class="breakdown-subitems slide-down">
<div
v-for="(item, idx) in casaItems"
:key="idx"
class="breakdown-subitem"
>
<span class="breakdown-sublabel">
{{ item.nume || `Cont ${item.cont}` }}
<span v-if="item.cont" class="breakdown-cont"
>({{ item.cont }})</span
>
</span>
<span class="breakdown-subvalue">{{
formatCurrency(item.sold)
}}</span>
</div>
</div>
</div>
<!-- Bancă Breakdown -->
<div class="breakdown-group" v-if="bancaItems.length > 0">
<div class="breakdown-header" @click="toggleBancaExpanded">
<div class="breakdown-header-left">
<i
class="pi pi-chevron-right breakdown-toggle"
:class="{ expanded: isBancaExpanded }"
></i>
<span class="breakdown-label">Bancă</span>
</div>
<span class="breakdown-value">{{ formatCurrency(bancaTotal) }}</span>
</div>
<!-- Bancă Sub-items -->
<div v-show="isBancaExpanded" class="breakdown-subitems slide-down">
<div
v-for="(item, idx) in bancaItems"
:key="idx"
class="breakdown-subitem"
>
<span class="breakdown-sublabel">
{{ item.nume || `Cont ${item.cont}` }}
<span v-if="item.cont" class="breakdown-cont"
>({{ item.cont }})</span
>
</span>
<span class="breakdown-subvalue">{{
formatCurrency(item.sold)
}}</span>
</div>
</div>
</div>
</div>
<!-- Cache Footer -->
<CacheFooter
:cache-hit="cacheInfo?.hit"
:response-time-ms="cacheInfo?.time"
:cache-source="cacheInfo?.source"
/>
</div>
</template>
@@ -125,6 +112,7 @@ import {
nextTick,
} from "vue";
import { Chart, registerables } from "chart.js";
import CacheFooter from "@/shared/components/CacheFooter.vue";
Chart.register(...registerables);
@@ -173,6 +161,10 @@ const props = defineProps({
type: Object,
default: null,
},
cacheInfo: {
type: Object,
default: () => ({ hit: false, time: 0, source: null }),
},
});
// Refs pentru 2 canvas-uri separate
@@ -182,6 +174,7 @@ let casaChartInstance = null;
let bancaChartInstance = null;
const isCasaExpanded = ref(false);
const isBancaExpanded = ref(false);
const chartsExpanded = ref(false);
// Toggle functions
const toggleCasaExpanded = () => {
@@ -192,6 +185,10 @@ const toggleBancaExpanded = () => {
isBancaExpanded.value = !isBancaExpanded.value;
};
const toggleChartsExpanded = () => {
chartsExpanded.value = !chartsExpanded.value;
};
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return "0 RON";
@@ -612,34 +609,101 @@ onBeforeUnmount(() => {
</script>
<style scoped>
/* Component-specific: Dual-layout for TreasuryDualCard (Casa | Bancă) */
/* Component-specific: TreasuryDualCard (Casa | Bancă) */
/* Override min-height for dual chart layout */
/* Override min-height for treasury card */
.treasury-dual-card {
min-height: 420px;
min-height: 320px;
}
/* Split layout: Casa | Divider | Bancă */
.values-section {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 1rem;
align-items: start;
}
.value-block {
/* Treasury items container - stacked vertical */
.treasury-items {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
/* Treasury group (Casa sau Bancă) */
.treasury-group {
border: 1px solid var(--surface-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
/* Treasury header - clickable */
.treasury-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: var(--space-md);
cursor: pointer;
user-select: none;
background: var(--surface-ground);
transition: background-color var(--transition-fast);
}
.divider {
width: 1px;
height: 100%;
background: var(--color-border);
min-height: 60px;
.treasury-header:hover {
background: var(--surface-hover);
}
.treasury-header-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.treasury-toggle {
color: var(--text-color-secondary);
font-size: var(--text-xs);
transition: transform var(--transition-fast);
}
.treasury-toggle.expanded {
transform: rotate(90deg);
}
.treasury-label {
font-size: var(--text-base);
font-weight: var(--font-semibold);
color: var(--text-color);
}
.treasury-value {
font-size: var(--text-xl);
font-weight: var(--font-bold);
font-family: var(--font-mono, monospace);
}
/* Treasury sub-items */
.treasury-subitems {
padding: var(--space-sm) var(--space-md) var(--space-md);
background: var(--surface-card);
border-top: 1px solid var(--surface-border);
}
.treasury-subitem {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-xs) 0;
}
.treasury-sublabel {
font-size: var(--text-sm);
color: var(--text-color-secondary);
}
.treasury-cont {
font-size: var(--text-xs);
opacity: 0.7;
margin-left: var(--space-xs);
}
.treasury-subvalue {
font-size: var(--text-sm);
font-weight: var(--font-medium);
font-family: var(--font-mono, monospace);
color: var(--text-color);
}
/* Dual sparkline container (unique to this card) */
@@ -672,28 +736,47 @@ onBeforeUnmount(() => {
display: block;
}
/* Component-specific: Account number display in breakdown */
.breakdown-cont {
font-size: 0.8125rem;
opacity: 0.7;
margin-left: 0.25rem;
/* Charts toggle header */
.charts-toggle-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) var(--space-md);
margin-top: var(--space-sm);
background: var(--surface-hover);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
transition: background-color var(--transition-fast);
}
.charts-toggle-header:hover {
background: var(--surface-border);
}
.charts-toggle-header i {
transition: transform var(--transition-fast);
}
.charts-toggle-header i.expanded {
transform: rotate(90deg);
}
/* Charts content wrapper */
.charts-content {
margin-top: var(--space-sm);
}
/* Responsive: Stack vertically on mobile */
@media (max-width: 768px) {
.treasury-dual-card {
min-height: 380px;
min-height: 280px;
}
.values-section {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.divider {
width: 100%;
height: 1px;
min-height: 1px;
.treasury-value {
font-size: var(--text-lg);
}
.sparkline-chart {
@@ -703,7 +786,15 @@ onBeforeUnmount(() => {
@media (max-width: 480px) {
.treasury-dual-card {
min-height: 340px;
min-height: 240px;
}
.treasury-header {
padding: var(--space-sm);
}
.treasury-value {
font-size: var(--text-base);
}
.sparkline-chart {
@@ -714,9 +805,5 @@ onBeforeUnmount(() => {
padding: 0;
border: none;
}
.values-section {
gap: 0.5rem;
}
}
</style>

View File

@@ -1,4 +1,5 @@
import axios from 'axios'
import { handleUnauthorized, isAuthRedirectInProgress } from '@/shared/utils/authRedirect'
const api = axios.create({
baseURL: import.meta.env.BASE_URL + 'api/reports',
@@ -7,6 +8,14 @@ const api = axios.create({
// Request interceptor for auth token
api.interceptors.request.use((config) => {
// Skip requests if we're already redirecting to login
if (isAuthRedirectInProgress()) {
const controller = new AbortController()
controller.abort()
config.signal = controller.signal
return config
}
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
@@ -19,14 +28,30 @@ api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expired or invalid - redirect to login
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
window.location.href = import.meta.env.BASE_URL + 'login'
// Use shared handler to prevent race conditions
handleUnauthorized()
}
return Promise.reject(error)
}
)
/**
* Helper for GET requests that include cache metadata
* Returns response data with cache_hit, response_time_ms, cache_source fields
*
* @param {string} url - API endpoint path
* @param {object} options - Axios request config (params, etc.)
* @returns {Promise<object>} Response data with cache metadata
*/
export const getWithCacheInfo = async (url, options = {}) => {
const response = await api.get(url, {
...options,
headers: {
...options.headers,
'X-Include-Cache-Metadata': 'true',
},
})
return response.data
}
export default api

View File

@@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import api from "@reports/services/api";
import api, { getWithCacheInfo } from "@reports/services/api";
export const useDashboardStore = defineStore("dashboard", () => {
// State existent
@@ -20,6 +20,7 @@ export const useDashboardStore = defineStore("dashboard", () => {
loading: false,
error: null,
data: null,
cacheInfo: { hit: false, time: 0, source: null },
});
// State pentru detailed data pagination
@@ -499,14 +500,21 @@ export const useDashboardStore = defineStore("dashboard", () => {
if (luna !== null) params.luna = luna;
if (an !== null) params.an = an;
const response = await api.get("/dashboard/financial-indicators", {
const data = await getWithCacheInfo("/dashboard/financial-indicators", {
params,
});
financialIndicators.value.data = response.data;
financialIndicators.value.data = data;
financialIndicators.value.loading = false;
return { success: true, data: response.data };
// Extract cache metadata
financialIndicators.value.cacheInfo = {
hit: data.cache_hit || false,
time: data.response_time_ms || 0,
source: data.cache_source || null,
};
return { success: true, data: data };
} catch (err) {
console.error("Failed to load financial indicators:", err);
@@ -524,6 +532,7 @@ export const useDashboardStore = defineStore("dashboard", () => {
financialIndicators.value.error = errorMessage;
financialIndicators.value.loading = false;
financialIndicators.value.data = null;
financialIndicators.value.cacheInfo = { hit: false, time: 0, source: null };
return { success: false, error: errorMessage };
}
@@ -557,6 +566,7 @@ export const useDashboardStore = defineStore("dashboard", () => {
loading: false,
error: null,
data: null,
cacheInfo: { hit: false, time: 0, source: null },
};
clearCache();
};

View File

@@ -83,6 +83,7 @@
:bancaPreviousSparklineData="bancaPreviousSparkline"
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:cacheInfo="treasuryCacheInfo"
/>
</template>
<!-- Page 3: CashFlowMetricCard (original graph card) -->
@@ -98,6 +99,7 @@
:outflowsPreviousSparkline="outflowsPreviousSparkline"
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:cacheInfo="cashflowCacheInfo"
/>
</template>
<!-- Page 4: ClientiBalanceCard (original graph card) -->
@@ -110,6 +112,7 @@
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:breakdown="netBalanceData?.breakdown?.clienti"
:cacheInfo="netBalanceCacheInfo"
/>
</template>
<!-- Page 5: FurnizoriBalanceCard (original graph card) -->
@@ -122,6 +125,7 @@
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:breakdown="netBalanceData?.breakdown?.furnizori"
:cacheInfo="netBalanceCacheInfo"
/>
</template>
<!-- Page 6: FinancialIndicatorsCard (US-015) -->
@@ -130,44 +134,19 @@
:loading="dashboardStore.financialIndicators.loading"
:error="dashboardStore.financialIndicators.error"
:data="dashboardStore.financialIndicators.data"
:initial-period="periodStore.selectedPeriod"
:initial-period="previousPeriodForIndicators"
:cache-info="dashboardStore.financialIndicators.cacheInfo"
mobile
@period-change="handleFinancialIndicatorsPeriodChange"
/>
</template>
</SwipeableCards>
<!-- US-2004: Desktop Solduri Section (sus, fără titlu) -->
<div v-if="!isMobile" class="desktop-solduri-section">
<SolduriCompactCard
type="trezorerie"
:total="totalTrezorerie"
:casaTotal="treasuryData?.breakdown?.casa?.total || 0"
:bancaTotal="treasuryData?.breakdown?.banca?.total || 0"
:breakdown="treasuryData?.breakdown"
/>
<SolduriCompactCard
type="clienti"
:total="netBalanceData?.clienti_total || 0"
:breakdown="netBalanceData?.breakdown?.clienti"
/>
<SolduriCompactCard
type="furnizori"
:total="netBalanceData?.furnizori_total || 0"
:breakdown="netBalanceData?.breakdown?.furnizori"
/>
<SolduriCompactCard
type="tva"
:total="tvaTotal"
/>
</div>
<!-- Desktop: Grid layout (carduri grafice originale) - collapsible by default -->
<div v-if="!isMobile" class="metrics-row">
<CollapsibleCard
label="Trezorerie"
:value="totalTrezorerie"
icon="pi pi-wallet"
:value-class="totalTrezorerie >= 0 ? 'positive' : 'negative'"
>
<TreasuryDualCard
@@ -183,12 +162,12 @@
:bancaPreviousSparklineData="bancaPreviousSparkline"
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:cacheInfo="treasuryCacheInfo"
/>
</CollapsibleCard>
<CollapsibleCard
label="Cash Flow"
:value="netCashFlow"
icon="pi pi-arrows-h"
:value-class="netCashFlow >= 0 ? 'positive' : 'negative'"
>
<CashFlowMetricCard
@@ -202,12 +181,12 @@
:outflowsPreviousSparkline="outflowsPreviousSparkline"
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:cacheInfo="cashflowCacheInfo"
/>
</CollapsibleCard>
<CollapsibleCard
label="Clienți"
:value="netBalanceData?.clienti_total || 0"
icon="pi pi-users"
:value-class="(netBalanceData?.clienti_total || 0) >= 0 ? 'positive' : 'negative'"
>
<ClientiBalanceCard
@@ -218,12 +197,12 @@
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:breakdown="netBalanceData?.breakdown?.clienti"
:cacheInfo="netBalanceCacheInfo"
/>
</CollapsibleCard>
<CollapsibleCard
label="Furnizori"
:value="netBalanceData?.furnizori_total || 0"
icon="pi pi-truck"
:value-class="(netBalanceData?.furnizori_total || 0) <= 0 ? 'positive' : 'negative'"
>
<FurnizoriBalanceCard
@@ -234,6 +213,7 @@
:sparklineLabels="sparklineLabels"
:previousSparklineLabels="previousSparklineLabels"
:breakdown="netBalanceData?.breakdown?.furnizori"
:cacheInfo="netBalanceCacheInfo"
/>
</CollapsibleCard>
</div>
@@ -246,7 +226,8 @@
:loading="dashboardStore.financialIndicators.loading"
:error="dashboardStore.financialIndicators.error"
:data="dashboardStore.financialIndicators.data"
:initial-period="periodStore.selectedPeriod"
:initial-period="previousPeriodForIndicators"
:cache-info="dashboardStore.financialIndicators.cacheInfo"
@period-change="handleFinancialIndicatorsPeriodChange"
/>
</div>
@@ -285,7 +266,7 @@ import MobileDrawerMenu from "@shared/components/mobile/MobileDrawerMenu.vue";
import { useCompanyStore, useAuthStore } from "@reports/stores/sharedStores";
import { useDashboardStore } from "@reports/stores/dashboard";
import { useAccountingPeriodStore } from "@reports/stores/sharedStores";
import api from "@reports/services/api";
import api, { getWithCacheInfo } from "@reports/services/api";
import {
exportToExcel,
exportToPDF,
@@ -310,6 +291,11 @@ const monthlyOutflows = ref(0);
const treasuryData = ref(null);
const netBalanceData = ref(null);
// Cache info state for each card
const treasuryCacheInfo = ref({ hit: false, time: 0, source: null });
const netBalanceCacheInfo = ref({ hit: false, time: 0, source: null });
const cashflowCacheInfo = ref({ hit: false, time: 0, source: null });
// New dashboard state
const selectedPeriod = ref("12m");
const selectedChartType = ref("line");
@@ -693,6 +679,20 @@ const currentMonthLabel = computed(() => {
return "Se încarcă...";
});
// Computed property pentru luna anterioară - pentru indicatorii financiari
// Luna curentă e în lucru, deci folosim luna anterioară pentru date finale
const previousPeriodForIndicators = computed(() => {
if (!periodStore.selectedPeriod) return null;
const { luna, an } = periodStore.selectedPeriod;
// Calculează luna anterioară cu rollover la decembrie anul anterior
if (luna === 1) {
return { luna: 12, an: an - 1 };
}
return { luna: luna - 1, an };
});
// Methods
const handleCompanyChanged = async (company) => {
if (company) {
@@ -949,7 +949,7 @@ const handleCompanySelect = async (event) => {
};
// Fixed: Changed company_id to company parameter
// Updated: Added luna/an from period selector
// Updated: Added luna/an from period selector + cache info
const loadMonthlyFlows = async () => {
if (!companyStore.selectedCompany) return;
@@ -960,9 +960,16 @@ const loadMonthlyFlows = async () => {
params.an = periodStore.selectedPeriod.an;
}
const response = await api.get("/dashboard/monthly-flows", { params });
monthlyInflows.value = response.data.inflows || 0;
monthlyOutflows.value = response.data.outflows || 0;
const data = await getWithCacheInfo("/dashboard/monthly-flows", { params });
monthlyInflows.value = data.inflows || 0;
monthlyOutflows.value = data.outflows || 0;
// Extract cache metadata
cashflowCacheInfo.value = {
hit: data.cache_hit || false,
time: data.response_time_ms || 0,
source: data.cache_source || null,
};
} catch (error) {
console.error("Failed to load monthly flows:", error);
}
@@ -978,8 +985,15 @@ const loadTreasuryBreakdown = async () => {
params.an = periodStore.selectedPeriod.an;
}
const response = await api.get("/dashboard/treasury-breakdown", { params });
treasuryData.value = response.data;
const data = await getWithCacheInfo("/dashboard/treasury-breakdown", { params });
treasuryData.value = data;
// Extract cache metadata
treasuryCacheInfo.value = {
hit: data.cache_hit || false,
time: data.response_time_ms || 0,
source: data.cache_source || null,
};
} catch (error) {
console.error("Failed to load treasury breakdown:", error);
}
@@ -995,13 +1009,13 @@ const loadNetBalanceBreakdown = async () => {
params.an = periodStore.selectedPeriod.an;
}
const response = await api.get("/dashboard/net-balance-breakdown", { params });
const data = await getWithCacheInfo("/dashboard/net-balance-breakdown", { params });
// Folosește direct datele structurate de la backend
netBalanceData.value = {
clienti_total: response.data.clienti_total || 0,
furnizori_total: response.data.furnizori_total || 0,
breakdown: response.data.breakdown || {
clienti_total: data.clienti_total || 0,
furnizori_total: data.furnizori_total || 0,
breakdown: data.breakdown || {
clienti: {
total: 0,
in_termen: { total: 0 },
@@ -1015,6 +1029,13 @@ const loadNetBalanceBreakdown = async () => {
},
};
// Extract cache metadata
netBalanceCacheInfo.value = {
hit: data.cache_hit || false,
time: data.response_time_ms || 0,
source: data.cache_source || null,
};
console.log("[NetBalance] Loaded balance data:", {
clienti_total: netBalanceData.value.clienti_total,
furnizori_total: netBalanceData.value.furnizori_total,
@@ -1029,9 +1050,32 @@ const loadDashboardData = async () => {
if (!companyStore.selectedCompany) return;
isLoading.value = true;
// FIX: Reset state înainte de a încărca date noi
// Previne afișarea datelor de la firma anterioară în timpul încărcării
treasuryData.value = null;
netBalanceData.value = null;
monthlyInflows.value = 0;
monthlyOutflows.value = 0;
// Reset cache info
treasuryCacheInfo.value = { hit: false, time: 0, source: null };
netBalanceCacheInfo.value = { hit: false, time: 0, source: null };
cashflowCacheInfo.value = { hit: false, time: 0, source: null };
// Reset dashboard store financial indicators (afișează loading state imediat)
dashboardStore.financialIndicators.loading = true;
dashboardStore.financialIndicators.error = null;
dashboardStore.financialIndicators.data = null;
dashboardStore.financialIndicators.cacheInfo = { hit: false, time: 0, source: null };
const luna = periodStore.selectedPeriod?.luna || null;
const an = periodStore.selectedPeriod?.an || null;
// Pentru indicatori financiari folosim luna anterioară (luna curentă e în lucru)
const prevPeriod = previousPeriodForIndicators.value;
const indicatorLuna = prevPeriod?.luna || null;
const indicatorAn = prevPeriod?.an || null;
try {
await Promise.all([
dashboardStore.loadDashboardSummary(
@@ -1044,11 +1088,11 @@ const loadDashboardData = async () => {
loadMonthlyFlows(),
loadTreasuryBreakdown(),
loadNetBalanceBreakdown(),
// US-014: Load financial indicators for desktop card
// US-014: Load financial indicators for desktop card (luna anterioară)
dashboardStore.loadFinancialIndicators(
companyStore.selectedCompany.id_firma,
luna,
an,
indicatorLuna,
indicatorAn,
),
]);
} catch (error) {
@@ -1449,14 +1493,6 @@ onUnmounted(() => {
padding: 0 var(--space-md);
}
/* US-2004: Desktop Solduri Section - 2x2 grid (2 cards per row) */
.desktop-solduri-section {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
/* Metrics Cards Layout - Component-specific grid layouts */
.metrics-row {
display: grid;

View File

@@ -0,0 +1,123 @@
<template>
<div class="cache-footer" v-if="showCacheInfo">
<span class="cache-badge" :class="badgeClass">
{{ cacheText }} | {{ (responseTimeMs || 0).toFixed(2) }}ms
</span>
</div>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
cacheHit: {
type: Boolean,
default: false,
},
responseTimeMs: {
type: Number,
default: 0,
},
cacheSource: {
type: String,
default: null,
},
});
// Show only when we have valid response time
const showCacheInfo = computed(() => {
return props.responseTimeMs > 0;
});
// Text to display: "cached L1", "cached L2", or "db"
const cacheText = computed(() => {
if (props.cacheHit && props.cacheSource) {
return `cached ${props.cacheSource}`;
} else if (props.cacheHit) {
return "cached";
}
return "db";
});
// CSS class for styling based on cache status
const badgeClass = computed(() => {
if (props.cacheHit) {
return props.cacheSource === "L1" ? "cache-l1" : "cache-l2";
}
return "cache-db";
});
</script>
<style scoped>
.cache-footer {
display: flex;
justify-content: flex-end;
padding: var(--space-xs) 0;
margin-top: var(--space-sm);
border-top: 1px solid var(--surface-border);
}
.cache-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm, 4px);
font-size: 11px;
font-weight: var(--font-medium);
font-family: "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
letter-spacing: 0.02em;
}
/* L1 Cache - Memory (fastest) */
.cache-badge.cache-l1 {
background: var(--green-50);
color: var(--green-700);
}
/* L2 Cache - SQLite */
.cache-badge.cache-l2 {
background: var(--blue-50);
color: var(--blue-700);
}
/* Database - Fresh query */
.cache-badge.cache-db {
background: var(--surface-hover);
color: var(--text-color-secondary);
}
/* Dark mode adjustments */
[data-theme="dark"] .cache-badge.cache-l1 {
background: rgba(34, 197, 94, 0.15);
color: var(--green-400);
}
[data-theme="dark"] .cache-badge.cache-l2 {
background: rgba(59, 130, 246, 0.15);
color: var(--blue-400);
}
[data-theme="dark"] .cache-badge.cache-db {
background: var(--surface-100);
color: var(--text-color-secondary);
}
/* Respect system dark mode preference */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .cache-badge.cache-l1 {
background: rgba(34, 197, 94, 0.15);
color: var(--green-400);
}
:root:not([data-theme="light"]) .cache-badge.cache-l2 {
background: rgba(59, 130, 246, 0.15);
color: var(--blue-400);
}
:root:not([data-theme="light"]) .cache-badge.cache-db {
background: var(--surface-100);
color: var(--text-color-secondary);
}
}
</style>

View File

@@ -0,0 +1,58 @@
/**
* Auth Redirect Utility
*
* Handles 401 Unauthorized responses with protection against race conditions.
* When multiple API calls return 401 simultaneously, only the first one
* triggers the redirect to prevent UI flickering and double redirects.
*/
// Flag to prevent multiple simultaneous redirects
let isRedirecting = false
/**
* Handle 401 Unauthorized error by clearing auth data and redirecting to login.
* Uses a flag to prevent race conditions when multiple API calls fail simultaneously.
*
* @returns {boolean} true if redirect was initiated, false if already redirecting
*/
export function handleUnauthorized() {
// Prevent multiple redirects
if (isRedirecting) {
console.log('[Auth] Redirect already in progress, skipping...')
return false
}
isRedirecting = true
console.log('[Auth] 401 Unauthorized - clearing auth data and redirecting to login')
// Clear all auth-related data from localStorage
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
// Small delay to ensure localStorage is cleared before redirect
// This also allows any pending API calls to complete/fail gracefully
setTimeout(() => {
window.location.href = import.meta.env.BASE_URL + 'login'
}, 100)
return true
}
/**
* Check if a redirect is currently in progress.
* Useful for components that want to skip error handling during redirect.
*
* @returns {boolean} true if redirect is in progress
*/
export function isAuthRedirectInProgress() {
return isRedirecting
}
/**
* Reset the redirect flag.
* Should only be used in tests or special cases.
*/
export function resetAuthRedirectFlag() {
isRedirecting = false
}

View File

@@ -0,0 +1,213 @@
# PRD: Dashboard Desktop Cleanup - Eliminare Duplicate și Reorganizare Layout
## 1. Introducere
Dashboard-ul desktop afișează în prezent carduri duplicate pentru Trezorerie, Clienți și Furnizori. Există `SolduriCompactCard` (în secțiunea `desktop-solduri-section`) și `CollapsibleCard` cu grafice (în secțiunea `metrics-row`). Scopul este eliminarea duplicatelor, păstrând doar `CollapsibleCard`-urile cu grafice, dar reorganizate astfel încât textul cu valorile să apară ÎNAINTE de grafice pentru vizibilitate mai bună.
## 2. Obiective
### Obiectiv Principal
- Eliminarea cardurilor duplicate de pe dashboard-ul desktop, păstrând doar `CollapsibleCard`-urile cu conținut reorganizat (text → grafice)
### Obiective Secundare
- Îmbunătățirea vizibilității valorilor principale (afișate înaintea graficelor)
- Menținerea unui layout compact și informativ
- Păstrarea funcționalității mobile neschimbată
### Metrici de Succes
- Zero carduri duplicate pe dashboard desktop
- Valorile principale vizibile fără scroll
- Același număr de informații disponibile ca înainte
## 3. User Stories
### US-001: Eliminare SolduriCompactCard de pe Desktop
**Ca** utilizator desktop
**Vreau** să văd doar un set de carduri pentru fiecare metric
**Pentru că** duplicatele creează confuzie și ocupă spațiu inutil
**Acceptance Criteria:**
- [ ] Secțiunea `desktop-solduri-section` este eliminată din `DashboardView.vue` pentru desktop
- [ ] Rămân doar cele 4 `CollapsibleCard`-uri: Trezorerie, Cash Flow, Clienți, Furnizori
- [ ] Layout-ul grid 2x2 din `metrics-row` rămâne neschimbat
- [ ] npm run typecheck passes
- [ ] Verify in browser că pe desktop nu mai există duplicate
### US-002: Eliminare Icoane din Header-ul CollapsibleCard
**Ca** utilizator
**Vreau** un header curat, fără icoane/emoji-uri
**Pentru că** vreau un design minimalist și profesional
**Acceptance Criteria:**
- [ ] Prop-ul `icon` nu mai este transmis către `CollapsibleCard` în `DashboardView.vue`
- [ ] Header-ul afișează doar: "Label Valoare ▼" (ex: "Trezorerie 125.500 RON ▼")
- [ ] npm run typecheck passes
- [ ] Verify in browser că header-urile nu au icoane
### US-003: Reorganizare TreasuryDualCard - Text Înainte de Grafice
**Ca** utilizator
**Vreau** să văd valorile Casa/Bancă și breakdown-ul ÎNAINTE de grafice
**Pentru că** textul cu valorile este mai important decât graficele
**Acceptance Criteria:**
- [ ] În `TreasuryDualCard.vue`, secțiunea `breakdown-section` se mută ÎNAINTE de `sparkline-dual-container`
- [ ] Ordinea în body devine: values-section → breakdown-section → sparkline-dual-container
- [ ] Breakdown-ul Casa/Bancă cu sub-conturi rămâne expandabil
- [ ] Graficele apar la sfârșit
- [ ] npm run typecheck passes
- [ ] Verify in browser că textul apare înainte de grafice
### US-004: Reorganizare ClientiBalanceCard - Text Înainte de Grafice
**Ca** utilizator
**Vreau** să văd breakdown-ul (În termen/Restant) ÎNAINTE de grafic
**Pentru că** valorile text sunt prioritare față de grafic
**Acceptance Criteria:**
- [ ] În `ClientiBalanceCard.vue`, secțiunea `breakdown-section` se mută ÎNAINTE de `metric-sparkline`
- [ ] Ordinea în body devine: value-section → breakdown-section → metric-sparkline
- [ ] Breakdown-ul În termen/Restant cu perioade rămâne expandabil
- [ ] Graficul apare la sfârșit
- [ ] npm run typecheck passes
- [ ] Verify in browser că textul apare înainte de grafic
### US-005: Reorganizare FurnizoriBalanceCard - Text Înainte de Grafice
**Ca** utilizator
**Vreau** să văd breakdown-ul (În termen/Restant) ÎNAINTE de grafic
**Pentru că** consistență cu celelalte carduri
**Acceptance Criteria:**
- [ ] În `FurnizoriBalanceCard.vue`, secțiunea `breakdown-section` se mută ÎNAINTE de `metric-sparkline`
- [ ] Ordinea în body devine: value-section → breakdown-section → metric-sparkline
- [ ] Breakdown-ul În termen/Restant cu perioade rămâne expandabil
- [ ] Graficul apare la sfârșit
- [ ] npm run typecheck passes
- [ ] Verify in browser că textul apare înainte de grafic
### US-006: Păstrare Layout Mobile Neschimbat
**Ca** utilizator mobile
**Vreau** ca experiența pe mobil să rămână neschimbată
**Pentru că** layout-ul mobile este deja optimizat
**Acceptance Criteria:**
- [ ] `SwipeableCards` pe mobile rămâne identic
- [ ] Prima pagină cu `solduri-grid-2x2` (4x `SolduriCompactCard`) rămâne pe mobile
- [ ] Paginile 2-5 cu carduri grafice rămân neschimbate pe mobile
- [ ] npm run typecheck passes
- [ ] Verify in browser (mobile viewport) că experiența nu s-a schimbat
### US-007: Grafice Colapsabile și Colapsate Implicit
**Ca** utilizator desktop
**Vreau** să pot expanda/colasa graficele separat
**Pentru că** vreau să văd rapid valorile fără să scrollez peste grafice
**Acceptance Criteria:**
- [ ] Secțiunea cu grafice are un header clickabil: "Grafice evoluție ▶" (collapsed) / "▼" (expanded)
- [ ] Graficele sunt COLAPSATE implicit (`chartsExpanded = false`)
- [ ] La click pe header, graficele se expandează la dimensiunea lor completă (150px height fiecare)
- [ ] Se folosește `v-show` pentru toggle (păstrează Chart.js instances în memorie)
- [ ] Header-ul graficelor are styling consistent cu breakdown headers (font, culoare, spacing)
- [ ] Implementat în: `TreasuryDualCard.vue`, `ClientiBalanceCard.vue`, `FurnizoriBalanceCard.vue`
- [ ] `CashFlowMetricCard.vue` primește același treatment (grafic colapsabil)
- [ ] npm run typecheck passes
- [ ] Verify in browser că graficele sunt collapsed implicit și se expandează corect la click
## 4. Cerințe Funcționale
1. [REQ-001] Pe desktop (`!isMobile`), secțiunea `desktop-solduri-section` nu trebuie să se mai afișeze
2. [REQ-002] Prop-ul `icon` trebuie eliminat din toate utilizările `CollapsibleCard` în `DashboardView.vue`
3. [REQ-003] În cardurile cu grafice (Treasury, Clienti, Furnizori, CashFlow), ordinea secțiunilor trebuie să fie: Header → Text/Breakdown → Grafice (colapsabile)
4. [REQ-004] Breakdown-urile (Casa/Bancă, În termen/Restant) rămân expandabile cu click
5. [REQ-005] Pe mobile, nimic nu se schimbă - condiția `isMobile` protejează codul existent
6. [REQ-006] Graficele din toate cardurile trebuie să fie colapsabile și COLAPSATE implicit, cu header clickabil pentru expand
## 5. Non-Goals (Ce NU facem)
- NU modificăm componentele `SolduriCompactCard` - doar le ascundem pe desktop
- NU schimbăm layout-ul mobile
- NU modificăm stilul/dimensiunea graficelor - doar adăugăm collapse toggle
- NU modificăm `CollapsibleCard.vue` component-ul shared
- NU modificăm logica de date/calcul din carduri
## 6. Considerații Tehnice
### Stack/Tehnologii
- Vue 3 Composition API
- Chart.js pentru grafice
- CSS cu design tokens existente
### Patterns de Urmat
- Utilizare `v-if="!isMobile"` pentru condiții desktop
- Utilizare `v-show` pentru grafice colapsabile (păstrează Chart.js instances)
- Design tokens din `DESIGN_TOKENS.md`
- BEM naming convention pentru CSS
### Fișiere Afectate
- `src/modules/reports/views/DashboardView.vue` - eliminare secțiune și icoane
- `src/modules/reports/components/dashboard/cards/TreasuryDualCard.vue` - reordonare secțiuni + grafice colapsabile
- `src/modules/reports/components/dashboard/cards/ClientiBalanceCard.vue` - reordonare secțiuni + grafice colapsabile
- `src/modules/reports/components/dashboard/cards/FurnizoriBalanceCard.vue` - reordonare secțiuni + grafice colapsabile
- `src/modules/reports/components/dashboard/cards/CashFlowMetricCard.vue` - grafice colapsabile
### Dependențe
- Nicio dependență nouă necesară
### Riscuri Tehnice
- ~~Chart.js poate avea probleme dacă canvas-ul nu este vizibil la render~~ → Rezolvat cu `v-show` care păstrează DOM-ul
- Chart.js instances rămân în memorie când graficele sunt colapsate - acceptabil pentru 4 carduri
## 7. Considerații UI/UX
### Layout Nou (Desktop) - Stare Implicită (Collapsed)
```
┌─────────────────────────────────────────────┐
│ Trezorerie 125.500 RON ▼│ ← Header card (fără icoane)
├─────────────────────────────────────────────┤
│ Casa 35.500 RON │ ← Valori principale (vizibile)
│ Bancă 90.000 RON │
│ ▶ Casa breakdown (click expand) │ ← Detalii conturi (expandabile)
│ ▶ Bancă breakdown (click expand) │
├─────────────────────────────────────────────┤
│ ▶ Grafice evoluție │ ← COLLAPSED IMPLICIT
└─────────────────────────────────────────────┘
```
### Layout Nou (Desktop) - Grafice Expandate
```
┌─────────────────────────────────────────────┐
│ Trezorerie 125.500 RON ▼│
├─────────────────────────────────────────────┤
│ Casa 35.500 RON │
│ Bancă 90.000 RON │
│ ▶ Casa breakdown │
│ ▶ Bancă breakdown │
├─────────────────────────────────────────────┤
│ ▼ Grafice evoluție │ ← EXPANDED (click toggle)
│ ┌─────────────────────────────────────────┐ │
│ │ 📈 [Grafic Casa - 150px height] │ │ ← Full size charts
│ │ 📈 [Grafic Bancă - 150px height] │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
### Dark Mode
- Toate componentele folosesc deja design tokens compatibile dark mode
- Nu necesită modificări suplimentare
### Accesibilitate
- Header-ul CollapsibleCard are deja `role="button"` și `tabindex="0"`
- Breakdown-urile au keyboard support pentru expand/collapse
## 8. Success Metrics
- **Eliminare duplicate**: 100% (3 carduri duplicate eliminate)
- **Timp de scanare**: valorile principale vizibile în primele 2 secunde
- **Funcționalitate păstrată**: 100% (aceleași date disponibile, doar reorganizate)
## 9. Open Questions
- [x] Confirmat: Se elimină complet `desktop-solduri-section` pe desktop
- [x] Confirmat: Header fără icoane
- [x] Confirmat: Text/breakdown înainte de grafice
- [x] Confirmat: Layout grid 2x2 rămâne
- [x] Confirmat: Mobile neschimbat
- [x] Confirmat: Grafice colapsabile și colapsate implicit (cu `v-show`)