diff --git a/.claude/handover-cleanup-roa2web.md b/.claude/handover-cleanup-roa2web.md new file mode 100644 index 0000000..7412c8e --- /dev/null +++ b/.claude/handover-cleanup-roa2web.md @@ -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 diff --git a/.claude/plans/immutable-chasing-flute.md b/.claude/plans/immutable-chasing-flute.md new file mode 100644 index 0000000..62ca59d --- /dev/null +++ b/.claude/plans/immutable-chasing-flute.md @@ -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.* diff --git a/.claude/rules/financial-indicators.md b/.claude/rules/financial-indicators.md new file mode 100644 index 0000000..3fef5ee --- /dev/null +++ b/.claude/rules/financial-indicators.md @@ -0,0 +1,96 @@ +# Claude Rules: Financial Indicators + + +## 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 diff --git a/.claude/sessions/2025-01-06_frontend-style-audit.md b/.claude/sessions/2025-01-06_frontend-style-audit.md deleted file mode 100644 index ff2757d..0000000 --- a/.claude/sessions/2025-01-06_frontend-style-audit.md +++ /dev/null @@ -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ă diff --git a/backend/modules/reports/models/financial_indicators.py b/backend/modules/reports/models/financial_indicators.py index c22491a..8908f35 100644 --- a/backend/modules/reports/models/financial_indicators.py +++ b/backend/modules/reports/models/financial_indicators.py @@ -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"} } } } diff --git a/backend/modules/reports/routers/dashboard.py b/backend/modules/reports/routers/dashboard.py index b088b61..53fdda1 100644 --- a/backend/modules/reports/routers/dashboard.py +++ b/backend/modules/reports/routers/dashboard.py @@ -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: diff --git a/backend/modules/reports/services/dashboard_service.py b/backend/modules/reports/services/dashboard_service.py index 00973bd..9e9b626 100644 --- a/backend/modules/reports/services/dashboard_service.py +++ b/backend/modules/reports/services/dashboard_service.py @@ -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: diff --git a/backend/modules/reports/services/financial_indicators_service.py b/backend/modules/reports/services/financial_indicators_service.py index 12acef5..6677046 100644 --- a/backend/modules/reports/services/financial_indicators_service.py +++ b/backend/modules/reports/services/financial_indicators_service.py @@ -9,6 +9,7 @@ import logging from decimal import Decimal from typing import Optional, Dict, List, Any +from fastapi import Request from shared.database.oracle_pool import oracle_pool from ..cache.decorators import cached from ..models.dashboard import DashboardSummary @@ -22,6 +23,7 @@ from ..models.financial_indicators import ( DynamicsIndicators, AltmanZScore, ProfitabilityIndicators, + SolvabilityIndicators, FinancialIndicatorsResponse ) @@ -110,6 +112,14 @@ ACCOUNT_GROUPS = { "credit": ["101", "104", "105", "106"] }, + # CAPITAL SOCIAL STRICT (doar contul 101) + # Folosit pentru calculul Rata ANC / Capital Social conform definiției legale stricte + # 101 - Capital social subscris și vărsat + "capital_social_strict": { + "debit": [], + "credit": ["101"] # Doar Capital Social subscris și vărsat + }, + # REZULTAT (Profit/Pierdere) # 107 - Rezultatul reportat # 117 - Rezultatul reportat (în unele versiuni PCG) @@ -147,7 +157,7 @@ ACCOUNT_GROUPS = { # 437 - Ajutor de șomaj (dacă creditoare) # 4411 - Impozit pe profit de plătit # 4423 - TVA de plată - # 4428 - TVA neexigibilă + # 4428 - TVA neexigibilă (NU ESTE DATORIE) # 444 - Impozit pe venituri salariale de plată # 446 - Alte impozite și taxe # 447 - Fonduri speciale @@ -157,7 +167,7 @@ ACCOUNT_GROUPS = { "datorii_curente": { "debit": [], "credit": ["401", "403", "404", "408", "419", "421", "423", "424", - "426", "427", "4311", "4371", "4411", "4423", "4428", + "426", "427", "4311", "4371", "4411", "4423", "444", "446", "447", "462", "509", "5191", "5192", "5198"] }, @@ -167,10 +177,14 @@ ACCOUNT_GROUPS = { # 72 - Venituri din producția de imobilizări # 74 - Venituri din subvenții de exploatare # 75 - Alte venituri din exploatare + # 709 - Reduceri comerciale acordate (se scade din venituri) + # IMPORTANT: Folosim TOTCRED/TOTDEB (YTD - de la începutul anului) pentru că + # conturile din clasa 7 se închid lunar în 121 (SOLD=0 după închidere) + # RULCRED/RULDEB ar da doar valoarea lunară, nu YTD! "venituri": { - "debit": [], - "credit": ["70", "701", "702", "703", "704", "705", "706", "707", "708", - "71", "72", "74", "75", "758"] + "tot_credit": ["70", "701", "702", "703", "704", "705", "706", "707", "708", + "71", "72", "74", "75", "758"], + "tot_debit": ["709"] # Reduceri acordate clienților - funcționează INVERS }, # CHELTUIELI OPERAȚIONALE (din Clasa 6, FĂRĂ 66x) - pentru calculul EBIT @@ -181,15 +195,19 @@ ACCOUNT_GROUPS = { # 64 - Cheltuieli cu personalul # 65 - Alte cheltuieli de exploatare # 68 - Amortizări și provizioane (adăugat) + # 609 - Reduceri comerciale primite (se scade din cheltuieli) + # IMPORTANT: Folosim TOTDEB/TOTCRED (YTD - de la începutul anului) pentru că + # conturile din clasa 6 se închid lunar în 121 (SOLD=0 după închidere) + # RULDEB/RULCRED ar da doar valoarea lunară, nu YTD! "cheltuieli_operationale": { - "debit": ["60", "601", "602", "603", "604", "605", "606", "607", "608", - "61", "611", "612", "613", "614", - "62", "621", "622", "623", "624", "625", "626", "627", "628", - "63", "635", - "64", "641", "642", "643", "644", "645", - "65", "654", "658", - "68", "681", "686"], - "credit": [] + "tot_debit": ["60", "601", "602", "603", "604", "605", "606", "607", "608", + "61", "611", "612", "613", "614", + "62", "621", "622", "623", "624", "625", "626", "627", "628", + "63", "635", + "64", "641", "642", "643", "644", "645", + "65", "654", "658", + "68", "681", "686"], + "tot_credit": ["609"] # Reduceri primite de la furnizori - funcționează INVERS }, # CHELTUIELI FINANCIARE (Clasa 66) - separat de cheltuieli operaționale @@ -200,9 +218,44 @@ ACCOUNT_GROUPS = { # 666 - Cheltuieli privind dobânzile # 667 - Cheltuieli privind sconturile acordate # 668 - Alte cheltuieli financiare + # IMPORTANT: Folosim TOTDEB (YTD - de la începutul anului) pentru că + # conturile din clasa 6 se închid lunar în 121 (SOLD=0 după închidere) + # RULDEB ar da doar valoarea lunară, nu YTD! "cheltuieli_financiare": { - "debit": ["66", "661", "663", "664", "665", "666", "667", "668"], - "credit": [] + "tot_debit": ["66", "661", "663", "664", "665", "666", "667", "668"] + }, + + # CIFRA DE AFACERI (doar conturile 70x - venituri din vânzări) + # Conform definiției oficiale, CA include DOAR venituri din vânzarea de bunuri și servicii + # NU include: + # - 71x (Variația stocurilor) + # - 72x (Venituri din producția de imobilizări) + # - 74x (Venituri din subvenții de exploatare) + # - 75x (Alte venituri din exploatare) + # - 76x (Venituri financiare) + # Formula: TOTCRED(701-708) - TOTDEB(709 reduceri comerciale acordate) + # IMPORTANT: Acesta este FĂRĂ TVA - TVA-ul merge în 4427, nu în 7xx + "cifra_afaceri": { + "tot_credit": ["701", "702", "703", "704", "705", "706", "707", "708"], + "tot_debit": ["709"] # Reduceri comerciale acordate (se scad) + }, + + # ACHIZIȚII STOCURI (Clasa 3 - intrări în stocuri FĂRĂ TVA) + # Când cumpărăm: Debit 301/371 = Credit 401 → 301/371 DEBIT este FĂRĂ TVA + # TVA-ul merge în 4426/4428, NU în contul de stoc + # Aceasta evită complexitatea TVA la încasare, facturi nesosită, etc. + # Include: materii prime (30x), materiale (31x, 32x), producție în curs (33x), + # produse finite (34x), stocuri la terți (35x), animale (36x), mărfuri (37x), ambalaje (38x) + "achizitii_stocuri": { + "tot_debit": ["30", "301", "302", "303", "308", + "31", "311", "312", + "32", "321", "322", "323", + "33", "331", "332", + "34", "341", "345", "346", "348", + "35", "351", "354", "356", "357", "358", + "36", "361", "368", + "37", "371", "378", + "38", "381", "388"] } } @@ -358,20 +411,52 @@ class FinancialIndicatorsService: ACCOUNT_GROUPS["datorii_curente"]["credit"], "SOLDCRED" )} as datorii_curente, - -- VENITURI (sold creditor - pentru calcul EBIT) + -- VENITURI YTD (TOTCRED - TOTDEB pentru 709) + -- Folosim TOTCRED/TOTDEB pentru valori cumulate de la începutul anului + ({FinancialIndicatorsService._build_aggregate_case( + ACCOUNT_GROUPS["venituri"]["tot_credit"], "TOTCRED" + )} - {FinancialIndicatorsService._build_aggregate_case( - ACCOUNT_GROUPS["venituri"]["credit"], "SOLDCRED" - )} as venituri, + ACCOUNT_GROUPS["venituri"]["tot_debit"], "TOTDEB" + )}) as venituri, - -- CHELTUIELI OPERAȚIONALE (sold debitor - pentru calcul EBIT) + -- CHELTUIELI OPERAȚIONALE YTD (TOTDEB - TOTCRED pentru 609) + -- Folosim TOTDEB/TOTCRED pentru valori cumulate de la începutul anului + ({FinancialIndicatorsService._build_aggregate_case( + ACCOUNT_GROUPS["cheltuieli_operationale"]["tot_debit"], "TOTDEB" + )} - {FinancialIndicatorsService._build_aggregate_case( - ACCOUNT_GROUPS["cheltuieli_operationale"]["debit"], "SOLDDEB" - )} as cheltuieli_operationale, + ACCOUNT_GROUPS["cheltuieli_operationale"]["tot_credit"], "TOTCRED" + )}) as cheltuieli_operationale, - -- CHELTUIELI FINANCIARE (sold debitor - Clasa 66) + -- CHELTUIELI FINANCIARE YTD (TOTDEB - Clasa 66) + -- Folosim TOTDEB pentru valori cumulate de la începutul anului {FinancialIndicatorsService._build_aggregate_case( - ACCOUNT_GROUPS["cheltuieli_financiare"]["debit"], "SOLDDEB" - )} as cheltuieli_financiare + ACCOUNT_GROUPS["cheltuieli_financiare"]["tot_debit"], "TOTDEB" + )} as cheltuieli_financiare, + + -- CAPITAL SOCIAL STRICT (sold creditor - doar contul 101) + -- Pentru calculul Rata ANC / Capital Social conform definiției legale stricte + {FinancialIndicatorsService._build_aggregate_case( + ACCOUNT_GROUPS["capital_social_strict"]["credit"], "SOLDCRED" + )} as capital_social_strict, + + -- CIFRA DE AFACERI YTD (doar 70x - venituri din vânzări, FĂRĂ TVA) + -- NU include: 71x variația stocurilor, 72x producție imobilizări, + -- 74x subvenții, 75x alte venituri + -- Formula: TOTCRED(701-708) - TOTDEB(709 reduceri comerciale acordate) + ({FinancialIndicatorsService._build_aggregate_case( + ACCOUNT_GROUPS["cifra_afaceri"]["tot_credit"], "TOTCRED" + )} - + {FinancialIndicatorsService._build_aggregate_case( + ACCOUNT_GROUPS["cifra_afaceri"]["tot_debit"], "TOTDEB" + )}) as cifra_afaceri, + + -- ACHIZIȚII STOCURI YTD (Clasa 3 TOTDEB - intrări în stocuri, FĂRĂ TVA) + -- TVA-ul merge în 4426/4428, nu în conturile de stoc + {FinancialIndicatorsService._build_aggregate_case( + ACCOUNT_GROUPS["achizitii_stocuri"]["tot_debit"], "TOTDEB" + )} as achizitii_stocuri FROM {schema}.VBAL WHERE AN = :an @@ -402,7 +487,10 @@ class FinancialIndicatorsService: datorii_curente=Decimal('0'), venituri=Decimal('0'), cheltuieli_operationale=Decimal('0'), - cheltuieli_financiare=Decimal('0') + cheltuieli_financiare=Decimal('0'), + capital_social_strict=Decimal('0'), + cifra_afaceri=Decimal('0'), + achizitii_stocuri=Decimal('0') ) # Construim modelul cu valorile agregate @@ -420,7 +508,10 @@ class FinancialIndicatorsService: datorii_curente=Decimal(str(row[7] or 0)), venituri=Decimal(str(row[8] or 0)), cheltuieli_operationale=Decimal(str(row[9] or 0)), - cheltuieli_financiare=Decimal(str(row[10] or 0)) + cheltuieli_financiare=Decimal(str(row[10] or 0)), + capital_social_strict=Decimal(str(row[11] or 0)), + cifra_afaceri=Decimal(str(row[12] or 0)), + achizitii_stocuri=Decimal(str(row[13] or 0)) ) logger.info( @@ -429,11 +520,184 @@ class FinancialIndicatorsService: f"active_imobilizate={aggregates.active_imobilizate}, " f"stocuri={aggregates.stocuri}, " f"disponibilitati={aggregates.disponibilitati}, " - f"datorii_curente={aggregates.datorii_curente}" + f"datorii_curente={aggregates.datorii_curente}, " + f"venituri={aggregates.venituri}, " + f"cheltuieli_op={aggregates.cheltuieli_operationale}, " + f"cifra_afaceri={aggregates.cifra_afaceri}, " + f"achizitii_stocuri={aggregates.achizitii_stocuri}" ) + # Warning: venituri/cheltuieli = 0 but balance sheet has data + # This typically means VBAL doesn't have Class 6/7 data for this period + # (accounting period may not be closed yet) + has_balance_data = ( + aggregates.active_imobilizate > 0 or + aggregates.disponibilitati > 0 or + aggregates.datorii_curente > 0 + ) + if has_balance_data and aggregates.venituri == 0 and aggregates.cheltuieli_operationale == 0: + logger.warning( + f"Company {company_id}, luna={luna}, an={an}: " + f"Balance sheet data exists but venituri/cheltuieli = 0. " + f"VBAL may not have Class 6/7 data for this period " + f"(accounting period not closed?)." + ) + return aggregates + @staticmethod + @cached(cache_type='fin_achizitii', key_params=['company_id', 'luna', 'an']) + async def get_achizitii_ytd( + company_id: int, + luna: int, + an: int + ) -> Decimal: + """ + Calculează totalul achizițiilor YTD din Registrul Jurnal (ACT). + + Această metodă folosește tabelul ACT (înregistrări contabile) în loc de VBAL + deoarece captează TOATE achizițiile: + - Achiziții stocuri: 3x = 4x/46x (materii prime, mărfuri, materiale) + - Achiziții directe: 6x = 4x/46x (servicii, consumabile pe cheltuieli) + + IMPORTANT: SUMA în tabelul ACT este ÎNTOTDEAUNA fără TVA! + TVA-ul merge în conturi separate (4426, 4428). + + Formula: + - (+) Intrări în stocuri/cheltuieli: SCD în 3x sau 6x, SCC în 4x sau 46x + - (-) Discount/rabat primit: SCD în 40x, SCC în 667 sau 609 + + Args: + company_id: ID-ul firmei + luna: Luna contabilă (1-12) - calcul YTD până la această lună + an: Anul contabil + + Returns: + Total achiziții YTD fără TVA (Decimal) + """ + schema = await FinancialIndicatorsService._get_schema(company_id) + + async with oracle_pool.get_connection() as connection: + with connection.cursor() as cursor: + query = f""" + SELECT + NVL(SUM(CASE + WHEN (SCD LIKE '3%' OR SCD LIKE '6%') + AND (SCC LIKE '4%' OR SCC LIKE '46%') + THEN SUMA + ELSE 0 + END), 0) + - + NVL(SUM(CASE + WHEN SCD LIKE '40%' AND (SCC = '667' OR SCC = '609') + THEN SUMA + ELSE 0 + END), 0) as achizitii_ytd + FROM {schema}.ACT + WHERE AN = :an + AND LUNA <= :luna + AND NVL(STERS, 0) = 0 + """ + cursor.execute(query, {'an': an, 'luna': luna}) + row = cursor.fetchone() + + achizitii_total = Decimal(str(row[0] or 0)) + + logger.info( + f"Achiziții YTD from ACT for company {company_id}, " + f"luna<={luna}, an={an}: {achizitii_total}" + ) + + return achizitii_total + + @staticmethod + @cached(cache_type='fin_cashflow_vbal', key_params=['company_id', 'luna', 'an']) + async def get_cashflow_from_vbal( + company_id: int, + luna: int, + an: int + ) -> dict: + """ + Calculează datele de Cash Flow direct din VBAL (balanța de verificare). + + Aceasta este sursa preferată pentru Cash Flow, în loc de vbalanta_parteneri, + pentru consistență cu celelalte calcule de indicatori. + + IMPORTANT: Cash Flow INCLUDE TVA deoarece măsoară fluxuri reale de numerar. + Când clientul plătește 1.190 RON, primim 1.190 RON (nu 1.000). + + Coloane VBAL utilizate: + - RULCRED/RULDEB: Rulaj lunar (mișcări în luna curentă) + - TOTCRED/TOTDEB: Total YTD (de la 1 ianuarie până la luna curentă) + + Args: + company_id: ID-ul firmei + luna: Luna contabilă (1-12) + an: Anul contabil + + Returns: + Dict cu: + - incasari_luna: Încasări lunare (4111+461 RULCRED) + - plati_luna: Plăți lunare (401+404+462 RULDEB) + - incasari_ytd: Încasări YTD (4111+461 TOTCRED) + - plati_ytd: Plăți YTD (401+404+462 TOTDEB) + """ + schema = await FinancialIndicatorsService._get_schema(company_id) + + async with oracle_pool.get_connection() as connection: + with connection.cursor() as cursor: + query = f""" + SELECT + -- Încasări luna curentă (4111+461 RULCRED) + -- Credit pe 4111 = client plătește + NVL(SUM(CASE + WHEN CONT IN ('4111', '461') THEN RULCRED + ELSE 0 + END), 0) as incasari_luna, + + -- Plăți luna curentă (401+404+462 RULDEB) + -- Debit pe 401 = plătim furnizorul + NVL(SUM(CASE + WHEN CONT IN ('401', '404', '462') THEN RULDEB + ELSE 0 + END), 0) as plati_luna, + + -- Încasări YTD (4111+461 TOTCRED) + NVL(SUM(CASE + WHEN CONT IN ('4111', '461') THEN TOTCRED + ELSE 0 + END), 0) as incasari_ytd, + + -- Plăți YTD (401+404+462 TOTDEB) + NVL(SUM(CASE + WHEN CONT IN ('401', '404', '462') THEN TOTDEB + ELSE 0 + END), 0) as plati_ytd + + FROM {schema}.VBAL + WHERE AN = :an AND LUNA = :luna + """ + cursor.execute(query, {'an': an, 'luna': luna}) + row = cursor.fetchone() + + result = { + 'incasari_luna': Decimal(str(row[0] or 0)), + 'plati_luna': Decimal(str(row[1] or 0)), + 'incasari_ytd': Decimal(str(row[2] or 0)), + 'plati_ytd': Decimal(str(row[3] or 0)) + } + + logger.info( + f"Cash flow from VBAL for company {company_id}, " + f"luna={luna}, an={an}: " + f"incasari_luna={result['incasari_luna']}, " + f"plati_luna={result['plati_luna']}, " + f"incasari_ytd={result['incasari_ytd']}, " + f"plati_ytd={result['plati_ytd']}" + ) + + return result + @staticmethod def _calculate_indicator_status( value: float, @@ -538,6 +802,27 @@ class FinancialIndicatorsService: threshold_min=0.2, threshold_max=None, message=no_debt_message + ), + # Sub-indicatori pentru verificare manuală în balanță + active_curente=IndicatorResult( + value=round(active_curente, 2), + status="good", + message="Stocuri + Creanțe + Disponibilități" + ), + disponibilitati=IndicatorResult( + value=round(disponibilitati, 2), + status="good", + message="Bancă (512x) + Casă (531x)" + ), + creante=IndicatorResult( + value=round(creante, 2), + status="good", + message="Clienți (411x) + Debitori (461x)" + ), + datorii_curente=IndicatorResult( + value=0, + status="good", + message="Fără datorii pe termen scurt" ) ) @@ -587,6 +872,27 @@ class FinancialIndicatorsService: status=lichiditate_vedere_status, threshold_min=0.2, threshold_max=None + ), + # Sub-indicatori pentru verificare manuală în balanță + active_curente=IndicatorResult( + value=round(active_curente, 2), + status="good", + message="Stocuri + Creanțe + Disponibilități" + ), + disponibilitati=IndicatorResult( + value=round(disponibilitati, 2), + status="good", + message="Bancă (512x) + Casă (531x)" + ), + creante=IndicatorResult( + value=round(creante, 2), + status="good", + message="Clienți (411x) + Debitori (461x)" + ), + datorii_curente=IndicatorResult( + value=round(datorii_curente, 2), + status="good", + message="Furnizori + TVA + Salarii etc." ) ) @@ -811,7 +1117,38 @@ class FinancialIndicatorsService: dpo=dpo, cash_conversion_cycle=cash_conversion_cycle, rata_incasare=rata_incasare, - rata_plata=rata_plata + rata_plata=rata_plata, + # Sub-indicatori pentru verificare manuală + sold_clienti=IndicatorResult( + value=round(clienti_sold, 2), + status="good", + message="Sold Clienți la final de lună" + ), + facturari_lunare=IndicatorResult( + value=round(facturari_lunare, 2), + status="good", + message="Media facturărilor lunare (3 luni)" + ), + sold_furnizori=IndicatorResult( + value=round(furnizori_sold, 2), + status="good", + message="Sold Furnizori la final de lună" + ), + achizitii_lunare=IndicatorResult( + value=round(achizitii_lunare, 2), + status="good", + message="Media achizițiilor lunare (3 luni)" + ), + incasari_luna=IndicatorResult( + value=round(incasari_lunare, 2), + status="good", + message="Media încasărilor lunare (3 luni)" + ), + plati_luna=IndicatorResult( + value=round(plati_lunare, 2), + status="good", + message="Media plăților lunare (3 luni)" + ) ) logger.info( @@ -1002,7 +1339,38 @@ class FinancialIndicatorsService: creante_restante_pct=creante_restante_pct, creante_90plus_pct=creante_90plus_pct, datorii_restante_pct=datorii_restante_pct, - raport_datorii_trezorerie=raport_datorii_trezorerie + raport_datorii_trezorerie=raport_datorii_trezorerie, + # Sub-indicatori pentru verificare manuală + total_clienti=IndicatorResult( + value=round(clienti_sold_total, 2), + status="good", + message="Sold total clienți (411x)" + ), + clienti_restanti=IndicatorResult( + value=round(clienti_sold_restant, 2), + status="good", + message="Clienți cu facturi restante" + ), + clienti_90plus=IndicatorResult( + value=round(clienti_restant_90plus, 2), + status="good", + message="Clienți restant >90 zile" + ), + total_furnizori=IndicatorResult( + value=round(furnizori_sold_total, 2), + status="good", + message="Sold total furnizori (401x)" + ), + furnizori_restanti=IndicatorResult( + value=round(furnizori_sold_restant, 2), + status="good", + message="Furnizori cu facturi restante" + ), + trezorerie=IndicatorResult( + value=round(trezorerie, 2), + status="good", + message="Disponibilități (512x + 531x)" + ) ) logger.info( @@ -1047,16 +1415,16 @@ class FinancialIndicatorsService: Returns: CashFlowIndicators cu cei patru indicatori de cash flow """ - # Import DashboardService here to avoid circular imports + # Import DashboardService here to avoid circular imports (for datorii restante) from .dashboard_service import DashboardService - # Obținem datele din trends pentru încasări/plăți istorice - # Folosim perioada 'ytd' pentru a obține datele de la începutul anului - trends = await DashboardService.get_trends( - company_id=company_id, - period='ytd', - luna=luna, - an=an + # Obținem datele de cash flow din VBAL (sursa preferată) + # VBAL oferă date directe: RULCRED/RULDEB pentru lunar, TOTCRED/TOTDEB pentru YTD + cf_data_curent = await FinancialIndicatorsService.get_cashflow_from_vbal( + company_id, luna, an + ) + cf_data_anterior = await FinancialIndicatorsService.get_cashflow_from_vbal( + company_id, luna, an - 1 ) # Obținem datele din summary pentru datorii restante @@ -1070,126 +1438,89 @@ class FinancialIndicatorsService: if isinstance(summary, dict): summary = DashboardSummary(**summary) - # Extragem arrayurile din trends - clienti_incasat = trends.get("clienti_incasat", []) - furnizori_achitat = trends.get("furnizori_achitat", []) - clienti_incasat_prev = trends.get("clienti_incasat_prev", []) - furnizori_achitat_prev = trends.get("furnizori_achitat_prev", []) + # Calculăm valorile de cash flow din VBAL + # Flux net lunar = încasări luna - plăți luna (RULCRED - RULDEB) + incasari_luna = float(cf_data_curent['incasari_luna']) + plati_luna = float(cf_data_curent['plati_luna']) + flux_net_val = incasari_luna - plati_luna - # Calculăm fluxurile nete lunare (încasări - plăți) pentru fiecare lună - fluxuri_nete = [ - (inc - plat) - for inc, plat in zip(clienti_incasat, furnizori_achitat) - ] if clienti_incasat and furnizori_achitat else [] + # Cash flow YTD = încasări YTD - plăți YTD (TOTCRED - TOTDEB) + incasari_ytd = float(cf_data_curent['incasari_ytd']) + plati_ytd = float(cf_data_curent['plati_ytd']) + cf_ytd_val = incasari_ytd - plati_ytd - # Calculăm fluxurile nete pentru anul anterior - fluxuri_nete_prev = [ - (inc - plat) - for inc, plat in zip(clienti_incasat_prev, furnizori_achitat_prev) - ] if clienti_incasat_prev and furnizori_achitat_prev else [] + # Cash flow YTD an anterior (pentru comparație YoY) + incasari_ytd_prev = float(cf_data_anterior['incasari_ytd']) + plati_ytd_prev = float(cf_data_anterior['plati_ytd']) + cf_ytd_anterior = incasari_ytd_prev - plati_ytd_prev - # 1. Flux net lunar (ultima lună disponibilă) - if fluxuri_nete: - flux_net_val = fluxuri_nete[-1] # Ultima lună (luna curentă) - flux_net_status = "good" if flux_net_val > 0 else "danger" - flux_net_message = ( - "Firma generează numerar" - if flux_net_val > 0 - else "Firma consumă numerar" - ) - flux_net_lunar = IndicatorResult( - value=round(flux_net_val, 2), - status=flux_net_status, - threshold_min=0, - threshold_max=None, - message=flux_net_message - ) - else: - flux_net_lunar = IndicatorResult( - value=None, - status="warning", - threshold_min=0, - threshold_max=None, - message="Fără date de cash flow pentru perioada selectată" - ) + # 1. Flux net lunar + flux_net_status = "good" if flux_net_val > 0 else "danger" + flux_net_message = ( + "Firma generează numerar" + if flux_net_val > 0 + else "Firma consumă numerar" + ) + flux_net_lunar = IndicatorResult( + value=round(flux_net_val, 2), + status=flux_net_status, + threshold_min=0, + threshold_max=None, + message=flux_net_message + ) - # 2. Cash flow YTD (suma fluxurilor de la ianuarie până la luna curentă) - if fluxuri_nete: - cf_ytd_val = sum(fluxuri_nete) - cf_ytd_status = "good" if cf_ytd_val > 0 else "danger" - cash_flow_ytd = IndicatorResult( - value=round(cf_ytd_val, 2), - status=cf_ytd_status, - threshold_min=0, - threshold_max=None - ) - else: - cf_ytd_val = 0 - cash_flow_ytd = IndicatorResult( - value=None, - status="warning", - threshold_min=0, - threshold_max=None, - message="Fără date de cash flow YTD" - ) + # 2. Cash flow YTD (încasări YTD - plăți YTD din VBAL TOTCRED/TOTDEB) + cf_ytd_status = "good" if cf_ytd_val > 0 else "danger" + cash_flow_ytd = IndicatorResult( + value=round(cf_ytd_val, 2), + status=cf_ytd_status, + threshold_min=0, + threshold_max=None + ) # 3. Flux net YoY % (variația an-la-an) # Comparăm cash flow-ul YTD curent cu cel din aceeași perioadă a anului anterior - if fluxuri_nete and fluxuri_nete_prev: - cf_curent = sum(fluxuri_nete) - # Luăm același număr de luni din anul anterior pentru comparație corectă - num_months = len(fluxuri_nete) - cf_anterior = sum(fluxuri_nete_prev[:num_months]) if len(fluxuri_nete_prev) >= num_months else sum(fluxuri_nete_prev) - - if abs(cf_anterior) > 0: - yoy_pct = ((cf_curent - cf_anterior) / abs(cf_anterior)) * 100 - yoy_status = "good" if yoy_pct >= 0 else "danger" - yoy_message = ( - "Creștere cash flow față de anul anterior" - if yoy_pct >= 0 - else "Scădere cash flow față de anul anterior" - ) - flux_net_yoy_pct = IndicatorResult( - value=round(yoy_pct, 1), - status=yoy_status, - threshold_min=0, - threshold_max=None, - message=yoy_message - ) - else: - # Cash flow anterior era zero - if cf_curent > 0: - flux_net_yoy_pct = IndicatorResult( - value=100.0, - status="good", - threshold_min=0, - threshold_max=None, - message="Cash flow pozitiv vs zero anul anterior" - ) - elif cf_curent < 0: - flux_net_yoy_pct = IndicatorResult( - value=-100.0, - status="danger", - threshold_min=0, - threshold_max=None, - message="Cash flow negativ vs zero anul anterior" - ) - else: - flux_net_yoy_pct = IndicatorResult( - value=0.0, - status="warning", - threshold_min=0, - threshold_max=None, - message="Cash flow zero în ambii ani" - ) - else: + if abs(cf_ytd_anterior) > 0: + yoy_pct = ((cf_ytd_val - cf_ytd_anterior) / abs(cf_ytd_anterior)) * 100 + yoy_status = "good" if yoy_pct >= 0 else "danger" + yoy_message = ( + "Creștere cash flow față de anul anterior" + if yoy_pct >= 0 + else "Scădere cash flow față de anul anterior" + ) flux_net_yoy_pct = IndicatorResult( - value=None, - status="warning", + value=round(yoy_pct, 1), + status=yoy_status, threshold_min=0, threshold_max=None, - message="Fără date pentru comparație YoY" + message=yoy_message ) + else: + # Cash flow anterior era zero + if cf_ytd_val > 0: + flux_net_yoy_pct = IndicatorResult( + value=100.0, + status="good", + threshold_min=0, + threshold_max=None, + message="Cash flow pozitiv vs zero anul anterior" + ) + elif cf_ytd_val < 0: + flux_net_yoy_pct = IndicatorResult( + value=-100.0, + status="danger", + threshold_min=0, + threshold_max=None, + message="Cash flow negativ vs zero anul anterior" + ) + else: + flux_net_yoy_pct = IndicatorResult( + value=0.0, + status="warning", + threshold_min=0, + threshold_max=None, + message="Cash flow zero în ambii ani" + ) # 4. Acoperire cash flow = cash_flow_ytd / datorii_restante # Datoriile restante sunt furnizori_sold_restant din summary @@ -1235,11 +1566,36 @@ class FinancialIndicatorsService: message="Nu se poate calcula acoperirea cash flow" ) + # Sub-indicatori pentru verificare manuală + # NOTA: incasari_luna și plati_luna sunt calculate mai sus din VBAL (liniile 1442-1443) + # cf_ytd_anterior este calculat mai sus din VBAL (linia 1454) + result = CashFlowIndicators( flux_net_lunar=flux_net_lunar, cash_flow_ytd=cash_flow_ytd, flux_net_yoy_pct=flux_net_yoy_pct, - acoperire_cash_flow=acoperire_cash_flow + acoperire_cash_flow=acoperire_cash_flow, + # Sub-indicatori pentru verificare manuală (din VBAL) + incasari_luna=IndicatorResult( + value=round(incasari_luna, 2), + status="good", + message="Încasări luna curentă (4111+461 RULCRED)" + ), + plati_luna=IndicatorResult( + value=round(plati_luna, 2), + status="good", + message="Plăți luna curentă (401+404+462 RULDEB)" + ), + cf_an_precedent=IndicatorResult( + value=round(cf_ytd_anterior, 2), + status="good", + message="Cash Flow YTD an precedent (din VBAL)" + ), + datorii_restante=IndicatorResult( + value=round(datorii_restante, 2), + status="good", + message="Datorii cu scadență depășită" + ) ) logger.info( @@ -1265,17 +1621,25 @@ class FinancialIndicatorsService: Compară vânzările și achizițiile cu anul anterior (YoY - Year-over-Year) pentru a determina dacă afacerea crește sau scade. + 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) + + Anterior, indicatorii foloseau vbalanta_parteneri care include TVA în 4111 + (Clienți DEBIT), ceea ce ducea la cifre ~20% mai mari decât realitatea. + Indicatori calculați: - - crestere_vanzari_yoy: Creșterea procentuală a facturărilor față de anul anterior - Formula: (facturari_curent - facturari_anterior) / facturari_anterior * 100 + - crestere_vanzari_yoy: Creșterea procentuală a Cifrei de Afaceri față de anul anterior + Formula: (CA_curent - CA_anterior) / CA_anterior * 100 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 (include stocuri + cheltuieli directe, fără TVA) Informativ - creșterea achizițiilor poate indica expansiune - - marja_implicita: Marja implicită din diferența facturări - achiziții - Formula: (facturari - achizitii) / facturari * 100 + - marja_implicita: Marja implicită din diferența CA - achiziții totale + Formula: (CA - achizitii) / CA * 100 Good: > 20%, Warning: 10-20%, Danger: < 10% Args: @@ -1286,51 +1650,44 @@ class FinancialIndicatorsService: Returns: DynamicsIndicators cu cei trei indicatori de dinamică """ - # Import DashboardService here to avoid circular imports - from .dashboard_service import DashboardService - - # Obținem datele din trends pentru perioada curentă (YTD) și anul anterior - trends = await DashboardService.get_trends( - company_id=company_id, - period='ytd', - luna=luna, - an=an + # Obținem agregatele pentru anul curent și anul anterior + # Cifra de Afaceri (70x) din VBAL - FĂRĂ TVA + aggregates_curent = await FinancialIndicatorsService.get_balance_sheet_aggregates( + company_id, luna, an + ) + aggregates_anterior = await FinancialIndicatorsService.get_balance_sheet_aggregates( + company_id, luna, an - 1 ) - # Extragem arrayurile din trends - facturări și achiziții - # clienti_facturat = facturări (vânzări) - # furnizori_facturat = achiziții - clienti_facturat = trends.get("clienti_facturat", []) - clienti_facturat_prev = trends.get("clienti_facturat_prev", []) - furnizori_facturat = trends.get("furnizori_facturat", []) - furnizori_facturat_prev = trends.get("furnizori_facturat_prev", []) + # Ensure aggregates is a BalanceSheetAggregates model (cache may return dict) + if isinstance(aggregates_curent, dict): + aggregates_curent = BalanceSheetAggregates(**aggregates_curent) + if isinstance(aggregates_anterior, dict): + aggregates_anterior = BalanceSheetAggregates(**aggregates_anterior) - # Calculăm totalurile pentru perioada curentă și anterioară - # Luăm același număr de luni pentru comparație corectă YoY - num_months = len(clienti_facturat) + # Cifra de Afaceri YTD (Clasa 7 - 70x, FĂRĂ TVA) + # Aceasta este sursa corectă pentru vânzări + total_vanzari_curent = float(aggregates_curent.cifra_afaceri) + total_vanzari_anterior = float(aggregates_anterior.cifra_afaceri) - # Total facturări (vânzări) an curent și anterior - total_facturari_curent = sum(clienti_facturat) if clienti_facturat else 0 - total_facturari_anterior = ( - sum(clienti_facturat_prev[:num_months]) - if len(clienti_facturat_prev) >= num_months - else sum(clienti_facturat_prev) - ) if clienti_facturat_prev else 0 + # Achiziții TOTALE YTD din ACT (stocuri + cheltuieli directe, FĂRĂ TVA) + # Include: 3x=4x (stocuri) + 6x=4x (servicii, consumabile) + # Exclude: discount/rabat (40x=667/609) + achizitii_curent = await FinancialIndicatorsService.get_achizitii_ytd( + company_id, luna, an + ) + achizitii_anterior = await FinancialIndicatorsService.get_achizitii_ytd( + company_id, luna, an - 1 + ) + total_achizitii_curent = float(achizitii_curent) + total_achizitii_anterior = float(achizitii_anterior) - # Total achiziții an curent și anterior - total_achizitii_curent = sum(furnizori_facturat) if furnizori_facturat else 0 - total_achizitii_anterior = ( - sum(furnizori_facturat_prev[:num_months]) - if len(furnizori_facturat_prev) >= num_months - else sum(furnizori_facturat_prev) - ) if furnizori_facturat_prev else 0 - - # 1. Creștere vânzări YoY - # Formula: (facturari_curent - facturari_anterior) / facturari_anterior * 100 - if total_facturari_anterior > 0: + # 1. Creștere vânzări YoY (Cifra de Afaceri) + # Formula: (CA_curent - CA_anterior) / CA_anterior * 100 + if total_vanzari_anterior > 0: crestere_vanzari_val = ( - (total_facturari_curent - total_facturari_anterior) / - total_facturari_anterior * 100 + (total_vanzari_curent - total_vanzari_anterior) / + total_vanzari_anterior * 100 ) # Status: good > 5%, warning 0-5%, danger < 0% crestere_vanzari_status = FinancialIndicatorsService._calculate_indicator_status( @@ -1352,8 +1709,8 @@ class FinancialIndicatorsService: threshold_max=None, message=crestere_vanzari_message ) - elif total_facturari_curent > 0: - # Anul anterior nu avea facturări, dar anul curent da + elif total_vanzari_curent > 0: + # Anul anterior nu avea vânzări, dar anul curent da crestere_vanzari_yoy = IndicatorResult( value=100.0, status="good", @@ -1362,13 +1719,13 @@ class FinancialIndicatorsService: message="Vânzări noi - nu existau în anul anterior" ) else: - # Fără facturări în niciun an + # Fără vânzări în niciun an crestere_vanzari_yoy = IndicatorResult( value=None, status="warning", threshold_min=5.0, threshold_max=None, - message="Fără date de facturări pentru comparație" + message="Fără date de vânzări pentru comparație" ) # 2. Creștere achiziții YoY @@ -1412,12 +1769,12 @@ class FinancialIndicatorsService: ) # 3. Marja implicită - # Formula: (facturari - achizitii) / facturari * 100 - # Arată ce procent din vânzări rămâne după achiziții - if total_facturari_curent > 0: + # Formula: (CA - achizitii_stocuri) / CA * 100 + # Arată ce procent din Cifra de Afaceri rămâne după achiziții de stocuri + if total_vanzari_curent > 0: marja_val = ( - (total_facturari_curent - total_achizitii_curent) / - total_facturari_curent * 100 + (total_vanzari_curent - total_achizitii_curent) / + total_vanzari_curent * 100 ) # Status: good > 20%, warning 10-20%, danger < 10% marja_status = FinancialIndicatorsService._calculate_indicator_status( @@ -1445,13 +1802,35 @@ class FinancialIndicatorsService: status="warning", threshold_min=20.0, threshold_max=None, - message="Fără facturări - nu se poate calcula marja" + message="Fără vânzări - nu se poate calcula marja" ) result = DynamicsIndicators( crestere_vanzari_yoy=crestere_vanzari_yoy, crestere_achizitii_yoy=crestere_achizitii_yoy, - marja_implicita=marja_implicita + marja_implicita=marja_implicita, + # Sub-indicatori pentru verificare manuală + # IMPORTANT: Acestea sunt acum FĂRĂ TVA (Cifra de Afaceri din Clasa 7) + vanzari_an_curent=IndicatorResult( + value=round(total_vanzari_curent, 2), + status="good", + message="Cifra de Afaceri YTD an curent (fără TVA)" + ), + vanzari_an_precedent=IndicatorResult( + value=round(total_vanzari_anterior, 2), + status="good", + message="Cifra de Afaceri YTD an precedent (fără TVA)" + ), + achizitii_an_curent=IndicatorResult( + value=round(total_achizitii_curent, 2), + status="good", + message="Achiziții totale YTD an curent (stocuri + cheltuieli directe, fără TVA)" + ), + achizitii_an_precedent=IndicatorResult( + value=round(total_achizitii_anterior, 2), + status="good", + message="Achiziții totale YTD an precedent (stocuri + cheltuieli directe, fără TVA)" + ) ) logger.info( @@ -1756,13 +2135,26 @@ class FinancialIndicatorsService: total_active = float(aggregates.total_active) capitaluri_proprii_val = float(aggregates.capitaluri_proprii) + # Detect if balance sheet has data but income/expense accounts are 0 + # This typically means the accounting period is not closed in Oracle + has_balance_data = total_active > 0 or float(aggregates.datorii_curente) > 0 + no_income_data = venituri == 0 and cheltuieli_oper == 0 + period_not_closed = has_balance_data and no_income_data + # 1. Cifra de afaceri (venituri totale) - informativ + if period_not_closed: + cifra_afaceri_status = "warning" + cifra_afaceri_message = "Date indisponibile - perioada contabilă neînchisă?" + else: + cifra_afaceri_status = "good" + cifra_afaceri_message = "Total venituri din activitatea operațională" + cifra_afaceri = IndicatorResult( value=round(venituri, 2), - status="good", # Informativ - nu are praguri + status=cifra_afaceri_status, threshold_min=None, threshold_max=None, - message="Total venituri din activitatea operațională" + message=cifra_afaceri_message ) # 2. Cheltuieli operaționale (fără dobânzi 66x) - pentru verificare @@ -1793,12 +2185,16 @@ class FinancialIndicatorsService: ) # 3. Profit brut (EBIT) - profit_status = "good" if profit_brut_val > 0 else "danger" - profit_message = ( - "Profit operațional pozitiv" - if profit_brut_val > 0 - else "Pierdere operațională - costuri depășesc veniturile" - ) + if period_not_closed: + profit_status = "warning" + profit_message = "Date indisponibile - perioada contabilă neînchisă?" + elif profit_brut_val > 0: + profit_status = "good" + profit_message = "Profit operațional pozitiv" + else: + profit_status = "danger" + profit_message = "Pierdere operațională - costuri depășesc veniturile" + profit_brut = IndicatorResult( value=round(profit_brut_val, 2), status=profit_status, @@ -1932,6 +2328,12 @@ class FinancialIndicatorsService: cheltuieli_financiare=cheltuieli_financiare, cheltuieli_totale=cheltuieli_totale, profit_brut=profit_brut, + # Sub-indicator pentru verificare calcul EBIT + venituri=IndicatorResult( + value=round(venituri, 2), + status="good", + message="Total venituri (Clasa 7) - verificare EBIT" + ), marja_profit_brut=marja_profit_brut, active_totale=active_totale, capitaluri_proprii=capitaluri_proprii, @@ -1950,6 +2352,142 @@ class FinancialIndicatorsService: return result + @staticmethod + async def calculate_solvability_indicators( + company_id: int, + luna: int, + an: int + ) -> SolvabilityIndicators: + """ + Calculează indicatorii de solvabilitate pentru evaluarea capacității + firmei de a-și acoperi datoriile pe termen lung. + + Indicatori calculați: + - Activ Net Contabil (ANC) = Total Active - Total Datorii + - Rata ANC/Capital Social = (ANC / Capital Social) × 100 + + Praguri de referință: + - ANC: Good: > 0, Danger: <= 0 + - Rata ANC/Capital: Good: >= 100%, Warning: 50-100%, Danger: < 50% + + Implicații legale (din 1 ianuarie 2026): + Sub 50% din capital social → restricții dividende, restituire împrumuturi, + acordare împrumuturi noi. + + Args: + company_id: ID-ul firmei + luna: Luna contabilă (1-12) + an: Anul contabil + + Returns: + SolvabilityIndicators cu ANC și rata ANC/Capital Social + """ + # Obținem agregatele din balanță + aggregates = await FinancialIndicatorsService.get_balance_sheet_aggregates( + company_id, luna, an + ) + # Ensure aggregates is a BalanceSheetAggregates model (cache may return dict) + if isinstance(aggregates, dict): + aggregates = BalanceSheetAggregates(**aggregates) + + # Extragem valorile necesare + total_active = float(aggregates.total_active) + total_datorii = float(aggregates.total_datorii) + # Folosim capital_social_strict (doar contul 101) conform definiției legale stricte + capital_social = float(aggregates.capital_social_strict) # Capital Social = doar contul 101 + + # 1. Calculăm Activul Net Contabil (ANC) + anc_val = total_active - total_datorii + + # Status pentru ANC: pozitiv = good, negativ sau zero = danger + if anc_val > 0: + anc_status = "good" + anc_message = "Activ net pozitiv - firma solvabilă" + else: + anc_status = "danger" + anc_message = "Activ net negativ sau zero - risc de insolvență" + + activ_net_contabil = IndicatorResult( + value=round(anc_val, 2), + status=anc_status, + threshold_min=0, + threshold_max=None, + message=anc_message + ) + + # 2. Calculăm Rata ANC / Capital Social + if capital_social > 0: + rata_val = (anc_val / capital_social) * 100 + + # Praguri conform legislației din 2026 + if rata_val >= 100: + rata_status = "good" + rata_message = "ANC peste capitalul social - situație sănătoasă" + elif rata_val >= 50: + rata_status = "warning" + rata_message = "ANC între 50-100% din capital - atenție la evoluție" + else: + rata_status = "danger" + rata_message = "ANC sub 50% din capital - restricții legale aplicabile" + + rata_anc_capital = IndicatorResult( + value=round(rata_val, 2), + status=rata_status, + threshold_min=100.0, + threshold_max=None, + message=rata_message + ) + else: + # Capital social zero sau negativ - situație critică + rata_anc_capital = IndicatorResult( + value=None, + status="danger", + threshold_min=100.0, + threshold_max=None, + message="Capital social zero sau negativ - situație critică" + ) + + # Indicatori de bază pentru verificare + total_active_indicator = IndicatorResult( + value=round(total_active, 2), + status="good", + threshold_min=None, + threshold_max=None, + message="Active Imobilizate + Active Curente" + ) + + total_datorii_indicator = IndicatorResult( + value=round(total_datorii, 2), + status="good" if total_datorii < total_active else "warning", + threshold_min=None, + threshold_max=None, + message="Datorii Curente + Datorii Termen Lung" + ) + + capital_social_indicator = IndicatorResult( + value=round(capital_social, 2), + status="good" if capital_social > 0 else "danger", + threshold_min=None, + threshold_max=None, + message="Capital social subscris și vărsat" + ) + + result = SolvabilityIndicators( + activ_net_contabil=activ_net_contabil, + rata_anc_capital=rata_anc_capital, + total_active=total_active_indicator, + total_datorii=total_datorii_indicator, + capital_social=capital_social_indicator + ) + + logger.info( + f"Solvability indicators for company {company_id}, luna={luna}, an={an}: " + f"ANC={activ_net_contabil.value} ({activ_net_contabil.status}), " + f"Rata ANC/CS={rata_anc_capital.value}% ({rata_anc_capital.status})" + ) + + return result + @staticmethod def _generate_month_labels(luna: int, an: int, months: int = 12) -> List[str]: """ @@ -2117,11 +2655,15 @@ class FinancialIndicatorsService: 'marja_profit_brut': [], 'roa': [], 'roe': [] + }, + 'solvabilitate': { + 'activ_net_contabil': [], + 'rata_anc_capital': [] } } # Calculăm indicatorii pentru fiecare perioadă - all_categories = ['lichiditate', 'eficienta', 'risc', 'cash_flow', 'dinamica', 'altman_zscore', 'profitabilitate'] + all_categories = ['lichiditate', 'eficienta', 'risc', 'cash_flow', 'dinamica', 'altman_zscore', 'profitabilitate', 'solvabilitate'] for period_luna, period_an in periods: # Track which categories were successfully processed in this period @@ -2251,6 +2793,21 @@ class FinancialIndicatorsService: historical_data['profitabilitate']['roe'].append(profitabilitate.roe.value) processed_categories.add('profitabilitate') + # Solvabilitate + solvabilitate = await FinancialIndicatorsService.calculate_solvability_indicators( + company_id, period_luna, period_an + ) + # Ensure solvabilitate is a model (cache may return dict) + if isinstance(solvabilitate, dict): + solvabilitate = SolvabilityIndicators(**solvabilitate) + historical_data['solvabilitate']['activ_net_contabil'].append( + solvabilitate.activ_net_contabil.value + ) + historical_data['solvabilitate']['rata_anc_capital'].append( + solvabilitate.rata_anc_capital.value + ) + processed_categories.add('solvabilitate') + except Exception as e: logger.warning( f"Error calculating indicators for company {company_id}, " @@ -2272,15 +2829,17 @@ class FinancialIndicatorsService: return historical_data @staticmethod + @cached(cache_type='financial_indicators_sparklines', key_params=['company_id', 'luna', 'an', 'months']) async def get_indicators_with_sparklines( company_id: int, luna: int, an: int, - months: int = 12 + months: int = 12, + request: Optional[Request] = None ) -> FinancialIndicatorsResponse: """ Calculează toți indicatorii financiari și adaugă datele de sparkline - pentru vizualizarea trendului pe ultimele luni. + pentru vizualizarea trendului pe ultimele luni (CACHED 30 min). Această metodă combină calculele curente ale indicatorilor cu datele istorice pentru sparklines. @@ -2290,6 +2849,7 @@ class FinancialIndicatorsService: luna: Luna contabilă (1-12) an: Anul contabil months: Numărul de luni pentru sparkline (default 12) + request: Request object pentru cache metadata Returns: FinancialIndicatorsResponse cu sparkline_data integrat în fiecare indicator @@ -2322,6 +2882,9 @@ class FinancialIndicatorsService: profitabilitate_task = FinancialIndicatorsService.calculate_profitability_indicators( company_id, luna, an ) + solvabilitate_task = FinancialIndicatorsService.calculate_solvability_indicators( + company_id, luna, an + ) ( historical_data, @@ -2331,7 +2894,8 @@ class FinancialIndicatorsService: cash_flow, dinamica, altman_zscore, - profitabilitate + profitabilitate, + solvabilitate ) = await asyncio.gather( historical_task, lichiditate_task, @@ -2340,7 +2904,8 @@ class FinancialIndicatorsService: cash_flow_task, dinamica_task, altman_task, - profitabilitate_task + profitabilitate_task, + solvabilitate_task ) # Ensure all indicator results are models (cache may return dicts) @@ -2358,6 +2923,8 @@ class FinancialIndicatorsService: altman_zscore = AltmanZScore(**altman_zscore) if isinstance(profitabilitate, dict): profitabilitate = ProfitabilityIndicators(**profitabilitate) + if isinstance(solvabilitate, dict): + solvabilitate = SolvabilityIndicators(**solvabilitate) # Extragem etichetele comune sparkline_labels = historical_data['sparkline_labels'] @@ -2436,6 +3003,36 @@ class FinancialIndicatorsService: profitabilitate.roe.sparkline_data = historical_data['profitabilitate']['roe'] profitabilitate.roe.sparkline_labels = sparkline_labels + # Actualizăm indicatorii de solvabilitate cu sparkline data + solvabilitate.activ_net_contabil.sparkline_data = historical_data['solvabilitate']['activ_net_contabil'] + solvabilitate.activ_net_contabil.sparkline_labels = sparkline_labels + solvabilitate.rata_anc_capital.sparkline_data = historical_data['solvabilitate']['rata_anc_capital'] + solvabilitate.rata_anc_capital.sparkline_labels = sparkline_labels + + # FIX: Sincronizare ultima valoare sparkline cu valoarea curentă calculată + # Aceasta rezolvă inconsistențele când cache-ul pentru indicatori curenți + # se reîmprospătează mai des decât cache-ul pentru istoric (sparklines) + def sync_last_sparkline_value(indicator_obj, attr_name): + """Înlocuiește ultima valoare din sparkline cu valoarea curentă calculată""" + indicator = getattr(indicator_obj, attr_name, None) + if indicator and hasattr(indicator, 'sparkline_data') and indicator.sparkline_data: + if hasattr(indicator, 'value') and indicator.value is not None: + indicator.sparkline_data[-1] = indicator.value + + # Sincronizăm toți indicatorii + for ind_obj, attrs in [ + (lichiditate, ['lichiditate_curenta', 'lichiditate_imediata', 'lichiditate_vedere']), + (eficienta, ['dso', 'dpo', 'cash_conversion_cycle', 'rata_incasare', 'rata_plata']), + (risc, ['creante_restante_pct', 'creante_90plus_pct', 'datorii_restante_pct', 'raport_datorii_trezorerie']), + (cash_flow, ['flux_net_lunar', 'cash_flow_ytd', 'flux_net_yoy_pct', 'acoperire_cash_flow']), + (dinamica, ['crestere_vanzari_yoy', 'crestere_achizitii_yoy', 'marja_implicita']), + (altman_zscore, ['zscore', 'x1', 'x2', 'x3', 'x4']), + (profitabilitate, ['cifra_afaceri', 'cheltuieli_totale', 'profit_brut', 'marja_profit_brut', 'roa', 'roe']), + (solvabilitate, ['activ_net_contabil', 'rata_anc_capital']), + ]: + for attr in attrs: + sync_last_sparkline_value(ind_obj, attr) + # Construim răspunsul final response = FinancialIndicatorsResponse( lichiditate=lichiditate, @@ -2444,13 +3041,15 @@ class FinancialIndicatorsService: cash_flow=cash_flow, dinamica=dinamica, altman_zscore=altman_zscore, - profitabilitate=profitabilitate + profitabilitate=profitabilitate, + solvabilitate=solvabilitate ) logger.info( f"Indicators with sparklines for company {company_id}, luna={luna}, an={an}: " f"Z-Score={altman_zscore.zscore.value} ({altman_zscore.zscore.status}), " - f"ROA={profitabilitate.roa.value}% ({profitabilitate.roa.status})" + f"ROA={profitabilitate.roa.value}% ({profitabilitate.roa.status}), " + f"ANC={solvabilitate.activ_net_contabil.value} ({solvabilitate.activ_net_contabil.status})" ) return response diff --git a/src/App.vue b/src/App.vue index fff5607..da5ff41 100644 --- a/src/App.vue +++ b/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) diff --git a/src/modules/data-entry/services/api.js b/src/modules/data-entry/services/api.js index 60c3699..7e19dc6 100644 --- a/src/modules/data-entry/services/api.js +++ b/src/modules/data-entry/services/api.js @@ -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) } diff --git a/src/modules/reports/components/dashboard/cards/CashFlowMetricCard.vue b/src/modules/reports/components/dashboard/cards/CashFlowMetricCard.vue index e886c50..dc99ba7 100644 --- a/src/modules/reports/components/dashboard/cards/CashFlowMetricCard.vue +++ b/src/modules/reports/components/dashboard/cards/CashFlowMetricCard.vue @@ -22,8 +22,22 @@ - -
+ +
+ Grafice evoluție + +
+ + +
+
Încasări
@@ -40,6 +54,14 @@
+
+ + + @@ -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%; diff --git a/src/modules/reports/components/dashboard/cards/ClientiBalanceCard.vue b/src/modules/reports/components/dashboard/cards/ClientiBalanceCard.vue index e8bac60..3c5c0b9 100644 --- a/src/modules/reports/components/dashboard/cards/ClientiBalanceCard.vue +++ b/src/modules/reports/components/dashboard/cards/ClientiBalanceCard.vue @@ -1,25 +1,23 @@ @@ -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 { diff --git a/src/modules/reports/components/dashboard/cards/ClientsFurnizoriBalanceCard.vue b/src/modules/reports/components/dashboard/cards/ClientsFurnizoriBalanceCard.vue index e73e11e..f35401f 100644 --- a/src/modules/reports/components/dashboard/cards/ClientsFurnizoriBalanceCard.vue +++ b/src/modules/reports/components/dashboard/cards/ClientsFurnizoriBalanceCard.vue @@ -1,49 +1,65 @@ @@ -98,6 +99,7 @@ :outflowsPreviousSparkline="outflowsPreviousSparkline" :sparklineLabels="sparklineLabels" :previousSparklineLabels="previousSparklineLabels" + :cacheInfo="cashflowCacheInfo" /> @@ -110,6 +112,7 @@ :sparklineLabels="sparklineLabels" :previousSparklineLabels="previousSparklineLabels" :breakdown="netBalanceData?.breakdown?.clienti" + :cacheInfo="netBalanceCacheInfo" /> @@ -122,6 +125,7 @@ :sparklineLabels="sparklineLabels" :previousSparklineLabels="previousSparklineLabels" :breakdown="netBalanceData?.breakdown?.furnizori" + :cacheInfo="netBalanceCacheInfo" /> @@ -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" /> - -
- - - - -
-
@@ -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" /> @@ -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; diff --git a/src/shared/components/CacheFooter.vue b/src/shared/components/CacheFooter.vue new file mode 100644 index 0000000..088acaf --- /dev/null +++ b/src/shared/components/CacheFooter.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/src/shared/utils/authRedirect.js b/src/shared/utils/authRedirect.js new file mode 100644 index 0000000..8783860 --- /dev/null +++ b/src/shared/utils/authRedirect.js @@ -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 +} diff --git a/tasks/prd-dashboard-desktop-cleanup.md b/tasks/prd-dashboard-desktop-cleanup.md new file mode 100644 index 0000000..4137f1f --- /dev/null +++ b/tasks/prd-dashboard-desktop-cleanup.md @@ -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`)