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:
60
reports-app/backend/Dockerfile
Normal file
60
reports-app/backend/Dockerfile
Normal 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"]
|
||||
204
reports-app/backend/README.md
Normal file
204
reports-app/backend/README.md
Normal 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
|
||||
0
reports-app/backend/app/__init__.py
Normal file
0
reports-app/backend/app/__init__.py
Normal file
222
reports-app/backend/app/main.py
Normal file
222
reports-app/backend/app/main.py
Normal 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()}
|
||||
0
reports-app/backend/app/models/__init__.py
Normal file
0
reports-app/backend/app/models/__init__.py
Normal file
118
reports-app/backend/app/models/dashboard.py
Normal file
118
reports-app/backend/app/models/dashboard.py
Normal 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')
|
||||
73
reports-app/backend/app/models/invoice.py
Normal file
73
reports-app/backend/app/models/invoice.py
Normal 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
|
||||
37
reports-app/backend/app/models/treasury.py
Normal file
37
reports-app/backend/app/models/treasury.py
Normal 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
|
||||
0
reports-app/backend/app/routers/__init__.py
Normal file
0
reports-app/backend/app/routers/__init__.py
Normal file
177
reports-app/backend/app/routers/companies.py
Normal file
177
reports-app/backend/app/routers/companies.py
Normal 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"
|
||||
}
|
||||
327
reports-app/backend/app/routers/dashboard.py
Normal file
327
reports-app/backend/app/routers/dashboard.py
Normal 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)}")
|
||||
143
reports-app/backend/app/routers/invoices.py
Normal file
143
reports-app/backend/app/routers/invoices.py
Normal 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")
|
||||
559
reports-app/backend/app/routers/telegram.py
Normal file
559
reports-app/backend/app/routers/telegram.py
Normal 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()
|
||||
}
|
||||
67
reports-app/backend/app/routers/treasury.py
Normal file
67
reports-app/backend/app/routers/treasury.py
Normal 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)}")
|
||||
0
reports-app/backend/app/schemas/__init__.py
Normal file
0
reports-app/backend/app/schemas/__init__.py
Normal file
0
reports-app/backend/app/services/__init__.py
Normal file
0
reports-app/backend/app/services/__init__.py
Normal file
1842
reports-app/backend/app/services/dashboard_service.py
Normal file
1842
reports-app/backend/app/services/dashboard_service.py
Normal file
File diff suppressed because it is too large
Load Diff
268
reports-app/backend/app/services/invoice_service.py
Normal file
268
reports-app/backend/app/services/invoice_service.py
Normal 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)
|
||||
161
reports-app/backend/app/services/treasury_service.py
Normal file
161
reports-app/backend/app/services/treasury_service.py
Normal 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
|
||||
)
|
||||
13
reports-app/backend/requirements.txt
Normal file
13
reports-app/backend/requirements.txt
Normal 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
|
||||
36
reports-app/frontend/.eslintrc.cjs
Normal file
36
reports-app/frontend/.eslintrc.cjs
Normal 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
64
reports-app/frontend/.gitignore
vendored
Normal 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
|
||||
231
reports-app/frontend/ANDROID_QUICK_START.md
Normal file
231
reports-app/frontend/ANDROID_QUICK_START.md
Normal 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`
|
||||
55
reports-app/frontend/Dockerfile
Normal file
55
reports-app/frontend/Dockerfile
Normal 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;"]
|
||||
202
reports-app/frontend/QUICK_START_TESTING.md
Normal file
202
reports-app/frontend/QUICK_START_TESTING.md
Normal 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!
|
||||
437
reports-app/frontend/README.md
Normal file
437
reports-app/frontend/README.md
Normal 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
|
||||
617
reports-app/frontend/RESPONSIVE_EXPORT_PLAN.md
Normal file
617
reports-app/frontend/RESPONSIVE_EXPORT_PLAN.md
Normal 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
|
||||
249
reports-app/frontend/TEST_RUNNER_GUIDE.md
Normal file
249
reports-app/frontend/TEST_RUNNER_GUIDE.md
Normal 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`*
|
||||
153
reports-app/frontend/cleanup-tests.sh
Normal file
153
reports-app/frontend/cleanup-tests.sh
Normal 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
|
||||
20
reports-app/frontend/index.html
Normal file
20
reports-app/frontend/index.html
Normal 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>
|
||||
54
reports-app/frontend/nginx.conf
Normal file
54
reports-app/frontend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
56
reports-app/frontend/package.json
Normal file
56
reports-app/frontend/package.json
Normal 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"
|
||||
}
|
||||
46
reports-app/frontend/playwright.config.js
Normal file
46
reports-app/frontend/playwright.config.js
Normal 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,
|
||||
},
|
||||
});
|
||||
70
reports-app/frontend/playwright.real-api.config.js
Normal file
70
reports-app/frontend/playwright.real-api.config.js
Normal 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'
|
||||
}
|
||||
});
|
||||
514
reports-app/frontend/run-comprehensive-tests.sh
Normal file
514
reports-app/frontend/run-comprehensive-tests.sh
Normal 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 "$@"
|
||||
210
reports-app/frontend/run-tests.sh
Normal file
210
reports-app/frontend/run-tests.sh
Normal 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 "$@"
|
||||
236
reports-app/frontend/scripts/README_ANDROID.md
Normal file
236
reports-app/frontend/scripts/README_ANDROID.md
Normal 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)
|
||||
39
reports-app/frontend/scripts/android-disconnect.sh
Normal file
39
reports-app/frontend/scripts/android-disconnect.sh
Normal 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
|
||||
266
reports-app/frontend/scripts/android-test-setup.ps1
Normal file
266
reports-app/frontend/scripts/android-test-setup.ps1
Normal 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
|
||||
221
reports-app/frontend/src/App.vue
Normal file
221
reports-app/frontend/src/App.vue
Normal 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>
|
||||
430
reports-app/frontend/src/assets/css/components/buttons.css
Normal file
430
reports-app/frontend/src/assets/css/components/buttons.css
Normal 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;
|
||||
}
|
||||
360
reports-app/frontend/src/assets/css/components/cards.css
Normal file
360
reports-app/frontend/src/assets/css/components/cards.css
Normal 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);
|
||||
}
|
||||
}
|
||||
460
reports-app/frontend/src/assets/css/components/forms.css
Normal file
460
reports-app/frontend/src/assets/css/components/forms.css
Normal 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;
|
||||
}
|
||||
}
|
||||
448
reports-app/frontend/src/assets/css/components/stats.css
Normal file
448
reports-app/frontend/src/assets/css/components/stats.css
Normal 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;
|
||||
}
|
||||
}
|
||||
876
reports-app/frontend/src/assets/css/components/tables.css
Normal file
876
reports-app/frontend/src/assets/css/components/tables.css
Normal 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;
|
||||
}
|
||||
}
|
||||
126
reports-app/frontend/src/assets/css/core/reset.css
Normal file
126
reports-app/frontend/src/assets/css/core/reset.css
Normal 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;
|
||||
}
|
||||
}
|
||||
155
reports-app/frontend/src/assets/css/core/typography.css
Normal file
155
reports-app/frontend/src/assets/css/core/typography.css
Normal 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);
|
||||
}
|
||||
}
|
||||
181
reports-app/frontend/src/assets/css/core/variables.css
Normal file
181
reports-app/frontend/src/assets/css/core/variables.css
Normal 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 */
|
||||
}
|
||||
}
|
||||
686
reports-app/frontend/src/assets/css/global.css
Normal file
686
reports-app/frontend/src/assets/css/global.css
Normal 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);
|
||||
}
|
||||
}
|
||||
212
reports-app/frontend/src/assets/css/layout/containers.css
Normal file
212
reports-app/frontend/src/assets/css/layout/containers.css
Normal 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;
|
||||
}
|
||||
159
reports-app/frontend/src/assets/css/layout/grid.css
Normal file
159
reports-app/frontend/src/assets/css/layout/grid.css
Normal 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);
|
||||
}
|
||||
289
reports-app/frontend/src/assets/css/layout/navigation.css
Normal file
289
reports-app/frontend/src/assets/css/layout/navigation.css
Normal 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;
|
||||
}
|
||||
}
|
||||
144
reports-app/frontend/src/assets/css/main.css
Normal file
144
reports-app/frontend/src/assets/css/main.css
Normal 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;
|
||||
}
|
||||
}
|
||||
695
reports-app/frontend/src/assets/css/mobile.css
Normal file
695
reports-app/frontend/src/assets/css/mobile.css
Normal 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 */
|
||||
}
|
||||
}
|
||||
259
reports-app/frontend/src/assets/css/utilities/display.css
Normal file
259
reports-app/frontend/src/assets/css/utilities/display.css
Normal 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; }
|
||||
}
|
||||
135
reports-app/frontend/src/assets/css/utilities/flex.css
Normal file
135
reports-app/frontend/src/assets/css/utilities/flex.css
Normal 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; }
|
||||
}
|
||||
206
reports-app/frontend/src/assets/css/utilities/spacing.css
Normal file
206
reports-app/frontend/src/assets/css/utilities/spacing.css
Normal 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); }
|
||||
}
|
||||
137
reports-app/frontend/src/assets/css/utilities/text.css
Normal file
137
reports-app/frontend/src/assets/css/utilities/text.css
Normal 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); }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
316
reports-app/frontend/src/components/dashboard/TrendChart.vue
Normal file
316
reports-app/frontend/src/components/dashboard/TrendChart.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
255
reports-app/frontend/src/components/layout/DashboardHeader.vue
Normal file
255
reports-app/frontend/src/components/layout/DashboardHeader.vue
Normal 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>
|
||||
75
reports-app/frontend/src/components/layout/HamburgerMenu.vue
Normal file
75
reports-app/frontend/src/components/layout/HamburgerMenu.vue
Normal 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>
|
||||
7
reports-app/frontend/src/composables/index.js
Normal file
7
reports-app/frontend/src/composables/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
useResponsive,
|
||||
useMobileNav,
|
||||
useResponsiveTable,
|
||||
useResponsiveForm,
|
||||
useResponsiveGrid,
|
||||
} from "./useResponsive";
|
||||
311
reports-app/frontend/src/composables/useResponsive.js
Normal file
311
reports-app/frontend/src/composables/useResponsive.js
Normal 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,
|
||||
};
|
||||
}
|
||||
76
reports-app/frontend/src/main.js
Normal file
76
reports-app/frontend/src/main.js
Normal 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");
|
||||
101
reports-app/frontend/src/router/index.js
Normal file
101
reports-app/frontend/src/router/index.js
Normal 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;
|
||||
0
reports-app/frontend/src/services/__init__.py
Normal file
0
reports-app/frontend/src/services/__init__.py
Normal file
139
reports-app/frontend/src/services/api.js
Normal file
139
reports-app/frontend/src/services/api.js
Normal 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;
|
||||
6
reports-app/frontend/src/services/index.js
Normal file
6
reports-app/frontend/src/services/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
apiService,
|
||||
authAPI,
|
||||
companiesAPI,
|
||||
invoicesAPI,
|
||||
} from "./api";
|
||||
188
reports-app/frontend/src/stores/companies.js
Normal file
188
reports-app/frontend/src/stores/companies.js
Normal 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,
|
||||
};
|
||||
});
|
||||
373
reports-app/frontend/src/stores/dashboard.js
Normal file
373
reports-app/frontend/src/stores/dashboard.js
Normal 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
|
||||
};
|
||||
});
|
||||
5
reports-app/frontend/src/stores/index.js
Normal file
5
reports-app/frontend/src/stores/index.js
Normal 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";
|
||||
165
reports-app/frontend/src/stores/invoices.js
Normal file
165
reports-app/frontend/src/stores/invoices.js
Normal 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,
|
||||
};
|
||||
});
|
||||
77
reports-app/frontend/src/stores/treasury.js
Normal file
77
reports-app/frontend/src/stores/treasury.js
Normal 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
|
||||
};
|
||||
});
|
||||
0
reports-app/frontend/src/utils/__init__.py
Normal file
0
reports-app/frontend/src/utils/__init__.py
Normal file
221
reports-app/frontend/src/utils/exportUtils.js
Normal file
221
reports-app/frontend/src/utils/exportUtils.js
Normal 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;
|
||||
};
|
||||
0
reports-app/frontend/src/utils/index.js
Normal file
0
reports-app/frontend/src/utils/index.js
Normal file
376
reports-app/frontend/src/views/BankCashRegisterView.vue
Normal file
376
reports-app/frontend/src/views/BankCashRegisterView.vue
Normal 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>
|
||||
2042
reports-app/frontend/src/views/DashboardView.vue
Normal file
2042
reports-app/frontend/src/views/DashboardView.vue
Normal file
File diff suppressed because it is too large
Load Diff
754
reports-app/frontend/src/views/InvoicesView.vue
Normal file
754
reports-app/frontend/src/views/InvoicesView.vue
Normal 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>
|
||||
394
reports-app/frontend/src/views/LoginView.vue
Normal file
394
reports-app/frontend/src/views/LoginView.vue
Normal 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>
|
||||
432
reports-app/frontend/src/views/TelegramView.vue
Normal file
432
reports-app/frontend/src/views/TelegramView.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
1035
reports-app/frontend/src/views/backup_dashboards/DashboardView.vue
Normal file
1035
reports-app/frontend/src/views/backup_dashboards/DashboardView.vue
Normal file
File diff suppressed because it is too large
Load Diff
908
reports-app/frontend/tests/ANDROID_TESTING_GUIDE.md
Normal file
908
reports-app/frontend/tests/ANDROID_TESTING_GUIDE.md
Normal 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)
|
||||
275
reports-app/frontend/tests/README.md
Normal file
275
reports-app/frontend/tests/README.md
Normal 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`* 🎭
|
||||
192
reports-app/frontend/tests/e2e/button-fix-test.spec.js
Normal file
192
reports-app/frontend/tests/e2e/button-fix-test.spec.js
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user