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:
69
.claude/handover-cleanup-roa2web.md
Normal file
69
.claude/handover-cleanup-roa2web.md
Normal 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
|
||||||
253
.claude/plans/immutable-chasing-flute.md
Normal file
253
.claude/plans/immutable-chasing-flute.md
Normal 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.*
|
||||||
96
.claude/rules/financial-indicators.md
Normal file
96
.claude/rules/financial-indicators.md
Normal 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
|
||||||
@@ -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ă
|
|
||||||
@@ -89,6 +89,18 @@ class BalanceSheetAggregates(BaseModel):
|
|||||||
default=Decimal('0'),
|
default=Decimal('0'),
|
||||||
description="Cheltuieli financiare (Clasa 66 - dobânzi, diferențe curs)"
|
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
|
# Computed properties pentru calculele ulterioare
|
||||||
@property
|
@property
|
||||||
@@ -220,6 +232,12 @@ class LiquidityIndicators(BaseModel):
|
|||||||
lichiditate_vedere: Cash Ratio = disponibilități / datorii_curente
|
lichiditate_vedere: Cash Ratio = disponibilități / datorii_curente
|
||||||
- Măsoară capacitatea de plată imediată doar din numerar
|
- Măsoară capacitatea de plată imediată doar din numerar
|
||||||
- Good: >= 0.2, Warning: 0.1-0.2, Danger: < 0.1
|
- 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(
|
lichiditate_curenta: IndicatorResult = Field(
|
||||||
description="Current Ratio = active_curente / datorii_curente"
|
description="Current Ratio = active_curente / datorii_curente"
|
||||||
@@ -230,6 +248,23 @@ class LiquidityIndicators(BaseModel):
|
|||||||
lichiditate_vedere: IndicatorResult = Field(
|
lichiditate_vedere: IndicatorResult = Field(
|
||||||
description="Cash Ratio = disponibilități / datorii_curente"
|
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:
|
class Config:
|
||||||
json_schema_extra = {
|
json_schema_extra = {
|
||||||
@@ -301,6 +336,31 @@ class EfficiencyIndicators(BaseModel):
|
|||||||
rata_plata: IndicatorResult = Field(
|
rata_plata: IndicatorResult = Field(
|
||||||
description="Rata de plată = plati / achizitii * 100"
|
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:
|
class Config:
|
||||||
json_schema_extra = {
|
json_schema_extra = {
|
||||||
@@ -379,6 +439,31 @@ class RiskIndicators(BaseModel):
|
|||||||
raport_datorii_trezorerie: IndicatorResult = Field(
|
raport_datorii_trezorerie: IndicatorResult = Field(
|
||||||
description="Raport datorii/trezorerie = furnizori_sold_total / trezorerie"
|
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:
|
class Config:
|
||||||
json_schema_extra = {
|
json_schema_extra = {
|
||||||
@@ -447,6 +532,23 @@ class CashFlowIndicators(BaseModel):
|
|||||||
acoperire_cash_flow: IndicatorResult = Field(
|
acoperire_cash_flow: IndicatorResult = Field(
|
||||||
description="Acoperire datorii = cash_flow_ytd / datorii_restante"
|
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:
|
class Config:
|
||||||
json_schema_extra = {
|
json_schema_extra = {
|
||||||
@@ -488,19 +590,24 @@ class DynamicsIndicators(BaseModel):
|
|||||||
|
|
||||||
Arată dacă afacerea crește sau scade prin comparație YoY (Year-over-Year).
|
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:
|
Attributes:
|
||||||
crestere_vanzari_yoy: Creșterea procentuală a vânzărilor față de anul anterior
|
crestere_vanzari_yoy: Creșterea procentuală a Cifrei de Afaceri față de anul anterior
|
||||||
- Formula: (facturari_curent - facturari_anterior) / facturari_anterior * 100
|
- Formula: (CA_curent - CA_anterior) / CA_anterior * 100
|
||||||
- Măsoară dinamica vânzărilor - creștere sau scădere
|
- Sursa: VBAL TOTCRED(70x) - TOTDEB(709)
|
||||||
- Good: > 5%, Warning: 0-5%, Danger: < 0%
|
- 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
|
- 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
|
- Creșterea achizițiilor poate indica expansiune sau costuri mai mari
|
||||||
|
|
||||||
marja_implicita: Marja implicită din diferența facturări - achiziții
|
marja_implicita: Marja implicită din diferența CA - achiziții totale
|
||||||
- Formula: (facturari - achizitii) / facturari * 100
|
- Formula: (CA - achizitii_totale) / CA * 100
|
||||||
- Arată ce procent din vânzări rămâne după achiziții
|
- Arată ce procent din Cifra de Afaceri rămâne după achiziții
|
||||||
- Good: > 20%, Warning: 10-20%, Danger: < 10%
|
- Good: > 20%, Warning: 10-20%, Danger: < 10%
|
||||||
"""
|
"""
|
||||||
crestere_vanzari_yoy: IndicatorResult = Field(
|
crestere_vanzari_yoy: IndicatorResult = Field(
|
||||||
@@ -512,6 +619,23 @@ class DynamicsIndicators(BaseModel):
|
|||||||
marja_implicita: IndicatorResult = Field(
|
marja_implicita: IndicatorResult = Field(
|
||||||
description="Marja implicită = (facturari - achizitii) / facturari * 100"
|
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:
|
class Config:
|
||||||
json_schema_extra = {
|
json_schema_extra = {
|
||||||
@@ -713,6 +837,11 @@ class ProfitabilityIndicators(BaseModel):
|
|||||||
profit_brut: IndicatorResult = Field(
|
profit_brut: IndicatorResult = Field(
|
||||||
description="Profit brut (EBIT) = Venituri - Cheltuieli operaționale"
|
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(
|
marja_profit_brut: IndicatorResult = Field(
|
||||||
description="Marja de profit = Profit brut / Cifra afaceri * 100"
|
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):
|
class FinancialIndicatorsResponse(BaseModel):
|
||||||
"""
|
"""
|
||||||
Răspunsul complet al endpoint-ului /api/reports/dashboard/financial-indicators.
|
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ă)
|
dinamica: Indicatori de dinamică (creștere vânzări/achiziții YoY, marjă)
|
||||||
altman_zscore: Scorul Altman Z-Score și componentele X1-X4
|
altman_zscore: Scorul Altman Z-Score și componentele X1-X4
|
||||||
profitabilitate: Indicatori de profitabilitate (ROA, ROE, marjă profit)
|
profitabilitate: Indicatori de profitabilitate (ROA, ROE, marjă profit)
|
||||||
|
solvabilitate: Indicatori de solvabilitate (ANC, rata ANC/Capital Social)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
GET /api/reports/dashboard/financial-indicators?company=123&luna=12&an=2024
|
GET /api/reports/dashboard/financial-indicators?company=123&luna=12&an=2024
|
||||||
@@ -803,7 +1018,8 @@ class FinancialIndicatorsResponse(BaseModel):
|
|||||||
"cash_flow": { ... },
|
"cash_flow": { ... },
|
||||||
"dinamica": { ... },
|
"dinamica": { ... },
|
||||||
"altman_zscore": { ... },
|
"altman_zscore": { ... },
|
||||||
"profitabilitate": { ... }
|
"profitabilitate": { ... },
|
||||||
|
"solvabilitate": { ... }
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
lichiditate: LiquidityIndicators = Field(
|
lichiditate: LiquidityIndicators = Field(
|
||||||
@@ -827,6 +1043,9 @@ class FinancialIndicatorsResponse(BaseModel):
|
|||||||
profitabilitate: ProfitabilityIndicators = Field(
|
profitabilitate: ProfitabilityIndicators = Field(
|
||||||
description="Indicatori de profitabilitate: ROA, ROE, marja de profit"
|
description="Indicatori de profitabilitate: ROA, ROE, marja de profit"
|
||||||
)
|
)
|
||||||
|
solvabilitate: SolvabilityIndicators = Field(
|
||||||
|
description="Indicatori de solvabilitate: ANC, rata ANC/Capital Social"
|
||||||
|
)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
json_schema_extra = {
|
json_schema_extra = {
|
||||||
@@ -868,6 +1087,13 @@ class FinancialIndicatorsResponse(BaseModel):
|
|||||||
"x4": {"value": 1.80, "status": "good"},
|
"x4": {"value": 1.80, "status": "good"},
|
||||||
"working_capital": 450000.00,
|
"working_capital": 450000.00,
|
||||||
"total_assets": 1800000.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"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
# import sys # Removed - no longer needed
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from shared.auth.dependencies import get_current_user
|
from shared.auth.dependencies import get_current_user
|
||||||
@@ -290,6 +289,7 @@ async def get_maturity_analysis(
|
|||||||
|
|
||||||
@router.get("/monthly-flows")
|
@router.get("/monthly-flows")
|
||||||
async def get_monthly_flows(
|
async def get_monthly_flows(
|
||||||
|
request: Request,
|
||||||
company: int = Query(..., description="ID-ul firmei"),
|
company: int = Query(..., description="ID-ul firmei"),
|
||||||
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
|
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"),
|
an: Optional[int] = Query(None, ge=2000, le=2100, description="Anul contabil"),
|
||||||
@@ -301,14 +301,30 @@ async def get_monthly_flows(
|
|||||||
- Necesită autentificare JWT
|
- Necesită autentificare JWT
|
||||||
- Returnează date pentru analiza fluxurilor lunare
|
- Returnează date pentru analiza fluxurilor lunare
|
||||||
- luna/an: perioada contabilă de referință (dacă nu sunt specificate, folosește ultima perioadă)
|
- 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:
|
try:
|
||||||
# Verifică dacă utilizatorul are acces la firma specificată
|
# Verifică dacă utilizatorul are acces la firma specificată
|
||||||
if str(company) not in current_user.companies:
|
if str(company) not in current_user.companies:
|
||||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
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)
|
# Apelăm serviciul cu request pentru cache metadata
|
||||||
return result
|
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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -435,7 +451,6 @@ async def get_current_period(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/financial-indicators",
|
"/financial-indicators",
|
||||||
response_model=FinancialIndicatorsResponse,
|
|
||||||
tags=["dashboard"]
|
tags=["dashboard"]
|
||||||
)
|
)
|
||||||
async def get_financial_indicators(
|
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"),
|
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)"),
|
include_sparklines: bool = Query(True, description="Include date istorice pentru sparklines (12 luni)"),
|
||||||
current_user: CurrentUser = Depends(get_current_user)
|
current_user: CurrentUser = Depends(get_current_user)
|
||||||
) -> FinancialIndicatorsResponse:
|
):
|
||||||
"""
|
"""
|
||||||
Returnează toți indicatorii financiari calculați pentru firma selectată.
|
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
|
# Dacă include_sparklines este True, folosim metoda care include datele istorice
|
||||||
if include_sparklines:
|
if include_sparklines:
|
||||||
response = await FinancialIndicatorsService.get_indicators_with_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(
|
logger.info(
|
||||||
f"Financial indicators with sparklines for company {company}, "
|
f"Financial indicators with sparklines for company {company}, "
|
||||||
f"luna={resolved_luna}, an={resolved_an}: "
|
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
|
return response
|
||||||
|
|
||||||
# Dacă include_sparklines este False, calculăm doar indicatorii curenți
|
# 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(
|
altman_task = FinancialIndicatorsService.calculate_altman_zscore(
|
||||||
company, resolved_luna, resolved_an
|
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ță
|
# Executăm toate calculele în paralel pentru performanță
|
||||||
(
|
(
|
||||||
@@ -545,14 +576,18 @@ async def get_financial_indicators(
|
|||||||
risc,
|
risc,
|
||||||
cash_flow,
|
cash_flow,
|
||||||
dinamica,
|
dinamica,
|
||||||
altman_zscore
|
altman_zscore,
|
||||||
|
profitabilitate,
|
||||||
|
solvabilitate
|
||||||
) = await asyncio.gather(
|
) = await asyncio.gather(
|
||||||
lichiditate_task,
|
lichiditate_task,
|
||||||
eficienta_task,
|
eficienta_task,
|
||||||
risc_task,
|
risc_task,
|
||||||
cash_flow_task,
|
cash_flow_task,
|
||||||
dinamica_task,
|
dinamica_task,
|
||||||
altman_task
|
altman_task,
|
||||||
|
profitabilitate_task,
|
||||||
|
solvabilitate_task
|
||||||
)
|
)
|
||||||
|
|
||||||
# Construim răspunsul
|
# Construim răspunsul
|
||||||
@@ -562,7 +597,9 @@ async def get_financial_indicators(
|
|||||||
risc=risc,
|
risc=risc,
|
||||||
cash_flow=cash_flow,
|
cash_flow=cash_flow,
|
||||||
dinamica=dinamica,
|
dinamica=dinamica,
|
||||||
altman_zscore=altman_zscore
|
altman_zscore=altman_zscore,
|
||||||
|
profitabilitate=profitabilitate,
|
||||||
|
solvabilitate=solvabilitate
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -570,6 +607,14 @@ async def get_financial_indicators(
|
|||||||
f"Z-Score={altman_zscore.zscore.value} ({altman_zscore.zscore.status})"
|
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
|
return response
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -1546,14 +1546,16 @@ class DashboardService:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
@staticmethod
|
@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:
|
Args:
|
||||||
company: ID-ul firmei
|
company: ID-ul firmei
|
||||||
luna: Luna contabilă (1-12), opțional
|
luna: Luna contabilă (1-12), opțional
|
||||||
an: Anul contabil, opțional
|
an: Anul contabil, opțional
|
||||||
|
request: Request object pentru cache metadata
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with oracle_pool.get_connection() as connection:
|
async with oracle_pool.get_connection() as connection:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
21
src/App.vue
21
src/App.vue
@@ -48,6 +48,7 @@ import { createCompaniesStore } from '@shared/stores/companies.js'
|
|||||||
import { createAccountingPeriodStore } from '@shared/stores/accountingPeriod.js'
|
import { createAccountingPeriodStore } from '@shared/stores/accountingPeriod.js'
|
||||||
import { menuSections } from '@/config/menu.js'
|
import { menuSections } from '@/config/menu.js'
|
||||||
import { getEnabledMenuSections } from '@/config/features.js'
|
import { getEnabledMenuSections } from '@/config/features.js'
|
||||||
|
import { handleUnauthorized, isAuthRedirectInProgress } from '@/shared/utils/authRedirect'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -68,6 +69,14 @@ const authApi = axios.create({
|
|||||||
|
|
||||||
// Add interceptor to inject auth token from localStorage
|
// Add interceptor to inject auth token from localStorage
|
||||||
authApi.interceptors.request.use(config => {
|
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')
|
const token = localStorage.getItem('access_token')
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
@@ -75,6 +84,18 @@ authApi.interceptors.request.use(config => {
|
|||||||
return 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)
|
// Store definitions (factories return store definitions)
|
||||||
const useAuthStore = createAuthStore(authApi)
|
const useAuthStore = createAuthStore(authApi)
|
||||||
const useCompanyStore = createCompaniesStore(authApi, useAuthStore)
|
const useCompanyStore = createCompaniesStore(authApi, useAuthStore)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import { handleUnauthorized, isAuthRedirectInProgress } from '@/shared/utils/authRedirect'
|
||||||
|
|
||||||
// Use relative path - works with both Vite dev proxy and IIS production proxy
|
// Use relative path - works with both Vite dev proxy and IIS production proxy
|
||||||
const baseURL = import.meta.env.BASE_URL + 'api/data-entry'
|
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
|
// Request interceptor for auth token and company header
|
||||||
api.interceptors.request.use((config) => {
|
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')
|
const token = localStorage.getItem('access_token')
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
@@ -57,18 +66,18 @@ api.interceptors.response.use(
|
|||||||
return response
|
return response
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
// Skip logging for aborted requests (happens during auth redirect)
|
||||||
|
if (error.code !== 'ERR_CANCELED') {
|
||||||
console.error('❌ API Error:', {
|
console.error('❌ API Error:', {
|
||||||
url: error.config?.url,
|
url: error.config?.url,
|
||||||
method: error.config?.method,
|
method: error.config?.method,
|
||||||
code: error.code,
|
code: error.code,
|
||||||
message: error.message
|
message: error.message
|
||||||
})
|
})
|
||||||
|
}
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
// Token expired or invalid - redirect to login
|
// Use shared handler to prevent race conditions
|
||||||
localStorage.removeItem('access_token')
|
handleUnauthorized()
|
||||||
localStorage.removeItem('refresh_token')
|
|
||||||
localStorage.removeItem('user')
|
|
||||||
window.location.href = import.meta.env.BASE_URL + 'login'
|
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dual sparkline charts - stacked vertical -->
|
<!-- 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">
|
<div class="sparkline-dual-container" v-if="hasSparklineData">
|
||||||
<!-- Grafic Încasări -->
|
<!-- Grafic Încasări -->
|
||||||
<div class="sparkline-wrapper">
|
<div class="sparkline-wrapper">
|
||||||
@@ -41,6 +55,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cache Footer -->
|
||||||
|
<CacheFooter
|
||||||
|
:cache-hit="cacheInfo?.hit"
|
||||||
|
:response-time-ms="cacheInfo?.time"
|
||||||
|
:cache-source="cacheInfo?.source"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -53,6 +75,7 @@ import {
|
|||||||
nextTick,
|
nextTick,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import { Chart, registerables } from "chart.js";
|
import { Chart, registerables } from "chart.js";
|
||||||
|
import CacheFooter from "@/shared/components/CacheFooter.vue";
|
||||||
|
|
||||||
Chart.register(...registerables);
|
Chart.register(...registerables);
|
||||||
|
|
||||||
@@ -97,6 +120,10 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
cacheInfo: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ hit: false, time: 0, source: null }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refs pentru 2 canvas-uri separate
|
// Refs pentru 2 canvas-uri separate
|
||||||
@@ -105,6 +132,13 @@ const outflowsCanvas = ref(null);
|
|||||||
let inflowsChartInstance = null;
|
let inflowsChartInstance = null;
|
||||||
let outflowsChartInstance = null;
|
let outflowsChartInstance = null;
|
||||||
|
|
||||||
|
// Charts collapsible state
|
||||||
|
const chartsExpanded = ref(false);
|
||||||
|
|
||||||
|
const toggleChartsExpanded = () => {
|
||||||
|
chartsExpanded.value = !chartsExpanded.value;
|
||||||
|
};
|
||||||
|
|
||||||
// Format currency
|
// Format currency
|
||||||
const formatCurrency = (amount) => {
|
const formatCurrency = (amount) => {
|
||||||
if (!amount && amount !== 0) return "0 RON";
|
if (!amount && amount !== 0) return "0 RON";
|
||||||
@@ -607,6 +641,39 @@ onBeforeUnmount(() => {
|
|||||||
min-height: 60px;
|
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) */
|
/* Dual sparkline container (unique to this card) */
|
||||||
.sparkline-dual-container {
|
.sparkline-dual-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="metric-card clienti-balance-card">
|
<div class="metric-card clienti-balance-card">
|
||||||
<!-- Main value section -->
|
<!-- Header cu total și trend -->
|
||||||
<div class="value-section">
|
<div class="card-header-mobile">
|
||||||
<div class="metric-label">Clienți</div>
|
<div class="header-left">
|
||||||
<div class="metric-value" :class="getBalanceClass(total)">
|
<span class="header-dot clienti"></span>
|
||||||
|
<span class="header-label">Clienți</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-values">
|
||||||
|
<span class="header-total" :class="getBalanceClass(total)">
|
||||||
{{ formatCurrency(total) }}
|
{{ formatCurrency(total) }}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="value-trend trend-indicator"
|
class="header-trend"
|
||||||
:class="getTrendClass(trend)"
|
:class="getTrendClass(trend)"
|
||||||
v-if="trend"
|
v-if="trend"
|
||||||
>
|
>
|
||||||
<span class="trend-icon">{{ getTrendIcon(trend) }}</span>
|
<i :class="getTrendIconClass(trend)"></i>
|
||||||
<span class="trend-value">{{ Math.round(Math.abs(trend.value)) }}%</span>
|
<span>{{ Math.round(Math.abs(trend.value)) }}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sparkline chart -->
|
|
||||||
<div class="metric-sparkline" v-if="hasSparklineData">
|
|
||||||
<div class="sparkline-chart">
|
|
||||||
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,6 +59,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -74,6 +101,7 @@ import {
|
|||||||
nextTick,
|
nextTick,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import { Chart, registerables } from "chart.js";
|
import { Chart, registerables } from "chart.js";
|
||||||
|
import CacheFooter from "@/shared/components/CacheFooter.vue";
|
||||||
|
|
||||||
Chart.register(...registerables);
|
Chart.register(...registerables);
|
||||||
|
|
||||||
@@ -106,18 +134,27 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
cacheInfo: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ hit: false, time: 0, source: null }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const chartCanvas = ref(null);
|
const chartCanvas = ref(null);
|
||||||
let chartInstance = null;
|
let chartInstance = null;
|
||||||
const isRestantExpanded = ref(false);
|
const isRestantExpanded = ref(false);
|
||||||
|
const chartsExpanded = ref(false);
|
||||||
|
|
||||||
// Toggle functions
|
// Toggle functions
|
||||||
const toggleRestantExpanded = () => {
|
const toggleRestantExpanded = () => {
|
||||||
isRestantExpanded.value = !isRestantExpanded.value;
|
isRestantExpanded.value = !isRestantExpanded.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleChartsExpanded = () => {
|
||||||
|
chartsExpanded.value = !chartsExpanded.value;
|
||||||
|
};
|
||||||
|
|
||||||
// Format currency
|
// Format currency
|
||||||
const formatCurrency = (amount) => {
|
const formatCurrency = (amount) => {
|
||||||
if (!amount && amount !== 0) return "0 RON";
|
if (!amount && amount !== 0) return "0 RON";
|
||||||
@@ -159,7 +196,7 @@ const getTrendClass = (trend) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Trend icon
|
// Trend icon (text version)
|
||||||
const getTrendIcon = (trend) => {
|
const getTrendIcon = (trend) => {
|
||||||
if (!trend) return "";
|
if (!trend) return "";
|
||||||
switch (trend.direction) {
|
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
|
// Check if sparkline data exists
|
||||||
const hasSparklineData = computed(() => {
|
const hasSparklineData = computed(() => {
|
||||||
return props.sparklineData && props.sparklineData.length > 0;
|
return props.sparklineData && props.sparklineData.length > 0;
|
||||||
@@ -414,29 +466,114 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
/* Override min-height for balance card */
|
/* Override min-height for balance card */
|
||||||
.clienti-balance-card {
|
.clienti-balance-card {
|
||||||
min-height: 320px;
|
min-height: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Value section: horizontal layout */
|
/* Mobile header with total and trend */
|
||||||
.value-section {
|
.card-header-mobile {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
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 {
|
.positive {
|
||||||
color: var(--color-success);
|
color: var(--green-600);
|
||||||
}
|
}
|
||||||
|
|
||||||
.negative {
|
.negative {
|
||||||
color: var(--color-error);
|
color: var(--red-600);
|
||||||
}
|
}
|
||||||
|
|
||||||
.neutral {
|
.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 */
|
/* Sparkline chart dimensions */
|
||||||
@@ -453,6 +590,39 @@ onBeforeUnmount(() => {
|
|||||||
display: block;
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.clienti-balance-card {
|
.clienti-balance-card {
|
||||||
|
|||||||
@@ -1,49 +1,65 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="balance-dual-card">
|
<div class="balance-dual-card">
|
||||||
<!-- Header -->
|
<!-- Header cu sold net -->
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
<div class="header-left">
|
||||||
<span class="card-icon">💰</span>
|
<span class="card-icon">💰</span>
|
||||||
<span class="card-title">SOLD CLIENȚI / FURNIZORI</span>
|
<span class="card-title">SOLD CLIENȚI / FURNIZORI</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
<!-- Main values section - Split layout -->
|
<span class="header-total" :class="getBalanceClass(netBalance)">
|
||||||
<div class="values-section">
|
{{ formatCurrency(netBalance) }}
|
||||||
<!-- Clienți Section -->
|
</span>
|
||||||
<div class="value-block clienti">
|
|
||||||
<div class="value-label">Clienți</div>
|
|
||||||
<div class="value-amount" :class="getBalanceClass(clientiTotal)">
|
|
||||||
{{ formatCurrency(clientiTotal) }}
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="value-trend"
|
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>
|
||||||
|
|
||||||
|
<!-- 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="balance-values">
|
||||||
|
<span class="balance-amount" :class="getBalanceClass(clientiTotal)">
|
||||||
|
{{ formatCurrency(clientiTotal) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="balance-trend"
|
||||||
:class="getTrendClass(clientiTrend)"
|
:class="getTrendClass(clientiTrend)"
|
||||||
v-if="clientiTrend"
|
v-if="clientiTrend"
|
||||||
>
|
>
|
||||||
<span class="trend-icon">{{ getTrendIcon(clientiTrend) }}</span>
|
{{ getTrendIcon(clientiTrend) }}{{ Math.round(Math.abs(clientiTrend.value)) }}%
|
||||||
<span class="trend-value"
|
</span>
|
||||||
>{{ Math.round(Math.abs(clientiTrend.value)) }}%</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Divider -->
|
<!-- Furnizori -->
|
||||||
<div class="divider"></div>
|
<div class="balance-row">
|
||||||
|
<div class="balance-label">
|
||||||
<!-- Furnizori Section -->
|
<span class="balance-dot furnizori"></span>
|
||||||
<div class="value-block furnizori">
|
Furnizori
|
||||||
<div class="value-label">Furnizori</div>
|
</div>
|
||||||
<div class="value-amount" :class="getBalanceClass(furnizoriTotal)">
|
<div class="balance-values">
|
||||||
|
<span class="balance-amount" :class="getBalanceClass(-furnizoriTotal)">
|
||||||
{{ formatCurrency(furnizoriTotal) }}
|
{{ formatCurrency(furnizoriTotal) }}
|
||||||
</div>
|
</span>
|
||||||
<div
|
<span
|
||||||
class="value-trend"
|
class="balance-trend"
|
||||||
:class="getTrendClass(furnizoriTrend)"
|
:class="getTrendClass(furnizoriTrend)"
|
||||||
v-if="furnizoriTrend"
|
v-if="furnizoriTrend"
|
||||||
>
|
>
|
||||||
<span class="trend-icon">{{ getTrendIcon(furnizoriTrend) }}</span>
|
{{ getTrendIcon(furnizoriTrend) }}{{ Math.round(Math.abs(furnizoriTrend.value)) }}%
|
||||||
<span class="trend-value"
|
</span>
|
||||||
>{{ Math.round(Math.abs(furnizoriTrend.value)) }}%</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,7 +316,7 @@ const getTrendClass = (trend) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Trend icon
|
// Trend icon (text version)
|
||||||
const getTrendIcon = (trend) => {
|
const getTrendIcon = (trend) => {
|
||||||
if (!trend) return "";
|
if (!trend) return "";
|
||||||
switch (trend.direction) {
|
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
|
// Check if sparkline data exists
|
||||||
const hasSparklineData = computed(() => {
|
const hasSparklineData = computed(() => {
|
||||||
return (
|
return (
|
||||||
@@ -647,12 +700,20 @@ onBeforeUnmount(() => {
|
|||||||
border-color: var(--color-primary, #3b82f6);
|
border-color: var(--color-primary, #3b82f6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header cu sold net */
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
margin-bottom: var(--space-md);
|
||||||
margin-bottom: 0.5rem;
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-icon {
|
.card-icon {
|
||||||
@@ -662,87 +723,147 @@ onBeforeUnmount(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
background: var(--color-bg-secondary, #f8fafc);
|
background: var(--surface-hover);
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: var(--text-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
font-weight: var(--font-semibold, 600);
|
font-weight: var(--font-semibold);
|
||||||
color: var(--color-text-secondary, #6b7280);
|
color: var(--text-color-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Values section - Split layout */
|
.header-right {
|
||||||
.values-section {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-block {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-label {
|
|
||||||
font-size: var(--text-xs, 0.75rem);
|
|
||||||
font-weight: var(--font-medium, 500);
|
|
||||||
color: var(--color-text-secondary, #6b7280);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-amount {
|
|
||||||
font-size: var(--text-xl, 1.5rem);
|
|
||||||
font-weight: var(--font-bold, 700);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-amount.positive {
|
|
||||||
color: var(--color-success, #10b981);
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-amount.negative {
|
|
||||||
color: var(--color-danger, #ef4444);
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-amount.neutral {
|
|
||||||
color: var(--color-text, #111827);
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-trend {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: var(--space-sm);
|
||||||
font-size: var(--text-sm, 0.875rem);
|
|
||||||
font-weight: var(--font-medium, 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
.trend-up {
|
||||||
color: var(--color-success, #10b981);
|
color: var(--green-600);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-down {
|
.trend-down {
|
||||||
color: var(--color-danger, #ef4444);
|
color: var(--red-600);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-neutral {
|
.trend-neutral {
|
||||||
color: var(--color-text-secondary, #6b7280);
|
color: var(--text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-icon {
|
/* Dark mode */
|
||||||
font-size: 0.75rem;
|
[data-theme="dark"] .header-total.positive,
|
||||||
|
[data-theme="dark"] .balance-amount.positive,
|
||||||
|
[data-theme="dark"] .trend-up {
|
||||||
|
color: var(--green-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
[data-theme="dark"] .header-total.negative,
|
||||||
width: 1px;
|
[data-theme="dark"] .balance-amount.negative,
|
||||||
height: 100%;
|
[data-theme="dark"] .trend-down {
|
||||||
background: var(--color-border, #e5e7eb);
|
color: var(--red-400);
|
||||||
min-height: 60px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dual sparkline container - stack vertical */
|
/* Dual sparkline container - stack vertical */
|
||||||
@@ -955,23 +1076,27 @@ onBeforeUnmount(() => {
|
|||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.balance-dual-card {
|
.balance-dual-card {
|
||||||
min-height: 380px;
|
min-height: 320px;
|
||||||
padding: var(--space-md, 1rem);
|
padding: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.values-section {
|
.card-header {
|
||||||
grid-template-columns: 1fr;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
align-items: flex-start;
|
||||||
|
gap: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.header-right {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 1px;
|
justify-content: space-between;
|
||||||
min-height: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.value-amount {
|
.header-total {
|
||||||
font-size: var(--text-lg, 1.125rem);
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-amount {
|
||||||
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sparkline-chart {
|
.sparkline-chart {
|
||||||
@@ -981,15 +1106,15 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.balance-dual-card {
|
.balance-dual-card {
|
||||||
min-height: 340px;
|
min-height: 280px;
|
||||||
padding: 0.5rem 0.25rem;
|
padding: var(--space-sm);
|
||||||
gap: 0.5rem;
|
gap: var(--space-sm);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
padding: 0.25rem;
|
padding: var(--space-xs);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-icon {
|
.card-icon {
|
||||||
@@ -999,11 +1124,15 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 0.625rem;
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.value-amount {
|
.header-total {
|
||||||
font-size: var(--text-base, 1rem);
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-amount {
|
||||||
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sparkline-chart {
|
.sparkline-chart {
|
||||||
@@ -1014,10 +1143,6 @@ onBeforeUnmount(() => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.values-section {
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode support */
|
/* Dark mode support */
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="metric-card furnizori-balance-card">
|
<div class="metric-card furnizori-balance-card">
|
||||||
<!-- Main value section -->
|
<!-- Header cu total și trend -->
|
||||||
<div class="value-section">
|
<div class="card-header-mobile">
|
||||||
<div class="metric-label">Furnizori</div>
|
<div class="header-left">
|
||||||
<div class="metric-value" :class="getBalanceClass(total)">
|
<span class="header-dot furnizori"></span>
|
||||||
|
<span class="header-label">Furnizori</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-values">
|
||||||
|
<span class="header-total" :class="getBalanceClass(total)">
|
||||||
{{ formatCurrency(total) }}
|
{{ formatCurrency(total) }}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="value-trend trend-indicator"
|
class="header-trend"
|
||||||
:class="getTrendClass(trend)"
|
:class="getTrendClass(trend)"
|
||||||
v-if="trend"
|
v-if="trend"
|
||||||
>
|
>
|
||||||
<span class="trend-icon">{{ getTrendIcon(trend) }}</span>
|
<i :class="getTrendIconClass(trend)"></i>
|
||||||
<span class="trend-value">{{ Math.round(Math.abs(trend.value)) }}%</span>
|
<span>{{ Math.round(Math.abs(trend.value)) }}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sparkline chart -->
|
|
||||||
<div class="metric-sparkline" v-if="hasSparklineData">
|
|
||||||
<div class="sparkline-chart">
|
|
||||||
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,6 +59,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -74,6 +101,7 @@ import {
|
|||||||
nextTick,
|
nextTick,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import { Chart, registerables } from "chart.js";
|
import { Chart, registerables } from "chart.js";
|
||||||
|
import CacheFooter from "@/shared/components/CacheFooter.vue";
|
||||||
|
|
||||||
Chart.register(...registerables);
|
Chart.register(...registerables);
|
||||||
|
|
||||||
@@ -106,18 +134,27 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
cacheInfo: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ hit: false, time: 0, source: null }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const chartCanvas = ref(null);
|
const chartCanvas = ref(null);
|
||||||
let chartInstance = null;
|
let chartInstance = null;
|
||||||
const isRestantExpanded = ref(false);
|
const isRestantExpanded = ref(false);
|
||||||
|
const chartsExpanded = ref(false);
|
||||||
|
|
||||||
// Toggle functions
|
// Toggle functions
|
||||||
const toggleRestantExpanded = () => {
|
const toggleRestantExpanded = () => {
|
||||||
isRestantExpanded.value = !isRestantExpanded.value;
|
isRestantExpanded.value = !isRestantExpanded.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleChartsExpanded = () => {
|
||||||
|
chartsExpanded.value = !chartsExpanded.value;
|
||||||
|
};
|
||||||
|
|
||||||
// Format currency
|
// Format currency
|
||||||
const formatCurrency = (amount) => {
|
const formatCurrency = (amount) => {
|
||||||
if (!amount && amount !== 0) return "0 RON";
|
if (!amount && amount !== 0) return "0 RON";
|
||||||
@@ -159,7 +196,7 @@ const getTrendClass = (trend) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Trend icon
|
// Trend icon (text version)
|
||||||
const getTrendIcon = (trend) => {
|
const getTrendIcon = (trend) => {
|
||||||
if (!trend) return "";
|
if (!trend) return "";
|
||||||
switch (trend.direction) {
|
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
|
// Check if sparkline data exists
|
||||||
const hasSparklineData = computed(() => {
|
const hasSparklineData = computed(() => {
|
||||||
return props.sparklineData && props.sparklineData.length > 0;
|
return props.sparklineData && props.sparklineData.length > 0;
|
||||||
@@ -414,29 +466,114 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
/* Override min-height for balance card */
|
/* Override min-height for balance card */
|
||||||
.furnizori-balance-card {
|
.furnizori-balance-card {
|
||||||
min-height: 320px;
|
min-height: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Value section: horizontal layout */
|
/* Mobile header with total and trend */
|
||||||
.value-section {
|
.card-header-mobile {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
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 {
|
.positive {
|
||||||
color: var(--color-success);
|
color: var(--green-600);
|
||||||
}
|
}
|
||||||
|
|
||||||
.negative {
|
.negative {
|
||||||
color: var(--color-error);
|
color: var(--red-600);
|
||||||
}
|
}
|
||||||
|
|
||||||
.neutral {
|
.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 */
|
/* Sparkline chart dimensions */
|
||||||
@@ -453,6 +590,39 @@ onBeforeUnmount(() => {
|
|||||||
display: block;
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.furnizori-balance-card {
|
.furnizori-balance-card {
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="indicator-item" :class="statusClass">
|
<div class="indicator-item" :class="statusClass">
|
||||||
<!-- Label (top) -->
|
<!-- Label (top) cu toggle pentru descriere -->
|
||||||
<div class="indicator-label">{{ label }}</div>
|
<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) -->
|
<!-- Description (collapsible) -->
|
||||||
<div v-if="description" class="indicator-description">{{ description }}</div>
|
<div v-if="description && descExpanded" class="indicator-description slide-down">
|
||||||
|
{{ description }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main content: Value centered + Status icon on right -->
|
<!-- Main content: Value centered + Status icon on right -->
|
||||||
<div class="indicator-main">
|
<div class="indicator-main">
|
||||||
@@ -62,6 +73,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Threshold info -->
|
||||||
<div v-if="thresholdText" class="indicator-threshold">
|
<div v-if="thresholdText" class="indicator-threshold">
|
||||||
{{ thresholdText }}
|
{{ thresholdText }}
|
||||||
@@ -70,7 +92,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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({
|
const props = defineProps({
|
||||||
label: {
|
label: {
|
||||||
@@ -264,6 +293,39 @@ const thresholdText = computed(() => {
|
|||||||
return parts.join(' | ')
|
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
|
// Methods
|
||||||
const handleMouseMove = (event) => {
|
const handleMouseMove = (event) => {
|
||||||
if (!pointPositions.value.length || !sparklineContainer.value) return
|
if (!pointPositions.value.length || !sparklineContainer.value) return
|
||||||
@@ -312,22 +374,62 @@ const handleMouseLeave = () => {
|
|||||||
border-color: var(--surface-hover);
|
border-color: var(--surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Label (top) */
|
/* Label (top) with toggle */
|
||||||
.indicator-label {
|
.indicator-label {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: var(--font-medium);
|
font-weight: var(--font-medium);
|
||||||
color: var(--text-color-secondary);
|
color: var(--text-color-secondary);
|
||||||
text-align: center;
|
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 {
|
.indicator-description {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-color-secondary);
|
color: var(--text-color-secondary);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
line-height: 1.3;
|
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 */
|
/* Main section: Value + Status Icon */
|
||||||
@@ -450,6 +552,72 @@ const handleMouseLeave = () => {
|
|||||||
font-family: var(--font-mono);
|
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 */
|
/* Threshold info */
|
||||||
.indicator-threshold {
|
.indicator-threshold {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
|||||||
@@ -1,29 +1,81 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="metric-card treasury-dual-card">
|
<div class="metric-card treasury-dual-card">
|
||||||
<!-- Main values section - Split layout (Casa | Bancă) -->
|
<!-- Treasury items - Casa și Bancă stacked vertical cu expand individual -->
|
||||||
<div class="values-section">
|
<div class="treasury-items">
|
||||||
<!-- Casa Section -->
|
<!-- Casa -->
|
||||||
<div class="value-block casa">
|
<div class="treasury-group" v-if="casaItems.length > 0 || casaTotal > 0">
|
||||||
<div class="metric-label">Casa</div>
|
<div class="treasury-header" @click="toggleCasaExpanded">
|
||||||
<div class="metric-value text-success">
|
<div class="treasury-header-left">
|
||||||
{{ formatCurrency(casaTotal) }}
|
<i
|
||||||
|
class="pi pi-chevron-right treasury-toggle"
|
||||||
|
:class="{ expanded: isCasaExpanded }"
|
||||||
|
></i>
|
||||||
|
<span class="treasury-label">Casa</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="treasury-value text-success">{{ formatCurrency(casaTotal) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Divider -->
|
<!-- Casa Sub-items -->
|
||||||
<div class="divider"></div>
|
<div v-show="isCasaExpanded && casaItems.length > 0" class="treasury-subitems slide-down">
|
||||||
|
<div
|
||||||
<!-- Bancă Section -->
|
v-for="(item, idx) in casaItems"
|
||||||
<div class="value-block banca">
|
:key="idx"
|
||||||
<div class="metric-label">Bancă</div>
|
class="treasury-subitem"
|
||||||
<div class="metric-value text-primary">
|
>
|
||||||
{{ formatCurrency(bancaTotal) }}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dual sparkline charts - stacked vertical -->
|
<!-- Bancă -->
|
||||||
<div class="sparkline-dual-container" v-if="hasSparklineData">
|
<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ă 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>
|
||||||
|
|
||||||
|
<!-- 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 -->
|
<!-- Grafic Casa -->
|
||||||
<div class="sparkline-wrapper">
|
<div class="sparkline-wrapper">
|
||||||
<div class="sparkline-title text-success">Casa</div>
|
<div class="sparkline-title text-success">Casa</div>
|
||||||
@@ -41,77 +93,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Breakdown section -->
|
<!-- Cache Footer -->
|
||||||
<div
|
<CacheFooter
|
||||||
class="breakdown-section"
|
:cache-hit="cacheInfo?.hit"
|
||||||
v-if="casaItems.length > 0 || bancaItems.length > 0"
|
:response-time-ms="cacheInfo?.time"
|
||||||
>
|
:cache-source="cacheInfo?.source"
|
||||||
<!-- Casa Breakdown -->
|
/>
|
||||||
<div class="breakdown-group" v-if="casaItems.length > 0">
|
|
||||||
<div class="breakdown-header" @click="toggleCasaExpanded">
|
|
||||||
<div class="breakdown-header-left">
|
|
||||||
<i
|
|
||||||
class="pi pi-chevron-right breakdown-toggle"
|
|
||||||
:class="{ expanded: isCasaExpanded }"
|
|
||||||
></i>
|
|
||||||
<span class="breakdown-label">Casa</span>
|
|
||||||
</div>
|
|
||||||
<span class="breakdown-value">{{ formatCurrency(casaTotal) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Casa Sub-items -->
|
|
||||||
<div v-show="isCasaExpanded" class="breakdown-subitems slide-down">
|
|
||||||
<div
|
|
||||||
v-for="(item, idx) in casaItems"
|
|
||||||
:key="idx"
|
|
||||||
class="breakdown-subitem"
|
|
||||||
>
|
|
||||||
<span class="breakdown-sublabel">
|
|
||||||
{{ item.nume || `Cont ${item.cont}` }}
|
|
||||||
<span v-if="item.cont" class="breakdown-cont"
|
|
||||||
>({{ item.cont }})</span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
<span class="breakdown-subvalue">{{
|
|
||||||
formatCurrency(item.sold)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bancă Breakdown -->
|
|
||||||
<div class="breakdown-group" v-if="bancaItems.length > 0">
|
|
||||||
<div class="breakdown-header" @click="toggleBancaExpanded">
|
|
||||||
<div class="breakdown-header-left">
|
|
||||||
<i
|
|
||||||
class="pi pi-chevron-right breakdown-toggle"
|
|
||||||
:class="{ expanded: isBancaExpanded }"
|
|
||||||
></i>
|
|
||||||
<span class="breakdown-label">Bancă</span>
|
|
||||||
</div>
|
|
||||||
<span class="breakdown-value">{{ formatCurrency(bancaTotal) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bancă Sub-items -->
|
|
||||||
<div v-show="isBancaExpanded" class="breakdown-subitems slide-down">
|
|
||||||
<div
|
|
||||||
v-for="(item, idx) in bancaItems"
|
|
||||||
:key="idx"
|
|
||||||
class="breakdown-subitem"
|
|
||||||
>
|
|
||||||
<span class="breakdown-sublabel">
|
|
||||||
{{ item.nume || `Cont ${item.cont}` }}
|
|
||||||
<span v-if="item.cont" class="breakdown-cont"
|
|
||||||
>({{ item.cont }})</span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
<span class="breakdown-subvalue">{{
|
|
||||||
formatCurrency(item.sold)
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -125,6 +112,7 @@ import {
|
|||||||
nextTick,
|
nextTick,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import { Chart, registerables } from "chart.js";
|
import { Chart, registerables } from "chart.js";
|
||||||
|
import CacheFooter from "@/shared/components/CacheFooter.vue";
|
||||||
|
|
||||||
Chart.register(...registerables);
|
Chart.register(...registerables);
|
||||||
|
|
||||||
@@ -173,6 +161,10 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
cacheInfo: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ hit: false, time: 0, source: null }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refs pentru 2 canvas-uri separate
|
// Refs pentru 2 canvas-uri separate
|
||||||
@@ -182,6 +174,7 @@ let casaChartInstance = null;
|
|||||||
let bancaChartInstance = null;
|
let bancaChartInstance = null;
|
||||||
const isCasaExpanded = ref(false);
|
const isCasaExpanded = ref(false);
|
||||||
const isBancaExpanded = ref(false);
|
const isBancaExpanded = ref(false);
|
||||||
|
const chartsExpanded = ref(false);
|
||||||
|
|
||||||
// Toggle functions
|
// Toggle functions
|
||||||
const toggleCasaExpanded = () => {
|
const toggleCasaExpanded = () => {
|
||||||
@@ -192,6 +185,10 @@ const toggleBancaExpanded = () => {
|
|||||||
isBancaExpanded.value = !isBancaExpanded.value;
|
isBancaExpanded.value = !isBancaExpanded.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleChartsExpanded = () => {
|
||||||
|
chartsExpanded.value = !chartsExpanded.value;
|
||||||
|
};
|
||||||
|
|
||||||
// Format currency
|
// Format currency
|
||||||
const formatCurrency = (amount) => {
|
const formatCurrency = (amount) => {
|
||||||
if (!amount && amount !== 0) return "0 RON";
|
if (!amount && amount !== 0) return "0 RON";
|
||||||
@@ -612,34 +609,101 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.treasury-dual-card {
|
||||||
min-height: 420px;
|
min-height: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Split layout: Casa | Divider | Bancă */
|
/* Treasury items container - stacked vertical */
|
||||||
.values-section {
|
.treasury-items {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: var(--space-sm);
|
||||||
align-items: start;
|
}
|
||||||
}
|
|
||||||
|
/* Treasury group (Casa sau Bancă) */
|
||||||
.value-block {
|
.treasury-group {
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Treasury header - clickable */
|
||||||
|
.treasury-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
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 {
|
.treasury-header:hover {
|
||||||
width: 1px;
|
background: var(--surface-hover);
|
||||||
height: 100%;
|
}
|
||||||
background: var(--color-border);
|
|
||||||
min-height: 60px;
|
.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) */
|
/* Dual sparkline container (unique to this card) */
|
||||||
@@ -672,28 +736,47 @@ onBeforeUnmount(() => {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Component-specific: Account number display in breakdown */
|
/* Charts toggle header */
|
||||||
.breakdown-cont {
|
.charts-toggle-header {
|
||||||
font-size: 0.8125rem;
|
display: flex;
|
||||||
opacity: 0.7;
|
justify-content: space-between;
|
||||||
margin-left: 0.25rem;
|
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 */
|
/* Responsive: Stack vertically on mobile */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.treasury-dual-card {
|
.treasury-dual-card {
|
||||||
min-height: 380px;
|
min-height: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.values-section {
|
.treasury-value {
|
||||||
grid-template-columns: 1fr;
|
font-size: var(--text-lg);
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
width: 100%;
|
|
||||||
height: 1px;
|
|
||||||
min-height: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sparkline-chart {
|
.sparkline-chart {
|
||||||
@@ -703,7 +786,15 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.treasury-dual-card {
|
.treasury-dual-card {
|
||||||
min-height: 340px;
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treasury-header {
|
||||||
|
padding: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.treasury-value {
|
||||||
|
font-size: var(--text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sparkline-chart {
|
.sparkline-chart {
|
||||||
@@ -714,9 +805,5 @@ onBeforeUnmount(() => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.values-section {
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import { handleUnauthorized, isAuthRedirectInProgress } from '@/shared/utils/authRedirect'
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: import.meta.env.BASE_URL + 'api/reports',
|
baseURL: import.meta.env.BASE_URL + 'api/reports',
|
||||||
@@ -7,6 +8,14 @@ const api = axios.create({
|
|||||||
|
|
||||||
// Request interceptor for auth token
|
// Request interceptor for auth token
|
||||||
api.interceptors.request.use((config) => {
|
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')
|
const token = localStorage.getItem('access_token')
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
@@ -19,14 +28,30 @@ api.interceptors.response.use(
|
|||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
// Token expired or invalid - redirect to login
|
// Use shared handler to prevent race conditions
|
||||||
localStorage.removeItem('access_token')
|
handleUnauthorized()
|
||||||
localStorage.removeItem('refresh_token')
|
|
||||||
localStorage.removeItem('user')
|
|
||||||
window.location.href = import.meta.env.BASE_URL + 'login'
|
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
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
|
export default api
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import api from "@reports/services/api";
|
import api, { getWithCacheInfo } from "@reports/services/api";
|
||||||
|
|
||||||
export const useDashboardStore = defineStore("dashboard", () => {
|
export const useDashboardStore = defineStore("dashboard", () => {
|
||||||
// State existent
|
// State existent
|
||||||
@@ -20,6 +20,7 @@ export const useDashboardStore = defineStore("dashboard", () => {
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
data: null,
|
data: null,
|
||||||
|
cacheInfo: { hit: false, time: 0, source: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
// State pentru detailed data pagination
|
// State pentru detailed data pagination
|
||||||
@@ -499,14 +500,21 @@ export const useDashboardStore = defineStore("dashboard", () => {
|
|||||||
if (luna !== null) params.luna = luna;
|
if (luna !== null) params.luna = luna;
|
||||||
if (an !== null) params.an = an;
|
if (an !== null) params.an = an;
|
||||||
|
|
||||||
const response = await api.get("/dashboard/financial-indicators", {
|
const data = await getWithCacheInfo("/dashboard/financial-indicators", {
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
financialIndicators.value.data = response.data;
|
financialIndicators.value.data = data;
|
||||||
financialIndicators.value.loading = false;
|
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) {
|
} catch (err) {
|
||||||
console.error("Failed to load financial indicators:", err);
|
console.error("Failed to load financial indicators:", err);
|
||||||
|
|
||||||
@@ -524,6 +532,7 @@ export const useDashboardStore = defineStore("dashboard", () => {
|
|||||||
financialIndicators.value.error = errorMessage;
|
financialIndicators.value.error = errorMessage;
|
||||||
financialIndicators.value.loading = false;
|
financialIndicators.value.loading = false;
|
||||||
financialIndicators.value.data = null;
|
financialIndicators.value.data = null;
|
||||||
|
financialIndicators.value.cacheInfo = { hit: false, time: 0, source: null };
|
||||||
|
|
||||||
return { success: false, error: errorMessage };
|
return { success: false, error: errorMessage };
|
||||||
}
|
}
|
||||||
@@ -557,6 +566,7 @@ export const useDashboardStore = defineStore("dashboard", () => {
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
data: null,
|
data: null,
|
||||||
|
cacheInfo: { hit: false, time: 0, source: null },
|
||||||
};
|
};
|
||||||
clearCache();
|
clearCache();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
:bancaPreviousSparklineData="bancaPreviousSparkline"
|
:bancaPreviousSparklineData="bancaPreviousSparkline"
|
||||||
:sparklineLabels="sparklineLabels"
|
:sparklineLabels="sparklineLabels"
|
||||||
:previousSparklineLabels="previousSparklineLabels"
|
:previousSparklineLabels="previousSparklineLabels"
|
||||||
|
:cacheInfo="treasuryCacheInfo"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<!-- Page 3: CashFlowMetricCard (original graph card) -->
|
<!-- Page 3: CashFlowMetricCard (original graph card) -->
|
||||||
@@ -98,6 +99,7 @@
|
|||||||
:outflowsPreviousSparkline="outflowsPreviousSparkline"
|
:outflowsPreviousSparkline="outflowsPreviousSparkline"
|
||||||
:sparklineLabels="sparklineLabels"
|
:sparklineLabels="sparklineLabels"
|
||||||
:previousSparklineLabels="previousSparklineLabels"
|
:previousSparklineLabels="previousSparklineLabels"
|
||||||
|
:cacheInfo="cashflowCacheInfo"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<!-- Page 4: ClientiBalanceCard (original graph card) -->
|
<!-- Page 4: ClientiBalanceCard (original graph card) -->
|
||||||
@@ -110,6 +112,7 @@
|
|||||||
:sparklineLabels="sparklineLabels"
|
:sparklineLabels="sparklineLabels"
|
||||||
:previousSparklineLabels="previousSparklineLabels"
|
:previousSparklineLabels="previousSparklineLabels"
|
||||||
:breakdown="netBalanceData?.breakdown?.clienti"
|
:breakdown="netBalanceData?.breakdown?.clienti"
|
||||||
|
:cacheInfo="netBalanceCacheInfo"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<!-- Page 5: FurnizoriBalanceCard (original graph card) -->
|
<!-- Page 5: FurnizoriBalanceCard (original graph card) -->
|
||||||
@@ -122,6 +125,7 @@
|
|||||||
:sparklineLabels="sparklineLabels"
|
:sparklineLabels="sparklineLabels"
|
||||||
:previousSparklineLabels="previousSparklineLabels"
|
:previousSparklineLabels="previousSparklineLabels"
|
||||||
:breakdown="netBalanceData?.breakdown?.furnizori"
|
:breakdown="netBalanceData?.breakdown?.furnizori"
|
||||||
|
:cacheInfo="netBalanceCacheInfo"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<!-- Page 6: FinancialIndicatorsCard (US-015) -->
|
<!-- Page 6: FinancialIndicatorsCard (US-015) -->
|
||||||
@@ -130,44 +134,19 @@
|
|||||||
:loading="dashboardStore.financialIndicators.loading"
|
:loading="dashboardStore.financialIndicators.loading"
|
||||||
:error="dashboardStore.financialIndicators.error"
|
:error="dashboardStore.financialIndicators.error"
|
||||||
:data="dashboardStore.financialIndicators.data"
|
:data="dashboardStore.financialIndicators.data"
|
||||||
:initial-period="periodStore.selectedPeriod"
|
:initial-period="previousPeriodForIndicators"
|
||||||
|
:cache-info="dashboardStore.financialIndicators.cacheInfo"
|
||||||
mobile
|
mobile
|
||||||
@period-change="handleFinancialIndicatorsPeriodChange"
|
@period-change="handleFinancialIndicatorsPeriodChange"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</SwipeableCards>
|
</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 -->
|
<!-- Desktop: Grid layout (carduri grafice originale) - collapsible by default -->
|
||||||
<div v-if="!isMobile" class="metrics-row">
|
<div v-if="!isMobile" class="metrics-row">
|
||||||
<CollapsibleCard
|
<CollapsibleCard
|
||||||
label="Trezorerie"
|
label="Trezorerie"
|
||||||
:value="totalTrezorerie"
|
:value="totalTrezorerie"
|
||||||
icon="pi pi-wallet"
|
|
||||||
:value-class="totalTrezorerie >= 0 ? 'positive' : 'negative'"
|
:value-class="totalTrezorerie >= 0 ? 'positive' : 'negative'"
|
||||||
>
|
>
|
||||||
<TreasuryDualCard
|
<TreasuryDualCard
|
||||||
@@ -183,12 +162,12 @@
|
|||||||
:bancaPreviousSparklineData="bancaPreviousSparkline"
|
:bancaPreviousSparklineData="bancaPreviousSparkline"
|
||||||
:sparklineLabels="sparklineLabels"
|
:sparklineLabels="sparklineLabels"
|
||||||
:previousSparklineLabels="previousSparklineLabels"
|
:previousSparklineLabels="previousSparklineLabels"
|
||||||
|
:cacheInfo="treasuryCacheInfo"
|
||||||
/>
|
/>
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
<CollapsibleCard
|
<CollapsibleCard
|
||||||
label="Cash Flow"
|
label="Cash Flow"
|
||||||
:value="netCashFlow"
|
:value="netCashFlow"
|
||||||
icon="pi pi-arrows-h"
|
|
||||||
:value-class="netCashFlow >= 0 ? 'positive' : 'negative'"
|
:value-class="netCashFlow >= 0 ? 'positive' : 'negative'"
|
||||||
>
|
>
|
||||||
<CashFlowMetricCard
|
<CashFlowMetricCard
|
||||||
@@ -202,12 +181,12 @@
|
|||||||
:outflowsPreviousSparkline="outflowsPreviousSparkline"
|
:outflowsPreviousSparkline="outflowsPreviousSparkline"
|
||||||
:sparklineLabels="sparklineLabels"
|
:sparklineLabels="sparklineLabels"
|
||||||
:previousSparklineLabels="previousSparklineLabels"
|
:previousSparklineLabels="previousSparklineLabels"
|
||||||
|
:cacheInfo="cashflowCacheInfo"
|
||||||
/>
|
/>
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
<CollapsibleCard
|
<CollapsibleCard
|
||||||
label="Clienți"
|
label="Clienți"
|
||||||
:value="netBalanceData?.clienti_total || 0"
|
:value="netBalanceData?.clienti_total || 0"
|
||||||
icon="pi pi-users"
|
|
||||||
:value-class="(netBalanceData?.clienti_total || 0) >= 0 ? 'positive' : 'negative'"
|
:value-class="(netBalanceData?.clienti_total || 0) >= 0 ? 'positive' : 'negative'"
|
||||||
>
|
>
|
||||||
<ClientiBalanceCard
|
<ClientiBalanceCard
|
||||||
@@ -218,12 +197,12 @@
|
|||||||
:sparklineLabels="sparklineLabels"
|
:sparklineLabels="sparklineLabels"
|
||||||
:previousSparklineLabels="previousSparklineLabels"
|
:previousSparklineLabels="previousSparklineLabels"
|
||||||
:breakdown="netBalanceData?.breakdown?.clienti"
|
:breakdown="netBalanceData?.breakdown?.clienti"
|
||||||
|
:cacheInfo="netBalanceCacheInfo"
|
||||||
/>
|
/>
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
<CollapsibleCard
|
<CollapsibleCard
|
||||||
label="Furnizori"
|
label="Furnizori"
|
||||||
:value="netBalanceData?.furnizori_total || 0"
|
:value="netBalanceData?.furnizori_total || 0"
|
||||||
icon="pi pi-truck"
|
|
||||||
:value-class="(netBalanceData?.furnizori_total || 0) <= 0 ? 'positive' : 'negative'"
|
:value-class="(netBalanceData?.furnizori_total || 0) <= 0 ? 'positive' : 'negative'"
|
||||||
>
|
>
|
||||||
<FurnizoriBalanceCard
|
<FurnizoriBalanceCard
|
||||||
@@ -234,6 +213,7 @@
|
|||||||
:sparklineLabels="sparklineLabels"
|
:sparklineLabels="sparklineLabels"
|
||||||
:previousSparklineLabels="previousSparklineLabels"
|
:previousSparklineLabels="previousSparklineLabels"
|
||||||
:breakdown="netBalanceData?.breakdown?.furnizori"
|
:breakdown="netBalanceData?.breakdown?.furnizori"
|
||||||
|
:cacheInfo="netBalanceCacheInfo"
|
||||||
/>
|
/>
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,7 +226,8 @@
|
|||||||
:loading="dashboardStore.financialIndicators.loading"
|
:loading="dashboardStore.financialIndicators.loading"
|
||||||
:error="dashboardStore.financialIndicators.error"
|
:error="dashboardStore.financialIndicators.error"
|
||||||
:data="dashboardStore.financialIndicators.data"
|
:data="dashboardStore.financialIndicators.data"
|
||||||
:initial-period="periodStore.selectedPeriod"
|
:initial-period="previousPeriodForIndicators"
|
||||||
|
:cache-info="dashboardStore.financialIndicators.cacheInfo"
|
||||||
@period-change="handleFinancialIndicatorsPeriodChange"
|
@period-change="handleFinancialIndicatorsPeriodChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,7 +266,7 @@ import MobileDrawerMenu from "@shared/components/mobile/MobileDrawerMenu.vue";
|
|||||||
import { useCompanyStore, useAuthStore } from "@reports/stores/sharedStores";
|
import { useCompanyStore, useAuthStore } from "@reports/stores/sharedStores";
|
||||||
import { useDashboardStore } from "@reports/stores/dashboard";
|
import { useDashboardStore } from "@reports/stores/dashboard";
|
||||||
import { useAccountingPeriodStore } from "@reports/stores/sharedStores";
|
import { useAccountingPeriodStore } from "@reports/stores/sharedStores";
|
||||||
import api from "@reports/services/api";
|
import api, { getWithCacheInfo } from "@reports/services/api";
|
||||||
import {
|
import {
|
||||||
exportToExcel,
|
exportToExcel,
|
||||||
exportToPDF,
|
exportToPDF,
|
||||||
@@ -310,6 +291,11 @@ const monthlyOutflows = ref(0);
|
|||||||
const treasuryData = ref(null);
|
const treasuryData = ref(null);
|
||||||
const netBalanceData = 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
|
// New dashboard state
|
||||||
const selectedPeriod = ref("12m");
|
const selectedPeriod = ref("12m");
|
||||||
const selectedChartType = ref("line");
|
const selectedChartType = ref("line");
|
||||||
@@ -693,6 +679,20 @@ const currentMonthLabel = computed(() => {
|
|||||||
return "Se încarcă...";
|
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
|
// Methods
|
||||||
const handleCompanyChanged = async (company) => {
|
const handleCompanyChanged = async (company) => {
|
||||||
if (company) {
|
if (company) {
|
||||||
@@ -949,7 +949,7 @@ const handleCompanySelect = async (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Fixed: Changed company_id to company parameter
|
// 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 () => {
|
const loadMonthlyFlows = async () => {
|
||||||
if (!companyStore.selectedCompany) return;
|
if (!companyStore.selectedCompany) return;
|
||||||
|
|
||||||
@@ -960,9 +960,16 @@ const loadMonthlyFlows = async () => {
|
|||||||
params.an = periodStore.selectedPeriod.an;
|
params.an = periodStore.selectedPeriod.an;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.get("/dashboard/monthly-flows", { params });
|
const data = await getWithCacheInfo("/dashboard/monthly-flows", { params });
|
||||||
monthlyInflows.value = response.data.inflows || 0;
|
monthlyInflows.value = data.inflows || 0;
|
||||||
monthlyOutflows.value = response.data.outflows || 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) {
|
} catch (error) {
|
||||||
console.error("Failed to load monthly flows:", error);
|
console.error("Failed to load monthly flows:", error);
|
||||||
}
|
}
|
||||||
@@ -978,8 +985,15 @@ const loadTreasuryBreakdown = async () => {
|
|||||||
params.an = periodStore.selectedPeriod.an;
|
params.an = periodStore.selectedPeriod.an;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.get("/dashboard/treasury-breakdown", { params });
|
const data = await getWithCacheInfo("/dashboard/treasury-breakdown", { params });
|
||||||
treasuryData.value = response.data;
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to load treasury breakdown:", error);
|
console.error("Failed to load treasury breakdown:", error);
|
||||||
}
|
}
|
||||||
@@ -995,13 +1009,13 @@ const loadNetBalanceBreakdown = async () => {
|
|||||||
params.an = periodStore.selectedPeriod.an;
|
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
|
// Folosește direct datele structurate de la backend
|
||||||
netBalanceData.value = {
|
netBalanceData.value = {
|
||||||
clienti_total: response.data.clienti_total || 0,
|
clienti_total: data.clienti_total || 0,
|
||||||
furnizori_total: response.data.furnizori_total || 0,
|
furnizori_total: data.furnizori_total || 0,
|
||||||
breakdown: response.data.breakdown || {
|
breakdown: data.breakdown || {
|
||||||
clienti: {
|
clienti: {
|
||||||
total: 0,
|
total: 0,
|
||||||
in_termen: { 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:", {
|
console.log("[NetBalance] Loaded balance data:", {
|
||||||
clienti_total: netBalanceData.value.clienti_total,
|
clienti_total: netBalanceData.value.clienti_total,
|
||||||
furnizori_total: netBalanceData.value.furnizori_total,
|
furnizori_total: netBalanceData.value.furnizori_total,
|
||||||
@@ -1029,9 +1050,32 @@ const loadDashboardData = async () => {
|
|||||||
if (!companyStore.selectedCompany) return;
|
if (!companyStore.selectedCompany) return;
|
||||||
isLoading.value = true;
|
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 luna = periodStore.selectedPeriod?.luna || null;
|
||||||
const an = periodStore.selectedPeriod?.an || 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 {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
dashboardStore.loadDashboardSummary(
|
dashboardStore.loadDashboardSummary(
|
||||||
@@ -1044,11 +1088,11 @@ const loadDashboardData = async () => {
|
|||||||
loadMonthlyFlows(),
|
loadMonthlyFlows(),
|
||||||
loadTreasuryBreakdown(),
|
loadTreasuryBreakdown(),
|
||||||
loadNetBalanceBreakdown(),
|
loadNetBalanceBreakdown(),
|
||||||
// US-014: Load financial indicators for desktop card
|
// US-014: Load financial indicators for desktop card (luna anterioară)
|
||||||
dashboardStore.loadFinancialIndicators(
|
dashboardStore.loadFinancialIndicators(
|
||||||
companyStore.selectedCompany.id_firma,
|
companyStore.selectedCompany.id_firma,
|
||||||
luna,
|
indicatorLuna,
|
||||||
an,
|
indicatorAn,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1449,14 +1493,6 @@ onUnmounted(() => {
|
|||||||
padding: 0 var(--space-md);
|
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 Cards Layout - Component-specific grid layouts */
|
||||||
.metrics-row {
|
.metrics-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
123
src/shared/components/CacheFooter.vue
Normal file
123
src/shared/components/CacheFooter.vue
Normal 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>
|
||||||
58
src/shared/utils/authRedirect.js
Normal file
58
src/shared/utils/authRedirect.js
Normal 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
|
||||||
|
}
|
||||||
213
tasks/prd-dashboard-desktop-cleanup.md
Normal file
213
tasks/prd-dashboard-desktop-cleanup.md
Normal 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`)
|
||||||
Reference in New Issue
Block a user