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'),
|
||||
description="Cheltuieli financiare (Clasa 66 - dobânzi, diferențe curs)"
|
||||
)
|
||||
capital_social_strict: Decimal = Field(
|
||||
default=Decimal('0'),
|
||||
description="Capital Social strict (doar contul 101 - subscris și vărsat)"
|
||||
)
|
||||
cifra_afaceri: Decimal = Field(
|
||||
default=Decimal('0'),
|
||||
description="Cifra de afaceri (doar 70x FĂRĂ TVA, fără 71x-75x)"
|
||||
)
|
||||
achizitii_stocuri: Decimal = Field(
|
||||
default=Decimal('0'),
|
||||
description="Achiziții stocuri YTD (Clasa 3 TOTDEB, FĂRĂ TVA)"
|
||||
)
|
||||
|
||||
# Computed properties pentru calculele ulterioare
|
||||
@property
|
||||
@@ -220,6 +232,12 @@ class LiquidityIndicators(BaseModel):
|
||||
lichiditate_vedere: Cash Ratio = disponibilități / datorii_curente
|
||||
- Măsoară capacitatea de plată imediată doar din numerar
|
||||
- Good: >= 0.2, Warning: 0.1-0.2, Danger: < 0.1
|
||||
|
||||
Sub-indicatori pentru verificare:
|
||||
- active_curente: Active Curente = Stocuri + Creanțe + Disponibilități
|
||||
- disponibilitati: Disponibilități (bancă + casă)
|
||||
- creante: Creanțe comerciale
|
||||
- datorii_curente: Datorii pe termen scurt
|
||||
"""
|
||||
lichiditate_curenta: IndicatorResult = Field(
|
||||
description="Current Ratio = active_curente / datorii_curente"
|
||||
@@ -230,6 +248,23 @@ class LiquidityIndicators(BaseModel):
|
||||
lichiditate_vedere: IndicatorResult = Field(
|
||||
description="Cash Ratio = disponibilități / datorii_curente"
|
||||
)
|
||||
# Sub-indicatori pentru verificare manuală în balanță
|
||||
active_curente: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Active Curente = Stocuri + Creanțe + Disponibilități (RON)"
|
||||
)
|
||||
disponibilitati: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Disponibilități = Bancă (512x) + Casă (531x) (RON)"
|
||||
)
|
||||
creante: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Creanțe comerciale = Clienți (411x) + Debitori (461x) (RON)"
|
||||
)
|
||||
datorii_curente: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Datorii pe termen scurt = Furnizori + TVA + Salarii etc. (RON)"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
@@ -301,6 +336,31 @@ class EfficiencyIndicators(BaseModel):
|
||||
rata_plata: IndicatorResult = Field(
|
||||
description="Rata de plată = plati / achizitii * 100"
|
||||
)
|
||||
# Sub-indicatori pentru verificare manuală
|
||||
sold_clienti: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Sold Clienți la final de lună (RON)"
|
||||
)
|
||||
facturari_lunare: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Total facturări în luna curentă (RON)"
|
||||
)
|
||||
sold_furnizori: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Sold Furnizori la final de lună (RON)"
|
||||
)
|
||||
achizitii_lunare: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Total achiziții în luna curentă (RON)"
|
||||
)
|
||||
incasari_luna: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Încasări efectuate în luna curentă (RON)"
|
||||
)
|
||||
plati_luna: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Plăți efectuate în luna curentă (RON)"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
@@ -379,6 +439,31 @@ class RiskIndicators(BaseModel):
|
||||
raport_datorii_trezorerie: IndicatorResult = Field(
|
||||
description="Raport datorii/trezorerie = furnizori_sold_total / trezorerie"
|
||||
)
|
||||
# Sub-indicatori pentru verificare manuală
|
||||
total_clienti: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Sold total clienți (411x) (RON)"
|
||||
)
|
||||
clienti_restanti: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Sold clienți cu facturi restante (RON)"
|
||||
)
|
||||
clienti_90plus: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Sold clienți cu facturi >90 zile restant (RON)"
|
||||
)
|
||||
total_furnizori: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Sold total furnizori (401x) (RON)"
|
||||
)
|
||||
furnizori_restanti: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Sold furnizori cu facturi restante (RON)"
|
||||
)
|
||||
trezorerie: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Disponibilități (512x + 531x) (RON)"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
@@ -447,6 +532,23 @@ class CashFlowIndicators(BaseModel):
|
||||
acoperire_cash_flow: IndicatorResult = Field(
|
||||
description="Acoperire datorii = cash_flow_ytd / datorii_restante"
|
||||
)
|
||||
# Sub-indicatori pentru verificare manuală
|
||||
incasari_luna: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Încasări luna curentă (RON)"
|
||||
)
|
||||
plati_luna: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Plăți luna curentă (RON)"
|
||||
)
|
||||
cf_an_precedent: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Cash Flow aceeași perioadă an precedent (RON)"
|
||||
)
|
||||
datorii_restante: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Total datorii cu scadență depășită (RON)"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
@@ -488,19 +590,24 @@ class DynamicsIndicators(BaseModel):
|
||||
|
||||
Arată dacă afacerea crește sau scade prin comparație YoY (Year-over-Year).
|
||||
|
||||
SURSE DE DATE (toate FĂRĂ TVA):
|
||||
- Vânzări: Cifra de Afaceri din VBAL (Clasa 7 - conturile 70x)
|
||||
- Achiziții: Registru Jurnal ACT (stocuri 3x=4x + cheltuieli directe 6x=4x)
|
||||
|
||||
Attributes:
|
||||
crestere_vanzari_yoy: Creșterea procentuală a vânzărilor față de anul anterior
|
||||
- Formula: (facturari_curent - facturari_anterior) / facturari_anterior * 100
|
||||
- Măsoară dinamica vânzărilor - creștere sau scădere
|
||||
crestere_vanzari_yoy: Creșterea procentuală a Cifrei de Afaceri față de anul anterior
|
||||
- Formula: (CA_curent - CA_anterior) / CA_anterior * 100
|
||||
- Sursa: VBAL TOTCRED(70x) - TOTDEB(709)
|
||||
- Good: > 5%, Warning: 0-5%, Danger: < 0%
|
||||
|
||||
crestere_achizitii_yoy: Creșterea procentuală a achizițiilor față de anul anterior
|
||||
crestere_achizitii_yoy: Creșterea procentuală a achizițiilor totale față de anul anterior
|
||||
- Formula: (achizitii_curent - achizitii_anterior) / achizitii_anterior * 100
|
||||
- Sursa: ACT (stocuri 3x=4x + cheltuieli directe 6x=4x, fără TVA)
|
||||
- Creșterea achizițiilor poate indica expansiune sau costuri mai mari
|
||||
|
||||
marja_implicita: Marja implicită din diferența facturări - achiziții
|
||||
- Formula: (facturari - achizitii) / facturari * 100
|
||||
- Arată ce procent din vânzări rămâne după achiziții
|
||||
marja_implicita: Marja implicită din diferența CA - achiziții totale
|
||||
- Formula: (CA - achizitii_totale) / CA * 100
|
||||
- Arată ce procent din Cifra de Afaceri rămâne după achiziții
|
||||
- Good: > 20%, Warning: 10-20%, Danger: < 10%
|
||||
"""
|
||||
crestere_vanzari_yoy: IndicatorResult = Field(
|
||||
@@ -512,6 +619,23 @@ class DynamicsIndicators(BaseModel):
|
||||
marja_implicita: IndicatorResult = Field(
|
||||
description="Marja implicită = (facturari - achizitii) / facturari * 100"
|
||||
)
|
||||
# Sub-indicatori pentru verificare manuală
|
||||
vanzari_an_curent: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Total vânzări YTD an curent (RON)"
|
||||
)
|
||||
vanzari_an_precedent: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Total vânzări YTD an precedent (RON)"
|
||||
)
|
||||
achizitii_an_curent: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Total achiziții YTD an curent (stocuri + cheltuieli directe, fără TVA)"
|
||||
)
|
||||
achizitii_an_precedent: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Total achiziții YTD an precedent (stocuri + cheltuieli directe, fără TVA)"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
@@ -713,6 +837,11 @@ class ProfitabilityIndicators(BaseModel):
|
||||
profit_brut: IndicatorResult = Field(
|
||||
description="Profit brut (EBIT) = Venituri - Cheltuieli operaționale"
|
||||
)
|
||||
# Sub-indicator pentru verificare EBIT
|
||||
venituri: Optional[IndicatorResult] = Field(
|
||||
default=None,
|
||||
description="Total venituri (Clasa 7) - pentru verificare calcul EBIT (RON)"
|
||||
)
|
||||
marja_profit_brut: IndicatorResult = Field(
|
||||
description="Marja de profit = Profit brut / Cifra afaceri * 100"
|
||||
)
|
||||
@@ -776,6 +905,91 @@ class ProfitabilityIndicators(BaseModel):
|
||||
}
|
||||
|
||||
|
||||
class SolvabilityIndicators(BaseModel):
|
||||
"""
|
||||
Indicatori de solvabilitate pentru evaluarea capacității firmei
|
||||
de a-și acoperi datoriile pe termen lung.
|
||||
|
||||
Conform articolului UniversulFiscal despre Activul Net Contabil (ANC):
|
||||
- ANC = Total Active - Total Datorii
|
||||
- Implicații legale (din 1 ianuarie 2026): Sub 50% din capital social
|
||||
→ restricții dividende, restituire împrumuturi, acordare împrumuturi noi
|
||||
|
||||
Attributes:
|
||||
activ_net_contabil: Activul Net Contabil (ANC) în RON
|
||||
- Formula: Total Active - Total Datorii
|
||||
- Good: > 0 (firma are avere netă pozitivă)
|
||||
- Danger: <= 0 (firma este insolvabilă tehnic)
|
||||
|
||||
rata_anc_capital: Rata ANC / Capital Social în %
|
||||
- Formula: (ANC / Capital Social) × 100
|
||||
- Good: >= 100% (ANC acoperă integral capitalul social)
|
||||
- Warning: 50-100% (ANC sub capital, dar peste pragul legal)
|
||||
- Danger: < 50% (sub pragul legal - restricții aplicabile)
|
||||
|
||||
total_active: Total Active - valoare de verificare
|
||||
total_datorii: Total Datorii - valoare de verificare
|
||||
capital_social: Capital Social - valoare de verificare
|
||||
"""
|
||||
activ_net_contabil: IndicatorResult = Field(
|
||||
description="Activ Net Contabil = Total Active - Total Datorii (RON)"
|
||||
)
|
||||
rata_anc_capital: IndicatorResult = Field(
|
||||
description="Rata ANC/Capital = (ANC / Capital Social) × 100 (%)"
|
||||
)
|
||||
# Valori de bază pentru verificare manuală în balanță
|
||||
total_active: IndicatorResult = Field(
|
||||
description="Total Active - bază calcul ANC"
|
||||
)
|
||||
total_datorii: IndicatorResult = Field(
|
||||
description="Total Datorii - bază calcul ANC"
|
||||
)
|
||||
capital_social: IndicatorResult = Field(
|
||||
description="Capital Social - bază calcul Rata ANC"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"activ_net_contabil": {
|
||||
"value": 850000.00,
|
||||
"status": "good",
|
||||
"threshold_min": 0,
|
||||
"threshold_max": None,
|
||||
"message": "Activ net pozitiv - firma solvabilă"
|
||||
},
|
||||
"rata_anc_capital": {
|
||||
"value": 125.5,
|
||||
"status": "good",
|
||||
"threshold_min": 100.0,
|
||||
"threshold_max": None,
|
||||
"message": "ANC peste capitalul social - situație sănătoasă"
|
||||
},
|
||||
"total_active": {
|
||||
"value": 1800000.00,
|
||||
"status": "good",
|
||||
"threshold_min": None,
|
||||
"threshold_max": None,
|
||||
"message": "Active Imobilizate + Active Curente"
|
||||
},
|
||||
"total_datorii": {
|
||||
"value": 950000.00,
|
||||
"status": "good",
|
||||
"threshold_min": None,
|
||||
"threshold_max": None,
|
||||
"message": "Datorii Curente + Datorii Termen Lung"
|
||||
},
|
||||
"capital_social": {
|
||||
"value": 680000.00,
|
||||
"status": "good",
|
||||
"threshold_min": None,
|
||||
"threshold_max": None,
|
||||
"message": "Capital subscris și vărsat"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class FinancialIndicatorsResponse(BaseModel):
|
||||
"""
|
||||
Răspunsul complet al endpoint-ului /api/reports/dashboard/financial-indicators.
|
||||
@@ -791,6 +1005,7 @@ class FinancialIndicatorsResponse(BaseModel):
|
||||
dinamica: Indicatori de dinamică (creștere vânzări/achiziții YoY, marjă)
|
||||
altman_zscore: Scorul Altman Z-Score și componentele X1-X4
|
||||
profitabilitate: Indicatori de profitabilitate (ROA, ROE, marjă profit)
|
||||
solvabilitate: Indicatori de solvabilitate (ANC, rata ANC/Capital Social)
|
||||
|
||||
Usage:
|
||||
GET /api/reports/dashboard/financial-indicators?company=123&luna=12&an=2024
|
||||
@@ -803,7 +1018,8 @@ class FinancialIndicatorsResponse(BaseModel):
|
||||
"cash_flow": { ... },
|
||||
"dinamica": { ... },
|
||||
"altman_zscore": { ... },
|
||||
"profitabilitate": { ... }
|
||||
"profitabilitate": { ... },
|
||||
"solvabilitate": { ... }
|
||||
}
|
||||
"""
|
||||
lichiditate: LiquidityIndicators = Field(
|
||||
@@ -827,6 +1043,9 @@ class FinancialIndicatorsResponse(BaseModel):
|
||||
profitabilitate: ProfitabilityIndicators = Field(
|
||||
description="Indicatori de profitabilitate: ROA, ROE, marja de profit"
|
||||
)
|
||||
solvabilitate: SolvabilityIndicators = Field(
|
||||
description="Indicatori de solvabilitate: ANC, rata ANC/Capital Social"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
@@ -868,6 +1087,13 @@ class FinancialIndicatorsResponse(BaseModel):
|
||||
"x4": {"value": 1.80, "status": "good"},
|
||||
"working_capital": 450000.00,
|
||||
"total_assets": 1800000.00
|
||||
},
|
||||
"solvabilitate": {
|
||||
"activ_net_contabil": {"value": 850000.00, "status": "good", "threshold_min": 0},
|
||||
"rata_anc_capital": {"value": 125.5, "status": "good", "threshold_min": 100.0},
|
||||
"total_active": {"value": 1800000.00, "status": "good"},
|
||||
"total_datorii": {"value": 950000.00, "status": "good"},
|
||||
"capital_social": {"value": 680000.00, "status": "good"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from typing import Optional
|
||||
# import sys # Removed - no longer needed
|
||||
import os
|
||||
|
||||
from shared.auth.dependencies import get_current_user
|
||||
@@ -290,6 +289,7 @@ async def get_maturity_analysis(
|
||||
|
||||
@router.get("/monthly-flows")
|
||||
async def get_monthly_flows(
|
||||
request: Request,
|
||||
company: int = Query(..., description="ID-ul firmei"),
|
||||
luna: Optional[int] = Query(None, ge=1, le=12, description="Luna contabilă (1-12)"),
|
||||
an: Optional[int] = Query(None, ge=2000, le=2100, description="Anul contabil"),
|
||||
@@ -301,15 +301,31 @@ async def get_monthly_flows(
|
||||
- Necesită autentificare JWT
|
||||
- Returnează date pentru analiza fluxurilor lunare
|
||||
- luna/an: perioada contabilă de referință (dacă nu sunt specificate, folosește ultima perioadă)
|
||||
- Include metadata cache pentru Telegram Bot (X-Include-Cache-Metadata header)
|
||||
"""
|
||||
try:
|
||||
# Verifică dacă utilizatorul are acces la firma specificată
|
||||
if str(company) not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
result = await DashboardService.get_monthly_flows(company, luna=luna, an=an)
|
||||
return result
|
||||
|
||||
# Apelăm serviciul cu request pentru cache metadata
|
||||
result = await DashboardService.get_monthly_flows(company, luna=luna, an=an, request=request)
|
||||
|
||||
# Convert to dict if needed
|
||||
result_dict = result.dict() if hasattr(result, 'dict') else result
|
||||
|
||||
# Add cache metadata if requested (for Telegram Bot / Dashboard)
|
||||
include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true'
|
||||
if include_metadata:
|
||||
cache_hit = getattr(request.state, 'cache_hit', False)
|
||||
response_time = getattr(request.state, 'response_time_ms', 0)
|
||||
cache_source = getattr(request.state, 'cache_source', None)
|
||||
result_dict['cache_hit'] = cache_hit
|
||||
result_dict['response_time_ms'] = response_time
|
||||
result_dict['cache_source'] = cache_source
|
||||
|
||||
return result_dict
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -435,7 +451,6 @@ async def get_current_period(
|
||||
|
||||
@router.get(
|
||||
"/financial-indicators",
|
||||
response_model=FinancialIndicatorsResponse,
|
||||
tags=["dashboard"]
|
||||
)
|
||||
async def get_financial_indicators(
|
||||
@@ -445,7 +460,7 @@ async def get_financial_indicators(
|
||||
an: Optional[int] = Query(None, ge=2000, le=2100, description="Anul contabil"),
|
||||
include_sparklines: bool = Query(True, description="Include date istorice pentru sparklines (12 luni)"),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> FinancialIndicatorsResponse:
|
||||
):
|
||||
"""
|
||||
Returnează toți indicatorii financiari calculați pentru firma selectată.
|
||||
|
||||
@@ -504,15 +519,25 @@ async def get_financial_indicators(
|
||||
# Dacă include_sparklines este True, folosim metoda care include datele istorice
|
||||
if include_sparklines:
|
||||
response = await FinancialIndicatorsService.get_indicators_with_sparklines(
|
||||
company, resolved_luna, resolved_an, months=12
|
||||
company, resolved_luna, resolved_an, months=12, request=request
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Financial indicators with sparklines for company {company}, "
|
||||
f"luna={resolved_luna}, an={resolved_an}: "
|
||||
f"Z-Score={response.altman_zscore.zscore.value} ({response.altman_zscore.zscore.status})"
|
||||
f"Z-Score={response.altman_zscore.zscore.value} ({response.altman_zscore.zscore.status}), "
|
||||
f"cache_hit={getattr(request.state, 'cache_hit', False)}, "
|
||||
f"response_time={getattr(request.state, 'response_time_ms', 0):.1f}ms"
|
||||
)
|
||||
|
||||
# Add cache metadata if requested (for Telegram Bot / Dashboard)
|
||||
include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true'
|
||||
if include_metadata:
|
||||
result_dict = response.dict() if hasattr(response, 'dict') else response
|
||||
result_dict['cache_hit'] = getattr(request.state, 'cache_hit', False)
|
||||
result_dict['response_time_ms'] = getattr(request.state, 'response_time_ms', 0)
|
||||
result_dict['cache_source'] = getattr(request.state, 'cache_source', None)
|
||||
return result_dict
|
||||
return response
|
||||
|
||||
# Dacă include_sparklines este False, calculăm doar indicatorii curenți
|
||||
@@ -537,6 +562,12 @@ async def get_financial_indicators(
|
||||
altman_task = FinancialIndicatorsService.calculate_altman_zscore(
|
||||
company, resolved_luna, resolved_an
|
||||
)
|
||||
profitabilitate_task = FinancialIndicatorsService.calculate_profitability_indicators(
|
||||
company, resolved_luna, resolved_an
|
||||
)
|
||||
solvabilitate_task = FinancialIndicatorsService.calculate_solvability_indicators(
|
||||
company, resolved_luna, resolved_an
|
||||
)
|
||||
|
||||
# Executăm toate calculele în paralel pentru performanță
|
||||
(
|
||||
@@ -545,14 +576,18 @@ async def get_financial_indicators(
|
||||
risc,
|
||||
cash_flow,
|
||||
dinamica,
|
||||
altman_zscore
|
||||
altman_zscore,
|
||||
profitabilitate,
|
||||
solvabilitate
|
||||
) = await asyncio.gather(
|
||||
lichiditate_task,
|
||||
eficienta_task,
|
||||
risc_task,
|
||||
cash_flow_task,
|
||||
dinamica_task,
|
||||
altman_task
|
||||
altman_task,
|
||||
profitabilitate_task,
|
||||
solvabilitate_task
|
||||
)
|
||||
|
||||
# Construim răspunsul
|
||||
@@ -562,7 +597,9 @@ async def get_financial_indicators(
|
||||
risc=risc,
|
||||
cash_flow=cash_flow,
|
||||
dinamica=dinamica,
|
||||
altman_zscore=altman_zscore
|
||||
altman_zscore=altman_zscore,
|
||||
profitabilitate=profitabilitate,
|
||||
solvabilitate=solvabilitate
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -570,6 +607,14 @@ async def get_financial_indicators(
|
||||
f"Z-Score={altman_zscore.zscore.value} ({altman_zscore.zscore.status})"
|
||||
)
|
||||
|
||||
# Add cache metadata if requested (for Telegram Bot / Dashboard)
|
||||
include_metadata = request.headers.get('X-Include-Cache-Metadata', '').lower() == 'true'
|
||||
if include_metadata:
|
||||
result_dict = response.dict() if hasattr(response, 'dict') else response
|
||||
result_dict['cache_hit'] = getattr(request.state, 'cache_hit', False)
|
||||
result_dict['response_time_ms'] = getattr(request.state, 'response_time_ms', 0)
|
||||
result_dict['cache_source'] = getattr(request.state, 'cache_source', None)
|
||||
return result_dict
|
||||
return response
|
||||
|
||||
except ValueError as e:
|
||||
|
||||
@@ -1546,14 +1546,16 @@ class DashboardService:
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
async def get_monthly_flows(company: int, luna: Optional[int] = None, an: Optional[int] = None) -> Dict[str, Any]:
|
||||
@cached(cache_type='monthly_flows', key_params=['company', 'luna', 'an'])
|
||||
async def get_monthly_flows(company: int, luna: Optional[int] = None, an: Optional[int] = None, request: Optional[Request] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Obține fluxurile lunare de intrare și ieșire pentru luna curentă
|
||||
Obține fluxurile lunare de intrare și ieșire pentru luna curentă (CACHED 30 min)
|
||||
|
||||
Args:
|
||||
company: ID-ul firmei
|
||||
luna: Luna contabilă (1-12), opțional
|
||||
an: Anul contabil, opțional
|
||||
request: Request object pentru cache metadata
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection() as connection:
|
||||
|
||||
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 { menuSections } from '@/config/menu.js'
|
||||
import { getEnabledMenuSections } from '@/config/features.js'
|
||||
import { handleUnauthorized, isAuthRedirectInProgress } from '@/shared/utils/authRedirect'
|
||||
import axios from 'axios'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -68,6 +69,14 @@ const authApi = axios.create({
|
||||
|
||||
// Add interceptor to inject auth token from localStorage
|
||||
authApi.interceptors.request.use(config => {
|
||||
// Skip requests if we're already redirecting to login
|
||||
if (isAuthRedirectInProgress()) {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
config.signal = controller.signal
|
||||
return config
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
@@ -75,6 +84,18 @@ authApi.interceptors.request.use(config => {
|
||||
return config
|
||||
})
|
||||
|
||||
// Response interceptor to handle 401 errors
|
||||
authApi.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
// Use shared handler to prevent race conditions
|
||||
handleUnauthorized()
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Store definitions (factories return store definitions)
|
||||
const useAuthStore = createAuthStore(authApi)
|
||||
const useCompanyStore = createCompaniesStore(authApi, useAuthStore)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import { handleUnauthorized, isAuthRedirectInProgress } from '@/shared/utils/authRedirect'
|
||||
|
||||
// Use relative path - works with both Vite dev proxy and IIS production proxy
|
||||
const baseURL = import.meta.env.BASE_URL + 'api/data-entry'
|
||||
@@ -10,6 +11,14 @@ const api = axios.create({
|
||||
|
||||
// Request interceptor for auth token and company header
|
||||
api.interceptors.request.use((config) => {
|
||||
// Skip requests if we're already redirecting to login
|
||||
if (isAuthRedirectInProgress()) {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
config.signal = controller.signal
|
||||
return config
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
@@ -57,18 +66,18 @@ api.interceptors.response.use(
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
console.error('❌ API Error:', {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
code: error.code,
|
||||
message: error.message
|
||||
})
|
||||
// Skip logging for aborted requests (happens during auth redirect)
|
||||
if (error.code !== 'ERR_CANCELED') {
|
||||
console.error('❌ API Error:', {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
code: error.code,
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
if (error.response?.status === 401) {
|
||||
// Token expired or invalid - redirect to login
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('user')
|
||||
window.location.href = import.meta.env.BASE_URL + 'login'
|
||||
// Use shared handler to prevent race conditions
|
||||
handleUnauthorized()
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
@@ -22,8 +22,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dual sparkline charts - stacked vertical -->
|
||||
<div class="sparkline-dual-container" v-if="hasSparklineData">
|
||||
<!-- Charts toggle header -->
|
||||
<div
|
||||
v-if="hasSparklineData"
|
||||
class="charts-toggle-header"
|
||||
@click="toggleChartsExpanded"
|
||||
>
|
||||
<span>Grafice evoluție</span>
|
||||
<i
|
||||
class="pi pi-chevron-right"
|
||||
:class="{ expanded: chartsExpanded }"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<!-- Dual sparkline charts - stacked vertical (collapsible) -->
|
||||
<div v-show="chartsExpanded" class="charts-content">
|
||||
<div class="sparkline-dual-container" v-if="hasSparklineData">
|
||||
<!-- Grafic Încasări -->
|
||||
<div class="sparkline-wrapper">
|
||||
<div class="sparkline-title text-success">Încasări</div>
|
||||
@@ -40,6 +54,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Footer -->
|
||||
<CacheFooter
|
||||
:cache-hit="cacheInfo?.hit"
|
||||
:response-time-ms="cacheInfo?.time"
|
||||
:cache-source="cacheInfo?.source"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -53,6 +75,7 @@ import {
|
||||
nextTick,
|
||||
} from "vue";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
import CacheFooter from "@/shared/components/CacheFooter.vue";
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
@@ -97,6 +120,10 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
cacheInfo: {
|
||||
type: Object,
|
||||
default: () => ({ hit: false, time: 0, source: null }),
|
||||
},
|
||||
});
|
||||
|
||||
// Refs pentru 2 canvas-uri separate
|
||||
@@ -105,6 +132,13 @@ const outflowsCanvas = ref(null);
|
||||
let inflowsChartInstance = null;
|
||||
let outflowsChartInstance = null;
|
||||
|
||||
// Charts collapsible state
|
||||
const chartsExpanded = ref(false);
|
||||
|
||||
const toggleChartsExpanded = () => {
|
||||
chartsExpanded.value = !chartsExpanded.value;
|
||||
};
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount && amount !== 0) return "0 RON";
|
||||
@@ -607,6 +641,39 @@ onBeforeUnmount(() => {
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* Charts toggle header */
|
||||
.charts-toggle-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin-top: var(--space-sm);
|
||||
background: var(--surface-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text-secondary);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.charts-toggle-header:hover {
|
||||
background: var(--surface-border);
|
||||
}
|
||||
|
||||
.charts-toggle-header i {
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.charts-toggle-header i.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Charts content wrapper */
|
||||
.charts-content {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Dual sparkline container (unique to this card) */
|
||||
.sparkline-dual-container {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
<template>
|
||||
<div class="metric-card clienti-balance-card">
|
||||
<!-- Main value section -->
|
||||
<div class="value-section">
|
||||
<div class="metric-label">Clienți</div>
|
||||
<div class="metric-value" :class="getBalanceClass(total)">
|
||||
{{ formatCurrency(total) }}
|
||||
<!-- Header cu total și trend -->
|
||||
<div class="card-header-mobile">
|
||||
<div class="header-left">
|
||||
<span class="header-dot clienti"></span>
|
||||
<span class="header-label">Clienți</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="value-trend trend-indicator"
|
||||
:class="getTrendClass(trend)"
|
||||
v-if="trend"
|
||||
>
|
||||
<span class="trend-icon">{{ getTrendIcon(trend) }}</span>
|
||||
<span class="trend-value">{{ Math.round(Math.abs(trend.value)) }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- Sparkline chart -->
|
||||
<div class="metric-sparkline" v-if="hasSparklineData">
|
||||
<div class="sparkline-chart">
|
||||
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
|
||||
<div class="header-values">
|
||||
<span class="header-total" :class="getBalanceClass(total)">
|
||||
{{ formatCurrency(total) }}
|
||||
</span>
|
||||
<div
|
||||
class="header-trend"
|
||||
:class="getTrendClass(trend)"
|
||||
v-if="trend"
|
||||
>
|
||||
<i :class="getTrendIconClass(trend)"></i>
|
||||
<span>{{ Math.round(Math.abs(trend.value)) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,6 +59,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts toggle header -->
|
||||
<div
|
||||
v-if="hasSparklineData"
|
||||
class="charts-toggle-header"
|
||||
@click="toggleChartsExpanded"
|
||||
>
|
||||
<span>Grafice evoluție</span>
|
||||
<i
|
||||
class="pi pi-chevron-right"
|
||||
:class="{ expanded: chartsExpanded }"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<!-- Sparkline chart (collapsible) -->
|
||||
<div v-show="chartsExpanded" class="charts-content">
|
||||
<div class="metric-sparkline" v-if="hasSparklineData">
|
||||
<div class="sparkline-chart">
|
||||
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Footer -->
|
||||
<CacheFooter
|
||||
:cache-hit="cacheInfo?.hit"
|
||||
:response-time-ms="cacheInfo?.time"
|
||||
:cache-source="cacheInfo?.source"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -74,6 +101,7 @@ import {
|
||||
nextTick,
|
||||
} from "vue";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
import CacheFooter from "@/shared/components/CacheFooter.vue";
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
@@ -106,18 +134,27 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
cacheInfo: {
|
||||
type: Object,
|
||||
default: () => ({ hit: false, time: 0, source: null }),
|
||||
},
|
||||
});
|
||||
|
||||
// Refs
|
||||
const chartCanvas = ref(null);
|
||||
let chartInstance = null;
|
||||
const isRestantExpanded = ref(false);
|
||||
const chartsExpanded = ref(false);
|
||||
|
||||
// Toggle functions
|
||||
const toggleRestantExpanded = () => {
|
||||
isRestantExpanded.value = !isRestantExpanded.value;
|
||||
};
|
||||
|
||||
const toggleChartsExpanded = () => {
|
||||
chartsExpanded.value = !chartsExpanded.value;
|
||||
};
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount && amount !== 0) return "0 RON";
|
||||
@@ -159,7 +196,7 @@ const getTrendClass = (trend) => {
|
||||
};
|
||||
};
|
||||
|
||||
// Trend icon
|
||||
// Trend icon (text version)
|
||||
const getTrendIcon = (trend) => {
|
||||
if (!trend) return "";
|
||||
switch (trend.direction) {
|
||||
@@ -174,6 +211,21 @@ const getTrendIcon = (trend) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Trend icon class (PrimeIcons version)
|
||||
const getTrendIconClass = (trend) => {
|
||||
if (!trend) return "pi pi-minus";
|
||||
switch (trend.direction) {
|
||||
case "up":
|
||||
return "pi pi-arrow-up";
|
||||
case "down":
|
||||
return "pi pi-arrow-down";
|
||||
case "neutral":
|
||||
return "pi pi-minus";
|
||||
default:
|
||||
return "pi pi-minus";
|
||||
}
|
||||
};
|
||||
|
||||
// Check if sparkline data exists
|
||||
const hasSparklineData = computed(() => {
|
||||
return props.sparklineData && props.sparklineData.length > 0;
|
||||
@@ -414,29 +466,114 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* Override min-height for balance card */
|
||||
.clienti-balance-card {
|
||||
min-height: 320px;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
/* Value section: horizontal layout */
|
||||
.value-section {
|
||||
/* Mobile header with total and trend */
|
||||
.card-header-mobile {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: var(--space-sm) 0;
|
||||
margin-bottom: var(--space-sm);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
/* Color classes for positive/negative/neutral (component-specific logic) */
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.header-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.header-dot.clienti {
|
||||
background: var(--green-500);
|
||||
}
|
||||
|
||||
.header-label {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.header-values {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.header-total {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.header-total.positive {
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.header-total.negative {
|
||||
color: var(--red-600);
|
||||
}
|
||||
|
||||
.header-total.neutral {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.header-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.header-trend i {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Color classes for positive/negative/neutral */
|
||||
.positive {
|
||||
color: var(--color-success);
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: var(--color-error);
|
||||
color: var(--red-600);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
color: var(--color-text);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Trend colors */
|
||||
.trend-up {
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.trend-down {
|
||||
color: var(--red-600);
|
||||
}
|
||||
|
||||
.trend-neutral {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
[data-theme="dark"] .header-total.positive,
|
||||
[data-theme="dark"] .positive,
|
||||
[data-theme="dark"] .trend-up {
|
||||
color: var(--green-400);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .header-total.negative,
|
||||
[data-theme="dark"] .negative,
|
||||
[data-theme="dark"] .trend-down {
|
||||
color: var(--red-400);
|
||||
}
|
||||
|
||||
/* Sparkline chart dimensions */
|
||||
@@ -453,6 +590,39 @@ onBeforeUnmount(() => {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Charts toggle header */
|
||||
.charts-toggle-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin-top: var(--space-sm);
|
||||
background: var(--surface-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text-secondary);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.charts-toggle-header:hover {
|
||||
background: var(--surface-border);
|
||||
}
|
||||
|
||||
.charts-toggle-header i {
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.charts-toggle-header i.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Charts content wrapper */
|
||||
.charts-content {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.clienti-balance-card {
|
||||
|
||||
@@ -1,49 +1,65 @@
|
||||
<template>
|
||||
<div class="balance-dual-card">
|
||||
<!-- Header -->
|
||||
<!-- Header cu sold net -->
|
||||
<div class="card-header">
|
||||
<span class="card-icon">💰</span>
|
||||
<span class="card-title">SOLD CLIENȚI / FURNIZORI</span>
|
||||
<div class="header-left">
|
||||
<span class="card-icon">💰</span>
|
||||
<span class="card-title">SOLD CLIENȚI / FURNIZORI</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="header-total" :class="getBalanceClass(netBalance)">
|
||||
{{ formatCurrency(netBalance) }}
|
||||
</span>
|
||||
<div
|
||||
class="header-trend"
|
||||
:class="getTrendClass(netTrend)"
|
||||
v-if="netTrend"
|
||||
>
|
||||
<i :class="getTrendIconClass(netTrend)"></i>
|
||||
<span>{{ Math.round(Math.abs(netTrend.value)) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main values section - Split layout -->
|
||||
<div class="values-section">
|
||||
<!-- Clienți Section -->
|
||||
<div class="value-block clienti">
|
||||
<div class="value-label">Clienți</div>
|
||||
<div class="value-amount" :class="getBalanceClass(clientiTotal)">
|
||||
{{ formatCurrency(clientiTotal) }}
|
||||
<!-- Detailed values section - Clienți și Furnizori -->
|
||||
<div class="balance-items">
|
||||
<!-- Clienți -->
|
||||
<div class="balance-row">
|
||||
<div class="balance-label">
|
||||
<span class="balance-dot clienti"></span>
|
||||
Clienți
|
||||
</div>
|
||||
<div
|
||||
class="value-trend"
|
||||
:class="getTrendClass(clientiTrend)"
|
||||
v-if="clientiTrend"
|
||||
>
|
||||
<span class="trend-icon">{{ getTrendIcon(clientiTrend) }}</span>
|
||||
<span class="trend-value"
|
||||
>{{ Math.round(Math.abs(clientiTrend.value)) }}%</span
|
||||
<div class="balance-values">
|
||||
<span class="balance-amount" :class="getBalanceClass(clientiTotal)">
|
||||
{{ formatCurrency(clientiTotal) }}
|
||||
</span>
|
||||
<span
|
||||
class="balance-trend"
|
||||
:class="getTrendClass(clientiTrend)"
|
||||
v-if="clientiTrend"
|
||||
>
|
||||
{{ getTrendIcon(clientiTrend) }}{{ Math.round(Math.abs(clientiTrend.value)) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Furnizori Section -->
|
||||
<div class="value-block furnizori">
|
||||
<div class="value-label">Furnizori</div>
|
||||
<div class="value-amount" :class="getBalanceClass(furnizoriTotal)">
|
||||
{{ formatCurrency(furnizoriTotal) }}
|
||||
<!-- Furnizori -->
|
||||
<div class="balance-row">
|
||||
<div class="balance-label">
|
||||
<span class="balance-dot furnizori"></span>
|
||||
Furnizori
|
||||
</div>
|
||||
<div
|
||||
class="value-trend"
|
||||
:class="getTrendClass(furnizoriTrend)"
|
||||
v-if="furnizoriTrend"
|
||||
>
|
||||
<span class="trend-icon">{{ getTrendIcon(furnizoriTrend) }}</span>
|
||||
<span class="trend-value"
|
||||
>{{ Math.round(Math.abs(furnizoriTrend.value)) }}%</span
|
||||
<div class="balance-values">
|
||||
<span class="balance-amount" :class="getBalanceClass(-furnizoriTotal)">
|
||||
{{ formatCurrency(furnizoriTotal) }}
|
||||
</span>
|
||||
<span
|
||||
class="balance-trend"
|
||||
:class="getTrendClass(furnizoriTrend)"
|
||||
v-if="furnizoriTrend"
|
||||
>
|
||||
{{ getTrendIcon(furnizoriTrend) }}{{ Math.round(Math.abs(furnizoriTrend.value)) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,7 +316,7 @@ const getTrendClass = (trend) => {
|
||||
};
|
||||
};
|
||||
|
||||
// Trend icon
|
||||
// Trend icon (text version)
|
||||
const getTrendIcon = (trend) => {
|
||||
if (!trend) return "";
|
||||
switch (trend.direction) {
|
||||
@@ -315,6 +331,43 @@ const getTrendIcon = (trend) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Trend icon class (PrimeIcons version)
|
||||
const getTrendIconClass = (trend) => {
|
||||
if (!trend) return "pi pi-minus";
|
||||
switch (trend.direction) {
|
||||
case "up":
|
||||
return "pi pi-arrow-up";
|
||||
case "down":
|
||||
return "pi pi-arrow-down";
|
||||
case "neutral":
|
||||
return "pi pi-minus";
|
||||
default:
|
||||
return "pi pi-minus";
|
||||
}
|
||||
};
|
||||
|
||||
// Computed: Net balance (Clienți - Furnizori)
|
||||
const netBalance = computed(() => {
|
||||
return props.clientiTotal - props.furnizoriTotal;
|
||||
});
|
||||
|
||||
// Computed: Net trend (average of both trends or dominant)
|
||||
const netTrend = computed(() => {
|
||||
if (!props.clientiTrend && !props.furnizoriTrend) return null;
|
||||
|
||||
// Use clienti trend as primary if available
|
||||
if (props.clientiTrend && !props.furnizoriTrend) return props.clientiTrend;
|
||||
if (!props.clientiTrend && props.furnizoriTrend) return props.furnizoriTrend;
|
||||
|
||||
// Calculate combined trend based on net balance change
|
||||
const avgValue = (props.clientiTrend.value - props.furnizoriTrend.value) / 2;
|
||||
let direction = "neutral";
|
||||
if (avgValue > 2) direction = "up";
|
||||
else if (avgValue < -2) direction = "down";
|
||||
|
||||
return { value: Math.abs(avgValue), direction };
|
||||
});
|
||||
|
||||
// Check if sparkline data exists
|
||||
const hasSparklineData = computed(() => {
|
||||
return (
|
||||
@@ -647,12 +700,20 @@ onBeforeUnmount(() => {
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
/* Header cu sold net */
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
@@ -662,87 +723,147 @@ onBeforeUnmount(() => {
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background: var(--color-bg-secondary, #f8fafc);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: var(--surface-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
font-weight: var(--font-semibold, 600);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-color-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Values section - Split layout */
|
||||
.values-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.value-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.value-label {
|
||||
font-size: var(--text-xs, 0.75rem);
|
||||
font-weight: var(--font-medium, 500);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.value-amount {
|
||||
font-size: var(--text-xl, 1.5rem);
|
||||
font-weight: var(--font-bold, 700);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.value-amount.positive {
|
||||
color: var(--color-success, #10b981);
|
||||
}
|
||||
|
||||
.value-amount.negative {
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.value-amount.neutral {
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.value-trend {
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: var(--text-sm, 0.875rem);
|
||||
font-weight: var(--font-medium, 500);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.header-total {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.header-total.positive {
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.header-total.negative {
|
||||
color: var(--red-600);
|
||||
}
|
||||
|
||||
.header-total.neutral {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.header-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.header-trend i {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Balance items - Clienți și Furnizori */
|
||||
.balance-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) 0;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.balance-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.balance-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.balance-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.balance-dot.clienti {
|
||||
background: var(--green-500);
|
||||
}
|
||||
|
||||
.balance-dot.furnizori {
|
||||
background: var(--red-500);
|
||||
}
|
||||
|
||||
.balance-values {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.balance-amount {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.balance-amount.positive {
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.balance-amount.negative {
|
||||
color: var(--red-600);
|
||||
}
|
||||
|
||||
.balance-amount.neutral {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.balance-trend {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
/* Trend colors */
|
||||
.trend-up {
|
||||
color: var(--color-success, #10b981);
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.trend-down {
|
||||
color: var(--color-danger, #ef4444);
|
||||
color: var(--red-600);
|
||||
}
|
||||
|
||||
.trend-neutral {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.trend-icon {
|
||||
font-size: 0.75rem;
|
||||
/* Dark mode */
|
||||
[data-theme="dark"] .header-total.positive,
|
||||
[data-theme="dark"] .balance-amount.positive,
|
||||
[data-theme="dark"] .trend-up {
|
||||
color: var(--green-400);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: var(--color-border, #e5e7eb);
|
||||
min-height: 60px;
|
||||
[data-theme="dark"] .header-total.negative,
|
||||
[data-theme="dark"] .balance-amount.negative,
|
||||
[data-theme="dark"] .trend-down {
|
||||
color: var(--red-400);
|
||||
}
|
||||
|
||||
/* Dual sparkline container - stack vertical */
|
||||
@@ -955,23 +1076,27 @@ onBeforeUnmount(() => {
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.balance-dual-card {
|
||||
min-height: 380px;
|
||||
padding: var(--space-md, 1rem);
|
||||
min-height: 320px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.values-section {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.divider {
|
||||
.header-right {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
min-height: 1px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.value-amount {
|
||||
font-size: var(--text-lg, 1.125rem);
|
||||
.header-total {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.balance-amount {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
@@ -981,15 +1106,15 @@ onBeforeUnmount(() => {
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.balance-dual-card {
|
||||
min-height: 340px;
|
||||
padding: 0.5rem 0.25rem;
|
||||
gap: 0.5rem;
|
||||
min-height: 280px;
|
||||
padding: var(--space-sm);
|
||||
gap: var(--space-sm);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
padding: var(--space-xs);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
@@ -999,11 +1124,15 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.625rem;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.value-amount {
|
||||
font-size: var(--text-base, 1rem);
|
||||
.header-total {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.balance-amount {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
@@ -1014,10 +1143,6 @@ onBeforeUnmount(() => {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.values-section {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,23 @@
|
||||
<template>
|
||||
<div class="metric-card furnizori-balance-card">
|
||||
<!-- Main value section -->
|
||||
<div class="value-section">
|
||||
<div class="metric-label">Furnizori</div>
|
||||
<div class="metric-value" :class="getBalanceClass(total)">
|
||||
{{ formatCurrency(total) }}
|
||||
<!-- Header cu total și trend -->
|
||||
<div class="card-header-mobile">
|
||||
<div class="header-left">
|
||||
<span class="header-dot furnizori"></span>
|
||||
<span class="header-label">Furnizori</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="value-trend trend-indicator"
|
||||
:class="getTrendClass(trend)"
|
||||
v-if="trend"
|
||||
>
|
||||
<span class="trend-icon">{{ getTrendIcon(trend) }}</span>
|
||||
<span class="trend-value">{{ Math.round(Math.abs(trend.value)) }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- Sparkline chart -->
|
||||
<div class="metric-sparkline" v-if="hasSparklineData">
|
||||
<div class="sparkline-chart">
|
||||
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
|
||||
<div class="header-values">
|
||||
<span class="header-total" :class="getBalanceClass(total)">
|
||||
{{ formatCurrency(total) }}
|
||||
</span>
|
||||
<div
|
||||
class="header-trend"
|
||||
:class="getTrendClass(trend)"
|
||||
v-if="trend"
|
||||
>
|
||||
<i :class="getTrendIconClass(trend)"></i>
|
||||
<span>{{ Math.round(Math.abs(trend.value)) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,6 +59,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts toggle header -->
|
||||
<div
|
||||
v-if="hasSparklineData"
|
||||
class="charts-toggle-header"
|
||||
@click="toggleChartsExpanded"
|
||||
>
|
||||
<span>Grafice evoluție</span>
|
||||
<i
|
||||
class="pi pi-chevron-right"
|
||||
:class="{ expanded: chartsExpanded }"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<!-- Sparkline chart (collapsible) -->
|
||||
<div v-show="chartsExpanded" class="charts-content">
|
||||
<div class="metric-sparkline" v-if="hasSparklineData">
|
||||
<div class="sparkline-chart">
|
||||
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Footer -->
|
||||
<CacheFooter
|
||||
:cache-hit="cacheInfo?.hit"
|
||||
:response-time-ms="cacheInfo?.time"
|
||||
:cache-source="cacheInfo?.source"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -74,6 +101,7 @@ import {
|
||||
nextTick,
|
||||
} from "vue";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
import CacheFooter from "@/shared/components/CacheFooter.vue";
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
@@ -106,18 +134,27 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
cacheInfo: {
|
||||
type: Object,
|
||||
default: () => ({ hit: false, time: 0, source: null }),
|
||||
},
|
||||
});
|
||||
|
||||
// Refs
|
||||
const chartCanvas = ref(null);
|
||||
let chartInstance = null;
|
||||
const isRestantExpanded = ref(false);
|
||||
const chartsExpanded = ref(false);
|
||||
|
||||
// Toggle functions
|
||||
const toggleRestantExpanded = () => {
|
||||
isRestantExpanded.value = !isRestantExpanded.value;
|
||||
};
|
||||
|
||||
const toggleChartsExpanded = () => {
|
||||
chartsExpanded.value = !chartsExpanded.value;
|
||||
};
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount && amount !== 0) return "0 RON";
|
||||
@@ -159,7 +196,7 @@ const getTrendClass = (trend) => {
|
||||
};
|
||||
};
|
||||
|
||||
// Trend icon
|
||||
// Trend icon (text version)
|
||||
const getTrendIcon = (trend) => {
|
||||
if (!trend) return "";
|
||||
switch (trend.direction) {
|
||||
@@ -174,6 +211,21 @@ const getTrendIcon = (trend) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Trend icon class (PrimeIcons version)
|
||||
const getTrendIconClass = (trend) => {
|
||||
if (!trend) return "pi pi-minus";
|
||||
switch (trend.direction) {
|
||||
case "up":
|
||||
return "pi pi-arrow-up";
|
||||
case "down":
|
||||
return "pi pi-arrow-down";
|
||||
case "neutral":
|
||||
return "pi pi-minus";
|
||||
default:
|
||||
return "pi pi-minus";
|
||||
}
|
||||
};
|
||||
|
||||
// Check if sparkline data exists
|
||||
const hasSparklineData = computed(() => {
|
||||
return props.sparklineData && props.sparklineData.length > 0;
|
||||
@@ -414,29 +466,114 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* Override min-height for balance card */
|
||||
.furnizori-balance-card {
|
||||
min-height: 320px;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
/* Value section: horizontal layout */
|
||||
.value-section {
|
||||
/* Mobile header with total and trend */
|
||||
.card-header-mobile {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: var(--space-sm) 0;
|
||||
margin-bottom: var(--space-sm);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
/* Color classes for positive/negative/neutral (component-specific logic) */
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.header-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.header-dot.furnizori {
|
||||
background: var(--red-500);
|
||||
}
|
||||
|
||||
.header-label {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.header-values {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.header-total {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.header-total.positive {
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.header-total.negative {
|
||||
color: var(--red-600);
|
||||
}
|
||||
|
||||
.header-total.neutral {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.header-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.header-trend i {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Color classes for positive/negative/neutral */
|
||||
.positive {
|
||||
color: var(--color-success);
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: var(--color-error);
|
||||
color: var(--red-600);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
color: var(--color-text);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Trend colors */
|
||||
.trend-up {
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.trend-down {
|
||||
color: var(--red-600);
|
||||
}
|
||||
|
||||
.trend-neutral {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
[data-theme="dark"] .header-total.positive,
|
||||
[data-theme="dark"] .positive,
|
||||
[data-theme="dark"] .trend-up {
|
||||
color: var(--green-400);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .header-total.negative,
|
||||
[data-theme="dark"] .negative,
|
||||
[data-theme="dark"] .trend-down {
|
||||
color: var(--red-400);
|
||||
}
|
||||
|
||||
/* Sparkline chart dimensions */
|
||||
@@ -453,6 +590,39 @@ onBeforeUnmount(() => {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Charts toggle header */
|
||||
.charts-toggle-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin-top: var(--space-sm);
|
||||
background: var(--surface-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text-secondary);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.charts-toggle-header:hover {
|
||||
background: var(--surface-border);
|
||||
}
|
||||
|
||||
.charts-toggle-header i {
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.charts-toggle-header i.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Charts content wrapper */
|
||||
.charts-content {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.furnizori-balance-card {
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
<template>
|
||||
<div class="indicator-item" :class="statusClass">
|
||||
<!-- Label (top) -->
|
||||
<div class="indicator-label">{{ label }}</div>
|
||||
<!-- Label (top) cu toggle pentru descriere -->
|
||||
<div class="indicator-label">
|
||||
{{ label }}
|
||||
<i
|
||||
v-if="description"
|
||||
class="pi desc-toggle"
|
||||
:class="descExpanded ? 'pi-chevron-up' : 'pi-chevron-down'"
|
||||
@click.stop="toggleDescription"
|
||||
title="Toggle descriere"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<!-- Description (optional) -->
|
||||
<div v-if="description" class="indicator-description">{{ description }}</div>
|
||||
<!-- Description (collapsible) -->
|
||||
<div v-if="description && descExpanded" class="indicator-description slide-down">
|
||||
{{ description }}
|
||||
</div>
|
||||
|
||||
<!-- Main content: Value centered + Status icon on right -->
|
||||
<div class="indicator-main">
|
||||
@@ -62,6 +73,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- YoY Trend indicator (shows variation from first to last sparkline value) -->
|
||||
<div
|
||||
v-if="hasSparklineData && trendInfo.text !== '-'"
|
||||
class="yoy-trend"
|
||||
:class="trendInfo.class"
|
||||
>
|
||||
<i :class="trendInfo.icon"></i>
|
||||
<span class="trend-value">{{ trendInfo.text }}</span>
|
||||
<span class="trend-label">vs 12 luni</span>
|
||||
</div>
|
||||
|
||||
<!-- Threshold info -->
|
||||
<div v-if="thresholdText" class="indicator-threshold">
|
||||
{{ thresholdText }}
|
||||
@@ -70,7 +92,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// Description toggle state
|
||||
const descExpanded = ref(false)
|
||||
|
||||
const toggleDescription = () => {
|
||||
descExpanded.value = !descExpanded.value
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
@@ -264,6 +293,39 @@ const thresholdText = computed(() => {
|
||||
return parts.join(' | ')
|
||||
})
|
||||
|
||||
// Computed: YoY Trend information (comparing first and last sparkline values)
|
||||
const trendInfo = computed(() => {
|
||||
if (!props.sparklineData || props.sparklineData.length < 2) {
|
||||
return { text: '-', icon: 'pi pi-minus', class: 'trend-neutral' }
|
||||
}
|
||||
|
||||
const validData = props.sparklineData.filter(v => v !== null && v !== undefined)
|
||||
if (validData.length < 2) {
|
||||
return { text: '-', icon: 'pi pi-minus', class: 'trend-neutral' }
|
||||
}
|
||||
|
||||
const first = validData[0]
|
||||
const last = validData[validData.length - 1]
|
||||
|
||||
// Handle division by zero
|
||||
if (first === 0) {
|
||||
if (last > 0) return { text: '+∞', icon: 'pi pi-arrow-up', class: 'trend-up' }
|
||||
if (last < 0) return { text: '-∞', icon: 'pi pi-arrow-down', class: 'trend-down' }
|
||||
return { text: '0%', icon: 'pi pi-minus', class: 'trend-neutral' }
|
||||
}
|
||||
|
||||
const change = ((last - first) / Math.abs(first)) * 100
|
||||
const sign = change > 0 ? '+' : ''
|
||||
const text = `${sign}${change.toFixed(1)}%`
|
||||
|
||||
if (change > 0) {
|
||||
return { text, icon: 'pi pi-arrow-up', class: 'trend-up' }
|
||||
} else if (change < 0) {
|
||||
return { text, icon: 'pi pi-arrow-down', class: 'trend-down' }
|
||||
}
|
||||
return { text: '0%', icon: 'pi pi-minus', class: 'trend-neutral' }
|
||||
})
|
||||
|
||||
// Methods
|
||||
const handleMouseMove = (event) => {
|
||||
if (!pointPositions.value.length || !sparklineContainer.value) return
|
||||
@@ -312,22 +374,62 @@ const handleMouseLeave = () => {
|
||||
border-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
/* Label (top) */
|
||||
/* Label (top) with toggle */
|
||||
.indicator-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-color-secondary);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Description (optional subtitle) */
|
||||
/* Description toggle icon */
|
||||
.desc-toggle {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.desc-toggle:hover {
|
||||
color: var(--primary-color);
|
||||
opacity: 1;
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
/* Description (collapsible) */
|
||||
.indicator-description {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-color-secondary);
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
line-height: 1.3;
|
||||
margin-top: calc(var(--space-xs) * -1);
|
||||
padding: var(--space-xs);
|
||||
background: var(--surface-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Slide down animation */
|
||||
.slide-down {
|
||||
animation: slideDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Main section: Value + Status Icon */
|
||||
@@ -450,6 +552,72 @@ const handleMouseLeave = () => {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* YoY Trend indicator */
|
||||
.yoy-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
margin-top: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.yoy-trend i {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.yoy-trend .trend-value {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: var(--font-semibold);
|
||||
}
|
||||
|
||||
.yoy-trend .trend-label {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: var(--text-2xs);
|
||||
}
|
||||
|
||||
/* Trend colors */
|
||||
.yoy-trend.trend-up {
|
||||
color: var(--green-600);
|
||||
background: var(--green-50);
|
||||
}
|
||||
|
||||
.yoy-trend.trend-down {
|
||||
color: var(--red-600);
|
||||
background: var(--red-50);
|
||||
}
|
||||
|
||||
.yoy-trend.trend-neutral {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Dark mode for YoY trend */
|
||||
[data-theme="dark"] .yoy-trend.trend-up {
|
||||
color: var(--green-400);
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .yoy-trend.trend-down {
|
||||
color: var(--red-400);
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) .yoy-trend.trend-up {
|
||||
color: var(--green-400);
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
:root:not([data-theme]) .yoy-trend.trend-down {
|
||||
color: var(--red-400);
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* Threshold info */
|
||||
.indicator-threshold {
|
||||
font-size: var(--text-xs);
|
||||
|
||||
@@ -1,29 +1,81 @@
|
||||
<template>
|
||||
<div class="metric-card treasury-dual-card">
|
||||
<!-- Main values section - Split layout (Casa | Bancă) -->
|
||||
<div class="values-section">
|
||||
<!-- Casa Section -->
|
||||
<div class="value-block casa">
|
||||
<div class="metric-label">Casa</div>
|
||||
<div class="metric-value text-success">
|
||||
{{ formatCurrency(casaTotal) }}
|
||||
<!-- Treasury items - Casa și Bancă stacked vertical cu expand individual -->
|
||||
<div class="treasury-items">
|
||||
<!-- Casa -->
|
||||
<div class="treasury-group" v-if="casaItems.length > 0 || casaTotal > 0">
|
||||
<div class="treasury-header" @click="toggleCasaExpanded">
|
||||
<div class="treasury-header-left">
|
||||
<i
|
||||
class="pi pi-chevron-right treasury-toggle"
|
||||
:class="{ expanded: isCasaExpanded }"
|
||||
></i>
|
||||
<span class="treasury-label">Casa</span>
|
||||
</div>
|
||||
<span class="treasury-value text-success">{{ formatCurrency(casaTotal) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Casa Sub-items -->
|
||||
<div v-show="isCasaExpanded && casaItems.length > 0" class="treasury-subitems slide-down">
|
||||
<div
|
||||
v-for="(item, idx) in casaItems"
|
||||
:key="idx"
|
||||
class="treasury-subitem"
|
||||
>
|
||||
<span class="treasury-sublabel">
|
||||
{{ item.nume || `Cont ${item.cont}` }}
|
||||
<span v-if="item.cont" class="treasury-cont">({{ item.cont }})</span>
|
||||
</span>
|
||||
<span class="treasury-subvalue">{{ formatCurrency(item.sold) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
<!-- Bancă -->
|
||||
<div class="treasury-group" v-if="bancaItems.length > 0 || bancaTotal > 0">
|
||||
<div class="treasury-header" @click="toggleBancaExpanded">
|
||||
<div class="treasury-header-left">
|
||||
<i
|
||||
class="pi pi-chevron-right treasury-toggle"
|
||||
:class="{ expanded: isBancaExpanded }"
|
||||
></i>
|
||||
<span class="treasury-label">Bancă</span>
|
||||
</div>
|
||||
<span class="treasury-value text-primary">{{ formatCurrency(bancaTotal) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Bancă Section -->
|
||||
<div class="value-block banca">
|
||||
<div class="metric-label">Bancă</div>
|
||||
<div class="metric-value text-primary">
|
||||
{{ formatCurrency(bancaTotal) }}
|
||||
<!-- Bancă Sub-items -->
|
||||
<div v-show="isBancaExpanded && bancaItems.length > 0" class="treasury-subitems slide-down">
|
||||
<div
|
||||
v-for="(item, idx) in bancaItems"
|
||||
:key="idx"
|
||||
class="treasury-subitem"
|
||||
>
|
||||
<span class="treasury-sublabel">
|
||||
{{ item.nume || `Cont ${item.cont}` }}
|
||||
<span v-if="item.cont" class="treasury-cont">({{ item.cont }})</span>
|
||||
</span>
|
||||
<span class="treasury-subvalue">{{ formatCurrency(item.sold) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dual sparkline charts - stacked vertical -->
|
||||
<div class="sparkline-dual-container" v-if="hasSparklineData">
|
||||
<!-- Charts toggle header -->
|
||||
<div
|
||||
v-if="hasSparklineData"
|
||||
class="charts-toggle-header"
|
||||
@click="toggleChartsExpanded"
|
||||
>
|
||||
<span>Grafice evoluție</span>
|
||||
<i
|
||||
class="pi pi-chevron-right"
|
||||
:class="{ expanded: chartsExpanded }"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<!-- Dual sparkline charts - stacked vertical (at the end) -->
|
||||
<div v-show="chartsExpanded" class="charts-content sparkline-dual-container">
|
||||
<!-- Grafic Casa -->
|
||||
<div class="sparkline-wrapper">
|
||||
<div class="sparkline-title text-success">Casa</div>
|
||||
@@ -41,77 +93,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown section -->
|
||||
<div
|
||||
class="breakdown-section"
|
||||
v-if="casaItems.length > 0 || bancaItems.length > 0"
|
||||
>
|
||||
<!-- Casa Breakdown -->
|
||||
<div class="breakdown-group" v-if="casaItems.length > 0">
|
||||
<div class="breakdown-header" @click="toggleCasaExpanded">
|
||||
<div class="breakdown-header-left">
|
||||
<i
|
||||
class="pi pi-chevron-right breakdown-toggle"
|
||||
:class="{ expanded: isCasaExpanded }"
|
||||
></i>
|
||||
<span class="breakdown-label">Casa</span>
|
||||
</div>
|
||||
<span class="breakdown-value">{{ formatCurrency(casaTotal) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Casa Sub-items -->
|
||||
<div v-show="isCasaExpanded" class="breakdown-subitems slide-down">
|
||||
<div
|
||||
v-for="(item, idx) in casaItems"
|
||||
:key="idx"
|
||||
class="breakdown-subitem"
|
||||
>
|
||||
<span class="breakdown-sublabel">
|
||||
{{ item.nume || `Cont ${item.cont}` }}
|
||||
<span v-if="item.cont" class="breakdown-cont"
|
||||
>({{ item.cont }})</span
|
||||
>
|
||||
</span>
|
||||
<span class="breakdown-subvalue">{{
|
||||
formatCurrency(item.sold)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bancă Breakdown -->
|
||||
<div class="breakdown-group" v-if="bancaItems.length > 0">
|
||||
<div class="breakdown-header" @click="toggleBancaExpanded">
|
||||
<div class="breakdown-header-left">
|
||||
<i
|
||||
class="pi pi-chevron-right breakdown-toggle"
|
||||
:class="{ expanded: isBancaExpanded }"
|
||||
></i>
|
||||
<span class="breakdown-label">Bancă</span>
|
||||
</div>
|
||||
<span class="breakdown-value">{{ formatCurrency(bancaTotal) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Bancă Sub-items -->
|
||||
<div v-show="isBancaExpanded" class="breakdown-subitems slide-down">
|
||||
<div
|
||||
v-for="(item, idx) in bancaItems"
|
||||
:key="idx"
|
||||
class="breakdown-subitem"
|
||||
>
|
||||
<span class="breakdown-sublabel">
|
||||
{{ item.nume || `Cont ${item.cont}` }}
|
||||
<span v-if="item.cont" class="breakdown-cont"
|
||||
>({{ item.cont }})</span
|
||||
>
|
||||
</span>
|
||||
<span class="breakdown-subvalue">{{
|
||||
formatCurrency(item.sold)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Cache Footer -->
|
||||
<CacheFooter
|
||||
:cache-hit="cacheInfo?.hit"
|
||||
:response-time-ms="cacheInfo?.time"
|
||||
:cache-source="cacheInfo?.source"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -125,6 +112,7 @@ import {
|
||||
nextTick,
|
||||
} from "vue";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
import CacheFooter from "@/shared/components/CacheFooter.vue";
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
@@ -173,6 +161,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
cacheInfo: {
|
||||
type: Object,
|
||||
default: () => ({ hit: false, time: 0, source: null }),
|
||||
},
|
||||
});
|
||||
|
||||
// Refs pentru 2 canvas-uri separate
|
||||
@@ -182,6 +174,7 @@ let casaChartInstance = null;
|
||||
let bancaChartInstance = null;
|
||||
const isCasaExpanded = ref(false);
|
||||
const isBancaExpanded = ref(false);
|
||||
const chartsExpanded = ref(false);
|
||||
|
||||
// Toggle functions
|
||||
const toggleCasaExpanded = () => {
|
||||
@@ -192,6 +185,10 @@ const toggleBancaExpanded = () => {
|
||||
isBancaExpanded.value = !isBancaExpanded.value;
|
||||
};
|
||||
|
||||
const toggleChartsExpanded = () => {
|
||||
chartsExpanded.value = !chartsExpanded.value;
|
||||
};
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount && amount !== 0) return "0 RON";
|
||||
@@ -612,34 +609,101 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Component-specific: Dual-layout for TreasuryDualCard (Casa | Bancă) */
|
||||
/* Component-specific: TreasuryDualCard (Casa | Bancă) */
|
||||
|
||||
/* Override min-height for dual chart layout */
|
||||
/* Override min-height for treasury card */
|
||||
.treasury-dual-card {
|
||||
min-height: 420px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
/* Split layout: Casa | Divider | Bancă */
|
||||
.values-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.value-block {
|
||||
/* Treasury items container - stacked vertical */
|
||||
.treasury-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Treasury group (Casa sau Bancă) */
|
||||
.treasury-group {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Treasury header - clickable */
|
||||
.treasury-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: var(--space-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: var(--surface-ground);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: var(--color-border);
|
||||
min-height: 60px;
|
||||
.treasury-header:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.treasury-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.treasury-toggle {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: var(--text-xs);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.treasury-toggle.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.treasury-label {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.treasury-value {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
/* Treasury sub-items */
|
||||
.treasury-subitems {
|
||||
padding: var(--space-sm) var(--space-md) var(--space-md);
|
||||
background: var(--surface-card);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.treasury-subitem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.treasury-sublabel {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.treasury-cont {
|
||||
font-size: var(--text-xs);
|
||||
opacity: 0.7;
|
||||
margin-left: var(--space-xs);
|
||||
}
|
||||
|
||||
.treasury-subvalue {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Dual sparkline container (unique to this card) */
|
||||
@@ -672,28 +736,47 @@ onBeforeUnmount(() => {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Component-specific: Account number display in breakdown */
|
||||
.breakdown-cont {
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.7;
|
||||
margin-left: 0.25rem;
|
||||
/* Charts toggle header */
|
||||
.charts-toggle-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin-top: var(--space-sm);
|
||||
background: var(--surface-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text-secondary);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.charts-toggle-header:hover {
|
||||
background: var(--surface-border);
|
||||
}
|
||||
|
||||
.charts-toggle-header i {
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.charts-toggle-header i.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Charts content wrapper */
|
||||
.charts-content {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Responsive: Stack vertically on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.treasury-dual-card {
|
||||
min-height: 380px;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.values-section {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
min-height: 1px;
|
||||
.treasury-value {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
@@ -703,7 +786,15 @@ onBeforeUnmount(() => {
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.treasury-dual-card {
|
||||
min-height: 340px;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.treasury-header {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.treasury-value {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
@@ -714,9 +805,5 @@ onBeforeUnmount(() => {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.values-section {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import { handleUnauthorized, isAuthRedirectInProgress } from '@/shared/utils/authRedirect'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.BASE_URL + 'api/reports',
|
||||
@@ -7,6 +8,14 @@ const api = axios.create({
|
||||
|
||||
// Request interceptor for auth token
|
||||
api.interceptors.request.use((config) => {
|
||||
// Skip requests if we're already redirecting to login
|
||||
if (isAuthRedirectInProgress()) {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
config.signal = controller.signal
|
||||
return config
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
@@ -19,14 +28,30 @@ api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token expired or invalid - redirect to login
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('user')
|
||||
window.location.href = import.meta.env.BASE_URL + 'login'
|
||||
// Use shared handler to prevent race conditions
|
||||
handleUnauthorized()
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Helper for GET requests that include cache metadata
|
||||
* Returns response data with cache_hit, response_time_ms, cache_source fields
|
||||
*
|
||||
* @param {string} url - API endpoint path
|
||||
* @param {object} options - Axios request config (params, etc.)
|
||||
* @returns {Promise<object>} Response data with cache metadata
|
||||
*/
|
||||
export const getWithCacheInfo = async (url, options = {}) => {
|
||||
const response = await api.get(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Include-Cache-Metadata': 'true',
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import api from "@reports/services/api";
|
||||
import api, { getWithCacheInfo } from "@reports/services/api";
|
||||
|
||||
export const useDashboardStore = defineStore("dashboard", () => {
|
||||
// State existent
|
||||
@@ -20,6 +20,7 @@ export const useDashboardStore = defineStore("dashboard", () => {
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
cacheInfo: { hit: false, time: 0, source: null },
|
||||
});
|
||||
|
||||
// State pentru detailed data pagination
|
||||
@@ -499,14 +500,21 @@ export const useDashboardStore = defineStore("dashboard", () => {
|
||||
if (luna !== null) params.luna = luna;
|
||||
if (an !== null) params.an = an;
|
||||
|
||||
const response = await api.get("/dashboard/financial-indicators", {
|
||||
const data = await getWithCacheInfo("/dashboard/financial-indicators", {
|
||||
params,
|
||||
});
|
||||
|
||||
financialIndicators.value.data = response.data;
|
||||
financialIndicators.value.data = data;
|
||||
financialIndicators.value.loading = false;
|
||||
|
||||
return { success: true, data: response.data };
|
||||
// Extract cache metadata
|
||||
financialIndicators.value.cacheInfo = {
|
||||
hit: data.cache_hit || false,
|
||||
time: data.response_time_ms || 0,
|
||||
source: data.cache_source || null,
|
||||
};
|
||||
|
||||
return { success: true, data: data };
|
||||
} catch (err) {
|
||||
console.error("Failed to load financial indicators:", err);
|
||||
|
||||
@@ -524,6 +532,7 @@ export const useDashboardStore = defineStore("dashboard", () => {
|
||||
financialIndicators.value.error = errorMessage;
|
||||
financialIndicators.value.loading = false;
|
||||
financialIndicators.value.data = null;
|
||||
financialIndicators.value.cacheInfo = { hit: false, time: 0, source: null };
|
||||
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
@@ -557,6 +566,7 @@ export const useDashboardStore = defineStore("dashboard", () => {
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
cacheInfo: { hit: false, time: 0, source: null },
|
||||
};
|
||||
clearCache();
|
||||
};
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
:bancaPreviousSparklineData="bancaPreviousSparkline"
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
:cacheInfo="treasuryCacheInfo"
|
||||
/>
|
||||
</template>
|
||||
<!-- Page 3: CashFlowMetricCard (original graph card) -->
|
||||
@@ -98,6 +99,7 @@
|
||||
:outflowsPreviousSparkline="outflowsPreviousSparkline"
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
:cacheInfo="cashflowCacheInfo"
|
||||
/>
|
||||
</template>
|
||||
<!-- Page 4: ClientiBalanceCard (original graph card) -->
|
||||
@@ -110,6 +112,7 @@
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
:breakdown="netBalanceData?.breakdown?.clienti"
|
||||
:cacheInfo="netBalanceCacheInfo"
|
||||
/>
|
||||
</template>
|
||||
<!-- Page 5: FurnizoriBalanceCard (original graph card) -->
|
||||
@@ -122,6 +125,7 @@
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
:breakdown="netBalanceData?.breakdown?.furnizori"
|
||||
:cacheInfo="netBalanceCacheInfo"
|
||||
/>
|
||||
</template>
|
||||
<!-- Page 6: FinancialIndicatorsCard (US-015) -->
|
||||
@@ -130,44 +134,19 @@
|
||||
:loading="dashboardStore.financialIndicators.loading"
|
||||
:error="dashboardStore.financialIndicators.error"
|
||||
:data="dashboardStore.financialIndicators.data"
|
||||
:initial-period="periodStore.selectedPeriod"
|
||||
:initial-period="previousPeriodForIndicators"
|
||||
:cache-info="dashboardStore.financialIndicators.cacheInfo"
|
||||
mobile
|
||||
@period-change="handleFinancialIndicatorsPeriodChange"
|
||||
/>
|
||||
</template>
|
||||
</SwipeableCards>
|
||||
|
||||
<!-- US-2004: Desktop Solduri Section (sus, fără titlu) -->
|
||||
<div v-if="!isMobile" class="desktop-solduri-section">
|
||||
<SolduriCompactCard
|
||||
type="trezorerie"
|
||||
:total="totalTrezorerie"
|
||||
:casaTotal="treasuryData?.breakdown?.casa?.total || 0"
|
||||
:bancaTotal="treasuryData?.breakdown?.banca?.total || 0"
|
||||
:breakdown="treasuryData?.breakdown"
|
||||
/>
|
||||
<SolduriCompactCard
|
||||
type="clienti"
|
||||
:total="netBalanceData?.clienti_total || 0"
|
||||
:breakdown="netBalanceData?.breakdown?.clienti"
|
||||
/>
|
||||
<SolduriCompactCard
|
||||
type="furnizori"
|
||||
:total="netBalanceData?.furnizori_total || 0"
|
||||
:breakdown="netBalanceData?.breakdown?.furnizori"
|
||||
/>
|
||||
<SolduriCompactCard
|
||||
type="tva"
|
||||
:total="tvaTotal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Grid layout (carduri grafice originale) - collapsible by default -->
|
||||
<div v-if="!isMobile" class="metrics-row">
|
||||
<CollapsibleCard
|
||||
label="Trezorerie"
|
||||
:value="totalTrezorerie"
|
||||
icon="pi pi-wallet"
|
||||
:value-class="totalTrezorerie >= 0 ? 'positive' : 'negative'"
|
||||
>
|
||||
<TreasuryDualCard
|
||||
@@ -183,12 +162,12 @@
|
||||
:bancaPreviousSparklineData="bancaPreviousSparkline"
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
:cacheInfo="treasuryCacheInfo"
|
||||
/>
|
||||
</CollapsibleCard>
|
||||
<CollapsibleCard
|
||||
label="Cash Flow"
|
||||
:value="netCashFlow"
|
||||
icon="pi pi-arrows-h"
|
||||
:value-class="netCashFlow >= 0 ? 'positive' : 'negative'"
|
||||
>
|
||||
<CashFlowMetricCard
|
||||
@@ -202,12 +181,12 @@
|
||||
:outflowsPreviousSparkline="outflowsPreviousSparkline"
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
:cacheInfo="cashflowCacheInfo"
|
||||
/>
|
||||
</CollapsibleCard>
|
||||
<CollapsibleCard
|
||||
label="Clienți"
|
||||
:value="netBalanceData?.clienti_total || 0"
|
||||
icon="pi pi-users"
|
||||
:value-class="(netBalanceData?.clienti_total || 0) >= 0 ? 'positive' : 'negative'"
|
||||
>
|
||||
<ClientiBalanceCard
|
||||
@@ -218,12 +197,12 @@
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
:breakdown="netBalanceData?.breakdown?.clienti"
|
||||
:cacheInfo="netBalanceCacheInfo"
|
||||
/>
|
||||
</CollapsibleCard>
|
||||
<CollapsibleCard
|
||||
label="Furnizori"
|
||||
:value="netBalanceData?.furnizori_total || 0"
|
||||
icon="pi pi-truck"
|
||||
:value-class="(netBalanceData?.furnizori_total || 0) <= 0 ? 'positive' : 'negative'"
|
||||
>
|
||||
<FurnizoriBalanceCard
|
||||
@@ -234,6 +213,7 @@
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
:breakdown="netBalanceData?.breakdown?.furnizori"
|
||||
:cacheInfo="netBalanceCacheInfo"
|
||||
/>
|
||||
</CollapsibleCard>
|
||||
</div>
|
||||
@@ -246,7 +226,8 @@
|
||||
:loading="dashboardStore.financialIndicators.loading"
|
||||
:error="dashboardStore.financialIndicators.error"
|
||||
:data="dashboardStore.financialIndicators.data"
|
||||
:initial-period="periodStore.selectedPeriod"
|
||||
:initial-period="previousPeriodForIndicators"
|
||||
:cache-info="dashboardStore.financialIndicators.cacheInfo"
|
||||
@period-change="handleFinancialIndicatorsPeriodChange"
|
||||
/>
|
||||
</div>
|
||||
@@ -285,7 +266,7 @@ import MobileDrawerMenu from "@shared/components/mobile/MobileDrawerMenu.vue";
|
||||
import { useCompanyStore, useAuthStore } from "@reports/stores/sharedStores";
|
||||
import { useDashboardStore } from "@reports/stores/dashboard";
|
||||
import { useAccountingPeriodStore } from "@reports/stores/sharedStores";
|
||||
import api from "@reports/services/api";
|
||||
import api, { getWithCacheInfo } from "@reports/services/api";
|
||||
import {
|
||||
exportToExcel,
|
||||
exportToPDF,
|
||||
@@ -310,6 +291,11 @@ const monthlyOutflows = ref(0);
|
||||
const treasuryData = ref(null);
|
||||
const netBalanceData = ref(null);
|
||||
|
||||
// Cache info state for each card
|
||||
const treasuryCacheInfo = ref({ hit: false, time: 0, source: null });
|
||||
const netBalanceCacheInfo = ref({ hit: false, time: 0, source: null });
|
||||
const cashflowCacheInfo = ref({ hit: false, time: 0, source: null });
|
||||
|
||||
// New dashboard state
|
||||
const selectedPeriod = ref("12m");
|
||||
const selectedChartType = ref("line");
|
||||
@@ -693,6 +679,20 @@ const currentMonthLabel = computed(() => {
|
||||
return "Se încarcă...";
|
||||
});
|
||||
|
||||
// Computed property pentru luna anterioară - pentru indicatorii financiari
|
||||
// Luna curentă e în lucru, deci folosim luna anterioară pentru date finale
|
||||
const previousPeriodForIndicators = computed(() => {
|
||||
if (!periodStore.selectedPeriod) return null;
|
||||
|
||||
const { luna, an } = periodStore.selectedPeriod;
|
||||
|
||||
// Calculează luna anterioară cu rollover la decembrie anul anterior
|
||||
if (luna === 1) {
|
||||
return { luna: 12, an: an - 1 };
|
||||
}
|
||||
return { luna: luna - 1, an };
|
||||
});
|
||||
|
||||
// Methods
|
||||
const handleCompanyChanged = async (company) => {
|
||||
if (company) {
|
||||
@@ -949,7 +949,7 @@ const handleCompanySelect = async (event) => {
|
||||
};
|
||||
|
||||
// Fixed: Changed company_id to company parameter
|
||||
// Updated: Added luna/an from period selector
|
||||
// Updated: Added luna/an from period selector + cache info
|
||||
const loadMonthlyFlows = async () => {
|
||||
if (!companyStore.selectedCompany) return;
|
||||
|
||||
@@ -960,9 +960,16 @@ const loadMonthlyFlows = async () => {
|
||||
params.an = periodStore.selectedPeriod.an;
|
||||
}
|
||||
|
||||
const response = await api.get("/dashboard/monthly-flows", { params });
|
||||
monthlyInflows.value = response.data.inflows || 0;
|
||||
monthlyOutflows.value = response.data.outflows || 0;
|
||||
const data = await getWithCacheInfo("/dashboard/monthly-flows", { params });
|
||||
monthlyInflows.value = data.inflows || 0;
|
||||
monthlyOutflows.value = data.outflows || 0;
|
||||
|
||||
// Extract cache metadata
|
||||
cashflowCacheInfo.value = {
|
||||
hit: data.cache_hit || false,
|
||||
time: data.response_time_ms || 0,
|
||||
source: data.cache_source || null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to load monthly flows:", error);
|
||||
}
|
||||
@@ -978,8 +985,15 @@ const loadTreasuryBreakdown = async () => {
|
||||
params.an = periodStore.selectedPeriod.an;
|
||||
}
|
||||
|
||||
const response = await api.get("/dashboard/treasury-breakdown", { params });
|
||||
treasuryData.value = response.data;
|
||||
const data = await getWithCacheInfo("/dashboard/treasury-breakdown", { params });
|
||||
treasuryData.value = data;
|
||||
|
||||
// Extract cache metadata
|
||||
treasuryCacheInfo.value = {
|
||||
hit: data.cache_hit || false,
|
||||
time: data.response_time_ms || 0,
|
||||
source: data.cache_source || null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to load treasury breakdown:", error);
|
||||
}
|
||||
@@ -995,13 +1009,13 @@ const loadNetBalanceBreakdown = async () => {
|
||||
params.an = periodStore.selectedPeriod.an;
|
||||
}
|
||||
|
||||
const response = await api.get("/dashboard/net-balance-breakdown", { params });
|
||||
const data = await getWithCacheInfo("/dashboard/net-balance-breakdown", { params });
|
||||
|
||||
// Folosește direct datele structurate de la backend
|
||||
netBalanceData.value = {
|
||||
clienti_total: response.data.clienti_total || 0,
|
||||
furnizori_total: response.data.furnizori_total || 0,
|
||||
breakdown: response.data.breakdown || {
|
||||
clienti_total: data.clienti_total || 0,
|
||||
furnizori_total: data.furnizori_total || 0,
|
||||
breakdown: data.breakdown || {
|
||||
clienti: {
|
||||
total: 0,
|
||||
in_termen: { total: 0 },
|
||||
@@ -1015,6 +1029,13 @@ const loadNetBalanceBreakdown = async () => {
|
||||
},
|
||||
};
|
||||
|
||||
// Extract cache metadata
|
||||
netBalanceCacheInfo.value = {
|
||||
hit: data.cache_hit || false,
|
||||
time: data.response_time_ms || 0,
|
||||
source: data.cache_source || null,
|
||||
};
|
||||
|
||||
console.log("[NetBalance] Loaded balance data:", {
|
||||
clienti_total: netBalanceData.value.clienti_total,
|
||||
furnizori_total: netBalanceData.value.furnizori_total,
|
||||
@@ -1029,9 +1050,32 @@ const loadDashboardData = async () => {
|
||||
if (!companyStore.selectedCompany) return;
|
||||
isLoading.value = true;
|
||||
|
||||
// FIX: Reset state înainte de a încărca date noi
|
||||
// Previne afișarea datelor de la firma anterioară în timpul încărcării
|
||||
treasuryData.value = null;
|
||||
netBalanceData.value = null;
|
||||
monthlyInflows.value = 0;
|
||||
monthlyOutflows.value = 0;
|
||||
|
||||
// Reset cache info
|
||||
treasuryCacheInfo.value = { hit: false, time: 0, source: null };
|
||||
netBalanceCacheInfo.value = { hit: false, time: 0, source: null };
|
||||
cashflowCacheInfo.value = { hit: false, time: 0, source: null };
|
||||
|
||||
// Reset dashboard store financial indicators (afișează loading state imediat)
|
||||
dashboardStore.financialIndicators.loading = true;
|
||||
dashboardStore.financialIndicators.error = null;
|
||||
dashboardStore.financialIndicators.data = null;
|
||||
dashboardStore.financialIndicators.cacheInfo = { hit: false, time: 0, source: null };
|
||||
|
||||
const luna = periodStore.selectedPeriod?.luna || null;
|
||||
const an = periodStore.selectedPeriod?.an || null;
|
||||
|
||||
// Pentru indicatori financiari folosim luna anterioară (luna curentă e în lucru)
|
||||
const prevPeriod = previousPeriodForIndicators.value;
|
||||
const indicatorLuna = prevPeriod?.luna || null;
|
||||
const indicatorAn = prevPeriod?.an || null;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
dashboardStore.loadDashboardSummary(
|
||||
@@ -1044,11 +1088,11 @@ const loadDashboardData = async () => {
|
||||
loadMonthlyFlows(),
|
||||
loadTreasuryBreakdown(),
|
||||
loadNetBalanceBreakdown(),
|
||||
// US-014: Load financial indicators for desktop card
|
||||
// US-014: Load financial indicators for desktop card (luna anterioară)
|
||||
dashboardStore.loadFinancialIndicators(
|
||||
companyStore.selectedCompany.id_firma,
|
||||
luna,
|
||||
an,
|
||||
indicatorLuna,
|
||||
indicatorAn,
|
||||
),
|
||||
]);
|
||||
} catch (error) {
|
||||
@@ -1449,14 +1493,6 @@ onUnmounted(() => {
|
||||
padding: 0 var(--space-md);
|
||||
}
|
||||
|
||||
/* US-2004: Desktop Solduri Section - 2x2 grid (2 cards per row) */
|
||||
.desktop-solduri-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Metrics Cards Layout - Component-specific grid layouts */
|
||||
.metrics-row {
|
||||
display: grid;
|
||||
|
||||
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