Initial commit: ROA2WEB - FastAPI + Vue.js + Telegram Bot

Modern ERP Reports Application with microservices architecture

Tech Stack:
- Backend: FastAPI + python-oracledb (Oracle DB integration)
- Frontend: Vue.js 3 + PrimeVue + Vite
- Telegram Bot: python-telegram-bot + SQLite
- Infrastructure: Shared database pool, JWT authentication, SSH tunnel

Features:
- FastAPI backend with async Oracle connection pool
- Vue.js 3 responsive frontend with PrimeVue components
- Telegram bot alternative interface
- Microservices architecture with shared components
- Complete deployment support (Linux Docker + Windows IIS)
- Comprehensive testing (Playwright E2E + pytest)

Repository Structure:
- reports-app/ - Main application (backend, frontend, telegram-bot)
- shared/ - Shared components (database pool, auth, utils)
- deployment/ - Deployment scripts (Linux & Windows)
- docs/ - Project documentation
- security/ - Security scanning and git hooks
This commit is contained in:
2025-10-25 14:55:08 +03:00
commit 6b13ffa183
237 changed files with 70035 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
# Multi-stage build for optimized production image
# Stage 1: Build dependencies
FROM python:3.11-slim as builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y \
gcc \
g++ \
libffi-dev \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies
COPY ./reports-app/backend/requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# Stage 2: Production image
FROM python:3.11-slim as production
# Create non-root user for security
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
# Install runtime dependencies only
RUN apt-get update && apt-get install -y \
tini \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Copy Python dependencies from builder stage
COPY --from=builder /root/.local /home/appuser/.local
# Copy application code
COPY ./reports-app/backend/app/ ./app/
# Copy shared modules (needed for auth and database)
COPY ./shared/ ./shared/
# Set ownership and permissions
RUN chown -R appuser:appuser /app
USER appuser
# Add user's local bin to PATH
ENV PATH=/home/appuser/.local/bin:$PATH
# Health check
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/health')" || exit 1
# Expose port
EXPOSE 8000
# Use tini as init system for proper signal handling
ENTRYPOINT ["/usr/bin/tini", "--"]
# Run application with production settings
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

View File

@@ -0,0 +1,204 @@
# ROA Reports Backend API
FastAPI backend pentru aplicația de rapoarte ROA2WEB.
## 🚀 Funcționalități
### 📊 API Endpoints Implementate
#### Authentication (`/auth`)
- `POST /auth/login` - Autentificare utilizator
- `POST /auth/logout` - Deconectare utilizator
- `GET /auth/me` - Informații utilizator curent
- `GET /auth/validate` - Validare token JWT
#### Companies (`/companies`)
- `GET /companies/` - Lista firmelor utilizatorului
- `GET /companies/{company_code}` - Detalii firmă
- `GET /companies/{company_code}/validate` - Validare acces firmă
#### Invoices (`/invoices`)
- `GET /invoices/` - Lista facturi cu filtrare și paginare
- `GET /invoices/summary` - Rezumat facturi pentru dashboard
- `GET /invoices/{invoice_number}` - Detalii factură specifică
- `GET /invoices/export/{format}` - Export facturi (planned)
#### Payments (`/payments`)
- `GET /payments/` - Lista încasări/plăți cu filtrare și paginare
- `GET /payments/summary` - Rezumat încasări pentru dashboard
- `GET /payments/{payment_number}` - Detalii încasare specifică
- `POST /payments/` - Creare încasare nouă (planned)
- `PUT /payments/{payment_number}` - Actualizare încasare (planned)
- `GET /payments/export/{format}` - Export încasări (planned)
#### Health Check
- `GET /` - Status API
- `GET /health` - Health check complet cu database
## 🏗️ Arhitectură
### Shared Components Integration
- **Database Pool**: Folosește `roa2web/shared/database/oracle_pool.py`
- **JWT Authentication**: Folosește `roa2web/shared/auth/` pentru validare token-uri
- **Middleware**: Authentication middleware cu rate limiting
### Structură Aplicație
```
app/
├── main.py # FastAPI entry point
├── models/ # Pydantic models
│ ├── invoice.py # Modele pentru facturi
│ └── payment.py # Modele pentru încasări
├── routers/ # API endpoints
│ ├── auth.py # Authentication endpoints
│ ├── companies.py # Companies management
│ ├── invoices.py # Invoices API
│ └── payments.py # Payments API
├── services/ # Business logic
│ ├── invoice_service.py # Serviciu facturi cu Oracle queries
│ └── payment_service.py # Serviciu încasări cu Oracle queries
└── schemas/ # Response schemas (reserved)
```
## 🔧 Instalare și Rulare
### Development Local
1. **Install dependencies**:
```bash
pip install -r requirements.txt
```
2. **Environment Variables**:
```bash
cp .env.example .env
# Editează .env cu configurările tale
```
3. **Run development server**:
```bash
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
4. **Access API**:
- API: http://localhost:8000
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
### Docker
```bash
# Build image
docker build -t roa-reports-backend .
# Run container
docker run -p 8000:8000 --env-file .env roa-reports-backend
```
## 📝 Configurație
### 🔐 Oracle Database Setup
**IMPORTANT**: Conectare la schema **CONTAFIN_ORACLE** pentru authentication!
#### Arhitectura Bazei de Date
- **Schema CONTAFIN_ORACLE**: Conține utilizatorii și procedura `pack_drepturi.verificautilizator`
- **Scheme separate pentru firme**: Fiecare firmă are propria schemă cu date (ROMFAST, EUROLEVIS, etc.)
#### Flow-ul de Autentificare
1. **Conectare**: La schema `CONTAFIN_ORACLE`
2. **Verificare**: User/pass prin `pack_drepturi.verificautilizator(username, password)`
3. **Drepturi**: Citire firme din `vdef_util_grup WHERE id_util = user_id`
4. **Selecție**: User selectează firma/schema pentru acces la date
### Environment Variables
```bash
# Oracle Database (prin SSH tunnel)
ORACLE_USER=CONTAFIN_ORACLE
ORACLE_PASSWORD=your_oracle_password
ORACLE_HOST=localhost
ORACLE_PORT=1521
ORACLE_SID=ROA
# SSH Tunnel Setup Required:
# ./ssh_tunnel.sh start
# Server: 83.103.197.79:22122 -> 10.0.20.36:1521
# Test User Credentials (pentru dezvoltare):
# Username: "MARIUS M" (cu spațiu în nume!)
# Password: "PAROLA81"
# Are acces la 66+ firme/scheme Oracle
# JWT Settings
JWT_SECRET_KEY=your-secret-key
ACCESS_TOKEN_EXPIRE_MINUTES=30
# API Settings
API_HOST=0.0.0.0
API_PORT=8000
DEBUG=True
# CORS
FRONTEND_URLS=http://localhost:3000,http://localhost:5173
```
## 🔐 Autentificare
API-ul folosește JWT Bearer tokens pentru autentificare:
1. **Login**: `POST /auth/login` cu username/password
2. **Token Usage**: Include `Authorization: Bearer <token>` în header
3. **Company Access**: Fiecare request verifică dacă utilizatorul are acces la firma specificată
## 📊 Models și Filtrare
### Invoice Filter Parameters
- `company`: Codul firmei (obligatoriu)
- `partner_type`: CLIENTI sau FURNIZORI
- `date_from`, `date_to`: Filtrare după dată
- `partner_name`: Filtrare după numele partenerului
- `only_unpaid`: Doar facturile neachitate
- `min_amount`, `max_amount`: Filtrare după sumă
- `page`, `page_size`: Paginare
### Response Models
- **InvoiceListResponse**: Lista paginată cu metadata
- **InvoiceSummary**: Statistici pentru dashboard
- **PaymentListResponse**: Lista încasări cu metadata
- **PaymentSummary**: Statistici încasări
## 🚀 Următorii Pași
1. **Export Functionality**: Implementare export Excel, PDF, CSV
2. **CRUD Operations**: Operațiuni complete pentru încasări
3. **Advanced Filtering**: Filtre avansate și sortare
4. **Caching**: Redis cache pentru performance
5. **Rate Limiting**: Advanced rate limiting
6. **Audit Logging**: Logging complet pentru operațiuni
## 🧪 Testing
```bash
# Unit tests (când vor fi implementate)
pytest tests/ -v
# Health check manual
curl http://localhost:8000/health
# API testing
# Vezi documentația Swagger la /docs pentru toate endpoint-urile
```
## 📚 Compatibilitate
API-ul este compatibil 100% cu query-urile și datele din aplicația Flask existentă:
- Stesso schema de date Oracle
- Aceleași view-uri (`vireg_parteneri`)
- Aceleași calcule pentru statusul facturilor
- Aceleași validări pentru acces utilizatori
---
**Status**: ✅ COMPLET - Ready for Frontend Integration
**Next Phase**: Frontend Vue.js Development

View File

View File

@@ -0,0 +1,222 @@
"""
ROA Reports API - FastAPI Backend
Aplicația principală pentru rapoarte facturi și încasări
"""
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import sys
import os
from datetime import datetime
from dotenv import load_dotenv
# Încărcare environment variables din .env
load_dotenv()
# Configurare TNS_ADMIN pentru Oracle
tns_path = os.path.join(os.path.dirname(__file__), '../../../../app')
os.environ['TNS_ADMIN'] = tns_path
# Adăugare path pentru shared modules
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../shared'))
from database.oracle_pool import oracle_pool
from auth.middleware import AuthenticationMiddleware
# from auth.routes import create_auth_router # Fixed inline
# Import routere locale
from app.routers import invoices, dashboard, treasury, companies, telegram
# Auth endpoints pentru test
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from datetime import datetime, timedelta
import jwt
import logging
logger = logging.getLogger(__name__)
# JWT Setup
JWT_SECRET = os.getenv("JWT_SECRET_KEY", "test-secret-key")
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_MINUTES = 30
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
access_token: str
refresh_token: str # Added refresh token
token_type: str
user: dict
def create_auth_router():
"""Create authentication router for testing"""
auth_router = APIRouter(tags=["authentication"])
@auth_router.post("/login", response_model=LoginResponse)
async def login(credentials: LoginRequest):
"""Autentificare utilizator prin Oracle database"""
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Call verificautilizator procedure using SELECT
cursor.execute("""
SELECT pack_drepturi.verificautilizator(:username, :password)
FROM DUAL
""", {
'username': credentials.username.upper(),
'password': credentials.password
})
result = cursor.fetchone()
verification_result = result[0] if result else -1
# Check if authentication was successful
if verification_result == -1:
raise HTTPException(status_code=401, detail="Invalid username or password")
# Get user companies - first get user ID from UTILIZATORI
cursor.execute("""
SELECT ID_UTIL, UTILIZATOR
FROM UTILIZATORI
WHERE UPPER(UTILIZATOR) = :username
""", {'username': credentials.username.upper()})
user_row = cursor.fetchone()
if not user_row:
raise HTTPException(status_code=401, detail="User not found in system")
user_id = user_row[0]
# Now get companies using the correct query structure
cursor.execute("""
SELECT A.ID_FIRMA, A.FIRMA
FROM V_NOM_FIRME A
WHERE A.ID_FIRMA IN (
SELECT ID_FIRMA
FROM VDEF_UTIL_FIRME
WHERE ID_PROGRAM = 2
AND ID_UTIL = :user_id
)
ORDER BY A.FIRMA
""", {'user_id': user_id})
companies_result = cursor.fetchall()
if not companies_result:
# Don't fail login if no companies - let frontend show message
companies = []
else:
companies = [str(row[0]) for row in companies_result]
# Create JWT token with all required fields
now = datetime.utcnow()
expire = now + timedelta(minutes=JWT_EXPIRE_MINUTES)
token_data = {
"username": credentials.username, # Changed from "sub" to "username"
"user_id": user_id, # Include user_id from database
"companies": companies,
"permissions": ["read", "reports"], # Default permissions
"exp": expire,
"iat": now, # Added issued at time
"type": "access" # Added token type
}
access_token = jwt.encode(token_data, JWT_SECRET, algorithm=JWT_ALGORITHM)
# Create refresh token
refresh_expire = now + timedelta(days=7)
refresh_token_data = {
"username": credentials.username,
"user_id": user_id,
"companies": companies,
"permissions": ["read", "reports"],
"exp": refresh_expire,
"iat": now,
"type": "refresh"
}
refresh_token = jwt.encode(refresh_token_data, JWT_SECRET, algorithm=JWT_ALGORITHM)
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token, # Include refresh token
token_type="bearer",
user={
"username": credentials.username,
"user_id": user_id, # Include user_id
"companies": companies,
"permissions": ["read", "reports"] # Include permissions
}
)
except Exception as e:
logger.error(f"Login error: {str(e)}")
raise HTTPException(status_code=500, detail="Internal authentication error")
return auth_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifecycle events pentru aplicație"""
# Startup
await oracle_pool.initialize()
print("[ROA Reports API] Started successfully")
yield
# Shutdown
await oracle_pool.close_pool()
print("[ROA Reports API] Stopped")
app = FastAPI(
title="ROA Reports API",
description="API pentru rapoarte ERP - facturi, încasări și alte rapoarte financiare",
version="1.0.0",
lifespan=lifespan
)
# CORS pentru frontend Vue.js
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allow all origins for production deployment
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Authentication middleware
print("[MAIN DEBUG] Adding AuthenticationMiddleware")
app.add_middleware(
AuthenticationMiddleware,
excluded_paths=[
"/", "/docs", "/health", "/api/auth/login", "/redoc", "/openapi.json",
"/api/telegram/auth/verify-user", # Public endpoint for Telegram bot
"/api/telegram/auth/refresh-token", # Public endpoint for token refresh
"/api/telegram/health" # Health check for Telegram router
]
)
print("[MAIN DEBUG] AuthenticationMiddleware added - FRESH RESTART - AUTH FIX APPLIED")
# Include routere with /api prefix
auth_router = create_auth_router()
app.include_router(auth_router, prefix="/api/auth", tags=["authentication"])
app.include_router(companies.router, prefix="/api/companies", tags=["companies"])
app.include_router(invoices.router, prefix="/api/invoices", tags=["invoices"])
app.include_router(dashboard.router, prefix="/api/dashboard", tags=["dashboard"])
app.include_router(treasury.router, prefix="/api/treasury", tags=["treasury"])
app.include_router(telegram.router, prefix="/api/telegram", tags=["telegram"])
@app.get("/")
async def root():
print("[MAIN DEBUG] Root endpoint accessed")
return {"message": "ROA Reports API", "version": "1.0.0", "status": "running"}
@app.get("/health")
async def health_check():
# Test database connection
try:
async with oracle_pool.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT 1 FROM DUAL")
return {"api": "healthy", "database": "connected", "timestamp": datetime.utcnow().isoformat()}
except Exception as e:
return {"api": "healthy", "database": f"error: {str(e)}", "timestamp": datetime.utcnow().isoformat()}

View File

@@ -0,0 +1,118 @@
from pydantic import BaseModel
from decimal import Decimal
from typing import List, Dict, Optional, Any
class TreasuryAccount(BaseModel):
"""Cont de trezorerie (bancă/casă)"""
cont: str # 5121, 5124, 5311, 5314
nume_cont: str # "Bancă LEI", "Casă VALUTA" etc
nume_banca: str # Numele băncii din vbalanta_parteneri.nume
sold: Decimal
valuta: str
class TrendData(BaseModel):
"""Model pentru datele de trend - MODEL VECHI"""
labels: List[str]
incasari: List[Decimal]
plati: List[Decimal]
trezorerie: List[Decimal]
incasari_total: Decimal
plati_total: Decimal
trezorerie_total: Decimal
incasari_change: Optional[float] = None
plati_change: Optional[float] = None
trezorerie_change: Optional[float] = None
class TrendsResponse(BaseModel):
"""Model pentru răspunsul endpoint-ului de trenduri - MODEL NOU"""
# Current period data
periods: List[str]
clienti_facturat: List[float]
clienti_incasat: List[float]
furnizori_facturat: List[float]
furnizori_achitat: List[float]
clienti_sold: List[float]
furnizori_sold: List[float]
trezorerie_sold: Optional[List[float]] = None
rata_incasare_clienti: List[float]
rata_achitare_furnizori: List[float]
# Previous period data (for year-over-year comparison in sparklines)
previous_periods: Optional[List[str]] = None
clienti_facturat_prev: Optional[List[float]] = None
clienti_incasat_prev: Optional[List[float]] = None
furnizori_facturat_prev: Optional[List[float]] = None
furnizori_achitat_prev: Optional[List[float]] = None
clienti_sold_prev: Optional[List[float]] = None
furnizori_sold_prev: Optional[List[float]] = None
trezorerie_sold_prev: Optional[List[float]] = None
# Metadata and analytics
metadata: Dict[str, Any]
growth_rates: Optional[Dict[str, float]] = None
class DashboardSummary(BaseModel):
"""Model pentru toate datele dashboard-ului"""
# CLIENȚI - statistici existente
clienti_total_facturat: Decimal # precdeb + debit (conturi 4111, 461)
clienti_total_incasat: Decimal # preccred + credit (conturi 4111, 461)
clienti_avansuri: Decimal # sold 419 (pasiv): credit - debit
clienti_sold_total: Decimal # (facturat - incasat) - avansuri
clienti_sold_restant: Decimal # sold cu datascad < azi
# CLIENȚI - NOI câmpuri pentru sold în termen
clienti_sold_in_termen: Decimal # sold cu datascad >= azi
# CLIENȚI - NOI detalieri restanțe (sold cu datascad < azi)
clienti_restant_7: Decimal # restant 1-7 zile
clienti_restant_14: Decimal # restant 8-14 zile
clienti_restant_30: Decimal # restant 15-30 zile
clienti_restant_60: Decimal # restant 31-60 zile
clienti_restant_90: Decimal # restant 61-90 zile
clienti_restant_90plus: Decimal # restant 90+ zile
# CLIENȚI - NOI detalieri scadențe (sold cu datascad >= azi)
clienti_scadent_7: Decimal # scadent în 1-7 zile
clienti_scadent_14: Decimal # scadent în 8-14 zile
clienti_scadent_30: Decimal # scadent în 15-30 zile
clienti_scadent_60: Decimal # scadent în 31-60 zile
clienti_scadent_90: Decimal # scadent în 61-90 zile
clienti_scadent_90plus: Decimal # scadent în 90+ zile
# FURNIZORI - statistici existente
furnizori_total_facturat: Decimal # preccred + credit (conturi 401, 404, 462)
furnizori_total_achitat: Decimal # precdeb + debit (conturi 401, 404, 462)
furnizori_avansuri: Decimal # sold 409x (activ): debit - credit
furnizori_sold_total: Decimal # (facturat - achitat) - avansuri
furnizori_sold_restant: Decimal # sold cu datascad < azi
# FURNIZORI - NOI câmpuri pentru sold în termen
furnizori_sold_in_termen: Decimal # sold cu datascad >= azi
# FURNIZORI - NOI detalieri restanțe (sold cu datascad < azi)
furnizori_restant_7: Decimal # restant 1-7 zile
furnizori_restant_14: Decimal # restant 8-14 zile
furnizori_restant_30: Decimal # restant 15-30 zile
furnizori_restant_60: Decimal # restant 31-60 zile
furnizori_restant_90: Decimal # restant 61-90 zile
furnizori_restant_90plus: Decimal # restant 90+ zile
# FURNIZORI - NOI detalieri scadențe (sold cu datascad >= azi)
furnizori_scadent_7: Decimal # scadent în 1-7 zile
furnizori_scadent_14: Decimal # scadent în 8-14 zile
furnizori_scadent_30: Decimal # scadent în 15-30 zile
furnizori_scadent_60: Decimal # scadent în 31-60 zile
furnizori_scadent_90: Decimal # scadent în 61-90 zile
furnizori_scadent_90plus: Decimal # scadent în 90+ zile
# TREZORERIE - existente
treasury_accounts: List[TreasuryAccount]
treasury_totals_by_currency: Dict[str, Decimal]
# DATE SUPLIMENTARE pentru trend analysis
clienti_facturat_luna_anterioara: Optional[Decimal] = Decimal('0')
furnizori_facturat_luna_anterioara: Optional[Decimal] = Decimal('0')
clienti_facturat_an_curent: Optional[Decimal] = Decimal('0')
clienti_facturat_an_anterior: Optional[Decimal] = Decimal('0')
furnizori_facturat_an_curent: Optional[Decimal] = Decimal('0')
furnizori_facturat_an_anterior: Optional[Decimal] = Decimal('0')

View File

@@ -0,0 +1,73 @@
"""
Modele Pydantic pentru facturi - Compatibile cu aplicația Flask existentă
"""
from pydantic import BaseModel, Field, validator
from datetime import date
from typing import Optional, List, Literal
from decimal import Decimal
class InvoiceBase(BaseModel):
"""Model de bază pentru factură - mapează exact pe rezultatul query-ului Flask"""
nume: str = Field(description="Numele partenerului")
nract: int = Field(description="Numărul actului")
dataact: Optional[date] = Field(description="Data actului")
datascad: Optional[date] = Field(description="Data scadentă")
contract: Optional[str] = Field(description="Numărul contractului")
cod_fiscal: Optional[str] = Field(description="Codul fiscal")
reg_comert: Optional[str] = Field(description="Registrul comerțului")
class Invoice(InvoiceBase):
"""Model complet pentru factură cu calcule financiare"""
totctva: Decimal = Field(description="Total cu TVA", decimal_places=2)
achitat: Decimal = Field(description="Suma achitată", decimal_places=2)
soldfinal: Decimal = Field(description="Soldul final", decimal_places=2)
css_class: Literal["", "invoice-paid", "invoice-overdue"] = Field(
default="", description="Clasa CSS pentru stilizare"
)
@validator('css_class', always=True)
def determine_css_class(cls, v, values):
"""Determină automat clasa CSS bazată pe status factură"""
if 'soldfinal' in values and 'datascad' in values:
sold = values['soldfinal']
data_scad = values['datascad']
if sold < 1:
return 'invoice-paid'
elif data_scad and data_scad < date.today() and sold != 0:
return 'invoice-overdue'
return ''
class InvoiceFilter(BaseModel):
"""Filtru pentru căutarea facturilor"""
company: str = Field(description="Codul firmei (schema Oracle)")
partner_type: Literal["CLIENTI", "FURNIZORI"] = Field(description="Tipul partenerului")
date_from: Optional[date] = Field(description="Data de început")
date_to: Optional[date] = Field(description="Data de sfârșit")
partner_name: Optional[str] = Field(description="Filtru după nume")
only_unpaid: bool = Field(default=True, description="Doar neachitate")
min_amount: Optional[Decimal] = Field(description="Suma minimă")
max_amount: Optional[Decimal] = Field(description="Suma maximă")
page: int = Field(default=1, ge=1, description="Pagina")
page_size: int = Field(default=50, ge=1, le=1000, description="Mărimea paginii")
class InvoiceListResponse(BaseModel):
"""Răspuns pentru lista de facturi"""
invoices: List[Invoice]
total_count: int
filtered_count: int
total_amount: Decimal
page: int
page_size: int
has_more: bool
class InvoiceSummary(BaseModel):
"""Rezumat pentru facturi - pentru dashboard"""
company: str
partner_type: str
total_invoices: int
total_amount: Decimal
paid_amount: Decimal
outstanding_amount: Decimal
overdue_amount: Decimal
overdue_count: int

View File

@@ -0,0 +1,37 @@
from pydantic import BaseModel
from decimal import Decimal
from datetime import datetime
from typing import Optional, List
class BankCashRegister(BaseModel):
"""Model pentru Registrul de Casă și Bancă"""
nume: str
nract: int
dataact: datetime
nume_cont_bancar: str # din vbalanta_parteneri.nume
incasari: Decimal
plati: Decimal
sold: Decimal
valuta: str
tip_registru: str # "BANCA LEI", "CASA VALUTA" etc
explicatia: str
class RegisterFilter(BaseModel):
"""Filtre pentru registrul de casă și bancă"""
company: str
date_from: Optional[datetime] = None
date_to: Optional[datetime] = None
partner_name: Optional[str] = None
page: int = 1
page_size: int = 50
class RegisterListResponse(BaseModel):
"""Răspuns pentru lista din registru"""
registers: List[BankCashRegister]
total_count: int
filtered_count: int
total_incasari: Decimal
total_plati: Decimal
page: int
page_size: int
has_more: bool

View File

@@ -0,0 +1,177 @@
"""
API Router pentru managementul firmelor
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from typing import List, Optional
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../../shared'))
from auth.dependencies import get_current_user
from auth.models import CurrentUser
from database.oracle_pool import oracle_pool
from pydantic import BaseModel
router = APIRouter(redirect_slashes=False)
class Company(BaseModel):
"""Model pentru firmă"""
id_firma: int # Cheia primară
name: str # Numele firmei
schema_name: str # Schema Oracle
fiscal_code: Optional[str] = None
is_active: bool = True
class CompanyListResponse(BaseModel):
"""Răspuns pentru lista de firme"""
companies: List[Company]
total_count: int
@router.get("", response_model=CompanyListResponse)
@router.get("/", response_model=CompanyListResponse)
async def get_user_companies(
request: Request,
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține lista firmelor la care utilizatorul are acces cu detalii complete
"""
print(f"[COMPANIES DEBUG] Request state: user={getattr(request.state, 'user', 'NOT_SET')}, is_authenticated={getattr(request.state, 'is_authenticated', 'NOT_SET')}")
print(f"[COMPANIES DEBUG] Authorization header: {request.headers.get('Authorization', 'NOT_SET')}")
try:
companies = []
# Obține toate companiile pentru utilizator direct din query-ul complet
# Ignorăm lista din JWT și recalculăm direct din Oracle pentru a obține toate cele 63 de companii
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
try:
# Primul pas: obținem ID-ul utilizatorului din UTILIZATORI
cursor.execute("""
SELECT ID_UTIL, UTILIZATOR
FROM UTILIZATORI
WHERE UPPER(UTILIZATOR) = :username
""", {'username': current_user.username.upper()})
user_row = cursor.fetchone()
if not user_row:
print(f"User {current_user.username} not found in UTILIZATORI table")
return CompanyListResponse(companies=[], total_count=0)
user_id = user_row[0]
print(f"Found user {current_user.username} with ID: {user_id}")
# Al doilea pas: obținem TOATE companiile pentru programul 2
cursor.execute("""
SELECT A.ID_FIRMA, A.FIRMA, A.SCHEMA, A.COD_FISCAL
FROM V_NOM_FIRME A
WHERE A.ID_FIRMA IN (
SELECT ID_FIRMA
FROM VDEF_UTIL_FIRME
WHERE ID_PROGRAM = 2 AND ID_UTIL = :user_id
)
ORDER BY A.FIRMA
""", {'user_id': user_id})
companies_rows = cursor.fetchall()
for row in companies_rows:
id_firma = row[0]
firma_name = row[1]
schema = row[2]
fiscal_code = row[3] # Poate fi NULL
company = Company(
id_firma=id_firma,
name=firma_name,
schema_name=schema,
fiscal_code=fiscal_code,
is_active=True
)
companies.append(company)
print(f"Found {len(companies)} companies for user {current_user.username}")
except Exception as e:
print(f"Eroare la obținerea companiilor din Oracle: {e}")
# Fallback: folosim lista din JWT dacă query-ul Oracle eșuează
for company_id in current_user.companies:
try:
id_firma = int(company_id)
company = Company(
id_firma=id_firma,
name=f"Company {id_firma}",
schema_name="",
fiscal_code="",
is_active=True
)
companies.append(company)
except ValueError:
# Skip invalid company IDs
continue
return CompanyListResponse(
companies=companies,
total_count=len(companies)
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Eroare la obținerea listei de firme: {str(e)}")
@router.get("/{company_id}", response_model=Company)
async def get_company_details(
company_id: str,
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține detaliile unei firme specifice
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company_id not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company_id}")
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Query pentru detaliile firmei
company_query = """
SELECT ID_FIRMA, FIRMA, SCHEMA, COD_FISCAL
FROM V_NOM_FIRME
WHERE ID_FIRMA = :company_id
"""
cursor.execute(company_query, {'company_id': int(company_id)})
row = cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail=f"Firma {company_id} nu a fost găsită")
return Company(
id_firma=row[0],
name=row[1],
schema_name=row[2],
fiscal_code=row[3] or "",
is_active=True
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Eroare la obținerea detaliilor firmei: {str(e)}")
@router.get("/{company_id}/validate")
async def validate_company_access(
company_id: str,
current_user: CurrentUser = Depends(get_current_user)
):
"""
Validează dacă utilizatorul are acces la o firmă specificată
"""
has_access = company_id in current_user.companies
return {
"company_id": company_id,
"has_access": has_access,
"user": current_user.username,
"message": "Acces validat" if has_access else "Acces refuzat"
}

View File

@@ -0,0 +1,327 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Optional
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../../shared'))
from auth.dependencies import get_current_user
from auth.models import CurrentUser
import logging
logger = logging.getLogger(__name__)
from ..models.dashboard import DashboardSummary, TrendsResponse, TrendData
from ..services.dashboard_service import DashboardService
router = APIRouter()
@router.get("/summary", response_model=DashboardSummary)
async def get_dashboard_summary(
company: str = Query(description="Codul firmei"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține toate datele pentru dashboard într-un singur apel
- Necesită autentificare JWT
- Returnează statistici clienți/furnizori și trezorerie
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
result = await DashboardService.get_complete_summary(company, current_user.username)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Eroare la obținerea datelor dashboard: {str(e)}")
@router.get("/trends", response_model=TrendsResponse)
async def get_dashboard_trends(
company: str = Query(description="Codul firmei"),
period: str = Query(default="30d", description="Perioada pentru trends: 7d, 30d, ytd, 12m"),
compare_previous: bool = Query(default=True, description="Compară cu perioada anterioară"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține trenduri pentru indicatorii principali (clienți/furnizori)
- period: "7d" (7 zile), "30d" (30 zile), "ytd" (year to date), "12m" (12 luni)
- compare_previous: dacă să compare cu perioada anterioară
- Necesită autentificare JWT
- Returnează date pentru grafice de trenduri
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
# Validează perioada
valid_periods = ["7d", "30d", "ytd", "12m"]
if period not in valid_periods:
raise HTTPException(
status_code=400,
detail=f"Perioadă nevalidă: {period}. Valori permise: {', '.join(valid_periods)}"
)
# Obține datele de trenduri
result = await DashboardService.get_trends(int(company), period)
# The service now returns the data in the correct format
# Return it directly as TrendsResponse
return TrendsResponse(**result)
except ValueError as e:
logger.error(f"Value error in trends endpoint: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea trendurilor: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea trendurilor: {str(e)}")
@router.get("/detailed-data")
async def get_detailed_data(
company: str = Query(description="Codul firmei"),
data_type: str = Query(description="Tipul de date: clients, suppliers, treasury"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1, le=100),
search: str = Query(default="", description="Termen de căutare"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține date detaliate pentru tabelele din dashboard
"""
logger.info(f"[ROUTER] detailed-data called: company={company}, data_type={data_type}")
try:
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
logger.info(f"[ROUTER] Calling DashboardService.get_detailed_data")
result = await DashboardService.get_detailed_data(
company=company,
data_type=data_type,
page=page,
page_size=page_size,
search=search
)
logger.info(f"[ROUTER] Service returned: {len(result.get('data', []))} rows")
return result
except Exception as e:
logger.error(f"Eroare la obținerea datelor detaliate: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/performance")
async def get_performance(
company: int = Query(..., description="ID-ul firmei"),
period: str = Query("7d", regex="^(7d|1m|3m|6m|ytd|12m)$", description="Perioada pentru analiză"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Returnează date performanță pentru perioada selectată
- Necesită autentificare JWT
- Returnează grafice încasări vs plăți pentru perioada selectată
- Calculează indicatori: rata încasării, cash conversion, working capital
"""
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_performance_data(company, period)
# Convert to Chart.js compatible format
return {
"labels": result.get("labels", []),
"datasets": [{
"data": result.get("data", []),
"label": result.get("label", "Performance"),
"borderColor": result.get("borderColor", "#3B82F6"),
"backgroundColor": result.get("backgroundColor", "rgba(59, 130, 246, 0.1)"),
"tension": 0.4
}]
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea datelor de performanță: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea datelor de performanță: {str(e)}")
@router.get("/cashflow")
async def get_cashflow(
company: int = Query(..., description="ID-ul firmei"),
period: str = Query("7d", regex="^(7d|1m|3m|6m)$", description="Perioada pentru previziune"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Returnează previziune cash flow pentru perioada selectată
- Necesită autentificare JWT
- Analizează scadențele viitoare pentru calculul cash flow-ului
- Identifică zilele critice cu deficit de cash
"""
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_cashflow_forecast(company, period)
# Convert to Chart.js compatible format
return {
"labels": result.get("labels", []),
"datasets": [{
"data": result.get("data", []),
"label": result.get("label", "Cash Flow"),
"borderColor": result.get("borderColor", "#10B981"),
"backgroundColor": result.get("backgroundColor", "rgba(16, 185, 129, 0.1)"),
"tension": 0.4
}]
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea previziunii cash flow: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea previziunii cash flow: {str(e)}")
@router.get("/maturity")
async def get_maturity_analysis(
company: int = Query(..., description="ID-ul firmei"),
period: str = Query("7d", regex="^(7d|1m|3m|6m|12m|all)$", description="Orizont de planificare pentru analiza scadențelor"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Returnează analiza scadențelor pentru orizontul de planificare selectat
- Necesită autentificare JWT
- Logică: Include TOATE restanțele + scadențele viitoare din perioada selectată
- Perioade disponibile:
* 7d: Toate restanțele + scadențe următoarelor 7 zile
* 1m: Toate restanțele + scadențe următoarelor 30 zile
* 3m: Toate restanțele + scadențe următoarelor 90 zile
* 6m: Toate restanțele + scadențe următoarelor 180 zile
* 12m: Toate restanțele + scadențe următoarelor 365 zile
* all: Toate soldurile (fără filtru)
- Compară scadențele clienți vs furnizori
- Calculează balanța și oferă recomandări
- Returnează metadate cu statistici complete
"""
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_maturity_analysis(company, period)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea analizei scadențelor: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea analizei scadențelor: {str(e)}")
@router.get("/monthly-flows")
async def get_monthly_flows(
company: int = Query(..., description="ID-ul firmei"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Returnează fluxurile lunare pentru firma selectată
- Necesită autentificare JWT
- Returnează date pentru analiza fluxurilor lunare
"""
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)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea fluxurilor lunare: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea fluxurilor lunare: {str(e)}")
@router.get("/treasury-breakdown")
async def get_treasury_breakdown(
company: int = Query(..., description="ID-ul firmei"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Returnează defalcarea trezoreriei pentru firma selectată
- Necesită autentificare JWT
- Returnează distribuția soldurilor pe conturi și tipuri
"""
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_treasury_breakdown(company)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea defalcării trezoreriei: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea defalcării trezoreriei: {str(e)}")
@router.get("/net-balance-breakdown")
async def get_net_balance_breakdown(
company: int = Query(..., description="ID-ul firmei"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Returnează defalcarea balanței nete pentru firma selectată
- Necesită autentificare JWT
- Returnează analiza detaliată a balanței nete
"""
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_net_balance_breakdown(company)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea defalcării balanței nete: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea defalcării balanței nete: {str(e)}")
@router.get("/current-period")
async def get_current_period(
company: int = Query(..., description="ID-ul firmei"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Returnează perioada curentă (an și lună) din calendarul Oracle
- Necesită autentificare JWT
- Returnează anul, luna și perioada curentă în format YYYY-MM
- Folosit pentru afișarea lunii curente în dashboard
"""
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_current_period(company)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Eroare la obținerea perioadei curente: {str(e)}")
raise HTTPException(status_code=500, detail=f"Eroare la obținerea perioadei curente: {str(e)}")

View File

@@ -0,0 +1,143 @@
"""
API Router pentru facturi
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Optional
from datetime import date
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../../shared'))
from auth.dependencies import get_current_user, require_company_access
from auth.models import CurrentUser
from ..models.invoice import InvoiceFilter, InvoiceListResponse, InvoiceSummary
from ..services.invoice_service import InvoiceService
router = APIRouter()
@router.get("/", response_model=InvoiceListResponse)
async def get_invoices(
company: str = Query(description="Codul firmei"),
partner_type: str = Query("CLIENTI", description="CLIENTI sau FURNIZORI"),
date_from: Optional[str] = Query(None, description="Data început (YYYY-MM-DD)"),
date_to: Optional[str] = Query(None, description="Data sfârșit (YYYY-MM-DD)"),
partner_name: Optional[str] = Query(None, description="Filtru nume partener"),
only_unpaid: bool = Query(True, description="Doar facturile neachitate"),
min_amount: Optional[float] = Query(None, description="Suma minimă"),
max_amount: Optional[float] = Query(None, description="Suma maximă"),
page: int = Query(1, ge=1, description="Pagina"),
page_size: int = Query(50, ge=1, le=1000, description="Mărimea paginii"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține lista de facturi pentru o firmă
- Necesită autentificare JWT
- Utilizatorul trebuie să aibă acces la firma specificată
- Suportă filtrare și paginare
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
# Convertește string-urile de date în obiecte date
date_from_obj = None
date_to_obj = None
if date_from:
try:
date_from_obj = date.fromisoformat(date_from)
except ValueError:
raise HTTPException(status_code=400, detail="Formatul datei de început este invalid. Folosiți YYYY-MM-DD")
if date_to:
try:
date_to_obj = date.fromisoformat(date_to)
except ValueError:
raise HTTPException(status_code=400, detail="Formatul datei de sfârșit este invalid. Folosiți YYYY-MM-DD")
filter_params = InvoiceFilter(
company=company,
partner_type=partner_type,
date_from=date_from_obj,
date_to=date_to_obj,
partner_name=partner_name,
only_unpaid=only_unpaid,
min_amount=min_amount,
max_amount=max_amount,
page=page,
page_size=page_size
)
result = await InvoiceService.get_invoices(filter_params, current_user.username)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Eroare la obținerea facturilor: {str(e)}")
@router.get("/summary", response_model=InvoiceSummary)
async def get_invoices_summary(
company: str = Query(description="Codul firmei"),
partner_type: str = Query("CLIENTI", description="CLIENTI sau FURNIZORI"),
current_user: CurrentUser = Depends(get_current_user)
):
"""Obține rezumatul facturilor pentru dashboard"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
result = await InvoiceService.get_invoice_summary(company, partner_type, current_user.username)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"Eroare la obținerea rezumatului facturilor: {str(e)}")
@router.get("/{invoice_number}")
async def get_invoice_details(
invoice_number: str,
company: str = Query(description="Codul firmei"),
current_user: CurrentUser = Depends(get_current_user)
):
"""Obține detaliile unei facturi specifice"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
result = await InvoiceService.get_invoice_details(company, invoice_number, current_user.username)
return result
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Eroare la obținerea detaliilor facturii: {str(e)}")
@router.get("/export/{format}")
async def export_invoices(
format: str,
company: str = Query(description="Codul firmei"),
partner_type: str = Query("CLIENTI", description="CLIENTI sau FURNIZORI"),
date_from: Optional[str] = Query(None, description="Data început (YYYY-MM-DD)"),
date_to: Optional[str] = Query(None, description="Data sfârșit (YYYY-MM-DD)"),
partner_name: Optional[str] = Query(None, description="Filtru nume partener"),
only_unpaid: bool = Query(True, description="Doar facturile neachitate"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Export facturi în format specificat (excel, pdf, csv)
Această funcție va fi implementată în viitor
"""
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
# Verifică formatul
if format not in ["excel", "pdf", "csv"]:
raise HTTPException(status_code=400, detail="Format invalid. Formatele suportate sunt: excel, pdf, csv")
# Pentru moment, returnează o eroare că funcția nu este implementată
raise HTTPException(status_code=501, detail=f"Export în format {format} nu este încă implementat")

View File

@@ -0,0 +1,559 @@
"""
API Router pentru Telegram Bot Integration
Furnizează endpoint-uri pentru autentificare, linking și export rapoarte pentru Telegram bot
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from typing import List, Optional, Dict, Any
import sys
import os
import secrets
import string
import httpx
from datetime import datetime, timedelta
from pydantic import BaseModel, Field
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../../shared'))
from auth.dependencies import get_current_user
from auth.models import CurrentUser
from auth.jwt_handler import jwt_handler
from database.oracle_pool import oracle_pool
# Telegram bot internal API URL (running on same server)
TELEGRAM_BOT_INTERNAL_API = os.getenv("TELEGRAM_BOT_INTERNAL_API", "http://localhost:8002")
router = APIRouter(redirect_slashes=False)
# ==================== Schemas ====================
class GenerateCodeRequest(BaseModel):
"""Request pentru generarea unui cod de linking"""
telegram_user_id: int = Field(description="ID-ul utilizatorului Telegram")
telegram_username: Optional[str] = Field(default=None, description="Username-ul Telegram")
telegram_first_name: Optional[str] = Field(default=None, description="Prenumele utilizatorului")
telegram_last_name: Optional[str] = Field(default=None, description="Numele utilizatorului")
class GenerateCodeResponse(BaseModel):
"""Response pentru generarea unui cod de linking"""
linking_code: str = Field(description="Codul de linking generat (8 caractere)")
expires_at: datetime = Field(description="Data și ora expirării codului")
expires_in_minutes: int = Field(description="Minutele până la expirare")
class VerifyUserRequest(BaseModel):
"""
Request pentru verificarea utilizatorului în Oracle
Suportă 2 flow-uri:
1. Auto-linking (recomandat): doar linking_code și oracle_username
- Bot-ul verifică codul în SQLite, extrage oracle_username
- Backend face lookup în Oracle fără verificare parolă
- Codul valid este proof-of-authorization
2. Full verification (opțional): username, password, linking_code
- Verificare completă cu parolă în Oracle
"""
linking_code: str = Field(description="Codul de linking de la /generate-code")
oracle_username: Optional[str] = Field(default=None, description="Username Oracle (pentru auto-linking)")
username: Optional[str] = Field(default=None, description="Username pentru verificare completă")
password: Optional[str] = Field(default=None, description="Parolă pentru verificare completă")
class VerifyUserResponse(BaseModel):
"""Response pentru verificarea utilizatorului"""
success: bool = Field(description="True dacă verificarea a avut succes")
access_token: Optional[str] = Field(default=None, description="JWT access token")
refresh_token: Optional[str] = Field(default=None, description="JWT refresh token")
user: Optional[Dict[str, Any]] = Field(default=None, description="Detalii utilizator")
message: str = Field(description="Mesaj de status")
class RefreshTokenRequest(BaseModel):
"""Request pentru refresh JWT token"""
refresh_token: str = Field(description="Refresh token-ul obținut la autentificare")
class RefreshTokenResponse(BaseModel):
"""Response pentru refresh token"""
access_token: str = Field(description="Noul JWT access token")
expires_in: int = Field(description="Timpul de expirare în secunde")
token_type: str = Field(default="bearer", description="Tipul token-ului")
class ExportReportRequest(BaseModel):
"""Request pentru exportul unui raport"""
company_id: int = Field(description="ID-ul firmei")
report_type: str = Field(description="Tipul raportului (invoices, payments, dashboard)")
format: str = Field(default="excel", description="Formatul exportului (excel, pdf, csv)")
filters: Optional[Dict[str, Any]] = Field(default=None, description="Filtre pentru raport")
class ExportReportResponse(BaseModel):
"""Response pentru exportul raportului"""
success: bool = Field(description="True dacă exportul a avut succes")
file_url: Optional[str] = Field(default=None, description="URL-ul fișierului generat")
file_name: Optional[str] = Field(default=None, description="Numele fișierului generat")
file_size_bytes: Optional[int] = Field(default=None, description="Mărimea fișierului în bytes")
message: str = Field(description="Mesaj de status")
# ==================== Helper Functions ====================
def generate_linking_code(length: int = 8) -> str:
"""
Generează un cod alfanumeric aleatoriu pentru linking
Args:
length: Lungimea codului (default: 8)
Returns:
Codul generat (uppercase alphanumeric)
"""
alphabet = string.ascii_uppercase + string.digits
# Exclude caractere care pot fi confundate: 0, O, I, 1
alphabet = alphabet.replace('0', '').replace('O', '').replace('I', '').replace('1', '')
return ''.join(secrets.choice(alphabet) for _ in range(length))
async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"""
Obține informații despre utilizator din Oracle FĂRĂ verificare parolă.
Folosit pentru auto-linking când utilizatorul a fost deja autentificat
prin generarea unui linking code valid în aplicația web.
Args:
username: Username-ul utilizatorului Oracle
Returns:
Dict cu informații despre utilizator sau None dacă nu există
"""
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Obține detalii utilizator
cursor.execute("""
SELECT ID_UTIL, UTILIZATOR
FROM UTILIZATORI
WHERE UPPER(UTILIZATOR) = :username
""", {'username': username.upper()})
user_row = cursor.fetchone()
if not user_row:
return None
user_id = user_row[0]
actual_username = user_row[1]
# Obține companiile utilizatorului
cursor.execute("""
SELECT A.ID_FIRMA, A.FIRMA
FROM V_NOM_FIRME A
WHERE A.ID_FIRMA IN (
SELECT ID_FIRMA
FROM VDEF_UTIL_FIRME
WHERE ID_PROGRAM = 2
AND ID_UTIL = :user_id
)
ORDER BY A.FIRMA
""", {'user_id': user_id})
companies_result = cursor.fetchall()
companies = [str(row[0]) for row in companies_result]
return {
'user_id': user_id,
'username': actual_username,
'companies': companies,
'permissions': ['read', 'reports']
}
except Exception as e:
print(f"Error getting Oracle user by username: {e}")
return None
async def verify_oracle_user(username: str, password: str) -> Optional[Dict[str, Any]]:
"""
Verifică utilizatorul în Oracle folosind pack_drepturi.verificautilizator
Args:
username: Username-ul utilizatorului
password: Parola utilizatorului
Returns:
Dict cu informații despre utilizator sau None dacă verificarea eșuează
"""
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Verifică autentificarea
cursor.execute("""
SELECT pack_drepturi.verificautilizator(:username, :password)
FROM DUAL
""", {
'username': username.upper(),
'password': password
})
result = cursor.fetchone()
verification_result = result[0] if result else -1
if verification_result == -1:
return None
# Obține detalii utilizator
cursor.execute("""
SELECT ID_UTIL, UTILIZATOR
FROM UTILIZATORI
WHERE UPPER(UTILIZATOR) = :username
""", {'username': username.upper()})
user_row = cursor.fetchone()
if not user_row:
return None
user_id = user_row[0]
# Obține companiile utilizatorului
cursor.execute("""
SELECT A.ID_FIRMA, A.FIRMA
FROM V_NOM_FIRME A
WHERE A.ID_FIRMA IN (
SELECT ID_FIRMA
FROM VDEF_UTIL_FIRME
WHERE ID_PROGRAM = 2
AND ID_UTIL = :user_id
)
ORDER BY A.FIRMA
""", {'user_id': user_id})
companies_result = cursor.fetchall()
companies = [str(row[0]) for row in companies_result]
return {
'user_id': user_id,
'username': username,
'companies': companies,
'permissions': ['read', 'reports']
}
except Exception as e:
print(f"Error verifying Oracle user: {e}")
return None
# ==================== Endpoints ====================
@router.post("/auth/generate-code", response_model=GenerateCodeResponse)
async def generate_linking_code_endpoint(
current_user: CurrentUser = Depends(get_current_user)
):
"""
Generează un cod de linking pentru conectarea unui utilizator Telegram
Flow:
1. Utilizatorul autentificat în aplicație solicită un cod
2. Se generează un cod unic de 8 caractere
3. Codul este trimis la Telegram bot pentru salvare în SQLite cu TTL de 15 minute
4. Utilizatorul introduce codul în Telegram bot pentru linking
Note:
- Acest endpoint necesită autentificare JWT (utilizatorul trebuie să fie logat în aplicație)
- Codul expiră după 15 minute
- Fiecare request generează un cod nou (codurile vechi devin invalide)
- Nu este nevoie de telegram_user_id în acest moment (utilizatorul nu e încă conectat la Telegram)
"""
try:
# Generează cod unic
linking_code = generate_linking_code()
# Setează expirarea la 15 minute
expires_at = datetime.utcnow() + timedelta(minutes=15)
expires_in_minutes = 15
# Salvează codul în database-ul Telegram bot (SQLite) via internal API
try:
async with httpx.AsyncClient(timeout=5.0) as client:
save_code_response = await client.post(
f"{TELEGRAM_BOT_INTERNAL_API}/internal/save-code",
json={
"code": linking_code,
"telegram_user_id": 0, # Not known yet (user hasn't linked)
"oracle_username": current_user.username,
"expires_in_minutes": expires_in_minutes
}
)
# Accept both 200 (OK) and 201 (Created) as success
if save_code_response.status_code not in [200, 201]:
raise HTTPException(
status_code=500,
detail=f"Failed to save code to Telegram bot: {save_code_response.text}"
)
except httpx.TimeoutException:
raise HTTPException(
status_code=503,
detail="Telegram bot service is not responding. Please try again later."
)
except httpx.ConnectError:
raise HTTPException(
status_code=503,
detail="Cannot connect to Telegram bot service. Please contact administrator."
)
return GenerateCodeResponse(
linking_code=linking_code,
expires_at=expires_at,
expires_in_minutes=expires_in_minutes
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Eroare la generarea codului de linking: {str(e)}"
)
@router.post("/auth/verify-user", response_model=VerifyUserResponse)
async def verify_user_endpoint(request: VerifyUserRequest):
"""
Verifică utilizatorul în Oracle și returnează JWT tokens
Suportă 2 flow-uri de autentificare:
Flow A - Auto-linking (RECOMANDAT):
1. Bot verifică linking_code în SQLite (code valid = user s-a autentificat în web app)
2. Bot extrage oracle_username din cod
3. Bot trimite: {linking_code, oracle_username}
4. Backend face lookup în Oracle (FĂRĂ verificare parolă)
5. Backend generează și returnează JWT tokens
Flow B - Full verification (OPȚIONAL):
1. Bot cere username și parolă de la user în Telegram
2. Bot trimite: {linking_code, username, password}
3. Backend verifică credențialele în Oracle
4. Backend generează și returnează JWT tokens
Note:
- Acest endpoint NU necesită autentificare JWT (este public pentru bot)
- Flow A oferă UX superior (fără re-introducere parolă)
- Linking code-ul valid este proof-of-authorization
"""
try:
# Flow A: Auto-linking (oracle_username provided, no password)
if request.oracle_username and not request.password:
user_data = await get_oracle_user_by_username(request.oracle_username)
if not user_data:
return VerifyUserResponse(
success=False,
message=f"Utilizatorul {request.oracle_username} nu există în Oracle"
)
# Flow B: Full verification (username + password provided)
elif request.username and request.password:
user_data = await verify_oracle_user(request.username, request.password)
if not user_data:
return VerifyUserResponse(
success=False,
message="Username sau parolă incorectă"
)
# Invalid request (missing required fields)
else:
return VerifyUserResponse(
success=False,
message="Trebuie furnizat fie oracle_username (auto-linking) fie username+password (verificare completă)"
)
# Generează JWT tokens
access_token = jwt_handler.create_access_token(
username=user_data['username'],
companies=user_data['companies'],
user_id=user_data['user_id'],
permissions=user_data['permissions']
)
refresh_token = jwt_handler.create_refresh_token(
username=user_data['username'],
user_id=user_data['user_id']
)
return VerifyUserResponse(
success=True,
access_token=access_token,
refresh_token=refresh_token,
user={
'user_id': user_data['user_id'],
'username': user_data['username'],
'companies': user_data['companies'],
'permissions': user_data['permissions']
},
message="Autentificare reușită"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Eroare la verificarea utilizatorului: {str(e)}"
)
@router.post("/auth/refresh-token", response_model=RefreshTokenResponse)
async def refresh_token_endpoint(request: RefreshTokenRequest):
"""
Refresh-uiește un JWT access token folosind refresh token-ul
Acest endpoint este folosit de Telegram bot pentru a obține un nou access token
când cel curent expiră, fără a solicita din nou username/password.
Flow:
1. Botul Telegram detectează că access token-ul a expirat
2. Trimite refresh token-ul la acest endpoint
3. Se validează refresh token-ul și se generează un nou access token
4. Botul stochează noul access token în SQLite
Note:
- Refresh token-ul este valid 7 zile (vs 30 minute pentru access token)
- Dacă refresh token-ul expiră, utilizatorul trebuie să se re-autentifice
"""
try:
# Verifică refresh token-ul
token_data = jwt_handler.verify_token(request.refresh_token)
if not token_data or token_data.token_type != "refresh":
raise HTTPException(
status_code=401,
detail="Refresh token invalid sau expirat"
)
# Obține companiile actualizate din Oracle
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT A.ID_FIRMA
FROM V_NOM_FIRME A
WHERE A.ID_FIRMA IN (
SELECT ID_FIRMA
FROM VDEF_UTIL_FIRME
WHERE ID_PROGRAM = 2
AND ID_UTIL = :user_id
)
ORDER BY A.FIRMA
""", {'user_id': token_data.user_id})
companies_result = cursor.fetchall()
companies = [str(row[0]) for row in companies_result]
# Generează nou access token
new_access_token = jwt_handler.create_access_token(
username=token_data.username,
companies=companies,
user_id=token_data.user_id,
permissions=token_data.permissions
)
return RefreshTokenResponse(
access_token=new_access_token,
expires_in=jwt_handler.access_token_expire_minutes * 60,
token_type="bearer"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Eroare la refresh token: {str(e)}"
)
@router.post("/export", response_model=ExportReportResponse)
async def export_report_endpoint(
request: ExportReportRequest,
current_user: CurrentUser = Depends(get_current_user)
):
"""
Exportă un raport în format Excel, PDF sau CSV
Acest endpoint este folosit de Telegram bot pentru a genera rapoarte
și a le trimite utilizatorului.
Flow:
1. Botul trimite cerere de export cu parametrii raportului
2. Se validează că utilizatorul are acces la firma specificată
3. Se generează raportul în formatul solicitat
4. Se returnează URL-ul sau conținutul fișierului
Tipuri de rapoarte suportate:
- invoices: Facturi (cu filtre: dată, status, client)
- payments: Încasări (cu filtre: dată, metodă plată)
- dashboard: Statistici dashboard (rezumat)
Formate suportate:
- excel: XLSX (cel mai complet)
- pdf: PDF (pentru printing)
- csv: CSV (pentru import în alte sisteme)
Note:
- Utilizatorul trebuie să aibă acces la firma specificată
- Fișierele generate sunt temporare (șterse după 1 oră)
"""
try:
# Verifică accesul la firmă
company_id_str = str(request.company_id)
if company_id_str not in current_user.companies:
raise HTTPException(
status_code=403,
detail=f"Nu aveți acces la firma {request.company_id}"
)
# TODO: Implementare export în funcție de report_type și format
# Deocamdată returnăm un placeholder
return ExportReportResponse(
success=True,
file_url=f"/api/telegram/downloads/report_{request.report_type}_{request.company_id}.{request.format}",
file_name=f"raport_{request.report_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{request.format}",
file_size_bytes=0,
message=f"Raport {request.report_type} generat cu succes în format {request.format}"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Eroare la generarea raportului: {str(e)}"
)
@router.get("/health")
async def telegram_health_check():
"""
Health check pentru routerul Telegram
Verifică conectivitatea la Oracle și disponibilitatea serviciilor
"""
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT 1 FROM DUAL")
return {
"status": "healthy",
"service": "telegram-router",
"database": "connected",
"timestamp": datetime.utcnow().isoformat()
}
except Exception as e:
return {
"status": "degraded",
"service": "telegram-router",
"database": f"error: {str(e)}",
"timestamp": datetime.utcnow().isoformat()
}

View File

@@ -0,0 +1,67 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Optional
from datetime import date
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../../shared'))
from auth.dependencies import get_current_user
from auth.models import CurrentUser
from ..models.treasury import RegisterFilter, RegisterListResponse
from ..services.treasury_service import TreasuryService
router = APIRouter()
@router.get("/bank-cash-register", response_model=RegisterListResponse)
async def get_bank_cash_register(
company: str = Query(description="Codul firmei"),
date_from: Optional[str] = Query(None, description="Data început (YYYY-MM-DD)"),
date_to: Optional[str] = Query(None, description="Data sfârșit (YYYY-MM-DD)"),
partner_name: Optional[str] = Query(None, description="Filtru nume partener"),
page: int = Query(1, ge=1, description="Pagina"),
page_size: int = Query(50, ge=1, le=1000, description="Mărimea paginii"),
current_user: CurrentUser = Depends(get_current_user)
):
"""
Obține registrul de casă și bancă
- Necesită autentificare JWT
- Suportă filtrare și paginare
"""
try:
# Verifică dacă utilizatorul are acces la firma specificată
if company not in current_user.companies:
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
# Convertește datele
date_from_obj = None
date_to_obj = None
if date_from:
try:
date_from_obj = date.fromisoformat(date_from)
except ValueError:
raise HTTPException(status_code=400, detail="Format dată început invalid")
if date_to:
try:
date_to_obj = date.fromisoformat(date_to)
except ValueError:
raise HTTPException(status_code=400, detail="Format dată sfârșit invalid")
filter_params = RegisterFilter(
company=company,
date_from=date_from_obj,
date_to=date_to_obj,
partner_name=partner_name,
page=page,
page_size=page_size
)
result = await TreasuryService.get_bank_cash_register(filter_params, current_user.username)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Eroare la obținerea registrului: {str(e)}")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,268 @@
"""
Service pentru logica facturi - Portează query-urile din aplicația Flask
"""
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../../shared'))
from database.oracle_pool import oracle_pool
from typing import List, Tuple
from ..models.invoice import Invoice, InvoiceFilter, InvoiceListResponse, InvoiceSummary
from decimal import Decimal
import logging
logger = logging.getLogger(__name__)
class InvoiceService:
"""Service pentru gestionarea facturilor"""
@staticmethod
async def get_invoices(filter_params: InvoiceFilter, username: str) -> InvoiceListResponse:
"""
Obține lista de facturi - Query simplu pentru afișare în tabel
"""
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Obține schema din v_nom_firme bazat pe id_firma
company_id = int(filter_params.company)
schema_query = "SELECT schema FROM CONTAFIN_ORACLE.v_nom_firme WHERE id_firma = :company_id"
cursor.execute(schema_query, {'company_id': company_id})
schema_result = cursor.fetchone()
if not schema_result:
raise ValueError(f"Schema nu a fost găsită pentru id_firma {company_id}")
schema = schema_result[0]
# Determină conturile în funcție de partner_type
if filter_params.partner_type == "CLIENTI":
conturi = "'4111', '461'"
elif filter_params.partner_type == "FURNIZORI":
conturi = "'401', '404', '462'"
else:
conturi = "'4111'" # default
# Query cu calculele corecte pentru solduri
base_query = f"""
SELECT
vp.NUME,
vp.NRACT,
vp.DATAACT,
vp.DATASCAD,
vp.CONTRACT,
vp.COD_FISCAL,
vp.REG_COMERT,
CASE
WHEN vp.CONT IN ('4111','461') THEN vp.PRECDEB + vp.DEBIT -- Total facturat clienți
WHEN vp.CONT IN ('401','404','462') THEN vp.PRECCRED + vp.CREDIT -- Total facturat furnizori
END as total_facturat,
CASE
WHEN vp.CONT IN ('4111','461') THEN vp.PRECCRED + vp.CREDIT -- Încasat clienți
WHEN vp.CONT IN ('401','404','462') THEN vp.PRECDEB + vp.DEBIT -- Achitat furnizori
END as achitat,
CASE
WHEN vp.CONT IN ('4111','461') THEN
(vp.PRECDEB + vp.DEBIT) - (vp.PRECCRED + vp.CREDIT) -- Sold clienți
WHEN vp.CONT IN ('401','404','462') THEN
(vp.PRECCRED + vp.CREDIT) - (vp.PRECDEB + vp.DEBIT) -- Sold furnizori
END as sold,
vp.CONT,
CASE
WHEN vp.DATASCAD < SYSDATE THEN 'restant'
ELSE 'in_termen'
END as status
FROM {schema}.vireg_parteneri vp
WHERE vp.an = (SELECT anul FROM {schema}.calendar WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar))
AND vp.luna = (SELECT luna FROM {schema}.calendar WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar))
AND (
(:partner_type = 'CLIENTI' AND vp.cont IN ('4111', '461'))
OR
(:partner_type = 'FURNIZORI' AND vp.cont IN ('401', '404', '462'))
)
"""
params = {'partner_type': filter_params.partner_type}
# Adaugă filtre dinamice
if filter_params.date_from:
base_query += " AND vp.dataact >= :date_from"
params['date_from'] = filter_params.date_from
if filter_params.date_to:
base_query += " AND vp.dataact <= :date_to"
params['date_to'] = filter_params.date_to
if filter_params.partner_name:
base_query += " AND UPPER(vp.nume) LIKE UPPER(:partner_name)"
params['partner_name'] = f"%{filter_params.partner_name}%"
if filter_params.min_amount:
base_query += " AND total_facturat >= :min_amount"
params['min_amount'] = filter_params.min_amount
if filter_params.max_amount:
base_query += " AND total_facturat <= :max_amount"
params['max_amount'] = filter_params.max_amount
if filter_params.only_unpaid:
# Nu putem folosi aliasul "sold" în WHERE în Oracle, trebuie să repetăm calculul
base_query += """ AND (
CASE
WHEN vp.CONT IN ('4111','461') THEN
(vp.PRECDEB + vp.DEBIT) - (vp.PRECCRED + vp.CREDIT)
WHEN vp.CONT IN ('401','404','462') THEN
(vp.PRECCRED + vp.CREDIT) - (vp.PRECDEB + vp.DEBIT)
END
) > 0"""
# Count total pentru paginare
count_query = f"SELECT COUNT(*) FROM ({base_query})"
cursor.execute(count_query, params)
total_count = cursor.fetchone()[0]
# Adaugă ORDER BY și paginare
base_query += " ORDER BY vp.DATAACT DESC, vp.NUME, vp.NRACT"
# Paginare Oracle
offset = (filter_params.page - 1) * filter_params.page_size
limit = offset + filter_params.page_size
paginated_query = f"""
SELECT * FROM (
SELECT ROWNUM as rn, t.* FROM ({base_query}) t WHERE ROWNUM <= :limit
) WHERE rn > :offset
"""
params['offset'] = offset
params['limit'] = limit
cursor.execute(paginated_query, params)
rows = cursor.fetchall()
# Procesează rezultatele cu structura nouă
invoices = []
total_amount = Decimal('0.00')
for row in rows:
# Skip ROWNUM, extrage valorile din query-ul nou
nume = row[1]
nract = row[2]
dataact = row[3]
datascad = row[4]
contract = row[5]
cod_fiscal = row[6]
reg_comert = row[7]
total_facturat = Decimal(str(row[8] or 0))
achitat = Decimal(str(row[9] or 0))
sold = Decimal(str(row[10] or 0))
cont = row[11]
status = row[12]
invoice_data = {
'nume': nume or '',
'nract': nract or 0,
'dataact': dataact,
'datascad': datascad,
'contract': contract,
'cod_fiscal': cod_fiscal,
'reg_comert': reg_comert,
'totctva': total_facturat,
'achitat': achitat,
'soldfinal': sold
}
invoice = Invoice(**invoice_data)
invoices.append(invoice)
total_amount += total_facturat
return InvoiceListResponse(
invoices=invoices,
total_count=total_count,
filtered_count=len(invoices),
total_amount=total_amount,
page=filter_params.page,
page_size=filter_params.page_size,
has_more=len(invoices) == filter_params.page_size
)
@staticmethod
async def get_invoice_details(company: str, invoice_number: str, username: str) -> Invoice:
"""
Obține detaliile unei facturi specifice
"""
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Obține schema din v_nom_firme bazat pe id_firma
company_id = int(company)
schema_query = "SELECT schema FROM CONTAFIN_ORACLE.v_nom_firme WHERE id_firma = :company_id"
cursor.execute(schema_query, {'company_id': company_id})
schema_result = cursor.fetchone()
if not schema_result:
raise ValueError(f"Schema nu a fost găsită pentru id_firma {company_id}")
schema = schema_result[0]
# Query simplu pentru detalii factură
detail_query = f"""
SELECT
NUME,
NRACT,
DATAACT,
DATASCAD,
CONTRACT,
COD_FISCAL,
REG_COMERT,
PRECDEB,
PRECCRED,
DEBIT,
CREDIT,
CONT
FROM {schema}.vireg_parteneri
WHERE nract = :invoice_number
AND an = (select anul from {schema}.calendar where anul*12+luna = (select max(anul*12+luna) as anmax from {schema}.calendar))
AND luna = (select luna from {schema}.calendar where anul*12+luna = (select max(anul*12+luna) as anmax from {schema}.calendar))
"""
cursor.execute(detail_query, {'invoice_number': invoice_number})
row = cursor.fetchone()
if not row:
raise ValueError(f"Factura {invoice_number} nu a fost găsită")
# Extrage valorile
nume = row[0]
nract = row[1]
dataact = row[2]
datascad = row[3]
contract = row[4]
cod_fiscal = row[5]
reg_comert = row[6]
precdeb = Decimal(str(row[7] or 0))
preccred = Decimal(str(row[8] or 0))
debit = Decimal(str(row[9] or 0))
credit = Decimal(str(row[10] or 0))
cont = row[11]
# Calculează valorile în funcție de tipul contului
if cont in ('4111', '461'): # CLIENTI
totctva = precdeb + debit
achitat = preccred + credit
soldfinal = precdeb - preccred + debit - credit
else: # FURNIZORI
totctva = preccred + credit
achitat = precdeb + debit
soldfinal = preccred - precdeb + credit - debit
invoice_data = {
'nume': nume or '',
'nract': nract or 0,
'dataact': dataact,
'datascad': datascad,
'contract': contract,
'cod_fiscal': cod_fiscal,
'reg_comert': reg_comert,
'totctva': totctva,
'achitat': achitat,
'soldfinal': soldfinal
}
return Invoice(**invoice_data)

View File

@@ -0,0 +1,161 @@
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../../shared'))
from database.oracle_pool import oracle_pool
from ..models.treasury import BankCashRegister, RegisterFilter, RegisterListResponse
from decimal import Decimal
import logging
logger = logging.getLogger(__name__)
class TreasuryService:
"""Service pentru trezorerie - registru casă și bancă"""
@staticmethod
async def get_bank_cash_register(filter_params: RegisterFilter, username: str) -> RegisterListResponse:
"""
Obține registrul de casă și bancă din vbancasa views
"""
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Obține schema
company_id = int(filter_params.company)
schema_query = "SELECT schema FROM CONTAFIN_ORACLE.v_nom_firme WHERE id_firma = :company_id"
cursor.execute(schema_query, {'company_id': company_id})
schema_result = cursor.fetchone()
if not schema_result:
raise ValueError(f"Schema nu a fost găsită pentru id_firma {company_id}")
schema = schema_result[0]
# Query pentru registrele de bancă și casă
union_queries = []
# BANCA LEI (5121)
union_queries.append(f"""
SELECT
vb.nume, vb.nract, vb.dataact, vb.bancasa,
vb.incasari, vb.plati,
vb.incasari - vb.plati as sold,
'RON' as valuta,
'BANCA LEI' as tip_registru,
vb.explicatia
FROM {schema}.vbancasa_5121_cum vb
WHERE (vb.incasari > 0 OR vb.plati > 0)
""")
# BANCA VALUTA (5124)
union_queries.append(f"""
SELECT
vb.nume, vb.nract, vb.dataact, vb.bancasa,
vb.incasval, vb.platival,
vb.incasval - vb.platival as sold,
COALESCE(vb.numeval, 'EUR') as valuta,
'BANCA VALUTA' as tip_registru,
vb.explicatia
FROM {schema}.vbancasa_5124_cum vb
WHERE (vb.incasval > 0 OR vb.platival > 0)
""")
# CASA LEI (5311)
union_queries.append(f"""
SELECT
vb.nume, vb.nract, vb.dataact, vb.bancasa,
vb.incasari, vb.plati,
vb.incasari - vb.plati as sold,
'RON' as valuta,
'CASA LEI' as tip_registru,
vb.explicatia
FROM {schema}.vbancasa_5311_cum vb
WHERE (vb.incasari > 0 OR vb.plati > 0)
""")
# CASA VALUTA (5314)
union_queries.append(f"""
SELECT
vb.nume, vb.nract, vb.dataact, vb.bancasa,
vb.incasval, vb.platival,
vb.incasval - vb.platival as sold,
COALESCE(vb.numeval, 'EUR') as valuta,
'CASA VALUTA' as tip_registru,
vb.explicatia
FROM {schema}.vbancasa_5314_cum vb
WHERE (vb.incasval > 0 OR vb.platival > 0)
""")
base_query = " UNION ALL ".join(union_queries)
params = {}
where_conditions = []
if filter_params.date_from:
where_conditions.append("dataact >= :date_from")
params['date_from'] = filter_params.date_from
if filter_params.date_to:
where_conditions.append("dataact <= :date_to")
params['date_to'] = filter_params.date_to
if filter_params.partner_name:
where_conditions.append("UPPER(nume) LIKE UPPER(:partner_name)")
params['partner_name'] = f"%{filter_params.partner_name}%"
if where_conditions:
base_query = f"SELECT * FROM ({base_query}) WHERE {' AND '.join(where_conditions)}"
# Count pentru paginare
count_query = f"SELECT COUNT(*) FROM ({base_query})"
cursor.execute(count_query, params)
total_count = cursor.fetchone()[0]
# Query cu paginare
base_query += " ORDER BY dataact DESC, nract"
offset = (filter_params.page - 1) * filter_params.page_size
limit = offset + filter_params.page_size
paginated_query = f"""
SELECT * FROM (
SELECT ROWNUM as rn, t.* FROM ({base_query}) t WHERE ROWNUM <= :limit
) WHERE rn > :offset
"""
params['offset'] = offset
params['limit'] = limit
cursor.execute(paginated_query, params)
rows = cursor.fetchall()
# Procesare rezultate
registers = []
total_incasari = Decimal('0.00')
total_plati = Decimal('0.00')
for row in rows:
# Skip ROWNUM
register_data = BankCashRegister(
nume=row[1] or '',
nract=row[2] or 0,
dataact=row[3],
nume_cont_bancar=row[4] or '',
incasari=Decimal(str(row[5] or 0)),
plati=Decimal(str(row[6] or 0)),
sold=Decimal(str(row[7] or 0)),
valuta=row[8],
tip_registru=row[9],
explicatia=row[10] or ''
)
registers.append(register_data)
total_incasari += register_data.incasari
total_plati += register_data.plati
return RegisterListResponse(
registers=registers,
total_count=total_count,
filtered_count=len(registers),
total_incasari=total_incasari,
total_plati=total_plati,
page=filter_params.page,
page_size=filter_params.page_size,
has_more=len(registers) == filter_params.page_size
)

View File

@@ -0,0 +1,13 @@
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
python-multipart>=0.0.6
pydantic>=2.5.0
python-jose[cryptography]>=3.3.0
PyJWT>=2.8.0
python-decouple>=3.8
oracledb>=1.4.0
python-dateutil>=2.8.2
openpyxl>=3.1.0
fpdf2>=2.7.0
email-validator>=2.0.0
httpx>=0.27.0

View File

@@ -0,0 +1,36 @@
module.exports = {
root: true,
env: {
node: true,
browser: true,
es2022: true
},
extends: [
'eslint:recommended'
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }]
},
overrides: [
{
files: ['**/*.spec.js', '**/tests/**/*.js'],
env: {
jest: true,
node: true
},
globals: {
test: 'readonly',
expect: 'readonly',
describe: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly'
}
}
]
};

64
reports-app/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,64 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Production builds
dist/
build/
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Test output
/test-results/
/playwright-report/
/playwright-report-integration/
/blob-report/
/playwright/.cache/
# Test artifacts and debugging files
*.png
*.jpg
*.jpeg
*.webm
*.mp4
*.har
trace*.zip
*-debug.json
*-report.json
*-report.xml
debug-*.png
journey-*.png
button-debug.png
responsive-*.png
# Coverage
coverage/
*.lcov
.nyc_output/
# Temporary files
*.tmp
*.temp
*.log

View File

@@ -0,0 +1,231 @@
# Android Testing - Quick Start (5 Minutes)
Ghid rapid pentru testarea ROA2WEB pe telefon Android real prin ADB WiFi.
## Prerequisites
- Windows 10/11
- Android phone (Android 10+)
- Phone and computer on same WiFi network
---
## Step 1: Install ADB on Windows (2 min)
**Windows PowerShell:**
```powershell
# Install ADB Platform Tools
winget install Google.PlatformTools
# Verify installation
adb version
```
---
## Step 2: Enable Wireless Debugging on Phone (1 min)
**On Android phone:**
```
Settings -> About phone -> Tap "Build number" 7 times
Settings -> Developer options -> Enable "USB debugging"
Settings -> Developer options -> Enable "Wireless debugging"
```
---
## Step 3: Connect Phone via WiFi (1 min)
**On phone:**
```
Settings -> Developer options -> Wireless debugging -> "Pair device with pairing code"
```
You'll see:
- **Pairing code:** 6 digits (e.g., 123456)
- **IP & Port:** e.g., 10.0.20.114:37639
**In Windows PowerShell:**
```powershell
# Pair (first time only)
adb pair 10.0.20.114:37639
# Enter the 6-digit code when prompted
# After pairing, check the MAIN port in "Wireless debugging" screen
# It's different from pairing port!
# Connect (use the wireless debugging port, NOT pairing port)
adb connect 10.0.20.114:XXXXX
# Verify
adb devices
# Should see: 10.0.20.114:XXXXX device
```
---
## Step 4: Setup Port Forwarding (1 min)
**Windows PowerShell (Administrator):**
```powershell
# A) ADB port forwarding (Chrome DevTools)
adb forward tcp:9222 localabstract:chrome_devtools_remote
# B) Windows port proxy (for WSL/MCP access)
netsh interface portproxy add v4tov4 listenport=9222 listenaddress=0.0.0.0 connectport=9222 connectaddress=127.0.0.1
# C) Firewall rule
New-NetFirewallRule -DisplayName "Chrome-DevTools-Android" -Direction Inbound -LocalPort 9222 -Protocol TCP -Action Allow
# D) Port forwarding for app access from phone
netsh interface portproxy add v4tov4 listenport=3000 listenaddress=0.0.0.0 connectport=3000 connectaddress=172.18.251.234
netsh interface portproxy add v4tov4 listenport=8001 listenaddress=0.0.0.0 connectport=8001 connectaddress=172.18.251.234
# E) Firewall rules for app
New-NetFirewallRule -DisplayName "ROA2WEB-Frontend-WSL" -Direction Inbound -LocalPort 3000 -Protocol TCP -Action Allow
New-NetFirewallRule -DisplayName "ROA2WEB-Backend-WSL" -Direction Inbound -LocalPort 8001 -Protocol TCP -Action Allow
# Verify
netsh interface portproxy show all
```
**OR use automated setup script:**
```powershell
cd E:\proiecte\roa2web\roa2web\reports-app\frontend\scripts
.\android-test-setup.ps1
```
---
## Step 5: Test on Phone
**In WSL (start app):**
```bash
cd /mnt/e/proiecte/roa2web/roa2web
./start-dev.sh
```
**On phone Chrome:**
```
http://localhost:3000
```
---
## Step 6: Configure Chrome DevTools MCP (Optional)
For Claude Code to control Chrome on phone:
**Edit:** `~/.claude.json` (in WSL)
Find `chrome-devtools-android` and ensure it uses physical Windows IP:
```json
"chrome-devtools-android": {
"type": "stdio",
"command": "npx",
"args": [
"chrome-devtools-mcp@latest",
"--browser-url",
"http://10.0.20.144:9222"
]
}
```
Replace `10.0.20.144` with YOUR Windows IP:
```powershell
# Get your Windows IP
ipconfig | findstr "IPv4"
```
**Reload Claude Code:** Ctrl+Shift+P -> "Developer: Reload Window"
---
## Daily Workflow
**Morning setup:**
```powershell
# Windows PowerShell
adb connect 10.0.20.114:XXXXX # Your phone IP:PORT
adb forward tcp:9222 localabstract:chrome_devtools_remote
```
**Start app (WSL):**
```bash
cd /mnt/e/proiecte/roa2web/roa2web
./start-dev.sh
```
**On phone:** Open Chrome -> `http://localhost:3000`
**In Claude Code:** Ask for screenshots directly via MCP:
```
"Folosind chrome-devtools-android, fa screenshot de pe telefon"
```
**End of day cleanup:**
```bash
# WSL
cd /mnt/e/proiecte/roa2web/roa2web/reports-app/frontend
./scripts/android-disconnect.sh
```
---
## Troubleshooting
### "adb: device unauthorized"
- Unlock phone
- Accept "Allow USB debugging" prompt
- Check "Always allow from this computer"
### "Connection refused"
- Phone and PC must be on SAME WiFi network
- Restart "Wireless debugging" on phone
- Re-pair and reconnect
### "localhost:3000 doesn't load on phone"
Try Windows IP instead:
```
http://10.0.20.144:3000
```
### "Chrome DevTools MCP doesn't connect"
```bash
# Test from WSL
curl http://10.0.20.144:9222/json/version
```
If fails, check Windows port proxy and firewall rules.
---
## Summary
| Component | Location | Command |
|-----------|----------|---------|
| **ADB Install** | Windows | `winget install Google.PlatformTools` |
| **Connect Phone** | Windows | `adb connect IP:PORT` |
| **Port Forwarding** | Windows Admin | `netsh interface portproxy ...` |
| **Setup Script** | Windows | `.\android-test-setup.ps1` |
| **Start App** | WSL | `./start-dev.sh` |
| **Screenshot** | Claude Code | Ask via MCP (inline, no file save) |
---
**Your physical IP addresses (fill in):**
- Windows IP: `___________________` (from `ipconfig`)
- Phone IP: `___________________` (from Wireless debugging)
- WSL IP for app: `172.18.251.234` (from `ip route show | grep default`)
---
For detailed guide, see: `tests/ANDROID_TESTING_GUIDE.md`

View File

@@ -0,0 +1,55 @@
# Multi-stage build for Vue.js frontend with Nginx
# Stage 1: Build the Vue.js application
FROM node:18-alpine as builder
WORKDIR /app
# Install dependencies first for better caching
COPY package*.json ./
RUN npm install
# Copy source code and build
COPY . .
RUN npm run build
# Stage 2: Serve with optimized Nginx
FROM nginx:1.25-alpine as production
# Install security packages
RUN apk add --no-cache \
tini \
&& rm -rf /var/cache/apk/*
# Create non-root user
RUN addgroup -g 1001 -S appuser && \
adduser -S -D -H -u 1001 -h /var/cache/nginx -s /sbin/nologin -G appuser appuser
# Copy built application from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy custom Nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Fix permissions
RUN chown -R appuser:appuser /var/cache/nginx && \
chown -R appuser:appuser /var/log/nginx && \
chown -R appuser:appuser /etc/nginx/conf.d && \
touch /var/run/nginx.pid && \
chown -R appuser:appuser /var/run/nginx.pid && \
chown -R appuser:appuser /usr/share/nginx/html
# Switch to non-root user
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Expose port
EXPOSE 3000
# Use tini as init system
ENTRYPOINT ["/sbin/tini", "--"]
# Start Nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,202 @@
# 🚀 ROA2WEB Testing - Quick Start
Ghid rapid pentru rularea testelor Playwright cu detectarea problemelor reale.
## ⚡ Start Rapid
```bash
# 1. Instalează dependențele (prima dată)
npm install
# 2. Curăță fișierele temporare (recomandat)
./cleanup-tests.sh
# 3. Rulează toate testele cu analiză completă
./run-comprehensive-tests.sh
# 4. Pentru teste simple/CI
./run-tests.sh
```
## 📋 Scripturi Disponibile
| Script | Platformă | Descriere | Funcționalitate |
|--------|-----------|-----------|------------------|
| **`run-comprehensive-tests.sh`** | Linux/macOS/WSL | **🎯 RECOMANDAT** - Testare completă cu detecție probleme | Analiză avansată, recomandări, raportare detaliată |
| `run-tests.sh` | Linux/macOS/WSL | Script Playwright standard | Testare de bază mock + integrare |
| `cleanup-tests.sh` | Linux/macOS/WSL | Curățare fișiere temporare | Screenshots, videos, rapoarte |
## 🎯 Comenzi Esențiale
### 🚀 **Testare Comprehensivă (RECOMANDATĂ)**
```bash
./run-comprehensive-tests.sh # Toate testele + analiză completă
./run-comprehensive-tests.sh --no-cleanup # Fără curățarea fișierelor
./run-comprehensive-tests.sh --no-real-world # Doar teste de bază
./run-comprehensive-tests.sh --help # Toate opțiunile
```
### 🔧 **Testare Standard**
```bash
./run-tests.sh # Teste mock + integrare
./run-tests.sh --no-mock # Doar teste de integrare
./run-tests.sh --no-integration # Doar teste mock
```
### 🧹 **Curățare & Management**
```bash
./cleanup-tests.sh # Curăță toate fișierele temporare
./cleanup-tests.sh --deep # Curățare avansată + cache
```
### 🔍 **Debug & Development**
```bash
npx playwright test auth/login.spec.js --headed # Login cu UI vizibil
npx playwright test --debug # Debug interactiv
npx playwright show-report # Afișează ultimul raport
```
## 📊 Ce Testează
### 🎭 **Testare Comprehensivă**
-**Authentication Flow** (10 teste): Login real, validare, token management
-**Real-World Scenarios** (4 teste): Journey complet utilizator, performance
-**Issue Detection** (4 teste): Debugging, network monitoring, CORS
-**Reports Functionality** (2 teste): Companies, invoices, payments API
-**Responsive Design** (4 teste): Mobile, tablet, desktop, orientări
### 🔧 **Testare Standard**
-**Basic Auth** (10 teste): Login, validare, erori
-**Dashboard** (8 teste): Companii, statistici, navigare
-**Invoices** (10 teste): Liste, filtrare, export
-**Payments** (12 teste): Încasări, totalizări, metodă
-**Responsive** (9 teste): Breakpoint-uri, touch interactions
**Total: 70+ teste specifice + 200+ teste standard pe 5 browsere**
## 🔧 Setup Minim
```bash
cd roa2web/reports-app/frontend
npm install # Instalează dependențele
npx playwright install # Instalează browserele
chmod +x *.sh # Permisiuni pentru scripturi
./run-comprehensive-tests.sh --help # Verifică că totul funcționează
```
## 📋 **Prerequisites pentru Testare**
```bash
# 1. Servicii necesare (în terminale separate):
cd roa2web/reports-app/backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
cd roa2web/reports-app/frontend && npm run dev
cd roa2web && ./ssh_tunnel.sh start
# 2. Verifică că serviciile rulează:
curl http://localhost:8000/health # Backend
curl http://localhost:3001 # Frontend
nc -z localhost 1521 # SSH Tunnel
```
## 📱 Browsere Suportate
- **Chromium** (Chrome/Edge) - Principal pentru debugging
- **Firefox** - Cross-browser compatibility
- **WebKit** (Safari) - Apple ecosystem
- **Mobile Chrome** (Pixel 5) - Mobile testing
- **Mobile Safari** (iPhone 12) - iOS testing
## 🎭 Exemple Practice
```bash
# 🚀 Workflow Development (RECOMANDAT)
./cleanup-tests.sh # Curăță vechile fișiere
./run-comprehensive-tests.sh # Toate testele cu analiză
cat test-reports-*/comprehensive-test-report.md # Vezi raportul
# 🔧 Workflow Standard/CI
./run-tests.sh # Teste rapide standard
npx playwright show-report # Vezi rezultatele
# 🔍 Debug Specific Issues
npx playwright test auth/login.spec.js --headed --debug
npx playwright test e2e/debugging-real-issues.spec.js --headed
# 🧹 Curățare după testare
./cleanup-tests.sh --deep # Curățare completă
```
## 🚨 Troubleshooting
### ❌ **Permission Denied pe Scripturi**
```bash
chmod +x *.sh # Permite execuția scripturilor
```
### ❌ **Windows Line Endings**
```bash
sed -i 's/\r$//' *.sh # Convertește line endings
```
### ❌ **Serviciile Nu Rulează**
```bash
# Verifică serviciile:
./run-comprehensive-tests.sh --no-basic --no-real-world # Doar verificare servicii
```
### ❌ **Teste Failed - Debugging**
```bash
./run-comprehensive-tests.sh # Vezi raport detaliat cu recomandări
npx playwright test --debug # Debug interactiv
npx playwright show-report # Ultimul raport Playwright
```
### ❌ **JWT Token Issues (Known Issue)**
```bash
# Problema curentă identificată:
# 401 Unauthorized pe /companies/ după login
# Vezi FINAL_COMPREHENSIVE_TEST_REPORT.md pentru soluție
```
### ❌ **Curățare Space pe Disk**
```bash
./cleanup-tests.sh --deep # Curăță tot + cache
```
## 📊 **Interpretarea Rezultatelor**
### ✅ **Success Output:**
```bash
✅ All tests completed successfully!
⚡ Average Response Time: 8ms
🎉 No critical issues found!
```
### 🚨 **Issues Found Output:**
```bash
❌ API Errors: 8
🚨 CRITICAL: JWT Token not sent to protected routes
💡 RECOMMENDATION: Fix token transmission in api.js
📋 Report: test-reports-TIMESTAMP/comprehensive-test-report.md
```
## 🎯 **Ce Faci Dacă...**
| Problemă | Soluție |
|----------|---------|
| 🚨 **Authentication 401 errors** | Vezi `FINAL_COMPREHENSIVE_TEST_REPORT.md` - problemă JWT token |
| ⚡ **Teste durează mult** | Folosește `./run-tests.sh` pentru testare rapidă |
| 🔍 **Vrei să debug o problemă** | `npx playwright test [test-name] --headed --debug` |
| 📊 **Vrei raport detaliat** | `./run-comprehensive-tests.sh` + vezi `test-reports-*/` |
| 🧹 **Disk plin cu fișiere test** | `./cleanup-tests.sh --deep` |
---
## 🎉 **Next Steps**
1. **🚀 Start:** `./run-comprehensive-tests.sh`
2. **📊 Review:** `cat test-reports-*/comprehensive-test-report.md`
3. **🔧 Fix Issues:** Follow recommendations in report
4. **📚 Deep Dive:** `TEST_RUNNER_GUIDE.md` pentru ghid complet
**🎯 RESULT:** Aplicație testată comprehensiv cu probleme reale identificate și soluții recomandate!

View File

@@ -0,0 +1,437 @@
# ROA Reports Frontend
Vue.js 3 frontend application for the ROA2WEB reports system.
## 🚀 Features
- **Vue.js 3** with Composition API
- **PrimeVue** UI components with Aura theme
- **Pinia** for state management
- **Vue Router** with navigation guards
- **Axios** for API communication
- **Responsive design** for mobile and desktop
- **JWT Authentication** with token refresh
- **TypeScript-ready** architecture
## 📁 Project Structure
```
src/
├── main.js # Application entry point
├── App.vue # Root component
├── assets/
│ ├── css/
│ │ ├── global.css # Global styles and utilities
│ │ └── mobile.css # Mobile-specific styles
│ └── images/ # Static images
├── components/ # Reusable components
│ ├── layout/ # Layout components
│ ├── reports/ # Report-specific components
│ └── ui/ # Generic UI components
├── composables/ # Vue composables
│ ├── index.js # Composables exports
│ └── useResponsive.js # Responsive design utilities
├── router/
│ └── index.js # Vue Router configuration
├── services/
│ ├── api.js # API service with interceptors
│ └── index.js # Services exports
├── stores/ # Pinia stores
│ ├── auth.js # Authentication store
│ ├── companies.js # Companies management
│ ├── invoices.js # Invoices store
│ ├── payments.js # Payments store
│ └── index.js # Stores exports
├── utils/ # Utility functions
│ └── index.js # Utils exports
└── views/ # Page components
├── LoginView.vue # Authentication page
├── DashboardView.vue # Main dashboard
├── InvoicesView.vue # Invoices management
└── PaymentsView.vue # Payments management
```
## 🛠️ Development Setup
### Prerequisites
- Node.js 16+ and npm 8+
- ROA2WEB Backend running on `http://localhost:8000`
### Installation
```bash
# Navigate to frontend directory
cd roa2web/reports-app/frontend/
# Install dependencies
npm install
# Start development server
npm run dev
# Application will be available at http://localhost:3000
```
### Development Commands
```bash
# Development server with hot reload
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
# Lint code
npm run lint
# Format code
npm run format
```
## 🔧 Configuration
### Environment Variables
Create a `.env` file in the frontend directory:
```env
# API Configuration
VITE_API_BASE_URL=http://localhost:8000/api
VITE_APP_TITLE=ROA Reports
VITE_APP_VERSION=1.0.0
# Development
VITE_DEV_MODE=true
VITE_LOG_LEVEL=debug
```
### API Integration
The frontend communicates with the FastAPI backend through:
- **Base URL**: `/api` (proxied to `http://localhost:8000` in development)
- **Authentication**: JWT tokens with automatic refresh
- **Error Handling**: Global interceptors for error management
- **Loading States**: Integrated with Pinia stores
### Responsive Design
The application uses a mobile-first approach with breakpoints:
- **xs**: < 640px (Small phones)
- **sm**: 640px+ (Large phones)
- **md**: 768px+ (Tablets)
- **lg**: 1024px+ (Desktop)
- **xl**: 1280px+ (Large desktop)
- **2xl**: 1536px+ (Extra large)
## 🎨 UI Components
### PrimeVue Components Used
- **Navigation**: Menubar, Menu
- **Forms**: InputText, Password, Dropdown, Calendar
- **Data**: DataTable, Column, Paginator
- **Feedback**: Toast, ConfirmDialog, ProgressSpinner
- **Layout**: Card, Badge, Tag, Button
- **Overlay**: Dialog
### Custom Styling
- **Global utilities** in `global.css`
- **Mobile optimizations** in `mobile.css`
- **Responsive composables** for dynamic behavior
- **CSS custom properties** for consistent theming
## 📱 Mobile Features
- **Touch-friendly** interface with 44px minimum touch targets
- **Swipe gestures** support (prepared for future implementation)
- **Responsive tables** that stack on mobile
- **Mobile navigation** with hamburger menu
- **Optimized forms** with proper input types
- **Accessible** design following WCAG guidelines
## 🔐 Authentication
### Login Flow
1. User submits credentials to `/auth/login`
2. Backend returns access and refresh tokens
3. Tokens stored in localStorage
4. API requests include JWT token in Authorization header
5. Automatic token refresh on expiration
### Route Protection
- **Public routes**: `/login`
- **Protected routes**: All others require authentication
- **Navigation guards** handle redirects
- **Auto-logout** on token expiry
## 📊 State Management
### Pinia Stores
#### Auth Store (`useAuthStore`)
```javascript
// Authentication state and actions
const authStore = useAuthStore()
authStore.login(credentials)
authStore.logout()
authStore.isAuthenticated
```
#### Companies Store (`useCompanyStore`)
```javascript
// Company selection and management
const companyStore = useCompanyStore()
companyStore.loadCompanies()
companyStore.setSelectedCompany(company)
companyStore.selectedCompany
```
#### Invoices Store (`useInvoicesStore`)
```javascript
// Invoice data and filtering
const invoicesStore = useInvoicesStore()
invoicesStore.loadInvoices(companyCode, filters)
invoicesStore.setFilters(newFilters)
invoicesStore.invoiceList
```
#### Payments Store (`usePaymentsStore`)
```javascript
// Payment data and statistics
const paymentsStore = usePaymentsStore()
paymentsStore.loadPayments(companyCode, filters)
paymentsStore.totalAmount
paymentsStore.paymentList
```
## 🧩 Composables
### useResponsive
```javascript
const { isMobile, isTablet, isDesktop, screenSize } = useResponsive()
```
### useResponsiveTable
```javascript
const { shouldStackTable, defaultRows, getMobileColumns } = useResponsiveTable()
```
### useResponsiveForm
```javascript
const { shouldStackButtons, getFormClass } = useResponsiveForm()
```
### useMobileNav
```javascript
const { isMenuOpen, toggleMenu, closeMenu } = useMobileNav()
```
## 🚀 Production Build
### Build Process
```bash
# Create production build
npm run build
# Build outputs to dist/ directory
# Static files ready for web server deployment
```
### Build Optimization
- **Code splitting** for vendor libraries
- **Tree shaking** for unused code elimination
- **Asset optimization** with Vite
- **Modern JS** with automatic fallbacks
- **Gzip compression** ready
### Deployment
The production build creates static files that can be served by:
- **Nginx** (recommended for production)
- **Apache** with URL rewriting
- **Node.js** static server
- **CDN** with SPA support
## 🔍 Debugging
### Development Tools
- **Vue DevTools** browser extension
- **Pinia DevTools** for state inspection
- **Network tab** for API debugging
- **Console logging** with environment-based levels
### Common Issues
1. **CORS errors**: Ensure backend allows frontend origin
2. **401 errors**: Check token expiration and refresh logic
3. **Loading states**: Verify store loading flags
4. **Responsive issues**: Test with browser dev tools
## 📋 Testing
### E2E Tests with Playwright
```bash
# Run all E2E tests (headless)
npm run test:e2e
# Run with browser UI visible
npm run test:e2e:headed
# Debug mode with step-through
npm run test:e2e:debug
# Interactive UI mode
npm run test:e2e:ui
# View test report
npm run test:e2e:report
```
**Test Structure:**
- Uses **Page Object Model** pattern
- Tests organized by feature: `tests/e2e/{auth,dashboard,invoices,payments,responsive}/`
- Page objects in `tests/page-objects/`
- Mocks all backend API calls for speed and reliability
See `tests/README.md` for detailed testing guide.
### Testing on Real Android Devices
For accurate mobile testing, you can test the application on a real Android phone using ADB WiFi and Chrome DevTools MCP.
**Quick Setup (Windows PowerShell):**
```powershell
# 1. Connect phone via WiFi (first time pairing)
adb pair 10.0.20.114:PAIRING_PORT # Use pairing code from phone
adb connect 10.0.20.114:MAIN_PORT # Connect to wireless debugging port
# 2. Run automated setup script
cd E:\proiecte\roa2web\roa2web\reports-app\frontend\scripts
.\android-test-setup.ps1
```
**Start Application (WSL):**
```bash
# Start backend and frontend
cd /mnt/e/proiecte/roa2web/roa2web
./start-dev.sh
# On phone Chrome: http://localhost:3000
```
**Cleanup (WSL):**
```bash
# Remove port forwarding when done
./scripts/android-disconnect.sh
```
**Advantages over emulation:**
- [OK] Real device performance and rendering
- [OK] Actual touch interactions
- [OK] True mobile experience on real hardware
- [OK] Claude Code can control your phone directly through MCP
- [OK] Screenshot capture from real device
**Architecture:**
- **ADB WiFi connection** (Android 10+) - no USB cable needed
- **Windows PowerShell** for all ADB operations (WSL cannot access Android devices)
- **Multi-layer port forwarding**: ADB forward + Windows port proxy + Firewall rules
- **Chrome DevTools MCP** configured with physical Windows IP (not localhost!)
**Complete Guides:**
- **Quick Start (5 minutes):** `ANDROID_QUICK_START.md`
- **Full Setup Guide:** `tests/ANDROID_TESTING_GUIDE.md`
- **Scripts Documentation:** `scripts/README_ANDROID.md`
**Requirements:**
- Android phone (Android 10+) with Wireless Debugging
- Windows 10/11 with PowerShell
- ADB Platform Tools (install via `winget install Google.PlatformTools`)
- Phone and PC on same WiFi network
- Chrome DevTools MCP server configured in Claude Code
### Unit Tests (Future Implementation)
```bash
# Unit tests with Vitest
npm run test
# Component tests
npm run test:component
```
## 🔄 API Endpoints
### Authentication
- `POST /auth/login` - User login
- `POST /auth/refresh` - Token refresh
- `POST /auth/logout` - User logout
### Companies
- `GET /companies` - List user companies
### Invoices
- `GET /invoices/{company_code}` - Get company invoices
- `GET /invoices/{company_code}/{invoice_id}` - Get invoice details
### Payments
- `GET /payments/{company_code}` - Get company payments
- `GET /payments/{company_code}/{payment_id}` - Get payment details
## 🎯 Browser Support
- **Chrome** 88+
- **Firefox** 84+
- **Safari** 14+
- **Edge** 88+
- **Mobile browsers** (iOS Safari 14+, Chrome Mobile 88+)
## 📝 Development Guidelines
### Code Style
- Use **Composition API** for new components
- Follow **Vue 3 best practices**
- Use **TypeScript-style** prop definitions
- Implement **responsive design** patterns
### Component Structure
```vue
<template>
<!-- Template with semantic HTML -->
</template>
<script setup>
// Composition API with proper imports
// Props, emits, lifecycle hooks
// Computed properties and methods
</script>
<style scoped>
/* Scoped styles with CSS custom properties */
/* Responsive breakpoints */
</style>
```
### Performance
- Use **v-memo** for expensive renders
- Implement **virtual scrolling** for large lists
- **Lazy load** images and components
- **Code split** routes and features
---
**ROA2WEB Frontend** - Vue.js 3 application for modern ERP reporting

View File

@@ -0,0 +1,617 @@
# ROA2WEB - Responsive Design & Export Functionality Implementation Plan
## Overview
This plan addresses the following requirements:
1. Make login form and dashboard fully responsive
2. Fix text size reduction issue in tables on mobile (keep text readable)
3. Implement consistent Export Excel/PDF buttons across all tables
4. Use same button styling throughout the dashboard
## Current Issues Identified
- Tables shrink text on mobile making numbers unreadable
- Export buttons are inconsistent (only 2 tables have them)
- Button styles vary across the dashboard
- Login form needs mobile optimization
## Implementation Tasks
### 1. Responsive Login Form Enhancement
**File: `src/views/LoginView.vue`**
#### Changes needed:
```css
/* Add to <style scoped> section */
@media (max-width: 768px) {
.login-container {
padding: 0.5rem;
}
.login-wrapper {
max-width: 100%;
padding: 0 1rem;
}
.login-card {
border-radius: 8px;
}
.login-header {
padding: 1.5rem 1rem;
}
.login-title {
font-size: 1.5rem;
}
.login-form {
padding: 0 1rem 1.5rem 1rem;
}
/* Ensure inputs are touch-friendly */
.p-inputtext,
.p-password input {
min-height: 44px;
font-size: 16px; /* Prevents zoom on iOS */
}
}
@media (max-width: 480px) {
.login-title {
font-size: 1.25rem;
}
.login-subtitle {
font-size: 0.875rem;
}
}
```
### 2. Dashboard Table Responsiveness Fix
**File: `src/views/DashboardView.vue`**
#### Key changes:
1. **Prevent text shrinking in tables**
2. **Add horizontal scroll on mobile**
3. **Maintain readable font sizes**
```css
/* Add to <style scoped> section */
/* Mobile Table Styles - Prevent text shrinking */
@media (max-width: 768px) {
/* Horizontal scroll for table containers */
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 0 -1rem;
padding: 0 1rem;
}
/* Minimum table width to prevent compression */
.summary-table,
.breakdown-table,
.dashboard-table {
min-width: 600px !important;
}
/* Maintain readable text size */
.summary-table td,
.summary-table th,
.breakdown-table td,
.breakdown-table th {
font-size: 14px !important; /* Never go below 14px */
padding: 0.5rem;
white-space: nowrap;
min-width: 80px;
}
/* Amount cells should never shrink */
.amount-cell {
font-size: 14px !important;
font-family: monospace;
white-space: nowrap;
}
/* Stack controls vertically */
.section-controls {
flex-direction: column;
width: 100%;
gap: 0.5rem;
}
.section-controls > * {
width: 100%;
}
/* Button groups on mobile */
.button-group {
display: flex;
gap: 0.5rem;
width: 100%;
}
.button-group .btn {
flex: 1;
}
}
/* Extra small devices */
@media (max-width: 480px) {
.summary-table,
.breakdown-table {
min-width: 500px !important;
font-size: 13px !important;
}
/* Stack button groups vertically on very small screens */
.button-group {
flex-direction: column;
}
.button-group .btn {
width: 100%;
}
}
```
### 3. Create Export Utility Functions
**New File: `src/utils/exportUtils.js`**
```javascript
import * as XLSX from 'xlsx';
import jsPDF from 'jspdf';
import 'jspdf-autotable';
/**
* Format currency values for export
*/
const formatCurrency = (value) => {
if (value == null || value === '-') return '-';
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value);
};
/**
* Export data to Excel
* @param {Array} data - Array of objects to export
* @param {String} filename - Name of the file (without extension)
* @param {String} sheetName - Name of the Excel sheet
*/
export const exportToExcel = (data, filename, sheetName = 'Sheet1') => {
try {
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
XLSX.writeFile(wb, `${filename}_${new Date().toISOString().split('T')[0]}.xlsx`);
return { success: true };
} catch (error) {
console.error('Excel export failed:', error);
return { success: false, error };
}
};
/**
* Export data to PDF
* @param {Array} data - Array of objects to export
* @param {Array} columns - Column definitions [{field: 'key', header: 'Display Name', type: 'currency'}]
* @param {String} filename - Name of the file (without extension)
* @param {String} title - Title for the PDF document
*/
export const exportToPDF = (data, columns, filename, title) => {
try {
const doc = new jsPDF('landscape', 'mm', 'a4');
// Add title
doc.setFontSize(16);
doc.text(title, 14, 15);
// Add company info
doc.setFontSize(10);
doc.text(`Generat: ${new Date().toLocaleString('ro-RO')}`, 14, 25);
// Prepare table data
const tableColumns = columns.map(col => col.header);
const tableRows = data.map(row =>
columns.map(col => {
const value = row[col.field];
if (col.type === 'currency') {
return formatCurrency(value);
}
return value || '-';
})
);
// Add table
doc.autoTable({
head: [tableColumns],
body: tableRows,
startY: 30,
styles: { fontSize: 9, cellPadding: 2 },
headStyles: { fillColor: [102, 126, 234] },
alternateRowStyles: { fillColor: [245, 245, 245] }
});
// Save PDF
doc.save(`${filename}_${new Date().toISOString().split('T')[0]}.pdf`);
return { success: true };
} catch (error) {
console.error('PDF export failed:', error);
return { success: false, error };
}
};
/**
* Export General Totals table
*/
export const exportGeneralTotals = (summaryData) => {
const data = [
{
Tip: 'Clienți',
'Total Facturat': summaryData?.clienti_total_facturat || 0,
'Total Încasat': summaryData?.clienti_total_incasat || 0,
'Sold Net': summaryData?.clienti_sold_total || 0,
'Sold În Termen': summaryData?.clienti_sold_in_termen || 0,
'Sold Restant': summaryData?.clienti_sold_restant || 0
},
{
Tip: 'Furnizori',
'Total Facturat': summaryData?.furnizori_total_facturat || 0,
'Total Achitat': summaryData?.furnizori_total_achitat || 0,
'Sold Net': summaryData?.furnizori_sold_total || 0,
'Sold În Termen': summaryData?.furnizori_sold_in_termen || 0,
'Sold Restant': summaryData?.furnizori_sold_restant || 0
},
{
Tip: 'Trezorerie',
'Total Facturat': '-',
'Total Încasat/Achitat': '-',
'Sold Net': summaryData?.trezorerie_sold || 0,
'Sold În Termen': '-',
'Sold Restant': '-'
}
];
return data;
};
/**
* Export Sold Net Breakdown table
*/
export const exportSoldNetBreakdown = (summaryData) => {
const data = [
{
Categorie: 'Clienți - Restant',
'TOTAL': summaryData?.clienti_sold_restant || 0,
'7 zile': summaryData?.clienti_restant_7 || 0,
'14 zile': summaryData?.clienti_restant_14 || 0,
'30 zile': summaryData?.clienti_restant_30 || 0,
'60 zile': summaryData?.clienti_restant_60 || 0,
'90 zile': summaryData?.clienti_restant_90 || 0,
'90+ zile': summaryData?.clienti_restant_over_90 || 0
},
{
Categorie: 'Furnizori - Restant',
'TOTAL': summaryData?.furnizori_sold_restant || 0,
'7 zile': summaryData?.furnizori_restant_7 || 0,
'14 zile': summaryData?.furnizori_restant_14 || 0,
'30 zile': summaryData?.furnizori_restant_30 || 0,
'60 zile': summaryData?.furnizori_restant_60 || 0,
'90 zile': summaryData?.furnizori_restant_90 || 0,
'90+ zile': summaryData?.furnizori_restant_over_90 || 0
}
];
return data;
};
```
### 4. Update Dashboard Export Methods
**File: `src/views/DashboardView.vue`**
#### Add import:
```javascript
import {
exportToExcel,
exportToPDF,
exportGeneralTotals as prepareGeneralTotals,
exportSoldNetBreakdown as prepareSoldNetBreakdown
} from '@/utils/exportUtils';
```
#### Update export methods:
```javascript
// Export General Totals to Excel
const exportGeneralTotalsExcel = () => {
const data = prepareGeneralTotals(dashboardStore.summary);
const result = exportToExcel(data, 'totaluri_generale', 'Totaluri Generale');
if (result.success) {
toast.add({
severity: 'success',
summary: 'Export Reușit',
detail: 'Fișier Excel generat cu succes',
life: 3000
});
} else {
toast.add({
severity: 'error',
summary: 'Eroare Export',
detail: 'Nu s-a putut genera fișierul Excel',
life: 3000
});
}
};
// Export General Totals to PDF
const exportGeneralTotalsPDF = () => {
const data = prepareGeneralTotals(dashboardStore.summary);
const columns = [
{ field: 'Tip', header: 'Tip', type: 'text' },
{ field: 'Total Facturat', header: 'Total Facturat', type: 'currency' },
{ field: 'Total Încasat', header: 'Total Încasat/Achitat', type: 'currency' },
{ field: 'Sold Net', header: 'Sold Net', type: 'currency' },
{ field: 'Sold În Termen', header: 'Sold În Termen', type: 'currency' },
{ field: 'Sold Restant', header: 'Sold Restant', type: 'currency' }
];
const result = exportToPDF(
data,
columns,
'totaluri_generale',
`Totaluri Generale - ${companyStore.selectedCompany?.name || 'ROA Reports'}`
);
if (result.success) {
toast.add({
severity: 'success',
summary: 'Export Reușit',
detail: 'Fișier PDF generat cu succes',
life: 3000
});
}
};
// Similar methods for Sold Net Breakdown
const exportSoldNetExcel = () => {
const data = prepareSoldNetBreakdown(dashboardStore.summary);
exportToExcel(data, 'detaliere_sold_net', 'Detaliere Sold Net');
};
const exportSoldNetPDF = () => {
const data = prepareSoldNetBreakdown(dashboardStore.summary);
const columns = [
{ field: 'Categorie', header: 'Categorie', type: 'text' },
{ field: 'TOTAL', header: 'TOTAL', type: 'currency' },
{ field: '7 zile', header: '7 zile', type: 'currency' },
{ field: '14 zile', header: '14 zile', type: 'currency' },
{ field: '30 zile', header: '30 zile', type: 'currency' },
{ field: '60 zile', header: '60 zile', type: 'currency' },
{ field: '90 zile', header: '90 zile', type: 'currency' },
{ field: '90+ zile', header: '90+ zile', type: 'currency' }
];
exportToPDF(
data,
columns,
'detaliere_sold_net',
`Detaliere Sold Net - ${companyStore.selectedCompany?.name || 'ROA Reports'}`
);
};
```
### 5. Update Table Templates with Consistent Buttons
**File: `src/views/DashboardView.vue`**
#### Update all table headers with consistent button groups:
```html
<!-- General Totals Table -->
<div class="section-header">
<h2 class="section-title">Totaluri Generale</h2>
<div class="section-controls">
<div class="button-group">
<button class="btn btn-sm btn-primary" @click="exportGeneralTotalsExcel">
<i class="pi pi-file-excel"></i>
<span class="btn-text">Excel</span>
</button>
<button class="btn btn-sm btn-primary" @click="exportGeneralTotalsPDF">
<i class="pi pi-file-pdf"></i>
<span class="btn-text">PDF</span>
</button>
<button class="btn btn-sm btn-outline" @click="refreshGeneralTotals">
<i class="pi pi-refresh"></i>
<span class="btn-text">Refresh</span>
</button>
</div>
</div>
</div>
<!-- Sold Net Breakdown Table -->
<div class="section-header">
<h2 class="section-title">DETALIERE SOLD NET</h2>
<div class="section-controls">
<div class="button-group">
<button class="btn btn-sm btn-primary" @click="exportSoldNetExcel">
<i class="pi pi-file-excel"></i>
<span class="btn-text">Excel</span>
</button>
<button class="btn btn-sm btn-primary" @click="exportSoldNetPDF">
<i class="pi pi-file-pdf"></i>
<span class="btn-text">PDF</span>
</button>
<button class="btn btn-sm btn-outline" @click="refreshSoldNetBreakdown">
<i class="pi pi-refresh"></i>
<span class="btn-text">Refresh</span>
</button>
</div>
</div>
</div>
<!-- Trend Section -->
<div class="section-header">
<h2 class="section-title">Analize Trend</h2>
<div class="section-controls">
<div class="control-group">
<label>Perioada:</label>
<select :value="selectedPeriod" class="trend-select" @change="(e) => { selectedPeriod = e.target.value; handlePeriodChange(); }">
<option value="ytd">An curent (YTD)</option>
<option value="12m">Ultimele 12 luni</option>
</select>
</div>
<div class="control-group">
<label>Tip Grafic:</label>
<select :value="selectedChartType" class="trend-select" @change="(e) => { selectedChartType = e.target.value; handleChartTypeChange(); }">
<option value="line">Linie</option>
<option value="bar">Bare</option>
<option value="area">Arie</option>
</select>
</div>
<div class="button-group">
<button class="btn btn-sm btn-primary" @click="exportTrendExcel">
<i class="pi pi-file-excel"></i>
<span class="btn-text">Excel</span>
</button>
<button class="btn btn-sm btn-primary" @click="exportTrendPDF">
<i class="pi pi-file-pdf"></i>
<span class="btn-text">PDF</span>
</button>
<button class="btn btn-sm btn-outline" @click="refreshTrendData">
<i class="pi pi-refresh"></i>
<span class="btn-text">Refresh</span>
</button>
</div>
</div>
</div>
```
### 6. Button Styling Updates
**File: `src/assets/css/components/buttons.css`**
Add button group styles:
```css
/* Button Groups for Dashboard */
.button-group {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.button-group .btn {
border-radius: var(--radius-md);
}
/* Hide button text on small screens */
@media (max-width: 640px) {
.btn-text {
display: none;
}
.button-group {
width: 100%;
}
.button-group .btn {
flex: 1;
justify-content: center;
}
}
/* Stack buttons vertically on very small screens */
@media (max-width: 480px) {
.button-group {
flex-direction: column;
width: 100%;
}
.button-group .btn {
width: 100%;
}
.btn-text {
display: inline; /* Show text again when stacked */
}
}
/* Primary button style for exports */
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover {
background: var(--color-primary-dark);
border-color: var(--color-primary-dark);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-primary:active {
transform: translateY(0);
}
```
## Testing Checklist
### Mobile Testing (320px - 768px)
- [ ] Login form is properly sized and centered
- [ ] Input fields are touch-friendly (44px min height)
- [ ] Tables scroll horizontally
- [ ] Table text remains at 14px minimum
- [ ] Numbers don't shrink or wrap
- [ ] Export buttons are visible and functional
- [ ] Buttons stack properly on small screens
### Tablet Testing (768px - 1024px)
- [ ] Tables display properly with slight compression
- [ ] All export buttons visible
- [ ] Controls layout is optimal
### Desktop Testing (1024px+)
- [ ] Full layout displays correctly
- [ ] All features accessible
- [ ] Export functions work properly
### Export Testing
- [ ] Excel export generates valid .xlsx files
- [ ] PDF export generates readable documents
- [ ] All data is included in exports
- [ ] Currency formatting is correct
- [ ] Date/time stamps are included
## Dependencies Required
```json
{
"xlsx": "^0.18.5",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.5.31"
}
```
## Implementation Order
1. Create export utility functions
2. Update button styles in CSS
3. Add responsive styles to LoginView
4. Update DashboardView with new export methods
5. Add consistent button groups to all tables
6. Update mobile.css with table fixes
7. Test on various devices
## Notes
- Maintain 14px minimum font size for readability
- Use horizontal scroll instead of text compression
- Keep button styling consistent across all tables
- Stack controls vertically on mobile for better UX
- Test exports with real data to ensure formatting
## Success Criteria
- ✅ Login form responsive on all devices
- ✅ Dashboard tables readable on mobile (no text shrinking)
- ✅ All tables have Excel and PDF export buttons
- ✅ Buttons use consistent styling
- ✅ Export functions work correctly
- ✅ Touch-friendly interface on mobile devices

View File

@@ -0,0 +1,249 @@
# ROA2WEB Test Runner Guide
Ghid pentru rularea testelor Playwright folosind scripturile disponibile.
## 🚀 Scripturi Disponibile
### 1. Script Bash (Linux/macOS/WSL)
```bash
./run_tests.sh [test_type] [browser] [mode]
```
### 2. Script Windows Batch
```cmd
run_tests.bat [test_type] [browser] [mode]
```
### 3. Script Python (Cross-platform)
```bash
python run_tests.py [options]
```
## 📋 Opțiuni Disponibile
### Tipuri de Teste
- `all` - Toate testele (implicit)
- `auth` - Doar testele de autentificare
- `dashboard` - Doar testele dashboard
- `invoices` - Doar testele facturi
- `payments` - Doar testele încasări
- `responsive` - Doar testele responsive
- `smoke` - Testele critice (login + dashboard basic)
### Browsere
- `chromium` - Doar pe Chromium
- `firefox` - Doar pe Firefox
- `webkit` - Doar pe WebKit (Safari)
- `mobile` - Doar pe browsere mobile
### Moduri de rulare
- `--headed` - Cu interfață browser vizibilă
- `--debug` - Mod debug (pas cu pas)
- `--ui` - Cu Playwright UI
- `--report` - Afișează raportul
## 🛠️ Exemple de Utilizare
### Bash/Linux/macOS
```bash
# Toate testele
./run_tests.sh
# Doar testele de autentificare
./run_tests.sh auth
# Testele dashboard pe Firefox cu UI
./run_tests.sh dashboard firefox --headed
# Teste smoke în mod debug
./run_tests.sh smoke --debug
# Afișează help
./run_tests.sh --help
```
### Windows
```cmd
REM Toate testele
run_tests.bat
REM Doar testele facturi
run_tests.bat invoices
REM Testele responsive pe Chromium
run_tests.bat responsive chromium
REM Teste cu Playwright UI
run_tests.bat all --ui
```
### Python (Cross-platform)
```bash
# Toate testele
python run_tests.py
# Testele payments cu opțiuni avansate
python run_tests.py payments --workers 2 --retries 1
# Lista toate testele disponibile
python run_tests.py --list
# Testele care conțin "login" în nume
python run_tests.py --grep "login"
# Afișează help complet
python run_tests.py --help
```
## 🔧 Opțiuni Avansate (Python)
Scriptul Python oferă opțiuni suplimentare:
```bash
# Numărul de worker-i paraleli
python run_tests.py all --workers 4
# Numărul de reîncercări pentru testele failed
python run_tests.py auth --retries 2
# Timeout personalizat (în milisecunde)
python run_tests.py invoices --timeout 60000
# Filtrare cu regex
python run_tests.py --grep "should.*correctly"
# Combinații complexe
python run_tests.py responsive webkit --headed --workers 1
```
## 📊 Monitorizare și Raportare
### Afișarea Raportului
```bash
# Bash
./run_tests.sh --report
# Windows
run_tests.bat --report
# Python
python run_tests.py --report
```
### Listarea Testelor
```bash
# Doar cu Python
python run_tests.py --list
```
## 🎯 Workflow Recomandat
### 1. Dezvoltare Rapidă
```bash
# Teste smoke pentru verificare rapidă
./run_tests.sh smoke
# Testele unei funcționalități specifice
./run_tests.sh auth --headed
```
### 2. Testing Complet
```bash
# Toate testele pe toate browserele
./run_tests.sh all
# Testele responsive pe mobile
./run_tests.sh responsive mobile
```
### 3. Debugging
```bash
# Debug interactiv
./run_tests.sh auth --debug
# Cu UI pentru investigare
./run_tests.sh invoices --ui
```
### 4. CI/CD
```bash
# Pentru integrare continuă
python run_tests.py all --workers 2 --retries 1
```
## 🔧 Setup Inițial
### 1. Asigură-te că dependențele sunt instalate
```bash
cd roa2web/reports-app/frontend
npm install
```
### 2. (Opțional) Pornește serverul frontend
```bash
npm run dev
```
*Nota: Testele funcționează și cu API-uri mock-uite*
### 3. Rulează testele
```bash
./run_tests.sh
```
## 🚨 Troubleshooting
### Problema: "command not found"
```bash
# Bash - fă scriptul executabil
chmod +x run_tests.sh
# Python - verifică că Python 3 este instalat
python3 --version
```
### Problema: "Frontend server not detected"
```bash
# Pornește serverul în alt terminal
npm run dev
# Sau rulează testele cu mock-uri (implicit)
./run_tests.sh auth
```
### Problema: "Playwright not installed"
```bash
npm install
npx playwright install
```
### Problema: Teste failed
```bash
# Verifică cu debug mode
./run_tests.sh [test_type] --debug
# Sau cu UI pentru investigare
./run_tests.sh [test_type] --ui
# Afișează raportul detaliat
./run_tests.sh --report
```
## 📈 Performance Tips
1. **Rulare Paralelă**: Folosește `--workers N` pentru teste mai rapide
2. **Browser Specific**: Rulează pe un singur browser pentru dezvoltare
3. **Teste Smoke**: Folosește `smoke` pentru verificări rapide
4. **Mock APIs**: Testele sunt optimizate să ruleze fără server backend
## 🎭 Informații Despre Teste
- **Total teste**: 262 (peste 5 browsere)
- **Coverage**: Login, Dashboard, Facturi, Încasări, Responsive
- **Pattern**: Page Object Model pentru mentenabilitate
- **Mock**: API responses pentru consistență
- **Screenshots**: Automat la failure
- **Videos**: Pentru testele failed
---
*Pentru documentație completă despre teste, vezi `tests/README.md`*

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# ROA2WEB Test Cleanup Script
# Removes all temporary test files, screenshots, videos, and reports
# Created: 2025-08-04
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
NC='\033[0m' # No Color
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FRONTEND_DIR="$SCRIPT_DIR"
echo -e "${PURPLE}"
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ ROA2WEB Test Cleanup Tool ║"
echo "║ ║"
echo "║ Removes all temporary test files, screenshots, and reports ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo -e "${NC}"
echo ""
log_info() {
echo -e "${BLUE} $1${NC}"
}
log_success() {
echo -e "${GREEN}$1${NC}"
}
log_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
log_error() {
echo -e "${RED}$1${NC}"
}
# Function to safely remove files/directories
safe_remove() {
local path="$1"
local description="$2"
if [ -e "$path" ]; then
if [ -d "$path" ]; then
local count=$(find "$path" -type f | wc -l)
rm -rf "$path"
log_success "Removed $description ($count files)"
else
rm -f "$path"
log_success "Removed $description"
fi
else
log_info "$description - not found (already clean)"
fi
}
# Function to count and remove files by pattern
remove_by_pattern() {
local pattern="$1"
local description="$2"
local files=($(find . -name "$pattern" -type f 2>/dev/null || true))
if [ ${#files[@]} -gt 0 ]; then
for file in "${files[@]}"; do
rm -f "$file"
done
log_success "Removed $description (${#files[@]} files)"
else
log_info "$description - not found"
fi
}
log_info "Starting cleanup process..."
echo ""
# 1. Playwright Reports and Results
log_info "Cleaning Playwright reports and results..."
safe_remove "playwright-report" "Main Playwright HTML report"
safe_remove "playwright-report-integration" "Integration Playwright HTML report"
safe_remove "test-results" "Test results directory"
# 2. Screenshots and Videos
log_info "Cleaning screenshots and debug images..."
remove_by_pattern "*.png" "Screenshot files"
remove_by_pattern "*.jpg" "JPEG image files"
remove_by_pattern "*.jpeg" "JPEG image files"
remove_by_pattern "debug-*.png" "Debug screenshot files"
remove_by_pattern "journey-*.png" "User journey screenshots"
remove_by_pattern "button-debug.png" "Button debug screenshots"
remove_by_pattern "responsive-*.png" "Responsive design screenshots"
# 3. Video files
log_info "Cleaning video recordings..."
remove_by_pattern "*.webm" "WebM video files"
remove_by_pattern "*.mp4" "MP4 video files"
# 4. Test artifacts and temporary files
log_info "Cleaning test artifacts..."
safe_remove ".nyc_output" "Coverage output directory"
safe_remove "coverage" "Coverage reports directory"
remove_by_pattern "*.tmp" "Temporary files"
remove_by_pattern "*.log" "Log files"
# 5. Node.js and build artifacts (optional cleanup)
if [ "$1" = "--deep" ]; then
log_info "Deep cleanup mode - removing build artifacts..."
safe_remove "node_modules/.cache" "Node modules cache"
safe_remove "dist" "Build output directory"
safe_remove ".vite" "Vite cache directory"
fi
# 6. Specific test debugging files
log_info "Cleaning debugging artifacts..."
remove_by_pattern "*-debug.json" "Debug JSON files"
remove_by_pattern "*-report.json" "JSON report files"
remove_by_pattern "*-report.xml" "XML report files"
remove_by_pattern "*.har" "HAR network recording files"
# 7. Clean any leftover Playwright traces
safe_remove "trace.zip" "Playwright trace files"
remove_by_pattern "trace-*.zip" "Individual trace files"
echo ""
log_success "🎉 Cleanup completed successfully!"
echo ""
# Show current directory size after cleanup
if command -v du &> /dev/null; then
dir_size=$(du -sh . 2>/dev/null | cut -f1)
log_info "Current directory size: $dir_size"
fi
echo -e "${PURPLE}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${PURPLE}║ Cleanup Summary ║${NC}"
echo -e "${PURPLE}╚══════════════════════════════════════════════════════════════╝${NC}"
echo ""
log_info "All temporary test files have been cleaned"
log_info "Run './cleanup-tests.sh --deep' for deep cleanup including build cache"
echo ""
# Optional: Show what would be ignored by git
if [ -f ".gitignore" ]; then
echo -e "${YELLOW}💡 Tip: Check .gitignore to ensure test artifacts are properly ignored${NC}"
fi

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ROA Reports - Rapoarte ERP</title>
<meta name="description" content="Aplicație pentru rapoarte ERP - facturi și încasări">
<!-- Cache busting - forțează reîncărcare asset-uri noi -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<meta name="app-version" content="BUILD_TIMESTAMP" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,54 @@
server {
listen 3000;
server_name localhost;
# Security headers
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' http://localhost:8000 ws://localhost:*" always;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
}
# SPA fallback
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
# Cache index.html for short time
location = /index.html {
expires 5m;
add_header Cache-Control "no-cache, must-revalidate";
}
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}

View File

@@ -0,0 +1,56 @@
{
"name": "roa-reports-frontend",
"version": "1.0.0",
"description": "ROA2WEB Reports Frontend - Vue.js 3 + PrimeVue",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"serve": "vite preview --port 3000",
"lint": "eslint tests/ --ext .js --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug",
"test:e2e:report": "playwright show-report",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"axios": "^1.6.2",
"chart.js": "^4.5.0",
"date-fns": "^2.30.0",
"jspdf": "^3.0.1",
"jspdf-autotable": "^5.0.2",
"pinia": "^2.1.7",
"primeicons": "^6.0.1",
"primevue": "^3.46.0",
"qrcode.vue": "^3.6.0",
"vue": "^3.4.0",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.2.5",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@playwright/test": "^1.54.2",
"@vitejs/plugin-vue": "^4.5.2",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.19.2",
"prettier": "^3.1.1",
"vite": "^5.0.8"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"keywords": [
"vue",
"fastapi",
"primevue",
"reports",
"oracle",
"erp"
],
"author": "ROA2WEB Team",
"license": "MIT"
}

View File

@@ -0,0 +1,46 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3001',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 12'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3001',
reuseExistingServer: !process.env.CI,
},
});

View File

@@ -0,0 +1,70 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright configuration for real API integration tests
* Uses actual Oracle database connections and real credentials
* No API mocking - full end-to-end testing with ROMFAST data
*/
export default defineConfig({
testDir: './tests/integration',
fullyParallel: false, // Run sequentially for real data tests
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0, // Fewer retries for real API tests
workers: 1, // Single worker to avoid conflicts with real data
timeout: 60000, // Longer timeout for real API calls
reporter: [
['html', { outputFolder: 'playwright-report-integration' }],
['json', { outputFile: 'test-results/integration-results.json' }],
['junit', { outputFile: 'test-results/integration-junit.xml' }]
],
use: {
baseURL: 'http://localhost:3001',
trace: 'on',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
// Extended timeouts for real API interactions
actionTimeout: 15000,
navigationTimeout: 30000,
// No API route interception - use real backend
ignoreHTTPSErrors: true,
},
projects: [
{
name: 'real-api-chrome',
use: {
...devices['Desktop Chrome'],
// Additional Chrome flags for testing
launchOptions: {
args: [
'--disable-web-security',
'--disable-features=VizDisplayCompositor',
'--no-sandbox'
]
}
},
},
{
name: 'real-api-firefox',
use: {
...devices['Desktop Firefox'],
},
}
],
// Global setup and teardown for real API tests
globalSetup: './tests/integration/global-setup.js',
globalTeardown: './tests/integration/global-teardown.js',
// Environment-specific configurations
metadata: {
testType: 'integration',
environment: 'development',
backend: 'http://localhost:8000',
frontend: 'http://localhost:3001',
database: 'Oracle via SSH tunnel',
credentials: 'Real Oracle credentials'
}
});

View File

@@ -0,0 +1,514 @@
#!/usr/bin/env bash
# ROA2WEB Comprehensive Test Execution and Reporting Script
# Executes all tests, identifies errors, and generates detailed reports
# Created: 2025-08-04
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FRONTEND_DIR="$SCRIPT_DIR"
BACKEND_DIR="$SCRIPT_DIR/../backend"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
REPORT_DIR="test-reports-$TIMESTAMP"
DETAILED_LOG="$REPORT_DIR/detailed-test-log.txt"
# Test execution flags
RUN_CLEANUP=true
RUN_BASIC_TESTS=true
RUN_REAL_WORLD_TESTS=true
RUN_DEBUGGING_TESTS=true
GENERATE_SCREENSHOTS=true
CHECK_SERVICES=true
# Results tracking
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
ISSUES_FOUND=()
RECOMMENDATIONS=()
echo -e "${PURPLE}"
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ ROA2WEB Comprehensive Test Execution Suite ║"
echo "║ ║"
echo "║ • Error Detection & Analysis ║"
echo "║ • Performance Monitoring ║"
echo "║ • Real-World Scenario Testing ║"
echo "║ • Detailed Reporting & Recommendations ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo -e "${NC}"
echo ""
# Logging functions
log_info() {
echo -e "${BLUE} $1${NC}"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] INFO: $1" >> "$DETAILED_LOG"
}
log_success() {
echo -e "${GREEN}$1${NC}"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] SUCCESS: $1" >> "$DETAILED_LOG"
}
log_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARNING: $1" >> "$DETAILED_LOG"
}
log_error() {
echo -e "${RED}$1${NC}"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" >> "$DETAILED_LOG"
ISSUES_FOUND+=("$1")
}
log_test() {
echo -e "${CYAN}🧪 $1${NC}"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] TEST: $1" >> "$DETAILED_LOG"
}
add_recommendation() {
RECOMMENDATIONS+=("$1")
echo "[$(date '+%Y-%m-%d %H:%M:%S')] RECOMMENDATION: $1" >> "$DETAILED_LOG"
}
# Create report directory
mkdir -p "$REPORT_DIR"
touch "$DETAILED_LOG"
# Initialize log
echo "ROA2WEB Comprehensive Test Execution Log" > "$DETAILED_LOG"
echo "Started: $(date)" >> "$DETAILED_LOG"
echo "=========================================" >> "$DETAILED_LOG"
echo "" >> "$DETAILED_LOG"
main() {
local start_time=$(date +%s)
log_info "Starting comprehensive test execution..."
log_info "Report directory: $REPORT_DIR"
echo ""
# Step 1: Cleanup previous test artifacts
if [ "$RUN_CLEANUP" = true ]; then
log_test "Step 1: Cleaning up previous test artifacts"
if [ -f "./cleanup-tests.sh" ]; then
./cleanup-tests.sh 2>&1 | tee -a "$DETAILED_LOG"
else
log_warning "Cleanup script not found, skipping cleanup"
fi
echo ""
fi
# Step 2: Environment and Service Check
if [ "$CHECK_SERVICES" = true ]; then
log_test "Step 2: Checking service availability"
check_services
echo ""
fi
# Step 3: Execute Basic Test Suite
if [ "$RUN_BASIC_TESTS" = true ]; then
log_test "Step 3: Executing basic test suite"
execute_basic_tests
echo ""
fi
# Step 4: Execute Real-World Tests
if [ "$RUN_REAL_WORLD_TESTS" = true ]; then
log_test "Step 4: Executing real-world comprehensive tests"
execute_real_world_tests
echo ""
fi
# Step 5: Execute Debugging Tests
if [ "$RUN_DEBUGGING_TESTS" = true ]; then
log_test "Step 5: Executing debugging and issue detection tests"
execute_debugging_tests
echo ""
fi
# Step 6: Performance Analysis
log_test "Step 6: Analyzing performance metrics"
analyze_performance
echo ""
# Step 7: Generate Comprehensive Report
log_test "Step 7: Generating comprehensive report"
generate_comprehensive_report
# Calculate execution time
local end_time=$(date +%s)
local execution_time=$((end_time - start_time))
local minutes=$((execution_time / 60))
local seconds=$((execution_time % 60))
echo ""
echo -e "${PURPLE}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${PURPLE}║ Execution Summary ║${NC}"
echo -e "${PURPLE}╚══════════════════════════════════════════════════════════════╝${NC}"
echo ""
log_info "Total Execution Time: ${minutes}m ${seconds}s"
log_info "Total Tests: $TOTAL_TESTS"
log_success "Passed Tests: $PASSED_TESTS"
if [ $FAILED_TESTS -gt 0 ]; then
log_error "Failed Tests: $FAILED_TESTS"
else
log_success "Failed Tests: $FAILED_TESTS"
fi
log_info "Issues Found: ${#ISSUES_FOUND[@]}"
log_info "Recommendations: ${#RECOMMENDATIONS[@]}"
echo ""
log_success "📊 Comprehensive test report generated: $REPORT_DIR/comprehensive-test-report.md"
log_info "📋 Detailed log available: $DETAILED_LOG"
if [ ${#ISSUES_FOUND[@]} -gt 0 ]; then
echo ""
log_warning "🚨 Issues found - check the detailed report for recommendations"
exit 1
else
echo ""
log_success "🎉 All tests completed successfully - no critical issues found!"
exit 0
fi
}
check_services() {
log_info "Checking required services..."
# Check backend
if curl -s http://localhost:8000/health > /dev/null 2>&1; then
log_success "Backend service (port 8000) is available"
else
log_error "Backend service (port 8000) is not available"
add_recommendation "Start backend service: cd ../backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000"
fi
# Check frontend
if curl -s http://localhost:3001 > /dev/null 2>&1; then
log_success "Frontend service (port 3001) is available"
else
log_error "Frontend service (port 3001) is not available"
add_recommendation "Start frontend service: npm run dev"
fi
# Check SSH tunnel (Oracle database)
if nc -z localhost 1521 2>/dev/null; then
log_success "SSH tunnel (port 1521) appears to be active"
else
log_warning "SSH tunnel (port 1521) may not be active"
add_recommendation "Start SSH tunnel: cd ../../../.. && ./ssh_tunnel.sh start"
fi
# Check Node.js and npm
if command -v node &> /dev/null && command -v npm &> /dev/null; then
local node_version=$(node --version)
local npm_version=$(npm --version)
log_success "Node.js $node_version and npm $npm_version are available"
else
log_error "Node.js or npm not found"
add_recommendation "Install Node.js and npm"
fi
}
execute_basic_tests() {
log_info "Running basic authentication and UI tests..."
local basic_result=0
# Run login tests
if npx playwright test auth/login.spec.js --project=chromium --reporter=json > "$REPORT_DIR/basic-tests-result.json" 2>&1; then
log_success "Basic authentication tests passed"
PASSED_TESTS=$((PASSED_TESTS + 10))
else
log_error "Basic authentication tests failed"
FAILED_TESTS=$((FAILED_TESTS + 10))
basic_result=1
fi
TOTAL_TESTS=$((TOTAL_TESTS + 10))
# Analyze basic test results
if [ -f "$REPORT_DIR/basic-tests-result.json" ]; then
analyze_test_results "$REPORT_DIR/basic-tests-result.json" "Basic Tests"
fi
return $basic_result
}
execute_real_world_tests() {
log_info "Running real-world comprehensive tests..."
local real_world_result=0
if npx playwright test e2e/real-world-comprehensive.spec.js --project=chromium --reporter=json > "$REPORT_DIR/real-world-tests-result.json" 2>&1; then
log_success "Real-world comprehensive tests passed"
PASSED_TESTS=$((PASSED_TESTS + 4))
else
log_error "Real-world comprehensive tests failed"
FAILED_TESTS=$((FAILED_TESTS + 4))
real_world_result=1
fi
TOTAL_TESTS=$((TOTAL_TESTS + 4))
# Analyze real-world test results
if [ -f "$REPORT_DIR/real-world-tests-result.json" ]; then
analyze_test_results "$REPORT_DIR/real-world-tests-result.json" "Real-World Tests"
fi
return $real_world_result
}
execute_debugging_tests() {
log_info "Running debugging and issue detection tests..."
local debug_result=0
if npx playwright test e2e/debugging-real-issues.spec.js --project=chromium --reporter=json > "$REPORT_DIR/debugging-tests-result.json" 2>&1; then
log_success "Debugging tests passed"
PASSED_TESTS=$((PASSED_TESTS + 4))
else
log_error "Debugging tests failed"
FAILED_TESTS=$((FAILED_TESTS + 4))
debug_result=1
fi
TOTAL_TESTS=$((TOTAL_TESTS + 4))
# Analyze debugging test results
if [ -f "$REPORT_DIR/debugging-tests-result.json" ]; then
analyze_test_results "$REPORT_DIR/debugging-tests-result.json" "Debugging Tests"
fi
return $debug_result
}
analyze_test_results() {
local result_file="$1"
local test_category="$2"
if [ ! -f "$result_file" ]; then
log_warning "Result file $result_file not found for $test_category"
return
fi
log_info "Analyzing $test_category results..."
# Extract key information from JSON results
if command -v jq &> /dev/null; then
local total_tests=$(jq -r '.stats.total // 0' "$result_file")
local passed_tests=$(jq -r '.stats.passed // 0' "$result_file")
local failed_tests=$(jq -r '.stats.failed // 0' "$result_file")
local duration=$(jq -r '.stats.duration // 0' "$result_file")
log_info "$test_category: $passed_tests/$total_tests passed (${duration}ms)"
# Extract failed test details
local failed_test_titles=$(jq -r '.tests[] | select(.status == "failed") | .title' "$result_file" 2>/dev/null || echo "")
if [ -n "$failed_test_titles" ]; then
log_warning "Failed tests in $test_category:"
echo "$failed_test_titles" | while read -r title; do
log_error " - $title"
done
fi
else
log_warning "jq not available for detailed JSON analysis"
add_recommendation "Install jq for better test result analysis: sudo apt-get install jq"
fi
}
analyze_performance() {
log_info "Analyzing application performance..."
# Check if we have performance data from tests
local perf_issues=()
# Look for performance-related issues in logs
if grep -q "slow" "$DETAILED_LOG" 2>/dev/null; then
perf_issues+=("Slow requests detected")
fi
if grep -q "timeout" "$DETAILED_LOG" 2>/dev/null; then
perf_issues+=("Timeout issues detected")
fi
if [ ${#perf_issues[@]} -gt 0 ]; then
log_warning "Performance issues found:"
for issue in "${perf_issues[@]}"; do
log_warning " - $issue"
ISSUES_FOUND+=("Performance: $issue")
done
add_recommendation "Review performance bottlenecks and optimize slow endpoints"
else
log_success "No significant performance issues detected"
fi
}
generate_comprehensive_report() {
local report_file="$REPORT_DIR/comprehensive-test-report.md"
cat > "$report_file" << EOF
# 🧪 ROA2WEB Comprehensive Test Report
**Generated:** $(date)
**Execution Time:** Total execution logged in detailed log
**Test Environment:** Development (localhost)
## 📊 Executive Summary
| Metric | Value |
|--------|-------|
| Total Tests | $TOTAL_TESTS |
| Passed Tests | $PASSED_TESTS |
| Failed Tests | $FAILED_TESTS |
| Success Rate | $(( PASSED_TESTS * 100 / (TOTAL_TESTS > 0 ? TOTAL_TESTS : 1) ))% |
| Issues Found | ${#ISSUES_FOUND[@]} |
| Recommendations | ${#RECOMMENDATIONS[@]} |
## 🚨 Issues Identified
EOF
if [ ${#ISSUES_FOUND[@]} -gt 0 ]; then
echo "### Critical Issues:" >> "$report_file"
for issue in "${ISSUES_FOUND[@]}"; do
echo "- ❌ $issue" >> "$report_file"
done
echo "" >> "$report_file"
else
echo "✅ **No critical issues found!**" >> "$report_file"
echo "" >> "$report_file"
fi
cat >> "$report_file" << EOF
## 💡 Recommendations
EOF
if [ ${#RECOMMENDATIONS[@]} -gt 0 ]; then
for rec in "${RECOMMENDATIONS[@]}"; do
echo "- 🔧 $rec" >> "$report_file"
done
else
echo "✅ **No specific recommendations - application is performing well!**" >> "$report_file"
fi
cat >> "$report_file" << EOF
## 📋 Test Categories Executed
### 1. 🔐 Authentication Tests
- Login form validation
- Authentication flow
- Error handling
- Session management
### 2. 🌍 Real-World Tests
- Complete user journeys
- Network performance analysis
- Error handling stress tests
- Responsive design validation
### 3. 🔍 Debugging Tests
- API request format validation
- Button state logic verification
- Error message format checking
- Network monitoring
## 📁 Report Files
- **Detailed Log:** \`$(basename "$DETAILED_LOG")\`
- **Basic Tests:** \`basic-tests-result.json\`
- **Real-World Tests:** \`real-world-tests-result.json\`
- **Debugging Tests:** \`debugging-tests-result.json\`
## 🎯 Next Steps
1. **Address Critical Issues:** Review and fix any issues listed above
2. **Implement Recommendations:** Follow the recommendations for optimal performance
3. **Regular Testing:** Run this comprehensive test suite regularly
4. **Monitor Performance:** Keep track of response times and error rates
---
*Report generated by ROA2WEB Comprehensive Test Suite*
EOF
log_success "Comprehensive report generated: $report_file"
}
# Handle cleanup on exit
cleanup_on_exit() {
if [ -d "$REPORT_DIR" ]; then
log_info "Test reports saved in: $REPORT_DIR"
fi
}
trap cleanup_on_exit EXIT
# Show usage if help requested
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
echo "ROA2WEB Comprehensive Test Execution Script"
echo ""
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " --no-cleanup Skip cleanup of previous test artifacts"
echo " --no-basic Skip basic test suite"
echo " --no-real-world Skip real-world tests"
echo " --no-debugging Skip debugging tests"
echo " --no-services Skip service availability check"
echo " --help, -h Show this help message"
echo ""
echo "Prerequisites:"
echo " - SSH tunnel active: ./ssh_tunnel.sh start"
echo " - Backend running: uvicorn app.main:app --reload --host 0.0.0.0 --port 8000"
echo " - Frontend running: npm run dev"
echo ""
exit 0
fi
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--no-cleanup)
RUN_CLEANUP=false
shift
;;
--no-basic)
RUN_BASIC_TESTS=false
shift
;;
--no-real-world)
RUN_REAL_WORLD_TESTS=false
shift
;;
--no-debugging)
RUN_DEBUGGING_TESTS=false
shift
;;
--no-services)
CHECK_SERVICES=false
shift
;;
*)
log_warning "Unknown option: $1"
shift
;;
esac
done
# Execute main function
main "$@"

View File

@@ -0,0 +1,210 @@
#!/usr/bin/env bash
# Comprehensive Playwright Testing Script with Console Error Monitoring
# Runs both mock-based e2e tests and real API integration tests
# Includes SSH tunnel management and service orchestration
set -e # Exit on any error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
ROA2WEB_DIR="$PROJECT_ROOT/roa2web"
FRONTEND_DIR="$SCRIPT_DIR"
BACKEND_DIR="$SCRIPT_DIR/../backend"
# Test configuration
RUN_MOCK_TESTS=true
RUN_INTEGRATION_TESTS=true
CLEANUP_ON_EXIT=true
GENERATE_REPORTS=true
CHECK_SERVICES=true
# Service PIDs for cleanup
BACKEND_PID=""
FRONTEND_PID=""
TUNNEL_ACTIVE=false
# Logging functions
log_info() {
echo -e "${BLUE} $1${NC}"
}
log_success() {
echo -e "${GREEN}$1${NC}"
}
log_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
log_error() {
echo -e "${RED}$1${NC}"
}
log_step() {
echo -e "${PURPLE}🔧 $1${NC}"
}
log_test() {
echo -e "${CYAN}🧪 $1${NC}"
}
# Simple version - just run the tests
main() {
echo -e "${PURPLE}"
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ ROA2WEB Comprehensive Playwright Testing Suite ║"
echo "║ ║"
echo "║ • Console Error Monitoring ║"
echo "║ • Real Oracle Data Integration ║"
echo "║ • Performance Regression Testing ║"
echo "║ • Mock-based E2E Testing ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo -e "${NC}"
echo ""
local start_time=$(date +%s)
log_info "Starting ROA2WEB test execution..."
echo ""
# Check if services are running
log_step "Checking service availability..."
# Check backend
if curl -s http://localhost:8000/health > /dev/null 2>&1; then
log_success "Backend service is available"
else
log_warning "Backend service not available - some tests may fail"
fi
# Check frontend
if curl -s http://localhost:3001 > /dev/null 2>&1; then
log_success "Frontend service is available"
else
log_warning "Frontend service not available - tests may fail"
fi
# Check SSH tunnel (check if Oracle port is accessible)
if nc -z localhost 1521 2>/dev/null; then
log_success "SSH tunnel appears to be active (port 1521 accessible)"
else
log_warning "SSH tunnel may not be active - integration tests may fail"
log_info "Run: cd ../../../.. && ./ssh_tunnel.sh start"
fi
echo ""
# Ensure test results directory exists
mkdir -p test-results
# Track test results
MOCK_TESTS_RESULT=0
INTEGRATION_TESTS_RESULT=0
# Run mock-based tests if requested
if [ "$1" != "--no-mock" ]; then
log_test "Running mock-based E2E tests..."
if npx playwright test --config=playwright.config.js --reporter=html,json,junit; then
log_success "Mock-based E2E tests completed successfully"
else
log_error "Mock-based E2E tests failed"
MOCK_TESTS_RESULT=1
fi
echo ""
fi
# Run integration tests if requested
if [ "$1" != "--no-integration" ]; then
log_test "Running real API integration tests..."
if npx playwright test --config=playwright.real-api.config.js --reporter=html,json,junit; then
log_success "Real API integration tests completed successfully"
else
log_error "Real API integration tests failed"
INTEGRATION_TESTS_RESULT=1
fi
echo ""
fi
# Calculate execution time
local end_time=$(date +%s)
local execution_time=$((end_time - start_time))
local minutes=$((execution_time / 60))
local seconds=$((execution_time % 60))
echo ""
echo -e "${PURPLE}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${PURPLE}║ Test Execution Summary ║${NC}"
echo -e "${PURPLE}╚══════════════════════════════════════════════════════════════╝${NC}"
echo ""
if [ $MOCK_TESTS_RESULT -eq 0 ]; then
log_success "Mock-based E2E Tests: PASSED"
else
log_error "Mock-based E2E Tests: FAILED"
fi
if [ $INTEGRATION_TESTS_RESULT -eq 0 ]; then
log_success "Real API Integration Tests: PASSED"
else
log_error "Real API Integration Tests: FAILED"
fi
echo ""
log_info "Total Execution Time: ${minutes}m ${seconds}s"
echo ""
log_info "Test reports available at:"
log_info " - HTML Reports: playwright-report/ and playwright-report-integration/"
log_info " - JSON Results: test-results/*.json"
log_info " - JUnit Results: test-results/*.xml"
echo ""
# Final result
if [ $MOCK_TESTS_RESULT -eq 0 ] && [ $INTEGRATION_TESTS_RESULT -eq 0 ]; then
log_success "🎉 All tests completed successfully!"
echo ""
log_info "✨ Console error monitoring detected no critical issues"
log_info "🚀 Performance baselines met for all operations"
log_info "🔒 Real Oracle data integration working correctly"
echo ""
exit 0
else
log_error "❌ Some tests failed - check reports for details"
echo ""
exit 1
fi
}
# Show usage if help requested
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
echo "ROA2WEB Comprehensive Playwright Testing Script"
echo ""
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " --no-mock Skip mock-based E2E tests"
echo " --no-integration Skip real API integration tests"
echo " --help, -h Show this help message"
echo ""
echo "Prerequisites:"
echo " - SSH tunnel active: ./ssh_tunnel.sh start"
echo " - Backend running: uvicorn app.main:app --reload --host 0.0.0.0 --port 8000"
echo " - Frontend running: npm run dev"
echo ""
exit 0
fi
# Execute main function with all arguments
main "$@"

View File

@@ -0,0 +1,236 @@
# 📱 Android Testing Scripts
Scripturi pentru testarea aplicației ROA2WEB pe telefoane Android reale.
## 🎯 Scripturi Disponibile
| Script | Platform | Status | Descriere |
|--------|----------|---------|-----------|
| **android-test-setup.ps1** | Windows PowerShell | [OK] Functional | Setup complet Android testing |
| **android-disconnect.sh** | Bash/WSL | [OK] Functional | Cleanup port forwarding |
---
## Quick Start (Windows)
**In Windows PowerShell:**
```powershell
cd E:\proiecte\roa2web\roa2web\reports-app\frontend\scripts
# Setup complet (prima data)
.\android-test-setup.ps1
```
**Pentru cleanup:**
```bash
# In WSL (dupa testare)
cd /mnt/e/proiecte/roa2web/roa2web/reports-app/frontend
./scripts/android-disconnect.sh
```
**Screenshot-uri (Claude Code):**
Nu mai este nevoie de script pentru salvare screenshot-uri! Claude Code poate face screenshot-uri direct prin MCP (chrome-devtools-android) si le primeste inline pentru analiza.
---
## 📜 Documentație Detaliată
### 1⃣ `android-test-setup.ps1` (Windows PowerShell)
**Scop:** Configurare completă conexiune Android pentru testare
**Ce face:**
- ✅ Verifică ADB este instalat
- ✅ Verifică telefon conectat (WiFi sau USB)
- ✅ Configurează port forwarding pentru Chrome DevTools (9222)
- ✅ Configurează reverse port forwarding pentru acces aplicație (3000, 8001)
- ✅ Testează conexiunea la Chrome pe telefon
- ✅ Afișează informații rețea și configurare MCP
- ✅ Comenzi utile pentru debugging
**Utilizare:**
```powershell
cd E:\proiecte\roa2web\roa2web\reports-app\frontend\scripts
.\android-test-setup.ps1
```
**Când să rulezi:**
- Prima dată când conectezi telefonul
- După restart calculator/telefon
- Când port forwarding nu mai funcționează
- Pentru verificare setup
**Output exemplu:**
```
================================
🚀 ROA2WEB - Android Testing Setup
================================
✓ ADB este instalat
Android Debug Bridge version 1.0.41
✓ Telefon Android conectat: 1 dispozitiv(e)
10.0.20.114:38261 device
✓ Port forwarding configurat: localhost:9222 -> Chrome pe telefon
10.0.20.114:38261 tcp:9222 localabstract:chrome_devtools_remote
✓ Reverse port forwarding configurat
Frontend: http://localhost:3000
Backend: http://localhost:8001/api
IP-ul calculatorului: 10.0.20.144
```
---
### 2 `android-disconnect.sh` (Bash/WSL)
**Scop:** Cleanup port forwarding când termini testarea
**Ce face:**
- ✅ Șterge toate port forwarding-urile (9222, 3000, 8001)
- ✅ Șterge reverse port forwarding
- ✅ Cleanup complet pentru deconectare sigură
**Utilizare:**
```bash
cd /mnt/e/proiecte/roa2web/roa2web/reports-app/frontend
./scripts/android-disconnect.sh
```
**Când să rulezi:**
- După finalizarea sesiunii de testare
- Înainte de a deconecta telefonul
- Pentru cleanup general
**Output exemplu:**
```
🔌 Deconectare telefon Android și cleanup...
✓ Port forwarding șters
✓ Reverse port forwarding șters
✅ Deconectare completă!
```
---
## 🔧 Workflow Complet de Testare
### Setup Inițial (o dată):
**1. Instalează ADB pe Windows:**
```powershell
winget install Google.PlatformTools
```
**2. Configurează telefonul Android:**
```
Setări → Despre telefon → Apasă 7x "Build number"
Setări → Developer options → Activează "USB debugging"
Setări → Developer options → Activează "Wireless debugging"
```
**3. Conectează telefonul:**
- **WiFi:** `adb pair IP:PORT` apoi `adb connect IP:PORT`
- **USB:** Conectează cablu, acceptă "Allow USB debugging"
### Workflow Zilnic:
```powershell
# Windows PowerShell
# 1. Setup conexiune
cd E:\proiecte\roa2web\roa2web\reports-app\frontend\scripts
.\android-test-setup.ps1
# 2. Pornește aplicația (în WSL)
cd /mnt/e/proiecte/roa2web/roa2web
./start-dev.sh
# 3. Pe telefon Chrome: http://localhost:3000
# 4. In Claude Code: "fa screenshot de pe telefon" (MCP inline)
# 5. La final, cleanup (WSL)
./scripts/android-disconnect.sh
```
---
## 🐛 Troubleshooting
### "ADB not found"
```powershell
winget install Google.PlatformTools
# Sau download manual: https://developer.android.com/tools/releases/platform-tools
```
### "No Android device connected"
**WiFi:**
```powershell
adb pair 10.0.20.114:PORT # Portul din "Pair device"
adb connect 10.0.20.114:PORT # Portul wireless debugging
adb devices # Verifică
```
**USB:**
- Verifică cablul (unele sunt doar pentru încărcare)
- Deblochează telefonul
- Acceptă "Allow USB debugging"
### "Port forwarding nu funcționează"
```powershell
# Re-setup complet
.\android-test-setup.ps1
```
---
## ⚠️ Note Importante
### De ce `android-test-setup.sh` nu funcționează în WSL?
ADB în WSL2 **nu poate vedea** dispozitivele USB conectate la Windows. Chiar și cu ADB wireless, există probleme de networking între WSL2 și Android device.
**Soluție:** Folosește scripturile **PowerShell** care rulează ADB direct în Windows!
### Chrome DevTools MCP
Pentru ca Chrome DevTools MCP să funcționeze din WSL (Claude Code), trebuie:
1. Port forwarding activ: `adb forward tcp:9222 ...`
2. Windows port proxy: `netsh interface portproxy add v4tov4 ...`
3. Configurare MCP cu IP-ul fizic Windows: `http://10.0.20.144:9222`
Vezi `tests/ANDROID_TESTING_GUIDE.md` pentru setup complet.
---
## 📚 Documentație Suplimentară
- **ANDROID_QUICK_START.md** - Ghid rapid 5 minute
- **tests/ANDROID_TESTING_GUIDE.md** - Ghid complet cu troubleshooting
- **frontend/README.md** - Secțiunea "Testing on Real Android Devices"
---
## Summary
**Scripturi functionale:**
- [OK] `android-test-setup.ps1` (Windows PowerShell) - Setup complet
- [OK] `android-disconnect.sh` (WSL) - Cleanup
**Screenshot-uri:**
- Nu mai este nevoie de script dedicat
- Claude Code face screenshot-uri prin MCP (chrome-devtools-android) inline
**Testare optima:**
- Ruleaza android-test-setup.ps1 din Windows PowerShell
- Claude Code controleaza Chrome pe telefon prin MCP
---
**Autor:** ROA2WEB Development Team
**Data:** 2025-10-20
**Versiune:** 3.0 (Final cleanup - doar scripturi esentiale)

View File

@@ -0,0 +1,39 @@
#!/bin/bash
# ROA2WEB - Android Disconnect Script
# Opreste conexiunea si curata port forwarding
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_success() { echo -e "${GREEN}[OK] $1${NC}"; }
print_info() { echo -e "${BLUE}[INFO] $1${NC}"; }
echo
print_info " Deconectare telefon Android si cleanup..."
echo
# Remove all port forwarding
print_info "Stergere port forwarding..."
adb forward --remove-all 2>/dev/null || true
print_success "Port forwarding sters"
# Remove all reverse port forwarding
print_info "Stergere reverse port forwarding..."
adb reverse --remove-all 2>/dev/null || true
print_success "Reverse port forwarding sters"
echo
print_success "[OK] Deconectare completa!"
echo
print_info "Poti deconecta telefonul de la calculator in siguranta."
echo
print_info "Pentru a reconecta, ruleaza (Windows PowerShell):"
echo " .\\android-test-setup.ps1"
echo

View File

@@ -0,0 +1,266 @@
# ROA2WEB - Android Testing Setup Script (PowerShell)
# Configureaza conexiunea la telefon Android pentru testare
param(
[switch]$Help
)
if ($Help) {
Write-Host @"
ROA2WEB - Android Testing Setup
Acest script configureaza conexiunea ADB Wireless pentru testare pe Android.
Usage:
.\android-test-setup.ps1
Prerequisites:
- ADB instalat (winget install Google.PlatformTools)
- Telefon Android cu Wireless Debugging activat
- Telefon si calculator in aceeasi retea WiFi
"@
exit 0
}
# Colors
function Write-Header($message) {
Write-Host "`n================================" -ForegroundColor Blue
Write-Host $message -ForegroundColor Blue
Write-Host "================================`n" -ForegroundColor Blue
}
function Write-Success($message) {
Write-Host "[OK] $message" -ForegroundColor Green
}
function Write-Error($message) {
Write-Host "[ERROR] $message" -ForegroundColor Red
}
function Write-Warning($message) {
Write-Host "[WARN] $message" -ForegroundColor Yellow
}
function Write-Info($message) {
Write-Host "[INFO] $message" -ForegroundColor Cyan
}
# Check ADB is installed
function Check-ADB {
Write-Header "Verificare ADB (Android Debug Bridge)"
$adb = Get-Command adb -ErrorAction SilentlyContinue
if (-not $adb) {
Write-Error "ADB nu este instalat sau nu este in PATH!"
Write-Host ""
Write-Info "Pentru a instala ADB:"
Write-Host " winget install Google.PlatformTools"
Write-Host ""
exit 1
}
Write-Success "ADB este instalat"
adb version | Select-Object -First 1
Write-Host ""
}
# Check device connection
function Check-Device {
Write-Header "Verificare Conexiune Telefon"
$devices = adb devices | Select-String "device$" | Measure-Object | Select-Object -ExpandProperty Count
if ($devices -eq 0) {
Write-Error "Niciun telefon Android conectat!"
Write-Host ""
Write-Info "Pasi de conectare ADB Wireless:"
Write-Host " 1. Pe telefon: Setari → Developer options → Wireless debugging → ON"
Write-Host " 2. Apasa pe 'Wireless debugging' → 'Pair device with pairing code'"
Write-Host " 3. In PowerShell: adb pair IP:PORT"
Write-Host " 4. Introdu codul de pe telefon"
Write-Host " 5. In PowerShell: adb connect IP:PORT_WIRELESS"
Write-Host ""
Write-Info "Dispozitive detectate:"
adb devices
Write-Host ""
exit 1
}
Write-Success "Telefon Android conectat: $devices dispozitiv(e)"
adb devices | Select-String "device$"
Write-Host ""
}
# Setup port forwarding for Chrome DevTools
function Setup-ChromeDevTools {
Write-Header "Configurare Chrome DevTools Port Forwarding"
# Remove existing forwarding
adb forward --remove-all | Out-Null
# Setup new forwarding
adb forward tcp:9222 localabstract:chrome_devtools_remote | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Success "Port forwarding configurat: localhost:9222 -> Chrome pe telefon"
} else {
Write-Error "Eroare la configurarea port forwarding!"
exit 1
}
# Verify forwarding
Write-Info "Port forwarding activ:"
adb forward --list
Write-Host ""
}
# Setup reverse port forwarding for app access
function Setup-AppAccess {
Write-Header "Configurare Acces la Aplicatie de pe Telefon"
# Remove existing reverse forwarding
adb reverse --remove-all 2>$null | Out-Null
# Setup reverse forwarding for frontend and backend
adb reverse tcp:3000 tcp:3000 | Out-Null
adb reverse tcp:8001 tcp:8001 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Success "Reverse port forwarding configurat"
Write-Info "Pe telefon poti accesa:"
Write-Host " Frontend: http://localhost:3000"
Write-Host " Backend: http://localhost:8001/api"
} else {
Write-Warning "Reverse port forwarding a esuat (optional)"
Write-Info "Alternativ, foloseste IP-ul calculatorului:"
$localIP = (Get-NetIPAddress -AddressFamily IPv4 -InterfaceAlias "Wi-Fi*","Ethernet*" | Select-Object -First 1).IPAddress
Write-Host " Frontend: http://${localIP}:3000"
Write-Host " Backend: http://${localIP}:8001/api"
}
Write-Host ""
}
# Test Chrome DevTools connection
function Test-ChromeConnection {
Write-Header "Testare Conexiune Chrome DevTools"
Write-Info "Asigura-te ca Chrome este deschis pe telefon!"
Write-Host ""
Read-Host "Apasa Enter cand Chrome este deschis pe telefon"
try {
$response = Invoke-WebRequest -Uri "http://localhost:9222/json/version" -ErrorAction Stop
Write-Success "Conexiune reusita la Chrome pe telefon!"
Write-Host ""
Write-Info "Detalii Chrome:"
$response.Content | ConvertFrom-Json | ConvertTo-Json -Depth 10
} catch {
Write-Error "Nu se poate conecta la Chrome pe telefon!"
Write-Host ""
Write-Info "Verifica ca:"
Write-Host " 1. Chrome este deschis pe telefon"
Write-Host " 2. Port forwarding este activ: adb forward --list"
Write-Host " 3. Wireless debugging este activat"
Write-Host ""
Write-Info "Pentru debugging manual:"
Write-Host " Invoke-WebRequest http://localhost:9222/json/version"
}
Write-Host ""
}
# Display network info
function Display-NetworkInfo {
Write-Header "Informatii Retea pentru Acces Remote"
$localIP = (Get-NetIPAddress -AddressFamily IPv4 -InterfaceAlias "Wi-Fi*","Ethernet*" | Select-Object -First 1).IPAddress
Write-Info "IP-ul calculatorului: $localIP"
Write-Host ""
Write-Info "Pentru acces de pe telefon (daca reverse port forwarding nu functioneaza):"
Write-Host " Frontend: http://${localIP}:3000"
Write-Host " Backend: http://${localIP}:8001/api"
Write-Host ""
Write-Warning "Asigura-te ca telefonul si calculatorul sunt in aceeasi retea WiFi!"
Write-Host ""
}
# Display MCP configuration
function Display-MCPConfig {
Write-Header "Configurare Chrome DevTools MCP pentru Claude Code"
Write-Info "Instalare chrome-devtools-mcp:"
Write-Host " npm install -g chrome-devtools-mcp"
Write-Host ""
Write-Info "Configurare in Claude Desktop (claude_desktop_config.json):"
Write-Host ""
Write-Host @'
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": [
"-y",
"chrome-devtools-mcp",
"--browser-url",
"http://localhost:9222"
]
}
}
}
'@
Write-Host ""
Write-Warning "Dupa configurare, restart Claude Desktop!"
Write-Host ""
}
# Display useful commands
function Display-UsefulCommands {
Write-Header "Comenzi Utile"
Write-Host "Verificare dispozitive:"
Write-Host " adb devices"
Write-Host ""
Write-Host "Restart ADB server:"
Write-Host " adb kill-server; adb start-server"
Write-Host ""
Write-Host "Screenshot de pe telefon:"
Write-Host " adb shell screencap -p /sdcard/screenshot.png"
Write-Host " adb pull /sdcard/screenshot.png .\screenshot.png"
Write-Host ""
Write-Host "Verificare Chrome DevTools:"
Write-Host " Invoke-WebRequest http://localhost:9222/json/version"
Write-Host ""
Write-Host "Deschide Chrome DevTools in browser:"
Write-Host " Start-Process chrome://inspect#devices"
Write-Host ""
}
# Main
function Main {
Clear-Host
Write-Header " ROA2WEB - Android Testing Setup"
Check-ADB
Check-Device
Setup-ChromeDevTools
Setup-AppAccess
Test-ChromeConnection
Display-NetworkInfo
Display-MCPConfig
Display-UsefulCommands
Write-Header "[OK] Setup Complet!"
Write-Success "Telefonul Android este configurat pentru testare!"
Write-Host ""
Write-Info "Next steps:"
Write-Host " 1. Deschide Chrome pe telefon"
Write-Host " 2. Navigheaza la aplicatia ROA2WEB (vezi IP-ul de mai sus)"
Write-Host " 3. In Claude Code, cere: 'Fa un screenshot al aplicatiei de pe telefonul meu Android'"
Write-Host ""
}
# Run main
Main

View File

@@ -0,0 +1,221 @@
<template>
<div id="app">
<!-- Navigation Bar -->
<Menubar
v-if="authStore.isAuthenticated"
:model="menuItems"
class="app-menubar"
>
<template #start>
<div class="flex align-items-center gap-2">
<i class="pi pi-chart-bar text-primary text-2xl"></i>
<span class="font-bold text-xl">ROA Reports</span>
</div>
</template>
<template #end>
<div class="flex align-items-center gap-3">
<Badge
:value="selectedCompany?.name || 'Selectați firmă'"
:severity="selectedCompany ? 'info' : 'warning'"
/>
<Button
icon="pi pi-sign-out"
label="Deconectare"
text
@click="logout"
class="p-button-text"
/>
</div>
</template>
</Menubar>
<!-- Main Content -->
<main
class="main-content"
:class="{ 'with-navbar': authStore.isAuthenticated }"
>
<router-view />
</main>
<!-- Global Toast Messages - positioned below header to avoid covering company selector -->
<Toast position="top-center" :style="{ top: '80px' }" />
<!-- Global Confirmation Dialog -->
<ConfirmDialog />
</div>
</template>
<script setup>
import { computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "./stores/auth";
import { useCompanyStore } from "./stores/companies";
const router = useRouter();
const authStore = useAuthStore();
const companyStore = useCompanyStore();
// Dashboard options
const dashboardOptions = [
{ label: 'Main Dashboard', value: '/dashboard' },
{ label: 'New Dashboard', value: '/dashboard-new' },
{ label: 'Ultra Minimal', value: '/dashboard-v1' },
{ label: 'Compact Grid', value: '/dashboard-v2' },
{ label: 'Data Tables', value: '/dashboard-v3' },
{ label: 'Action Center', value: '/dashboard-v4' }
];
// Menu items for navigation
const menuItems = computed(() => [
{
label: "Dashboard",
icon: "pi pi-home",
items: dashboardOptions.map(option => ({
label: option.label,
command: () => router.push(option.value)
}))
},
{
label: "Facturi",
icon: "pi pi-file-text",
command: () => router.push("/invoices"),
},
{
label: "Registru Casa si Banca",
icon: "pi pi-wallet",
command: () => router.push("/bank-cash-register"),
},
]);
// Get selected company
const selectedCompany = computed(() => companyStore.selectedCompany);
// Logout function
const logout = () => {
authStore.logout();
router.push("/login");
};
// Initialize app
onMounted(async () => {
// Check authentication on app start
if (authStore.isAuthenticated) {
try {
// Load companies if authenticated
await companyStore.loadCompanies();
} catch (error) {
console.error("Failed to load companies:", error);
}
}
});
</script>
<style scoped>
#app {
min-height: 100vh;
background-color: var(--surface-ground);
}
.app-menubar {
border-radius: 0;
border-left: none;
border-right: none;
border-top: none;
}
.main-content {
transition: all 0.3s ease;
}
.main-content.with-navbar {
margin-top: 0;
min-height: calc(100vh - 70px);
}
.main-content:not(.with-navbar) {
min-height: 100vh;
}
</style>
<style>
/* Global styles */
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
sans-serif;
background-color: var(--surface-ground);
}
.app-container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
/* Responsive design */
@media (max-width: 768px) {
.app-container {
padding: 0.25rem;
max-width: 100%;
}
.app-menubar .p-menubar-button {
display: block;
}
}
@media (max-width: 480px) {
.main-content {
padding: 0;
}
.app-container {
padding: 0;
max-width: 100vw;
}
}
/* Custom PrimeVue overrides */
.p-button {
font-weight: 500;
}
.p-datatable .p-datatable-tbody > tr.invoice-paid {
background-color: var(--green-50);
color: var(--green-900);
}
.p-datatable .p-datatable-tbody > tr.invoice-overdue {
background-color: var(--red-50);
color: var(--red-900);
}
/* Status badges */
.status-paid {
background-color: var(--green-100);
color: var(--green-900);
}
.status-overdue {
background-color: var(--red-100);
color: var(--red-900);
}
.status-pending {
background-color: var(--yellow-100);
color: var(--yellow-900);
}
</style>

View File

@@ -0,0 +1,430 @@
/* Button Components - ROA2WEB */
/* Base Button Styles */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
font-size: var(--text-sm);
font-weight: var(--font-medium);
line-height: var(--leading-normal);
border: 1px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
text-decoration: none;
transition: all var(--transition-fast);
user-select: none;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
/* Button Sizes */
.btn-xs {
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-xs);
gap: 2px;
}
.btn-sm {
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-sm);
}
.btn-md {
padding: var(--space-sm) var(--space-md);
font-size: var(--text-sm);
}
.btn-lg {
padding: var(--space-md) var(--space-lg);
font-size: var(--text-base);
}
.btn-xl {
padding: var(--space-lg) var(--space-xl);
font-size: var(--text-lg);
}
/* Button Variants */
.btn-primary {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
.btn-primary:hover {
background: var(--color-primary-dark);
border-color: var(--color-primary-dark);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-secondary {
background: var(--color-bg);
color: var(--color-text);
border-color: var(--color-border);
}
.btn-secondary:hover {
background: var(--color-bg-secondary);
border-color: var(--color-border-dark);
}
.btn-outline {
background: transparent;
color: var(--color-primary);
border-color: var(--color-primary);
}
.btn-outline:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
}
.btn-ghost {
background: transparent;
color: var(--color-text);
border-color: transparent;
}
.btn-ghost:hover {
background: var(--color-bg-secondary);
border-color: var(--color-border);
}
/* Status Button Variants */
.btn-success {
background: var(--color-success);
color: var(--color-text-inverse);
border-color: var(--color-success);
}
.btn-success:hover {
background: #047857;
border-color: #047857;
}
.btn-warning {
background: var(--color-warning);
color: var(--color-text-inverse);
border-color: var(--color-warning);
}
.btn-warning:hover {
background: #b45309;
border-color: #b45309;
}
.btn-error {
background: var(--color-error);
color: var(--color-text-inverse);
border-color: var(--color-error);
}
.btn-error:hover {
background: #b91c1c;
border-color: #b91c1c;
}
/* Button Shapes */
.btn-rounded {
border-radius: var(--radius-full);
}
.btn-square {
border-radius: 0;
}
.btn-circle {
border-radius: var(--radius-full);
width: 40px;
height: 40px;
padding: 0;
}
.btn-circle.btn-sm {
width: 32px;
height: 32px;
}
.btn-circle.btn-lg {
width: 48px;
height: 48px;
}
/* Icon Buttons */
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border-radius: var(--radius-md);
}
.btn-icon-sm {
width: 32px;
height: 32px;
}
.btn-icon-lg {
width: 48px;
height: 48px;
}
/* Button Groups */
.btn-group {
display: inline-flex;
align-items: center;
}
.btn-group .btn {
border-radius: 0;
border-right-width: 0;
}
.btn-group .btn:first-child {
border-top-left-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.btn-group .btn:last-child {
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
border-right-width: 1px;
}
.btn-group .btn:hover {
z-index: 1;
border-right-width: 1px;
}
/* Action Buttons for Dashboard V4 */
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-md);
padding: var(--space-xl);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
color: var(--color-text);
min-height: 120px;
}
.action-btn:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.action-btn-icon {
width: 32px;
height: 32px;
opacity: 0.8;
}
.action-btn:hover .action-btn-icon {
opacity: 1;
}
.action-btn-label {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
text-align: center;
}
/* Toggle Buttons */
.btn-toggle {
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
border-color: var(--color-border);
}
.btn-toggle.active,
.btn-toggle:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
/* Pill Buttons */
.btn-pill {
border-radius: var(--radius-full);
padding: var(--space-xs) var(--space-md);
font-size: var(--text-xs);
font-weight: var(--font-medium);
}
/* Loading State */
.btn-loading {
opacity: 0.7;
cursor: not-allowed;
}
.btn-loading::before {
content: '';
display: inline-block;
width: 16px;
height: 16px;
margin-right: var(--space-xs);
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: var(--radius-full);
animation: btn-spin 0.8s linear infinite;
}
@keyframes btn-spin {
to {
transform: rotate(360deg);
}
}
/* Mobile Button Adjustments */
@media (max-width: 768px) {
.btn {
padding: var(--space-md) var(--space-lg);
font-size: var(--text-base);
min-height: 44px;
}
.btn-sm {
padding: var(--space-sm) var(--space-md);
font-size: var(--text-sm);
min-height: 36px;
}
.btn-group {
flex-direction: column;
width: 100%;
}
.btn-group .btn {
border-radius: 0;
border-right-width: 1px;
border-bottom-width: 0;
width: 100%;
}
.btn-group .btn:first-child {
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
.btn-group .btn:last-child {
border-radius: 0 0 var(--radius-md) var(--radius-md);
border-bottom-width: 1px;
}
.action-btn {
padding: var(--space-lg);
min-height: 100px;
}
}
@media (max-width: 480px) {
.action-btn {
min-height: 80px;
padding: var(--space-md);
}
.action-btn-icon {
width: 24px;
height: 24px;
}
.action-btn-label {
font-size: var(--text-xs);
}
}
/* Focus States for Accessibility */
.btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Button Groups for Dashboard */
.button-group {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.button-group .btn {
border-radius: var(--radius-md);
}
/* Hide button text on small screens */
@media (max-width: 640px) {
.btn-text {
display: none;
}
.button-group {
width: 100%;
}
.button-group .btn {
flex: 1;
justify-content: center;
}
}
/* Stack buttons vertically on very small screens */
@media (max-width: 480px) {
.button-group {
flex-direction: column;
width: 100%;
}
.button-group .btn {
width: 100%;
}
.btn-text {
display: inline; /* Show text again when stacked */
}
}
/* Primary button style for exports */
.btn-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.btn-primary:hover {
background: var(--color-primary-dark);
border-color: var(--color-primary-dark);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-primary:active {
transform: translateY(0);
}
/* Utility Classes */
.btn-full-width {
width: 100%;
justify-content: center;
}
.btn-auto-width {
width: auto;
}

View File

@@ -0,0 +1,360 @@
/* Card Components - ROA2WEB */
/* Base Card Styles */
.card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
box-shadow: var(--shadow-sm);
transition: all var(--transition-fast);
overflow: hidden;
}
.card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-border-dark);
}
.card-header {
padding: var(--space-lg);
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.card-body {
padding: var(--space-lg);
}
.card-footer {
padding: var(--space-lg);
border-top: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
/* Card Variants */
.card-compact {
padding: var(--space-md);
}
.card-compact .card-header,
.card-compact .card-body,
.card-compact .card-footer {
padding: var(--space-md);
}
.card-minimal {
border: none;
box-shadow: none;
background: transparent;
}
.card-elevated {
box-shadow: var(--shadow-lg);
}
.card-elevated:hover {
box-shadow: var(--shadow-xl);
transform: translateY(-2px);
}
/* Stats Cards */
.stats-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-lg);
text-align: center;
transition: all var(--transition-fast);
}
.stats-card:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
}
.stats-card-mini {
padding: var(--space-md);
text-align: left;
}
.stats-card-large {
padding: var(--space-xl);
}
/* Stats Card Content */
.stats-value {
display: block;
font-size: var(--text-2xl);
font-weight: var(--font-bold);
color: var(--color-text);
line-height: var(--leading-tight);
margin-bottom: var(--space-xs);
}
.stats-value-large {
font-size: var(--text-4xl);
margin-bottom: var(--space-sm);
}
.stats-label {
display: block;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stats-change {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-sm);
font-weight: var(--font-medium);
margin-top: var(--space-xs);
}
.stats-change.positive {
color: var(--color-success);
}
.stats-change.negative {
color: var(--color-error);
}
/* KPI Cards */
.kpi-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-lg);
display: flex;
align-items: center;
gap: var(--space-md);
transition: all var(--transition-fast);
}
.kpi-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-primary);
}
.kpi-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: var(--color-text-inverse);
flex-shrink: 0;
}
.kpi-content {
flex: 1;
min-width: 0;
}
.kpi-value {
font-size: var(--text-xl);
font-weight: var(--font-bold);
color: var(--color-text);
line-height: var(--leading-tight);
}
.kpi-label {
font-size: var(--text-sm);
color: var(--color-text-secondary);
font-weight: var(--font-medium);
margin-top: var(--space-xs);
}
/* Action Cards */
.action-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-lg);
cursor: pointer;
transition: all var(--transition-fast);
text-align: center;
}
.action-card:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.action-icon {
width: 32px;
height: 32px;
margin: 0 auto var(--space-md);
opacity: 0.8;
}
.action-title {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
margin-bottom: var(--space-sm);
}
.action-description {
font-size: var(--text-sm);
opacity: 0.8;
}
/* Status Cards */
.status-card {
background: var(--color-bg);
border-left: 4px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-md);
box-shadow: var(--shadow-sm);
}
.status-card.success {
border-left-color: var(--color-success);
background: #f0fdf4;
}
.status-card.warning {
border-left-color: var(--color-warning);
background: #fffbeb;
}
.status-card.error {
border-left-color: var(--color-error);
background: #fef2f2;
}
.status-card.info {
border-left-color: var(--color-info);
background: #f0f9ff;
}
/* Company Banner Card */
.company-banner {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
color: var(--color-text-inverse);
border: none;
padding: var(--space-md);
margin-bottom: var(--space-lg);
}
.company-name {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
margin-bottom: var(--space-xs);
}
.company-info {
font-size: var(--text-sm);
opacity: 0.9;
}
/* Dashboard V2 Mini Cards */
.mini-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-sm);
text-align: center;
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.mini-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-primary);
}
.mini-card-icon {
width: 16px;
height: 16px;
margin-bottom: var(--space-xs);
opacity: 0.7;
}
.mini-card-value {
font-size: var(--text-lg);
font-weight: var(--font-bold);
line-height: var(--leading-tight);
}
.mini-card-label {
font-size: var(--text-xs);
color: var(--color-text-secondary);
margin-top: var(--space-xs);
}
/* Heatmap Colors for Mini Cards */
.mini-card.heat-low {
background: #f0fdf4;
border-color: var(--color-success);
}
.mini-card.heat-medium {
background: #fffbeb;
border-color: var(--color-warning);
}
.mini-card.heat-high {
background: #fef2f2;
border-color: var(--color-error);
}
/* Mobile Card Adjustments */
@media (max-width: 768px) {
.card-header,
.card-body,
.card-footer {
padding: var(--space-md);
}
.stats-card,
.kpi-card,
.action-card {
padding: var(--space-md);
}
.kpi-card {
flex-direction: column;
text-align: center;
}
.stats-value {
font-size: var(--text-xl);
}
.stats-value-large {
font-size: var(--text-3xl);
}
.company-banner {
padding: var(--space-sm);
}
}
@media (max-width: 480px) {
.card-header,
.card-body,
.card-footer,
.stats-card,
.kpi-card,
.action-card {
padding: var(--space-sm);
}
.mini-card {
padding: var(--space-xs);
}
.mini-card-value {
font-size: var(--text-base);
}
}

View File

@@ -0,0 +1,460 @@
/* Form Components - ROA2WEB */
/* Base Form Styles */
.form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.form-row {
display: flex;
gap: var(--space-md);
align-items: end;
}
.form-col {
flex: 1;
}
/* Labels */
.form-label {
display: block;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text);
margin-bottom: var(--space-xs);
}
.form-label.required::after {
content: ' *';
color: var(--color-error);
}
/* Input Base Styles */
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-base);
font-family: inherit;
color: var(--color-text);
background: var(--color-bg);
transition: all var(--transition-fast);
min-height: 44px;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-input:disabled,
.form-select:disabled,
.form-textarea:disabled {
background: var(--color-bg-muted);
color: var(--color-text-muted);
cursor: not-allowed;
opacity: 0.6;
}
/* Input Variants */
.form-input-sm {
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-sm);
min-height: 36px;
}
.form-input-lg {
padding: var(--space-md) var(--space-lg);
font-size: var(--text-lg);
min-height: 52px;
}
/* Textarea */
.form-textarea {
resize: vertical;
min-height: 100px;
line-height: var(--leading-normal);
}
.form-textarea-sm {
min-height: 80px;
}
.form-textarea-lg {
min-height: 120px;
}
/* Select */
.form-select {
cursor: pointer;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right var(--space-sm) center;
background-repeat: no-repeat;
background-size: 16px 16px;
padding-right: var(--space-xl);
appearance: none;
}
/* Input Groups */
.input-group {
display: flex;
align-items: stretch;
width: 100%;
}
.input-group .form-input {
border-radius: 0;
border-right: none;
}
.input-group .form-input:first-child {
border-top-left-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.input-group .form-input:last-child {
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
border-right: 1px solid var(--color-border);
}
.input-group-addon {
display: flex;
align-items: center;
padding: var(--space-sm) var(--space-md);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-size: var(--text-sm);
white-space: nowrap;
}
.input-group-addon:first-child {
border-top-left-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
border-right: none;
}
.input-group-addon:last-child {
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
border-left: none;
}
/* Floating Labels */
.form-floating {
position: relative;
}
.form-floating .form-input,
.form-floating .form-textarea {
padding-top: var(--space-lg);
padding-bottom: var(--space-xs);
}
.form-floating .form-label {
position: absolute;
top: 0;
left: var(--space-md);
padding: var(--space-sm) var(--space-xs);
background: var(--color-bg);
color: var(--color-text-muted);
font-size: var(--text-sm);
transition: all var(--transition-fast);
pointer-events: none;
transform-origin: left center;
z-index: 1;
}
.form-floating .form-input:focus + .form-label,
.form-floating .form-input:not(:placeholder-shown) + .form-label,
.form-floating .form-textarea:focus + .form-label,
.form-floating .form-textarea:not(:placeholder-shown) + .form-label {
transform: translateY(-50%) scale(0.85);
color: var(--color-primary);
}
/* Validation States */
.form-input.valid,
.form-select.valid,
.form-textarea.valid {
border-color: var(--color-success);
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%2316a34a' viewBox='0 0 16 16'%3E%3Cpath d='M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z'/%3E%3C/svg%3E");
background-position: right var(--space-sm) center;
background-repeat: no-repeat;
background-size: 16px 16px;
}
.form-input.invalid,
.form-select.invalid,
.form-textarea.invalid {
border-color: var(--color-error);
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23dc2626' viewBox='0 0 16 16'%3E%3Cpath d='M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E");
background-position: right var(--space-sm) center;
background-repeat: no-repeat;
background-size: 16px 16px;
}
/* Select with validation needs different padding */
.form-select.valid,
.form-select.invalid {
padding-right: calc(var(--space-xl) + var(--space-lg));
}
/* Help Text */
.form-help {
font-size: var(--text-sm);
color: var(--color-text-secondary);
margin-top: var(--space-xs);
}
.form-error {
font-size: var(--text-sm);
color: var(--color-error);
margin-top: var(--space-xs);
display: flex;
align-items: center;
gap: var(--space-xs);
}
.form-success {
font-size: var(--text-sm);
color: var(--color-success);
margin-top: var(--space-xs);
display: flex;
align-items: center;
gap: var(--space-xs);
}
/* Checkboxes and Radios */
.form-check {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.form-check-input {
width: 18px;
height: 18px;
border: 1px solid var(--color-border);
background: var(--color-bg);
cursor: pointer;
transition: all var(--transition-fast);
}
.form-check-input[type="checkbox"] {
border-radius: var(--radius-sm);
}
.form-check-input[type="radio"] {
border-radius: 50%;
}
.form-check-input:checked {
background: var(--color-primary);
border-color: var(--color-primary);
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' viewBox='0 0 16 16'%3E%3Cpath d='M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z'/%3E%3C/svg%3E");
background-position: center;
background-repeat: no-repeat;
background-size: 12px 12px;
}
.form-check-input[type="radio"]:checked {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='4'/%3E%3C/svg%3E");
background-size: 8px 8px;
}
.form-check-input:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-check-label {
font-size: var(--text-sm);
color: var(--color-text);
cursor: pointer;
user-select: none;
}
/* Form Actions */
.form-actions {
display: flex;
gap: var(--space-md);
justify-content: flex-end;
margin-top: var(--space-xl);
padding-top: var(--space-lg);
border-top: 1px solid var(--color-border);
}
.form-actions-center {
justify-content: center;
}
.form-actions-start {
justify-content: flex-start;
}
.form-actions-between {
justify-content: space-between;
}
/* Search Form */
.search-form {
display: flex;
gap: var(--space-sm);
align-items: end;
margin-bottom: var(--space-lg);
}
.search-input {
position: relative;
flex: 1;
}
.search-input .form-input {
padding-right: var(--space-3xl);
}
.search-icon {
position: absolute;
right: var(--space-md);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted);
font-size: var(--text-lg);
pointer-events: none;
}
/* Inline Forms */
.form-inline {
display: flex;
gap: var(--space-md);
align-items: end;
flex-wrap: wrap;
}
.form-inline .form-group {
flex: 1;
min-width: 150px;
}
/* File Upload */
.file-upload {
position: relative;
display: inline-block;
cursor: pointer;
width: 100%;
}
.file-upload-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.file-upload-label {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-lg);
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
min-height: 120px;
text-align: center;
}
.file-upload:hover .file-upload-label,
.file-upload-label.drag-over {
border-color: var(--color-primary);
background: rgba(37, 99, 235, 0.05);
color: var(--color-primary);
}
/* Mobile Form Styles */
@media (max-width: 768px) {
.form-row {
flex-direction: column;
gap: var(--space-md);
}
.form-inline {
flex-direction: column;
align-items: stretch;
}
.form-inline .form-group {
min-width: auto;
}
.form-actions {
flex-direction: column;
}
.form-actions-between {
justify-content: center;
flex-direction: column-reverse;
}
.search-form {
flex-direction: column;
}
/* Ensure mobile-friendly touch targets */
.form-input,
.form-select,
.form-textarea {
min-height: 44px;
font-size: 16px; /* Prevents zoom on iOS */
}
.form-check-input {
width: 20px;
height: 20px;
min-height: 20px;
}
}
/* Print Styles */
@media print {
.form-actions {
display: none;
}
.form-input,
.form-select,
.form-textarea {
border: none;
border-bottom: 1px solid #000;
border-radius: 0;
background: transparent;
padding: var(--space-xs) 0;
}
.form-label {
font-weight: bold;
}
}

View File

@@ -0,0 +1,448 @@
/* Stats Components - ROA2WEB Dashboard */
/* Stats Grid Layout */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-lg);
margin-bottom: var(--space-xl);
}
/* Stats Cards */
.stats-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-lg);
box-shadow: var(--shadow-sm);
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.stats-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-border-dark);
transform: translateY(-2px);
}
/* Stats Card Header */
.stats-card-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
padding-bottom: var(--space-sm);
border-bottom: 1px solid var(--color-border-light);
}
.stats-card-header i {
font-size: var(--text-xl);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
}
.stats-card-header h3 {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--color-text);
margin: 0;
}
/* Stats Details */
.stats-details {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--text-sm);
padding: var(--space-xs) 0;
min-height: 24px;
}
.stat-row span:first-child {
color: var(--color-text-secondary);
font-weight: var(--font-medium);
}
.stat-row span:last-child {
color: var(--color-text);
font-weight: var(--font-medium);
text-align: right;
}
.stat-highlight {
background: var(--color-bg-secondary);
padding: var(--space-sm);
border-radius: var(--radius-sm);
font-weight: var(--font-semibold);
margin: var(--space-sm) 0;
border-left: 3px solid var(--color-primary);
}
.stat-warning {
color: var(--color-error);
font-weight: var(--font-semibold);
}
.stat-warning span:first-child {
color: var(--color-error);
}
.stat-success {
color: var(--color-success);
font-weight: var(--font-semibold);
}
.stat-success span:first-child {
color: var(--color-success);
}
/* Treasury Specific Styling */
.treasury-content {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.treasury-section {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.treasury-section-title {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--color-text);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-xs);
padding-bottom: var(--space-xs);
border-bottom: 1px solid var(--color-border);
}
.account-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--text-xs);
padding: var(--space-xs) 0;
}
.account-name {
color: var(--color-text-secondary);
font-weight: var(--font-medium);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-balance {
color: var(--color-text);
font-weight: var(--font-semibold);
flex-shrink: 0;
margin-left: var(--space-sm);
}
.treasury-totals {
margin-top: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--color-border);
background: var(--color-bg-muted);
margin-left: calc(-1 * var(--space-lg));
margin-right: calc(-1 * var(--space-lg));
margin-bottom: calc(-1 * var(--space-lg));
padding-left: var(--space-lg);
padding-right: var(--space-lg);
padding-bottom: var(--space-lg);
}
.total-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--text-sm);
padding: var(--space-xs) 0;
color: var(--color-text);
font-weight: var(--font-semibold);
}
/* KPI Large Display */
.kpi-large-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-xl);
text-align: center;
box-shadow: var(--shadow-sm);
transition: all var(--transition-fast);
}
.kpi-large-card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-4px);
}
.kpi-large-value {
font-size: var(--text-4xl);
font-weight: var(--font-bold);
color: var(--color-text);
line-height: var(--leading-tight);
margin-bottom: var(--space-sm);
}
.kpi-large-label {
font-size: var(--text-base);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.kpi-large-change {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
font-size: var(--text-sm);
font-weight: var(--font-medium);
margin-top: var(--space-md);
}
.kpi-large-change.positive {
color: var(--color-success);
}
.kpi-large-change.negative {
color: var(--color-error);
}
/* Mini Stats for V2 Dashboard */
.mini-stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: var(--space-md);
}
.mini-stat-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-sm);
text-align: center;
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.mini-stat-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-primary);
transform: scale(1.02);
}
.mini-stat-icon {
width: 16px;
height: 16px;
margin-bottom: var(--space-xs);
opacity: 0.7;
}
.mini-stat-value {
font-size: var(--text-base);
font-weight: var(--font-bold);
line-height: var(--leading-tight);
color: var(--color-text);
margin-bottom: var(--space-xs);
}
.mini-stat-label {
font-size: var(--text-xs);
color: var(--color-text-secondary);
line-height: var(--leading-tight);
}
/* Heat Map Colors for Mini Cards */
.mini-stat-card.heat-low {
background: #f0fdf4;
border-color: var(--color-success);
}
.mini-stat-card.heat-low .mini-stat-value {
color: var(--color-success);
}
.mini-stat-card.heat-medium {
background: #fffbeb;
border-color: var(--color-warning);
}
.mini-stat-card.heat-medium .mini-stat-value {
color: var(--color-warning);
}
.mini-stat-card.heat-high {
background: #fef2f2;
border-color: var(--color-error);
}
.mini-stat-card.heat-high .mini-stat-value {
color: var(--color-error);
}
/* Quick Actions Grid */
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-md);
}
/* Loading Spinner for Stats */
.stats-loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-xl);
color: var(--color-text-secondary);
}
.stats-loading-spinner {
width: 24px;
height: 24px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: var(--space-sm);
}
/* Stats Card Variants */
.stats-card.clients {
border-left-color: #3b82f6;
}
.stats-card.clients .stats-card-header i {
color: #3b82f6;
background: #eff6ff;
}
.stats-card.suppliers {
border-left-color: #f59e0b;
}
.stats-card.suppliers .stats-card-header i {
color: #f59e0b;
background: #fffbeb;
}
.stats-card.treasury {
border-left-color: #10b981;
}
.stats-card.treasury .stats-card-header i {
color: #10b981;
background: #ecfdf5;
}
/* Responsive Adjustments */
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.mini-stats-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
gap: var(--space-md);
}
.stats-card {
padding: var(--space-md);
}
.treasury-totals {
margin-left: calc(-1 * var(--space-md));
margin-right: calc(-1 * var(--space-md));
margin-bottom: calc(-1 * var(--space-md));
padding-left: var(--space-md);
padding-right: var(--space-md);
padding-bottom: var(--space-md);
}
.mini-stats-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(6, 1fr);
}
.kpi-large-value {
font-size: var(--text-3xl);
}
.quick-actions-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.mini-stats-grid {
grid-template-columns: 1fr;
grid-template-rows: repeat(12, auto);
}
.mini-stat-card {
padding: var(--space-xs);
}
.mini-stat-value {
font-size: var(--text-sm);
}
.stats-card-header {
flex-direction: column;
text-align: center;
gap: var(--space-xs);
}
.stat-row {
flex-direction: column;
align-items: flex-start;
gap: var(--space-xs);
}
.stat-row span:last-child {
text-align: left;
}
}
/* Print Styles */
@media print {
.stats-card {
break-inside: avoid;
box-shadow: none;
border: 1px solid #ccc;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
}

View File

@@ -0,0 +1,876 @@
/* Table Components - ROA2WEB */
/* Base Table Styles */
.table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-bg);
border-radius: var(--card-radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.table th {
background: var(--color-bg-muted);
padding: var(--space-sm) var(--space-md);
text-align: left;
border-bottom: 2px solid var(--color-border);
font-weight: var(--font-semibold);
color: var(--color-text);
font-size: var(--text-sm);
position: sticky;
top: 0;
z-index: 1;
}
.table td {
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--color-border-light);
vertical-align: middle;
}
.table tbody tr:hover {
background: var(--color-bg-secondary);
}
.table tbody tr:last-child td {
border-bottom: none;
}
/* Table Variants */
.table-striped tbody tr:nth-child(even) {
background: var(--color-bg-secondary);
}
.table-striped tbody tr:nth-child(even):hover {
background: var(--color-bg-muted);
}
.table-bordered {
border: 1px solid var(--color-border);
}
.table-bordered th,
.table-bordered td {
border: 1px solid var(--color-border);
}
.table-borderless th,
.table-borderless td {
border: none;
}
.table-sm th,
.table-sm td {
padding: var(--space-xs) var(--space-sm);
}
.table-lg th,
.table-lg td {
padding: var(--space-md) var(--space-lg);
}
/* Responsive Table */
.table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-responsive .table {
min-width: 600px;
}
/* Sortable Headers */
.table th.sortable {
cursor: pointer;
user-select: none;
position: relative;
padding-right: var(--space-xl);
}
.table th.sortable:hover {
background: var(--color-border);
}
.table th.sortable::after {
content: '↕';
position: absolute;
right: var(--space-sm);
top: 50%;
transform: translateY(-50%);
opacity: 0.5;
font-size: var(--text-xs);
}
.table th.sortable.sorted-asc::after {
content: '↑';
opacity: 1;
color: var(--color-primary);
}
.table th.sortable.sorted-desc::after {
content: '↓';
opacity: 1;
color: var(--color-primary);
}
/* Table Status Colors */
.table .cell-success,
.table .text-success {
color: var(--color-success);
font-weight: var(--font-medium);
}
.table .cell-warning,
.table .text-warning {
color: var(--color-warning);
font-weight: var(--font-medium);
}
.table .cell-error,
.table .text-error {
color: var(--color-error);
font-weight: var(--font-medium);
}
.table .cell-info,
.table .text-info {
color: var(--color-info);
font-weight: var(--font-medium);
}
.table .cell-muted,
.table .text-muted {
color: var(--color-text-muted);
}
/* Table Row States */
.table .row-selected {
background: rgba(37, 99, 235, 0.1);
border-color: var(--color-primary);
}
.table .row-active {
background: rgba(16, 185, 129, 0.1);
}
.table .row-warning {
background: rgba(245, 158, 11, 0.1);
}
.table .row-error {
background: rgba(239, 68, 68, 0.1);
}
/* Editable Cells */
.table .cell-editable {
cursor: pointer;
position: relative;
}
.table .cell-editable:hover {
background: rgba(37, 99, 235, 0.05);
}
.table .cell-input {
width: 100%;
padding: var(--space-xs);
border: 1px solid var(--color-primary);
border-radius: var(--radius-sm);
font-size: inherit;
font-family: inherit;
color: inherit;
background: var(--color-bg);
}
.table .cell-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
/* Action Buttons in Tables */
.table .table-actions {
display: flex;
gap: var(--space-xs);
justify-content: flex-end;
}
.table .table-action-btn {
padding: var(--space-xs);
border: 1px solid var(--color-border);
background: var(--color-bg);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
.table .table-action-btn:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
/* Table Pagination */
.table-pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md);
background: var(--color-bg-secondary);
border-top: 1px solid var(--color-border);
}
.pagination-info {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.pagination-controls {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.pagination-btn {
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
border-radius: var(--radius-sm);
font-size: var(--text-sm);
transition: all var(--transition-fast);
}
.pagination-btn:hover:not(:disabled) {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-current {
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-primary);
color: var(--color-text-inverse);
border-radius: var(--radius-sm);
}
/* Table Search and Filters */
.table-filters {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
gap: var(--space-md);
flex-wrap: wrap;
}
.table-search {
flex: 1;
min-width: 200px;
}
.table-filter-group {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.table-filter-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
white-space: nowrap;
}
/* Data Table Stats */
.table-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--space-md);
padding: var(--space-md);
background: var(--color-bg-muted);
border-bottom: 1px solid var(--color-border);
}
.table-stat {
text-align: center;
}
.table-stat-value {
font-size: var(--text-xl);
font-weight: var(--font-bold);
color: var(--color-text);
display: block;
margin-bottom: var(--space-xs);
}
.table-stat-label {
font-size: var(--text-xs);
color: var(--color-text-secondary);
font-weight: var(--font-medium);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Empty State */
.table-empty {
text-align: center;
padding: var(--space-3xl) var(--space-xl);
color: var(--color-text-secondary);
}
.table-empty-icon {
font-size: var(--text-4xl);
margin-bottom: var(--space-lg);
opacity: 0.5;
}
.table-empty-message {
font-size: var(--text-lg);
margin-bottom: var(--space-sm);
}
.table-empty-description {
font-size: var(--text-sm);
opacity: 0.8;
}
/* Trends Section Styling */
.trends-container {
padding: var(--space-xl);
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
.trend-placeholder {
text-align: center;
padding: var(--space-xl);
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
color: var(--color-text-secondary);
border: 2px dashed var(--color-border);
max-width: 500px;
}
.placeholder-icon {
font-size: 48px;
color: var(--color-primary);
margin-bottom: var(--space-lg);
opacity: 0.7;
}
.trend-placeholder h3 {
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--color-text);
margin: 0 0 var(--space-md) 0;
}
.trend-placeholder p {
font-size: var(--text-base);
margin: 0 0 var(--space-md) 0;
line-height: 1.6;
}
.trend-placeholder ul {
text-align: left;
display: inline-block;
margin: 0;
padding-left: var(--space-lg);
}
.trend-placeholder li {
margin-bottom: var(--space-xs);
line-height: 1.5;
color: var(--color-text-secondary);
}
/* Chart Container for future charts */
.chart-container {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
margin: var(--space-md) 0;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
/* Loading States */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-3xl);
color: var(--color-text-secondary);
text-align: center;
min-height: 200px;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: var(--space-lg);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Enhanced Responsive Table Container */
.table-container {
overflow: auto;
max-height: 500px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
}
.table-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.table-container::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
}
.table-container::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: var(--radius-full);
}
.table-container::-webkit-scrollbar-thumb:hover {
background: var(--color-border-dark);
}
/* Mobile Table Styles */
@media (max-width: 768px) {
.table-mobile-stack {
display: block;
}
.table-mobile-stack thead {
display: none;
}
.table-mobile-stack tbody,
.table-mobile-stack tr,
.table-mobile-stack td {
display: block;
width: 100%;
}
.table-mobile-stack tr {
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-md);
margin-bottom: var(--space-md);
background: var(--color-bg);
box-shadow: var(--shadow-sm);
}
.table-mobile-stack td {
border: none;
position: relative;
padding: var(--space-sm) 0;
text-align: left;
}
.table-mobile-stack td::before {
content: attr(data-label) ': ';
font-weight: var(--font-semibold);
color: var(--color-text-secondary);
display: inline-block;
width: 40%;
margin-right: var(--space-sm);
}
.table-filters {
flex-direction: column;
align-items: stretch;
}
.table-filter-group {
justify-content: space-between;
}
.table-pagination {
flex-direction: column;
gap: var(--space-md);
text-align: center;
}
.table-stats {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.table-stats {
grid-template-columns: 1fr;
}
.table-mobile-stack td::before {
width: 100%;
display: block;
margin-bottom: var(--space-xs);
margin-right: 0;
}
/* Dashboard-specific mobile styles */
.dashboard-table {
font-size: var(--text-xs);
}
.dashboard-table th,
.dashboard-table td {
padding: var(--space-sm) var(--space-md);
}
.name-cell {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trends-container {
padding: var(--space-lg);
}
.placeholder-icon {
font-size: 36px;
}
.trend-placeholder h3 {
font-size: var(--text-lg);
}
}
/* Professional Dashboard Table Styles */
.dashboard-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-bg);
border-radius: var(--card-radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border-light);
}
.dashboard-table th {
background: var(--color-bg-muted);
padding: var(--space-md) var(--space-lg);
text-align: left;
border-bottom: 2px solid var(--color-border);
font-weight: var(--font-semibold);
color: var(--color-text);
font-size: var(--text-sm);
position: sticky;
top: 0;
z-index: 10;
text-transform: uppercase;
letter-spacing: 0.025em;
font-size: var(--text-xs);
}
.dashboard-table th.text-right {
text-align: right;
}
.dashboard-table td {
padding: var(--space-sm) var(--space-lg);
border-bottom: 1px solid var(--color-border-light);
vertical-align: middle;
transition: background-color var(--transition-fast);
}
.dashboard-table td.text-right {
text-align: right;
}
.dashboard-table tbody tr:hover {
background: var(--color-bg-secondary);
}
.dashboard-table tbody tr:last-child td {
border-bottom: none;
}
/* Enhanced Table Cell Types */
.dashboard-table .category-cell {
font-weight: var(--font-medium);
color: var(--color-text);
}
.dashboard-table .name-cell {
font-weight: var(--font-medium);
color: var(--color-text);
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-table .amount-cell {
font-family: var(--font-mono);
font-weight: var(--font-medium);
text-align: right;
white-space: nowrap;
}
.dashboard-table .status-cell {
text-align: center;
}
/* Enhanced Status Badge */
.status-badge {
display: inline-block;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--font-medium);
text-transform: uppercase;
letter-spacing: 0.05em;
border: 1px solid transparent;
}
.status-badge.activ {
background: var(--color-success-bg);
color: var(--color-success);
border-color: var(--color-success);
}
.status-badge.restant {
background: var(--color-warning-bg);
color: var(--color-warning);
border-color: var(--color-warning);
}
.status-badge.inactiv {
background: var(--color-error-bg);
color: var(--color-error);
border-color: var(--color-error);
}
/* Balance Color Classes */
.positive {
color: var(--color-success) !important;
font-weight: var(--font-semibold);
}
.negative {
color: var(--color-error) !important;
font-weight: var(--font-semibold);
}
.neutral {
color: var(--color-text) !important;
}
/* Grand Total Row Enhancement */
.grand-total-row {
background: var(--color-bg-muted);
font-weight: var(--font-semibold);
border-top: 2px solid var(--color-border);
border-bottom: 2px solid var(--color-border);
}
.grand-total-row td {
padding: var(--space-md) var(--space-lg);
color: var(--color-text);
font-size: var(--text-sm);
}
.grand-total-row:hover {
background: var(--color-bg-muted) !important;
}
/* Section Styling */
.dashboard-section {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
margin-bottom: var(--space-xl);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-lg) var(--space-xl);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
gap: var(--space-md);
}
.section-title {
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--color-text);
margin: 0;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.section-controls {
display: flex;
align-items: center;
gap: var(--space-md);
flex-wrap: wrap;
}
/* Control Groups */
.control-group {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.control-group label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
white-space: nowrap;
}
.detail-select,
.detail-input,
.trend-select {
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
min-width: 120px;
background: var(--color-bg);
color: var(--color-text);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.detail-select:focus,
.detail-input:focus,
.trend-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.2);
}
/* Enhanced Pagination */
.table-pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md) var(--space-xl);
background: var(--color-bg-secondary);
border-top: 1px solid var(--color-border);
flex-wrap: wrap;
gap: var(--space-md);
}
.pagination-info {
font-size: var(--text-sm);
color: var(--color-text-secondary);
font-weight: var(--font-medium);
}
.pagination-controls {
display: flex;
align-items: center;
gap: var(--space-md);
}
.page-info {
font-size: var(--text-sm);
color: var(--color-text);
font-weight: var(--font-medium);
padding: var(--space-xs) var(--space-sm);
background: var(--color-bg-muted);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
}
/* Print Styles */
@media print {
.table {
font-size: 12px;
}
.table-filters,
.table-pagination,
.table-actions {
display: none;
}
.table th,
.table td {
padding: 4px 8px;
}
.dashboard-table {
font-size: 10px;
box-shadow: none;
border: 1px solid #000 !important;
}
.dashboard-table th {
background: #f5f5f5 !important;
color: #000 !important;
border: 1px solid #000 !important;
padding: 4px 6px;
}
.dashboard-table td {
border: 1px solid #000 !important;
padding: 4px 6px;
background: white !important;
color: #000 !important;
}
.grand-total-row td {
background: #f0f0f0 !important;
font-weight: bold;
border: 2px solid #000 !important;
}
.section-header {
display: none;
}
.dashboard-section {
page-break-inside: avoid;
margin-bottom: 20px;
box-shadow: none;
}
}

View File

@@ -0,0 +1,126 @@
/* Modern CSS Reset - ROA2WEB */
/* Box sizing rules */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Remove default margin and padding */
* {
margin: 0;
padding: 0;
}
/* Remove list styles on ul, ol elements with a list role */
ul[role='list'],
ol[role='list'] {
list-style: none;
}
/* Set core root defaults */
html {
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
-moz-text-size-adjust: 100%;
text-size-adjust: 100%;
}
/* Set core body defaults */
body {
min-height: 100vh;
line-height: var(--leading-normal);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: var(--text-base);
color: var(--color-text);
background-color: var(--color-bg);
text-rendering: optimizeSpeed;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Remove default styling from common elements */
h1, h2, h3, h4, h5, h6 {
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
}
p {
line-height: var(--leading-normal);
}
/* A elements that don't have a class get default styles */
a:not([class]) {
text-decoration-skip-ink: auto;
color: var(--color-primary);
}
/* Make images easier to work with */
img,
picture,
svg {
max-width: 100%;
height: auto;
display: block;
}
/* Inherit fonts for inputs and buttons */
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
/* Remove default button styles */
button {
border: none;
background: none;
cursor: pointer;
}
/* Make sure textareas without a rows attribute are not tiny */
textarea:not([rows]) {
min-height: 10em;
}
/* Remove default styling from fieldsets */
fieldset {
border: none;
padding: 0;
margin: 0;
}
/* Remove default styling from legends */
legend {
padding: 0;
}
/* Remove default outline on focused elements for better accessibility */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Remove all animations and transitions for people that prefer not to see them */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Ensure minimum font size on iOS to prevent zoom */
@media screen and (max-width: 480px) {
input,
textarea,
select {
font-size: 16px;
}
}

View File

@@ -0,0 +1,155 @@
/* Typography System - ROA2WEB */
/* Heading Styles */
.text-4xl, .h1 {
font-size: var(--text-4xl);
font-weight: var(--font-bold);
line-height: var(--leading-tight);
letter-spacing: -0.025em;
}
.text-3xl, .h2 {
font-size: var(--text-3xl);
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
letter-spacing: -0.025em;
}
.text-2xl, .h3 {
font-size: var(--text-2xl);
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
}
.text-xl, .h4 {
font-size: var(--text-xl);
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
}
.text-lg, .h5 {
font-size: var(--text-lg);
font-weight: var(--font-medium);
line-height: var(--leading-normal);
}
.text-base, .h6 {
font-size: var(--text-base);
font-weight: var(--font-medium);
line-height: var(--leading-normal);
}
/* Body Text Sizes */
.text-sm {
font-size: var(--text-sm);
line-height: var(--leading-normal);
}
.text-xs {
font-size: var(--text-xs);
line-height: var(--leading-normal);
}
/* Font Weights */
.font-light { font-weight: var(--font-light); }
.font-normal { font-weight: var(--font-normal); }
.font-medium { font-weight: var(--font-medium); }
.font-semibold { font-weight: var(--font-semibold); }
.font-bold { font-weight: var(--font-bold); }
/* Text Colors */
.text-primary { color: var(--color-primary); }
.text-secondary { color: var(--color-text-secondary); }
.text-muted { color: var(--color-text-muted); }
.text-inverse { color: var(--color-text-inverse); }
.text-success { color: var(--color-success); }
.text-warning { color: var(--color-warning); }
.text-error { color: var(--color-error); }
.text-info { color: var(--color-info); }
/* Text Alignment */
.text-left { text-align: left; }
.text-center { text-align: center; }
.text-right { text-align: right; }
/* Line Heights */
.leading-tight { line-height: var(--leading-tight); }
.leading-normal { line-height: var(--leading-normal); }
.leading-loose { line-height: var(--leading-loose); }
/* Letter Spacing */
.tracking-tight { letter-spacing: -0.025em; }
.tracking-normal { letter-spacing: 0; }
.tracking-wide { letter-spacing: 0.025em; }
/* Text Transform */
.uppercase { text-transform: uppercase; }
.lowercase { text-transform: lowercase; }
.capitalize { text-transform: capitalize; }
/* Text Decoration */
.underline { text-decoration: underline; }
.no-underline { text-decoration: none; }
/* Page Title Styles */
.page-title {
color: var(--color-primary);
font-size: var(--text-3xl);
font-weight: var(--font-semibold);
line-height: var(--leading-tight);
margin-bottom: var(--space-md);
}
.page-subtitle {
color: var(--color-text-secondary);
font-size: var(--text-lg);
font-weight: var(--font-normal);
line-height: var(--leading-normal);
margin-bottom: var(--space-lg);
}
/* Section Title Styles */
.section-title {
color: var(--color-text);
font-size: var(--text-xl);
font-weight: var(--font-medium);
line-height: var(--leading-tight);
margin-bottom: var(--space-sm);
}
/* KPI Display Typography */
.kpi-value {
font-size: var(--text-2xl);
font-weight: var(--font-bold);
line-height: var(--leading-tight);
color: var(--color-text);
}
.kpi-large {
font-size: var(--text-4xl);
font-weight: var(--font-bold);
line-height: var(--leading-tight);
}
.kpi-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Mobile Typography Adjustments */
@media (max-width: 480px) {
.text-4xl, .h1 { font-size: var(--text-3xl); }
.text-3xl, .h2 { font-size: var(--text-2xl); }
.text-2xl, .h3 { font-size: var(--text-xl); }
.page-title {
font-size: var(--text-2xl);
}
.kpi-large {
font-size: var(--text-3xl);
}
}

View File

@@ -0,0 +1,181 @@
/* CSS Variables - ROA2WEB Design System */
:root {
/* Spacing System */
--space-xs: 0.25rem; /* 4px */
--space-sm: 0.5rem; /* 8px */
--space-md: 1rem; /* 16px */
--space-lg: 1.5rem; /* 24px */
--space-xl: 2rem; /* 32px */
--space-2xl: 3rem; /* 48px */
--space-3xl: 4rem; /* 64px */
/* Typography Scale */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 2rem; /* 32px */
--text-4xl: 2.5rem; /* 40px */
/* Font Weights */
--font-light: 300;
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
/* Line Heights */
--leading-tight: 1.2;
--leading-normal: 1.5;
--leading-loose: 1.75;
/* Colors - Minimal Professional Palette */
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;
--color-primary-light: #3b82f6;
--color-secondary: #64748b;
--color-secondary-dark: #475569;
--color-secondary-light: #94a3b8;
--color-success: #059669;
--color-warning: #d97706;
--color-error: #dc2626;
--color-info: #0891b2;
--color-text: #111827;
--color-text-secondary: #6b7280;
--color-text-muted: #9ca3af;
--color-text-inverse: #ffffff;
--color-bg: #ffffff;
--color-bg-secondary: #f9fafb;
--color-bg-muted: #f3f4f6;
--color-bg-dark: #111827;
--color-border: #e5e7eb;
--color-border-light: #f3f4f6;
--color-border-dark: #d1d5db;
/* Surface colors for PrimeVue compatibility */
--surface-0: #ffffff;
--surface-50: #f8fafc;
--surface-100: #f1f5f9;
--surface-200: #e2e8f0;
--surface-300: #cbd5e1;
--surface-400: #94a3b8;
--surface-500: #64748b;
--surface-600: #475569;
--surface-700: #334155;
--surface-800: #1e293b;
--surface-900: #0f172a;
--surface-950: #020617;
/* Red color palette for errors */
--red-50: #fef2f2;
--red-100: #fee2e2;
--red-200: #fecaca;
--red-300: #fca5a5;
--red-400: #f87171;
--red-500: #ef4444;
--red-600: #dc2626;
--red-700: #b91c1c;
--red-800: #991b1b;
--red-900: #7f1d1d;
--red-950: #450a0a;
/* Compatibility aliases for old variable names */
--primary-color: var(--color-primary);
--primary-color-dark: var(--color-primary-dark);
--primary-color-light: var(--color-primary-light);
--text-color: var(--color-text);
--text-color-secondary: var(--color-text-secondary);
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
/* Border Radius */
--radius-sm: 0.25rem; /* 4px */
--radius-md: 0.5rem; /* 8px */
--radius-lg: 0.75rem; /* 12px */
--radius-xl: 1rem; /* 16px */
--radius-full: 9999px;
/* Layout Specific */
--header-height: 56px;
--sidebar-width: 240px;
--card-radius: var(--radius-md);
--container-max-width: 1400px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-slow: 350ms ease;
/* Additional Status Colors */
--color-success-bg: rgba(5, 150, 105, 0.1);
--color-warning-bg: rgba(217, 119, 6, 0.1);
--color-error-bg: rgba(220, 38, 38, 0.1);
--color-info-bg: rgba(8, 145, 178, 0.1);
/* Color RGB values for opacity usage */
--color-primary-rgb: 37, 99, 235;
--color-success-rgb: 5, 150, 105;
--color-warning-rgb: 217, 119, 6;
--color-error-rgb: 220, 38, 38;
/* Monospace font for numbers */
--font-mono: 'SF Mono', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
/* Z-Index Scale */
--z-dropdown: 1200;
--z-sticky: 1020;
--z-fixed: 1030;
--z-modal-backdrop: 1040;
--z-modal: 1050;
--z-popover: 1060;
--z-tooltip: 1070;
/* Breakpoints (for reference in media queries) */
--breakpoint-mobile: 480px;
--breakpoint-tablet: 768px;
--breakpoint-desktop: 1024px;
--breakpoint-wide: 1400px;
}
/* Dark mode support (for future enhancement) */
@media (prefers-color-scheme: dark) {
:root {
--color-text: #f9fafb;
--color-text-secondary: #d1d5db;
--color-text-muted: #9ca3af;
--color-bg: #111827;
--color-bg-secondary: #1f2937;
--color-bg-muted: #374151;
--color-border: #374151;
--color-border-light: #4b5563;
--color-border-dark: #6b7280;
/* Surface colors for dark mode */
--surface-0: #ffffff;
--surface-50: #020617;
--surface-100: #0f172a;
--surface-200: #1e293b;
--surface-300: #334155;
--surface-400: #475569;
--surface-500: #64748b;
--surface-600: #94a3b8;
--surface-700: #cbd5e1;
--surface-800: #e2e8f0;
--surface-900: #f1f5f9;
--surface-950: #f8fafc;
/* Red colors remain the same in dark mode */
}
}

View File

@@ -0,0 +1,686 @@
/* Global CSS for ROA Reports */
/* CSS Custom Properties for consistent theming */
:root {
/* Primary Colors */
--roa-primary: #2563eb;
--roa-primary-hover: #1d4ed8;
--roa-primary-light: #dbeafe;
/* Success Colors */
--roa-success: #16a34a;
--roa-success-light: #dcfce7;
/* Warning Colors */
--roa-warning: #ca8a04;
--roa-warning-light: #fef3c7;
/* Danger Colors */
--roa-danger: #dc2626;
--roa-danger-light: #fee2e2;
/* Neutral Colors */
--roa-gray-50: #f9fafb;
--roa-gray-100: #f3f4f6;
--roa-gray-200: #e5e7eb;
--roa-gray-300: #d1d5db;
--roa-gray-400: #9ca3af;
--roa-gray-500: #6b7280;
--roa-gray-600: #4b5563;
--roa-gray-700: #374151;
--roa-gray-800: #1f2937;
--roa-gray-900: #111827;
/* Spacing */
--roa-spacing-xs: 0.25rem;
--roa-spacing-sm: 0.5rem;
--roa-spacing-md: 1rem;
--roa-spacing-lg: 1.5rem;
--roa-spacing-xl: 2rem;
--roa-spacing-2xl: 3rem;
/* Border Radius */
--roa-radius-sm: 0.375rem;
--roa-radius-md: 0.5rem;
--roa-radius-lg: 0.75rem;
--roa-radius-xl: 1rem;
/* Shadows */
--roa-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--roa-shadow-md:
0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--roa-shadow-lg:
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--roa-shadow-xl:
0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
/* Transitions */
--roa-transition-fast: 150ms ease-in-out;
--roa-transition-normal: 300ms ease-in-out;
--roa-transition-slow: 500ms ease-in-out;
}
/* Reset and Base Styles */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
"Noto Sans",
sans-serif;
}
body {
margin: 0;
background-color: var(--surface-ground, var(--roa-gray-50));
color: var(--text-color, var(--roa-gray-900));
font-feature-settings: "kern" 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Utility Classes */
/* Spacing Utilities */
.m-0 {
margin: 0;
}
.m-1 {
margin: var(--roa-spacing-xs);
}
.m-2 {
margin: var(--roa-spacing-sm);
}
.m-3 {
margin: var(--roa-spacing-md);
}
.m-4 {
margin: var(--roa-spacing-lg);
}
.m-5 {
margin: var(--roa-spacing-xl);
}
.p-0 {
padding: 0;
}
.p-1 {
padding: var(--roa-spacing-xs);
}
.p-2 {
padding: var(--roa-spacing-sm);
}
.p-3 {
padding: var(--roa-spacing-md);
}
.p-4 {
padding: var(--roa-spacing-lg);
}
.p-5 {
padding: var(--roa-spacing-xl);
}
.mt-0 {
margin-top: 0;
}
.mt-1 {
margin-top: var(--roa-spacing-xs);
}
.mt-2 {
margin-top: var(--roa-spacing-sm);
}
.mt-3 {
margin-top: var(--roa-spacing-md);
}
.mt-4 {
margin-top: var(--roa-spacing-lg);
}
.mt-5 {
margin-top: var(--roa-spacing-xl);
}
.mb-0 {
margin-bottom: 0;
}
.mb-1 {
margin-bottom: var(--roa-spacing-xs);
}
.mb-2 {
margin-bottom: var(--roa-spacing-sm);
}
.mb-3 {
margin-bottom: var(--roa-spacing-md);
}
.mb-4 {
margin-bottom: var(--roa-spacing-lg);
}
.mb-5 {
margin-bottom: var(--roa-spacing-xl);
}
/* Text Utilities */
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.text-base {
font-size: 1rem;
line-height: 1.5rem;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem;
}
.text-2xl {
font-size: 1.5rem;
line-height: 2rem;
}
.text-3xl {
font-size: 1.875rem;
line-height: 2.25rem;
}
.font-normal {
font-weight: 400;
}
.font-medium {
font-weight: 500;
}
.font-semibold {
font-weight: 600;
}
.font-bold {
font-weight: 700;
}
.text-left {
text-align: left;
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-primary {
color: var(--primary-color, var(--roa-primary));
}
.text-success {
color: var(--green-600, var(--roa-success));
}
.text-warning {
color: var(--yellow-600, var(--roa-warning));
}
.text-danger {
color: var(--red-600, var(--roa-danger));
}
.text-muted {
color: var(--text-color-secondary, var(--roa-gray-500));
}
/* Display Utilities */
.hidden {
display: none;
}
.block {
display: block;
}
.inline {
display: inline;
}
.inline-block {
display: inline-block;
}
.flex {
display: flex;
}
.inline-flex {
display: inline-flex;
}
.grid {
display: grid;
}
/* Flexbox Utilities */
.flex-row {
flex-direction: row;
}
.flex-col {
flex-direction: column;
}
.items-start {
align-items: flex-start;
}
.items-center {
align-items: center;
}
.items-end {
align-items: flex-end;
}
.justify-start {
justify-content: flex-start;
}
.justify-center {
justify-content: center;
}
.justify-end {
justify-content: flex-end;
}
.justify-between {
justify-content: space-between;
}
.justify-around {
justify-content: space-around;
}
.flex-1 {
flex: 1 1 0%;
}
.flex-auto {
flex: 1 1 auto;
}
.flex-none {
flex: none;
}
.gap-1 {
gap: var(--roa-spacing-xs);
}
.gap-2 {
gap: var(--roa-spacing-sm);
}
.gap-3 {
gap: var(--roa-spacing-md);
}
.gap-4 {
gap: var(--roa-spacing-lg);
}
.gap-5 {
gap: var(--roa-spacing-xl);
}
/* Width and Height Utilities */
.w-full {
width: 100%;
}
.w-auto {
width: auto;
}
.h-full {
height: 100%;
}
.h-auto {
height: auto;
}
/* Border Utilities */
.border {
border: 1px solid var(--surface-border, var(--roa-gray-200));
}
.border-0 {
border: 0;
}
.border-t {
border-top: 1px solid var(--surface-border, var(--roa-gray-200));
}
.border-b {
border-bottom: 1px solid var(--surface-border, var(--roa-gray-200));
}
.border-l {
border-left: 1px solid var(--surface-border, var(--roa-gray-200));
}
.border-r {
border-right: 1px solid var(--surface-border, var(--roa-gray-200));
}
.rounded {
border-radius: var(--roa-radius-md);
}
.rounded-sm {
border-radius: var(--roa-radius-sm);
}
.rounded-lg {
border-radius: var(--roa-radius-lg);
}
.rounded-xl {
border-radius: var(--roa-radius-xl);
}
.rounded-full {
border-radius: 9999px;
}
/* Shadow Utilities */
.shadow-sm {
box-shadow: var(--roa-shadow-sm);
}
.shadow-md {
box-shadow: var(--roa-shadow-md);
}
.shadow-lg {
box-shadow: var(--roa-shadow-lg);
}
.shadow-xl {
box-shadow: var(--roa-shadow-xl);
}
.shadow-none {
box-shadow: none;
}
/* Background Utilities */
.bg-white {
background-color: #ffffff;
}
.bg-gray-50 {
background-color: var(--roa-gray-50);
}
.bg-gray-100 {
background-color: var(--roa-gray-100);
}
.bg-primary {
background-color: var(--primary-color, var(--roa-primary));
}
.bg-success {
background-color: var(--green-100, var(--roa-success-light));
}
.bg-warning {
background-color: var(--yellow-100, var(--roa-warning-light));
}
.bg-danger {
background-color: var(--red-100, var(--roa-danger-light));
}
/* Hover Effects */
.hover-lift {
transition:
transform var(--roa-transition-fast),
box-shadow var(--roa-transition-fast);
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow: var(--roa-shadow-lg);
}
/* Focus States */
.focus-ring:focus {
outline: 2px solid var(--primary-color, var(--roa-primary));
outline-offset: 2px;
}
/* Animation Utilities */
.transition-all {
transition: all var(--roa-transition-normal);
}
.transition-colors {
transition:
color var(--roa-transition-normal),
background-color var(--roa-transition-normal),
border-color var(--roa-transition-normal);
}
.transition-opacity {
transition: opacity var(--roa-transition-normal);
}
.transition-transform {
transition: transform var(--roa-transition-normal);
}
/* Custom ROA Classes */
.roa-card {
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, var(--roa-gray-200));
border-radius: var(--roa-radius-lg);
box-shadow: var(--roa-shadow-sm);
padding: var(--roa-spacing-lg);
}
.roa-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--roa-spacing-sm);
padding: var(--roa-spacing-sm) var(--roa-spacing-lg);
border: none;
border-radius: var(--roa-radius-md);
font-weight: 500;
font-size: 0.875rem;
line-height: 1.25rem;
cursor: pointer;
transition: all var(--roa-transition-fast);
background-color: var(--primary-color, var(--roa-primary));
color: white;
}
.roa-button:hover {
background-color: var(--primary-color-dark, var(--roa-primary-hover));
transform: translateY(-1px);
box-shadow: var(--roa-shadow-md);
}
.roa-input {
width: 100%;
padding: var(--roa-spacing-sm) var(--roa-spacing-md);
border: 1px solid var(--surface-border, var(--roa-gray-200));
border-radius: var(--roa-radius-md);
font-size: 0.875rem;
line-height: 1.25rem;
transition:
border-color var(--roa-transition-fast),
box-shadow var(--roa-transition-fast);
}
.roa-input:focus {
outline: none;
border-color: var(--primary-color, var(--roa-primary));
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Invoice Status Classes */
.status-paid {
background-color: var(--green-100, var(--roa-success-light));
color: var(--green-800, var(--roa-success));
padding: var(--roa-spacing-xs) var(--roa-spacing-sm);
border-radius: var(--roa-radius-sm);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-overdue {
background-color: var(--red-100, var(--roa-danger-light));
color: var(--red-800, var(--roa-danger));
padding: var(--roa-spacing-xs) var(--roa-spacing-sm);
border-radius: var(--roa-radius-sm);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-pending {
background-color: var(--yellow-100, var(--roa-warning-light));
color: var(--yellow-800, var(--roa-warning));
padding: var(--roa-spacing-xs) var(--roa-spacing-sm);
border-radius: var(--roa-radius-sm);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Responsive Design */
@media (max-width: 640px) {
.sm\:hidden {
display: none;
}
.sm\:block {
display: block;
}
.sm\:flex {
display: flex;
}
.sm\:grid {
display: grid;
}
.sm\:flex-col {
flex-direction: column;
}
.sm\:items-center {
align-items: center;
}
.sm\:justify-center {
justify-content: center;
}
.sm\:text-center {
text-align: center;
}
.sm\:text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.sm\:p-2 {
padding: var(--roa-spacing-sm);
}
.sm\:m-2 {
margin: var(--roa-spacing-sm);
}
}
@media (max-width: 768px) {
.md\:hidden {
display: none;
}
.md\:block {
display: block;
}
.md\:flex {
display: flex;
}
.md\:grid {
display: grid;
}
.md\:flex-col {
flex-direction: column;
}
.md\:items-center {
align-items: center;
}
.md\:justify-center {
justify-content: center;
}
.md\:text-center {
text-align: center;
}
.md\:text-base {
font-size: 1rem;
line-height: 1.5rem;
}
.md\:p-3 {
padding: var(--roa-spacing-md);
}
.md\:m-3 {
margin: var(--roa-spacing-md);
}
}
@media (max-width: 1024px) {
.lg\:hidden {
display: none;
}
.lg\:block {
display: block;
}
.lg\:flex {
display: flex;
}
.lg\:grid {
display: grid;
}
.lg\:flex-row {
flex-direction: row;
}
.lg\:items-start {
align-items: flex-start;
}
.lg\:justify-start {
justify-content: flex-start;
}
.lg\:text-left {
text-align: left;
}
.lg\:text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.lg\:p-4 {
padding: var(--roa-spacing-lg);
}
.lg\:m-4 {
margin: var(--roa-spacing-lg);
}
}
/* Print Styles */
@media print {
.print\:hidden {
display: none !important;
}
.print\:block {
display: block !important;
}
* {
color-adjust: exact;
-webkit-print-color-adjust: exact;
}
}
/* Dark Mode Support (if implemented in the future) */
@media (prefers-color-scheme: dark) {
.dark\:bg-gray-800 {
background-color: var(--roa-gray-800);
}
.dark\:text-white {
color: #ffffff;
}
.dark\:border-gray-600 {
border-color: var(--roa-gray-600);
}
}

View File

@@ -0,0 +1,212 @@
/* Container System - ROA2WEB */
/* Main App Container */
.app-container {
max-width: var(--container-max-width);
margin: 0 auto;
padding: var(--space-lg);
min-height: calc(100vh - var(--header-height));
}
/* Page Container */
.page-container {
width: 100%;
max-width: var(--container-max-width);
margin: 0 auto;
padding: 0 var(--space-lg);
}
/* Section Container */
.section-container {
margin-bottom: var(--space-xl);
}
/* Content Container */
.content-container {
background: var(--color-bg);
border-radius: var(--card-radius);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
/* Header Container */
.header-container {
width: 100%;
height: var(--header-height);
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: var(--z-fixed);
display: flex;
align-items: center;
padding: 0 var(--space-lg);
}
/* Main Content with Header Offset */
.main-content {
margin-top: var(--header-height);
padding: var(--space-lg);
}
/* Dashboard Container */
.dashboard-container {
display: flex;
flex-direction: column;
gap: var(--space-xl);
padding: var(--space-lg);
}
/* Card Container */
.card-container {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-lg);
box-shadow: var(--shadow-sm);
transition: box-shadow var(--transition-fast);
}
.card-container:hover {
box-shadow: var(--shadow-md);
}
/* Compact Card Container */
.card-compact {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-md);
box-shadow: var(--shadow-sm);
}
/* Mini Card Container */
.card-mini {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-sm);
box-shadow: var(--shadow-sm);
}
/* Stats Container */
.stats-container {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-lg);
}
.stats-container-horizontal {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md);
}
/* Table Container */
.table-container {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
overflow: hidden;
}
/* Form Container */
.form-container {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
padding: var(--space-xl);
}
/* Toolbar Container */
.toolbar-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md) var(--space-lg);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
/* Action Bar Container */
.action-bar {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
background: var(--color-bg-secondary);
border-radius: var(--card-radius);
margin-bottom: var(--space-lg);
}
/* Mobile Container Adjustments */
@media (max-width: 768px) {
.app-container,
.main-content,
.page-container {
padding: var(--space-md);
}
.header-container {
padding: 0 var(--space-md);
}
.dashboard-container {
gap: var(--space-lg);
padding: var(--space-md);
}
.card-container {
padding: var(--space-md);
}
.toolbar-container {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
padding: var(--space-md);
}
.action-bar {
flex-direction: column;
align-items: stretch;
}
}
@media (max-width: 480px) {
.app-container,
.main-content,
.page-container,
.dashboard-container {
padding: var(--space-sm);
}
.header-container {
padding: 0 var(--space-sm);
}
.card-container {
padding: var(--space-sm);
}
.stats-container-horizontal {
flex-direction: column;
gap: var(--space-sm);
text-align: center;
}
}
/* Utility Container Classes */
.container-fluid { width: 100%; }
.container-full-height { min-height: 100vh; }
.container-centered {
display: flex;
align-items: center;
justify-content: center;
min-height: 50vh;
}

View File

@@ -0,0 +1,159 @@
/* Grid System - ROA2WEB */
/* Flexbox Grid System */
.flex { display: flex; }
.inline-flex { display: inline-flex; }
/* Flex Direction */
.flex-row { flex-direction: row; }
.flex-col { flex-direction: column; }
.flex-row-reverse { flex-direction: row-reverse; }
.flex-col-reverse { flex-direction: column-reverse; }
/* Flex Wrap */
.flex-wrap { flex-wrap: wrap; }
.flex-nowrap { flex-wrap: nowrap; }
/* Flex Grow/Shrink */
.flex-1 { flex: 1 1 0%; }
.flex-auto { flex: 1 1 auto; }
.flex-none { flex: none; }
/* Justify Content */
.justify-start { justify-content: flex-start; }
.justify-center { justify-content: center; }
.justify-end { justify-content: flex-end; }
.justify-between { justify-content: space-between; }
.justify-around { justify-content: space-around; }
.justify-evenly { justify-content: space-evenly; }
/* Align Items */
.items-start { align-items: flex-start; }
.items-center { align-items: center; }
.items-end { align-items: flex-end; }
.items-stretch { align-items: stretch; }
.items-baseline { align-items: baseline; }
/* CSS Grid */
.grid { display: grid; }
.inline-grid { display: inline-grid; }
/* Grid Template Columns */
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.grid-cols-12 { grid-template-columns: repeat(12, minmax(0, 1fr)); }
/* Grid Column Span */
.col-span-1 { grid-column: span 1 / span 1; }
.col-span-2 { grid-column: span 2 / span 2; }
.col-span-3 { grid-column: span 3 / span 3; }
.col-span-4 { grid-column: span 4 / span 4; }
.col-span-6 { grid-column: span 6 / span 6; }
.col-span-12 { grid-column: span 12 / span 12; }
.col-span-full { grid-column: 1 / -1; }
/* Grid Gap */
.gap-0 { gap: 0; }
.gap-1 { gap: var(--space-xs); }
.gap-2 { gap: var(--space-sm); }
.gap-4 { gap: var(--space-md); }
.gap-6 { gap: var(--space-lg); }
.gap-8 { gap: var(--space-xl); }
.gap-x-0 { column-gap: 0; }
.gap-x-1 { column-gap: var(--space-xs); }
.gap-x-2 { column-gap: var(--space-sm); }
.gap-x-4 { column-gap: var(--space-md); }
.gap-x-6 { column-gap: var(--space-lg); }
.gap-x-8 { column-gap: var(--space-xl); }
.gap-y-0 { row-gap: 0; }
.gap-y-1 { row-gap: var(--space-xs); }
.gap-y-2 { row-gap: var(--space-sm); }
.gap-y-4 { row-gap: var(--space-md); }
.gap-y-6 { row-gap: var(--space-lg); }
.gap-y-8 { row-gap: var(--space-xl); }
/* Dashboard Specific Grids */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-lg);
}
.dashboard-v2-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: var(--space-md);
}
.dashboard-v3-layout {
display: grid;
grid-template-columns: 1fr 300px;
gap: var(--space-xl);
}
.dashboard-v4-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-lg);
}
/* Responsive Grid Adjustments */
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.dashboard-v2-grid {
grid-template-columns: repeat(3, 1fr);
}
.dashboard-v3-layout {
grid-template-columns: 1fr;
}
.dashboard-v4-actions {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.dashboard-v2-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(6, 1fr);
}
.dashboard-v4-actions {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.dashboard-v2-grid {
grid-template-columns: 1fr;
grid-template-rows: repeat(12, auto);
}
}
/* Auto-fit and Auto-fill Grids */
.grid-auto-fit {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--space-lg);
}
.grid-auto-fill {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-md);
}

View File

@@ -0,0 +1,289 @@
/* Navigation System - ROA2WEB */
/* Header Navigation */
.header-nav {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
}
.header-brand {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--color-primary);
text-decoration: none;
}
.header-actions {
display: flex;
align-items: center;
gap: var(--space-md);
}
.header-user {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.header-user:hover {
background-color: var(--color-bg-secondary);
}
/* Hamburger Menu */
.hamburger-btn {
display: none;
flex-direction: column;
justify-content: space-between;
width: 24px;
height: 18px;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.hamburger-line {
width: 100%;
height: 2px;
background-color: var(--color-text);
border-radius: 1px;
transition: all var(--transition-fast);
}
.hamburger-btn.active .hamburger-line:nth-child(1) {
transform: rotate(45deg) translate(5px, 5px);
}
.hamburger-btn.active .hamburger-line:nth-child(2) {
opacity: 0;
}
.hamburger-btn.active .hamburger-line:nth-child(3) {
transform: rotate(-45deg) translate(5px, -5px);
}
/* Slide-out Menu */
.slide-menu {
position: fixed;
top: var(--header-height);
left: 0;
width: var(--sidebar-width);
height: calc(100vh - var(--header-height));
background: var(--color-bg);
border-right: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
transform: translateX(-100%);
transition: transform var(--transition-normal);
z-index: var(--z-modal);
overflow-y: auto;
}
.slide-menu.open {
transform: translateX(0);
}
.slide-menu-overlay {
position: fixed;
top: var(--header-height);
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: all var(--transition-normal);
z-index: var(--z-modal-backdrop);
}
.slide-menu-overlay.open {
opacity: 1;
visibility: visible;
}
/* Menu Content */
.menu-section {
padding: var(--space-lg);
border-bottom: 1px solid var(--color-border);
}
.menu-section:last-child {
border-bottom: none;
}
.menu-title {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-md);
}
.menu-list {
list-style: none;
}
.menu-item {
margin-bottom: var(--space-xs);
}
.menu-link {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
color: var(--color-text);
text-decoration: none;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.menu-link:hover,
.menu-link.active {
background-color: var(--color-bg-secondary);
color: var(--color-primary);
}
.menu-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
/* Dashboard Switcher */
.dashboard-switcher {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.dashboard-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-md);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.dashboard-option:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
.dashboard-option.active {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
.dashboard-label {
font-weight: var(--font-medium);
}
.dashboard-description {
font-size: var(--text-xs);
opacity: 0.8;
}
/* Breadcrumb Navigation */
.breadcrumb {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
font-size: var(--text-sm);
}
.breadcrumb-item {
color: var(--color-text-secondary);
}
.breadcrumb-item:last-child {
color: var(--color-text);
font-weight: var(--font-medium);
}
.breadcrumb-separator {
color: var(--color-text-muted);
}
/* Quick Actions Toolbar */
.quick-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.quick-action-btn {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
text-decoration: none;
font-size: var(--text-sm);
transition: all var(--transition-fast);
}
.quick-action-btn:hover {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
/* Mobile Navigation */
@media (max-width: 768px) {
.hamburger-btn {
display: flex;
}
.header-actions {
gap: var(--space-sm);
}
.quick-actions {
display: none;
}
.slide-menu {
width: 280px;
}
.menu-section {
padding: var(--space-md);
}
.quick-action-btn {
justify-content: center;
padding: var(--space-md);
}
}
@media (max-width: 480px) {
.header-brand {
font-size: var(--text-base);
}
.slide-menu {
width: 100vw;
max-width: 320px;
}
}

View File

@@ -0,0 +1,144 @@
/* Main CSS Entry Point - ROA2WEB */
/* Import order is critical for proper CSS cascade */
/* 1. Core Foundation */
@import './core/variables.css';
@import './core/reset.css';
@import './core/typography.css';
/* 2. Layout System */
@import './layout/grid.css';
@import './layout/containers.css';
@import './layout/navigation.css';
/* 3. Component Library */
@import './components/cards.css';
@import './components/buttons.css';
@import './components/tables.css';
@import './components/forms.css';
@import './components/stats.css';
/* 4. Utilities */
@import './utilities/spacing.css';
@import './utilities/display.css';
@import './utilities/text.css';
@import './utilities/flex.css';
/* 5. Existing Mobile Optimizations */
@import './mobile.css';
/* Global Application Styles */
html {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: var(--leading-normal);
color: var(--color-text);
background-color: var(--color-bg);
}
body {
margin: 0;
padding: 0;
min-height: 100vh;
overflow-x: hidden;
}
/* Vue App Wrapper */
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Remove default router-link styles */
.router-link-active,
.router-link-exact-active {
text-decoration: none;
}
/* Smooth scrolling behavior */
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
/* Focus management */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Loading states */
.loading {
opacity: 0.6;
pointer-events: none;
}
.loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
margin: -10px 0 0 -10px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Error states */
.error {
color: var(--color-error);
border-color: var(--color-error);
}
.success {
color: var(--color-success);
border-color: var(--color-success);
}
.warning {
color: var(--color-warning);
border-color: var(--color-warning);
}
/* Print styles */
@media print {
.no-print {
display: none !important;
}
.print-only {
display: block !important;
}
* {
background: white !important;
color: black !important;
box-shadow: none !important;
}
.card,
.stats-card,
.kpi-card {
border: 1px solid #ccc !important;
break-inside: avoid;
margin-bottom: 1rem;
}
}

View File

@@ -0,0 +1,695 @@
/* Mobile-specific styles for ROA Reports */
/* Mobile Navigation Enhancements */
@media (max-width: 768px) {
/* Menubar mobile optimizations */
.p-menubar {
padding: 0.5rem 1rem;
}
.p-menubar .p-menubar-root-list {
flex-direction: column;
width: 100%;
position: absolute;
top: 100%;
left: 0;
background: var(--surface-overlay);
border: 1px solid var(--surface-border);
border-radius: var(--border-radius);
box-shadow: var(--overlay-shadow);
z-index: 1000;
}
.p-menubar .p-menubar-root-list .p-menuitem {
width: 100%;
}
.p-menubar .p-menubar-root-list .p-menuitem-link {
padding: 1rem;
border-bottom: 1px solid var(--surface-border);
width: 100%;
justify-content: flex-start;
}
.p-menubar .p-menubar-button {
display: flex !important;
}
/* Hide menu items by default on mobile */
.p-menubar .p-menubar-root-list {
display: none;
}
.p-menubar.p-menubar-mobile-active .p-menubar-root-list {
display: flex;
}
}
/* Mobile DataTable Enhancements */
@media (max-width: 768px) {
.p-datatable .p-datatable-wrapper {
overflow-x: auto;
}
.p-datatable .p-datatable-thead > tr > th {
min-width: 120px;
padding: 0.5rem;
font-size: 0.875rem;
}
.p-datatable .p-datatable-tbody > tr > td {
padding: 0.5rem;
font-size: 0.875rem;
border-bottom: 1px solid var(--surface-border);
}
/* Stack table content vertically on very small screens */
.p-datatable.mobile-stack .p-datatable-thead {
display: none;
}
.p-datatable.mobile-stack .p-datatable-tbody,
.p-datatable.mobile-stack .p-datatable-tbody tr,
.p-datatable.mobile-stack .p-datatable-tbody td {
display: block;
width: 100%;
}
.p-datatable.mobile-stack .p-datatable-tbody tr {
border: 1px solid var(--surface-border);
border-radius: var(--border-radius);
padding: 1rem;
margin-bottom: 1rem;
background: var(--surface-card);
}
.p-datatable.mobile-stack .p-datatable-tbody td {
border: none;
position: relative;
padding: 0.5rem 0;
}
.p-datatable.mobile-stack .p-datatable-tbody td:before {
content: attr(data-label) ": ";
font-weight: 600;
color: var(--text-color-secondary);
display: inline-block;
width: 40%;
margin-right: 1rem;
}
}
/* Mobile Card Optimizations */
@media (max-width: 768px) {
.p-card .p-card-body {
padding: 1rem;
}
.p-card .p-card-header {
padding: 1rem 1rem 0.5rem 1rem;
}
.p-card .p-card-footer {
padding: 0.5rem 1rem 1rem 1rem;
}
.p-card .p-card-title {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.p-card .p-card-subtitle {
font-size: 0.875rem;
margin-bottom: 1rem;
}
}
/* Mobile Button Enhancements */
@media (max-width: 768px) {
.p-button {
min-height: 44px; /* Minimum touch target size */
padding: 0.75rem 1rem;
font-size: 0.875rem;
}
.p-button.p-button-sm {
min-height: 36px;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
.p-button.p-button-lg {
min-height: 52px;
padding: 1rem 1.5rem;
font-size: 1rem;
}
/* Full width buttons on mobile */
.mobile-full-width .p-button {
width: 100%;
margin-bottom: 0.5rem;
}
}
/* Mobile Form Enhancements */
@media (max-width: 768px) {
.p-inputtext,
.p-password input,
.p-dropdown,
.p-calendar input {
min-height: 44px;
font-size: 16px; /* Prevents zoom on iOS */
padding: 0.75rem;
}
.p-float-label > label {
top: 50%;
transform: translateY(-50%);
font-size: 1rem;
}
.p-float-label > .p-invalid + label {
color: var(--red-500);
}
/* Stack form fields vertically */
.mobile-form-stack .p-field {
margin-bottom: 1.5rem;
}
.mobile-form-stack .p-field:last-child {
margin-bottom: 0;
}
}
/* Mobile Dialog Enhancements */
@media (max-width: 768px) {
.p-dialog {
width: 95vw !important;
max-width: none !important;
margin: 0 !important;
max-height: 90vh;
}
.p-dialog .p-dialog-header {
padding: 1rem;
border-bottom: 1px solid var(--surface-border);
}
.p-dialog .p-dialog-content {
padding: 1rem;
max-height: calc(90vh - 120px);
overflow-y: auto;
}
.p-dialog .p-dialog-footer {
padding: 1rem;
border-top: 1px solid var(--surface-border);
justify-content: stretch;
}
.p-dialog .p-dialog-footer .p-button {
flex: 1;
margin: 0 0.25rem;
}
}
/* Mobile Toast Enhancements */
@media (max-width: 768px) {
.p-toast {
width: calc(100vw - 2rem) !important;
left: 1rem !important;
right: 1rem !important;
}
.p-toast .p-toast-message {
margin-bottom: 0.5rem;
}
}
/* Toast positioning to avoid header conflicts */
.p-toast {
z-index: 1100 !important;
}
/* Ensure toast notifications don't interfere with header dropdowns */
.p-toast[data-position="top-right"] {
top: 80px !important; /* Move below header */
}
/* Mobile Dropdown Enhancements */
@media (max-width: 768px) {
.p-dropdown-panel {
max-height: 60vh;
width: 100% !important;
}
.p-dropdown-item {
padding: 1rem;
font-size: 1rem;
}
}
/* Mobile Calendar Enhancements */
@media (max-width: 768px) {
.p-datepicker {
width: 100% !important;
max-width: none !important;
}
.p-datepicker table td {
padding: 0.5rem;
}
.p-datepicker table td > span {
width: 2.5rem;
height: 2.5rem;
line-height: 2.5rem;
}
}
/* Mobile-specific utility classes */
@media (max-width: 640px) {
.mobile-hide {
display: none !important;
}
.mobile-show {
display: block !important;
}
.mobile-flex {
display: flex !important;
}
.mobile-grid {
display: grid !important;
}
.mobile-full-width {
width: 100% !important;
}
.mobile-text-center {
text-align: center !important;
}
.mobile-text-left {
text-align: left !important;
}
.mobile-p-2 {
padding: 0.5rem !important;
}
.mobile-p-4 {
padding: 1rem !important;
}
.mobile-m-2 {
margin: 0.5rem !important;
}
.mobile-m-4 {
margin: 1rem !important;
}
.mobile-stack {
flex-direction: column !important;
}
.mobile-stack > * {
width: 100% !important;
margin-bottom: 0.5rem;
}
.mobile-stack > *:last-child {
margin-bottom: 0;
}
}
/* Tablet-specific styles */
@media (min-width: 641px) and (max-width: 1024px) {
.tablet-hide {
display: none !important;
}
.tablet-show {
display: block !important;
}
.tablet-flex {
display: flex !important;
}
.tablet-grid {
display: grid !important;
}
.tablet-full-width {
width: 100% !important;
}
.tablet-half-width {
width: 50% !important;
}
.tablet-text-center {
text-align: center !important;
}
.tablet-text-left {
text-align: left !important;
}
.tablet-p-3 {
padding: 0.75rem !important;
}
.tablet-m-3 {
margin: 0.75rem !important;
}
}
/* Touch-friendly enhancements */
@media (hover: none) and (pointer: coarse) {
/* Increase touch targets */
.p-button,
.p-inputtext,
.p-dropdown,
.p-calendar input {
min-height: 44px;
}
/* Remove hover effects on touch devices */
.p-button:hover {
transform: none;
box-shadow: none;
}
/* Add active states for better touch feedback */
.p-button:active {
transform: scale(0.98);
transition: transform 0.1s ease;
}
.p-datatable .p-datatable-tbody > tr:active {
background-color: var(--surface-hover);
}
}
/* Accessibility improvements for mobile */
@media (max-width: 768px) {
/* Ensure focus is visible */
.p-button:focus,
.p-inputtext:focus,
.p-dropdown:focus,
.p-calendar input:focus {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.p-button,
.p-inputtext,
.p-dropdown,
.p-calendar input {
border-width: 2px;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
}
/* Custom mobile components */
.mobile-nav-toggle {
display: none;
background: none;
border: none;
padding: 0.5rem;
cursor: pointer;
color: var(--text-color);
font-size: 1.5rem;
}
@media (max-width: 768px) {
.mobile-nav-toggle {
display: block;
}
}
.mobile-card-stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 769px) {
.mobile-card-stack {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
}
/* Mobile-optimized stats cards */
.mobile-stat-card {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: var(--border-radius);
padding: 1rem;
display: flex;
align-items: center;
gap: 1rem;
}
.mobile-stat-card .stat-icon {
font-size: 2rem;
color: var(--primary-color);
flex-shrink: 0;
}
.mobile-stat-card .stat-content {
flex: 1;
}
.mobile-stat-card .stat-value {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
color: var(--text-color);
}
.mobile-stat-card .stat-label {
font-size: 0.875rem;
color: var(--text-color-secondary);
margin: 0.25rem 0 0 0;
}
/* Mobile table alternative */
.mobile-list-view .list-item {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: var(--border-radius);
padding: 1rem;
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.mobile-list-view .list-item-content {
flex: 1;
}
.mobile-list-view .list-item-title {
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.25rem;
}
.mobile-list-view .list-item-subtitle {
font-size: 0.875rem;
color: var(--text-color-secondary);
margin-bottom: 0.5rem;
}
.mobile-list-view .list-item-actions {
flex-shrink: 0;
margin-left: 1rem;
}
/* Swipe gestures support (future enhancement) */
.swipe-item {
position: relative;
overflow: hidden;
}
.swipe-actions {
position: absolute;
top: 0;
right: -100px;
height: 100%;
width: 100px;
background: var(--red-500);
display: flex;
align-items: center;
justify-content: center;
color: white;
transition: right 0.3s ease;
}
.swipe-item.swiped .swipe-actions {
right: 0;
}
/* Enhanced Responsive Tables - Prevent text shrinking and add horizontal scroll */
@media (max-width: 768px) {
/* Container cu scroll orizontal pentru tabele */
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 0 -1rem; /* Extend to edges on mobile */
padding: 0 1rem;
}
/* Dimensiune minimă pentru tabele - Enhanced */
.summary-table,
.breakdown-table,
.dashboard-table,
.detailed-table,
.p-datatable table {
min-width: 600px !important; /* Prevent compression */
font-size: 14px !important; /* Minimum readable size */
}
/* Celule tabel - Enhanced */
.summary-table td,
.summary-table th,
.breakdown-table td,
.breakdown-table th,
.dashboard-table td,
.dashboard-table th,
.detailed-table td,
.detailed-table th {
padding: 0.5rem;
font-size: 14px !important;
white-space: nowrap; /* Prevent text wrapping */
min-width: 80px; /* Minimum column width */
}
/* Amount cells should never shrink */
.amount-cell {
font-size: 14px !important;
font-family: monospace;
white-space: nowrap;
}
/* Override PrimeVue table font sizes for mobile */
.p-datatable .p-datatable-thead > tr > th,
.p-datatable .p-datatable-tbody > tr > td {
font-size: 14px !important;
padding: 0.5rem !important;
}
/* Stack controls vertically on mobile */
.section-controls {
flex-direction: column;
width: 100%;
gap: 0.5rem;
}
.section-controls > * {
width: 100%;
}
/* Button groups on mobile */
.button-group {
display: flex;
gap: 0.5rem;
width: 100%;
}
.button-group .btn {
flex: 1;
}
/* Indicator de scroll */
.table-container::after {
content: '← Scroll orizontal pentru mai multe coloane →';
display: block;
text-align: center;
color: var(--color-text-secondary, #6b7280);
font-size: 12px;
margin-top: 0.5rem;
font-style: italic;
}
.table-container.scrolled-full::after {
display: none;
}
/* Ensure table wrappers don't compress */
.table-wrapper,
.data-table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
/* Tablet-specific improvements */
@media (min-width: 641px) and (max-width: 1024px) {
.summary-table,
.breakdown-table,
.detailed-table {
font-size: 14px !important; /* Slightly larger on tablet */
}
.summary-table td,
.summary-table th,
.breakdown-table td,
.breakdown-table th {
font-size: 14px !important;
padding: 0.6rem;
}
}
/* Extra small devices */
@media (max-width: 480px) {
/* Hide less important columns on very small screens */
.breakdown-table th:nth-child(6),
.breakdown-table td:nth-child(6),
.breakdown-table th:nth-child(7),
.breakdown-table td:nth-child(7) {
display: none;
}
/* Maintain readable font sizes but slightly smaller */
.summary-table,
.breakdown-table,
.detailed-table {
font-size: 13px !important;
min-width: 500px !important; /* Slightly smaller minimum on very small screens */
}
.summary-table td,
.summary-table th,
.breakdown-table td,
.breakdown-table th {
font-size: 13px !important;
padding: 0.4rem;
min-width: 70px;
}
/* Stack controls vertically on mobile */
.section-controls {
flex-direction: column !important;
gap: 0.5rem;
}
.section-controls > * {
width: 100% !important;
}
/* Adjust search inputs for mobile */
.search-input,
.data-type-select {
width: 100% !important;
font-size: 16px !important; /* Prevent zoom on iOS */
min-height: 44px; /* Touch-friendly height */
}
}

View File

@@ -0,0 +1,259 @@
/* Display Utilities - ROA2WEB */
/* Display Types */
.block { display: block; }
.inline-block { display: inline-block; }
.inline { display: inline; }
.flex { display: flex; }
.inline-flex { display: inline-flex; }
.grid { display: grid; }
.inline-grid { display: inline-grid; }
.table { display: table; }
.table-cell { display: table-cell; }
.table-row { display: table-row; }
.hidden { display: none; }
/* Visibility */
.visible { visibility: visible; }
.invisible { visibility: hidden; }
/* Position */
.static { position: static; }
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.sticky { position: sticky; }
/* Position Values */
.top-0 { top: 0; }
.top-1 { top: var(--space-xs); }
.top-2 { top: var(--space-sm); }
.top-4 { top: var(--space-md); }
.top-auto { top: auto; }
.right-0 { right: 0; }
.right-1 { right: var(--space-xs); }
.right-2 { right: var(--space-sm); }
.right-4 { right: var(--space-md); }
.right-auto { right: auto; }
.bottom-0 { bottom: 0; }
.bottom-1 { bottom: var(--space-xs); }
.bottom-2 { bottom: var(--space-sm); }
.bottom-4 { bottom: var(--space-md); }
.bottom-auto { bottom: auto; }
.left-0 { left: 0; }
.left-1 { left: var(--space-xs); }
.left-2 { left: var(--space-sm); }
.left-4 { left: var(--space-md); }
.left-auto { left: auto; }
.inset-0 {
top: 0;
right: 0;
bottom: 0;
left: 0;
}
/* Z-Index */
.z-0 { z-index: 0; }
.z-10 { z-index: 10; }
.z-20 { z-index: 20; }
.z-30 { z-index: 30; }
.z-40 { z-index: 40; }
.z-50 { z-index: 50; }
.z-auto { z-index: auto; }
.z-dropdown { z-index: var(--z-dropdown); }
.z-sticky { z-index: var(--z-sticky); }
.z-fixed { z-index: var(--z-fixed); }
.z-modal { z-index: var(--z-modal); }
/* Float */
.float-left { float: left; }
.float-right { float: right; }
.float-none { float: none; }
.clearfix::after {
content: "";
display: table;
clear: both;
}
/* Overflow */
.overflow-auto { overflow: auto; }
.overflow-hidden { overflow: hidden; }
.overflow-visible { overflow: visible; }
.overflow-scroll { overflow: scroll; }
.overflow-x-auto { overflow-x: auto; }
.overflow-x-hidden { overflow-x: hidden; }
.overflow-x-visible { overflow-x: visible; }
.overflow-x-scroll { overflow-x: scroll; }
.overflow-y-auto { overflow-y: auto; }
.overflow-y-hidden { overflow-y: hidden; }
.overflow-y-visible { overflow-y: visible; }
.overflow-y-scroll { overflow-y: scroll; }
/* Object Fit */
.object-contain { object-fit: contain; }
.object-cover { object-fit: cover; }
.object-fill { object-fit: fill; }
.object-none { object-fit: none; }
.object-scale-down { object-fit: scale-down; }
/* Object Position */
.object-bottom { object-position: bottom; }
.object-center { object-position: center; }
.object-left { object-position: left; }
.object-right { object-position: right; }
.object-top { object-position: top; }
/* Width */
.w-auto { width: auto; }
.w-full { width: 100%; }
.w-screen { width: 100vw; }
.w-min { width: min-content; }
.w-max { width: max-content; }
.w-fit { width: fit-content; }
.w-0 { width: 0; }
.w-1 { width: var(--space-xs); }
.w-2 { width: var(--space-sm); }
.w-4 { width: var(--space-md); }
.w-6 { width: var(--space-lg); }
.w-8 { width: var(--space-xl); }
.w-1\/2 { width: 50%; }
.w-1\/3 { width: 33.333333%; }
.w-2\/3 { width: 66.666667%; }
.w-1\/4 { width: 25%; }
.w-3\/4 { width: 75%; }
.w-1\/5 { width: 20%; }
.w-2\/5 { width: 40%; }
.w-3\/5 { width: 60%; }
.w-4\/5 { width: 80%; }
/* Max Width */
.max-w-none { max-width: none; }
.max-w-full { max-width: 100%; }
.max-w-screen { max-width: 100vw; }
.max-w-xs { max-width: 20rem; }
.max-w-sm { max-width: 24rem; }
.max-w-md { max-width: 28rem; }
.max-w-lg { max-width: 32rem; }
.max-w-xl { max-width: 36rem; }
.max-w-2xl { max-width: 42rem; }
.max-w-3xl { max-width: 48rem; }
.max-w-4xl { max-width: 56rem; }
.max-w-5xl { max-width: 64rem; }
.max-w-6xl { max-width: 72rem; }
.max-w-7xl { max-width: 80rem; }
/* Min Width */
.min-w-0 { min-width: 0; }
.min-w-full { min-width: 100%; }
.min-w-min { min-width: min-content; }
.min-w-max { min-width: max-content; }
.min-w-fit { min-width: fit-content; }
/* Height */
.h-auto { height: auto; }
.h-full { height: 100%; }
.h-screen { height: 100vh; }
.h-min { height: min-content; }
.h-max { height: max-content; }
.h-fit { height: fit-content; }
.h-0 { height: 0; }
.h-1 { height: var(--space-xs); }
.h-2 { height: var(--space-sm); }
.h-4 { height: var(--space-md); }
.h-6 { height: var(--space-lg); }
.h-8 { height: var(--space-xl); }
.h-10 { height: 2.5rem; }
.h-12 { height: var(--space-3xl); }
.h-16 { height: 4rem; }
.h-20 { height: 5rem; }
.h-24 { height: 6rem; }
.h-32 { height: 8rem; }
.h-40 { height: 10rem; }
.h-48 { height: 12rem; }
.h-56 { height: 14rem; }
.h-64 { height: 16rem; }
/* Max Height */
.max-h-full { max-height: 100%; }
.max-h-screen { max-height: 100vh; }
.max-h-none { max-height: none; }
/* Min Height */
.min-h-0 { min-height: 0; }
.min-h-full { min-height: 100%; }
.min-h-screen { min-height: 100vh; }
/* Aspect Ratio */
.aspect-auto { aspect-ratio: auto; }
.aspect-square { aspect-ratio: 1 / 1; }
.aspect-video { aspect-ratio: 16 / 9; }
/* Box Sizing */
.box-border { box-sizing: border-box; }
.box-content { box-sizing: content-box; }
/* Cursor */
.cursor-auto { cursor: auto; }
.cursor-default { cursor: default; }
.cursor-pointer { cursor: pointer; }
.cursor-wait { cursor: wait; }
.cursor-text { cursor: text; }
.cursor-move { cursor: move; }
.cursor-help { cursor: help; }
.cursor-not-allowed { cursor: not-allowed; }
/* User Select */
.select-none { user-select: none; }
.select-text { user-select: text; }
.select-all { user-select: all; }
.select-auto { user-select: auto; }
/* Pointer Events */
.pointer-events-none { pointer-events: none; }
.pointer-events-auto { pointer-events: auto; }
/* Resize */
.resize-none { resize: none; }
.resize { resize: both; }
.resize-y { resize: vertical; }
.resize-x { resize: horizontal; }
/* Responsive Utilities */
@media (max-width: 480px) {
.mobile-hidden { display: none !important; }
.mobile-block { display: block !important; }
.mobile-flex { display: flex !important; }
.mobile-grid { display: grid !important; }
}
@media (min-width: 481px) {
.mobile-only { display: none !important; }
}
@media (max-width: 768px) {
.tablet-hidden { display: none !important; }
.tablet-block { display: block !important; }
.tablet-flex { display: flex !important; }
.tablet-grid { display: grid !important; }
}
@media (min-width: 769px) {
.tablet-only { display: none !important; }
}
@media (min-width: 1024px) {
.desktop-only { display: block !important; }
}
@media (max-width: 1023px) {
.desktop-hidden { display: none !important; }
}

View File

@@ -0,0 +1,135 @@
/* Flex Utilities - ROA2WEB */
/* Flex Display */
.flex { display: flex; }
.inline-flex { display: inline-flex; }
/* Flex Direction */
.flex-row { flex-direction: row; }
.flex-row-reverse { flex-direction: row-reverse; }
.flex-col { flex-direction: column; }
.flex-col-reverse { flex-direction: column-reverse; }
/* Flex Wrap */
.flex-wrap { flex-wrap: wrap; }
.flex-nowrap { flex-wrap: nowrap; }
.flex-wrap-reverse { flex-wrap: wrap-reverse; }
/* Flex */
.flex-1 { flex: 1 1 0%; }
.flex-auto { flex: 1 1 auto; }
.flex-initial { flex: 0 1 auto; }
.flex-none { flex: none; }
/* Flex Grow */
.flex-grow-0 { flex-grow: 0; }
.flex-grow { flex-grow: 1; }
/* Flex Shrink */
.flex-shrink-0 { flex-shrink: 0; }
.flex-shrink { flex-shrink: 1; }
/* Justify Content */
.justify-start { justify-content: flex-start; }
.justify-end { justify-content: flex-end; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-around { justify-content: space-around; }
.justify-evenly { justify-content: space-evenly; }
/* Align Items */
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.items-center { align-items: center; }
.items-baseline { align-items: baseline; }
.items-stretch { align-items: stretch; }
/* Align Content */
.content-start { align-content: flex-start; }
.content-end { align-content: flex-end; }
.content-center { align-content: center; }
.content-between { align-content: space-between; }
.content-around { align-content: space-around; }
.content-evenly { align-content: space-evenly; }
/* Align Self */
.self-auto { align-self: auto; }
.self-start { align-self: flex-start; }
.self-end { align-self: flex-end; }
.self-center { align-self: center; }
.self-stretch { align-self: stretch; }
.self-baseline { align-self: baseline; }
/* Gap */
.gap-0 { gap: 0; }
.gap-1 { gap: var(--space-xs); }
.gap-2 { gap: var(--space-sm); }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: var(--space-md); }
.gap-5 { gap: 1.25rem; }
.gap-6 { gap: var(--space-lg); }
.gap-8 { gap: var(--space-xl); }
.gap-x-0 { column-gap: 0; }
.gap-x-1 { column-gap: var(--space-xs); }
.gap-x-2 { column-gap: var(--space-sm); }
.gap-x-3 { column-gap: 0.75rem; }
.gap-x-4 { column-gap: var(--space-md); }
.gap-x-6 { column-gap: var(--space-lg); }
.gap-x-8 { column-gap: var(--space-xl); }
.gap-y-0 { row-gap: 0; }
.gap-y-1 { row-gap: var(--space-xs); }
.gap-y-2 { row-gap: var(--space-sm); }
.gap-y-3 { row-gap: 0.75rem; }
.gap-y-4 { row-gap: var(--space-md); }
.gap-y-6 { row-gap: var(--space-lg); }
.gap-y-8 { row-gap: var(--space-xl); }
/* Order */
.order-1 { order: 1; }
.order-2 { order: 2; }
.order-3 { order: 3; }
.order-4 { order: 4; }
.order-5 { order: 5; }
.order-6 { order: 6; }
.order-7 { order: 7; }
.order-8 { order: 8; }
.order-9 { order: 9; }
.order-10 { order: 10; }
.order-11 { order: 11; }
.order-12 { order: 12; }
.order-first { order: -9999; }
.order-last { order: 9999; }
.order-none { order: 0; }
/* Responsive Flex Utilities */
@media (max-width: 480px) {
.mobile-flex { display: flex; }
.mobile-flex-col { flex-direction: column; }
.mobile-flex-wrap { flex-wrap: wrap; }
.mobile-items-center { align-items: center; }
.mobile-items-start { align-items: flex-start; }
.mobile-items-stretch { align-items: stretch; }
.mobile-justify-center { justify-content: center; }
.mobile-justify-between { justify-content: space-between; }
}
@media (max-width: 768px) {
.tablet-flex { display: flex; }
.tablet-flex-col { flex-direction: column; }
.tablet-flex-wrap { flex-wrap: wrap; }
.tablet-items-center { align-items: center; }
.tablet-items-start { align-items: flex-start; }
.tablet-items-stretch { align-items: stretch; }
.tablet-justify-center { justify-content: center; }
.tablet-justify-between { justify-content: space-between; }
}
@media (min-width: 1024px) {
.desktop-flex { display: flex; }
.desktop-flex-row { flex-direction: row; }
.desktop-flex-nowrap { flex-wrap: nowrap; }
.desktop-items-center { align-items: center; }
.desktop-justify-start { justify-content: flex-start; }
}

View File

@@ -0,0 +1,206 @@
/* Spacing Utilities - ROA2WEB */
/* Margin Utilities */
.m-0 { margin: 0; }
.m-1 { margin: var(--space-xs); }
.m-2 { margin: var(--space-sm); }
.m-3 { margin: 0.75rem; }
.m-4 { margin: var(--space-md); }
.m-5 { margin: 1.25rem; }
.m-6 { margin: var(--space-lg); }
.m-8 { margin: var(--space-xl); }
.m-10 { margin: 2.5rem; }
.m-12 { margin: var(--space-3xl); }
.m-auto { margin: auto; }
/* Margin Top */
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: var(--space-xs); }
.mt-2 { margin-top: var(--space-sm); }
.mt-3 { margin-top: 0.75rem; }
.mt-4 { margin-top: var(--space-md); }
.mt-5 { margin-top: 1.25rem; }
.mt-6 { margin-top: var(--space-lg); }
.mt-8 { margin-top: var(--space-xl); }
.mt-10 { margin-top: 2.5rem; }
.mt-12 { margin-top: var(--space-3xl); }
.mt-auto { margin-top: auto; }
/* Margin Right */
.mr-0 { margin-right: 0; }
.mr-1 { margin-right: var(--space-xs); }
.mr-2 { margin-right: var(--space-sm); }
.mr-3 { margin-right: 0.75rem; }
.mr-4 { margin-right: var(--space-md); }
.mr-5 { margin-right: 1.25rem; }
.mr-6 { margin-right: var(--space-lg); }
.mr-8 { margin-right: var(--space-xl); }
.mr-10 { margin-right: 2.5rem; }
.mr-12 { margin-right: var(--space-3xl); }
.mr-auto { margin-right: auto; }
/* Margin Bottom */
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: var(--space-xs); }
.mb-2 { margin-bottom: var(--space-sm); }
.mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: var(--space-md); }
.mb-5 { margin-bottom: 1.25rem; }
.mb-6 { margin-bottom: var(--space-lg); }
.mb-8 { margin-bottom: var(--space-xl); }
.mb-10 { margin-bottom: 2.5rem; }
.mb-12 { margin-bottom: var(--space-3xl); }
.mb-auto { margin-bottom: auto; }
/* Margin Left */
.ml-0 { margin-left: 0; }
.ml-1 { margin-left: var(--space-xs); }
.ml-2 { margin-left: var(--space-sm); }
.ml-3 { margin-left: 0.75rem; }
.ml-4 { margin-left: var(--space-md); }
.ml-5 { margin-left: 1.25rem; }
.ml-6 { margin-left: var(--space-lg); }
.ml-8 { margin-left: var(--space-xl); }
.ml-10 { margin-left: 2.5rem; }
.ml-12 { margin-left: var(--space-3xl); }
.ml-auto { margin-left: auto; }
/* Margin X (horizontal) */
.mx-0 { margin-left: 0; margin-right: 0; }
.mx-1 { margin-left: var(--space-xs); margin-right: var(--space-xs); }
.mx-2 { margin-left: var(--space-sm); margin-right: var(--space-sm); }
.mx-3 { margin-left: 0.75rem; margin-right: 0.75rem; }
.mx-4 { margin-left: var(--space-md); margin-right: var(--space-md); }
.mx-5 { margin-left: 1.25rem; margin-right: 1.25rem; }
.mx-6 { margin-left: var(--space-lg); margin-right: var(--space-lg); }
.mx-8 { margin-left: var(--space-xl); margin-right: var(--space-xl); }
.mx-auto { margin-left: auto; margin-right: auto; }
/* Margin Y (vertical) */
.my-0 { margin-top: 0; margin-bottom: 0; }
.my-1 { margin-top: var(--space-xs); margin-bottom: var(--space-xs); }
.my-2 { margin-top: var(--space-sm); margin-bottom: var(--space-sm); }
.my-3 { margin-top: 0.75rem; margin-bottom: 0.75rem; }
.my-4 { margin-top: var(--space-md); margin-bottom: var(--space-md); }
.my-5 { margin-top: 1.25rem; margin-bottom: 1.25rem; }
.my-6 { margin-top: var(--space-lg); margin-bottom: var(--space-lg); }
.my-8 { margin-top: var(--space-xl); margin-bottom: var(--space-xl); }
.my-auto { margin-top: auto; margin-bottom: auto; }
/* Padding Utilities */
.p-0 { padding: 0; }
.p-1 { padding: var(--space-xs); }
.p-2 { padding: var(--space-sm); }
.p-3 { padding: 0.75rem; }
.p-4 { padding: var(--space-md); }
.p-5 { padding: 1.25rem; }
.p-6 { padding: var(--space-lg); }
.p-8 { padding: var(--space-xl); }
.p-10 { padding: 2.5rem; }
.p-12 { padding: var(--space-3xl); }
/* Padding Top */
.pt-0 { padding-top: 0; }
.pt-1 { padding-top: var(--space-xs); }
.pt-2 { padding-top: var(--space-sm); }
.pt-3 { padding-top: 0.75rem; }
.pt-4 { padding-top: var(--space-md); }
.pt-5 { padding-top: 1.25rem; }
.pt-6 { padding-top: var(--space-lg); }
.pt-8 { padding-top: var(--space-xl); }
.pt-10 { padding-top: 2.5rem; }
.pt-12 { padding-top: var(--space-3xl); }
/* Padding Right */
.pr-0 { padding-right: 0; }
.pr-1 { padding-right: var(--space-xs); }
.pr-2 { padding-right: var(--space-sm); }
.pr-3 { padding-right: 0.75rem; }
.pr-4 { padding-right: var(--space-md); }
.pr-5 { padding-right: 1.25rem; }
.pr-6 { padding-right: var(--space-lg); }
.pr-8 { padding-right: var(--space-xl); }
.pr-10 { padding-right: 2.5rem; }
.pr-12 { padding-right: var(--space-3xl); }
/* Padding Bottom */
.pb-0 { padding-bottom: 0; }
.pb-1 { padding-bottom: var(--space-xs); }
.pb-2 { padding-bottom: var(--space-sm); }
.pb-3 { padding-bottom: 0.75rem; }
.pb-4 { padding-bottom: var(--space-md); }
.pb-5 { padding-bottom: 1.25rem; }
.pb-6 { padding-bottom: var(--space-lg); }
.pb-8 { padding-bottom: var(--space-xl); }
.pb-10 { padding-bottom: 2.5rem; }
.pb-12 { padding-bottom: var(--space-3xl); }
/* Padding Left */
.pl-0 { padding-left: 0; }
.pl-1 { padding-left: var(--space-xs); }
.pl-2 { padding-left: var(--space-sm); }
.pl-3 { padding-left: 0.75rem; }
.pl-4 { padding-left: var(--space-md); }
.pl-5 { padding-left: 1.25rem; }
.pl-6 { padding-left: var(--space-lg); }
.pl-8 { padding-left: var(--space-xl); }
.pl-10 { padding-left: 2.5rem; }
.pl-12 { padding-left: var(--space-3xl); }
/* Padding X (horizontal) */
.px-0 { padding-left: 0; padding-right: 0; }
.px-1 { padding-left: var(--space-xs); padding-right: var(--space-xs); }
.px-2 { padding-left: var(--space-sm); padding-right: var(--space-sm); }
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
.px-4 { padding-left: var(--space-md); padding-right: var(--space-md); }
.px-5 { padding-left: 1.25rem; padding-right: 1.25rem; }
.px-6 { padding-left: var(--space-lg); padding-right: var(--space-lg); }
.px-8 { padding-left: var(--space-xl); padding-right: var(--space-xl); }
/* Padding Y (vertical) */
.py-0 { padding-top: 0; padding-bottom: 0; }
.py-1 { padding-top: var(--space-xs); padding-bottom: var(--space-xs); }
.py-2 { padding-top: var(--space-sm); padding-bottom: var(--space-sm); }
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.py-4 { padding-top: var(--space-md); padding-bottom: var(--space-md); }
.py-5 { padding-top: 1.25rem; padding-bottom: 1.25rem; }
.py-6 { padding-top: var(--space-lg); padding-bottom: var(--space-lg); }
.py-8 { padding-top: var(--space-xl); padding-bottom: var(--space-xl); }
/* Space Between (for flex containers) */
.space-x-1 > * + * { margin-left: var(--space-xs); }
.space-x-2 > * + * { margin-left: var(--space-sm); }
.space-x-3 > * + * { margin-left: 0.75rem; }
.space-x-4 > * + * { margin-left: var(--space-md); }
.space-x-6 > * + * { margin-left: var(--space-lg); }
.space-x-8 > * + * { margin-left: var(--space-xl); }
.space-y-1 > * + * { margin-top: var(--space-xs); }
.space-y-2 > * + * { margin-top: var(--space-sm); }
.space-y-3 > * + * { margin-top: 0.75rem; }
.space-y-4 > * + * { margin-top: var(--space-md); }
.space-y-6 > * + * { margin-top: var(--space-lg); }
.space-y-8 > * + * { margin-top: var(--space-xl); }
/* Mobile Spacing Adjustments */
@media (max-width: 768px) {
.m-4 { margin: var(--space-sm); }
.p-4 { padding: var(--space-sm); }
.mt-4 { margin-top: var(--space-sm); }
.mb-4 { margin-bottom: var(--space-sm); }
.pt-4 { padding-top: var(--space-sm); }
.pb-4 { padding-bottom: var(--space-sm); }
.px-4 { padding-left: var(--space-sm); padding-right: var(--space-sm); }
.py-4 { padding-top: var(--space-sm); padding-bottom: var(--space-sm); }
}
@media (max-width: 480px) {
.m-6 { margin: var(--space-md); }
.p-6 { padding: var(--space-md); }
.mt-6 { margin-top: var(--space-md); }
.mb-6 { margin-bottom: var(--space-md); }
.pt-6 { padding-top: var(--space-md); }
.pb-6 { padding-bottom: var(--space-md); }
.px-6 { padding-left: var(--space-md); padding-right: var(--space-md); }
.py-6 { padding-top: var(--space-md); padding-bottom: var(--space-md); }
}

View File

@@ -0,0 +1,137 @@
/* Text Utilities - ROA2WEB */
/* Text Alignment */
.text-left { text-align: left; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.text-justify { text-align: justify; }
/* Text Transform */
.uppercase { text-transform: uppercase; }
.lowercase { text-transform: lowercase; }
.capitalize { text-transform: capitalize; }
.normal-case { text-transform: none; }
/* Font Weight */
.font-thin { font-weight: 100; }
.font-extralight { font-weight: 200; }
.font-light { font-weight: var(--font-light); }
.font-normal { font-weight: var(--font-normal); }
.font-medium { font-weight: var(--font-medium); }
.font-semibold { font-weight: var(--font-semibold); }
.font-bold { font-weight: var(--font-bold); }
.font-extrabold { font-weight: 800; }
.font-black { font-weight: 900; }
/* Font Size */
.text-xs { font-size: var(--text-xs); }
.text-sm { font-size: var(--text-sm); }
.text-base { font-size: var(--text-base); }
.text-lg { font-size: var(--text-lg); }
.text-xl { font-size: var(--text-xl); }
.text-2xl { font-size: var(--text-2xl); }
.text-3xl { font-size: var(--text-3xl); }
.text-4xl { font-size: var(--text-4xl); }
/* Line Height */
.leading-none { line-height: 1; }
.leading-tight { line-height: var(--leading-tight); }
.leading-snug { line-height: 1.375; }
.leading-normal { line-height: var(--leading-normal); }
.leading-relaxed { line-height: 1.625; }
.leading-loose { line-height: var(--leading-loose); }
/* Letter Spacing */
.tracking-tighter { letter-spacing: -0.05em; }
.tracking-tight { letter-spacing: -0.025em; }
.tracking-normal { letter-spacing: 0em; }
.tracking-wide { letter-spacing: 0.025em; }
.tracking-wider { letter-spacing: 0.05em; }
.tracking-widest { letter-spacing: 0.1em; }
/* Text Color */
.text-inherit { color: inherit; }
.text-current { color: currentColor; }
.text-transparent { color: transparent; }
.text-primary { color: var(--color-primary); }
.text-secondary { color: var(--color-secondary); }
.text-success { color: var(--color-success); }
.text-warning { color: var(--color-warning); }
.text-error { color: var(--color-error); }
.text-info { color: var(--color-info); }
.text-muted { color: var(--color-text-muted); }
/* Text Decoration */
.underline { text-decoration: underline; }
.line-through { text-decoration: line-through; }
.no-underline { text-decoration: none; }
/* Text Overflow */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-ellipsis {
text-overflow: ellipsis;
}
.text-clip {
text-overflow: clip;
}
/* White Space */
.whitespace-normal { white-space: normal; }
.whitespace-nowrap { white-space: nowrap; }
.whitespace-pre { white-space: pre; }
.whitespace-pre-line { white-space: pre-line; }
.whitespace-pre-wrap { white-space: pre-wrap; }
/* Word Break */
.break-normal {
overflow-wrap: normal;
word-break: normal;
}
.break-words {
overflow-wrap: break-word;
}
.break-all {
word-break: break-all;
}
/* Font Family */
.font-sans {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.font-serif {
font-family: Georgia, Cambria, "Times New Roman", Times, serif;
}
.font-mono {
font-family: var(--font-mono);
}
/* Responsive Text Utilities */
@media (max-width: 480px) {
.mobile-text-xs { font-size: var(--text-xs); }
.mobile-text-sm { font-size: var(--text-sm); }
.mobile-text-base { font-size: var(--text-base); }
.mobile-text-center { text-align: center; }
.mobile-text-left { text-align: left; }
}
@media (max-width: 768px) {
.tablet-text-xs { font-size: var(--text-xs); }
.tablet-text-sm { font-size: var(--text-sm); }
.tablet-text-center { text-align: center; }
.tablet-text-left { text-align: left; }
}
@media (min-width: 1024px) {
.desktop-text-lg { font-size: var(--text-lg); }
.desktop-text-xl { font-size: var(--text-xl); }
}

View File

@@ -0,0 +1,491 @@
<template>
<div class="company-selector-mini" ref="dropdownContainer">
<div class="company-dropdown" ref="dropdown">
<button
class="company-trigger"
@click="toggleDropdown"
:aria-expanded="dropdownOpen"
aria-label="Select company"
title="Alt+Q to quick select"
>
<div class="company-info">
<span class="company-name">{{ selectedCompanyName }}</span>
<span class="company-code">{{ selectedCompanyCode }}</span>
</div>
<i class="pi pi-chevron-down" :class="{ 'rotate-180': dropdownOpen }"></i>
</button>
<div
v-show="dropdownOpen"
class="company-dropdown-panel"
:class="{ 'panel-open': dropdownOpen }"
>
<div class="dropdown-search">
<div class="search-wrapper">
<i class="pi pi-search search-icon"></i>
<input
ref="searchInput"
type="text"
v-model="searchQuery"
placeholder="Search companies..."
class="search-input"
@keydown="handleKeyDown"
>
</div>
</div>
<div class="company-list">
<div
v-for="(company, index) in filteredCompanies"
:key="company.id_firma || company.id"
class="company-item"
:class="{
active: company.id_firma === selectedCompany?.id_firma,
'keyboard-highlighted': isHighlighted(index)
}"
@click="selectCompany(company)"
@mouseenter="highlightedIndex = index"
>
<div class="company-details">
<div class="company-main-name">{{ company.name }}</div>
<div class="company-sub-info">
<span class="company-cui">CUI: {{ company.fiscal_code }}</span>
<span class="company-separator"></span>
<span class="company-status" :class="company.status">{{ company.status }}</span>
</div>
</div>
<i v-if="company.id_firma === selectedCompany?.id_firma" class="pi pi-check company-selected-icon"></i>
</div>
</div>
<div v-if="filteredCompanies.length === 0" class="no-results">
<i class="pi pi-info-circle"></i>
<span>No companies found</span>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useCompanyStore } from '../../stores/companies'
export default {
name: 'CompanySelectorMini',
props: {
modelValue: {
type: Object,
default: null
}
},
emits: ['update:modelValue', 'company-changed'],
setup(props, { emit }) {
const companiesStore = useCompanyStore()
const dropdown = ref(null)
const dropdownContainer = ref(null)
const searchInput = ref(null)
const dropdownOpen = ref(false)
const searchQuery = ref('')
const highlightedIndex = ref(-1)
const selectedCompany = computed({
get: () => props.modelValue || companiesStore.selectedCompany,
set: (value) => {
emit('update:modelValue', value)
companiesStore.setSelectedCompany(value)
}
})
const selectedCompanyName = computed(() => {
return selectedCompany.value?.name || 'Select Company'
})
const selectedCompanyCode = computed(() => {
return selectedCompany.value?.fiscal_code ? `CUI: ${selectedCompany.value.fiscal_code}` : ''
})
const filteredCompanies = computed(() => {
const companies = companiesStore.companies || []
if (!searchQuery.value || searchQuery.value.trim() === '') {
return companies
}
const query = searchQuery.value.toLowerCase().trim()
return companies.filter(company =>
company.name?.toLowerCase().includes(query) ||
company.fiscal_code?.toLowerCase().includes(query)
)
})
const toggleDropdown = async () => {
dropdownOpen.value = !dropdownOpen.value
if (dropdownOpen.value) {
searchQuery.value = ''
highlightedIndex.value = -1
// Focus on search input after dropdown opens
await nextTick()
searchInput.value?.focus()
}
}
const closeDropdown = () => {
dropdownOpen.value = false
searchQuery.value = ''
}
const selectCompany = (company) => {
selectedCompany.value = company
emit('company-changed', company)
closeDropdown()
}
const scrollToHighlighted = () => {
nextTick(() => {
const highlightedElement = document.querySelector('.company-item.keyboard-highlighted')
if (highlightedElement) {
highlightedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
})
}
const handleKeyDown = (event) => {
if (!dropdownOpen.value || filteredCompanies.value.length === 0) return
switch(event.key) {
case 'ArrowDown':
event.preventDefault()
highlightedIndex.value = (highlightedIndex.value + 1) % filteredCompanies.value.length
scrollToHighlighted()
break
case 'ArrowUp':
event.preventDefault()
if (highlightedIndex.value <= 0) {
highlightedIndex.value = filteredCompanies.value.length - 1
} else {
highlightedIndex.value--
}
scrollToHighlighted()
break
case 'Enter':
event.preventDefault()
if (highlightedIndex.value >= 0 && highlightedIndex.value < filteredCompanies.value.length) {
selectCompany(filteredCompanies.value[highlightedIndex.value])
}
break
case 'Escape':
closeDropdown()
break
}
}
const isHighlighted = (index) => {
return index === highlightedIndex.value
}
const openWithShortcut = async () => {
// Scroll to selector
if (dropdownContainer.value) {
dropdownContainer.value.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// Wait for scroll to complete
await new Promise(resolve => setTimeout(resolve, 300))
// Open dropdown and focus
if (!dropdownOpen.value) {
dropdownOpen.value = true
highlightedIndex.value = -1
searchQuery.value = ''
await nextTick()
searchInput.value?.focus()
} else {
// If already open, just focus
searchInput.value?.focus()
}
}
const handleGlobalKeyDown = (event) => {
// Check for Alt+Q (left-hand shortcut)
if (event.altKey && event.key === 'q') {
event.preventDefault()
openWithShortcut()
}
}
const handleClickOutside = (event) => {
if (dropdown.value && !dropdown.value.contains(event.target)) {
closeDropdown()
}
}
// Watch for search query changes and reset highlighted index
watch(searchQuery, () => {
highlightedIndex.value = -1
})
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleGlobalKeyDown)
// Load companies if not already loaded
if (companiesStore.companies.length === 0) {
companiesStore.loadCompanies()
}
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleGlobalKeyDown)
})
return {
dropdown,
dropdownContainer,
searchInput,
dropdownOpen,
searchQuery,
highlightedIndex,
selectedCompany,
selectedCompanyName,
selectedCompanyCode,
filteredCompanies,
toggleDropdown,
closeDropdown,
selectCompany,
handleKeyDown,
isHighlighted
}
}
}
</script>
<style scoped>
.company-selector-mini {
position: relative;
max-width: 450px;
}
.company-dropdown {
position: relative;
}
.company-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
width: 100%;
text-align: left;
min-width: 300px;
}
.company-trigger:hover {
border-color: var(--color-primary);
background: var(--color-bg-secondary);
}
.company-info {
flex: 1;
min-width: 0;
}
.company-name {
display: block;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.company-code {
display: block;
font-size: var(--text-xs);
color: var(--color-text-secondary);
margin-top: 2px;
}
.pi-chevron-down {
transition: transform var(--transition-fast);
color: var(--color-text-secondary);
font-size: var(--text-xs);
}
.rotate-180 {
transform: rotate(180deg);
}
.company-dropdown-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: var(--z-dropdown);
max-height: 300px;
overflow: hidden;
opacity: 0;
transform: translateY(-10px);
transition: all var(--transition-fast);
}
.panel-open {
opacity: 1;
transform: translateY(0);
}
.dropdown-search {
padding: var(--space-sm);
border-bottom: 1px solid var(--color-border);
}
.search-wrapper {
position: relative;
}
.search-icon {
position: absolute;
left: var(--space-sm);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
font-size: var(--text-sm);
pointer-events: none;
}
.search-input {
width: 100%;
padding: var(--space-sm) var(--space-sm) var(--space-sm) var(--space-xl);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
background: var(--color-bg);
color: var(--color-text);
transition: border-color var(--transition-fast);
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
}
.company-list {
max-height: 200px;
overflow-y: auto;
}
.company-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md);
cursor: pointer;
transition: background-color var(--transition-fast);
border-bottom: 1px solid var(--color-border-light);
}
.company-item:last-child {
border-bottom: none;
}
.company-item:hover {
background: var(--color-bg-secondary);
}
.company-item.active {
background: var(--color-primary);
color: var(--color-text-inverse);
}
.company-item.keyboard-highlighted {
background: var(--color-bg-secondary);
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.company-item.active.keyboard-highlighted {
/* When both active and highlighted, outline with semi-transparent white */
outline: 2px solid rgba(255, 255, 255, 0.5);
}
.company-details {
flex: 1;
min-width: 0;
}
.company-main-name {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.company-sub-info {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-xs);
opacity: 0.8;
}
.company-separator {
opacity: 0.5;
}
.company-status.active {
color: var(--color-success);
}
.company-status.inactive {
color: var(--color-error);
}
.company-selected-icon {
color: inherit;
font-size: var(--text-sm);
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-xl);
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.company-selector-mini {
max-width: none;
width: 100%;
}
.company-trigger {
min-width: auto;
}
.company-dropdown-panel {
left: -16px;
right: -16px;
width: calc(100% + 32px);
}
}
</style>

View File

@@ -0,0 +1,658 @@
<template>
<div class="detailed-data-section">
<div class="section-header">
<h2 class="section-title">Date Detaliate</h2>
<div class="section-controls">
<!-- Selector tip date -->
<select v-model="selectedType" @change="loadData" class="data-type-select">
<option value="clients">Clienți</option>
<option value="suppliers">Furnizori</option>
<option value="treasury">Trezorerie</option>
</select>
<!-- Căutare -->
<div class="search-wrapper">
<input
v-model="searchTerm"
@input="handleSearch"
type="text"
placeholder="Căutare..."
class="search-input"
>
<i class="pi pi-search"></i>
</div>
<!-- Export buttons -->
<button @click="exportExcel" class="btn btn-sm btn-outline">
<i class="pi pi-file-excel"></i> Excel
</button>
<button @click="exportPDF" class="btn btn-sm btn-outline">
<i class="pi pi-file-pdf"></i> PDF
</button>
</div>
</div>
<!-- Tabel cu date -->
<div class="table-container">
<table class="detailed-table">
<thead>
<tr>
<th v-for="column in displayColumns" :key="column.field">
{{ column.header }}
</th>
</tr>
</thead>
<tbody v-if="selectedType === 'treasury'">
<!-- Treasury - normal table without grouping -->
<tr v-for="row in paginatedData" :key="row.id">
<td v-for="column in displayColumns" :key="column.field">
{{ formatValue(row[column.field], column.type) }}
</td>
</tr>
</tbody>
<tbody v-else>
<!-- Clients/Suppliers - grouped with expand/collapse -->
<template v-for="group in paginatedGroups" :key="group.name">
<!-- Single invoice: show direct row -->
<tr v-if="group.facturi.length === 1"
class="single-invoice-row"
:class="{ 'row-restant': group.hasRestant }">
<td><strong>{{ group.name }}</strong></td>
<td>{{ group.facturi[0].numar_document }}</td>
<td>{{ formatValue(group.facturi[0].data_document, 'date') }}</td>
<td>{{ formatValue(group.facturi[0].data_scadenta, 'date') }}</td>
<td>{{ formatValue(group.facturi[0].facturat, 'currency') }}</td>
<td>{{ formatValue(group.facturi[0][selectedType === 'clients' ? 'incasat' : 'achitat'], 'currency') }}</td>
<td :class="{ 'sold-restant': group.facturi[0].status === 'Restant' }">{{ formatValue(group.facturi[0].sold, 'currency') }}</td>
</tr>
<!-- Multiple invoices: show expand/collapse -->
<template v-else>
<!-- Group row (client/supplier header with subtotal) -->
<tr
class="group-row"
:class="{ 'has-restant': group.hasRestant }"
@click="toggleClient(group.name)"
>
<td class="group-name-cell">
<strong>{{ group.name }}</strong>
<span class="facturi-count">({{ group.facturi.length }})</span>
</td>
<td colspan="5"></td>
<td class="subtotal-cell" :class="{ 'sold-restant': group.hasRestant }">
<strong>{{ formatValue(group.totalSold, 'currency') }}</strong>
</td>
</tr>
<!-- Detail rows (invoices) - only if expanded -->
<template v-if="isExpanded(group.name)">
<tr
v-for="(factura, idx) in group.facturi"
:key="`${group.name}-${idx}`"
class="detail-row"
:class="getRowClass(factura)"
>
<td class="detail-name">{{ factura.client || factura.furnizor || '' }}</td>
<td>{{ factura.numar_document }}</td>
<td>{{ formatValue(factura.data_document, 'date') }}</td>
<td>{{ formatValue(factura.data_scadenta, 'date') }}</td>
<td>{{ formatValue(factura[selectedType === 'clients' ? 'facturat' : 'facturat'], 'currency') }}</td>
<td>{{ formatValue(factura[selectedType === 'clients' ? 'incasat' : 'achitat'], 'currency') }}</td>
<td :class="{ 'sold-restant': factura.status === 'Restant' }">{{ formatValue(factura.sold, 'currency') }}</td>
</tr>
</template>
</template>
</template>
</tbody>
<tfoot>
<tr class="totals-row">
<td><strong>TOTAL</strong></td>
<td v-for="column in displayColumns.slice(1)" :key="column.field">
<strong v-if="column.showTotal">
{{ formatValue(calculateTotal(column.field), column.type) }}
</strong>
</td>
</tr>
</tfoot>
</table>
</div>
<!-- Paginare -->
<div class="pagination-wrapper">
<Paginator
:rows="rowsPerPage"
:totalRecords="totalRecords"
v-model:first="firstRow"
:rowsPerPageOptions="[10, 25, 50, 100]"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useDashboardStore } from '@/stores/dashboard'
import { useCompanyStore } from '@/stores/companies'
import { useToast } from 'primevue/usetoast'
import Paginator from 'primevue/paginator'
import * as XLSX from 'xlsx'
import jsPDF from 'jspdf'
import 'jspdf-autotable'
const dashboardStore = useDashboardStore()
const companyStore = useCompanyStore()
const toast = useToast()
// State
const selectedType = ref('clients')
const searchTerm = ref('')
const data = ref([])
const firstRow = ref(0)
const rowsPerPage = ref(25)
const expandedClients = ref(new Set())
// Columns configuration based on type
const columns = computed(() => {
switch(selectedType.value) {
case 'clients':
return [
{ field: 'client', header: 'Client', type: 'text' },
{ field: 'numar_document', header: 'Nr. Document', type: 'text' },
{ field: 'data_document', header: 'Data Document', type: 'date' },
{ field: 'data_scadenta', header: 'Data Scadență', type: 'date' },
{ field: 'facturat', header: 'Facturat', type: 'currency', showTotal: true },
{ field: 'incasat', header: 'Încasat', type: 'currency', showTotal: true },
{ field: 'sold', header: 'Sold', type: 'currency', showTotal: true }
]
case 'suppliers':
return [
{ field: 'furnizor', header: 'Furnizor', type: 'text' },
{ field: 'numar_document', header: 'Nr. Document', type: 'text' },
{ field: 'data_document', header: 'Data Document', type: 'date' },
{ field: 'data_scadenta', header: 'Data Scadență', type: 'date' },
{ field: 'facturat', header: 'Facturat', type: 'currency', showTotal: true },
{ field: 'achitat', header: 'Achitat', type: 'currency', showTotal: true },
{ field: 'sold', header: 'Sold', type: 'currency', showTotal: true }
]
case 'treasury':
return [
{ field: 'cont', header: 'Cont', type: 'text' },
{ field: 'nume_cont', header: 'Nume Cont', type: 'text' },
{ field: 'sold', header: 'Sold', type: 'currency', showTotal: true },
{ field: 'valuta', header: 'Valută', type: 'text' },
{ field: 'tip', header: 'Tip', type: 'text' }
]
default:
return []
}
})
// Display columns for header (without first column for grouped tables)
const displayColumns = computed(() => {
if (selectedType.value === 'treasury') {
return columns.value
}
// For clients/suppliers, keep all columns in header
return columns.value
})
// Filtered data based on search
const filteredData = computed(() => {
if (!searchTerm.value) return data.value
return data.value.filter(row => {
return Object.values(row).some(val =>
String(val).toLowerCase().includes(searchTerm.value.toLowerCase())
)
})
})
// Group data by client/supplier
const groupedData = computed(() => {
if (selectedType.value === 'treasury') {
return []
}
const groups = {}
const nameField = selectedType.value === 'clients' ? 'client' : 'furnizor'
filteredData.value.forEach(row => {
const clientName = row[nameField]
if (!clientName) return
if (!groups[clientName]) {
groups[clientName] = {
name: clientName,
facturi: [],
totalSold: 0,
hasRestant: false
}
}
groups[clientName].facturi.push(row)
groups[clientName].totalSold += (row.sold || 0)
if (row.status === 'Restant') {
groups[clientName].hasRestant = true
}
})
return Object.values(groups)
})
// Paginated groups
const paginatedGroups = computed(() => {
if (selectedType.value === 'treasury') {
return []
}
const start = firstRow.value
const end = start + rowsPerPage.value
return groupedData.value.slice(start, end)
})
// Paginated data (for treasury)
const paginatedData = computed(() => {
if (selectedType.value !== 'treasury') {
return []
}
const end = firstRow.value + rowsPerPage.value
return filteredData.value.slice(firstRow.value, end)
})
// Total records for paginator
const totalRecords = computed(() => {
if (selectedType.value === 'treasury') {
return filteredData.value.length
}
return groupedData.value.length
})
// Expand/collapse functions
const toggleClient = (clientName) => {
if (expandedClients.value.has(clientName)) {
expandedClients.value.delete(clientName)
} else {
expandedClients.value.add(clientName)
}
}
const isExpanded = (clientName) => {
return expandedClients.value.has(clientName)
}
const getRowClass = (row) => {
if (row.status === 'Restant') return 'row-restant'
return 'row-in-termen'
}
// Methods
const loadData = async () => {
try {
if (!companyStore.selectedCompany) {
toast.add({
severity: 'warn',
summary: 'Atenție',
detail: 'Vă rugăm să selectați o companie',
life: 3000
})
return
}
const response = await dashboardStore.loadDetailedData(
selectedType.value,
companyStore.selectedCompany.id_firma
)
data.value = response.data
// Reset expanded state when loading new data
expandedClients.value.clear()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-au putut încărca datele detaliate'
})
}
}
const formatValue = (value, type) => {
switch(type) {
case 'currency':
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON'
}).format(value || 0)
case 'date':
if (!value) return '-'
// Handle Oracle date format (YYYY-MM-DD or Date object)
const date = new Date(value)
if (isNaN(date.getTime())) return value // Return original if invalid
return date.toLocaleDateString('ro-RO', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
case 'badge':
return value
default:
return value
}
}
const calculateTotal = (field) => {
return filteredData.value.reduce((sum, row) => sum + (row[field] || 0), 0)
}
const handleSearch = () => {
firstRow.value = 0 // Reset pagination on search
expandedClients.value.clear() // Reset expanded state on search
}
const exportExcel = () => {
const ws = XLSX.utils.json_to_sheet(filteredData.value)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, selectedType.value)
XLSX.writeFile(wb, `${selectedType.value}_${new Date().toISOString()}.xlsx`)
}
const exportPDF = () => {
const doc = new jsPDF()
const tableColumns = columns.value.map(c => c.header)
const tableRows = filteredData.value.map(row =>
columns.value.map(c => formatValue(row[c.field], c.type))
)
doc.autoTable({
head: [tableColumns],
body: tableRows,
theme: 'grid'
})
doc.save(`${selectedType.value}_${new Date().toISOString()}.pdf`)
}
onMounted(() => {
loadData()
})
watch(selectedType, () => {
loadData()
})
// Watch for company changes to reload data
watch(() => companyStore.selectedCompany, (newCompany) => {
if (newCompany) {
loadData()
}
})
</script>
<style scoped>
.detailed-data-section {
margin-top: 2rem;
background: var(--color-bg);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.section-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text-primary);
}
.section-controls {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.data-type-select {
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text-primary);
min-width: 120px;
}
.search-wrapper {
position: relative;
display: inline-block;
}
.search-input {
padding: 0.5rem 2.5rem 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text-primary);
width: 200px;
}
.search-wrapper i {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
}
.btn {
padding: 0.5rem 1rem;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8rem;
}
.btn-outline {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-primary);
}
.btn-outline:hover {
background: var(--color-bg-muted);
border-color: var(--color-primary);
}
.table-container {
overflow-x: auto;
margin: 1rem 0;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
}
.detailed-table {
width: 100%;
border-collapse: collapse;
min-width: 600px;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 12px;
line-height: 1.4;
}
.detailed-table th,
.detailed-table td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid var(--color-border);
font-weight: 400;
}
.detailed-table th {
background: #ffffff;
font-weight: 500;
color: var(--color-text-primary);
position: sticky;
top: 0;
font-size: 12px;
letter-spacing: 0.025em;
border-bottom: 2px solid var(--color-border);
}
/* Group row styling */
.group-row {
background: #ffffff;
cursor: pointer;
font-weight: 500;
border-top: 1px solid var(--color-border);
transition: background 0.15s ease;
}
.group-row:hover {
background: #f8f9fa;
}
.group-row.has-restant:hover {
background: #f8f9fa;
}
/* Single invoice row styling */
.single-invoice-row {
background: #ffffff;
font-weight: 400;
transition: background 0.15s ease;
}
.single-invoice-row:hover {
background: #f8f9fa;
}
.single-invoice-row.row-restant:hover {
background: #f8f9fa;
}
.single-invoice-row td:first-child {
font-weight: 500;
}
.group-name-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.facturi-count {
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 400;
margin-left: 4px;
}
.subtotal-cell {
text-align: right;
font-weight: 600;
color: var(--color-primary);
}
/* Detail row styling */
.detail-row {
font-size: 11px;
transition: background 0.15s ease;
}
.detail-name {
padding-left: 1.5rem;
color: var(--color-text-secondary);
font-size: 11px;
}
.detail-row.row-restant:hover {
background: #f8f9fa;
}
.detail-row.row-in-termen:hover {
background: #f8f9fa;
}
/* Sold restant - only color the amount text */
.sold-restant {
color: rgb(239, 68, 68);
font-weight: 600;
}
.detailed-table tbody tr:hover {
background: #f8f9fa;
}
.totals-row {
background: #f8f9fa !important;
border-top: 2px solid var(--color-border) !important;
font-weight: 500;
}
.pagination-wrapper {
margin-top: 1rem;
display: flex;
justify-content: center;
}
/* Responsive */
@media (max-width: 768px) {
.section-header {
flex-direction: column;
align-items: stretch;
}
.section-controls {
justify-content: space-between;
}
.search-input {
width: 150px;
}
.table-container {
margin: 1rem -1rem;
border-radius: 0;
border-left: none;
border-right: none;
}
.detailed-table th,
.detailed-table td {
padding: 0.5rem;
font-size: 0.875rem;
white-space: nowrap;
}
}
@media (max-width: 480px) {
.section-controls {
flex-direction: column;
gap: 0.5rem;
}
.section-controls > * {
width: 100%;
}
.search-input {
width: 100%;
}
.btn {
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,316 @@
<template>
<div class="trend-chart">
<canvas
ref="chartCanvas"
:width="width"
:height="height"
class="chart-canvas"
></canvas>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import {
Chart,
CategoryScale,
LinearScale,
LineElement,
PointElement,
BarElement,
LineController,
BarController,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
// Register Chart.js components
Chart.register(
CategoryScale,
LinearScale,
LineElement,
PointElement,
BarElement,
LineController,
BarController,
Title,
Tooltip,
Legend,
Filler
)
// Props definition
const props = defineProps({
data: {
type: Object,
required: true,
default: () => ({
labels: [],
datasets: []
})
},
type: {
type: String,
default: 'line',
validator: (value) => ['line', 'bar', 'area'].includes(value)
},
compare: {
type: Boolean,
default: false
},
width: {
type: Number,
default: 400
},
height: {
type: Number,
default: 200
},
options: {
type: Object,
default: () => ({})
}
})
// Refs
const chartCanvas = ref(null)
const chartInstance = ref(null)
// Romanian currency formatter
const formatCurrency = (value) => {
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
}
// Chart configuration
const getChartConfig = () => {
const chartType = props.type === 'area' ? 'line' : props.type
const config = {
type: chartType,
data: {
labels: props.data.labels || [],
datasets: (props.data.datasets || []).map((dataset, index) => {
const baseConfig = {
...dataset,
borderWidth: props.type === 'line' || props.type === 'area' ? 2 : 0,
pointBackgroundColor: dataset.borderColor || dataset.backgroundColor,
pointBorderColor: dataset.borderColor || dataset.backgroundColor,
pointRadius: props.type === 'line' || props.type === 'area' ? 4 : 0,
pointHoverRadius: props.type === 'line' || props.type === 'area' ? 6 : 0
}
// Area chart specific configuration
if (props.type === 'area') {
baseConfig.fill = true
baseConfig.backgroundColor = dataset.backgroundColor ||
(dataset.borderColor ? dataset.borderColor.replace('rgb', 'rgba').replace(')', ', 0.1)') : 'rgba(54, 162, 235, 0.1)')
}
// Bar chart specific configuration
if (props.type === 'bar') {
baseConfig.borderRadius = 4
baseConfig.borderSkipped = false
}
return baseConfig
})
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: props.compare,
position: 'top',
labels: {
usePointStyle: true,
padding: 20,
font: {
size: 12
}
}
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
label: function(context) {
let label = context.dataset.label || ''
if (label) {
label += ': '
}
if (context.parsed.y !== null) {
label += formatCurrency(context.parsed.y)
}
return label
}
},
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1
}
},
scales: {
x: {
display: true,
grid: {
display: false
},
ticks: {
font: {
size: 11
},
color: '#6b7280'
}
},
y: {
display: true,
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
},
ticks: {
font: {
size: 11
},
color: '#6b7280',
callback: function(value) {
return formatCurrency(value)
}
}
}
},
interaction: {
mode: 'index',
intersect: false
},
hover: {
mode: 'index',
intersect: false
},
// Merge with custom options
...props.options
}
}
return config
}
// Create chart instance
const createChart = () => {
if (!chartCanvas.value) return
const config = getChartConfig()
// Deep clone the entire config to break Vue reactivity circular references
const clonedConfig = JSON.parse(JSON.stringify(config))
chartInstance.value = new Chart(chartCanvas.value, clonedConfig)
}
// Destroy chart instance
const destroyChart = () => {
if (chartInstance.value) {
chartInstance.value.destroy()
chartInstance.value = null
}
}
// Update chart data
const updateChart = () => {
if (!chartInstance.value) return
const config = getChartConfig()
// Deep clone the data to break Vue reactivity circular references
const clonedData = JSON.parse(JSON.stringify(config.data))
// Update data
chartInstance.value.data = clonedData
// Update options (clone options too to be safe)
chartInstance.value.options = JSON.parse(JSON.stringify(config.options))
// Re-render
chartInstance.value.update('none')
}
// Recreate chart completely
const recreateChart = async () => {
destroyChart()
await nextTick()
createChart()
}
// Watch for prop changes
watch(
() => [props.data, props.type, props.compare, props.options],
async (newValues, oldValues) => {
// Skip if chart is not initialized
if (!chartInstance.value) return
// If chart type changed, recreate completely
if (newValues[1] !== oldValues[1]) {
await recreateChart()
} else {
// Otherwise just update
updateChart()
}
},
{ deep: true }
)
// Lifecycle hooks
onMounted(() => {
nextTick(() => {
createChart()
})
})
onBeforeUnmount(() => {
destroyChart()
})
// Expose methods for parent components
defineExpose({
updateChart,
recreateChart,
chartInstance: () => chartInstance.value
})
</script>
<style scoped>
.trend-chart {
position: relative;
width: 100%;
height: 100%;
min-height: 200px;
}
.chart-canvas {
width: 100% !important;
height: 100% !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.trend-chart {
min-height: 150px;
}
}
@media (max-width: 480px) {
.trend-chart {
min-height: 120px;
}
}
</style>

View File

@@ -0,0 +1,742 @@
<template>
<div class="cashflow-card">
<!-- Card Header -->
<div class="card-header">
<div class="header-content">
<h3 class="card-title">📅 Cash Flow Previzionat</h3>
<div class="period-selector">
<select
v-model="selectedPeriod"
@change="handlePeriodChange"
class="period-select"
>
<option value="7d">Următoarele 7 zile</option>
<option value="1m">Următoarea lună</option>
<option value="3m">Următoarele 3 luni</option>
<option value="6m">Următoarele 6 luni</option>
</select>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="loading-state">
<div class="loading-spinner"></div>
<p>Se încarcă previziunea cash flow...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="error-state">
<div class="error-icon"></div>
<p>{{ error }}</p>
<button @click="loadCashFlowData" class="retry-btn">Încearcă din nou</button>
</div>
<!-- Cash Flow Content -->
<div v-else class="cashflow-content">
<!-- Chart Container -->
<div class="cashflow-bars" v-if="chartData && chartData.periods.length > 0">
<div class="chart-header">
<div class="chart-legend">
<div class="legend-item">
<span class="legend-color inflow"></span>
<span class="legend-label">Încasări</span>
</div>
<div class="legend-item">
<span class="legend-color outflow"></span>
<span class="legend-label">Plăți</span>
</div>
</div>
</div>
<!-- Chart.js Canvas -->
<div class="chart-canvas-container">
<canvas
ref="cashflowChart"
v-if="chartData?.periods?.length"
width="400"
height="200"
></canvas>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state">
<div class="empty-icon">📊</div>
<p>Nu există date de cash flow pentru perioada selectată</p>
</div>
<!-- Cash Flow Summary -->
<div v-if="chartData" class="cashflow-summary">
<div class="summary-row">
<div class="summary-item net-flow" :class="getNetFlowClass(chartData.netTotal)">
<span class="summary-label">Net Total:</span>
<span class="summary-value">{{ formatCurrency(chartData.netTotal) }}</span>
</div>
</div>
<!-- Critical Days Warnings -->
<div v-if="chartData.criticalDays && chartData.criticalDays.length > 0" class="warnings">
<div class="warning-header">
<span class="warning-icon"></span>
<span class="warning-title">Zile Critice</span>
</div>
<div class="critical-days">
<span
v-for="day in chartData.criticalDays"
:key="day"
class="critical-day"
>
{{ day }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { Chart, registerables } from 'chart.js'
import { useDashboardStore } from '../../../stores/dashboard'
// Register Chart.js components
Chart.register(...registerables)
// Props
const props = defineProps({
companyId: {
type: Number,
required: true
}
})
// Emits
const emit = defineEmits(['periodChanged'])
// Store
const dashboardStore = useDashboardStore()
// State
const selectedPeriod = ref('7d')
const isLoading = ref(false)
const error = ref(null)
const chartData = ref(null)
const cashflowChart = ref(null)
const chartInstance = ref(null)
// Computed
const maxValue = computed(() => {
if (!chartData.value) return 1
const allValues = [
...chartData.value.inflows,
...chartData.value.outflows.map(Math.abs)
].filter(v => v > 0)
return Math.max(...allValues, 1)
})
// Methods
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return '0,00 RON'
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
if (isNaN(numAmount)) return '0,00 RON'
try {
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(numAmount)
} catch (error) {
return `${numAmount.toLocaleString('ro-RO', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} RON`
}
}
const formatCurrencyShort = (amount) => {
if (!amount && amount !== 0) return '0'
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
if (isNaN(numAmount)) return '0'
const absAmount = Math.abs(numAmount)
if (absAmount >= 1000000) {
return `${(numAmount / 1000000).toFixed(1)}M`
} else if (absAmount >= 1000) {
return `${(numAmount / 1000).toFixed(0)}k`
}
return numAmount.toLocaleString('ro-RO', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
}
const initializeChart = async () => {
if (!cashflowChart.value || !chartData.value) return
// Destroy existing chart instance
if (chartInstance.value) {
chartInstance.value.destroy()
chartInstance.value = null
}
await nextTick()
const ctx = cashflowChart.value.getContext('2d')
chartInstance.value = new Chart(ctx, {
type: 'line',
data: {
labels: chartData.value.periods,
datasets: [
{
label: 'Încasări',
data: chartData.value.inflows,
borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
borderWidth: 2,
fill: false,
tension: 0.4,
pointBackgroundColor: 'rgb(34, 197, 94)',
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: 5,
pointHoverRadius: 7
},
{
label: 'Plăți',
data: chartData.value.outflows.map(Math.abs),
borderColor: 'rgb(239, 68, 68)',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderWidth: 2,
fill: false,
tension: 0.4,
pointBackgroundColor: 'rgb(239, 68, 68)',
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: 5,
pointHoverRadius: 7
},
{
label: 'Net Flow',
data: chartData.value.netFlow,
borderColor: 'rgb(99, 102, 241)',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
borderWidth: 2,
fill: false,
tension: 0.4,
pointBackgroundColor: 'rgb(99, 102, 241)',
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: 5,
pointHoverRadius: 7
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
position: 'top',
labels: {
usePointStyle: true,
padding: 20,
font: {
size: 12,
weight: '500'
}
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
cornerRadius: 8,
displayColors: true,
callbacks: {
label: function(context) {
const label = context.dataset.label
const value = context.parsed.y
return `${label}: ${formatCurrency(value)}`
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false
},
ticks: {
font: {
size: 11
},
color: 'rgba(107, 114, 128, 0.8)'
}
},
y: {
display: true,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawBorder: false
},
ticks: {
font: {
size: 11
},
color: 'rgba(107, 114, 128, 0.8)',
callback: function(value) {
return formatCurrencyShort(value)
}
}
}
},
elements: {
line: {
borderJoinStyle: 'round'
},
point: {
hoverBorderWidth: 3
}
}
}
})
}
const getNetFlowClass = (amount) => {
if (!amount && amount !== 0) return 'neutral'
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
return numAmount > 0 ? 'positive' : numAmount < 0 ? 'negative' : 'neutral'
}
const handlePeriodChange = () => {
emit('periodChanged', selectedPeriod.value)
loadCashFlowData()
}
const loadCashFlowData = async () => {
if (!props.companyId) return
isLoading.value = true
error.value = null
try {
const result = await dashboardStore.loadCashFlowData(props.companyId, selectedPeriod.value)
if (result.success) {
chartData.value = result.data
await nextTick()
initializeChart()
} else {
error.value = result.error || 'Nu s-au putut încărca datele'
// Fallback to mock data for development
chartData.value = generateMockData()
await nextTick()
initializeChart()
}
} catch (err) {
console.error('Error loading cash flow data:', err)
error.value = 'Eroare la încărcarea datelor'
// Fallback to mock data for development
chartData.value = generateMockData()
await nextTick()
initializeChart()
} finally {
isLoading.value = false
}
}
const generateMockData = () => {
const periods = {
'7d': ['Luni', 'Marți', 'Miercuri', 'Joi', 'Vineri', 'Sâmbătă', 'Duminică'],
'1m': ['S1', 'S2', 'S3', 'S4'],
'3m': ['Luna 1', 'Luna 2', 'Luna 3'],
'6m': ['Trim 1', 'Trim 2']
}
const periodLabels = periods[selectedPeriod.value] || periods['7d']
const inflows = periodLabels.map(() => Math.random() * 500000 + 100000)
const outflows = periodLabels.map(() => -(Math.random() * 400000 + 50000))
const netFlow = inflows.map((inflow, i) => inflow + outflows[i])
const cumulative = netFlow.reduce((acc, val, i) => {
acc.push((acc[i - 1] || 0) + val)
return acc
}, [])
const criticalDays = netFlow
.map((net, i) => net < -50000 ? periodLabels[i] : null)
.filter(Boolean)
return {
periods: periodLabels,
inflows,
outflows,
netFlow,
cumulative,
criticalDays,
netTotal: netFlow.reduce((sum, val) => sum + val, 0)
}
}
// Watchers
watch(() => props.companyId, (newId) => {
if (newId) {
loadCashFlowData()
}
}, { immediate: true })
watch(chartData, (newData) => {
if (newData) {
nextTick(() => {
initializeChart()
})
}
}, { deep: true })
// Lifecycle
onMounted(() => {
if (props.companyId) {
loadCashFlowData()
}
})
onUnmounted(() => {
if (chartInstance.value) {
chartInstance.value.destroy()
chartInstance.value = null
}
})
</script>
<style scoped>
.cashflow-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
overflow: hidden;
transition: all var(--transition-fast);
min-height: 400px;
display: flex;
flex-direction: column;
}
.cashflow-card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
/* Card Header */
.card-header {
padding: var(--space-lg) var(--space-xl);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-md);
}
.card-title {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--color-text);
margin: 0;
}
.period-selector {
display: flex;
align-items: center;
}
.period-select {
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
transition: all var(--transition-fast);
}
.period-select:hover {
border-color: var(--color-primary);
}
.period-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.2);
}
/* Loading and Error States */
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl);
color: var(--color-text-secondary);
text-align: center;
flex: 1;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: var(--space-md);
}
.error-icon,
.empty-icon {
font-size: 2rem;
margin-bottom: var(--space-md);
}
.retry-btn {
background: var(--color-primary);
color: white;
border: none;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--text-sm);
transition: background-color var(--transition-fast);
margin-top: var(--space-md);
}
.retry-btn:hover {
background: var(--color-primary-dark);
}
/* Cash Flow Content */
.cashflow-content {
flex: 1;
display: flex;
flex-direction: column;
}
/* Chart */
.cashflow-bars {
padding: var(--space-lg) var(--space-xl);
flex: 1;
}
.chart-header {
display: flex;
justify-content: center;
margin-bottom: var(--space-lg);
}
.chart-legend {
display: flex;
gap: var(--space-lg);
}
.legend-item {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.legend-color {
width: 16px;
height: 16px;
border-radius: var(--radius-sm);
}
.legend-color.inflow {
background: var(--color-success);
}
.legend-color.outflow {
background: var(--color-error);
}
.legend-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
}
/* Chart.js Container */
.chart-canvas-container {
position: relative;
height: 250px;
padding: var(--space-md) 0;
}
.chart-canvas-container canvas {
max-width: 100%;
height: 100% !important;
}
/* Cash Flow Summary */
.cashflow-summary {
padding: var(--space-lg) var(--space-xl);
background: var(--color-bg-secondary);
border-top: 1px solid var(--color-border);
}
.summary-row {
display: flex;
justify-content: center;
margin-bottom: var(--space-md);
}
.summary-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
}
.summary-item.positive {
background: var(--color-success-bg);
color: var(--color-success);
}
.summary-item.negative {
background: var(--color-error-bg);
color: var(--color-error);
}
.summary-item.neutral {
background: var(--color-bg-muted);
color: var(--color-text);
}
.summary-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.summary-value {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
font-family: var(--font-mono);
}
/* Warnings */
.warnings {
margin-top: var(--space-md);
}
.warning-header {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.warning-icon {
font-size: var(--text-lg);
}
.warning-title {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--color-warning);
}
.critical-days {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.critical-day {
background: var(--color-warning-bg);
color: var(--color-warning);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: var(--font-medium);
}
/* Animations */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Responsive Design */
@media (max-width: 1024px) {
.header-content {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
}
.chart-legend {
justify-content: center;
flex-wrap: wrap;
}
.chart-canvas-container {
height: 200px;
}
}
@media (max-width: 768px) {
.cashflow-card {
min-height: 350px;
}
.card-header,
.cashflow-bars,
.cashflow-summary {
padding: var(--space-md);
}
.chart-canvas-container {
height: 180px;
}
.summary-item {
flex-direction: column;
text-align: center;
gap: var(--space-xs);
}
.summary-value {
font-size: var(--text-base);
}
}
@media (max-width: 480px) {
.chart-legend {
flex-direction: column;
align-items: center;
gap: var(--space-sm);
}
.chart-canvas-container {
height: 160px;
}
.critical-days {
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,715 @@
<template>
<div class="cashflow-card">
<!-- Main values section - Split layout (Încasări | Plăți) -->
<div class="values-section">
<!-- Încasări Section -->
<div class="value-block inflows">
<div class="value-label">Încasări</div>
<div class="value-amount positive">
{{ formatCurrency(inflowsValue) }}
</div>
</div>
<!-- Divider -->
<div class="divider"></div>
<!-- Plăți Section -->
<div class="value-block outflows">
<div class="value-label">Plăți</div>
<div class="value-amount negative">
{{ formatCurrency(outflowsValue) }}
</div>
</div>
</div>
<!-- Dual sparkline charts - stacked vertical -->
<div class="sparkline-dual-container" v-if="hasSparklineData">
<!-- Grafic Încasări -->
<div class="sparkline-wrapper">
<div class="sparkline-label">Încasări</div>
<div class="sparkline-chart">
<canvas ref="inflowsCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
<!-- Grafic Plăți -->
<div class="sparkline-wrapper">
<div class="sparkline-label">Plăți</div>
<div class="sparkline-chart">
<canvas ref="outflowsCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Chart, registerables } from 'chart.js'
Chart.register(...registerables)
const props = defineProps({
inflowsValue: {
type: Number,
default: 0
},
outflowsValue: {
type: Number,
default: 0
},
inflowsTrend: {
type: Object,
default: null
},
outflowsTrend: {
type: Object,
default: null
},
inflowsSparkline: {
type: Array,
default: () => []
},
outflowsSparkline: {
type: Array,
default: () => []
},
inflowsPreviousSparkline: {
type: Array,
default: () => []
},
outflowsPreviousSparkline: {
type: Array,
default: () => []
},
sparklineLabels: {
type: Array,
default: () => []
},
previousSparklineLabels: {
type: Array,
default: () => []
}
})
// Refs pentru 2 canvas-uri separate
const inflowsCanvas = ref(null)
const outflowsCanvas = ref(null)
let inflowsChartInstance = null
let outflowsChartInstance = null
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return '0 RON'
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(Math.abs(amount))
}
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return props.inflowsSparkline.length > 0 && props.outflowsSparkline.length > 0
})
// Initialize Încasări chart
const initializeInflowsChart = async () => {
if (!inflowsCanvas.value || props.inflowsSparkline.length === 0) {
return
}
// Destroy existing chart
if (inflowsChartInstance) {
inflowsChartInstance.destroy()
inflowsChartInstance = null
}
await nextTick()
const ctx = inflowsCanvas.value.getContext('2d')
// Generate labels
const labels = props.sparklineLabels.length > 0
? props.sparklineLabels
: props.inflowsSparkline.map((_, i) => `L${i + 1}`)
// Prepare datasets
const datasets = [{
label: 'Încasări (curent)',
data: props.inflowsSparkline,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: '#10b981',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
}]
// Add previous year dataset if available
if (props.inflowsPreviousSparkline && props.inflowsPreviousSparkline.length > 0) {
datasets.push({
label: 'Încasări (anul precedent)',
data: props.inflowsPreviousSparkline,
borderColor: 'rgba(16, 185, 129, 0.4)',
backgroundColor: 'rgba(16, 185, 129, 0.05)',
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: 'rgba(16, 185, 129, 0.4)',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
})
}
// Calculate limits including both datasets
const allDataPoints = [...props.inflowsSparkline]
if (props.inflowsPreviousSparkline && props.inflowsPreviousSparkline.length > 0) {
allDataPoints.push(...props.inflowsPreviousSparkline)
}
const dataMin = Math.min(...allDataPoints)
const dataMax = Math.max(...allDataPoints)
const dataRange = dataMax - dataMin
const dataMean = allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
const minVisibleRange = dataMean * 0.25 // 25% din medie = range minim vizibil
const center = (dataMin + dataMax) / 2
const targetRange = Math.max(dataRange, minVisibleRange)
// Calculează limite simetric față de centru
let calculatedMin = center - targetRange / 2
let calculatedMax = center + targetRange / 2
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
const paddingAmount = targetRange * 0.10 // 10% padding suplimentar
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
const allPositive = dataMin >= 0
const yMin = allPositive ? Math.max(0, calculatedMin - paddingAmount) : calculatedMin - paddingAmount
const yMax = calculatedMax + paddingAmount
inflowsChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: datasets.length > 1,
position: 'top',
align: 'end',
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
color: 'rgba(107, 114, 128, 0.9)',
usePointStyle: true,
pointStyle: 'line'
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || '',
label: (context) => {
const value = context.parsed.y
const label = context.dataset.label || ''
const formattedValue = new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
return `${label}: ${formattedValue}`
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false
},
ticks: {
color: 'rgba(107, 114, 128, 0.7)',
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6
},
border: {
display: false
}
},
y: {
display: true,
min: yMin,
max: yMax,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawBorder: false
},
ticks: {
color: '#10b981',
font: {
size: 11,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxTicksLimit: 3,
callback: function(value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M'
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + 'k'
}
return value.toFixed(0)
}
},
border: {
display: false
}
}
}
}
})
}
// Initialize Plăți chart
const initializeOutflowsChart = async () => {
if (!outflowsCanvas.value || props.outflowsSparkline.length === 0) {
return
}
// Destroy existing chart
if (outflowsChartInstance) {
outflowsChartInstance.destroy()
outflowsChartInstance = null
}
await nextTick()
const ctx = outflowsCanvas.value.getContext('2d')
// Generate labels
const labels = props.sparklineLabels.length > 0
? props.sparklineLabels
: props.outflowsSparkline.map((_, i) => `L${i + 1}`)
// Prepare datasets
const datasets = [{
label: 'Plăți (curent)',
data: props.outflowsSparkline,
borderColor: '#ef4444',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: '#ef4444',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
}]
// Add previous year dataset if available
if (props.outflowsPreviousSparkline && props.outflowsPreviousSparkline.length > 0) {
datasets.push({
label: 'Plăți (anul precedent)',
data: props.outflowsPreviousSparkline,
borderColor: 'rgba(239, 68, 68, 0.4)',
backgroundColor: 'rgba(239, 68, 68, 0.05)',
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: 'rgba(239, 68, 68, 0.4)',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
})
}
// Calculate limits including both datasets
const allDataPoints = [...props.outflowsSparkline]
if (props.outflowsPreviousSparkline && props.outflowsPreviousSparkline.length > 0) {
allDataPoints.push(...props.outflowsPreviousSparkline)
}
const dataMin = Math.min(...allDataPoints)
const dataMax = Math.max(...allDataPoints)
const dataRange = dataMax - dataMin
const dataMean = allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
const minVisibleRange = dataMean * 0.25 // 25% din medie = range minim vizibil
const center = (dataMin + dataMax) / 2
const targetRange = Math.max(dataRange, minVisibleRange)
// Calculează limite simetric față de centru
let calculatedMin = center - targetRange / 2
let calculatedMax = center + targetRange / 2
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
const paddingAmount = targetRange * 0.10 // 10% padding suplimentar
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
const allPositive = dataMin >= 0
const yMin = allPositive ? Math.max(0, calculatedMin - paddingAmount) : calculatedMin - paddingAmount
const yMax = calculatedMax + paddingAmount
outflowsChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: datasets.length > 1,
position: 'top',
align: 'end',
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
color: 'rgba(107, 114, 128, 0.9)',
usePointStyle: true,
pointStyle: 'line'
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || '',
label: (context) => {
const value = context.parsed.y
const label = context.dataset.label || ''
const formattedValue = new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
return `${label}: ${formattedValue}`
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false
},
ticks: {
color: 'rgba(107, 114, 128, 0.7)',
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6
},
border: {
display: false
}
},
y: {
display: true,
min: yMin,
max: yMax,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawBorder: false
},
ticks: {
color: '#ef4444',
font: {
size: 11,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxTicksLimit: 3,
callback: function(value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M'
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + 'k'
}
return value.toFixed(0)
}
},
border: {
display: false
}
}
}
}
})
}
// Watch for data changes
watch(() => [
props.inflowsSparkline,
props.outflowsSparkline,
props.sparklineLabels,
props.inflowsPreviousSparkline,
props.outflowsPreviousSparkline,
props.previousSparklineLabels
], async () => {
await Promise.all([
initializeInflowsChart(),
initializeOutflowsChart()
])
}, { deep: true })
// Lifecycle hooks
onMounted(async () => {
await Promise.all([
initializeInflowsChart(),
initializeOutflowsChart()
])
})
onBeforeUnmount(() => {
if (inflowsChartInstance) {
inflowsChartInstance.destroy()
inflowsChartInstance = null
}
if (outflowsChartInstance) {
outflowsChartInstance.destroy()
outflowsChartInstance = null
}
})
</script>
<style scoped>
/* === TYPOGRAPHY TOKENS === */
:root {
--card-label-size: 0.875rem;
--card-value-size: 1.5rem;
--card-trend-size: 0.75rem;
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.cashflow-card {
background: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--card-radius, 8px);
padding: var(--space-lg, 1.5rem);
transition: all 0.3s ease;
position: relative;
min-height: 420px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.cashflow-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: var(--color-primary, #3b82f6);
}
/* Values section - Split layout */
.values-section {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 1rem;
align-items: start;
}
.value-block {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.value-label {
font-size: var(--card-label-size);
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: var(--font-sans);
}
.value-amount {
font-size: var(--card-value-size);
font-weight: 700;
line-height: 1.2;
font-family: var(--font-mono);
}
.value-amount.positive {
color: var(--color-success, #10b981);
}
.value-amount.negative {
color: var(--color-danger, #ef4444);
}
.value-amount.neutral {
color: var(--color-text, #111827);
}
.divider {
width: 1px;
height: 100%;
background: var(--color-border, #e5e7eb);
min-height: 60px;
}
/* Dual sparkline container - stack vertical */
.sparkline-dual-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0.5rem 0;
}
.sparkline-wrapper {
width: 100%;
background: var(--color-bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.5rem;
}
.sparkline-label {
font-size: var(--card-label-size);
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: var(--font-sans);
}
/* Culori distinctive pentru label-uri */
.sparkline-wrapper:first-child .sparkline-label {
color: #10b981; /* Verde pentru Încasări */
}
.sparkline-wrapper:last-child .sparkline-label {
color: #ef4444; /* Roșu pentru Plăți */
}
.sparkline-chart {
width: 100%;
height: 150px;
position: relative;
}
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Responsive */
@media (max-width: 768px) {
.cashflow-card {
min-height: 380px;
padding: var(--space-md, 1rem);
}
.values-section {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.divider {
width: 100%;
height: 1px;
min-height: 1px;
}
.sparkline-chart {
height: 130px;
}
}
@media (max-width: 480px) {
.cashflow-card {
min-height: 380px;
padding: 0.5rem 0.25rem;
gap: 0.5rem;
}
.sparkline-chart {
height: 150px; /* Minim 150px pentru a afișa 3 ticks pe axa Y */
}
.sparkline-wrapper {
padding: 0.25rem;
border: 1px solid var(--color-border, #e5e7eb);
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.cashflow-card {
--color-bg: #1f2937;
--color-bg-secondary: #374151;
--color-border: #4b5563;
--color-text: #f9fafb;
--color-text-secondary: #d1d5db;
}
}
</style>

View File

@@ -0,0 +1,625 @@
<template>
<div class="clienti-balance-card">
<!-- Main value section - NO HEADER -->
<div class="value-section">
<div class="value-label">Clienți</div>
<div class="value-amount" :class="getBalanceClass(total)">
{{ formatCurrency(total) }}
</div>
</div>
<div class="value-trend" :class="getTrendClass(trend)" v-if="trend">
<span class="trend-icon">{{ getTrendIcon(trend) }}</span>
<span class="trend-value">{{ Math.round(Math.abs(trend.value)) }}%</span>
</div>
<!-- Sparkline chart -->
<div class="sparkline-container" v-if="hasSparklineData">
<div class="sparkline-chart">
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
<!-- Breakdown section -->
<div class="breakdown-section" v-if="breakdown">
<!-- În termen -->
<div class="breakdown-item">
<span class="breakdown-label">În termen</span>
<span class="breakdown-value">{{ formatCurrency(breakdown.in_termen?.total || 0) }}</span>
</div>
<!-- Restant cu sub-perioade -->
<div class="breakdown-group">
<div class="breakdown-header" @click="toggleRestantExpanded">
<div class="breakdown-header-left">
<span class="collapse-icon">{{ isRestantExpanded ? '▼' : '▶' }}</span>
<span class="breakdown-label">Restant</span>
</div>
<span class="breakdown-value">{{ formatCurrency(breakdown.restant?.total || 0) }}</span>
</div>
<!-- Perioade restante -->
<div v-show="isRestantExpanded" class="breakdown-subitems">
<div class="breakdown-subitem" v-for="(value, key) in breakdown.restant?.perioade" :key="key">
<span class="breakdown-sublabel">{{ formatPeriodLabel(key) }}</span>
<span class="breakdown-subvalue">{{ formatCurrency(value) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Chart, registerables } from 'chart.js'
Chart.register(...registerables)
const props = defineProps({
total: {
type: Number,
required: true
},
trend: {
type: Object,
default: null
},
sparklineData: {
type: Array,
default: () => []
},
previousSparklineData: {
type: Array,
default: () => []
},
sparklineLabels: {
type: Array,
default: () => []
},
previousSparklineLabels: {
type: Array,
default: () => []
},
breakdown: {
type: Object,
default: null
}
})
// Refs
const chartCanvas = ref(null)
let chartInstance = null
const isRestantExpanded = ref(false)
// Toggle functions
const toggleRestantExpanded = () => {
isRestantExpanded.value = !isRestantExpanded.value
}
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return '0 RON'
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(Math.abs(amount))
}
// Format period label
const formatPeriodLabel = (key) => {
const labelMap = {
'7_zile': '7 zile',
'14_zile': '14 zile',
'30_zile': '30 zile',
'60_zile': '60 zile',
'90_zile': '90 zile',
'peste_90_zile': 'Peste 90 zile'
}
return labelMap[key] || key
}
// Balance class
const getBalanceClass = (amount) => {
if (!amount && amount !== 0) return 'neutral'
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
return numAmount > 0 ? 'positive' : numAmount < 0 ? 'negative' : 'neutral'
}
// Trend class
const getTrendClass = (trend) => {
if (!trend) return ''
return {
'trend-up': trend.direction === 'up',
'trend-down': trend.direction === 'down',
'trend-neutral': trend.direction === 'neutral'
}
}
// Trend icon
const getTrendIcon = (trend) => {
if (!trend) return ''
switch (trend.direction) {
case 'up': return '▲'
case 'down': return '▼'
case 'neutral': return '▶'
default: return ''
}
}
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return props.sparklineData && props.sparklineData.length > 0
})
// Initialize chart
const initializeChart = async () => {
if (!chartCanvas.value || !hasSparklineData.value) {
return
}
// Destroy existing chart
if (chartInstance) {
chartInstance.destroy()
chartInstance = null
}
await nextTick()
const ctx = chartCanvas.value.getContext('2d')
// Generate labels
const labels = props.sparklineLabels.length > 0
? props.sparklineLabels
: props.sparklineData.map((_, i) => `L${i + 1}`)
// Calculate limits including both datasets
const allDataPoints = [...props.sparklineData]
if (props.previousSparklineData && props.previousSparklineData.length > 0) {
allDataPoints.push(...props.previousSparklineData)
}
const dataMin = Math.min(...allDataPoints)
const dataMax = Math.max(...allDataPoints)
const dataRange = dataMax - dataMin
const dataMean = allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
const minVisibleRange = dataMean * 0.25 // 25% din medie = range minim vizibil
const center = (dataMin + dataMax) / 2
const targetRange = Math.max(dataRange, minVisibleRange)
// Calculează limite simetric față de centru
let calculatedMin = center - targetRange / 2
let calculatedMax = center + targetRange / 2
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
const paddingAmount = targetRange * 0.10 // 10% padding suplimentar
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
const allPositive = dataMin >= 0
const yMin = allPositive ? Math.max(0, calculatedMin - paddingAmount) : calculatedMin - paddingAmount
const yMax = calculatedMax + paddingAmount
// Prepare datasets
const datasets = [{
label: 'Clienți (curent)',
data: props.sparklineData,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: '#10b981',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
}]
// Add previous year dataset if available
if (props.previousSparklineData && props.previousSparklineData.length > 0) {
datasets.push({
label: 'Clienți (anul precedent)',
data: props.previousSparklineData,
borderColor: 'rgba(16, 185, 129, 0.4)',
backgroundColor: 'rgba(16, 185, 129, 0.05)',
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: 'rgba(16, 185, 129, 0.6)',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
})
}
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: true,
position: 'top',
align: 'end',
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
color: 'rgba(107, 114, 128, 0.8)',
usePointStyle: true
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || '',
label: (context) => {
const value = context.parsed.y
const label = context.dataset.label || ''
const formattedValue = new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
return `${label}: ${formattedValue}`
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false
},
ticks: {
color: 'rgba(107, 114, 128, 0.7)',
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6
},
border: {
display: false
}
},
y: {
display: true,
min: yMin,
max: yMax,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawBorder: false
},
ticks: {
color: '#10b981',
font: {
size: 11,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxTicksLimit: 3,
callback: function(value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M'
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + 'k'
}
return value.toFixed(0)
}
},
border: {
display: false
}
}
}
}
})
}
// Watch for data changes
watch(() => [props.sparklineData, props.previousSparklineData, props.sparklineLabels, props.previousSparklineLabels], async () => {
await initializeChart()
}, { deep: true })
// Lifecycle hooks
onMounted(async () => {
await initializeChart()
})
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.destroy()
chartInstance = null
}
})
</script>
<style scoped>
/* === TYPOGRAPHY TOKENS === */
:root {
--card-label-size: 0.875rem;
--card-value-size: 1.5rem;
--card-trend-size: 0.75rem;
--breakdown-label-size: 0.875rem;
--breakdown-value-size: 0.9375rem;
--breakdown-sub-label-size: 0.8125rem;
--breakdown-sub-value-size: 0.8125rem;
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.clienti-balance-card {
background: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--card-radius, 8px);
padding: var(--space-lg, 1.5rem);
transition: all 0.3s ease;
position: relative;
min-height: 320px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.clienti-balance-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: var(--color-primary, #3b82f6);
}
/* Value section */
.value-section {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.value-label {
font-size: var(--card-label-size);
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: var(--font-sans);
}
.value-amount {
font-size: var(--card-value-size);
font-weight: 700;
line-height: 1.2;
font-family: var(--font-mono);
}
.value-amount.positive {
color: var(--color-success, #10b981);
}
.value-amount.negative {
color: var(--color-danger, #ef4444);
}
.value-amount.neutral {
color: var(--color-text, #111827);
}
.value-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: var(--card-trend-size);
font-weight: 500;
}
.trend-up {
color: var(--color-success, #10b981);
}
.trend-down {
color: var(--color-danger, #ef4444);
}
.trend-neutral {
color: var(--color-text-secondary, #6b7280);
}
.trend-icon {
font-size: 0.625rem;
}
/* Sparkline container */
.sparkline-container {
width: 100%;
background: var(--color-bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.5rem;
}
.sparkline-chart {
width: 100%;
height: 150px;
position: relative;
}
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Breakdown section */
.breakdown-section {
margin-top: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.breakdown-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0;
}
.breakdown-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.breakdown-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: 0.375rem 0.5rem;
border-radius: var(--radius-sm, 4px);
transition: all 0.2s ease;
}
.breakdown-header:hover {
background: var(--color-bg-secondary, #f8fafc);
}
.breakdown-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-icon {
font-size: 0.625rem;
color: var(--color-text-secondary, #6b7280);
transition: transform 0.2s ease;
display: inline-block;
width: 0.875rem;
}
.breakdown-label {
font-size: var(--breakdown-label-size);
color: var(--color-text, #111827);
font-weight: 500;
font-family: var(--font-sans);
}
.breakdown-value {
font-size: var(--breakdown-value-size);
font-weight: 600;
font-family: var(--font-mono);
color: var(--color-text, #111827);
}
.breakdown-subitems {
padding-left: 0;
animation: slideDown 0.2s ease-out;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.breakdown-subitem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.5rem 0.25rem 1.5rem;
}
.breakdown-sublabel {
color: var(--color-text-secondary, #6b7280);
font-size: var(--breakdown-sub-label-size);
font-weight: 400;
font-family: var(--font-sans);
}
.breakdown-subvalue {
font-weight: 500;
font-family: var(--font-mono);
font-size: var(--breakdown-sub-value-size);
color: var(--color-text, #111827);
}
/* Responsive */
@media (max-width: 768px) {
.clienti-balance-card {
min-height: 280px;
padding: var(--space-md, 1rem);
}
.sparkline-chart {
height: 130px;
}
}
@media (max-width: 480px) {
.clienti-balance-card {
min-height: 280px;
padding: 0.5rem 0.25rem;
gap: 0.5rem;
}
.sparkline-chart {
height: 150px; /* Minim 150px pentru a afișa 3 ticks pe axa Y */
}
.sparkline-container {
padding: 0.25rem;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.clienti-balance-card {
--color-bg: #1f2937;
--color-bg-secondary: #374151;
--color-border: #4b5563;
--color-text: #f9fafb;
--color-text-secondary: #d1d5db;
}
}
</style>

View File

@@ -0,0 +1,969 @@
<template>
<div class="balance-dual-card">
<!-- Header -->
<div class="card-header">
<span class="card-icon">💰</span>
<span class="card-title">SOLD CLIENȚI / FURNIZORI</span>
</div>
<!-- Main values section - Split layout -->
<div class="values-section">
<!-- Clienți Section -->
<div class="value-block clienti">
<div class="value-label">Clienți</div>
<div class="value-amount" :class="getBalanceClass(clientiTotal)">
{{ formatCurrency(clientiTotal) }}
</div>
<div class="value-trend" :class="getTrendClass(clientiTrend)" v-if="clientiTrend">
<span class="trend-icon">{{ getTrendIcon(clientiTrend) }}</span>
<span class="trend-value">{{ Math.round(Math.abs(clientiTrend.value)) }}%</span>
</div>
</div>
<!-- Divider -->
<div class="divider"></div>
<!-- Furnizori Section -->
<div class="value-block furnizori">
<div class="value-label">Furnizori</div>
<div class="value-amount" :class="getBalanceClass(furnizoriTotal)">
{{ formatCurrency(furnizoriTotal) }}
</div>
<div class="value-trend" :class="getTrendClass(furnizoriTrend)" v-if="furnizoriTrend">
<span class="trend-icon">{{ getTrendIcon(furnizoriTrend) }}</span>
<span class="trend-value">{{ Math.round(Math.abs(furnizoriTrend.value)) }}%</span>
</div>
</div>
</div>
<!-- Dual sparkline charts - stacked vertical -->
<div class="sparkline-dual-container" v-if="hasSparklineData">
<!-- Grafic Clienți -->
<div class="sparkline-wrapper">
<div class="sparkline-label">Clienți</div>
<div class="sparkline-chart">
<canvas ref="clientiCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
<!-- Grafic Furnizori -->
<div class="sparkline-wrapper">
<div class="sparkline-label">Furnizori</div>
<div class="sparkline-chart">
<canvas ref="furnizoriCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
</div>
<!-- Breakdown section -->
<div class="breakdown-section" v-if="breakdown">
<!-- Clienți Breakdown -->
<div class="breakdown-group">
<div class="breakdown-header" @click="toggleClientiExpanded">
<div class="breakdown-header-left">
<span class="collapse-icon">{{ isClientiExpanded ? '▼' : '▶' }}</span>
<span class="breakdown-label">Clienți - Detaliere</span>
</div>
<span class="breakdown-value">{{ formatCurrency(breakdown.clienti.total) }}</span>
</div>
<!-- Clienți Sub-items -->
<div v-show="isClientiExpanded" class="breakdown-subitems">
<!-- În termen -->
<div class="breakdown-subitem">
<span class="breakdown-sublabel">În termen</span>
<span class="breakdown-subvalue">{{ formatCurrency(breakdown.clienti.in_termen.total) }}</span>
</div>
<!-- Restant cu sub-perioade -->
<div class="breakdown-subitem-group">
<div class="breakdown-subitem-header" @click="toggleClientiRestantExpanded">
<div class="subitem-header-left">
<span class="collapse-icon-small">{{ isClientiRestantExpanded ? '▼' : '▶' }}</span>
<span class="breakdown-sublabel">Restant</span>
</div>
<span class="breakdown-subvalue">{{ formatCurrency(breakdown.clienti.restant.total) }}</span>
</div>
<!-- Perioade restante -->
<div v-show="isClientiRestantExpanded" class="breakdown-perioade">
<div class="perioada-item" v-for="(value, key) in breakdown.clienti.restant.perioade" :key="key">
<span class="perioada-label">{{ formatPeriodLabel(key) }}</span>
<span class="perioada-value">{{ formatCurrency(value) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Furnizori Breakdown -->
<div class="breakdown-group">
<div class="breakdown-header" @click="toggleFurnizoriExpanded">
<div class="breakdown-header-left">
<span class="collapse-icon">{{ isFurnizoriExpanded ? '▼' : '▶' }}</span>
<span class="breakdown-label">Furnizori - Detaliere</span>
</div>
<span class="breakdown-value">{{ formatCurrency(breakdown.furnizori.total) }}</span>
</div>
<!-- Furnizori Sub-items -->
<div v-show="isFurnizoriExpanded" class="breakdown-subitems">
<!-- În termen -->
<div class="breakdown-subitem">
<span class="breakdown-sublabel">În termen</span>
<span class="breakdown-subvalue">{{ formatCurrency(breakdown.furnizori.in_termen.total) }}</span>
</div>
<!-- Restant cu sub-perioade -->
<div class="breakdown-subitem-group">
<div class="breakdown-subitem-header" @click="toggleFurnizoriRestantExpanded">
<div class="subitem-header-left">
<span class="collapse-icon-small">{{ isFurnizoriRestantExpanded ? '▼' : '▶' }}</span>
<span class="breakdown-sublabel">Restant</span>
</div>
<span class="breakdown-subvalue">{{ formatCurrency(breakdown.furnizori.restant.total) }}</span>
</div>
<!-- Perioade restante -->
<div v-show="isFurnizoriRestantExpanded" class="breakdown-perioade">
<div class="perioada-item" v-for="(value, key) in breakdown.furnizori.restant.perioade" :key="key">
<span class="perioada-label">{{ formatPeriodLabel(key) }}</span>
<span class="perioada-value">{{ formatCurrency(value) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Chart, registerables } from 'chart.js'
Chart.register(...registerables)
const props = defineProps({
clientiTotal: {
type: Number,
required: true
},
furnizoriTotal: {
type: Number,
required: true
},
clientiTrend: {
type: Object,
default: null
},
furnizoriTrend: {
type: Object,
default: null
},
clientiSparklineData: {
type: Array,
default: () => []
},
furnizoriSparklineData: {
type: Array,
default: () => []
},
sparklineLabels: {
type: Array,
default: () => []
},
breakdown: {
type: Object,
default: null
}
})
// Refs pentru 2 canvas-uri separate
const clientiCanvas = ref(null)
const furnizoriCanvas = ref(null)
let clientiChartInstance = null
let furnizoriChartInstance = null
const isClientiExpanded = ref(false)
const isFurnizoriExpanded = ref(false)
const isClientiRestantExpanded = ref(false)
const isFurnizoriRestantExpanded = ref(false)
// Toggle functions
const toggleClientiExpanded = () => {
isClientiExpanded.value = !isClientiExpanded.value
}
const toggleFurnizoriExpanded = () => {
isFurnizoriExpanded.value = !isFurnizoriExpanded.value
}
const toggleClientiRestantExpanded = () => {
isClientiRestantExpanded.value = !isClientiRestantExpanded.value
}
const toggleFurnizoriRestantExpanded = () => {
isFurnizoriRestantExpanded.value = !isFurnizoriRestantExpanded.value
}
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return '0 RON'
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(Math.abs(amount))
}
// Format period label
const formatPeriodLabel = (key) => {
const labelMap = {
'7_zile': '7 zile',
'14_zile': '14 zile',
'30_zile': '30 zile',
'60_zile': '60 zile',
'90_zile': '90 zile',
'peste_90_zile': 'Peste 90 zile'
}
return labelMap[key] || key
}
// Balance class
const getBalanceClass = (amount) => {
if (!amount && amount !== 0) return 'neutral'
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
return numAmount > 0 ? 'positive' : numAmount < 0 ? 'negative' : 'neutral'
}
// Trend class
const getTrendClass = (trend) => {
if (!trend) return ''
return {
'trend-up': trend.direction === 'up',
'trend-down': trend.direction === 'down',
'trend-neutral': trend.direction === 'neutral'
}
}
// Trend icon
const getTrendIcon = (trend) => {
if (!trend) return ''
switch (trend.direction) {
case 'up': return '▲'
case 'down': return '▼'
case 'neutral': return '▶'
default: return ''
}
}
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return props.clientiSparklineData.length > 0 && props.furnizoriSparklineData.length > 0
})
// Initialize Clienți chart
const initializeClientiChart = async () => {
if (!clientiCanvas.value || props.clientiSparklineData.length === 0) {
return
}
// Destroy existing chart
if (clientiChartInstance) {
clientiChartInstance.destroy()
clientiChartInstance = null
}
await nextTick()
const ctx = clientiCanvas.value.getContext('2d')
// Generate labels
const labels = props.sparklineLabels.length > 0
? props.sparklineLabels
: props.clientiSparklineData.map((_, i) => `L${i + 1}`)
// Calculează limite pentru clienți
const clientiMin = Math.min(...props.clientiSparklineData)
const clientiMax = Math.max(...props.clientiSparklineData)
const clientiRange = clientiMax - clientiMin
const clientiPadding = clientiRange * 0.05
clientiChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Clienți',
data: props.clientiSparklineData,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0, // Ascunde punctele pentru a economisi spațiu
pointHoverRadius: 4,
pointHoverBackgroundColor: '#10b981',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: false // Ascunde legenda - e clar din label că e "Clienți"
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
cornerRadius: 6,
displayColors: false,
callbacks: {
title: (context) => context[0].label || '',
label: (context) => {
const value = context.parsed.y
const formatted = new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
return formatted
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false
},
ticks: {
color: 'rgba(107, 114, 128, 0.7)',
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6
},
border: {
display: false
}
},
y: {
display: true,
min: clientiMin - clientiPadding,
max: clientiMax + clientiPadding,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawBorder: false
},
ticks: {
color: '#10b981',
font: {
size: 11,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxTicksLimit: 3,
callback: function(value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M'
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + 'k'
}
return value.toFixed(0)
}
},
border: {
display: false
}
}
}
}
})
}
// Initialize Furnizori chart
const initializeFurnizoriChart = async () => {
if (!furnizoriCanvas.value || props.furnizoriSparklineData.length === 0) {
return
}
// Destroy existing chart
if (furnizoriChartInstance) {
furnizoriChartInstance.destroy()
furnizoriChartInstance = null
}
await nextTick()
const ctx = furnizoriCanvas.value.getContext('2d')
// Generate labels
const labels = props.sparklineLabels.length > 0
? props.sparklineLabels
: props.furnizoriSparklineData.map((_, i) => `L${i + 1}`)
// Calculează limite pentru furnizori
const furnizoriMin = Math.min(...props.furnizoriSparklineData)
const furnizoriMax = Math.max(...props.furnizoriSparklineData)
const furnizoriRange = furnizoriMax - furnizoriMin
const furnizoriPadding = furnizoriRange * 0.05
furnizoriChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Furnizori',
data: props.furnizoriSparklineData,
borderColor: '#ef4444',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: '#ef4444',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
cornerRadius: 6,
displayColors: false,
callbacks: {
title: (context) => context[0].label || '',
label: (context) => {
const value = context.parsed.y
const formatted = new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
return formatted
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false
},
ticks: {
color: 'rgba(107, 114, 128, 0.7)',
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6
},
border: {
display: false
}
},
y: {
display: true,
min: furnizoriMin - furnizoriPadding,
max: furnizoriMax + furnizoriPadding,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawBorder: false
},
ticks: {
color: '#ef4444',
font: {
size: 11,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxTicksLimit: 3,
callback: function(value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M'
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + 'k'
}
return value.toFixed(0)
}
},
border: {
display: false
}
}
}
}
})
}
// Watch for data changes
watch(() => [props.clientiSparklineData, props.furnizoriSparklineData, props.sparklineLabels], async () => {
await Promise.all([
initializeClientiChart(),
initializeFurnizoriChart()
])
}, { deep: true })
// Lifecycle hooks
onMounted(async () => {
await Promise.all([
initializeClientiChart(),
initializeFurnizoriChart()
])
})
onBeforeUnmount(() => {
if (clientiChartInstance) {
clientiChartInstance.destroy()
clientiChartInstance = null
}
if (furnizoriChartInstance) {
furnizoriChartInstance.destroy()
furnizoriChartInstance = null
}
})
</script>
<style scoped>
.balance-dual-card {
background: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--card-radius, 8px);
padding: var(--space-lg, 1.5rem);
transition: all 0.3s ease;
position: relative;
min-height: 420px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.balance-dual-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: var(--color-primary, #3b82f6);
}
/* Header */
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.card-icon {
font-size: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: var(--color-bg-secondary, #f8fafc);
border-radius: var(--radius-sm, 4px);
}
.card-title {
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-semibold, 600);
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Values section - Split layout */
.values-section {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 1rem;
align-items: start;
}
.value-block {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.value-label {
font-size: var(--text-xs, 0.75rem);
font-weight: var(--font-medium, 500);
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.value-amount {
font-size: var(--text-xl, 1.5rem);
font-weight: var(--font-bold, 700);
line-height: 1.2;
}
.value-amount.positive {
color: var(--color-success, #10b981);
}
.value-amount.negative {
color: var(--color-danger, #ef4444);
}
.value-amount.neutral {
color: var(--color-text, #111827);
}
.value-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-medium, 500);
}
.trend-up {
color: var(--color-success, #10b981);
}
.trend-down {
color: var(--color-danger, #ef4444);
}
.trend-neutral {
color: var(--color-text-secondary, #6b7280);
}
.trend-icon {
font-size: 0.75rem;
}
.divider {
width: 1px;
height: 100%;
background: var(--color-border, #e5e7eb);
min-height: 60px;
}
/* Dual sparkline container - stack vertical */
.sparkline-dual-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0.5rem 0;
}
.sparkline-wrapper {
width: 100%;
background: var(--color-bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.5rem;
}
.sparkline-label {
font-size: var(--text-xs, 0.75rem);
font-weight: var(--font-semibold, 600);
color: var(--color-text-secondary, #6b7280);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Culori distinctive pentru label-uri */
.sparkline-wrapper:first-child .sparkline-label {
color: #10b981; /* Verde pentru Clienți */
}
.sparkline-wrapper:last-child .sparkline-label {
color: #ef4444; /* Roșu pentru Furnizori */
}
.sparkline-chart {
width: 100%;
height: 120px; /* Înălțime mărită pentru fiecare grafic individual */
position: relative;
}
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Breakdown section */
.breakdown-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.breakdown-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.breakdown-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: 0.5rem;
border-radius: var(--radius-sm, 4px);
transition: all 0.2s ease;
font-weight: var(--font-semibold, 600);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.breakdown-header:hover {
background: var(--color-bg-secondary, #f8fafc);
}
.breakdown-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-icon {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
transition: transform 0.2s ease;
display: inline-block;
width: 1rem;
}
.breakdown-label {
font-size: 0.9rem;
color: var(--color-text, #111827);
}
.breakdown-value {
font-size: 1rem;
font-weight: var(--font-semibold, 600);
font-family: monospace;
color: var(--color-text, #111827);
}
.breakdown-subitems {
padding-left: 0;
animation: slideDown 0.2s ease-out;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.breakdown-subitem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0.5rem;
}
.breakdown-subitem-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.breakdown-subitem-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: 0.375rem 0.5rem;
border-radius: var(--radius-sm, 4px);
transition: all 0.2s ease;
}
.breakdown-subitem-header:hover {
background: var(--color-bg-secondary, #f8fafc);
}
.subitem-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-icon-small {
font-size: 0.65rem;
color: var(--color-text-secondary, #6b7280);
transition: transform 0.2s ease;
display: inline-block;
width: 0.75rem;
}
.breakdown-sublabel {
color: var(--color-text-secondary, #6b7280);
font-size: 0.9rem;
}
.breakdown-subvalue {
font-weight: var(--font-medium, 500);
font-family: monospace;
font-size: 1rem;
color: var(--color-text, #111827);
}
.breakdown-perioade {
padding-left: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
animation: slideDown 0.2s ease-out;
}
.perioada-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
}
.perioada-label {
color: var(--color-text-secondary, #6b7280);
font-size: 0.9rem;
}
.perioada-value {
font-family: monospace;
font-weight: var(--font-medium, 500);
color: var(--color-text, #111827);
font-size: 1rem;
}
/* Responsive */
@media (max-width: 768px) {
.balance-dual-card {
min-height: 380px;
padding: var(--space-md, 1rem);
}
.values-section {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.divider {
width: 100%;
height: 1px;
min-height: 1px;
}
.value-amount {
font-size: var(--text-lg, 1.125rem);
}
.sparkline-chart {
height: 100px;
}
}
@media (max-width: 480px) {
.balance-dual-card {
min-height: 340px;
padding: 0.5rem 0.25rem;
gap: 0.5rem;
border-radius: 0;
}
.card-header {
padding: 0.25rem;
margin-bottom: 0.25rem;
}
.card-icon {
width: 1.25rem;
height: 1.25rem;
font-size: 0.875rem;
}
.card-title {
font-size: 0.625rem;
}
.value-amount {
font-size: var(--text-base, 1rem);
}
.sparkline-chart {
height: 120px;
}
.sparkline-wrapper {
padding: 0;
border: none;
}
.values-section {
gap: 0.5rem;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.balance-dual-card {
--color-bg: #1f2937;
--color-bg-secondary: #374151;
--color-border: #4b5563;
--color-text: #f9fafb;
--color-text-secondary: #d1d5db;
}
}
</style>

View File

@@ -0,0 +1,625 @@
<template>
<div class="furnizori-balance-card">
<!-- Main value section - NO HEADER -->
<div class="value-section">
<div class="value-label">Furnizori</div>
<div class="value-amount" :class="getBalanceClass(total)">
{{ formatCurrency(total) }}
</div>
</div>
<div class="value-trend" :class="getTrendClass(trend)" v-if="trend">
<span class="trend-icon">{{ getTrendIcon(trend) }}</span>
<span class="trend-value">{{ Math.round(Math.abs(trend.value)) }}%</span>
</div>
<!-- Sparkline chart -->
<div class="sparkline-container" v-if="hasSparklineData">
<div class="sparkline-chart">
<canvas ref="chartCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
<!-- Breakdown section -->
<div class="breakdown-section" v-if="breakdown">
<!-- În termen -->
<div class="breakdown-item">
<span class="breakdown-label">În termen</span>
<span class="breakdown-value">{{ formatCurrency(breakdown.in_termen?.total || 0) }}</span>
</div>
<!-- Restant cu sub-perioade -->
<div class="breakdown-group">
<div class="breakdown-header" @click="toggleRestantExpanded">
<div class="breakdown-header-left">
<span class="collapse-icon">{{ isRestantExpanded ? '▼' : '▶' }}</span>
<span class="breakdown-label">Restant</span>
</div>
<span class="breakdown-value">{{ formatCurrency(breakdown.restant?.total || 0) }}</span>
</div>
<!-- Perioade restante -->
<div v-show="isRestantExpanded" class="breakdown-subitems">
<div class="breakdown-subitem" v-for="(value, key) in breakdown.restant?.perioade" :key="key">
<span class="breakdown-sublabel">{{ formatPeriodLabel(key) }}</span>
<span class="breakdown-subvalue">{{ formatCurrency(value) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Chart, registerables } from 'chart.js'
Chart.register(...registerables)
const props = defineProps({
total: {
type: Number,
required: true
},
trend: {
type: Object,
default: null
},
sparklineData: {
type: Array,
default: () => []
},
previousSparklineData: {
type: Array,
default: () => []
},
sparklineLabels: {
type: Array,
default: () => []
},
previousSparklineLabels: {
type: Array,
default: () => []
},
breakdown: {
type: Object,
default: null
}
})
// Refs
const chartCanvas = ref(null)
let chartInstance = null
const isRestantExpanded = ref(false)
// Toggle functions
const toggleRestantExpanded = () => {
isRestantExpanded.value = !isRestantExpanded.value
}
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return '0 RON'
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(Math.abs(amount))
}
// Format period label
const formatPeriodLabel = (key) => {
const labelMap = {
'7_zile': '7 zile',
'14_zile': '14 zile',
'30_zile': '30 zile',
'60_zile': '60 zile',
'90_zile': '90 zile',
'peste_90_zile': 'Peste 90 zile'
}
return labelMap[key] || key
}
// Balance class
const getBalanceClass = (amount) => {
if (!amount && amount !== 0) return 'neutral'
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
return numAmount > 0 ? 'positive' : numAmount < 0 ? 'negative' : 'neutral'
}
// Trend class
const getTrendClass = (trend) => {
if (!trend) return ''
return {
'trend-up': trend.direction === 'up',
'trend-down': trend.direction === 'down',
'trend-neutral': trend.direction === 'neutral'
}
}
// Trend icon
const getTrendIcon = (trend) => {
if (!trend) return ''
switch (trend.direction) {
case 'up': return '▲'
case 'down': return '▼'
case 'neutral': return '▶'
default: return ''
}
}
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return props.sparklineData && props.sparklineData.length > 0
})
// Initialize chart
const initializeChart = async () => {
if (!chartCanvas.value || !hasSparklineData.value) {
return
}
// Destroy existing chart
if (chartInstance) {
chartInstance.destroy()
chartInstance = null
}
await nextTick()
const ctx = chartCanvas.value.getContext('2d')
// Generate labels
const labels = props.sparklineLabels.length > 0
? props.sparklineLabels
: props.sparklineData.map((_, i) => `L${i + 1}`)
// Calculate limits including both datasets
const allDataPoints = [...props.sparklineData]
if (props.previousSparklineData && props.previousSparklineData.length > 0) {
allDataPoints.push(...props.previousSparklineData)
}
const dataMin = Math.min(...allDataPoints)
const dataMax = Math.max(...allDataPoints)
const dataRange = dataMax - dataMin
const dataMean = allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length
// CORECT: Forțăm range minim SIMETRIC, apoi adăugăm padding
const minVisibleRange = dataMean * 0.25 // 25% din medie = range minim vizibil
const center = (dataMin + dataMax) / 2
const targetRange = Math.max(dataRange, minVisibleRange)
// Calculează limite simetric față de centru
let calculatedMin = center - targetRange / 2
let calculatedMax = center + targetRange / 2
// Adaugă padding PESTE range-ul asigurat (nu înăuntru!)
const paddingAmount = targetRange * 0.10 // 10% padding suplimentar
// Aplică limitele finale (nu permite negative dacă toate datele sunt pozitive)
const allPositive = dataMin >= 0
const yMin = allPositive ? Math.max(0, calculatedMin - paddingAmount) : calculatedMin - paddingAmount
const yMax = calculatedMax + paddingAmount
// Prepare datasets
const datasets = [{
label: 'Furnizori (curent)',
data: props.sparklineData,
borderColor: '#ef4444',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: '#ef4444',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
}]
// Add previous year dataset if available
if (props.previousSparklineData && props.previousSparklineData.length > 0) {
datasets.push({
label: 'Furnizori (anul precedent)',
data: props.previousSparklineData,
borderColor: 'rgba(239, 68, 68, 0.4)',
backgroundColor: 'rgba(239, 68, 68, 0.05)',
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: 'rgba(239, 68, 68, 0.6)',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
})
}
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: true,
position: 'top',
align: 'end',
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
color: 'rgba(107, 114, 128, 0.8)',
usePointStyle: true
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || '',
label: (context) => {
const value = context.parsed.y
const label = context.dataset.label || ''
const formattedValue = new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
return `${label}: ${formattedValue}`
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false
},
ticks: {
color: 'rgba(107, 114, 128, 0.7)',
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6
},
border: {
display: false
}
},
y: {
display: true,
min: yMin,
max: yMax,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawBorder: false
},
ticks: {
color: '#ef4444',
font: {
size: 11,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxTicksLimit: 3,
callback: function(value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M'
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + 'k'
}
return value.toFixed(0)
}
},
border: {
display: false
}
}
}
}
})
}
// Watch for data changes
watch(() => [props.sparklineData, props.previousSparklineData, props.sparklineLabels, props.previousSparklineLabels], async () => {
await initializeChart()
}, { deep: true })
// Lifecycle hooks
onMounted(async () => {
await initializeChart()
})
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.destroy()
chartInstance = null
}
})
</script>
<style scoped>
/* === TYPOGRAPHY TOKENS === */
:root {
--card-label-size: 0.875rem;
--card-value-size: 1.5rem;
--card-trend-size: 0.75rem;
--breakdown-label-size: 0.875rem;
--breakdown-value-size: 0.9375rem;
--breakdown-sub-label-size: 0.8125rem;
--breakdown-sub-value-size: 0.8125rem;
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.furnizori-balance-card {
background: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--card-radius, 8px);
padding: var(--space-lg, 1.5rem);
transition: all 0.3s ease;
position: relative;
min-height: 320px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.furnizori-balance-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: var(--color-primary, #3b82f6);
}
/* Value section */
.value-section {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.value-label {
font-size: var(--card-label-size);
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: var(--font-sans);
}
.value-amount {
font-size: var(--card-value-size);
font-weight: 700;
line-height: 1.2;
font-family: var(--font-mono);
}
.value-amount.positive {
color: var(--color-success, #10b981);
}
.value-amount.negative {
color: var(--color-danger, #ef4444);
}
.value-amount.neutral {
color: var(--color-text, #111827);
}
.value-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: var(--card-trend-size);
font-weight: 500;
}
.trend-up {
color: var(--color-success, #10b981);
}
.trend-down {
color: var(--color-danger, #ef4444);
}
.trend-neutral {
color: var(--color-text-secondary, #6b7280);
}
.trend-icon {
font-size: 0.625rem;
}
/* Sparkline container */
.sparkline-container {
width: 100%;
background: var(--color-bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.5rem;
}
.sparkline-chart {
width: 100%;
height: 150px;
position: relative;
}
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Breakdown section */
.breakdown-section {
margin-top: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.breakdown-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0;
}
.breakdown-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.breakdown-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: 0.375rem 0.5rem;
border-radius: var(--radius-sm, 4px);
transition: all 0.2s ease;
}
.breakdown-header:hover {
background: var(--color-bg-secondary, #f8fafc);
}
.breakdown-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-icon {
font-size: 0.625rem;
color: var(--color-text-secondary, #6b7280);
transition: transform 0.2s ease;
display: inline-block;
width: 0.875rem;
}
.breakdown-label {
font-size: var(--breakdown-label-size);
color: var(--color-text, #111827);
font-weight: 500;
font-family: var(--font-sans);
}
.breakdown-value {
font-size: var(--breakdown-value-size);
font-weight: 600;
font-family: var(--font-mono);
color: var(--color-text, #111827);
}
.breakdown-subitems {
padding-left: 0;
animation: slideDown 0.2s ease-out;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.breakdown-subitem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.5rem 0.25rem 1.5rem;
}
.breakdown-sublabel {
color: var(--color-text-secondary, #6b7280);
font-size: var(--breakdown-sub-label-size);
font-weight: 400;
font-family: var(--font-sans);
}
.breakdown-subvalue {
font-weight: 500;
font-family: var(--font-mono);
font-size: var(--breakdown-sub-value-size);
color: var(--color-text, #111827);
}
/* Responsive */
@media (max-width: 768px) {
.furnizori-balance-card {
min-height: 280px;
padding: var(--space-md, 1rem);
}
.sparkline-chart {
height: 130px;
}
}
@media (max-width: 480px) {
.furnizori-balance-card {
min-height: 280px;
padding: 0.5rem 0.25rem;
gap: 0.5rem;
}
.sparkline-chart {
height: 150px; /* Minim 150px pentru a afișa 3 ticks pe axa Y */
}
.sparkline-container {
padding: 0.25rem;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.furnizori-balance-card {
--color-bg: #1f2937;
--color-bg-secondary: #374151;
--color-border: #4b5563;
--color-text: #f9fafb;
--color-text-secondary: #d1d5db;
}
}
</style>

View File

@@ -0,0 +1,775 @@
<template>
<div class="maturity-card">
<div class="card-header">
<h3>Analiză Comparativă Scadențe</h3>
<select
v-model="selectedPeriod"
@change="handlePeriodChange"
class="period-selector"
:disabled="isLoading"
>
<option value="7d">7 zile</option>
<option value="1m">1 lună</option>
<option value="3m">3 luni</option>
<option value="6m">6 luni</option>
<option value="12m">12 luni</option>
<option value="all">Toate</option>
</select>
</div>
<div v-if="isLoading" class="loading-state">
<div class="loading-spinner"></div>
<p>Se încarcă analiza scadențelor...</p>
</div>
<div v-else-if="error" class="error-state">
<div class="error-icon">!</div>
<p>{{ error }}</p>
<button @click="loadData" class="retry-btn">Încearcă din nou</button>
</div>
<div v-else class="maturity-comparison">
<!-- Clients Side -->
<div class="clients-side">
<h4 class="side-title clients-title">
Clienți - De încasat
<span class="total-amount">{{ formatCurrency(clientsTotal) }}</span>
</h4>
<div class="maturity-list">
<div
v-for="(client, index) in clientsData"
:key="`client-${index}`"
class="maturity-item"
:class="{ 'overdue': client.daysOverdue > 0, 'critical': client.daysOverdue > 30 }"
>
<div class="item-info">
<span class="client-name">{{ client.name }}</span>
<span class="due-info">
<span v-if="client.daysOverdue > 0" class="overdue-days">
Restant {{ client.daysOverdue }} zile
</span>
<span v-else class="due-date">
Scadent în {{ Math.abs(client.daysOverdue) }} zile
</span>
</span>
</div>
<div class="amount-bar">
<div class="bar-container">
<div
class="bar-fill clients-bar"
:style="{ width: getBarWidth(client.amount, maxClientAmount) + '%' }"
></div>
</div>
<span class="amount-value">{{ formatCurrency(client.amount) }}</span>
</div>
</div>
<div v-if="clientsData.length === 0" class="empty-state">
<p>Nu există facturi de încasat pentru această perioadă</p>
</div>
</div>
</div>
<!-- Divider -->
<div class="comparison-divider"></div>
<!-- Suppliers Side -->
<div class="suppliers-side">
<h4 class="side-title suppliers-title">
Furnizori - De plătit
<span class="total-amount">{{ formatCurrency(suppliersTotal) }}</span>
</h4>
<div class="maturity-list">
<div
v-for="(supplier, index) in suppliersData"
:key="`supplier-${index}`"
class="maturity-item"
:class="{ 'overdue': supplier.daysOverdue > 0, 'critical': supplier.daysOverdue > 30 }"
>
<div class="item-info">
<span class="supplier-name">{{ supplier.name }}</span>
<span class="due-info">
<span v-if="supplier.daysOverdue > 0" class="overdue-days">
Restant {{ supplier.daysOverdue }} zile
</span>
<span v-else class="due-date">
Scadent în {{ Math.abs(supplier.daysOverdue) }} zile
</span>
</span>
</div>
<div class="amount-bar">
<div class="bar-container">
<div
class="bar-fill suppliers-bar"
:style="{ width: getBarWidth(supplier.amount, maxSupplierAmount) + '%' }"
></div>
</div>
<span class="amount-value">{{ formatCurrency(supplier.amount) }}</span>
</div>
</div>
<div v-if="suppliersData.length === 0" class="empty-state">
<p>Nu există facturi de plătit pentru această perioadă</p>
</div>
</div>
</div>
</div>
<!-- Balance Indicator -->
<div v-if="!isLoading && !error" class="balance-indicator">
<div class="balance-content">
<div class="balance-text">
<span class="balance-label">{{ balanceLabel }}</span>
<span class="balance-amount" :class="balanceClass">
{{ formatCurrency(Math.abs(balance)) }}
</span>
</div>
<div v-if="recommendations.length > 0" class="recommendations">
<details>
<summary>Recomandări</summary>
<ul>
<li v-for="(rec, index) in recommendations" :key="index">
{{ rec }}
</li>
</ul>
</details>
</div>
</div>
</div>
<!-- Footer with period info -->
<div v-if="!isLoading && !error" class="card-footer">
<div class="period-info">
<span class="period-label">Perioada analizată:</span>
<span class="period-value">{{ getPeriodLabel(selectedPeriod) }}</span>
</div>
<div class="last-updated">
<span class="update-label">Actualizat:</span>
<span class="update-time">{{ formatLastUpdated(lastUpdated) }}</span>
<button @click="refreshData" class="refresh-btn" :disabled="isLoading" title="Reîmprospătează datele">
<i class="pi pi-refresh refresh-icon" :class="{ 'spinning': isLoading }"></i>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useDashboardStore } from '../../../stores/dashboard'
// Props
const props = defineProps({
companyId: {
type: [Number, String],
required: true
}
})
// Emits
const emit = defineEmits(['periodChanged'])
// Store
const dashboardStore = useDashboardStore()
// Reactive state
const selectedPeriod = ref('1m')
const isLoading = ref(false)
const error = ref(null)
const lastUpdated = ref(null)
// Mock data structure - in production this would come from API
const maturityData = ref({
clients: [],
suppliers: [],
balance: 0,
recommendations: []
})
// Romanian currency formatter
const formatCurrency = (value) => {
if (value === null || value === undefined) return '0,00 RON'
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
}
// Computed properties
const clientsData = computed(() => maturityData.value.clients || [])
const suppliersData = computed(() => maturityData.value.suppliers || [])
const recommendations = computed(() => maturityData.value.recommendations || [])
const clientsTotal = computed(() =>
clientsData.value.reduce((sum, client) => sum + (client.amount || 0), 0)
)
const suppliersTotal = computed(() =>
suppliersData.value.reduce((sum, supplier) => sum + (supplier.amount || 0), 0)
)
const balance = computed(() => clientsTotal.value - suppliersTotal.value)
const balanceClass = computed(() =>
balance.value < 0 ? 'deficit' : 'surplus'
)
const balanceIcon = computed(() =>
balance.value < 0 ? '📉' : '📈'
)
const balanceLabel = computed(() =>
balance.value < 0 ? 'Deficit estimat:' : 'Surplus estimat:'
)
const maxClientAmount = computed(() =>
Math.max(...clientsData.value.map(c => c.amount || 0), 1)
)
const maxSupplierAmount = computed(() =>
Math.max(...suppliersData.value.map(s => s.amount || 0), 1)
)
// Methods
const getBarWidth = (amount, maxAmount) => {
return maxAmount > 0 ? Math.min((amount / maxAmount) * 100, 100) : 0
}
const getPeriodLabel = (period) => {
const labels = {
'7d': 'Toate restanțele + următoarele 7 zile',
'1m': 'Toate restanțele + următoarea lună',
'3m': 'Toate restanțele + următoarele 3 luni',
'6m': 'Toate restanțele + următoarele 6 luni',
'12m': 'Toate restanțele + următorul an',
'all': 'Toate soldurile (fără filtru)'
}
return labels[period] || period
}
const formatLastUpdated = (timestamp) => {
if (!timestamp) return 'Necunoscut'
return new Date(timestamp).toLocaleString('ro-RO')
}
const handlePeriodChange = () => {
emit('periodChanged', selectedPeriod.value)
loadData()
}
const refreshData = () => {
loadData(true)
}
const loadData = async (forceRefresh = false) => {
if (!props.companyId) {
error.value = 'ID firmă necunoscut'
return
}
isLoading.value = true
error.value = null
try {
// Apelăm API-ul real pentru a obține datele de scadențe
const response = await dashboardStore.loadMaturityData(props.companyId, selectedPeriod.value)
if (response && response.success) {
maturityData.value = response.data
lastUpdated.value = new Date()
} else {
throw new Error(response?.error || 'Eroare la încărcarea datelor')
}
} catch (err) {
console.error('Failed to load maturity data:', err)
error.value = err.message || 'Eroare la încărcarea datelor. Vă rugăm încercați din nou.'
} finally {
isLoading.value = false
}
}
// Watchers
watch(() => props.companyId, (newCompanyId) => {
if (newCompanyId) {
loadData()
}
}, { immediate: false })
// Lifecycle
onMounted(() => {
if (props.companyId) {
loadData()
}
})
</script>
<style scoped>
/* Base Card Styles */
.maturity-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius, 8px);
padding: 0;
box-shadow: var(--shadow-sm);
transition: all var(--transition-fast, 0.3s ease);
overflow: hidden;
}
.maturity-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-primary);
}
/* Card Header */
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-lg, 1rem);
border-bottom: 1px solid var(--color-border);
background: transparent;
}
.card-header h3 {
margin: 0;
font-size: var(--text-lg, 1.125rem);
font-weight: var(--font-semibold, 600);
color: var(--color-text);
}
.period-selector {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm, 0.875rem);
cursor: pointer;
transition: border-color 0.2s ease;
}
.period-selector:hover {
border-color: var(--color-primary);
}
.period-selector:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Loading and Error States */
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl, 2rem);
text-align: center;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-border);
border-top: 3px solid var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: var(--space-md, 1rem);
}
.error-icon {
font-size: 2rem;
margin-bottom: var(--space-sm, 0.5rem);
}
.retry-btn {
margin-top: var(--space-md, 1rem);
padding: 0.5rem 1rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-sm, 4px);
cursor: pointer;
transition: background-color 0.2s ease;
}
.retry-btn:hover {
background: var(--color-primary-dark);
}
/* Comparison Layout */
.maturity-comparison {
display: grid;
grid-template-columns: 1fr 1px 1fr;
gap: var(--space-lg, 1rem);
padding: var(--space-lg, 1rem);
min-height: 300px;
}
.comparison-divider {
background: var(--color-border);
margin: var(--space-md, 1rem) 0;
}
/* Side Headers */
.side-title {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 var(--space-md, 1rem) 0;
font-size: var(--text-base, 1rem);
font-weight: var(--font-semibold, 600);
padding-bottom: var(--space-sm, 0.5rem);
border-bottom: 1px solid var(--color-border);
}
.clients-title {
color: var(--color-text);
}
.suppliers-title {
color: var(--color-text);
}
.total-amount {
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-bold, 700);
padding: 0.25rem 0.5rem;
background: var(--color-bg-secondary, #f8f9fa);
border-radius: var(--radius-sm, 4px);
}
/* Maturity Lists */
.maturity-list {
display: flex;
flex-direction: column;
gap: var(--space-sm, 0.5rem);
max-height: 250px;
overflow-y: auto;
padding-right: var(--space-xs, 0.25rem);
}
.maturity-list::-webkit-scrollbar {
width: 4px;
}
.maturity-list::-webkit-scrollbar-track {
background: var(--color-bg-secondary, #f8f9fa);
border-radius: 2px;
}
.maturity-list::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 2px;
}
/* Maturity Items */
.maturity-item {
padding: var(--space-sm, 0.5rem);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
transition: all 0.2s ease;
}
.maturity-item:hover {
background: var(--color-bg-secondary, #f8f9fa);
border-color: var(--color-primary);
}
.maturity-item.overdue {
border: 1px solid var(--color-border);
}
.maturity-item.critical {
border: 1px solid var(--color-border);
}
.item-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-xs, 0.25rem);
}
.client-name,
.supplier-name {
font-weight: var(--font-medium, 500);
color: var(--color-text);
font-size: var(--text-sm, 0.875rem);
}
.due-info {
font-size: var(--text-xs, 0.75rem);
color: var(--color-text-secondary);
}
.overdue-days {
color: var(--color-text);
font-weight: var(--font-medium, 500);
}
.due-date {
color: var(--color-text-secondary);
}
/* Amount Bars */
.amount-bar {
display: flex;
align-items: center;
gap: var(--space-sm, 0.5rem);
}
.bar-container {
flex: 1;
height: 8px;
background: var(--color-bg-secondary, #f8f9fa);
border-radius: 4px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.clients-bar {
background: var(--color-primary);
}
.suppliers-bar {
background: var(--color-secondary, #6b7280);
}
.amount-value {
font-size: var(--text-xs, 0.75rem);
font-weight: var(--font-bold, 700);
color: var(--color-text);
white-space: nowrap;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl, 2rem);
text-align: center;
color: var(--color-text-secondary);
}
/* Balance Indicator */
.balance-indicator {
padding: var(--space-lg, 1rem) 0;
border-top: 1px solid var(--color-border);
background: transparent;
}
.balance-content {
display: flex;
align-items: center;
gap: var(--space-md, 1rem);
}
.balance-text {
display: flex;
flex-direction: column;
gap: var(--space-xs, 0.25rem);
}
.balance-label {
font-size: var(--text-sm, 0.875rem);
color: var(--color-text-secondary);
}
.balance-amount {
font-size: var(--text-lg, 1.125rem);
font-weight: var(--font-bold, 700);
color: var(--color-text);
}
.balance-amount.surplus {
color: var(--color-text);
}
.balance-amount.deficit {
color: var(--color-text);
}
/* Recommendations */
.recommendations {
margin-left: auto;
}
.recommendations details {
cursor: pointer;
}
.recommendations summary {
font-size: var(--text-sm, 0.875rem);
color: var(--color-primary);
list-style: none;
padding: 0.5rem;
border-radius: var(--radius-sm, 4px);
transition: background-color 0.2s ease;
}
.recommendations summary:hover {
background: rgba(59, 130, 246, 0.1);
}
.recommendations ul {
margin: var(--space-sm, 0.5rem) 0 0 0;
padding-left: var(--space-lg, 1rem);
}
.recommendations li {
font-size: var(--text-sm, 0.875rem);
color: var(--color-text-secondary);
margin-bottom: var(--space-xs, 0.25rem);
}
/* Card Footer */
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md, 0.75rem) var(--space-lg, 1rem);
border-top: 1px solid var(--color-border);
background: var(--color-bg);
}
.period-info,
.last-updated {
display: flex;
align-items: center;
gap: var(--space-xs, 0.25rem);
font-size: var(--text-xs, 0.75rem);
}
.period-label,
.update-label {
color: var(--color-text-secondary);
}
.period-value,
.update-time {
color: var(--color-text);
font-weight: var(--font-medium, 500);
}
.refresh-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
margin-left: var(--space-sm, 0.5rem);
border-radius: var(--radius-sm, 4px);
transition: background-color 0.2s ease;
}
.refresh-btn:hover {
background: var(--color-bg-secondary, #f8f9fa);
}
.refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.refresh-icon {
display: inline-block;
font-size: var(--text-sm, 0.875rem);
transition: transform 0.3s ease;
}
.refresh-icon.spinning {
animation: spin 1s linear infinite;
}
/* Animations */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Responsive Design */
@media (max-width: 1024px) {
.maturity-comparison {
grid-template-columns: 1fr;
gap: var(--space-md, 1rem);
}
.comparison-divider {
display: none;
}
}
@media (max-width: 768px) {
.card-header {
flex-direction: column;
gap: var(--space-sm, 0.5rem);
align-items: stretch;
}
.card-header h3 {
text-align: center;
font-size: var(--text-base, 1rem);
}
.period-selector {
width: 100%;
}
.maturity-comparison {
padding: var(--space-md, 0.75rem);
}
.balance-content {
flex-direction: column;
text-align: center;
}
.recommendations {
margin-left: 0;
width: 100%;
}
.card-footer {
flex-direction: column;
gap: var(--space-sm, 0.5rem);
align-items: center;
}
.side-title {
flex-direction: column;
align-items: flex-start;
gap: var(--space-xs, 0.25rem);
}
.total-amount {
align-self: flex-end;
}
}
@media (max-width: 480px) {
.maturity-card {
margin: 0 -var(--space-sm, 0.5rem);
}
.card-header,
.maturity-comparison,
.balance-indicator,
.card-footer {
padding: var(--space-md, 0.75rem);
}
.maturity-list {
max-height: 200px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,708 @@
<template>
<div class="metric-card">
<!-- Header with icon and title -->
<div class="metric-header">
<span class="metric-icon" :class="iconClass">{{ icon }}</span>
<span class="metric-title">{{ title }}</span>
</div>
<!-- Main value display -->
<div class="metric-value" :class="valueClass">
{{ formatCurrency(value) }}
</div>
<!-- Trend indicator -->
<div class="metric-trend" :class="trendClass" v-if="trend">
<span class="trend-icon">{{ trendIcon }}</span>
<span class="trend-value">{{ Math.round(Math.abs(trend.value), 2) }}%</span>
</div>
<!-- Sparkline mini-chart - STACKED BELOW (Best Practice) -->
<div class="metric-sparkline-container" v-if="sparklineData && sparklineData.length > 0">
<canvas
ref="sparklineCanvas"
class="sparkline-canvas"
></canvas>
</div>
<!-- Breakdown display section - Suport ierarhic -->
<div class="metric-breakdown" v-if="breakdown">
<div v-for="(value, key) in breakdown" :key="key" class="breakdown-section">
<!-- Valoare simplă (backward compatible) -->
<div v-if="!isHierarchical(value)" class="breakdown-item">
<span class="breakdown-label">{{ formatBreakdownLabel(key) }}:</span>
<span class="breakdown-value">{{ formatCurrency(value) }}</span>
</div>
<!-- Valoare ierarhică (cu sub-items) -->
<div v-else class="breakdown-group">
<div class="breakdown-item breakdown-header" @click="() => toggleExpanded(key)">
<div class="breakdown-header-left">
<span class="collapse-icon">{{ isItemExpanded(key) ? '▼' : '▶' }}</span>
<span class="breakdown-label">{{ formatBreakdownLabel(key) }}:</span>
</div>
<span class="breakdown-value">{{ formatCurrency(value.total) }}</span>
</div>
<!-- Sub-items (collapsible) -->
<div v-if="value.items && value.items.length > 0" v-show="isItemExpanded(key)" class="breakdown-subitems">
<div v-for="(item, idx) in value.items" :key="idx" class="breakdown-subitem">
<span class="breakdown-sublabel">
{{ item.nume }} <span v-if="item.cont" class="breakdown-cont">({{ item.cont }})</span>
</span>
<span class="breakdown-subvalue">{{ formatCurrency(item.sold) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Chart, registerables } from 'chart.js'
// Register Chart.js components
Chart.register(...registerables)
// Props definition with validation
const props = defineProps({
icon: {
type: String,
required: true,
validator: (value) => value.length > 0
},
title: {
type: String,
required: true,
validator: (value) => value.length > 0
},
value: {
type: Number,
required: true
},
trend: {
type: Object,
default: null,
validator: (value) => {
if (value === null) return true
return typeof value.value === 'number' &&
['up', 'down', 'neutral'].includes(value.direction)
}
},
sparklineData: {
type: Array,
default: () => [],
validator: (value) => {
return value.every(item => typeof item === 'number')
}
},
sparklineLabels: {
type: Array,
default: () => []
},
breakdown: {
type: Object,
required: false,
default: null
}
})
// Refs
const sparklineCanvas = ref(null)
let chartInstance = null
const expandedStates = ref({})
// Toggle breakdown expansion for a specific key
const toggleExpanded = (key) => {
expandedStates.value[key] = !expandedStates.value[key]
}
// Check if a specific breakdown item is expanded
const isItemExpanded = (key) => {
return !!expandedStates.value[key]
}
// Format currency value
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return '0 RON'
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(Math.abs(amount)).replace('RON', 'RON')
}
// Format breakdown label
const formatBreakdownLabel = (key) => {
const labelMap = {
'casa': 'Casă',
'banca': 'Bancă',
'clienti': 'Clienți',
'furnizori': 'Furnizori',
'clienti_in_termen': 'Clienți în termen',
'clienti_restanti': 'Clienți restanți',
'furnizori_termen': 'Furnizori în termen',
'furnizori_scadent': 'Furnizori scadenți',
'numerar': 'Numerar',
'cont': 'Cont',
'depozit': 'Depozit',
'credit': 'Credit',
'debit': 'Debit',
'sold': 'Sold',
'total': 'Total'
}
return labelMap[key.toLowerCase()] || key.charAt(0).toUpperCase() + key.slice(1)
}
// Check if value is hierarchical (has total and items)
const isHierarchical = (value) => {
return value !== null &&
typeof value === 'object' &&
'total' in value &&
'items' in value
}
// Computed properties for styling
const iconClass = computed(() => {
return `icon-${props.title.toLowerCase().replace(/\s+/g, '-')}`
})
const valueClass = computed(() => {
if (!props.value && props.value !== 0) return ''
return props.value < 0 ? 'negative' : 'positive'
})
const trendClass = computed(() => {
if (!props.trend) return ''
return {
'trend-up': props.trend.direction === 'up',
'trend-down': props.trend.direction === 'down',
'trend-neutral': props.trend.direction === 'neutral'
}
})
const trendIcon = computed(() => {
if (!props.trend) return ''
switch (props.trend.direction) {
case 'up': return '▲'
case 'down': return '▼'
case 'neutral': return '▶'
default: return ''
}
})
// Sparkline color based on trend
const sparklineColor = computed(() => {
if (!props.trend) {
return '#3b82f6' // Primary blue
}
switch (props.trend.direction) {
case 'up':
return '#10b981' // Success green
case 'down':
return '#ef4444' // Danger red
default:
return '#3b82f6' // Primary blue
}
})
// Initialize Chart.js sparkline
const initializeSparkline = async () => {
if (!sparklineCanvas.value || !props.sparklineData || props.sparklineData.length === 0) {
return
}
// Destroy existing chart instance
if (chartInstance) {
chartInstance.destroy()
chartInstance = null
}
await nextTick()
const ctx = sparklineCanvas.value.getContext('2d')
const color = sparklineColor.value
// Generate labels: use provided labels or generate generic ones
const labels = props.sparklineLabels.length > 0
? props.sparklineLabels
: props.sparklineData.map((_, i) => `L${i + 1}`)
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
data: props.sparklineData,
borderColor: color,
backgroundColor: `${color}20`, // 20 = 12.5% opacity in hex
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0, // Hide points by default
pointHoverRadius: 4,
pointHoverBackgroundColor: color,
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
cornerRadius: 6,
displayColors: false,
callbacks: {
title: (context) => {
// Show period label in tooltip
return context[0].label || ''
},
label: (context) => {
const value = context.parsed.y
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false
},
ticks: {
color: 'rgba(107, 114, 128, 0.7)',
font: {
size: 9,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6
},
border: {
display: false
}
},
y: {
display: true,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawBorder: false
},
ticks: {
color: 'rgba(107, 114, 128, 0.7)',
font: {
size: 9,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxTicksLimit: 4,
callback: function(value) {
// Format as compact currency
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M'
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + 'k'
}
return value.toFixed(0)
}
},
border: {
display: false
}
}
},
elements: {
line: {
borderCapStyle: 'round',
borderJoinStyle: 'round'
}
}
}
})
}
// Watch for data changes
watch(() => props.sparklineData, async () => {
await initializeSparkline()
}, { deep: true })
watch(() => props.sparklineLabels, async () => {
await initializeSparkline()
}, { deep: true })
watch(() => props.trend, async () => {
await initializeSparkline()
}, { deep: true })
// Lifecycle hooks
onMounted(async () => {
await initializeSparkline()
})
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.destroy()
chartInstance = null
}
})
</script>
<style scoped>
.metric-card {
background: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--card-radius, 8px);
padding: var(--space-lg, 1.5rem);
transition: all 0.3s ease;
position: relative;
min-height: 280px;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.metric-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: var(--color-primary, #3b82f6);
}
/* Header */
.metric-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.metric-icon {
font-size: 1.25rem;
display: inline-block;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-secondary, #f8fafc);
border-radius: var(--radius-sm, 4px);
transition: all 0.3s ease;
}
.metric-card:hover .metric-icon {
background: var(--color-primary-light, #dbeafe);
}
.metric-title {
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-medium, 500);
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Value */
.metric-value {
font-size: var(--text-2xl, 1.875rem);
font-weight: var(--font-bold, 700);
color: var(--color-text-primary, #111827);
line-height: 1.2;
margin-bottom: 0.5rem;
}
.metric-value.positive {
color: var(--color-success, #10b981);
}
.metric-value.negative {
color: var(--color-danger, #ef4444);
}
/* Trend - Positioned below value */
.metric-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-medium, 500);
margin-bottom: 0.75rem;
}
.trend-up {
color: var(--color-success, #10b981);
}
.trend-down {
color: var(--color-danger, #ef4444);
}
.trend-neutral {
color: var(--color-text-secondary, #6b7280);
}
.trend-icon {
font-size: 0.75rem;
}
.trend-value {
font-weight: var(--font-semibold, 600);
}
/* Sparkline Container - STACKED LAYOUT (Best Practice) */
.metric-sparkline-container {
width: 100%;
height: 80px;
margin-bottom: 0.75rem;
border-radius: 4px;
position: relative;
background: var(--color-bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e5e7eb);
}
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Responsive design */
@media (max-width: 768px) {
.metric-card {
min-height: 240px;
padding: var(--space-md, 1rem);
}
.metric-value {
font-size: var(--text-xl, 1.5rem);
margin-bottom: 0.375rem;
}
.metric-trend {
margin-bottom: 0.5rem;
}
.metric-sparkline-container {
height: 70px;
margin-bottom: 0.5rem;
}
}
@media (max-width: 480px) {
.metric-card {
min-height: 200px;
padding: var(--space-sm, 0.75rem);
gap: 0.5rem;
}
.metric-icon {
width: 1.5rem;
height: 1.5rem;
font-size: 1rem;
}
.metric-title {
font-size: var(--text-xs, 0.75rem);
}
.metric-value {
font-size: var(--text-lg, 1.125rem);
margin-bottom: 0.25rem;
}
.metric-trend {
font-size: var(--text-xs, 0.75rem);
margin-bottom: 0.375rem;
}
.metric-sparkline-container {
height: 60px;
margin-bottom: 0.5rem;
}
}
/* CSS Custom Properties fallbacks */
:root {
--color-bg: #ffffff;
--color-bg-secondary: #f8fafc;
--color-border: #e5e7eb;
--color-primary: #3b82f6;
--color-primary-light: #dbeafe;
--color-success: #10b981;
--color-danger: #ef4444;
--color-text-primary: #111827;
--color-text-secondary: #6b7280;
--card-radius: 8px;
--radius-sm: 4px;
--space-sm: 0.75rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-lg: 1.125rem;
--text-xl: 1.5rem;
--text-2xl: 1.875rem;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
}
/* Breakdown section */
.metric-breakdown {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.breakdown-section {
margin-bottom: 0.875rem;
}
.breakdown-section:last-child {
margin-bottom: 0;
}
.breakdown-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
gap: 1rem;
}
.breakdown-item:last-child {
margin-bottom: 0;
}
.breakdown-label {
font-size: 1rem;
color: var(--color-text-secondary, #6b7280);
font-weight: var(--font-medium, 500);
}
.breakdown-value {
font-size: 1.125rem;
color: var(--color-text-primary, #111827);
font-weight: var(--font-semibold, 600);
font-family: monospace;
}
/* Hierarchical breakdown styles */
.breakdown-group {
margin-bottom: 0.875rem;
}
.breakdown-header {
font-weight: var(--font-semibold, 600);
border-bottom: 1px solid var(--color-border, #e5e7eb);
padding-bottom: 0.5rem;
margin-bottom: 0.625rem;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
}
.breakdown-header:hover {
background: var(--color-bg-secondary, #f8fafc);
padding: 0.25rem 0.5rem;
margin: -0.25rem -0.5rem 0.625rem -0.5rem;
border-radius: var(--radius-sm, 4px);
}
.breakdown-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-icon {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
transition: transform 0.2s ease;
display: inline-block;
width: 1rem;
}
.breakdown-subitems {
padding-left: 0;
border-left: none;
margin-top: 0.625rem;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.breakdown-subitem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0;
gap: 0.75rem;
}
.breakdown-sublabel {
color: var(--color-text-secondary, #6b7280);
font-size: 0.875rem;
}
.breakdown-cont {
font-size: 0.875rem;
opacity: 0.7;
margin-left: 0.25rem;
}
.breakdown-subvalue {
font-weight: var(--font-medium, 500);
font-family: monospace;
font-size: 0.875rem;
color: var(--color-text-primary, #111827);
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.metric-card {
--color-bg: #1f2937;
--color-bg-secondary: #374151;
--color-border: #4b5563;
--color-text-primary: #f9fafb;
--color-text-secondary: #d1d5db;
}
}
</style>

View File

@@ -0,0 +1,915 @@
<template>
<div class="performance-card card">
<div class="card-header">
<div class="header-content">
<div class="header-title">
<span class="card-icon">📊</span>
<h3 class="card-title">Performanță & Cash Flow</h3>
</div>
<div class="period-selector">
<select
v-model="selectedPeriod"
@change="handlePeriodChange"
class="period-select"
:disabled="isLoading"
>
<option value="7d">7 zile</option>
<option value="1m">1 lună</option>
<option value="3m">3 luni</option>
<option value="6m">6 luni</option>
<option value="ytd">YTD</option>
<option value="12m">12 luni</option>
</select>
<i class="pi pi-chevron-down select-icon"></i>
</div>
</div>
</div>
<div class="card-body">
<!-- Loading State -->
<div v-if="isLoading" class="loading-state">
<div class="loading-spinner"></div>
<span class="loading-text">Se încarcă datele...</span>
</div>
<!-- Error State -->
<div v-else-if="error" class="error-state">
<i class="pi pi-exclamation-triangle error-icon"></i>
<span class="error-text">{{ error }}</span>
<button @click="retryLoad" class="retry-button">
<i class="pi pi-refresh"></i>
Reîncarcă
</button>
</div>
<!-- Content -->
<template v-else>
<!-- Chart Container -->
<div class="chart-container">
<div class="chart-placeholder" v-if="!chartData?.labels?.length">
<div class="placeholder-content">
<i class="pi pi-chart-line placeholder-icon"></i>
<span class="placeholder-text">Grafic încasări vs plăți</span>
<small class="placeholder-subtitle">Datele vor fi afișate aici</small>
</div>
</div>
<div v-else class="chart-content">
<div class="chart-legend">
<div class="legend-item">
<span class="legend-color income"></span>
<span class="legend-label">Încasări</span>
<span class="legend-value">{{ formatCurrency(totalIncome) }}</span>
</div>
<div class="legend-item">
<span class="legend-color expenses"></span>
<span class="legend-label">Plăți</span>
<span class="legend-value">{{ formatCurrency(totalExpenses) }}</span>
</div>
</div>
<div class="chart-canvas-container">
<canvas
ref="performanceChart"
v-if="chartData?.labels?.length"
width="400"
height="200"
></canvas>
</div>
</div>
</div>
<!-- Performance Indicators -->
<div class="indicators-section">
<div class="indicators-grid">
<div class="indicator-card">
<div class="indicator-icon">💰</div>
<div class="indicator-content">
<div class="indicator-label">Rata încasare</div>
<div class="indicator-value" :class="getRateClass(performanceData.rataIncasare)">
{{ performanceData.rataIncasare || 0 }}%
</div>
</div>
</div>
<div class="indicator-card">
<div class="indicator-icon"></div>
<div class="indicator-content">
<div class="indicator-label">Cash conversion</div>
<div class="indicator-value">
{{ performanceData.cashConversion || 0 }} zile
</div>
</div>
</div>
<div class="indicator-card">
<div class="indicator-icon">📈</div>
<div class="indicator-content">
<div class="indicator-label">Trend</div>
<div class="indicator-value" :class="getTrendClass(performanceData.trend)">
<i :class="getTrendIcon(performanceData.trend)"></i>
{{ getTrendText(performanceData.trend) }}
</div>
</div>
</div>
<div class="indicator-card">
<div class="indicator-icon">💼</div>
<div class="indicator-content">
<div class="indicator-label">Capital lucru</div>
<div class="indicator-value" :class="getWorkingCapitalClass(performanceData.workingCapital)">
{{ formatCurrency(performanceData.workingCapital || 0) }}
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Chart, registerables } from 'chart.js'
import { useDashboardStore } from '../../../stores/dashboard'
// Register Chart.js components
Chart.register(...registerables)
// Props
const props = defineProps({
companyId: {
type: [Number, String],
required: true
}
})
// Emits
const emit = defineEmits(['periodChanged'])
// State
const selectedPeriod = ref('7d')
const isLoading = ref(false)
const error = ref(null)
const performanceChart = ref(null)
let chartInstance = null
// Store
const dashboardStore = useDashboardStore()
// Sample data (will be replaced with actual API data)
const performanceData = ref({
rataIncasare: 85.2,
cashConversion: 45,
trend: 'up',
workingCapital: 125000
})
const chartData = ref({
labels: ['Lun', 'Mar', 'Mie', 'Joi', 'Vin', 'Sâm', 'Dum'],
income: [12000, 15000, 8000, 22000, 18000, 14000, 16000],
expenses: [8000, 12000, 15000, 16000, 14000, 11000, 13000]
})
// Computed
const totalIncome = computed(() => {
return chartData.value.income?.reduce((sum, val) => sum + val, 0) || 0
})
const totalExpenses = computed(() => {
return chartData.value.expenses?.reduce((sum, val) => sum + val, 0) || 0
})
const maxValue = computed(() => {
const allValues = [...(chartData.value.income || []), ...(chartData.value.expenses || [])]
return Math.max(...allValues, 0)
})
// Methods
const handlePeriodChange = () => {
emit('periodChanged', selectedPeriod.value)
loadPerformanceData()
}
const loadPerformanceData = async () => {
if (!props.companyId) return
isLoading.value = true
error.value = null
try {
// This will be replaced with actual API call
// const result = await dashboardStore.loadPerformanceData(props.companyId, selectedPeriod.value)
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
// Mock data based on period
const mockData = {
'7d': {
rataIncasare: 85.2,
cashConversion: 45,
trend: 'up',
workingCapital: 125000,
chartData: {
labels: ['Lun', 'Mar', 'Mie', 'Joi', 'Vin', 'Sâm', 'Dum'],
income: [12000, 15000, 8000, 22000, 18000, 14000, 16000],
expenses: [8000, 12000, 15000, 16000, 14000, 11000, 13000]
}
},
'1m': {
rataIncasare: 78.5,
cashConversion: 52,
trend: 'stable',
workingCapital: 89000,
chartData: {
labels: ['S1', 'S2', 'S3', 'S4'],
income: [45000, 52000, 38000, 48000],
expenses: [42000, 47000, 51000, 45000]
}
},
'3m': {
rataIncasare: 82.1,
cashConversion: 38,
trend: 'up',
workingCapital: 156000,
chartData: {
labels: ['Ian', 'Feb', 'Mar'],
income: [165000, 182000, 155000],
expenses: [158000, 162000, 168000]
}
},
'6m': {
rataIncasare: 79.8,
cashConversion: 41,
trend: 'down',
workingCapital: 98000,
chartData: {
labels: ['Oct', 'Noi', 'Dec', 'Ian', 'Feb', 'Mar'],
income: [145000, 162000, 185000, 165000, 182000, 155000],
expenses: [152000, 158000, 172000, 158000, 162000, 168000]
}
},
'ytd': {
rataIncasare: 81.3,
cashConversion: 43,
trend: 'stable',
workingCapital: 142000,
chartData: {
labels: ['Q1', 'Q2', 'Q3'],
income: [502000, 485000, 456000],
expenses: [488000, 512000, 478000]
}
},
'12m': {
rataIncasare: 83.7,
cashConversion: 39,
trend: 'up',
workingCapital: 178000,
chartData: {
labels: ['T1', 'T2', 'T3', 'T4'],
income: [1456000, 1523000, 1387000, 1612000],
expenses: [1423000, 1498000, 1456000, 1534000]
}
}
}
const data = mockData[selectedPeriod.value] || mockData['7d']
performanceData.value = data
chartData.value = data.chartData
// Initialize or update chart after data is loaded
await nextTick()
await updateChart()
} catch (err) {
console.error('Failed to load performance data:', err)
error.value = 'Nu s-au putut încărca datele de performanță'
} finally {
isLoading.value = false
}
}
const retryLoad = () => {
loadPerformanceData()
}
const formatCurrency = (value) => {
if (value === null || value === undefined) return '0 RON'
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
}
const initializeChart = async () => {
if (!performanceChart.value || !chartData.value?.labels?.length) return
// Destroy existing chart instance
if (chartInstance) {
chartInstance.destroy()
chartInstance = null
}
await nextTick()
const ctx = performanceChart.value.getContext('2d')
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: chartData.value.labels,
datasets: [
{
label: 'Încasări',
data: chartData.value.income,
borderColor: 'rgba(16, 185, 129, 1)', // var(--color-success)
backgroundColor: 'rgba(16, 185, 129, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointBackgroundColor: 'rgba(16, 185, 129, 1)',
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6
},
{
label: 'Plăți',
data: chartData.value.expenses,
borderColor: 'rgba(239, 68, 68, 1)', // var(--color-error)
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointBackgroundColor: 'rgba(239, 68, 68, 1)',
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: false // We have our own custom legend
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
cornerRadius: 8,
displayColors: true,
callbacks: {
label: function(context) {
const value = context.parsed.y
const formattedValue = new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
return `${context.dataset.label}: ${formattedValue}`
}
}
}
},
scales: {
x: {
grid: {
display: false
},
border: {
display: false
},
ticks: {
color: 'rgba(107, 114, 128, 0.8)',
font: {
size: 12,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
}
}
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawBorder: false
},
border: {
display: false
},
ticks: {
color: 'rgba(107, 114, 128, 0.8)',
font: {
size: 12,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
callback: function(value) {
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
notation: 'compact'
}).format(value)
}
}
}
},
elements: {
line: {
borderCapStyle: 'round',
borderJoinStyle: 'round'
}
}
}
})
}
const updateChart = async () => {
if (chartInstance && chartData.value?.labels?.length) {
chartInstance.data.labels = chartData.value.labels
chartInstance.data.datasets[0].data = chartData.value.income
chartInstance.data.datasets[1].data = chartData.value.expenses
chartInstance.update('active')
} else {
await initializeChart()
}
}
const getRateClass = (rate) => {
if (rate >= 85) return 'rate-excellent'
if (rate >= 75) return 'rate-good'
if (rate >= 60) return 'rate-average'
return 'rate-poor'
}
const getTrendClass = (trend) => {
switch (trend) {
case 'up': return 'trend-up'
case 'down': return 'trend-down'
default: return 'trend-stable'
}
}
const getTrendIcon = (trend) => {
switch (trend) {
case 'up': return 'pi pi-arrow-up'
case 'down': return 'pi pi-arrow-down'
default: return 'pi pi-minus'
}
}
const getTrendText = (trend) => {
switch (trend) {
case 'up': return 'Crescător'
case 'down': return 'Descrescător'
default: return 'Stabil'
}
}
const getWorkingCapitalClass = (value) => {
if (value > 100000) return 'capital-positive'
if (value > 0) return 'capital-neutral'
return 'capital-negative'
}
// Watchers
watch(() => props.companyId, (newId) => {
if (newId) {
loadPerformanceData()
}
}, { immediate: true })
watch(chartData, async () => {
if (chartData.value?.labels?.length) {
await nextTick()
await updateChart()
}
}, { deep: true })
// Lifecycle
onMounted(async () => {
if (props.companyId) {
await loadPerformanceData()
}
})
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.destroy()
chartInstance = null
}
})
</script>
<style scoped>
/* Performance Card Styles */
.performance-card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--card-radius);
box-shadow: var(--shadow-sm);
transition: all var(--transition-fast);
overflow: hidden;
}
.performance-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
/* Header */
.card-header {
padding: var(--space-lg);
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.header-title {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.card-icon {
font-size: var(--text-lg);
}
.card-title {
margin: 0;
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--color-text);
}
/* Period Selector */
.period-selector {
position: relative;
}
.period-select {
appearance: none;
padding: var(--space-sm) var(--space-xl) var(--space-sm) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-sm);
font-weight: var(--font-medium);
cursor: pointer;
transition: all var(--transition-fast);
min-width: 100px;
}
.period-select:hover {
border-color: var(--color-primary);
}
.period-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.1);
}
.period-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.select-icon {
position: absolute;
right: var(--space-sm);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
font-size: var(--text-xs);
pointer-events: none;
}
/* Body */
.card-body {
padding: var(--space-lg);
}
/* Loading State */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-2xl);
gap: var(--space-md);
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error State */
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-2xl);
gap: var(--space-md);
text-align: center;
}
.error-icon {
color: var(--color-error);
font-size: var(--text-2xl);
}
.error-text {
color: var(--color-error);
font-size: var(--text-sm);
margin-bottom: var(--space-sm);
}
.retry-button {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
background: var(--color-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--text-sm);
transition: background-color var(--transition-fast);
}
.retry-button:hover {
background: var(--color-primary-dark);
}
/* Chart Container */
.chart-container {
margin-bottom: var(--space-xl);
min-height: 200px;
}
.chart-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
}
.placeholder-content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
text-align: center;
}
.placeholder-icon {
font-size: var(--text-3xl);
color: var(--color-text-muted);
}
.placeholder-text {
font-size: var(--text-base);
font-weight: var(--font-medium);
color: var(--color-text-secondary);
}
.placeholder-subtitle {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
/* Chart Content */
.chart-content {
background: var(--color-bg);
border-radius: var(--radius-md);
overflow: hidden;
}
.chart-legend {
display: flex;
justify-content: space-around;
padding: var(--space-md);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
.legend-item {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-sm);
}
.legend-color {
width: 12px;
height: 12px;
border-radius: var(--radius-sm);
}
.legend-color.income {
background: var(--color-success);
}
.legend-color.expenses {
background: var(--color-error);
}
.legend-label {
font-weight: var(--font-medium);
color: var(--color-text-secondary);
}
.legend-value {
font-weight: var(--font-semibold);
color: var(--color-text);
}
.chart-canvas-container {
padding: var(--space-lg);
height: 200px;
position: relative;
}
.chart-canvas-container canvas {
width: 100% !important;
height: 100% !important;
}
/* Indicators */
.indicators-section {
border-top: 1px solid var(--color-border);
padding-top: var(--space-lg);
}
.indicators-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-md);
}
.indicator-card {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.indicator-card:hover {
background: var(--color-bg-muted);
}
.indicator-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
border-radius: var(--radius-lg);
font-size: var(--text-lg);
flex-shrink: 0;
}
.indicator-content {
flex: 1;
min-width: 0;
}
.indicator-label {
font-size: var(--text-xs);
color: var(--color-text-secondary);
font-weight: var(--font-medium);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-xs);
}
.indicator-value {
font-size: var(--text-lg);
font-weight: var(--font-bold);
color: var(--color-text);
display: flex;
align-items: center;
gap: var(--space-xs);
}
/* Indicator Value Colors */
.rate-excellent { color: var(--color-success); }
.rate-good { color: #10b981; }
.rate-average { color: var(--color-warning); }
.rate-poor { color: var(--color-error); }
.trend-up { color: var(--color-success); }
.trend-down { color: var(--color-error); }
.trend-stable { color: var(--color-secondary); }
.capital-positive { color: var(--color-success); }
.capital-neutral { color: var(--color-warning); }
.capital-negative { color: var(--color-error); }
/* Mobile Responsive */
@media (max-width: 768px) {
.card-header {
padding: var(--space-md);
}
.card-body {
padding: var(--space-md);
}
.header-content {
flex-direction: column;
align-items: flex-start;
gap: var(--space-sm);
}
.indicators-grid {
grid-template-columns: 1fr;
gap: var(--space-sm);
}
.indicator-card {
padding: var(--space-sm);
}
.indicator-icon {
width: 32px;
height: 32px;
font-size: var(--text-base);
}
.chart-canvas-container {
height: 160px;
padding: var(--space-md);
}
.chart-legend {
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm);
}
}
@media (max-width: 480px) {
.card-header,
.card-body {
padding: var(--space-sm);
}
.card-title {
font-size: var(--text-base);
}
.indicator-value {
font-size: var(--text-base);
}
.loading-state,
.error-state {
padding: var(--space-lg);
}
}
</style>

View File

@@ -0,0 +1,858 @@
<template>
<div class="treasury-dual-card">
<!-- Main values section - Split layout (Casa | Bancă) -->
<div class="values-section">
<!-- Casa Section -->
<div class="value-block casa">
<div class="value-label">Casa</div>
<div class="value-amount positive">
{{ formatCurrency(casaTotal) }}
</div>
</div>
<!-- Divider -->
<div class="divider"></div>
<!-- Bancă Section -->
<div class="value-block banca">
<div class="value-label">Bancă</div>
<div class="value-amount positive">
{{ formatCurrency(bancaTotal) }}
</div>
</div>
</div>
<!-- Dual sparkline charts - stacked vertical -->
<div class="sparkline-dual-container" v-if="hasSparklineData">
<!-- Grafic Casa -->
<div class="sparkline-wrapper">
<div class="sparkline-label">Casa</div>
<div class="sparkline-chart">
<canvas ref="casaCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
<!-- Grafic Bancă -->
<div class="sparkline-wrapper">
<div class="sparkline-label">Bancă</div>
<div class="sparkline-chart">
<canvas ref="bancaCanvas" class="sparkline-canvas"></canvas>
</div>
</div>
</div>
<!-- Breakdown section -->
<div class="breakdown-section" v-if="casaItems.length > 0 || bancaItems.length > 0">
<!-- Casa Breakdown -->
<div class="breakdown-group" v-if="casaItems.length > 0">
<div class="breakdown-header" @click="toggleCasaExpanded">
<div class="breakdown-header-left">
<span class="collapse-icon">{{ isCasaExpanded ? '▼' : '▶' }}</span>
<span class="breakdown-label">Casa</span>
</div>
<span class="breakdown-value">{{ formatCurrency(casaTotal) }}</span>
</div>
<!-- Casa Sub-items -->
<div v-show="isCasaExpanded" class="breakdown-subitems">
<div v-for="(item, idx) in casaItems" :key="idx" class="breakdown-subitem">
<span class="breakdown-sublabel">
{{ item.nume || `Cont ${item.cont}` }}
<span v-if="item.cont" class="breakdown-cont">({{ item.cont }})</span>
</span>
<span class="breakdown-subvalue">{{ formatCurrency(item.sold) }}</span>
</div>
</div>
</div>
<!-- Bancă Breakdown -->
<div class="breakdown-group" v-if="bancaItems.length > 0">
<div class="breakdown-header" @click="toggleBancaExpanded">
<div class="breakdown-header-left">
<span class="collapse-icon">{{ isBancaExpanded ? '▼' : '▶' }}</span>
<span class="breakdown-label">Bancă</span>
</div>
<span class="breakdown-value">{{ formatCurrency(bancaTotal) }}</span>
</div>
<!-- Bancă Sub-items -->
<div v-show="isBancaExpanded" class="breakdown-subitems">
<div v-for="(item, idx) in bancaItems" :key="idx" class="breakdown-subitem">
<span class="breakdown-sublabel">
{{ item.nume || `Cont ${item.cont}` }}
<span v-if="item.cont" class="breakdown-cont">({{ item.cont }})</span>
</span>
<span class="breakdown-subvalue">{{ formatCurrency(item.sold) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Chart, registerables } from 'chart.js'
Chart.register(...registerables)
const props = defineProps({
casaTotal: {
type: Number,
default: 0
},
bancaTotal: {
type: Number,
default: 0
},
casaItems: {
type: Array,
default: () => []
},
bancaItems: {
type: Array,
default: () => []
},
casaSparklineData: {
type: Array,
default: () => []
},
bancaSparklineData: {
type: Array,
default: () => []
},
casaPreviousSparklineData: {
type: Array,
default: () => []
},
bancaPreviousSparklineData: {
type: Array,
default: () => []
},
sparklineLabels: {
type: Array,
default: () => []
},
previousSparklineLabels: {
type: Array,
default: () => []
},
trend: {
type: Object,
default: null
}
})
// Refs pentru 2 canvas-uri separate
const casaCanvas = ref(null)
const bancaCanvas = ref(null)
let casaChartInstance = null
let bancaChartInstance = null
const isCasaExpanded = ref(false)
const isBancaExpanded = ref(false)
// Toggle functions
const toggleCasaExpanded = () => {
isCasaExpanded.value = !isCasaExpanded.value
}
const toggleBancaExpanded = () => {
isBancaExpanded.value = !isBancaExpanded.value
}
// Format currency
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return '0 RON'
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(Math.abs(amount))
}
// Check if sparkline data exists
const hasSparklineData = computed(() => {
return props.casaSparklineData.length > 0 && props.bancaSparklineData.length > 0
})
// Initialize Casa chart
const initializeCasaChart = async () => {
if (!casaCanvas.value || props.casaSparklineData.length === 0) {
return
}
// Destroy existing chart
if (casaChartInstance) {
casaChartInstance.destroy()
casaChartInstance = null
}
await nextTick()
const ctx = casaCanvas.value.getContext('2d')
// Generate labels
const labels = props.sparklineLabels.length > 0
? props.sparklineLabels
: props.casaSparklineData.map((_, i) => `L${i + 1}`)
// Prepare datasets
const datasets = [{
label: 'Casa (curent)',
data: props.casaSparklineData,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: '#10b981',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
}]
// Add previous year dataset if available
if (props.casaPreviousSparklineData && props.casaPreviousSparklineData.length > 0) {
datasets.push({
label: 'Casa (anul precedent)',
data: props.casaPreviousSparklineData,
borderColor: 'rgba(16, 185, 129, 0.4)',
backgroundColor: 'rgba(16, 185, 129, 0.05)',
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: 'rgba(16, 185, 129, 0.4)',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
})
}
// Calculate limits including both datasets
const allDataPoints = [...props.casaSparklineData]
if (props.casaPreviousSparklineData && props.casaPreviousSparklineData.length > 0) {
allDataPoints.push(...props.casaPreviousSparklineData)
}
const dataMin = Math.min(...allDataPoints)
const dataMax = Math.max(...allDataPoints)
const dataRange = dataMax - dataMin
const dataPadding = dataRange * 0.05
casaChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: datasets.length > 1,
position: 'top',
align: 'end',
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
color: 'rgba(107, 114, 128, 0.9)',
usePointStyle: true,
pointStyle: 'line'
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || '',
label: (context) => {
const value = context.parsed.y
const label = context.dataset.label || ''
const formattedValue = new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
return `${label}: ${formattedValue}`
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false
},
ticks: {
color: 'rgba(107, 114, 128, 0.7)',
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6
},
border: {
display: false
}
},
y: {
display: true,
min: dataMin - dataPadding,
max: dataMax + dataPadding,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawBorder: false
},
ticks: {
color: '#10b981',
font: {
size: 11,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxTicksLimit: 3,
callback: function(value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M'
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + 'k'
}
return value.toFixed(0)
}
},
border: {
display: false
}
}
}
}
})
}
// Initialize Bancă chart
const initializeBancaChart = async () => {
if (!bancaCanvas.value || props.bancaSparklineData.length === 0) {
return
}
// Destroy existing chart
if (bancaChartInstance) {
bancaChartInstance.destroy()
bancaChartInstance = null
}
await nextTick()
const ctx = bancaCanvas.value.getContext('2d')
// Generate labels
const labels = props.sparklineLabels.length > 0
? props.sparklineLabels
: props.bancaSparklineData.map((_, i) => `L${i + 1}`)
// Prepare datasets
const datasets = [{
label: 'Bancă (curent)',
data: props.bancaSparklineData,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: '#3b82f6',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
}]
// Add previous year dataset if available
if (props.bancaPreviousSparklineData && props.bancaPreviousSparklineData.length > 0) {
datasets.push({
label: 'Bancă (anul precedent)',
data: props.bancaPreviousSparklineData,
borderColor: 'rgba(59, 130, 246, 0.4)',
backgroundColor: 'rgba(59, 130, 246, 0.05)',
borderWidth: 2,
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: 'rgba(59, 130, 246, 0.4)',
pointHoverBorderColor: '#ffffff',
pointHoverBorderWidth: 2
})
}
// Calculate limits including both datasets
const allDataPoints = [...props.bancaSparklineData]
if (props.bancaPreviousSparklineData && props.bancaPreviousSparklineData.length > 0) {
allDataPoints.push(...props.bancaPreviousSparklineData)
}
const dataMin = Math.min(...allDataPoints)
const dataMax = Math.max(...allDataPoints)
const dataRange = dataMax - dataMin
const dataPadding = dataRange * 0.05
bancaChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: datasets.length > 1,
position: 'top',
align: 'end',
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
color: 'rgba(107, 114, 128, 0.9)',
usePointStyle: true,
pointStyle: 'line'
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 1,
cornerRadius: 6,
displayColors: true,
callbacks: {
title: (context) => context[0].label || '',
label: (context) => {
const value = context.parsed.y
const label = context.dataset.label || ''
const formattedValue = new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
return `${label}: ${formattedValue}`
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false,
drawBorder: false
},
ticks: {
color: 'rgba(107, 114, 128, 0.7)',
font: {
size: 10,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 6
},
border: {
display: false
}
},
y: {
display: true,
min: dataMin - dataPadding,
max: dataMax + dataPadding,
grid: {
color: 'rgba(107, 114, 128, 0.1)',
drawBorder: false
},
ticks: {
color: '#3b82f6',
font: {
size: 11,
family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif'
},
maxTicksLimit: 3,
callback: function(value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M'
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + 'k'
}
return value.toFixed(0)
}
},
border: {
display: false
}
}
}
}
})
}
// Watch for data changes
watch(() => [
props.casaSparklineData,
props.bancaSparklineData,
props.sparklineLabels,
props.casaPreviousSparklineData,
props.bancaPreviousSparklineData,
props.previousSparklineLabels
], async () => {
await Promise.all([
initializeCasaChart(),
initializeBancaChart()
])
}, { deep: true })
// Lifecycle hooks
onMounted(async () => {
await Promise.all([
initializeCasaChart(),
initializeBancaChart()
])
})
onBeforeUnmount(() => {
if (casaChartInstance) {
casaChartInstance.destroy()
casaChartInstance = null
}
if (bancaChartInstance) {
bancaChartInstance.destroy()
bancaChartInstance = null
}
})
</script>
<style scoped>
/* === TYPOGRAPHY TOKENS === */
:root {
--card-label-size: 0.875rem;
--card-value-size: 1.5rem;
--card-trend-size: 0.75rem;
--breakdown-label-size: 0.875rem;
--breakdown-value-size: 0.9375rem;
--breakdown-sub-label-size: 0.8125rem;
--breakdown-sub-value-size: 0.8125rem;
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.treasury-dual-card {
background: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--card-radius, 8px);
padding: var(--space-lg, 1.5rem);
transition: all 0.3s ease;
position: relative;
min-height: 420px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.treasury-dual-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: var(--color-primary, #3b82f6);
}
/* Values section - Split layout */
.values-section {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 1rem;
align-items: start;
}
.value-block {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.value-label {
font-size: var(--card-label-size);
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: var(--font-sans);
}
.value-amount {
font-size: var(--card-value-size);
font-weight: 700;
line-height: 1.2;
font-family: var(--font-mono);
}
.value-amount.positive {
color: var(--color-success, #10b981);
}
.value-amount.negative {
color: var(--color-danger, #ef4444);
}
.value-amount.neutral {
color: var(--color-text, #111827);
}
.divider {
width: 1px;
height: 100%;
background: var(--color-border, #e5e7eb);
min-height: 60px;
}
/* Dual sparkline container - stack vertical */
.sparkline-dual-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 0.5rem 0;
}
.sparkline-wrapper {
width: 100%;
background: var(--color-bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.5rem;
}
.sparkline-label {
font-size: var(--card-label-size);
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: var(--font-sans);
}
/* Culori distinctive pentru label-uri */
.sparkline-wrapper:first-child .sparkline-label {
color: #10b981; /* Verde pentru Casa */
}
.sparkline-wrapper:last-child .sparkline-label {
color: #3b82f6; /* Albastru pentru Bancă */
}
.sparkline-chart {
width: 100%;
height: 150px;
position: relative;
}
.sparkline-canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
/* Breakdown section */
.breakdown-section {
margin-top: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.breakdown-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.breakdown-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: 0.375rem 0.5rem;
border-radius: var(--radius-sm, 4px);
transition: all 0.2s ease;
}
.breakdown-header:hover {
background: var(--color-bg-secondary, #f8fafc);
}
.breakdown-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-icon {
font-size: 0.625rem;
color: var(--color-text-secondary, #6b7280);
transition: transform 0.2s ease;
display: inline-block;
width: 0.875rem;
}
.breakdown-label {
font-size: var(--breakdown-label-size);
color: var(--color-text, #111827);
font-weight: 500;
font-family: var(--font-sans);
}
.breakdown-value {
font-size: var(--breakdown-value-size);
font-weight: 600;
font-family: var(--font-mono);
color: var(--color-text, #111827);
}
.breakdown-subitems {
padding-left: 0;
animation: slideDown 0.2s ease-out;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.breakdown-subitem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.5rem 0.25rem 1.5rem;
}
.breakdown-sublabel {
color: var(--color-text-secondary, #6b7280);
font-size: var(--breakdown-sub-label-size);
font-weight: 400;
font-family: var(--font-sans);
}
.breakdown-cont {
font-size: var(--breakdown-sub-label-size);
opacity: 0.7;
margin-left: 0.25rem;
}
.breakdown-subvalue {
font-weight: 500;
font-family: var(--font-mono);
font-size: var(--breakdown-sub-value-size);
color: var(--color-text, #111827);
}
/* Responsive */
@media (max-width: 768px) {
.treasury-dual-card {
min-height: 380px;
padding: var(--space-md, 1rem);
}
.values-section {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.divider {
width: 100%;
height: 1px;
min-height: 1px;
}
.sparkline-chart {
height: 130px;
}
}
@media (max-width: 480px) {
.treasury-dual-card {
min-height: 340px;
padding: 0.5rem 0.25rem;
gap: 0.5rem;
border-radius: 0;
margin: 0;
}
.sparkline-chart {
height: 120px;
}
.sparkline-wrapper {
padding: 0;
border: none;
}
.values-section {
gap: 0.5rem;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.treasury-dual-card {
--color-bg: #1f2937;
--color-bg-secondary: #374151;
--color-border: #4b5563;
--color-text: #f9fafb;
--color-text-secondary: #d1d5db;
}
}
</style>

View File

@@ -0,0 +1,255 @@
<template>
<header class="header-container">
<nav class="header-nav">
<!-- Left side: Brand + Hamburger -->
<div class="flex items-center gap-4">
<button
class="hamburger-btn"
:class="{ active: menuOpen }"
@click="toggleMenu"
aria-label="Toggle navigation menu"
>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
<router-link to="/dashboard" class="header-brand">
<span>ROA2WEB</span>
</router-link>
</div>
<!-- Right side: Company + User -->
<div class="header-actions">
<CompanySelectorMini
v-model="selectedCompany"
@company-changed="onCompanyChanged"
/>
<div class="user-menu-container">
<div class="header-user" @click="toggleUserMenu">
<i class="pi pi-user"></i>
<span class="desktop-only">{{ currentUser?.username || 'User' }}</span>
<i class="pi pi-chevron-down" :class="{ 'rotate-180': userMenuOpen }"></i>
</div>
<!-- User Dropdown Menu -->
<div v-if="userMenuOpen" class="user-dropdown">
<div class="user-dropdown-header">
<div class="user-info">
<div class="user-name">{{ currentUser?.username || 'User' }}</div>
<div class="user-email">{{ currentUser?.email || '' }}</div>
</div>
</div>
<div class="user-dropdown-divider"></div>
<button class="user-dropdown-item" @click="navigateToTelegram">
<i class="pi pi-telegram"></i>
<span>Telegram Bot</span>
</button>
<div class="user-dropdown-divider"></div>
<button class="user-dropdown-item" @click="handleLogout">
<i class="pi pi-sign-out"></i>
<span>Logout</span>
</button>
</div>
</div>
</div>
</nav>
<!-- Overlay for user menu -->
<div
v-if="userMenuOpen"
class="user-menu-overlay"
@click="closeUserMenu"
></div>
</header>
</template>
<script>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import CompanySelectorMini from '../dashboard/CompanySelectorMini.vue'
import { useCompanyStore } from '../../stores/companies'
import { useAuthStore } from '../../stores/auth'
export default {
name: 'DashboardHeader',
components: {
CompanySelectorMini
},
emits: ['menu-toggle', 'company-changed'],
setup(props, { emit }) {
const router = useRouter()
const companiesStore = useCompanyStore()
const authStore = useAuthStore()
const menuOpen = ref(false)
const userMenuOpen = ref(false)
const selectedCompany = computed({
get: () => companiesStore.selectedCompany,
set: (value) => companiesStore.setSelectedCompany(value)
})
const currentUser = computed(() => authStore.currentUser)
const toggleMenu = () => {
menuOpen.value = !menuOpen.value
emit('menu-toggle', menuOpen.value)
}
const toggleUserMenu = () => {
userMenuOpen.value = !userMenuOpen.value
}
const closeUserMenu = () => {
userMenuOpen.value = false
}
const onCompanyChanged = (company) => {
emit('company-changed', company)
}
const navigateToTelegram = async () => {
try {
closeUserMenu()
await router.push('/telegram')
} catch (error) {
console.error('Navigation error:', error)
}
}
const handleLogout = async () => {
try {
authStore.logout()
closeUserMenu()
await router.push('/login')
} catch (error) {
console.error('Logout error:', error)
}
}
return {
menuOpen,
userMenuOpen,
selectedCompany,
currentUser,
toggleMenu,
toggleUserMenu,
closeUserMenu,
onCompanyChanged,
navigateToTelegram,
handleLogout
}
}
}
</script>
<style scoped>
/* User Menu Container */
.user-menu-container {
position: relative;
}
/* User Dropdown */
.user-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 220px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: var(--z-dropdown);
overflow: hidden;
}
.user-dropdown-header {
padding: var(--space-md);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
.user-info {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.user-name {
font-weight: var(--font-semibold);
color: var(--color-text);
font-size: var(--text-sm);
}
.user-email {
color: var(--color-text-secondary);
font-size: var(--text-xs);
}
.user-dropdown-divider {
height: 1px;
background: var(--color-border);
}
.user-dropdown-item {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: none;
border: none;
color: var(--color-text);
font-size: var(--text-sm);
text-align: left;
cursor: pointer;
transition: background-color var(--transition-fast);
}
.user-dropdown-item:hover {
background: var(--color-bg-secondary);
}
.user-dropdown-item:focus {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
background: var(--color-bg-secondary);
}
/* User Menu Overlay */
.user-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
background: transparent;
}
/* Chevron rotation animation */
.rotate-180 {
transform: rotate(180deg);
transition: transform var(--transition-fast);
}
.pi-chevron-down {
transition: transform var(--transition-fast);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.user-dropdown {
min-width: 200px;
}
.user-dropdown-header {
padding: var(--space-sm);
}
.user-dropdown-item {
padding: var(--space-sm);
}
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div>
<!-- Menu Overlay -->
<div
class="slide-menu-overlay"
:class="{ open: isOpen }"
@click="closeMenu"
></div>
<!-- Slide Menu -->
<nav class="slide-menu" :class="{ open: isOpen }">
<!-- Navigation Section -->
<div class="menu-section">
<h3 class="menu-title">Navigation</h3>
<ul class="menu-list">
<li class="menu-item">
<router-link
to="/dashboard"
class="menu-link"
:class="{ active: $route.name === 'Dashboard' }"
@click="closeMenu"
>
<i class="menu-icon pi pi-home"></i>
<span>Dashboard</span>
</router-link>
</li>
<li class="menu-item">
<router-link
to="/invoices"
class="menu-link"
:class="{ active: $route.name === 'Invoices' }"
@click="closeMenu"
>
<i class="menu-icon pi pi-file-text"></i>
<span>Invoices</span>
</router-link>
</li>
<li class="menu-item">
<router-link
to="/bank-cash-register"
class="menu-link"
:class="{ active: $route.name === 'BankCashRegister' }"
@click="closeMenu"
>
<i class="menu-icon pi pi-money-bill"></i>
<span>Bank & Cash</span>
</router-link>
</li>
</ul>
</div>
</nav>
</div>
</template>
<script>
export default {
name: 'HamburgerMenu',
props: {
isOpen: {
type: Boolean,
default: false
}
},
emits: ['close'],
setup(props, { emit }) {
const closeMenu = () => {
emit('close')
}
return {
closeMenu
}
}
}
</script>

View File

@@ -0,0 +1,7 @@
export {
useResponsive,
useMobileNav,
useResponsiveTable,
useResponsiveForm,
useResponsiveGrid,
} from "./useResponsive";

View File

@@ -0,0 +1,311 @@
import { ref, onMounted, onUnmounted, computed } from "vue";
/**
* Composable for responsive design utilities
* Provides reactive breakpoint detection and mobile/desktop states
*/
export function useResponsive() {
const windowWidth = ref(0);
const windowHeight = ref(0);
// Breakpoint definitions (matching our CSS)
const breakpoints = {
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
"2xl": 1536,
};
// Update window dimensions
const updateDimensions = () => {
windowWidth.value = window.innerWidth;
windowHeight.value = window.innerHeight;
};
// Reactive breakpoint states
const isMobile = computed(() => windowWidth.value < breakpoints.md);
const isTablet = computed(
() =>
windowWidth.value >= breakpoints.md && windowWidth.value < breakpoints.lg,
);
const isDesktop = computed(() => windowWidth.value >= breakpoints.lg);
const isSmallScreen = computed(() => windowWidth.value < breakpoints.sm);
const isLargeScreen = computed(() => windowWidth.value >= breakpoints.xl);
// Specific breakpoint checks
const isAbove = (breakpoint) =>
computed(() => windowWidth.value >= breakpoints[breakpoint]);
const isBelow = (breakpoint) =>
computed(() => windowWidth.value < breakpoints[breakpoint]);
const isBetween = (min, max) =>
computed(
() =>
windowWidth.value >= breakpoints[min] &&
windowWidth.value < breakpoints[max],
);
// Device type detection
const isTouchDevice = computed(
() => "ontouchstart" in window || navigator.maxTouchPoints > 0,
);
const isPortrait = computed(() => window.innerHeight > window.innerWidth);
const isLandscape = computed(() => window.innerWidth > window.innerHeight);
// Screen size categories
const screenSize = computed(() => {
if (windowWidth.value < breakpoints.sm) return "xs";
if (windowWidth.value < breakpoints.md) return "sm";
if (windowWidth.value < breakpoints.lg) return "md";
if (windowWidth.value < breakpoints.xl) return "lg";
if (windowWidth.value < breakpoints["2xl"]) return "xl";
return "2xl";
});
// Grid columns helper based on screen size
const getGridCols = (config = {}) => {
const defaultConfig = {
xs: 1,
sm: 1,
md: 2,
lg: 3,
xl: 4,
"2xl": 4,
};
const cols = { ...defaultConfig, ...config };
return cols[screenSize.value] || cols.lg;
};
// Table rows per page based on screen size
const getTableRows = (config = {}) => {
const defaultConfig = {
xs: 10,
sm: 15,
md: 25,
lg: 50,
xl: 100,
"2xl": 100,
};
const rows = { ...defaultConfig, ...config };
return rows[screenSize.value] || rows.lg;
};
// Component size variants
const getComponentSize = () => {
if (isMobile.value) return "small";
if (isTablet.value) return "normal";
return "large";
};
// Padding/margin helpers
const getSpacing = (config = {}) => {
const defaultConfig = {
xs: "0.5rem",
sm: "0.75rem",
md: "1rem",
lg: "1.5rem",
xl: "2rem",
"2xl": "2rem",
};
const spacing = { ...defaultConfig, ...config };
return spacing[screenSize.value] || spacing.lg;
};
// Setup event listeners
onMounted(() => {
updateDimensions();
window.addEventListener("resize", updateDimensions);
window.addEventListener("orientationchange", () => {
// Delay to ensure correct dimensions after orientation change
setTimeout(updateDimensions, 100);
});
});
onUnmounted(() => {
window.removeEventListener("resize", updateDimensions);
window.removeEventListener("orientationchange", updateDimensions);
});
return {
// Dimensions
windowWidth,
windowHeight,
// Breakpoint states
isMobile,
isTablet,
isDesktop,
isSmallScreen,
isLargeScreen,
// Breakpoint utilities
isAbove,
isBelow,
isBetween,
// Device detection
isTouchDevice,
isPortrait,
isLandscape,
// Screen info
screenSize,
// Helpers
getGridCols,
getTableRows,
getComponentSize,
getSpacing,
// Breakpoints reference
breakpoints,
};
}
/**
* Composable for mobile navigation
*/
export function useMobileNav() {
const isMenuOpen = ref(false);
const { isMobile } = useResponsive();
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value;
};
const closeMenu = () => {
isMenuOpen.value = false;
};
const openMenu = () => {
isMenuOpen.value = true;
};
// Close menu when switching to desktop
const handleResize = () => {
if (!isMobile.value) {
closeMenu();
}
};
onMounted(() => {
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
});
return {
isMenuOpen,
toggleMenu,
closeMenu,
openMenu,
isMobile,
};
}
/**
* Composable for responsive table behavior
*/
export function useResponsiveTable() {
const { isMobile, isTablet, getTableRows } = useResponsive();
const shouldStackTable = computed(() => isMobile.value);
const shouldShowPagination = computed(() => !isMobile.value);
const defaultRows = computed(() => getTableRows());
// Mobile table item renderer
const getMobileTableClass = () => {
return shouldStackTable.value ? "mobile-stack" : "";
};
// Get visible columns for mobile
const getMobileColumns = (
allColumns,
priority = ["title", "amount", "status"],
) => {
if (!isMobile.value) return allColumns;
return allColumns.filter((col) => priority.includes(col.key));
};
return {
shouldStackTable,
shouldShowPagination,
defaultRows,
getMobileTableClass,
getMobileColumns,
isMobile,
isTablet,
};
}
/**
* Composable for responsive forms
*/
export function useResponsiveForm() {
const { isMobile, getSpacing } = useResponsive();
const getFormLayout = () => {
return isMobile.value ? "vertical" : "horizontal";
};
const getFormSpacing = () => {
return getSpacing({
xs: "0.5rem",
sm: "0.75rem",
md: "1rem",
lg: "1.5rem",
});
};
const shouldStackButtons = computed(() => isMobile.value);
const getFormClass = () => {
return isMobile.value ? "mobile-form-stack" : "";
};
const getButtonClass = () => {
return isMobile.value ? "mobile-full-width" : "";
};
return {
getFormLayout,
getFormSpacing,
shouldStackButtons,
getFormClass,
getButtonClass,
isMobile,
};
}
/**
* Composable for responsive cards/grids
*/
export function useResponsiveGrid() {
const { getGridCols, getSpacing, isMobile } = useResponsive();
const getGridColumns = (config) => {
return getGridCols(config);
};
const getGridGap = () => {
return getSpacing({
xs: "0.5rem",
sm: "0.75rem",
md: "1rem",
lg: "1.5rem",
});
};
const getCardClass = () => {
return isMobile.value ? "mobile-card-stack" : "";
};
return {
getGridColumns,
getGridGap,
getCardClass,
isMobile,
};
}

View File

@@ -0,0 +1,76 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import PrimeVue from "primevue/config";
// import Aura from '@primevue/themes/aura'
import ToastService from "primevue/toastservice";
import ConfirmationService from "primevue/confirmationservice";
// Core components
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import Password from "primevue/password";
import DataTable from "primevue/datatable";
import Column from "primevue/column";
import Card from "primevue/card";
import Toast from "primevue/toast";
import ConfirmDialog from "primevue/confirmdialog";
import Menu from "primevue/menu";
import Menubar from "primevue/menubar";
import Badge from "primevue/badge";
import Tag from "primevue/tag";
import Dropdown from "primevue/dropdown";
import AutoComplete from "primevue/autocomplete";
import Calendar from "primevue/calendar";
import ProgressSpinner from "primevue/progressspinner";
import Dialog from "primevue/dialog";
// PrimeVue CSS
import "primevue/resources/themes/saga-blue/theme.css";
import "primevue/resources/primevue.min.css";
// Icons
import "primeicons/primeicons.css";
// ROA2WEB CSS System (replaces global.css)
import "./assets/css/main.css";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
// Pinia store
app.use(createPinia());
// Vue Router
app.use(router);
// PrimeVue with default theme
app.use(PrimeVue, {
ripple: true,
});
// PrimeVue services
app.use(ToastService);
app.use(ConfirmationService);
// Global PrimeVue components
app.component("Button", Button);
app.component("InputText", InputText);
app.component("Password", Password);
app.component("DataTable", DataTable);
app.component("Column", Column);
app.component("Card", Card);
app.component("Toast", Toast);
app.component("ConfirmDialog", ConfirmDialog);
app.component("Menu", Menu);
app.component("Menubar", Menubar);
app.component("Badge", Badge);
app.component("Tag", Tag);
app.component("Dropdown", Dropdown);
app.component("AutoComplete", AutoComplete);
app.component("Calendar", Calendar);
app.component("ProgressSpinner", ProgressSpinner);
app.component("Dialog", Dialog);
app.mount("#app");

View File

@@ -0,0 +1,101 @@
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "../stores/auth";
// Import views
import LoginView from "../views/LoginView.vue";
import DashboardView from "../views/DashboardView.vue";
import InvoicesView from "../views/InvoicesView.vue";
import BankCashRegisterView from "../views/BankCashRegisterView.vue";
import TelegramView from "../views/TelegramView.vue";
const routes = [
{
path: "/",
redirect: "/dashboard",
},
{
path: "/login",
name: "Login",
component: LoginView,
meta: {
requiresAuth: false,
title: "Autentificare - ROA Reports",
},
},
{
path: "/dashboard",
name: "Dashboard",
component: DashboardView,
meta: {
requiresAuth: true,
title: "Dashboard - ROA Reports",
},
},
{
path: "/invoices",
name: "Invoices",
component: InvoicesView,
meta: {
requiresAuth: true,
title: "Facturi - ROA Reports",
},
},
{
path: "/bank-cash-register",
name: "BankCashRegister",
component: BankCashRegisterView,
meta: {
requiresAuth: true,
title: "Registru Casa si Banca - ROA Reports",
},
},
{
path: "/telegram",
name: "Telegram",
component: TelegramView,
meta: {
requiresAuth: true,
title: "Telegram Bot - ROA Reports",
},
},
{
path: "/:pathMatch(.*)*",
name: "NotFound",
redirect: "/dashboard",
},
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
linkActiveClass: "router-link-active",
linkExactActiveClass: "router-link-exact-active",
});
// Navigation guards
router.beforeEach((to, from, next) => {
const authStore = useAuthStore();
// Set page title
if (to.meta.title) {
document.title = to.meta.title;
}
// Check authentication
if (to.meta.requiresAuth !== false && !authStore.isAuthenticated) {
// Redirect to login if not authenticated
next("/login");
} else if (to.path === "/login" && authStore.isAuthenticated) {
// Redirect to dashboard if already authenticated and trying to access login
next("/dashboard");
} else {
next();
}
});
router.afterEach((to) => {
// Scroll to top after navigation
window.scrollTo(0, 0);
});
export default router;

View File

@@ -0,0 +1,139 @@
import axios from "axios";
// Create axios instance with base configuration
const apiService = axios.create({
baseURL: import.meta.env.BASE_URL + "api",
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
// Request interceptor to add auth token
apiService.interceptors.request.use(
(config) => {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
// Response interceptor for handling errors and token refresh
apiService.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
// Handle 401 Unauthorized errors
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem("refresh_token");
if (refreshToken) {
const response = await axios.post(import.meta.env.BASE_URL + "api/auth/refresh", {
refresh_token: refreshToken,
});
const { access_token } = response.data;
localStorage.setItem("access_token", access_token);
// Update the authorization header
apiService.defaults.headers.common["Authorization"] =
`Bearer ${access_token}`;
originalRequest.headers["Authorization"] = `Bearer ${access_token}`;
// Retry the original request
return apiService(originalRequest);
}
} catch (refreshError) {
// Refresh failed, clear tokens and redirect to login
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
// Note: selected_company is now per-user (selected_company_${username})
// and persists across sessions - not cleared on token expiry
// Redirect to login page
const loginPath = import.meta.env.BASE_URL + "login";
if (window.location.pathname !== loginPath) {
window.location.href = loginPath;
}
return Promise.reject(refreshError);
}
}
// Handle other errors
if (error.response) {
// Server responded with error status
const message =
error.response.data?.detail ||
error.response.data?.message ||
`Server error: ${error.response.status}`;
console.error("API Error:", {
status: error.response.status,
message: message,
url: error.config.url,
});
} else if (error.request) {
// Request was made but no response received
console.error("Network Error:", error.message);
} else {
// Something else happened
console.error("Request Error:", error.message);
}
return Promise.reject(error);
},
);
// API service methods
export { apiService };
// Specific API endpoints
export const authAPI = {
login: (credentials) => {
return apiService.post("/auth/login", {
username: credentials.username,
password: credentials.password,
});
},
refresh: (refreshToken) => {
return apiService.post("/auth/refresh", {
refresh_token: refreshToken,
});
},
logout: () => {
return apiService.post("/auth/logout");
},
};
export const companiesAPI = {
getAll: () => {
return apiService.get("/companies");
},
};
export const invoicesAPI = {
getByCompany: (companyCode, params = {}) => {
return apiService.get(`/invoices/${companyCode}`, { params });
},
getById: (companyCode, invoiceId) => {
return apiService.get(`/invoices/${companyCode}/${invoiceId}`);
},
};
export default apiService;

View File

@@ -0,0 +1,6 @@
export {
apiService,
authAPI,
companiesAPI,
invoicesAPI,
} from "./api";

View File

@@ -0,0 +1,188 @@
import { defineStore } from "pinia";
import { ref, computed, watch } from "vue";
import { apiService } from "../services/api";
import { useAuthStore } from "./auth";
export const useCompanyStore = defineStore("companies", () => {
// Initialize from localStorage - per user
const initializeSelectedCompany = () => {
// Get current username from auth store
const authStore = useAuthStore();
const username = authStore.user?.username;
if (!username) {
console.log('[Companies] No username available for initialization');
return null;
}
const key = `selected_company_${username}`;
const saved = localStorage.getItem(key);
if (saved) {
try {
const company = JSON.parse(saved);
console.log(`[Companies] Loaded saved company for user ${username}:`, company.name);
return company;
} catch (e) {
console.error('Failed to parse saved company', e);
localStorage.removeItem(key);
}
}
return null;
};
// State
const companies = ref([]);
const selectedCompany = ref(initializeSelectedCompany());
const isLoading = ref(false);
const error = ref(null);
// Watch for auth user changes to restore selected company
const authStore = useAuthStore();
watch(
() => authStore.user,
(newUser) => {
if (newUser && newUser.username && !selectedCompany.value) {
console.log('[Companies] User became available, attempting to restore selected company');
const restoredCompany = initializeSelectedCompany();
if (restoredCompany) {
selectedCompany.value = restoredCompany;
console.log('[Companies] Successfully restored selected company:', restoredCompany.name);
}
}
},
{ immediate: true }
);
// Getters
const companyList = computed(() => companies.value);
const hasCompanies = computed(() => companies.value.length > 0);
const selectedCompanyId = computed(
() => selectedCompany.value?.id_firma || null,
);
// Computed property for formatted company list display
const companyListFormatted = computed(() => {
return companies.value.map(company => ({
...company,
displayName: company.fiscal_code
? `${company.name} (${company.fiscal_code})`
: company.name
}));
});
// Actions
const loadCompanies = async () => {
isLoading.value = true;
error.value = null;
try {
console.log('[COMPANY STORE DEBUG] Loading companies...');
const response = await apiService.get("/companies");
console.log('[COMPANY STORE DEBUG] API Response:', response.data);
companies.value = response.data.companies || [];
console.log('[COMPANY STORE DEBUG] Companies array:', companies.value);
// Security validation: Check if saved company is accessible to current user
if (selectedCompany.value) {
const exists = companies.value.find(
c => c.id_firma === selectedCompany.value.id_firma
);
if (!exists) {
console.warn('[Companies][Security] Saved company not accessible to current user, clearing');
clearSelectedCompany();
} else {
console.log('[Companies][Security] Saved company validated successfully');
}
}
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load companies";
console.error("Failed to load companies:", err);
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const setSelectedCompany = (company) => {
selectedCompany.value = company;
// Get current username from auth store
const authStore = useAuthStore();
const username = authStore.user?.username;
if (!username) {
console.warn('[Companies] Cannot save company - no username available');
return;
}
const key = `selected_company_${username}`;
if (company) {
localStorage.setItem(key, JSON.stringify(company));
console.log(`[Companies] Saved company for user ${username}:`, company.name);
} else {
localStorage.removeItem(key);
console.log(`[Companies] Cleared company for user ${username}`);
}
};
const clearSelectedCompany = () => {
selectedCompany.value = null;
// Get current username from auth store
const authStore = useAuthStore();
const username = authStore.user?.username;
if (username) {
const key = `selected_company_${username}`;
localStorage.removeItem(key);
console.log(`[Companies] Cleared company for user ${username}`);
}
};
const getCompanyById = (id_firma) => {
return companies.value.find((company) => company.id_firma === parseInt(id_firma));
};
const clearError = () => {
error.value = null;
};
const reset = () => {
companies.value = [];
selectedCompany.value = null;
isLoading.value = false;
error.value = null;
// Clear saved company for current user
const authStore = useAuthStore();
const username = authStore.user?.username;
if (username) {
const key = `selected_company_${username}`;
localStorage.removeItem(key);
}
};
return {
// State
companies,
selectedCompany,
isLoading,
error,
// Getters
companyList,
companyListFormatted,
hasCompanies,
selectedCompanyId,
// Actions
loadCompanies,
setSelectedCompany,
clearSelectedCompany,
getCompanyById,
clearError,
reset,
};
});

View File

@@ -0,0 +1,373 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { apiService } from "../services/api";
export const useDashboardStore = defineStore("dashboard", () => {
// State existent
const summary = ref(null);
const trends = ref(null);
const isLoading = ref(false);
const error = ref(null);
// State nou pentru carduri
const performanceData = ref({});
const cashflowData = ref({});
const maturityData = ref({});
const currentPeriod = ref(null);
// State pentru detailed data pagination
const detailedDataTotal = ref(0);
// Cache pentru date
const dataCache = new Map();
const loadDashboardSummary = async (companyId) => {
isLoading.value = true;
error.value = null;
try {
const response = await apiService.get('/dashboard/summary', {
params: { company: companyId }
});
summary.value = response.data;
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load dashboard";
console.error("Failed to load dashboard:", err);
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const loadTrendData = async (companyId, period = '12m', chartType = 'line') => {
isLoading.value = true;
error.value = null;
try {
console.log(`Loading trend data for company ${companyId}, period: ${period}`);
const response = await apiService.get('/dashboard/trends', {
params: {
company: companyId,
period: period
}
});
// Validate response structure
if (!response.data) {
throw new Error('Empty response from trends API');
}
console.log('Raw trends response:', response.data);
// Transform backend response to Chart.js format
const backendData = response.data;
const transformedData = transformTrendsData(backendData);
if (!transformedData) {
throw new Error('Failed to transform trends data - invalid format');
}
trends.value = transformedData;
console.log('Transformed trends data:', transformedData);
return { success: true, data: transformedData };
} catch (err) {
const errorMessage = err.response?.data?.detail || err.message || "Failed to load trend data";
error.value = errorMessage;
console.error("Failed to load trend data:", err);
console.error("Error details:", {
status: err.response?.status,
statusText: err.response?.statusText,
data: err.response?.data
});
// Clear trends data and return error - no more mock data
trends.value = null;
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
// Transform backend trends data to Chart.js format AND preserve raw data
const transformTrendsData = (backendData) => {
if (!backendData || !backendData.periods || !Array.isArray(backendData.periods) || backendData.periods.length === 0) {
console.warn('Invalid trends data received:', backendData);
return null;
}
// Validate that we have all required data
const requiredFields = ['trezorerie_sold', 'clienti_sold', 'furnizori_sold', 'clienti_incasat', 'furnizori_achitat'];
for (const field of requiredFields) {
if (!backendData[field] || !Array.isArray(backendData[field])) {
console.warn(`Missing ${field} data`);
return null;
}
}
// Data is already in ASC order from backend
const periods = [...backendData.periods];
// Format labels for monthly data (YYYY-MM -> MM/YYYY)
const formattedPeriods = periods.map(period => {
const [year, month] = period.split('-');
const date = new Date(year, month - 1);
return date.toLocaleDateString('ro-RO', { month: '2-digit', year: 'numeric' });
});
// Preserve all raw data from backend for card calculations
return {
labels: formattedPeriods,
raw: {
// Current period data
periods: backendData.periods,
clienti_facturat: backendData.clienti_facturat || [],
clienti_incasat: backendData.clienti_incasat || [],
clienti_sold: backendData.clienti_sold || [],
furnizori_facturat: backendData.furnizori_facturat || [],
furnizori_achitat: backendData.furnizori_achitat || [],
furnizori_sold: backendData.furnizori_sold || [],
trezorerie_sold: backendData.trezorerie_sold || [],
// Previous period data (year-over-year comparison)
previous_periods: backendData.previous_periods || [],
clienti_facturat_prev: backendData.clienti_facturat_prev || [],
clienti_incasat_prev: backendData.clienti_incasat_prev || [],
clienti_sold_prev: backendData.clienti_sold_prev || [],
furnizori_facturat_prev: backendData.furnizori_facturat_prev || [],
furnizori_achitat_prev: backendData.furnizori_achitat_prev || [],
furnizori_sold_prev: backendData.furnizori_sold_prev || [],
trezorerie_sold_prev: backendData.trezorerie_sold_prev || [],
},
datasets: [
{
label: 'Trezorerie - Sold Net',
data: [...backendData.trezorerie_sold].map(val => Number(val) || 0),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: false,
pointBackgroundColor: 'rgb(59, 130, 246)',
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6
}
]
};
};
const loadDetailedData = async (dataType, companyId, page = 1, pageSize = 25, search = '') => {
isLoading.value = true;
error.value = null;
try {
const response = await apiService.get('/dashboard/detailed-data', {
params: {
company: companyId,
data_type: dataType,
page: page,
page_size: pageSize,
search: search
}
});
// Store total for pagination
detailedDataTotal.value = response.data.total || 0;
return {
success: true,
data: response.data.data || [], // Backend returns 'data' not 'items'
total: response.data.total || 0,
page: response.data.page || 1
};
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load detailed data";
console.error("Failed to load detailed data:", err);
// Return mock data structure for testing
const mockData = generateMockDetailedData(dataType);
detailedDataTotal.value = mockData.length;
return {
success: false,
error: error.value,
data: mockData,
total: mockData.length,
page: 1
};
} finally {
isLoading.value = false;
}
};
// Generate mock data for testing until backend endpoint is implemented
const generateMockDetailedData = (dataType) => {
switch(dataType) {
case 'clients':
return [
{ id: 1, client: 'SC ALPHA SRL', facturat: 15000, incasat: 12000, sold: 3000, status: 'Activ' },
{ id: 2, client: 'SC BETA SRL', facturat: 8500, incasat: 8500, sold: 0, status: 'Activ' },
{ id: 3, client: 'SC GAMMA SRL', facturat: 22000, incasat: 15000, sold: 7000, status: 'Activ' },
{ id: 4, client: 'SC DELTA SRL', facturat: 5500, incasat: 2000, sold: 3500, status: 'Întârziere' },
{ id: 5, client: 'SC EPSILON SRL', facturat: 18000, incasat: 18000, sold: 0, status: 'Activ' }
];
case 'suppliers':
return [
{ id: 1, furnizor: 'SC SUPPLIER A SRL', facturat: 12000, achitat: 10000, sold: 2000, status: 'Activ' },
{ id: 2, furnizor: 'SC SUPPLIER B SRL', facturat: 7500, achitat: 7500, sold: 0, status: 'Activ' },
{ id: 3, furnizor: 'SC SUPPLIER C SRL', facturat: 19000, achitat: 12000, sold: 7000, status: 'Pendente' },
{ id: 4, furnizor: 'SC SUPPLIER D SRL', facturat: 4200, achitat: 4200, sold: 0, status: 'Activ' },
{ id: 5, furnizor: 'SC SUPPLIER E SRL', facturat: 16800, achitat: 8000, sold: 8800, status: 'Pendente' }
];
case 'treasury':
return [
{ id: 1, cont: '5121', nume_cont: 'Cont curent BCR', sold: 45000, valuta: 'RON', tip: 'Bancă' },
{ id: 2, cont: '5311', nume_cont: 'Casa RON', sold: 2500, valuta: 'RON', tip: 'Numerar' },
{ id: 3, cont: '5124', nume_cont: 'Cont curent BRD EUR', sold: 8500, valuta: 'EUR', tip: 'Bancă' },
{ id: 4, cont: '5125', nume_cont: 'Cont economii ING', sold: 125000, valuta: 'RON', tip: 'Economii' },
{ id: 5, cont: '5312', nume_cont: 'Casa valută', sold: 500, valuta: 'EUR', tip: 'Numerar' }
];
default:
return [];
}
};
// Funcții noi pentru carduri
const loadPerformanceData = async (companyId, period = '7d') => {
const cacheKey = `performance-${companyId}-${period}`;
// Check cache
if (dataCache.has(cacheKey)) {
performanceData.value[period] = dataCache.get(cacheKey);
return { success: true, data: dataCache.get(cacheKey) };
}
try {
const response = await apiService.get('/dashboard/performance', {
params: { company: companyId, period }
});
performanceData.value[period] = response.data;
dataCache.set(cacheKey, response.data);
return { success: true, data: response.data };
} catch (err) {
console.error('Failed to load performance data:', err);
return { success: false, error: err.message };
}
};
const loadCashFlowData = async (companyId, period = '7d') => {
const cacheKey = `cashflow-${companyId}-${period}`;
if (dataCache.has(cacheKey)) {
cashflowData.value[period] = dataCache.get(cacheKey);
return { success: true, data: dataCache.get(cacheKey) };
}
try {
const response = await apiService.get('/dashboard/cashflow', {
params: { company: companyId, period }
});
cashflowData.value[period] = response.data;
dataCache.set(cacheKey, response.data);
return { success: true, data: response.data };
} catch (err) {
console.error('Failed to load cashflow data:', err);
return { success: false, error: err.message };
}
};
const loadMaturityData = async (companyId, period = '7d') => {
const cacheKey = `maturity-${companyId}-${period}`;
if (dataCache.has(cacheKey)) {
maturityData.value[period] = dataCache.get(cacheKey);
return { success: true, data: dataCache.get(cacheKey) };
}
try {
const response = await apiService.get('/dashboard/maturity', {
params: { company: companyId, period }
});
maturityData.value[period] = response.data;
dataCache.set(cacheKey, response.data);
return { success: true, data: response.data };
} catch (err) {
console.error('Failed to load maturity data:', err);
return { success: false, error: err.message };
}
};
const loadCurrentPeriod = async (companyId) => {
try {
const response = await apiService.get('/dashboard/current-period', {
params: { company: companyId }
});
currentPeriod.value = response.data;
return { success: true, data: response.data };
} catch (err) {
console.error('Failed to load current period:', err);
// Fallback to current date if API fails
const now = new Date();
const fallbackPeriod = {
year: now.getFullYear(),
month: now.getMonth() + 1,
period: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
};
currentPeriod.value = fallbackPeriod;
return { success: false, error: err.message, data: fallbackPeriod };
}
};
// Clear cache
const clearCache = () => {
dataCache.clear();
};
const reset = () => {
summary.value = null;
trends.value = null;
isLoading.value = false;
error.value = null;
// Clear new data as well
performanceData.value = {};
cashflowData.value = {};
maturityData.value = {};
currentPeriod.value = null;
clearCache();
};
return {
// Existing
summary,
trends,
isLoading,
error,
loadDashboardSummary,
loadTrendData,
loadDetailedData,
reset,
// New
performanceData,
cashflowData,
maturityData,
currentPeriod,
loadPerformanceData,
loadCashFlowData,
loadMaturityData,
loadCurrentPeriod,
clearCache,
// Detailed data pagination
detailedDataTotal
};
});

View File

@@ -0,0 +1,5 @@
export { useAuthStore } from "./auth";
export { useCompanyStore } from "./companies";
export { useInvoicesStore } from "./invoices";
export { useDashboardStore } from "./dashboard";
export { useTreasuryStore } from "./treasury";

View File

@@ -0,0 +1,165 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { apiService } from "../services/api";
export const useInvoicesStore = defineStore("invoices", () => {
// State
const invoices = ref([]);
const isLoading = ref(false);
const error = ref(null);
const filters = ref({
company: null,
type: "CLIENTI", // CLIENTI or FURNIZORI
dateFrom: null,
dateTo: null,
searchTerm: "",
});
const pagination = ref({
page: 0,
rows: 50,
totalRecords: 0,
});
// Getters
const invoiceList = computed(() => invoices.value);
const hasInvoices = computed(() => invoices.value.length > 0);
const totalInvoices = computed(() => pagination.value.totalRecords);
const paidInvoices = computed(() =>
invoices.value.filter((invoice) => invoice.css_class === "invoice-paid"),
);
const overdueInvoices = computed(() =>
invoices.value.filter((invoice) => invoice.css_class === "invoice-overdue"),
);
const totalAmountPaid = computed(() =>
paidInvoices.value.reduce((sum, invoice) => sum + (invoice.suma || 0), 0),
);
const totalAmountOverdue = computed(() =>
overdueInvoices.value.reduce(
(sum, invoice) => sum + (invoice.suma || 0),
0,
),
);
// Actions
const loadInvoices = async (companyCode, options = {}) => {
if (!companyCode) {
error.value = "Company code is required";
return { success: false, error: error.value };
}
isLoading.value = true;
error.value = null;
try {
const params = {
partner_type: filters.value.type,
page: pagination.value.page + 1,
size: pagination.value.rows,
...options,
};
if (filters.value.dateFrom) {
params.date_from = filters.value.dateFrom;
}
if (filters.value.dateTo) {
params.date_to = filters.value.dateTo;
}
if (filters.value.searchTerm) {
params.search = filters.value.searchTerm;
}
// Fixed: Use company as query parameter instead of path parameter
const response = await apiService.get(`/invoices/`, {
params: {
company: companyCode,
...params
}
});
invoices.value = response.data.invoices || [];
pagination.value.totalRecords = response.data.total_count || 0;
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load invoices";
console.error("Failed to load invoices:", err);
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const setFilters = (newFilters) => {
filters.value = { ...filters.value, ...newFilters };
};
const setPagination = (newPagination) => {
pagination.value = { ...pagination.value, ...newPagination };
};
const setInvoiceType = (type) => {
filters.value.type = type;
};
const clearFilters = () => {
filters.value = {
company: null,
type: "CLIENTI",
dateFrom: null,
dateTo: null,
searchTerm: "",
};
};
const clearError = () => {
error.value = null;
};
const reset = () => {
invoices.value = [];
isLoading.value = false;
error.value = null;
clearFilters();
pagination.value = {
page: 0,
rows: 50,
totalRecords: 0,
};
};
const getInvoiceById = (id) => {
return invoices.value.find((invoice) => invoice.id === id);
};
return {
// State
invoices,
isLoading,
error,
filters,
pagination,
// Getters
invoiceList,
hasInvoices,
totalInvoices,
paidInvoices,
overdueInvoices,
totalAmountPaid,
totalAmountOverdue,
// Actions
loadInvoices,
setFilters,
setPagination,
setInvoiceType,
clearFilters,
clearError,
reset,
getInvoiceById,
};
});

View File

@@ -0,0 +1,77 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { apiService } from "../services/api";
export const useTreasuryStore = defineStore("treasury", () => {
const registers = ref([]);
const isLoading = ref(false);
const error = ref(null);
const pagination = ref({
page: 0,
rows: 50,
totalRecords: 0,
});
const totals = ref({
total_incasari: 0,
total_plati: 0
});
const loadBankCashRegister = async (companyId, filters = {}) => {
isLoading.value = true;
error.value = null;
try {
const params = {
company: companyId,
page: pagination.value.page + 1,
page_size: pagination.value.rows,
...filters
};
const response = await apiService.get('/treasury/bank-cash-register', {
params
});
registers.value = response.data.registers || [];
pagination.value.totalRecords = response.data.total_count || 0;
totals.value = {
total_incasari: response.data.total_incasari,
total_plati: response.data.total_plati
};
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load register";
console.error("Failed to load register:", err);
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const setPagination = (newPagination) => {
pagination.value = { ...pagination.value, ...newPagination };
};
const reset = () => {
registers.value = [];
isLoading.value = false;
error.value = null;
pagination.value = {
page: 0,
rows: 50,
totalRecords: 0,
};
};
return {
registers,
isLoading,
error,
pagination,
totals,
loadBankCashRegister,
setPagination,
reset
};
});

View File

@@ -0,0 +1,221 @@
import * as XLSX from 'xlsx';
import jsPDF from 'jspdf';
import 'jspdf-autotable';
/**
* Format currency values for export
*/
const formatCurrency = (value) => {
if (value == null || value === '-') return '-';
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value);
};
/**
* Export data to Excel
* @param {Array} data - Array of objects to export
* @param {String} filename - Name of the file (without extension)
* @param {String} sheetName - Name of the Excel sheet
*/
export const exportToExcel = (data, filename, sheetName = 'Sheet1') => {
try {
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
XLSX.writeFile(wb, `${filename}_${new Date().toISOString().split('T')[0]}.xlsx`);
return { success: true };
} catch (error) {
console.error('Excel export failed:', error);
return { success: false, error };
}
};
/**
* Export data to PDF
* @param {Array} data - Array of objects to export
* @param {Array} columns - Column definitions [{field: 'key', header: 'Display Name', type: 'currency'}]
* @param {String} filename - Name of the file (without extension)
* @param {String} title - Title for the PDF document
*/
export const exportToPDF = (data, columns, filename, title) => {
try {
// Check if data exists
if (!data || data.length === 0) {
console.error('No data to export');
return { success: false, error: 'No data available' };
}
// Check if jsPDF is properly imported
if (typeof jsPDF === 'undefined') {
console.error('jsPDF not properly imported');
return { success: false, error: 'PDF library not available' };
}
const doc = new jsPDF('landscape', 'mm', 'a4');
// Add title
doc.setFontSize(16);
doc.text(title, 14, 15);
// Add generation date
doc.setFontSize(10);
doc.text(`Generat: ${new Date().toLocaleString('ro-RO')}`, 14, 25);
// Prepare table data
const tableColumns = columns.map(col => col.header);
const tableRows = data.map(row =>
columns.map(col => {
const value = row[col.field];
if (col.type === 'currency') {
return formatCurrency(value);
}
return value || '-';
})
);
// Check if autoTable is available
if (typeof doc.autoTable === 'function') {
// Add table using autoTable
doc.autoTable({
head: [tableColumns],
body: tableRows,
startY: 30,
styles: {
fontSize: 9,
cellPadding: 2,
halign: 'center'
},
headStyles: {
fillColor: [102, 126, 234],
textColor: 255,
fontStyle: 'bold'
},
alternateRowStyles: { fillColor: [245, 245, 245] },
columnStyles: {
// Right align currency columns
...Object.fromEntries(
columns.map((col, index) =>
col.type === 'currency' ? [index, { halign: 'right' }] : null
).filter(Boolean)
)
}
});
} else {
// Fallback: manual table creation
let yPos = 40;
// Draw headers
doc.setFontSize(10);
doc.setFont(undefined, 'bold');
tableColumns.forEach((header, index) => {
doc.text(header, 14 + (index * 40), yPos);
});
// Draw rows
doc.setFont(undefined, 'normal');
tableRows.forEach((row, rowIndex) => {
yPos += 10;
row.forEach((cell, cellIndex) => {
doc.text(String(cell), 14 + (cellIndex * 40), yPos);
});
});
}
// Save PDF
doc.save(`${filename}_${new Date().toISOString().split('T')[0]}.pdf`);
return { success: true };
} catch (error) {
console.error('PDF export error details:', error);
return { success: false, error: error.message || 'PDF generation failed' };
}
};
/**
* Export General Totals table
*/
export const exportGeneralTotals = (summaryData) => {
const data = [
{
Tip: 'Clienți',
'Total Facturat': summaryData?.clienti_total_facturat || 0,
'Total Încasat': summaryData?.clienti_total_incasat || 0,
'Sold Net': summaryData?.clienti_sold_total || 0,
'Sold În Termen': summaryData?.clienti_sold_in_termen || 0,
'Sold Restant': summaryData?.clienti_sold_restant || 0
},
{
Tip: 'Furnizori',
'Total Facturat': summaryData?.furnizori_total_facturat || 0,
'Total Achitat': summaryData?.furnizori_total_achitat || 0,
'Sold Net': summaryData?.furnizori_sold_total || 0,
'Sold În Termen': summaryData?.furnizori_sold_in_termen || 0,
'Sold Restant': summaryData?.furnizori_sold_restant || 0
},
{
Tip: 'Trezorerie',
'Total Facturat': '-',
'Total Încasat/Achitat': '-',
'Sold Net': summaryData?.trezorerie_sold || 0,
'Sold În Termen': '-',
'Sold Restant': '-'
}
];
return data;
};
/**
* Export Sold Net Breakdown table
*/
export const exportSoldNetBreakdown = (summaryData) => {
const data = [
{
Categorie: 'Clienți - Restant',
'TOTAL': summaryData?.clienti_sold_restant || 0,
'7 zile': summaryData?.clienti_restant_7 || 0,
'14 zile': summaryData?.clienti_restant_14 || 0,
'30 zile': summaryData?.clienti_restant_30 || 0,
'60 zile': summaryData?.clienti_restant_60 || 0,
'90 zile': summaryData?.clienti_restant_90 || 0,
'90+ zile': summaryData?.clienti_restant_over_90 || 0
},
{
Categorie: 'Furnizori - Restant',
'TOTAL': summaryData?.furnizori_sold_restant || 0,
'7 zile': summaryData?.furnizori_restant_7 || 0,
'14 zile': summaryData?.furnizori_restant_14 || 0,
'30 zile': summaryData?.furnizori_restant_30 || 0,
'60 zile': summaryData?.furnizori_restant_60 || 0,
'90 zile': summaryData?.furnizori_restant_90 || 0,
'90+ zile': summaryData?.furnizori_restant_over_90 || 0
}
];
return data;
};
/**
* Export Trend Data
*/
export const exportTrendData = (trendsData, period, chartType) => {
if (!trendsData || !trendsData.labels || !trendsData.datasets) {
return [];
}
const data = trendsData.labels.map((label, index) => {
const row = { Perioada: label };
trendsData.datasets.forEach(dataset => {
const value = dataset.data[index];
row[dataset.label] = value || 0;
});
return row;
});
return data;
};

View File

View File

@@ -0,0 +1,376 @@
<template>
<div class="app-container">
<div class="register-view">
<!-- Header -->
<div class="view-header">
<h1 class="page-title">
<i class="pi pi-wallet"></i>
Registrul de Casă și Bancă
</h1>
<p class="page-subtitle">
Vizualizați toate mișcările din conturile de bancă și casă
</p>
</div>
<!-- Filters -->
<Card class="filters-card">
<template #content>
<div class="filters-grid">
<div class="filter-item">
<label>Data început</label>
<Calendar v-model="filters.dateFrom" dateFormat="dd/mm/yy" />
</div>
<div class="filter-item">
<label>Data sfârșit</label>
<Calendar v-model="filters.dateTo" dateFormat="dd/mm/yy" />
</div>
<div class="filter-item">
<label>Căutare partener</label>
<InputText v-model="filters.partnerName" placeholder="Nume partener..." />
</div>
<div class="filter-actions">
<Button
label="Aplică Filtre"
icon="pi pi-filter"
@click="applyFilters"
/>
<Button
label="Resetează"
icon="pi pi-times"
class="p-button-secondary"
@click="resetFilters"
/>
</div>
</div>
</template>
</Card>
<!-- Summary Stats -->
<div class="summary-stats">
<Card class="stat-card">
<template #content>
<div class="stat-content">
<div class="stat-icon green">
<i class="pi pi-arrow-down"></i>
</div>
<div class="stat-details">
<h3 class="stat-value">{{ formatCurrency(treasuryStore.totals.total_incasari) }}</h3>
<p class="stat-label">Total Încasări</p>
</div>
</div>
</template>
</Card>
<Card class="stat-card">
<template #content>
<div class="stat-content">
<div class="stat-icon red">
<i class="pi pi-arrow-up"></i>
</div>
<div class="stat-details">
<h3 class="stat-value">{{ formatCurrency(treasuryStore.totals.total_plati) }}</h3>
<p class="stat-label">Total Plăți</p>
</div>
</div>
</template>
</Card>
</div>
<!-- Data Table -->
<Card class="data-card">
<template #content>
<DataTable
:value="treasuryStore.registers"
:loading="treasuryStore.isLoading"
:paginator="true"
:rows="pagination.rows"
:total-records="treasuryStore.pagination.totalRecords"
:lazy="true"
@page="onPage"
class="p-datatable-sm"
:rowClass="getRowClass"
>
<Column field="dataact" header="Data">
<template #body="slotProps">
{{ formatDate(slotProps.data.dataact) }}
</template>
</Column>
<Column field="nract" header="Nr. Act" style="width: 100px" />
<Column field="nume" header="Partener" />
<Column field="nume_cont_bancar" header="Cont" />
<Column field="tip_registru" header="Tip">
<template #body="slotProps">
<Tag :value="slotProps.data.tip_registru" :severity="getRegisterSeverity(slotProps.data.tip_registru)" />
</template>
</Column>
<Column field="incasari" header="Încasări">
<template #body="slotProps">
<span class="amount-green" v-if="slotProps.data.incasari > 0">
{{ formatCurrency(slotProps.data.incasari, slotProps.data.valuta) }}
</span>
<span v-else>-</span>
</template>
</Column>
<Column field="plati" header="Plăți">
<template #body="slotProps">
<span class="amount-red" v-if="slotProps.data.plati > 0">
{{ formatCurrency(slotProps.data.plati, slotProps.data.valuta) }}
</span>
<span v-else>-</span>
</template>
</Column>
<Column field="sold" header="Sold">
<template #body="slotProps">
<span :class="slotProps.data.sold >= 0 ? 'amount-green' : 'amount-red'">
{{ formatCurrency(slotProps.data.sold, slotProps.data.valuta) }}
</span>
</template>
</Column>
<Column field="explicatia" header="Explicație" />
</DataTable>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { useTreasuryStore } from "../stores/treasury";
import { useCompanyStore } from "../stores/companies";
import { format } from "date-fns";
import { ro } from "date-fns/locale";
const treasuryStore = useTreasuryStore();
const companyStore = useCompanyStore();
const filters = ref({
dateFrom: null,
dateTo: null,
partnerName: ""
});
const pagination = ref({
page: 0,
rows: 50
});
const formatCurrency = (amount, currency = 'RON') => {
if (!amount) return "0,00 " + currency;
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: currency
}).format(amount);
};
const formatDate = (dateString) => {
if (!dateString) return "";
return format(new Date(dateString), "dd MMM yyyy", { locale: ro });
};
const getRowClass = (data) => {
return data.tip_registru.includes('BANCA') ? 'bank-row' : 'cash-row';
};
const getRegisterSeverity = (type) => {
if (type.includes('BANCA')) return 'info';
if (type.includes('CASA')) return 'warning';
return null;
};
const onPage = (event) => {
pagination.value = event;
loadData();
};
const applyFilters = () => {
pagination.value.page = 0;
loadData();
};
const resetFilters = () => {
filters.value = {
dateFrom: null,
dateTo: null,
partnerName: ""
};
loadData();
};
const loadData = async () => {
if (!companyStore.selectedCompany) return;
treasuryStore.setPagination(pagination.value);
await treasuryStore.loadBankCashRegister(
companyStore.selectedCompany.id_firma,
{
date_from: filters.value.dateFrom?.toISOString().split("T")[0],
date_to: filters.value.dateTo?.toISOString().split("T")[0],
partner_name: filters.value.partnerName
}
);
};
onMounted(() => {
if (companyStore.selectedCompany) {
loadData();
}
});
</script>
<style scoped>
.app-container {
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
.view-header {
margin-bottom: 2rem;
}
.page-title {
color: var(--primary-color);
font-size: 2rem;
font-weight: 600;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-subtitle {
color: var(--text-color-secondary);
font-size: 1.1rem;
margin: 0;
}
.filters-card {
margin-bottom: 2rem;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
align-items: end;
}
.filter-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-item label {
font-weight: 600;
color: var(--text-color);
}
.filter-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-start;
}
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
border-radius: 12px;
border: 1px solid var(--surface-border);
overflow: hidden;
}
.stat-content {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem;
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
}
.stat-icon.green {
background: linear-gradient(135deg, var(--green-500), var(--green-600));
}
.stat-icon.red {
background: linear-gradient(135deg, var(--red-500), var(--red-600));
}
.stat-details h3 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
color: var(--text-color);
}
.stat-details p {
font-size: 0.875rem;
color: var(--text-color-secondary);
margin: 0.25rem 0 0 0;
}
.data-card {
border-radius: 12px;
border: 1px solid var(--surface-border);
}
.amount-green {
color: var(--green-600);
font-weight: 600;
}
.amount-red {
color: var(--red-600);
font-weight: 600;
}
:deep(.bank-row) {
background-color: var(--blue-50);
}
:deep(.cash-row) {
background-color: var(--yellow-50);
}
@media (max-width: 768px) {
.app-container {
padding: 1rem;
}
.page-title {
font-size: 1.75rem;
}
.filters-grid {
grid-template-columns: 1fr;
}
.summary-stats {
grid-template-columns: 1fr;
}
.filter-actions {
justify-content: stretch;
}
.filter-actions .p-button {
flex: 1;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,754 @@
<template>
<div class="app-container">
<div class="invoices">
<!-- Header Section -->
<div class="page-header">
<h1 class="page-title">
<i class="pi pi-file-text"></i>
Facturi
</h1>
<p class="page-subtitle">
Vizualizați și gestionați facturile pentru compania selectată
</p>
</div>
<!-- Company Selection -->
<Card v-if="!companyStore.selectedCompany" class="company-selection-card">
<template #content>
<div class="company-selection">
<p class="text-color-secondary mb-3">
Selectați o companie pentru a vizualiza facturile:
</p>
<Dropdown
v-model="selectedCompanyId"
:options="companyStore.companyListFormatted"
option-label="displayName"
option-value="id_firma"
placeholder="Alegeți compania"
class="w-full"
@change="handleCompanyChange"
/>
</div>
</template>
</Card>
<!-- Filters and Controls -->
<Card v-if="companyStore.selectedCompany" class="filters-card">
<template #content>
<div class="filters-container">
<div class="filters-row">
<!-- Invoice Type -->
<div class="filter-group">
<label class="filter-label">Tip Factură</label>
<Dropdown
v-model="filters.type"
:options="invoiceTypes"
option-label="label"
option-value="value"
placeholder="Tip factură"
@change="handleFilterChange"
/>
</div>
<!-- Date Range -->
<div class="filter-group">
<label class="filter-label">Data De</label>
<Calendar
v-model="filters.dateFrom"
date-format="dd/mm/yy"
placeholder="Selectați data"
@date-select="handleFilterChange"
/>
</div>
<div class="filter-group">
<label class="filter-label">Data Până</label>
<Calendar
v-model="filters.dateTo"
date-format="dd/mm/yy"
placeholder="Selectați data"
@date-select="handleFilterChange"
/>
</div>
<!-- Search -->
<div class="filter-group search-group">
<label class="filter-label">Căutare</label>
<InputText
v-model="filters.searchTerm"
placeholder="Căutați după număr, partener..."
@input="handleSearchChange"
/>
</div>
</div>
<div class="filters-actions">
<Button
icon="pi pi-filter-slash"
label="Resetează Filtre"
class="p-button-outlined p-button-secondary"
@click="clearFilters"
/>
<Button
icon="pi pi-refresh"
label="Actualizează"
:loading="invoicesStore.isLoading"
@click="refreshData"
/>
</div>
</div>
</template>
</Card>
<!-- Summary Statistics -->
<div
v-if="companyStore.selectedCompany && invoicesStore.hasInvoices"
class="summary-stats"
>
<Card class="stat-card stat-total">
<template #content>
<div class="stat-content">
<div class="stat-icon">
<i class="pi pi-file-text"></i>
</div>
<div class="stat-details">
<h3 class="stat-value">{{ invoicesStore.totalInvoices }}</h3>
<p class="stat-label">Total Facturi</p>
</div>
</div>
</template>
</Card>
<Card class="stat-card stat-paid">
<template #content>
<div class="stat-content">
<div class="stat-icon">
<i class="pi pi-check-circle"></i>
</div>
<div class="stat-details">
<h3 class="stat-value">
{{ invoicesStore.paidInvoices.length }}
</h3>
<p class="stat-label">Achitate</p>
<small class="stat-amount">{{
formatCurrency(invoicesStore.totalAmountPaid)
}}</small>
</div>
</div>
</template>
</Card>
<Card class="stat-card stat-overdue">
<template #content>
<div class="stat-content">
<div class="stat-icon">
<i class="pi pi-exclamation-circle"></i>
</div>
<div class="stat-details">
<h3 class="stat-value">
{{ invoicesStore.overdueInvoices.length }}
</h3>
<p class="stat-label">Restante</p>
<small class="stat-amount">{{
formatCurrency(invoicesStore.totalAmountOverdue)
}}</small>
</div>
</div>
</template>
</Card>
</div>
<!-- Invoices Table -->
<Card v-if="companyStore.selectedCompany" class="table-card">
<template #content>
<DataTable
:value="invoicesStore.invoiceList"
:loading="invoicesStore.isLoading"
:paginator="true"
:rows="pagination.rows"
:total-records="invoicesStore.totalInvoices"
:lazy="true"
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rows-per-page-options="[25, 50, 100]"
current-page-report-template="Afișare {first} - {last} din {totalRecords} înregistrări"
:row-class="getRowClass"
responsive-layout="scroll"
@page="onPageChange"
@sort="onSort"
>
<template #empty>
<div class="no-data">
<i class="pi pi-info-circle"></i>
<p>Nu au fost găsite facturi</p>
</div>
</template>
<template #loading>
<div class="loading-table">
<ProgressSpinner />
<p>Se încarcă facturile...</p>
</div>
</template>
<Column field="numar_document" header="Număr Document" sortable>
<template #body="slotProps">
<strong>{{ slotProps.data.numar_document }}</strong>
</template>
</Column>
<Column field="data_document" header="Data Document" sortable>
<template #body="slotProps">
{{ formatDate(slotProps.data.data_document) }}
</template>
</Column>
<Column field="nume_partener" header="Partener" sortable>
<template #body="slotProps">
<div class="partner-info">
<span class="partner-name">{{
slotProps.data.nume_partener
}}</span>
<small
v-if="slotProps.data.cod_partener"
class="partner-code"
>
{{ slotProps.data.cod_partener }}
</small>
</div>
</template>
</Column>
<Column field="suma" header="Sumă" sortable>
<template #body="slotProps">
<span class="amount" :class="getAmountClass(slotProps.data)">
{{ formatCurrency(slotProps.data.suma) }}
</span>
</template>
</Column>
<Column field="css_class" header="Status">
<template #body="slotProps">
<Tag
:value="getStatusText(slotProps.data.css_class)"
:severity="getStatusSeverity(slotProps.data.css_class)"
/>
</template>
</Column>
<Column field="data_scadenta" header="Data Scadență" sortable>
<template #body="slotProps">
{{ formatDate(slotProps.data.data_scadenta) }}
</template>
</Column>
<Column header="Acțiuni" :exportable="false">
<template #body="slotProps">
<div class="table-actions">
<Button
icon="pi pi-eye"
class="p-button-rounded p-button-text p-button-sm"
v-tooltip="'Vezi detalii'"
@click="viewInvoiceDetails(slotProps.data)"
/>
</div>
</template>
</Column>
</DataTable>
</template>
</Card>
<!-- Invoice Details Dialog -->
<Dialog
v-model:visible="showDetailsDialog"
:header="`Detalii Factură ${selectedInvoice?.numar_document}`"
:modal="true"
:style="{ width: '50vw' }"
:breakpoints="{ '960px': '75vw', '641px': '90vw' }"
>
<div v-if="selectedInvoice" class="invoice-details">
<div class="details-grid">
<div class="detail-item">
<label>Număr Document:</label>
<span>{{ selectedInvoice.numar_document }}</span>
</div>
<div class="detail-item">
<label>Data Document:</label>
<span>{{ formatDate(selectedInvoice.data_document) }}</span>
</div>
<div class="detail-item">
<label>Partener:</label>
<span>{{ selectedInvoice.nume_partener }}</span>
</div>
<div class="detail-item">
<label>Cod Partener:</label>
<span>{{ selectedInvoice.cod_partener || "-" }}</span>
</div>
<div class="detail-item">
<label>Sumă:</label>
<span class="amount">{{
formatCurrency(selectedInvoice.suma)
}}</span>
</div>
<div class="detail-item">
<label>Status:</label>
<Tag
:value="getStatusText(selectedInvoice.css_class)"
:severity="getStatusSeverity(selectedInvoice.css_class)"
/>
</div>
<div class="detail-item">
<label>Data Scadență:</label>
<span>{{ formatDate(selectedInvoice.data_scadenta) }}</span>
</div>
</div>
</div>
</Dialog>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from "vue";
import { useToast } from "primevue/usetoast";
import { useCompanyStore } from "../stores/companies";
import { useInvoicesStore } from "../stores/invoices";
import { format } from "date-fns";
import { ro } from "date-fns/locale";
const toast = useToast();
const companyStore = useCompanyStore();
const invoicesStore = useInvoicesStore();
// State
const selectedCompanyId = ref(companyStore.selectedCompany?.id_firma || null);
const showDetailsDialog = ref(false);
const selectedInvoice = ref(null);
const filters = ref({
type: "CLIENTI",
dateFrom: null,
dateTo: null,
searchTerm: "",
});
const pagination = ref({
page: 0,
rows: 50,
});
// Options
const invoiceTypes = [
{ label: "Clienți", value: "CLIENTI" },
{ label: "Furnizori", value: "FURNIZORI" },
];
// Methods
const formatCurrency = (amount) => {
if (!amount) return "0,00 RON";
return new Intl.NumberFormat("ro-RO", {
style: "currency",
currency: "RON",
}).format(amount);
};
const formatDate = (dateString) => {
if (!dateString) return "";
try {
return format(new Date(dateString), "dd/MM/yyyy", { locale: ro });
} catch (error) {
return dateString;
}
};
const getStatusText = (cssClass) => {
switch (cssClass) {
case "invoice-paid":
return "Achitat";
case "invoice-overdue":
return "Restant";
default:
return "Neutru";
}
};
const getStatusSeverity = (cssClass) => {
switch (cssClass) {
case "invoice-paid":
return "success";
case "invoice-overdue":
return "danger";
default:
return "info";
}
};
const getRowClass = (data) => {
return data.css_class || "";
};
const getAmountClass = (invoice) => {
switch (invoice.css_class) {
case "invoice-paid":
return "amount-paid";
case "invoice-overdue":
return "amount-overdue";
default:
return "";
}
};
const handleCompanyChange = async () => {
if (!selectedCompanyId.value) return;
const company = companyStore.getCompanyById(selectedCompanyId.value);
if (company) {
companyStore.setSelectedCompany(company);
await loadInvoices();
}
};
const handleFilterChange = async () => {
pagination.value.page = 0;
await loadInvoices();
};
const handleSearchChange = (() => {
let timeout;
return () => {
clearTimeout(timeout);
timeout = setTimeout(async () => {
pagination.value.page = 0;
await loadInvoices();
}, 500);
};
})();
const clearFilters = async () => {
filters.value = {
type: "CLIENTI",
dateFrom: null,
dateTo: null,
searchTerm: "",
};
pagination.value.page = 0;
await loadInvoices();
};
const refreshData = async () => {
await loadInvoices();
toast.add({
severity: "success",
summary: "Actualizare reușită",
detail: "Facturile au fost actualizate cu succes",
life: 3000,
});
};
const loadInvoices = async () => {
if (!companyStore.selectedCompany) return;
try {
invoicesStore.setFilters(filters.value);
invoicesStore.setPagination(pagination.value);
await invoicesStore.loadInvoices(companyStore.selectedCompany.id_firma, {
tip: filters.value.type,
date_from: filters.value.dateFrom?.toISOString().split("T")[0],
date_to: filters.value.dateTo?.toISOString().split("T")[0],
search: filters.value.searchTerm,
page: pagination.value.page,
size: pagination.value.rows,
});
} catch (error) {
console.error("Failed to load invoices:", error);
toast.add({
severity: "error",
summary: "Eroare",
detail: "Nu s-au putut încărca facturile",
life: 5000,
});
}
};
const onPageChange = async (event) => {
pagination.value.page = event.page;
pagination.value.rows = event.rows;
await loadInvoices();
};
const onSort = async (event) => {
// Handle sorting if needed
await loadInvoices();
};
const viewInvoiceDetails = (invoice) => {
selectedInvoice.value = invoice;
showDetailsDialog.value = true;
};
// Lifecycle
onMounted(async () => {
// Load companies if not loaded
if (!companyStore.hasCompanies) {
await companyStore.loadCompanies();
}
// Load invoices if company is selected
if (companyStore.selectedCompany) {
await loadInvoices();
}
});
// Watch for company changes
watch(
() => companyStore.selectedCompany,
async (newCompany) => {
if (newCompany) {
await loadInvoices();
}
},
);
</script>
<style scoped>
.invoices {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.page-header {
margin-bottom: 2rem;
}
.page-title {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-color);
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-subtitle {
font-size: 1.1rem;
color: var(--text-color-secondary);
margin: 0;
}
.company-selection-card,
.filters-card {
margin-bottom: 2rem;
}
.filters-container {
padding: 1rem;
}
.filters-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.search-group {
grid-column: span 2;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-label {
font-weight: 600;
color: var(--text-color);
font-size: 0.9rem;
}
.filters-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
padding-top: 1rem;
border-top: 1px solid var(--surface-border);
}
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
border-left: 4px solid var(--primary-color);
}
.stat-card.stat-total {
border-left-color: var(--blue-500);
}
.stat-card.stat-paid {
border-left-color: var(--green-500);
}
.stat-card.stat-overdue {
border-left-color: var(--red-500);
}
.stat-content {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
}
.stat-icon {
font-size: 2rem;
color: var(--primary-color);
}
.stat-details {
flex: 1;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
margin: 0 0 0.25rem 0;
color: var(--text-color);
}
.stat-label {
font-weight: 600;
margin: 0 0 0.25rem 0;
color: var(--text-color-secondary);
}
.stat-amount {
color: var(--text-color-secondary);
font-weight: 500;
}
.table-card {
margin-bottom: 2rem;
}
.partner-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.partner-name {
font-weight: 500;
}
.partner-code {
color: var(--text-color-secondary);
}
.amount {
font-weight: 600;
}
.amount-paid {
color: var(--green-600);
}
.amount-overdue {
color: var(--red-600);
}
.table-actions {
display: flex;
gap: 0.5rem;
}
.no-data,
.loading-table {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-color-secondary);
}
.no-data i,
.loading-table i {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.invoice-details {
padding: 1rem 0;
}
.details-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.detail-item label {
font-weight: 600;
color: var(--text-color-secondary);
font-size: 0.9rem;
}
.detail-item span {
font-weight: 500;
color: var(--text-color);
}
/* Row styling based on status */
:deep(.p-datatable .p-datatable-tbody > tr.invoice-paid) {
background-color: var(--green-50);
}
:deep(.p-datatable .p-datatable-tbody > tr.invoice-overdue) {
background-color: var(--red-50);
}
/* Responsive design */
@media (max-width: 768px) {
.invoices {
padding: 1rem;
}
.page-title {
font-size: 2rem;
}
.filters-row {
grid-template-columns: 1fr;
}
.search-group {
grid-column: span 1;
}
.summary-stats {
grid-template-columns: 1fr;
}
.filters-actions {
flex-direction: column;
}
.details-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,394 @@
<template>
<div class="login-container">
<div class="login-wrapper">
<Card class="login-card">
<template #header>
<div class="login-header">
<i class="pi pi-chart-bar text-primary text-6xl"></i>
<h1 class="login-title">ROA Reports</h1>
<p class="login-subtitle">Rapoarte ERP - Facturi și Încasări</p>
</div>
</template>
<template #content>
<form @submit.prevent="handleLogin" class="login-form">
<div class="field">
<label for="username" class="field-label">Utilizator</label>
<InputText
id="username"
v-model="credentials.username"
placeholder="Introduceți numele de utilizator"
:class="{ 'p-invalid': formErrors.username }"
class="w-full"
autocomplete="username"
@blur="validateField('username')"
/>
<small v-if="formErrors.username" class="p-error">
{{ formErrors.username }}
</small>
</div>
<div class="field">
<label for="password" class="field-label">Parolă</label>
<Password
id="password"
v-model="credentials.password"
placeholder="Introduceți parola"
:class="{ 'p-invalid': formErrors.password }"
class="w-full"
:feedback="false"
toggle-mask
autocomplete="current-password"
@blur="validateField('password')"
/>
<small v-if="formErrors.password" class="p-error">
{{ formErrors.password }}
</small>
</div>
<div v-if="authStore.error" class="error-message">
<i class="pi pi-exclamation-triangle"></i>
<span>{{ authStore.error }}</span>
</div>
<Button
type="submit"
label="Conectare"
class="w-full login-button"
:loading="authStore.isLoading"
:disabled="!isFormValid"
/>
</form>
</template>
<template #footer>
<div class="login-footer">
<small class="text-color-secondary">
ROA2WEB © {{ currentYear }} - Toate drepturile rezervate
</small>
</div>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { useToast } from "primevue/usetoast";
import { useAuthStore } from "../stores/auth";
const router = useRouter();
const toast = useToast();
const authStore = useAuthStore();
// Form data
const credentials = ref({
username: "",
password: "",
});
const formErrors = ref({
username: "",
password: "",
});
// Computed properties
const currentYear = computed(() => new Date().getFullYear());
const isFormValid = computed(() => {
return (
credentials.value.username.trim() !== "" &&
credentials.value.password.trim() !== "" &&
!formErrors.value.username &&
!formErrors.value.password
);
});
// Methods
const validateField = (field) => {
switch (field) {
case "username":
formErrors.value.username =
credentials.value.username.trim() === ""
? "Numele de utilizator este obligatoriu"
: "";
break;
case "password":
formErrors.value.password =
credentials.value.password.trim() === ""
? "Parola este obligatorie"
: "";
break;
}
};
const validateForm = () => {
validateField("username");
validateField("password");
return isFormValid.value;
};
const handleLogin = async () => {
if (!validateForm()) {
return;
}
try {
const result = await authStore.login(credentials.value);
if (result.success) {
// Redirect to dashboard (removed welcome notification)
router.push("/dashboard");
} else {
toast.add({
severity: "error",
summary: "Eroare de conectare",
detail: result.error || "Date de conectare incorecte",
life: 5000,
});
}
} catch (error) {
console.error("Login error:", error);
toast.add({
severity: "error",
summary: "Eroare",
detail: "A apărut o eroare neașteptată",
life: 5000,
});
}
};
// Clear errors when user starts typing
const clearErrors = () => {
authStore.clearError();
formErrors.value = {
username: "",
password: "",
};
};
// Lifecycle hooks
onMounted(() => {
// Clear any previous errors
clearErrors();
// Focus on username field
const usernameInput = document.getElementById("username");
if (usernameInput) {
usernameInput.focus();
}
});
onUnmounted(() => {
clearErrors();
});
</script>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
padding: 1rem;
}
.login-wrapper {
width: 100%;
max-width: 400px;
}
.login-card {
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.login-header {
text-align: center;
padding: 2rem 2rem 1rem 2rem;
background: white;
}
.login-title {
margin: 1rem 0 0.5rem 0;
color: var(--primary-color);
font-size: 2rem;
font-weight: 700;
}
.login-subtitle {
margin: 0;
color: var(--text-color-secondary);
font-size: 0.95rem;
}
.login-form {
padding: 0 2rem 2rem 2rem;
}
.field {
margin-bottom: 1.5rem;
}
.field-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--text-color);
}
.login-button {
margin-top: 1rem;
padding: 0.75rem;
font-size: 1.1rem;
font-weight: 600;
background: #3b82f6 !important;
color: white !important;
border: none !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.login-button:hover {
background: #2563eb !important;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.login-button:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Better input styling */
:deep(.p-inputtext),
:deep(.p-password input) {
border: 2px solid #e5e7eb !important;
padding: 12px !important;
font-size: 16px !important;
transition: all 0.3s ease !important;
border-radius: 8px !important;
}
:deep(.p-inputtext:focus),
:deep(.p-password input:focus) {
border-color: #3b82f6 !important;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
outline: none !important;
}
:deep(.p-inputtext:hover),
:deep(.p-password input:hover) {
border-color: #9ca3af !important;
}
.error-message {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
margin-bottom: 1rem;
background-color: var(--red-50);
color: var(--red-800);
border: 1px solid var(--red-200);
border-radius: 6px;
font-size: 0.9rem;
}
.login-footer {
text-align: center;
padding: 1rem 2rem;
background-color: var(--surface-50);
border-top: 1px solid var(--surface-200);
}
/* Responsive design */
@media (max-width: 768px) {
.login-container {
padding: 0.5rem;
}
.login-wrapper {
max-width: 100%;
padding: 0 1rem;
}
.login-card {
border-radius: 8px;
}
.login-header {
padding: 1.5rem 1rem;
}
.login-title {
font-size: 1.5rem;
}
.login-form {
padding: 0 1rem 1.5rem 1rem;
}
/* Ensure inputs are touch-friendly */
.p-inputtext,
.p-password input {
min-height: 44px;
font-size: 16px; /* Prevents zoom on iOS */
}
.login-footer {
padding: 1rem;
}
}
@media (max-width: 480px) {
.login-container {
padding: 0.25rem;
}
.login-card {
margin: 0;
}
.login-header {
padding: 1rem 0.5rem;
}
.login-title {
font-size: 1.25rem;
}
.login-subtitle {
font-size: 0.875rem;
}
.login-form {
padding: 0 0.5rem 1rem 0.5rem;
}
.login-footer {
padding: 0.75rem 0.5rem;
}
}
/* Animation for smooth transitions */
.login-card {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,432 @@
<template>
<div>
<!-- Dashboard Header -->
<DashboardHeader @menu-toggle="handleMenuToggle" />
<!-- Hamburger Menu -->
<HamburgerMenu :is-open="menuOpen" @close="handleMenuClose" />
<!-- Main Content -->
<main class="main-content">
<div class="app-container">
<!-- Page Header -->
<div class="page-header">
<h1 class="page-title">Telegram Bot</h1>
<p class="page-subtitle">Conectează-ți contul pentru acces rapid din Telegram</p>
</div>
<!-- Loading State -->
<div v-if="loading" class="loading-state">
<div class="loading-spinner"></div>
<p>Se generează codul...</p>
</div>
<!-- Main Card -->
<div v-else class="telegram-card">
<!-- Generate Button -->
<div class="generate-section">
<button
@click="generateCode"
:disabled="loading"
class="generate-btn"
>
{{ loading ? 'Se generează...' : 'Generează Cod' }}
</button>
</div>
<!-- Code Display & Actions -->
<div v-if="linkingCode" class="code-section">
<!-- Code Display -->
<div class="code-display">
<div class="code-header">
<span class="code-label">Cod</span>
<span class="code-timer">{{ formatTime(timeRemaining) }}</span>
</div>
<div class="code-value">{{ linkingCode }}</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<a
:href="telegramDeepLink"
target="_blank"
rel="noopener noreferrer"
class="action-btn primary-action-btn"
>
Deschide Telegram
</a>
<Button
:label="showQR ? 'Ascunde QR' : 'Arată QR'"
@click="showQR = !showQR"
class="action-btn"
outlined
/>
<Button
label="Copiază Cod"
@click="copyCode"
class="action-btn"
outlined
/>
</div>
<!-- QR Code Display -->
<div v-if="showQR" class="qr-section">
<QRCodeVue :value="telegramDeepLink" :size="200" level="H" />
</div>
</div>
</div>
</div>
</main>
<!-- Toast -->
<Toast position="top-right" />
</div>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import DashboardHeader from '../components/layout/DashboardHeader.vue'
import HamburgerMenu from '../components/layout/HamburgerMenu.vue'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import QRCodeVue from 'qrcode.vue'
import { apiService } from '../services/api'
const toast = useToast()
// State
const menuOpen = ref(false)
const linkingCode = ref('')
const timeRemaining = ref(0)
const loading = ref(false)
const showQR = ref(false)
let countdownInterval = null
// Config
const BOT_USERNAME = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || 'roa2web_bot'
// Computed
const telegramDeepLink = computed(() => {
if (!linkingCode.value) return ''
return `https://t.me/${BOT_USERNAME}?start=${linkingCode.value}`
})
// Methods
const handleMenuToggle = (isOpen) => {
menuOpen.value = isOpen
}
const handleMenuClose = () => {
menuOpen.value = false
}
const generateCode = async () => {
loading.value = true
showQR.value = false
try {
const response = await apiService.post('/telegram/auth/generate-code')
linkingCode.value = response.data.linking_code
timeRemaining.value = response.data.expires_in_minutes * 60
toast.add({
severity: 'success',
summary: 'Cod Generat',
detail: 'Alege o metodă de conectare',
life: 3000
})
startCountdown()
} catch (error) {
console.error('Error generating code:', error)
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.response?.data?.detail || 'Nu am putut genera codul',
life: 5000
})
} finally {
loading.value = false
}
}
const startCountdown = () => {
if (countdownInterval) clearInterval(countdownInterval)
countdownInterval = setInterval(() => {
if (timeRemaining.value > 0) {
timeRemaining.value--
} else {
clearInterval(countdownInterval)
linkingCode.value = ''
toast.add({
severity: 'warn',
summary: 'Cod Expirat',
detail: 'Generează un cod nou',
life: 4000
})
}
}, 1000)
}
const formatTime = (seconds) => {
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
const copyCode = async () => {
try {
await navigator.clipboard.writeText(linkingCode.value)
toast.add({
severity: 'success',
summary: 'Copiat',
detail: 'Cod copiat în clipboard',
life: 2000
})
} catch (error) {
const tempInput = document.createElement('input')
tempInput.value = linkingCode.value
document.body.appendChild(tempInput)
tempInput.select()
document.execCommand('copy')
document.body.removeChild(tempInput)
toast.add({
severity: 'success',
summary: 'Copiat',
life: 2000
})
}
}
onUnmounted(() => {
if (countdownInterval) clearInterval(countdownInterval)
})
</script>
<style scoped>
/* Layout - Consistent with Dashboard */
.main-content {
margin-left: 0;
padding: var(--space-lg);
min-height: 100vh;
background: var(--color-bg);
}
.app-container {
max-width: 1000px;
margin: 0 auto;
}
/* Page Header */
.page-header {
margin-bottom: var(--space-xl);
}
.page-title {
margin: 0 0 var(--space-xs) 0;
font-size: var(--text-2xl);
font-weight: var(--font-semibold);
color: var(--color-text);
}
.page-subtitle {
margin: 0;
font-size: var(--text-base);
color: var(--color-text-secondary);
}
/* Loading */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
padding: var(--space-3xl);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Main Card */
.telegram-card {
background: var(--color-bg);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--space-xl);
border: 1px solid var(--color-border);
}
/* Generate Section */
.generate-section {
display: flex;
justify-content: center;
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--color-border);
}
.generate-btn {
min-width: 200px;
padding: 12px 24px;
background: #6366f1;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.generate-btn:hover:not(:disabled) {
background: #4f46e5;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
.generate-btn:active:not(:disabled) {
transform: translateY(0);
}
.generate-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Code Section */
.code-section {
margin-top: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
/* Code Display */
.code-display {
background: linear-gradient(135deg, rgba(67, 97, 238, 0.08), rgba(67, 97, 238, 0.02));
border: 2px solid var(--color-primary);
border-radius: var(--radius-md);
padding: var(--space-md);
text-align: center;
}
.code-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-xs);
font-size: var(--text-sm);
}
.code-label {
color: var(--color-text-secondary);
font-weight: var(--font-semibold);
}
.code-timer {
color: var(--color-primary);
font-weight: var(--font-bold);
font-family: 'Courier New', monospace;
}
.code-value {
font-size: 2rem;
font-weight: var(--font-bold);
color: var(--color-primary);
letter-spacing: 0.3em;
font-family: 'Courier New', monospace;
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.action-btn {
flex: 1;
min-width: 160px;
justify-content: center;
}
.primary-action-btn {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-width: 160px;
padding: 11px 20px;
background: var(--primary-500, #6366f1);
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
font-size: 14px;
transition: all 0.2s ease;
border: none;
cursor: pointer;
line-height: 1.5;
}
.primary-action-btn:hover {
background: var(--primary-600, #4f46e5);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
/* QR Section */
.qr-section {
display: flex;
justify-content: center;
padding: var(--space-lg);
background: white;
border-radius: var(--radius-md);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
/* Responsive */
@media (max-width: 768px) {
.main-content {
padding: var(--space-md);
}
.telegram-card {
padding: var(--space-md);
}
.code-value {
font-size: 1.5rem;
letter-spacing: 0.2em;
}
.action-buttons {
flex-direction: column;
}
.action-btn,
.primary-btn {
width: 100%;
min-width: unset;
}
}
</style>

View File

@@ -0,0 +1,378 @@
<template>
<div class="company-selector-mini">
<div class="company-dropdown" ref="dropdown">
<button
class="company-trigger"
@click="toggleDropdown"
:aria-expanded="dropdownOpen"
aria-label="Select company"
>
<div class="company-info">
<span class="company-name">{{ selectedCompanyName }}</span>
<span class="company-code">{{ selectedCompanyCode }}</span>
</div>
<i class="pi pi-chevron-down" :class="{ 'rotate-180': dropdownOpen }"></i>
</button>
<div
v-show="dropdownOpen"
class="company-dropdown-panel"
:class="{ 'panel-open': dropdownOpen }"
>
<div class="dropdown-search">
<div class="search-wrapper">
<i class="pi pi-search search-icon"></i>
<input
type="text"
v-model="searchQuery"
placeholder="Search companies..."
class="search-input"
@keydown.escape="closeDropdown"
>
</div>
</div>
<div class="company-list">
<div
v-for="company in filteredCompanies"
:key="company.id"
class="company-item"
:class="{ active: company.id === selectedCompany?.id }"
@click="selectCompany(company)"
>
<div class="company-details">
<div class="company-main-name">{{ company.name }}</div>
<div class="company-sub-info">
<span class="company-cui">CUI: {{ company.cui }}</span>
<span class="company-separator"></span>
<span class="company-status" :class="company.status">{{ company.status }}</span>
</div>
</div>
<i v-if="company.id === selectedCompany?.id" class="pi pi-check company-selected-icon"></i>
</div>
</div>
<div v-if="filteredCompanies.length === 0" class="no-results">
<i class="pi pi-info-circle"></i>
<span>No companies found</span>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useCompanyStore } from '../../stores/companies'
export default {
name: 'CompanySelectorMini',
props: {
modelValue: {
type: Object,
default: null
}
},
emits: ['update:modelValue', 'company-changed'],
setup(props, { emit }) {
const companiesStore = useCompanyStore()
const dropdown = ref(null)
const dropdownOpen = ref(false)
const searchQuery = ref('')
const selectedCompany = computed({
get: () => props.modelValue || companiesStore.selectedCompany,
set: (value) => {
emit('update:modelValue', value)
companiesStore.setSelectedCompany(value)
}
})
const selectedCompanyName = computed(() => {
return selectedCompany.value?.name || 'Select Company'
})
const selectedCompanyCode = computed(() => {
return selectedCompany.value?.cui ? `CUI: ${selectedCompany.value.cui}` : ''
})
const filteredCompanies = computed(() => {
if (!searchQuery.value) {
return companiesStore.companies
}
const query = searchQuery.value.toLowerCase()
return companiesStore.companies.filter(company =>
company.name.toLowerCase().includes(query) ||
company.cui.toLowerCase().includes(query)
)
})
const toggleDropdown = () => {
dropdownOpen.value = !dropdownOpen.value
if (dropdownOpen.value) {
searchQuery.value = ''
}
}
const closeDropdown = () => {
dropdownOpen.value = false
searchQuery.value = ''
}
const selectCompany = (company) => {
selectedCompany.value = company
emit('company-changed', company)
closeDropdown()
}
const handleClickOutside = (event) => {
if (dropdown.value && !dropdown.value.contains(event.target)) {
closeDropdown()
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
// Load companies if not already loaded
if (companiesStore.companies.length === 0) {
companiesStore.fetchCompanies()
}
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
return {
dropdown,
dropdownOpen,
searchQuery,
selectedCompany,
selectedCompanyName,
selectedCompanyCode,
filteredCompanies,
toggleDropdown,
closeDropdown,
selectCompany
}
}
}
</script>
<style scoped>
.company-selector-mini {
position: relative;
max-width: 280px;
}
.company-dropdown {
position: relative;
}
.company-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
width: 100%;
text-align: left;
min-width: 200px;
}
.company-trigger:hover {
border-color: var(--color-primary);
background: var(--color-bg-secondary);
}
.company-info {
flex: 1;
min-width: 0;
}
.company-name {
display: block;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.company-code {
display: block;
font-size: var(--text-xs);
color: var(--color-text-secondary);
margin-top: 2px;
}
.pi-chevron-down {
transition: transform var(--transition-fast);
color: var(--color-text-secondary);
font-size: var(--text-xs);
}
.rotate-180 {
transform: rotate(180deg);
}
.company-dropdown-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: var(--z-dropdown);
max-height: 300px;
overflow: hidden;
opacity: 0;
transform: translateY(-10px);
transition: all var(--transition-fast);
}
.panel-open {
opacity: 1;
transform: translateY(0);
}
.dropdown-search {
padding: var(--space-sm);
border-bottom: 1px solid var(--color-border);
}
.search-wrapper {
position: relative;
}
.search-icon {
position: absolute;
left: var(--space-sm);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
font-size: var(--text-sm);
pointer-events: none;
}
.search-input {
width: 100%;
padding: var(--space-sm) var(--space-sm) var(--space-sm) var(--space-xl);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
background: var(--color-bg);
color: var(--color-text);
transition: border-color var(--transition-fast);
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
}
.company-list {
max-height: 200px;
overflow-y: auto;
}
.company-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md);
cursor: pointer;
transition: background-color var(--transition-fast);
border-bottom: 1px solid var(--color-border-light);
}
.company-item:last-child {
border-bottom: none;
}
.company-item:hover {
background: var(--color-bg-secondary);
}
.company-item.active {
background: var(--color-primary);
color: var(--color-text-inverse);
}
.company-details {
flex: 1;
min-width: 0;
}
.company-main-name {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.company-sub-info {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-xs);
opacity: 0.8;
}
.company-separator {
opacity: 0.5;
}
.company-status.active {
color: var(--color-success);
}
.company-status.inactive {
color: var(--color-error);
}
.company-selected-icon {
color: inherit;
font-size: var(--text-sm);
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-xl);
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.company-selector-mini {
max-width: none;
width: 100%;
}
.company-trigger {
min-width: auto;
}
.company-dropdown-panel {
left: -16px;
right: -16px;
width: calc(100% + 32px);
}
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<header class="header-container">
<nav class="header-nav">
<!-- Left side: Brand + Hamburger -->
<div class="flex items-center gap-4">
<button
class="hamburger-btn"
:class="{ active: menuOpen }"
@click="toggleMenu"
aria-label="Toggle navigation menu"
>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
<router-link to="/dashboard" class="header-brand">
<span>ROA2WEB</span>
</router-link>
</div>
<!-- Center: Quick Actions -->
<div class="quick-actions desktop-only">
<button class="quick-action-btn" @click="refreshData" title="Refresh Data">
<i class="pi pi-refresh"></i>
<span>Refresh</span>
</button>
<button class="quick-action-btn" @click="exportData" title="Export Data">
<i class="pi pi-download"></i>
<span>Export</span>
</button>
<button class="quick-action-btn" @click="searchData" title="Search">
<i class="pi pi-search"></i>
<span>Search</span>
</button>
</div>
<!-- Right side: Company + User -->
<div class="header-actions">
<CompanySelectorMini
v-model="selectedCompany"
@company-changed="onCompanyChanged"
/>
<div class="header-user" @click="toggleUserMenu">
<i class="pi pi-user"></i>
<span class="desktop-only">User</span>
<i class="pi pi-chevron-down"></i>
</div>
</div>
</nav>
</header>
</template>
<script>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import CompanySelectorMini from '../dashboard/CompanySelectorMini.vue'
import { useCompanyStore } from '../../stores/companies'
export default {
name: 'DashboardHeader',
components: {
CompanySelectorMini
},
emits: ['menu-toggle', 'refresh', 'export', 'search', 'company-changed'],
setup(props, { emit }) {
const router = useRouter()
const companiesStore = useCompanyStore()
const menuOpen = ref(false)
const selectedCompany = computed({
get: () => companiesStore.selectedCompany,
set: (value) => companiesStore.setSelectedCompany(value)
})
const toggleMenu = () => {
menuOpen.value = !menuOpen.value
emit('menu-toggle', menuOpen.value)
}
const refreshData = () => {
emit('refresh')
}
const exportData = () => {
emit('export')
}
const searchData = () => {
emit('search')
}
const onCompanyChanged = (company) => {
emit('company-changed', company)
}
const toggleUserMenu = () => {
// TODO: Implement user menu dropdown
console.log('User menu clicked')
}
return {
menuOpen,
selectedCompany,
toggleMenu,
refreshData,
exportData,
searchData,
onCompanyChanged,
toggleUserMenu
}
}
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,908 @@
# Ghid Testare pe Telefon Android Real cu Chrome DevTools MCP
Ghid complet pentru configurarea testării aplicației ROA2WEB pe telefon Android real folosind ADB WiFi și Chrome DevTools MCP server.
## De ce Chrome DevTools MCP?
**Avantaje fata de emulare Playwright:**
- [OK] Testezi pe hardware real (touch, senzori, performanta reala)
- [OK] Vezi exact cum arata pe telefonul tau
- [OK] Claude Code poate controla direct telefonul prin MCP
- [OK] Performance profiling real
- [OK] Network throttling real
- [OK] Screenshot-uri de pe dispozitiv real
## Cerinte
- Telefon Android (versiune 10+) pentru ADB WiFi
- Windows 10/11 cu PowerShell
- WSL/Linux pentru development
- Chrome instalat pe telefon
- Telefon si calculator in aceeasi retea WiFi
## Arhitectura Retea
```
Android Phone Windows Host WSL Environment
(10.0.20.114) (10.0.20.144) (172.18.251.234)
| | |
|--- ADB WiFi ----------| |
| | |
| |--- Port Proxy --------|
| | (9222, 3000, 8001) |
| | |
|<-- http://localhost:3000 (reverse proxy) --> App (Vite)
| |
|<-- http://localhost:8001 (reverse proxy) --> API (FastAPI)
|
Claude Code (WSL) --> MCP --> http://10.0.20.144:9222 --> ADB Forward --> Chrome on Phone
```
**IP-uri importante:**
- Phone WiFi IP: 10.0.20.114 (variaza)
- Windows physical IP: 10.0.20.144 (variaza)
- WSL IP: 172.18.251.234 (WSL internal network)
- WSL gateway: 172.18.240.1
---
## Pas 1: Instalare ADB (Android Debug Bridge) pe Windows
**IMPORTANT:** ADB trebuie instalat pe Windows, NU in WSL! WSL2 nu poate vedea dispozitivele USB conectate la Windows, iar chiar si cu ADB WiFi exista probleme de networking intre WSL2 si dispozitivele Android.
### Metoda 1: Winget (Recomandat)
**Windows PowerShell:**
```powershell
# Instalare ADB Platform Tools
winget install Google.PlatformTools
# Verifica instalarea
adb version
```
**Daca adb version nu returneaza nimic:**
ADB poate fi instalat de winget dar nu adaugat automat in PATH. Locatie posibila:
```
C:\Users\[USERNAME]\AppData\Local\Microsoft\WinGet\Packages\Google.PlatformTools_Microsoft.Winget.Source_8wekyb3d8bbwe\platform-tools\
```
**Adauga temporar in PATH (sesiunea curenta):**
```powershell
$env:Path += ";C:\Users\$env:USERNAME\AppData\Local\Microsoft\WinGet\Packages\Google.PlatformTools_Microsoft.Winget.Source_8wekyb3d8bbwe\platform-tools"
# Verifica
adb version
```
**Adauga permanent in PATH:**
```powershell
# Windows PowerShell (Administrator)
[Environment]::SetEnvironmentVariable("Path", $env:Path + ";C:\Users\$env:USERNAME\AppData\Local\Microsoft\WinGet\Packages\Google.PlatformTools_Microsoft.Winget.Source_8wekyb3d8bbwe\platform-tools", "Machine")
```
### Metoda 2: Download Manual
Download de pe site oficial:
```
https://developer.android.com/tools/releases/platform-tools
```
1. Download "SDK Platform-Tools for Windows"
2. Extrage in `C:\platform-tools`
3. Adauga `C:\platform-tools` in PATH (System Environment Variables)
---
## Pas 2: Configurare Telefon Android
### 1. Activeaza "Developer Options"
```
Setari -> Despre telefon -> Apasa de 7 ori pe "Numar compilare" (Build number)
```
Vei vedea un mesaj: "Esti acum developer!"
### 2. Activeaza USB Debugging si Wireless Debugging
```
Setari -> System -> Optiuni pentru dezvoltatori
-> Activeaza "USB debugging"
-> Activeaza "Wireless debugging"
```
**Pe unele telefoane:**
- Samsung: Setari -> Optiuni dezvoltator -> USB debugging + Wireless debugging
- Xiaomi: Setari -> Setari suplimentare -> Optiuni pentru dezvoltatori
- Huawei: Setari -> System & updates -> Developer options
**IMPORTANT:** Android 10+ este necesar pentru Wireless debugging.
### 3. Conectare ADB WiFi
**Pe telefon:**
```
Setari -> Developer options -> Wireless debugging -> "Pair device with pairing code"
```
Vei vedea un dialog cu:
- **Pairing code:** 6 cifre (ex: 123456)
- **IP address & Port:** ex: 10.0.20.114:37639
**NOTA IMPORTANTA:** Exista doua porturi diferite:
- **Pairing port:** Pentru asociere initiala (ex: 37639)
- **Wireless debugging port:** Pentru conexiune permanenta (diferit de pairing port!)
**In Windows PowerShell:**
```powershell
# Pas 1: Pair (prima data, foloseste pairing port)
adb pair 10.0.20.114:37639
# Introdu codul de 6 cifre cand este cerut
# Pas 2: Verifica portul wireless debugging (MAIN PORT)
# Pe telefon: Wireless debugging screen -> IP address & Port (diferit de pairing!)
# Exemplu: 10.0.20.114:38261
# Pas 3: Connect (foloseste wireless debugging port, NU pairing port!)
adb connect 10.0.20.114:38261
# Verifica conexiunea
adb devices
```
**Output asteptat:**
```
List of devices attached
10.0.20.114:38261 device
```
**Daca vezi `unauthorized`:**
- Deblocheaza telefonul
- Accepta prompt-ul "Allow wireless debugging"
- Bifeaza "Always allow from this computer"
### 4. Troubleshooting Conexiune
**Error: "failed to connect"**
```powershell
# Restart ADB server
adb kill-server
adb start-server
# Reincearca pairing si connect
```
**Pairing sau connect nu raspunde:**
- Verifica telefonul si PC-ul sunt pe aceeasi retea WiFi
- Dezactiveaza si reactiveaza "Wireless debugging" pe telefon
- Verifica nu exista restrictii WiFi (guest network, isolation)
---
## Pas 3: Configurare Port Forwarding Complet
Port forwarding este necesar la TREI niveluri pentru ca totul sa functioneze:
1. **ADB forward** - Phone Chrome -> Windows localhost
2. **Windows port proxy** - Windows -> WSL (pentru MCP si acces aplicatie)
3. **Firewall rules** - Permite conexiuni pe porturile necesare
### A) ADB Port Forwarding (Chrome DevTools)
**Windows PowerShell:**
```powershell
# Forward Chrome DevTools Protocol de pe telefon la Windows
adb forward tcp:9222 localabstract:chrome_devtools_remote
# Verifica port forwarding
adb forward --list
```
**Output asteptat:**
```
10.0.20.114:38261 tcp:9222 localabstract:chrome_devtools_remote
```
**Testeaza conexiunea:**
Deschide in browser Windows desktop:
```
http://localhost:9222/json/version
```
Ar trebui sa vezi informatii despre Chrome de pe telefon (versiune, WebSocket URL, etc.)
### B) Windows Port Proxy (pentru WSL/MCP Access)
**Windows PowerShell (Administrator):**
```powershell
# Port proxy pentru Chrome DevTools (WSL -> Windows -> Phone)
netsh interface portproxy add v4tov4 listenport=9222 listenaddress=0.0.0.0 connectport=9222 connectaddress=127.0.0.1
# Port proxy pentru aplicatie (Phone -> Windows -> WSL)
# Inlocuieste 172.18.251.234 cu IP-ul tau WSL!
netsh interface portproxy add v4tov4 listenport=3000 listenaddress=0.0.0.0 connectport=3000 connectaddress=172.18.251.234
netsh interface portproxy add v4tov4 listenport=8001 listenaddress=0.0.0.0 connectport=8001 connectaddress=172.18.251.234
# Verifica configurarea
netsh interface portproxy show all
```
**Cum afli IP-ul WSL:**
```bash
# In WSL bash
ip route show | grep default | awk '{print $3}' # Gateway IP
hostname -I | awk '{print $1}' # WSL IP direct
```
### C) Configurare Firewall
**Windows PowerShell (Administrator):**
```powershell
# Firewall rule pentru Chrome DevTools
New-NetFirewallRule -DisplayName "Chrome-DevTools-Android" -Direction Inbound -LocalPort 9222 -Protocol TCP -Action Allow
# Firewall rules pentru aplicatie ROA2WEB
New-NetFirewallRule -DisplayName "ROA2WEB-Frontend-WSL" -Direction Inbound -LocalPort 3000 -Protocol TCP -Action Allow
New-NetFirewallRule -DisplayName "ROA2WEB-Backend-WSL" -Direction Inbound -LocalPort 8001 -Protocol TCP -Action Allow
# Verifica rules
Get-NetFirewallRule -DisplayName "*ROA2WEB*" | Select-Object DisplayName, Enabled, Direction
Get-NetFirewallRule -DisplayName "Chrome-DevTools-Android" | Select-Object DisplayName, Enabled, Direction
```
### D) Script Automatizat pentru Setup Complet
In loc de comenzi manuale, foloseste scriptul automatizat:
**Windows PowerShell:**
```powershell
cd E:\proiecte\roa2web\roa2web\reports-app\frontend\scripts
.\android-test-setup.ps1
```
Scriptul:
- Verifica ADB si telefon conectat
- Configureaza ADB forward pentru Chrome DevTools
- Configureaza Windows port proxy (9222, 3000, 8001)
- Testeaza conexiunea la Chrome pe telefon
- Afiseaza informatii de retea si configurare MCP
### E) Testare Completa
**In WSL:**
```bash
# Testeaza acces Chrome DevTools de la WSL
# Inlocuieste 10.0.20.144 cu IP-ul tau Windows!
curl http://10.0.20.144:9222/json/version
```
**Pe telefon Chrome:**
```
http://localhost:3000
```
Daca aplicatia se incarca, port forwarding functioneaza corect!
### F) Verificare chrome://inspect (Optional)
Pe calculatorul tau, in Chrome desktop Windows:
```
chrome://inspect#devices
```
Ar trebui sa vezi telefonul tau listat si tab-urile deschise in Chrome pe telefon.
---
## Pas 4: Instalare si Configurare Chrome DevTools MCP Server
### 1. Configurare MCP in Claude Code (WSL)
**IMPORTANT:** Pentru Claude Code care ruleaza in WSL, trebuie sa folosim IP-ul FIZIC al Windows, NU localhost!
**Editare fisier de configurare:**
```bash
# In WSL
nano ~/.claude.json
```
**Gaseste sau adauga sectiunea chrome-devtools-android:**
```json
{
"mcpServers": {
"chrome-devtools-android": {
"type": "stdio",
"command": "npx",
"args": [
"chrome-devtools-mcp@latest",
"--browser-url",
"http://10.0.20.144:9222"
]
}
}
}
```
**NOTA CRITICA:** Inlocuieste `10.0.20.144` cu IP-ul FIZIC al calculatorului tau Windows!
**Cum afli IP-ul fizic Windows:**
```powershell
# Windows PowerShell
ipconfig | findstr "IPv4"
```
Output exemplu:
```
IPv4 Address. . . . . . . . . . . : 10.0.20.144
```
**De ce NU localhost:9222?**
In WSL, `localhost` se refera la WSL intern, NU la Windows host. Deoarece ADB forward asculta pe Windows localhost, trebuie sa accesam prin IP-ul fizic Windows cu Windows port proxy configurat.
### 2. Alternativ: Chrome DevTools pentru Browser Desktop (Optional)
Daca vrei sa controlezi atat Chrome desktop CAT si Chrome Android:
```json
{
"mcpServers": {
"chrome-devtools": {
"type": "stdio",
"command": "npx",
"args": [
"chrome-devtools-mcp@latest",
"--browser-url",
"http://localhost:9222"
]
},
"chrome-devtools-android": {
"type": "stdio",
"command": "npx",
"args": [
"chrome-devtools-mcp@latest",
"--browser-url",
"http://10.0.20.144:9222"
]
}
}
}
```
### 3. Reload Claude Code
Dupa salvarea configuratiei:
**VSCode (Claude Code):**
```
Ctrl+Shift+P -> "Developer: Reload Window"
```
Sau restart complet VSCode.
### 4. Verificare MCP Functioneaza
**In WSL bash (pentru debugging):**
```bash
# Testeaza ca MCP poate ajunge la Chrome pe telefon
curl http://10.0.20.144:9222/json/version
```
**In Claude Code:**
Cere Claude Code:
```
"Folosind chrome-devtools-android, fa un screenshot de pe telefonul Android"
```
Daca totul este configurat corect, Claude Code va putea controla Chrome pe telefon!
---
## Pas 5: Testare pe Telefon Android
### Workflow Complet de Testare
#### 1. Setup Initial (Prima Data)
**Windows PowerShell (Administrator):**
```powershell
# A) Conectare telefon WiFi
adb pair 10.0.20.114:PAIRING_PORT # Cu pairing code
adb connect 10.0.20.114:MAIN_PORT # Wireless debugging port
# B) Setup complet port forwarding si firewall
cd E:\proiecte\roa2web\roa2web\reports-app\frontend\scripts
.\android-test-setup.ps1
# Scriptul configureaza automat:
# - ADB forward pentru Chrome DevTools (9222)
# - Windows port proxy pentru WSL access
# - Firewall rules pentru porturile necesare
# - Reverse port forwarding pentru acces localhost pe telefon
```
#### 2. Porneste Aplicatia ROA2WEB
**In WSL:**
```bash
cd /mnt/e/proiecte/roa2web/roa2web
./start-dev.sh
```
Aplicatia va porni:
- Backend (FastAPI): http://localhost:8001
- Frontend (Vite): http://localhost:3000
#### 3. Acceseaza Aplicatia pe Telefon
**Pe telefon Chrome:**
Opțiunea 1 (Recomandat - cu reverse proxy):
```
http://localhost:3000
```
Opțiunea 2 (Alternativ - IP Windows direct):
```
http://10.0.20.144:3000
```
**Important:** Daca folosesti localhost pe telefon, trebuie sa fi rulat scriptul `android-test-setup.ps1` care configureaza reverse port forwarding automat!
#### 4. Control Chrome pe Telefon prin Claude Code
In Claude Code (WSL), poti cere:
```
"Folosind chrome-devtools-android, fa un screenshot de pe telefonul Android"
"Navigheaza la pagina de facturi pe telefon"
"Verifica performanta dashboard-ului pe telefon"
"Analizeaza console errors de pe Chrome Android"
```
Claude Code poate:
- [OK] Captura screenshot-uri de pe telefon
- [OK] Naviga intre pagini
- [OK] Analiza performance
- [OK] Inspectie DOM
- [OK] Citire console logs
- [OK] Network request monitoring
---
## Comenzi Utile ADB (Windows PowerShell)
### Verificare conexiune:
```powershell
adb devices -l
```
### Verificare Chrome este pornit pe telefon:
```powershell
adb shell dumpsys activity activities | Select-String -Pattern "chrome"
```
### Restart ADB server:
```powershell
adb kill-server
adb start-server
adb devices
```
### Port forwarding manual:
```powershell
# Forward Chrome DevTools
adb forward tcp:9222 localabstract:chrome_devtools_remote
# Reverse port forwarding (pentru acces localhost pe telefon)
adb reverse tcp:3000 tcp:3000
adb reverse tcp:8001 tcp:8001
# Verifica forwarding
adb forward --list
adb reverse --list
```
### Logcat in timp real:
```powershell
adb logcat | Select-String -Pattern "chrome"
```
### Informatii retea:
```powershell
# IP Windows fizic
ipconfig | findstr "IPv4"
# Verifica port proxy Windows
netsh interface portproxy show all
# Verifica firewall rules
Get-NetFirewallRule -DisplayName "*ROA2WEB*" | Select-Object DisplayName, Enabled
Get-NetFirewallRule -DisplayName "Chrome-DevTools-Android" | Select-Object DisplayName, Enabled
```
---
## Workflow Zilnic de Testare
### Dimineata (Setup Rapid):
**Windows PowerShell:**
```powershell
# 1. Conectare telefon WiFi (daca s-a deconectat)
adb connect 10.0.20.114:38261 # Inlocuieste cu IP:PORT-ul tau
# 2. Verifica conexiune
adb devices
# 3. Setup port forwarding complet
cd E:\proiecte\roa2web\roa2web\reports-app\frontend\scripts
.\android-test-setup.ps1
```
### Pornire Aplicatie:
**In WSL:**
```bash
cd /mnt/e/proiecte/roa2web/roa2web
./start-dev.sh
```
### Testare pe Telefon:
**Pe telefon Chrome:**
```
http://localhost:3000
```
**In Claude Code:**
```
"Folosind chrome-devtools-android, fa un screenshot de pe telefon"
"Testeaza dashboard-ul pe telefonul Android"
"Verifica performanta paginii de facturi pe telefon"
```
### Seara (Cleanup):
**In WSL:**
```bash
cd /mnt/e/proiecte/roa2web/roa2web/reports-app/frontend
./scripts/android-disconnect.sh
```
Script cleanup:
- Sterge toate ADB forward rules
- Sterge toate ADB reverse rules
- Cleanup complet pentru deconectare sigura
---
## Troubleshooting
### Problema: `adb: no devices/emulators found`
**Cauze posibile:**
1. Telefonul nu este conectat WiFi la aceeasi retea
2. Wireless debugging nu este activat
3. ADB server nu ruleaza
**Solutie:**
```powershell
# Windows PowerShell
# 1. Verifica ADB server ruleaza
adb devices
# 2. Restart ADB server
adb kill-server
adb start-server
# 3. Reconnect telefon WiFi
adb connect 10.0.20.114:38261 # Inlocuieste cu IP:PORT-ul tau
```
### Problema: `device unauthorized`
**Solutie:**
1. Deblocheaza telefonul
2. Ar trebui sa apara prompt-ul de autorizare
3. Bifeaza "Always allow from this computer"
4. Restart ADB:
```powershell
adb kill-server
adb start-server
adb devices
```
### Problema: Nu pot accesa http://localhost:9222 din Windows
**Solutie:**
```powershell
# Verifica port forwarding
adb forward --list
# Re-forward portul
adb forward --remove-all
adb forward tcp:9222 localabstract:chrome_devtools_remote
# Deschide Chrome pe telefon si incearca din nou
curl http://localhost:9222/json/version
```
### Problema: Nu pot accesa http://10.0.20.144:9222 din WSL
**Cauza:** Windows port proxy nu este configurat sau firewall blocheaza.
**Solutie:**
```powershell
# Windows PowerShell (Administrator)
# 1. Configureaza port proxy
netsh interface portproxy add v4tov4 listenport=9222 listenaddress=0.0.0.0 connectport=9222 connectaddress=127.0.0.1
# 2. Adauga firewall rule
New-NetFirewallRule -DisplayName "Chrome-DevTools-Android" -Direction Inbound -LocalPort 9222 -Protocol TCP -Action Allow
# 3. Verifica din WSL
```
```bash
# WSL
curl http://10.0.20.144:9222/json/version # Inlocuieste cu IP-ul tau Windows
```
### Problema: Pe telefon nu se incarca aplicatia (http://localhost:3000)
**Cauza:** Reverse port forwarding nu este configurat.
**Solutie:**
```powershell
# Windows PowerShell
# Ruleaza scriptul care configureaza automat reverse forwarding
cd E:\proiecte\roa2web\roa2web\reports-app\frontend\scripts
.\android-test-setup.ps1
```
Sau manual:
```powershell
adb reverse tcp:3000 tcp:3000
adb reverse tcp:8001 tcp:8001
# Verifica
adb reverse --list
```
### Problema: Pe telefon "ERR_EMPTY_RESPONSE" sau "This site can't be reached"
**Verificari:**
1. **Telefonul si PC-ul sunt in aceeasi retea WiFi?**
2. **Windows port proxy este configurat?**
```powershell
netsh interface portproxy show all
# Ar trebui sa vezi forwarding pentru 3000 si 8001
```
3. **Firewall permite conexiuni?**
```powershell
Get-NetFirewallRule -DisplayName "*ROA2WEB*"
```
4. **Backend ruleaza pe 0.0.0.0:8001 (nu doar 127.0.0.1)?**
```bash
# In WSL
netstat -tulpn | grep 8001
# Ar trebui: 0.0.0.0:8001
```
### Problema: Chrome DevTools MCP nu se conecteaza din Claude Code
**Cauze posibile:**
1. MCP configurat cu localhost in loc de IP fizic Windows
2. Port proxy nu este configurat
3. Chrome nu ruleaza pe telefon
**Solutie:**
```bash
# 1. Verifica configuratie MCP in WSL
cat ~/.claude.json | grep browser-url
# Ar trebui sa fie: http://10.0.20.144:9222 (IP fizic Windows, NU localhost!)
# 2. Testeaza manual din WSL
curl http://10.0.20.144:9222/json/version
# 3. Daca nu functioneaza, verifica Windows port proxy
```
```powershell
# Windows PowerShell (Administrator)
netsh interface portproxy show all
# Ar trebui sa vezi: 9222 -> 127.0.0.1:9222
# 4. Reload Claude Code
# VSCode: Ctrl+Shift+P -> "Developer: Reload Window"
```
### Problema: "adb pair" nu raspunde sau timeout
**Solutie:**
1. Verifica telefonul si PC sunt pe aceeasi retea WiFi (nu guest network!)
2. Dezactiveaza si reactiveaza "Wireless debugging" pe telefon
3. Incearca alt port (genereaza un nou pairing code)
4. Verifica nu exista WiFi isolation (router settings)
### Problema: MCP screenshot deschide Chrome pe calculator, nu pe telefon
**Cauza:** MCP configurat cu `http://localhost:9222` in loc de IP fizic Windows.
**Solutie:**
```bash
# WSL - Editeaza configuratie MCP
nano ~/.claude.json
# Schimba localhost cu IP fizic Windows:
# "http://localhost:9222" -> "http://10.0.20.144:9222"
# Afla IP Windows:
```
```powershell
# Windows PowerShell
ipconfig | findstr "IPv4"
```
### Problema: WSL nu poate accesa serviciile (3000, 8001)
**Cauza:** Port proxy inversat (WSL -> Windows in loc de Windows -> WSL).
Port proxy corect:
- **Pentru MCP (WSL -> Windows -> Phone):** Listen pe 0.0.0.0:9222, connect la 127.0.0.1:9222
- **Pentru aplicatie (Phone -> Windows -> WSL):** Listen pe 0.0.0.0:3000, connect la 172.18.x.x:3000
**Solutie:**
```powershell
# Windows PowerShell (Administrator)
# Sterge port proxy gresit
netsh interface portproxy delete v4tov4 listenport=3000 listenaddress=0.0.0.0
# Adauga port proxy corect (Phone -> WSL)
# Inlocuieste 172.18.251.234 cu IP-ul tau WSL!
netsh interface portproxy add v4tov4 listenport=3000 listenaddress=0.0.0.0 connectport=3000 connectaddress=172.18.251.234
netsh interface portproxy add v4tov4 listenport=8001 listenaddress=0.0.0.0 connectport=8001 connectaddress=172.18.251.234
```
---
## Comparatie: Playwright Emulation vs Chrome DevTools MCP
| Aspect | Playwright Emulation | Chrome DevTools MCP (Real Device) |
|--------|---------------------|----------------------------------|
| **Setup** | [OK] Simplu, zero config | [WARN] Necesita ADB WiFi + port forwarding |
| **Viteza** | [OK] Foarte rapid | [WARN] Mai lent (network latency) |
| **Acuratete vizuala** | [WARN] Nu exact ca pe telefon | [OK] 100% real |
| **Touch gestures** | [ERROR] Simulate | [OK] Touch real |
| **Performance** | [ERROR] Hardware desktop | [OK] Performance reala telefon |
| **CI/CD** | [OK] Perfect | [ERROR] Nu se poate (necesita device fizic) |
| **Debug interactiv** | [WARN] Limitat | [OK] Excelent |
| **Cost** | [OK] Gratis, instantaneu | [WARN] Timp + configurare |
| **Platform** | [OK] Orice OS | [WARN] Necesita Windows + WSL |
**Recomandare:**
- **Development zilnic:** Playwright emulation (rapid, suficient)
- **Final testing:** Chrome DevTools MCP pe telefon real (asigura calitate)
- **CI/CD:** Playwright emulation
---
## De ce WSL ADB nu functioneaza?
**Limitare tehnica:** WSL2 nu poate accesa dispozitive USB conectate la Windows host.
**Explicatie:**
- WSL2 ruleaza intr-un lightweight VM (Hyper-V)
- USB passthrough nu este suportat complet in WSL2
- Chiar si cu ADB WiFi, exista probleme de networking intre WSL si Android device
**Solutia:** Foloseste ADB din Windows PowerShell direct, apoi Windows port proxy pentru a permite WSL (Claude Code) sa acceseze Chrome DevTools prin IP fizic Windows.
---
## Scripturi Disponibile
| Script | Platform | Descriere |
|--------|----------|-----------|
| **android-test-setup.ps1** | Windows PowerShell | Setup complet: ADB forward, port proxy, firewall |
| **android-disconnect.sh** | Bash/WSL | Cleanup: sterge port forwarding |
**Screenshot-uri:** Nu mai este nevoie de script! Claude Code face screenshot-uri prin MCP (chrome-devtools-android) inline, fara salvare fisiere.
**Locatie scripturi:**
```
E:\proiecte\roa2web\roa2web\reports-app\frontend\scripts\
```
**Documentatie scripturi:** Vedere `scripts/README_ANDROID.md`
---
## Next Steps
Dupa configurare completa:
1. **Testeaza manual** aplicatia pe telefon (http://localhost:3000)
2. **Cere Claude Code** sa faca screenshot-uri si sa analizeze layout-ul
3. **Verifica performance** pe hardware real
4. **Identifica probleme** care nu apar in emulare desktop
5. **Optimizeaza** pentru experienta mobile reala
6. **Documenteaza** probleme si solutii gasite
---
## Resurse Utile
- **Chrome DevTools MCP:** https://github.com/ChromeDevTools/chrome-devtools-mcp
- **Chrome Remote Debugging:** https://developer.chrome.com/docs/devtools/remote-debugging/
- **ADB Documentation:** https://developer.android.com/tools/adb
- **ADB WiFi Debugging:** https://developer.android.com/studio/command-line/adb#wireless
- **Playwright Mobile Testing:** https://playwright.dev/docs/emulation
- **Windows Port Proxy:** https://docs.microsoft.com/en-us/windows-server/networking/technologies/netsh/netsh-interface-portproxy
---
## Summary
**Arhitectura completa:**
- **Phone (Android 10+):** ADB WiFi -> Chrome DevTools Protocol
- **Windows Host:** ADB forward (9222) + Windows port proxy (9222, 3000, 8001) + Firewall rules
- **WSL (Claude Code):** MCP client -> http://WINDOWS_IP:9222 -> Chrome on Phone
**Componente cheie:**
1. ADB WiFi pairing and connection (Windows PowerShell)
2. ADB forward pentru Chrome DevTools (9222)
3. Windows port proxy pentru WSL access
4. Firewall rules pentru porturile necesare
5. MCP configurat cu IP fizic Windows (NU localhost!)
6. Reverse port forwarding pentru acces localhost pe telefon
**Workflow zilnic:**
1. Connect phone WiFi (adb connect)
2. Run setup script (android-test-setup.ps1)
3. Start app (./start-dev.sh in WSL)
4. Test on phone (http://localhost:3000)
5. Control via Claude Code (screenshots, testing)
6. Cleanup (android-disconnect.sh)
---
**Autor:** ROA2WEB Development Team
**Data:** 2025-10-20
**Versiune:** 2.0 (ADB WiFi + Windows Port Forwarding)

View File

@@ -0,0 +1,275 @@
# ROA2WEB Frontend E2E Testing with Playwright
This directory contains end-to-end tests for the ROA2WEB frontend application using Playwright.
## 🚀 Quick Start
### Prerequisites
- Node.js 16+ and npm 8+
- Frontend development server running on `http://localhost:3001`
### Installation
```bash
cd roa2web/reports-app/frontend/
npm install
```
### Running Tests
```bash
# Run all tests headlessly
npm run test:e2e
# Run tests with browser UI visible
npm run test:e2e:headed
# Run tests in debug mode
npm run test:e2e:debug
# Run tests with Playwright UI mode
npm run test:e2e:ui
# Show test report
npm run test:e2e:report
```
## 📁 Test Structure
```
tests/
├── e2e/ # End-to-end test files
│ ├── auth/ # Authentication flow tests
│ │ └── login.spec.js
│ ├── dashboard/ # Dashboard view tests
│ │ └── dashboard.spec.js
│ ├── invoices/ # Invoice management tests
│ ├── payments/ # Payment tracking tests
│ └── responsive/ # Responsive design tests
│ └── breakpoints.spec.js
├── fixtures/ # Test data and mock responses
│ └── auth.js
├── page-objects/ # Page Object Models
│ ├── BasePage.js
│ ├── LoginPage.js
│ └── DashboardPage.js
├── utils/ # Test utilities
└── README.md # This file
```
## 🎭 Test Categories
### 1. Authentication Tests (`auth/login.spec.js`)
- ✅ Login page display and validation
- ✅ Form validation for empty fields
- ✅ Successful login flow with API mocking
- ✅ Invalid credentials handling
- ✅ Loading states and error handling
- ✅ Focus management and UX
### 2. Dashboard Tests (`dashboard/dashboard.spec.js`)
- ✅ Dashboard page rendering
- ✅ Company selection workflow
- ✅ Statistics display and data fetching
- ✅ Navigation to invoices/payments views
- ✅ API error handling
- ✅ Company switching functionality
### 3. Invoice Management Tests (`invoices/invoices.spec.js`)
- ✅ Invoice list display and table functionality
- ✅ Search and filtering by invoice number/status
- ✅ Sorting by different columns
- ✅ Invoice details view
- ✅ Data export functionality
- ✅ Pagination handling
- ✅ API error scenarios
### 4. Payment Tracking Tests (`payments/payments.spec.js`)
- ✅ Payment list display and management
- ✅ Filtering by payment method and date range
- ✅ Payment totals and summary views
- ✅ Export functionality
- ✅ Payment details modal/panel
- ✅ Method-based grouping and statistics
### 5. Responsive Design Tests (`responsive/breakpoints.spec.js`)
- ✅ Mobile layout (320px) - form stacking, touch targets
- ✅ Tablet layout (768px) - grid adjustments
- ✅ Desktop layout (1024px+) - full feature layout
- ✅ Wide screen (1920px) - content max-width
- ✅ Orientation changes (portrait ↔ landscape)
- ✅ Touch interaction testing
## 🏗️ Page Object Pattern
Tests use the Page Object Model pattern for maintainability:
### BasePage
Base class with common functionality:
- API response waiting
- Loading state management
- Error/success message checking
- Navigation helpers
### LoginPage
Encapsulates login page interactions:
- Form filling and validation
- Error message extraction
- Loading state checking
- Navigation to login page
### DashboardPage
Handles dashboard-specific operations:
- Company selection
- Statistics reading
- Action button clicks
- Content visibility checks
### InvoicesPage
Manages invoice-related interactions:
- Invoice table navigation and filtering
- Search functionality
- Sorting and pagination
- Export operations
### PaymentsPage
Handles payment view operations:
- Payment filtering and search
- Method and date range filters
- Summary view switching
- Export and totals display
## 🎯 API Mocking Strategy
Tests use Playwright's route interception to mock API calls:
```javascript
// Mock successful login
await page.route('**/api/auth/login', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ /* mock response */ })
});
});
```
### Mocked Endpoints
- `POST /api/auth/login` - Authentication
- `GET /api/companies` - Company list
- `GET /api/invoices/{company}/summary` - Invoice statistics
- `GET /api/payments/{company}/summary` - Payment statistics
- `GET /api/invoices/{company}` - Invoice list with pagination
- `GET /api/payments/{company}` - Payment list with filtering
## 📱 Responsive Testing
Tests verify application behavior across different viewport sizes:
| Breakpoint | Width | Focus Areas |
|------------|-------|-------------|
| Mobile | 320px | Touch targets, vertical stacking |
| Tablet | 768px | Grid layouts, navigation |
| Desktop | 1024px+ | Full feature set, horizontal layouts |
| Wide | 1920px | Content max-width, spacing |
## 🔧 Configuration
### Playwright Config (`playwright.config.js`)
- **Base URL**: `http://localhost:3001`
- **Browsers**: Chromium, Firefox, WebKit, Mobile Chrome, Mobile Safari
- **Screenshots**: On failure only
- **Videos**: Retained on failure
- **Traces**: On first retry
### Test Environment
Tests run against the local development server with mocked backend API calls to ensure consistent, fast, and reliable testing.
## 📊 Test Reports
After running tests, view the HTML report:
```bash
npm run test:e2e:report
```
The report includes:
- Test results with pass/fail status
- Screenshots of failures
- Video recordings of failed tests
- Execution timing and performance metrics
## 🚨 Troubleshooting
### Common Issues
1. **Tests timing out**
- Ensure frontend server is running on port 3001
- Check network connectivity
- Increase timeout in test configuration
2. **Element not found errors**
- Verify page object selectors match current DOM
- Check for dynamic content loading
- Add appropriate wait conditions
3. **API mock not working**
- Verify route patterns match actual API calls
- Check mock response format
- Ensure mocks are set up before navigation
### Debug Mode
Run tests in debug mode to step through execution:
```bash
npm run test:e2e:debug
```
## 📝 Writing New Tests
### Test File Structure
```javascript
import { test, expect } from '@playwright/test';
import { YourPageObject } from '../../page-objects/YourPageObject.js';
test.describe('Feature Name', () => {
let pageObject;
test.beforeEach(async ({ page }) => {
pageObject = new YourPageObject(page);
// Setup mocks, navigation, etc.
});
test('should do something', async ({ page }) => {
// Test implementation
});
});
```
### Best Practices
1. **Use Page Objects** - Encapsulate page interactions
2. **Mock API calls** - Avoid dependencies on backend state
3. **Wait for elements** - Use proper wait strategies
4. **Descriptive test names** - Clear test intent
5. **Setup and teardown** - Clean test environment
6. **Group related tests** - Use describe blocks effectively
## 🔄 CI/CD Integration
Tests are designed to run in continuous integration environments:
```yaml
# Example GitHub Actions workflow
- name: Run E2E Tests
run: |
npm ci
npm run build
npm run test:e2e
```
## 📚 Resources
- [Playwright Documentation](https://playwright.dev/)
- [Page Object Model Pattern](https://playwright.dev/docs/test-pom)
- [API Mocking with Playwright](https://playwright.dev/docs/mock)
- [Visual Testing](https://playwright.dev/docs/test-screenshots)
---
*Tests implemented following the detailed plan in `PLAYWRIGHT_TESTING_PLAN.md`* 🎭

View File

@@ -0,0 +1,192 @@
import { test, expect } from '@playwright/test';
import { LoginPage } from '../page-objects/LoginPage.js';
test.describe('🔧 Button Fix Test - Identify Disabled State Issue', () => {
let loginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.navigate();
});
test('🐛 Debug Button Disabled State Logic', async ({ page }) => {
console.log('\n=== DEBUGGING BUTTON DISABLED STATE ===');
// Helper function to get detailed button state
const getButtonState = async () => {
return await page.evaluate(() => {
const usernameInput = document.getElementById('username');
const passwordInput = document.querySelector('#password input');
const button = document.querySelector('button[type="submit"]');
// Get Vue component data if available
const vueApp = document.querySelector('#app').__vue__;
let vueData = null;
try {
// Try to access Vue component state
const loginComponent = document.querySelector('.login-container').__vueParentComponent;
if (loginComponent && loginComponent.setupState) {
vueData = {
credentials: loginComponent.setupState.credentials?.value,
formErrors: loginComponent.setupState.formErrors?.value,
isFormValid: loginComponent.setupState.isFormValid?.value
};
}
} catch (e) {
console.log('Could not access Vue state:', e.message);
}
return {
dom: {
usernameValue: usernameInput?.value || '',
passwordValue: passwordInput?.value || '',
buttonDisabled: button?.disabled,
buttonClasses: button?.className,
usernameRequired: usernameInput?.required,
passwordRequired: passwordInput?.required
},
vue: vueData
};
});
};
// Test 1: Initial state
console.log('\n--- Test 1: Initial State ---');
let state = await getButtonState();
console.log('Initial state:', JSON.stringify(state, null, 2));
// Test 2: Fill only username
console.log('\n--- Test 2: Username Only ---');
await page.fill('#username', 'test_user');
await page.waitForTimeout(500); // Wait for Vue reactivity
state = await getButtonState();
console.log('Username only state:', JSON.stringify(state, null, 2));
// Test 3: Fill both fields
console.log('\n--- Test 3: Both Fields ---');
await page.fill('#password input', 'test_password');
await page.waitForTimeout(500); // Wait for Vue reactivity
state = await getButtonState();
console.log('Both fields state:', JSON.stringify(state, null, 2));
// Test 4: Check if validation triggers
console.log('\n--- Test 4: Trigger Validation ---');
await page.click('.login-card'); // Click outside to blur
await page.waitForTimeout(500);
state = await getButtonState();
console.log('After blur state:', JSON.stringify(state, null, 2));
// Test 5: Manual button click attempt
console.log('\n--- Test 5: Button Click Attempt ---');
const isClickable = await page.evaluate(() => {
const button = document.querySelector('button[type="submit"]');
return !button.disabled;
});
console.log('Button is clickable:', isClickable);
if (isClickable) {
console.log('✅ Button should be clickable');
} else {
console.log('❌ Button is still disabled - investigating why...');
// Check validation logic
const validationState = await page.evaluate(() => {
const usernameInput = document.getElementById('username');
const passwordInput = document.querySelector('#password input');
return {
usernameEmpty: !usernameInput.value.trim(),
passwordEmpty: !passwordInput.value.trim(),
usernameLength: usernameInput.value.length,
passwordLength: passwordInput.value.length,
formValidity: usernameInput.form?.checkValidity()
};
});
console.log('Validation details:', JSON.stringify(validationState, null, 2));
}
// Take screenshot for analysis
await page.screenshot({ path: 'button-debug.png', fullPage: true });
});
test('🔄 Test Button Reactivity with Real Input', async ({ page }) => {
console.log('\n=== TESTING BUTTON REACTIVITY ===');
// Monitor button state changes
const buttonStates = [];
const checkButton = async (action) => {
const disabled = await page.locator('button[type="submit"]').isDisabled();
buttonStates.push({ action, disabled });
console.log(`After ${action}: disabled = ${disabled}`);
};
await checkButton('initial load');
// Type character by character to see when button enables
const username = 'test';
const password = 'pass';
for (let i = 0; i < username.length; i++) {
await page.fill('#username', username.substring(0, i + 1));
await page.waitForTimeout(100);
await checkButton(`username: "${username.substring(0, i + 1)}"`);
}
for (let i = 0; i < password.length; i++) {
await page.fill('#password input', password.substring(0, i + 1));
await page.waitForTimeout(100);
await checkButton(`password: "${password.substring(0, i + 1)}"`);
}
console.log('\nButton state progression:');
buttonStates.forEach((state, index) => {
console.log(`${index + 1}. ${state.action}: ${state.disabled ? 'DISABLED' : 'ENABLED'}`);
});
});
test('🎯 Force Button Enable Test', async ({ page }) => {
console.log('\n=== TESTING FORCED BUTTON ENABLE ===');
// Fill valid data
await page.fill('#username', 'valid_user');
await page.fill('#password input', 'valid_password');
// Wait for Vue reactivity
await page.waitForTimeout(1000);
// Force enable button via JavaScript if needed
const buttonEnabled = await page.evaluate(() => {
const button = document.querySelector('button[type="submit"]');
const wasDisabled = button.disabled;
// Try to force enable for testing
button.disabled = false;
button.classList.remove('p-disabled');
return { wasDisabled, nowDisabled: button.disabled };
});
console.log('Button enable attempt:', buttonEnabled);
if (!buttonEnabled.nowDisabled) {
console.log('✅ Button was successfully enabled');
// Try to click it now
await page.click('button[type="submit"]');
console.log('✅ Button click succeeded');
// Wait for potential API call
await page.waitForTimeout(2000);
// Check if login was attempted
const currentUrl = page.url();
console.log('Current URL after click:', currentUrl);
} else {
console.log('❌ Could not enable button');
}
});
});

View File

@@ -0,0 +1,324 @@
import { test, expect } from '@playwright/test';
import { LoginPage } from '../page-objects/LoginPage.js';
import { DashboardPage } from '../page-objects/DashboardPage.js';
test.describe('📊 Complete Reports Functionality Test', () => {
let loginPage;
let dashboardPage;
let networkRequests = [];
let apiErrors = [];
test.beforeEach(async ({ page }) => {
// Reset monitoring arrays
networkRequests = [];
apiErrors = [];
// Monitor network requests
page.on('request', request => {
networkRequests.push({
url: request.url(),
method: request.method(),
timestamp: new Date().toISOString()
});
});
page.on('response', response => {
if (response.status() >= 400) {
apiErrors.push({
url: response.url(),
status: response.status(),
statusText: response.statusText()
});
console.log(`❌ API Error: ${response.status()} ${response.url()}`);
}
});
loginPage = new LoginPage(page);
dashboardPage = new DashboardPage(page);
});
test('🎯 Complete User Flow: Login → Dashboard → Reports', async ({ page }) => {
console.log('\n🎯 === TESTING COMPLETE REPORTS FUNCTIONALITY ===');
// Phase 1: Authentication
console.log('\n📍 Phase 1: Login with real credentials');
await loginPage.navigate();
// Use real test credentials
await page.fill('#username', 'MARIUS M');
await page.fill('#password input', 'PAROLA9911');
// Submit login and wait for response
const [authResponse] = await Promise.all([
page.waitForResponse('**/auth/login'),
page.click('button[type="submit"]')
]);
console.log(`📊 Auth Response: ${authResponse.status()}`);
expect(authResponse.status()).toBe(200);
// Wait for redirect to dashboard
await expect(page).toHaveURL(/.*dashboard/);
console.log('✅ Successfully redirected to dashboard');
// Phase 2: Test Dashboard Loading
console.log('\n📍 Phase 2: Dashboard Data Loading');
// Wait for dashboard to load
await page.waitForTimeout(2000);
// Check for companies API call
const companiesRequests = networkRequests.filter(req =>
req.url.includes('/companies') || req.url.includes('/api/companies')
);
console.log(`📊 Companies API requests: ${companiesRequests.length}`);
if (companiesRequests.length > 0) {
console.log('✅ Companies API was called');
// Check if there were CORS errors
const corsErrors = apiErrors.filter(err =>
err.url.includes('/companies')
);
if (corsErrors.length > 0) {
console.log('❌ CORS errors detected:');
corsErrors.forEach(err => {
console.log(` - ${err.status} ${err.url}`);
});
} else {
console.log('✅ No CORS errors for companies API');
}
} else {
console.log('⚠️ Companies API was not called - checking why...');
}
// Look for company selector dropdown
const companySelectors = [
'.p-dropdown',
'select',
'[data-testid="company-select"]',
'.company-selector'
];
let companySelectorFound = false;
for (const selector of companySelectors) {
const element = page.locator(selector).first();
if (await element.isVisible()) {
console.log(`✅ Company selector found: ${selector}`);
companySelectorFound = true;
// Try to interact with it
await element.click();
await page.waitForTimeout(500);
// Look for dropdown options
const options = page.locator('.p-dropdown-item, option');
const optionCount = await options.count();
console.log(`📊 Company options available: ${optionCount}`);
if (optionCount > 0) {
// Select first company
await options.first().click();
console.log('✅ Company selected');
// Wait for data to load after company selection
await page.waitForTimeout(3000);
// Check for additional API calls after company selection
const invoicesRequests = networkRequests.filter(req =>
req.url.includes('/invoices')
);
const paymentsRequests = networkRequests.filter(req =>
req.url.includes('/payments')
);
console.log(`📊 Invoices API requests: ${invoicesRequests.length}`);
console.log(`📊 Payments API requests: ${paymentsRequests.length}`);
}
break;
}
}
if (!companySelectorFound) {
console.log('⚠️ Company selector not found - taking screenshot for analysis');
await page.screenshot({ path: 'dashboard-no-company-selector.png', fullPage: true });
}
// Phase 3: Test Navigation to Reports
console.log('\n📍 Phase 3: Navigation to Reports');
// Look for navigation links
const navLinks = [
'text=/facturi/i',
'text=/invoices/i',
'text=/încasări/i',
'text=/payments/i',
'[href*="/invoices"]',
'[href*="/payments"]'
];
for (const linkSelector of navLinks) {
const link = page.locator(linkSelector).first();
if (await link.isVisible()) {
const linkText = await link.textContent();
console.log(`✅ Found navigation link: "${linkText}"`);
// Click the link
await link.click();
await page.waitForTimeout(2000);
// Check if we navigated to the correct page
const currentUrl = page.url();
console.log(`📍 Navigated to: ${currentUrl}`);
// Wait for page content to load
await page.waitForTimeout(2000);
// Take screenshot of the reports page
await page.screenshot({
path: `reports-${linkText.toLowerCase().replace(/[^a-z0-9]/g, '-')}.png`,
fullPage: true
});
// Look for data tables or report content
const dataElements = [
'table',
'.p-datatable',
'.data-table',
'.report-table',
'.invoice-list',
'.payment-list'
];
let dataFound = false;
for (const selector of dataElements) {
const element = page.locator(selector);
if (await element.isVisible()) {
const rowCount = await element.locator('tr, .row').count();
console.log(`✅ Data table found with ${rowCount} rows`);
dataFound = true;
break;
}
}
if (!dataFound) {
console.log('⚠️ No data tables found - checking for loading states or errors');
// Check for loading indicators
const loadingElements = [
'.loading',
'.p-progress-spinner',
'.spinner',
'[data-testid="loading"]'
];
for (const loadingSelector of loadingElements) {
if (await page.locator(loadingSelector).isVisible()) {
console.log('⏳ Loading indicator found - data may still be loading');
await page.waitForTimeout(5000); // Wait longer for data
break;
}
}
}
// Go back to dashboard for next test
await page.goto('/dashboard');
await page.waitForTimeout(1000);
break;
}
}
// Take final dashboard screenshot
await page.screenshot({ path: 'final-dashboard-state.png', fullPage: true });
});
test('🔍 API Endpoints Health Check', async ({ page }) => {
console.log('\n🔍 === API ENDPOINTS HEALTH CHECK ===');
// First authenticate to get access token
await loginPage.navigate();
await page.fill('#username', 'MARIUS M');
await page.fill('#password input', 'PAROLA9911');
const [authResponse] = await Promise.all([
page.waitForResponse('**/auth/login'),
page.click('button[type="submit"]')
]);
expect(authResponse.status()).toBe(200);
await page.waitForURL(/.*dashboard/);
// Test individual API endpoints
const endpoints = [
'/api/companies',
'/api/invoices',
'/api/payments'
];
for (const endpoint of endpoints) {
console.log(`\n--- Testing endpoint: ${endpoint} ---`);
// Navigate to trigger API call
if (endpoint.includes('invoices')) {
await page.goto('/invoices');
} else if (endpoint.includes('payments')) {
await page.goto('/payments');
} else {
await page.goto('/dashboard');
}
await page.waitForTimeout(2000);
// Check if API was called
const apiCalls = networkRequests.filter(req => req.url.includes(endpoint));
if (apiCalls.length > 0) {
console.log(`${endpoint} was called (${apiCalls.length} requests)`);
// Check for errors
const errors = apiErrors.filter(err => err.url.includes(endpoint));
if (errors.length > 0) {
console.log(`❌ Errors found for ${endpoint}:`);
errors.forEach(err => {
console.log(` - ${err.status} ${err.statusText}`);
});
} else {
console.log(`${endpoint} returned successful responses`);
}
} else {
console.log(`⚠️ ${endpoint} was not called`);
}
}
});
test.afterEach(async ({ page }) => {
// Generate test report
console.log('\n📋 === TEST REPORT ===');
console.log(`🌐 Total Network Requests: ${networkRequests.length}`);
console.log(`❌ API Errors: ${apiErrors.length}`);
if (apiErrors.length > 0) {
console.log('\n❌ API Errors Details:');
apiErrors.forEach(error => {
console.log(` - ${error.status} ${error.url} (${error.statusText})`);
});
}
// Check for specific CORS errors
const corsErrors = apiErrors.filter(err =>
err.statusText.includes('CORS') || err.status === 0
);
if (corsErrors.length > 0) {
console.log('\n🚨 CORS Issues Detected:');
corsErrors.forEach(error => {
console.log(` - ${error.url}`);
});
console.log('\n💡 Recommendation: Check CORS configuration in backend');
} else {
console.log('\n✅ No CORS issues detected');
}
});
});

Some files were not shown because too many files have changed in this diff Show More