feat: Add A-Z filter for clients/suppliers in Telegram bot
- Add A-Z alphabetical filter keyboard for clients and suppliers lists (same pattern as company selection, without emoji) - Increase clients/suppliers list pagination from 10 to 20 items per page - Remove emoji from company A-Z filter button for consistency - Add 6 new callback handlers: clients_alpha_menu, clients_alpha:LETTER, clients_alpha_page:PAGE:LETTER, and supplier equivalents - Dashboard service and models updates - Telegram bot: email handlers, auth, DB operations, internal API improvements - Frontend: dashboard cards updates (CashFlow, Clienti, Furnizori, Treasury) - Frontend: SolduriCompactCard and CollapsibleCard improvements - DashboardView enhancements - start.sh and run-with-restart.sh script updates - IIS web.config and service worker updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,69 +0,0 @@
|
|||||||
# Handover: Curățare Cod ROA2WEB
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Am analizat arhitectura ROA2WEB cu 3 agenți de explorare și am identificat 4 acțiuni de curățare cu randament mare și risc minim.
|
|
||||||
|
|
||||||
**Plan detaliat**: `/home/claude/.claude/plans/immutable-chasing-flute.md`
|
|
||||||
|
|
||||||
## Prompt pentru /ralph:prd
|
|
||||||
|
|
||||||
```
|
|
||||||
Implementează curățarea codului ROA2WEB conform planului din /home/claude/.claude/plans/immutable-chasing-flute.md
|
|
||||||
|
|
||||||
Obiective:
|
|
||||||
1. Elimină store duplication - șterge src/modules/reports/stores/sharedStores.js și src/modules/data-entry/stores/sharedStores.js, mută instantierea în App.vue
|
|
||||||
2. Creează factory pentru API services - src/shared/services/createApiService.js și simplifică api.js din module
|
|
||||||
3. Fă dependențele OCR opționale - adaugă variabile în .env (OCR_ENABLE_PADDLEOCR, OCR_ENABLE_TESSERACT), implementează lazy loading în OCR service, mută deps în requirements opțional
|
|
||||||
4. Consolidează CSS design tokens - unifică variables.css, tokens.css, md3-tokens.css într-un singur design-tokens.css
|
|
||||||
|
|
||||||
Constrângeri:
|
|
||||||
- NU schimba arhitectura (layered architecture rămâne)
|
|
||||||
- NU modifica auth middleware, cache decorator, oracle pool
|
|
||||||
- Testează după fiecare modificare: app pornește, login funcționează, dark mode arată corect
|
|
||||||
|
|
||||||
Ordinea execuției:
|
|
||||||
1. [15 min] Store duplication
|
|
||||||
2. [30 min] API factory
|
|
||||||
3. [30 min] OCR dependencies opționale (lazy loading via .env)
|
|
||||||
4. [1 oră] CSS tokens
|
|
||||||
|
|
||||||
Impact așteptat: -150 linii cod duplicat, OCR deps opționale via .env, -2 fișiere CSS, startup mai rapid
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fișiere Cheie de Referință
|
|
||||||
|
|
||||||
### De Șters
|
|
||||||
- `src/modules/reports/stores/sharedStores.js`
|
|
||||||
- `src/modules/data-entry/stores/sharedStores.js`
|
|
||||||
|
|
||||||
### De Creat
|
|
||||||
- `src/shared/services/createApiService.js`
|
|
||||||
- `src/assets/css/core/design-tokens.css` (consolidat)
|
|
||||||
|
|
||||||
### De Modificat
|
|
||||||
- `src/App.vue` - adaugă instantiere stores
|
|
||||||
- `src/modules/reports/services/api.js` - simplifică cu factory
|
|
||||||
- `src/modules/data-entry/services/api.js` - simplifică cu factory
|
|
||||||
- `backend/requirements.txt` - mută paddleocr/tesseract în secțiune opțională (comentate)
|
|
||||||
- `backend/.env` - adaugă `OCR_ENABLE_PADDLEOCR=false` și `OCR_ENABLE_TESSERACT=false`
|
|
||||||
- `backend/modules/data_entry/services/ocr_service.py` - implementează lazy loading pentru OCR engines
|
|
||||||
- `src/assets/css/main.css` - actualizează importuri CSS
|
|
||||||
|
|
||||||
## Verificare Finală
|
|
||||||
|
|
||||||
După implementare, verifică:
|
|
||||||
- [ ] `./start.sh prod` pornește fără erori
|
|
||||||
- [ ] Login funcționează
|
|
||||||
- [ ] Un raport se încarcă corect
|
|
||||||
- [ ] O chitanță se poate crea
|
|
||||||
- [ ] Dark mode arată corect pe toate paginile
|
|
||||||
- [ ] `pip install -r backend/requirements.txt` reușește
|
|
||||||
- [ ] Cu `OCR_ENABLE_PADDLEOCR=false` - app pornește fără PaddleOCR instalat
|
|
||||||
- [ ] Cu `OCR_ENABLE_PADDLEOCR=true` - OCR fallback funcționează (dacă e instalat)
|
|
||||||
|
|
||||||
## Note
|
|
||||||
|
|
||||||
- Arhitectura actuală (Layered) este potrivită pentru echipa de 1-2 developeri
|
|
||||||
- Nu este nevoie de Vertical Slice sau Feature-Sliced Design
|
|
||||||
- Acțiunile opționale (split receiptStore, simplificare cache) pot fi făcute ulterior
|
|
||||||
@@ -2,6 +2,22 @@ from pydantic import BaseModel
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import List, Dict, Optional, Any
|
from typing import List, Dict, Optional, Any
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetDebtSubAccount(BaseModel):
|
||||||
|
"""Cont individual din cadrul unui grup de datorii buget"""
|
||||||
|
cont: str # ex: "4311"
|
||||||
|
label: str # ex: "4311 - CAS angajat"
|
||||||
|
precedent: Decimal # sold luna precedentă (pozitiv=datorie, negativ=creanță)
|
||||||
|
curent: Decimal # sold luna curentă (pozitiv=datorie, negativ=creanță)
|
||||||
|
|
||||||
|
class BudgetDebtGroup(BaseModel):
|
||||||
|
"""Grup de datorii la buget (TVA / BASS / CAM)"""
|
||||||
|
key: str # 'TVA', 'BASS', 'CAM'
|
||||||
|
label: str # 'TVA', 'BASS', 'CAM'
|
||||||
|
precedent: Decimal # total grup luna prec (semn ±)
|
||||||
|
curent: Decimal # total grup luna crt (semn ±)
|
||||||
|
sub_accounts: List[BudgetDebtSubAccount] = []
|
||||||
|
|
||||||
class TreasuryAccount(BaseModel):
|
class TreasuryAccount(BaseModel):
|
||||||
"""Cont de trezorerie (bancă/casă)"""
|
"""Cont de trezorerie (bancă/casă)"""
|
||||||
cont: str # 5121, 5124, 5311, 5314
|
cont: str # 5121, 5124, 5311, 5314
|
||||||
@@ -126,4 +142,8 @@ class DashboardSummary(BaseModel):
|
|||||||
tva_plata_precedent: Decimal = Decimal('0')
|
tva_plata_precedent: Decimal = Decimal('0')
|
||||||
tva_recuperat_precedent: Decimal = Decimal('0')
|
tva_recuperat_precedent: Decimal = Decimal('0')
|
||||||
tva_plata_curent: Decimal = Decimal('0')
|
tva_plata_curent: Decimal = Decimal('0')
|
||||||
tva_recuperat_curent: Decimal = Decimal('0')
|
tva_recuperat_curent: Decimal = Decimal('0')
|
||||||
|
|
||||||
|
# DATORII LA BUGET - breakdown pe grupe (TVA / BASS / CAM) cu sub-conturi
|
||||||
|
budget_debt_breakdown: List[BudgetDebtGroup] = []
|
||||||
|
budget_debt_total_precedent: Decimal = Decimal('0') # suma tuturor grupurilor luna prec
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from shared.database.oracle_pool import oracle_pool
|
from shared.database.oracle_pool import oracle_pool
|
||||||
from ..models.dashboard import DashboardSummary, TreasuryAccount, TrendData
|
from ..models.dashboard import DashboardSummary, TreasuryAccount, TrendData, BudgetDebtGroup, BudgetDebtSubAccount
|
||||||
from ..cache.decorators import cached
|
from ..cache.decorators import cached
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
@@ -12,6 +12,45 @@ import logging
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Grupuri de conturi pentru Datorii la Buget (3 grupe: TVA / BASS / CAM)
|
||||||
|
BUDGET_GROUPS = [
|
||||||
|
{
|
||||||
|
'key': 'TVA',
|
||||||
|
'label': 'TVA',
|
||||||
|
'accounts': ['4423', '4424'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'BASS',
|
||||||
|
'label': 'BASS',
|
||||||
|
'accounts': ['431', '4311', '4313', '4315', '4316', '437', '444', '4411', '4418', '446', '447'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'CAM',
|
||||||
|
'label': 'CAM',
|
||||||
|
'accounts': ['436'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCOUNT_LABELS = {
|
||||||
|
'4311': '4311 - CAS angajat',
|
||||||
|
'4313': '4313 - CAS accidente muncă',
|
||||||
|
'4315': '4315 - CAS suplimentar',
|
||||||
|
'4316': '4316 - CASS angajat',
|
||||||
|
'431': '431 - CAS (cont total)',
|
||||||
|
'437': '437 - CASS (cont total)',
|
||||||
|
'444': '444 - Impozit salarii',
|
||||||
|
'4411': '4411 - Impozit pe profit',
|
||||||
|
'4418': '4418 - Impozit amânat',
|
||||||
|
'446': '446 - Alte impozite și taxe',
|
||||||
|
'447': '447 - Fonduri speciale',
|
||||||
|
'4423': '4423 - TVA de plată',
|
||||||
|
'4424': '4424 - TVA de recuperat',
|
||||||
|
'4426': '4426 - TVA deductibilă',
|
||||||
|
'4427': '4427 - TVA colectată',
|
||||||
|
'436': '436 - CAM',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DashboardService:
|
class DashboardService:
|
||||||
"""Service pentru dashboard - date agregate"""
|
"""Service pentru dashboard - date agregate"""
|
||||||
|
|
||||||
@@ -412,13 +451,17 @@ class DashboardService:
|
|||||||
AND ((cont IN ('5121','5311') AND soldcred - solddeb != 0)
|
AND ((cont IN ('5121','5311') AND soldcred - solddeb != 0)
|
||||||
OR (cont IN ('5124','5314') AND soldvalcred - soldvaldeb != 0))
|
OR (cont IN ('5124','5314') AND soldvalcred - soldvaldeb != 0))
|
||||||
GROUP BY cont, nume, nume_val
|
GROUP BY cont, nume, nume_val
|
||||||
ORDER BY cont, nume
|
ORDER BY
|
||||||
|
CASE WHEN cont IN ('5121','5311') THEN 0 ELSE 1 END,
|
||||||
|
cont,
|
||||||
|
UPPER(nume)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
cursor.execute(treasury_query, period_params)
|
cursor.execute(treasury_query, period_params)
|
||||||
treasury_rows = cursor.fetchall()
|
treasury_rows = cursor.fetchall()
|
||||||
|
|
||||||
# Query 3: Solduri TVA din tabelul vbal (folosește același period_cte)
|
# Query 3: Solduri datorii buget din tabelul vbal (43xx + 44xx)
|
||||||
|
# Extins față de TVA-only pentru a include CAS, CASS, impozite, etc.
|
||||||
tva_query = f"""
|
tva_query = f"""
|
||||||
{period_cte}
|
{period_cte}
|
||||||
SELECT
|
SELECT
|
||||||
@@ -426,34 +469,42 @@ class DashboardService:
|
|||||||
precdeb,
|
precdeb,
|
||||||
preccred,
|
preccred,
|
||||||
ruldeb,
|
ruldeb,
|
||||||
rulcred
|
rulcred,
|
||||||
|
solddeb,
|
||||||
|
soldcred
|
||||||
FROM {schema}.vbal
|
FROM {schema}.vbal
|
||||||
WHERE an = (SELECT anul FROM luna_curenta)
|
WHERE an = (SELECT anul FROM luna_curenta)
|
||||||
AND luna = (SELECT luna FROM luna_curenta)
|
AND luna = (SELECT luna FROM luna_curenta)
|
||||||
AND cont IN ('4423', '4424', '4426', '4427')
|
AND (cont LIKE '43%' OR cont LIKE '44%')
|
||||||
ORDER BY cont
|
ORDER BY cont
|
||||||
"""
|
"""
|
||||||
|
|
||||||
cursor.execute(tva_query, period_params)
|
cursor.execute(tva_query, period_params)
|
||||||
tva_rows = cursor.fetchall()
|
tva_rows = cursor.fetchall()
|
||||||
|
|
||||||
# Procesare solduri TVA
|
# Procesare solduri: dict generic pentru toate conturile 43xx/44xx
|
||||||
tva_data = {
|
_zero = {'precdeb': Decimal('0'), 'preccred': Decimal('0'), 'ruldeb': Decimal('0'), 'rulcred': Decimal('0'), 'solddeb': Decimal('0'), 'soldcred': Decimal('0')}
|
||||||
'4423': {'precdeb': Decimal('0'), 'preccred': Decimal('0'), 'ruldeb': Decimal('0'), 'rulcred': Decimal('0')},
|
all_budget_data: Dict[str, Any] = {}
|
||||||
'4424': {'precdeb': Decimal('0'), 'preccred': Decimal('0'), 'ruldeb': Decimal('0'), 'rulcred': Decimal('0')},
|
|
||||||
'4426': {'precdeb': Decimal('0'), 'preccred': Decimal('0'), 'ruldeb': Decimal('0'), 'rulcred': Decimal('0')},
|
|
||||||
'4427': {'precdeb': Decimal('0'), 'preccred': Decimal('0'), 'ruldeb': Decimal('0'), 'rulcred': Decimal('0')}
|
|
||||||
}
|
|
||||||
|
|
||||||
for row in tva_rows:
|
for row in tva_rows:
|
||||||
cont = row[0]
|
cont = row[0]
|
||||||
if cont in tva_data:
|
all_budget_data[cont] = {
|
||||||
tva_data[cont]['precdeb'] = Decimal(str(row[1] or 0))
|
'precdeb': Decimal(str(row[1] or 0)),
|
||||||
tva_data[cont]['preccred'] = Decimal(str(row[2] or 0))
|
'preccred': Decimal(str(row[2] or 0)),
|
||||||
tva_data[cont]['ruldeb'] = Decimal(str(row[3] or 0))
|
'ruldeb': Decimal(str(row[3] or 0)),
|
||||||
tva_data[cont]['rulcred'] = Decimal(str(row[4] or 0))
|
'rulcred': Decimal(str(row[4] or 0)),
|
||||||
|
'solddeb': Decimal(str(row[5] or 0)),
|
||||||
|
'soldcred': Decimal(str(row[6] or 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backward compat: tva_data dict cu doar conturile TVA clasice
|
||||||
|
tva_data = {
|
||||||
|
k: all_budget_data.get(k, dict(_zero))
|
||||||
|
for k in ['4423', '4424', '4426', '4427']
|
||||||
|
}
|
||||||
|
|
||||||
# Calcul TVA Luna Precedentă - FIE de plată (4423) FIE de recuperat (4424)
|
# Calcul TVA Luna Precedentă - FIE de plată (4423) FIE de recuperat (4424)
|
||||||
|
# Primary: folosim soldurile conturilor de regularizare TVA (luna anterioară închisă)
|
||||||
sold_4423 = tva_data['4423']['preccred'] - tva_data['4423']['precdeb']
|
sold_4423 = tva_data['4423']['preccred'] - tva_data['4423']['precdeb']
|
||||||
sold_4424 = tva_data['4424']['precdeb'] - tva_data['4424']['preccred']
|
sold_4424 = tva_data['4424']['precdeb'] - tva_data['4424']['preccred']
|
||||||
|
|
||||||
@@ -464,18 +515,97 @@ class DashboardService:
|
|||||||
tva_recuperat_precedent = sold_4424
|
tva_recuperat_precedent = sold_4424
|
||||||
tva_plata_precedent = Decimal('0')
|
tva_plata_precedent = Decimal('0')
|
||||||
else:
|
else:
|
||||||
tva_plata_precedent = Decimal('0')
|
# Fallback: când luna anterioară nu e închisă (4423/4424 = 0),
|
||||||
tva_recuperat_precedent = Decimal('0')
|
# calculăm soldul lunar net al conturilor 4427/4426
|
||||||
|
sold_4427_prec = tva_data['4427']['preccred'] - tva_data['4427']['precdeb']
|
||||||
|
sold_4426_prec = tva_data['4426']['precdeb'] - tva_data['4426']['preccred']
|
||||||
|
diferenta_prec = sold_4427_prec - sold_4426_prec
|
||||||
|
if diferenta_prec > 0:
|
||||||
|
tva_plata_precedent = diferenta_prec
|
||||||
|
tva_recuperat_precedent = Decimal('0')
|
||||||
|
else:
|
||||||
|
tva_recuperat_precedent = -diferenta_prec
|
||||||
|
tva_plata_precedent = Decimal('0')
|
||||||
|
|
||||||
# Calcul TVA Luna Curentă - FIE de plată FIE de recuperat
|
# Calcul TVA Luna Curentă - rulaj lunar 4423/4424 (nu sold cumulat)
|
||||||
diferenta_curent = tva_data['4427']['rulcred'] - tva_data['4426']['ruldeb']
|
sold_4423_cur = tva_data['4423']['rulcred'] - tva_data['4423']['ruldeb']
|
||||||
|
sold_4424_cur = tva_data['4424']['ruldeb'] - tva_data['4424']['rulcred']
|
||||||
|
|
||||||
if diferenta_curent > 0:
|
if sold_4423_cur > 0:
|
||||||
tva_plata_curent = diferenta_curent
|
tva_plata_curent = sold_4423_cur
|
||||||
tva_recuperat_curent = Decimal('0')
|
tva_recuperat_curent = Decimal('0')
|
||||||
else:
|
elif sold_4424_cur > 0:
|
||||||
tva_recuperat_curent = -diferenta_curent
|
tva_recuperat_curent = sold_4424_cur
|
||||||
tva_plata_curent = Decimal('0')
|
tva_plata_curent = Decimal('0')
|
||||||
|
else:
|
||||||
|
# Fallback: rulaj lunar net 4427/4426 (ruldeb/rulcred = doar luna curentă)
|
||||||
|
sold_4427_cur = tva_data['4427']['rulcred'] - tva_data['4427']['ruldeb']
|
||||||
|
sold_4426_cur = tva_data['4426']['ruldeb'] - tva_data['4426']['rulcred']
|
||||||
|
diferenta_curent = sold_4427_cur - sold_4426_cur
|
||||||
|
if diferenta_curent > 0:
|
||||||
|
tva_plata_curent = diferenta_curent
|
||||||
|
tva_recuperat_curent = Decimal('0')
|
||||||
|
else:
|
||||||
|
tva_recuperat_curent = -diferenta_curent
|
||||||
|
tva_plata_curent = Decimal('0')
|
||||||
|
|
||||||
|
# Calcul Datorii la Buget - 3 grupe (TVA / BASS / CAM) cu sub-conturi
|
||||||
|
budget_debt_breakdown = []
|
||||||
|
for group_def in BUDGET_GROUPS:
|
||||||
|
sub_accounts = []
|
||||||
|
group_prec = Decimal('0')
|
||||||
|
group_cur = Decimal('0')
|
||||||
|
|
||||||
|
if group_def['key'] == 'TVA':
|
||||||
|
# TVA special: valorile deja calculate (semn ±)
|
||||||
|
tva_prec = tva_plata_precedent - tva_recuperat_precedent
|
||||||
|
tva_cur = tva_plata_curent - tva_recuperat_curent
|
||||||
|
group_prec = tva_prec
|
||||||
|
group_cur = tva_cur
|
||||||
|
# Sub-conturi TVA (doar cele cu sold non-zero)
|
||||||
|
for cont in ['4423', '4424', '4426', '4427']:
|
||||||
|
if cont in all_budget_data:
|
||||||
|
d = all_budget_data[cont]
|
||||||
|
if cont == '4424': # creanță → semn negativ
|
||||||
|
val_prec = -(d['precdeb'] - d['preccred'])
|
||||||
|
val_cur = -(d['ruldeb'] - d['rulcred'])
|
||||||
|
else: # 4423, 4426, 4427: rulcred - ruldeb (lunar, nu cumulat)
|
||||||
|
val_prec = d['preccred'] - d['precdeb']
|
||||||
|
val_cur = d['rulcred'] - d['ruldeb']
|
||||||
|
if val_prec != 0 or val_cur != 0:
|
||||||
|
sub_accounts.append(BudgetDebtSubAccount(
|
||||||
|
cont=cont,
|
||||||
|
label=ACCOUNT_LABELS[cont],
|
||||||
|
precedent=val_prec,
|
||||||
|
curent=val_cur,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# BASS și CAM: matching exact pe codul contului
|
||||||
|
for account_code in group_def['accounts']:
|
||||||
|
if account_code in all_budget_data:
|
||||||
|
d = all_budget_data[account_code]
|
||||||
|
val_prec = d['preccred'] - d['precdeb'] # pozitiv = datorie
|
||||||
|
val_cur = d['rulcred'] - d['ruldeb'] # rulaj luna curentă (consistent cu TVA)
|
||||||
|
if val_prec != 0 or val_cur != 0:
|
||||||
|
sub_accounts.append(BudgetDebtSubAccount(
|
||||||
|
cont=account_code,
|
||||||
|
label=ACCOUNT_LABELS.get(account_code, account_code),
|
||||||
|
precedent=val_prec,
|
||||||
|
curent=val_cur,
|
||||||
|
))
|
||||||
|
group_prec += val_prec
|
||||||
|
group_cur += val_cur
|
||||||
|
|
||||||
|
if group_prec != 0 or group_cur != 0 or sub_accounts:
|
||||||
|
budget_debt_breakdown.append(BudgetDebtGroup(
|
||||||
|
key=group_def['key'],
|
||||||
|
label=group_def['label'],
|
||||||
|
precedent=group_prec,
|
||||||
|
curent=group_cur,
|
||||||
|
sub_accounts=sub_accounts,
|
||||||
|
))
|
||||||
|
|
||||||
|
budget_debt_total_precedent = sum(g.precedent for g in budget_debt_breakdown)
|
||||||
|
|
||||||
# Procesare trezorerie
|
# Procesare trezorerie
|
||||||
treasury_accounts = []
|
treasury_accounts = []
|
||||||
@@ -562,7 +692,11 @@ class DashboardService:
|
|||||||
tva_plata_precedent=tva_plata_precedent,
|
tva_plata_precedent=tva_plata_precedent,
|
||||||
tva_recuperat_precedent=tva_recuperat_precedent,
|
tva_recuperat_precedent=tva_recuperat_precedent,
|
||||||
tva_plata_curent=tva_plata_curent,
|
tva_plata_curent=tva_plata_curent,
|
||||||
tva_recuperat_curent=tva_recuperat_curent
|
tva_recuperat_curent=tva_recuperat_curent,
|
||||||
|
|
||||||
|
# Datorii la buget pe grupe cu sub-conturi
|
||||||
|
budget_debt_breakdown=budget_debt_breakdown,
|
||||||
|
budget_debt_total_precedent=budget_debt_total_precedent,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1691,7 +1825,9 @@ class DashboardService:
|
|||||||
AND vb.luna = lc.luna
|
AND vb.luna = lc.luna
|
||||||
AND vb.cont IN ('5311', '5314', '5328', '5121', '5124')
|
AND vb.cont IN ('5311', '5314', '5328', '5121', '5124')
|
||||||
AND (vb.solddeb - vb.soldcred) <> 0
|
AND (vb.solddeb - vb.soldcred) <> 0
|
||||||
ORDER BY tip, vb.cont
|
ORDER BY tip,
|
||||||
|
CASE WHEN vb.cont IN ('5121','5311') THEN 0 ELSE 1 END,
|
||||||
|
UPPER(vb.nume)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
cursor.execute(treasury_query, period_params)
|
cursor.execute(treasury_query, period_params)
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ class BackendAPIClient:
|
|||||||
async def verify_user(
|
async def verify_user(
|
||||||
self,
|
self,
|
||||||
oracle_username: str,
|
oracle_username: str,
|
||||||
linking_code: str
|
linking_code: str,
|
||||||
|
server_id: Optional[str] = None
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Verify user exists in Oracle and get JWT token.
|
Verify user exists in Oracle and get JWT token.
|
||||||
@@ -139,7 +140,8 @@ class BackendAPIClient:
|
|||||||
"/api/telegram/auth/verify-user",
|
"/api/telegram/auth/verify-user",
|
||||||
json={
|
json={
|
||||||
"linking_code": linking_code,
|
"linking_code": linking_code,
|
||||||
"oracle_username": oracle_username
|
"oracle_username": oracle_username,
|
||||||
|
"server_id": server_id
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -185,12 +187,13 @@ class BackendAPIClient:
|
|||||||
logger.error(f"Failed to refresh token: {e}")
|
logger.error(f"Failed to refresh token: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def verify_email(self, email: str) -> dict:
|
async def verify_email(self, email: str, server_id: Optional[str] = None) -> dict:
|
||||||
"""
|
"""
|
||||||
Verify if email exists in Oracle database
|
Verify if email exists in Oracle database
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
email: Email address to verify
|
email: Email address to verify
|
||||||
|
server_id: Optional Oracle server ID (for multi-server mode)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with 'success' (bool), 'username' (str or None), and 'message' (str)
|
dict with 'success' (bool), 'username' (str or None), and 'message' (str)
|
||||||
@@ -204,7 +207,7 @@ class BackendAPIClient:
|
|||||||
|
|
||||||
response = await self.client.post(
|
response = await self.client.post(
|
||||||
"/api/telegram/auth/verify-email",
|
"/api/telegram/auth/verify-email",
|
||||||
json={"email": email}
|
json={"email": email, "server_id": server_id}
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
@@ -229,7 +232,8 @@ class BackendAPIClient:
|
|||||||
email: str,
|
email: str,
|
||||||
password: str,
|
password: str,
|
||||||
telegram_user_id: int,
|
telegram_user_id: int,
|
||||||
session_token: str
|
session_token: str,
|
||||||
|
server_id: Optional[str] = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Login via email + password with session token
|
Login via email + password with session token
|
||||||
@@ -239,6 +243,7 @@ class BackendAPIClient:
|
|||||||
password: Oracle password
|
password: Oracle password
|
||||||
telegram_user_id: Telegram user ID
|
telegram_user_id: Telegram user ID
|
||||||
session_token: Signed token from code validation
|
session_token: Signed token from code validation
|
||||||
|
server_id: Optional Oracle server ID (for multi-server mode)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Login response with JWT tokens and user data
|
Login response with JWT tokens and user data
|
||||||
@@ -256,7 +261,8 @@ class BackendAPIClient:
|
|||||||
"email": email,
|
"email": email,
|
||||||
"password": password,
|
"password": password,
|
||||||
"telegram_user_id": telegram_user_id,
|
"telegram_user_id": telegram_user_id,
|
||||||
"session_token": session_token
|
"session_token": session_token,
|
||||||
|
"server_id": server_id
|
||||||
},
|
},
|
||||||
timeout=30.0 # 30 seconds timeout
|
timeout=30.0 # 30 seconds timeout
|
||||||
)
|
)
|
||||||
@@ -298,6 +304,52 @@ class BackendAPIClient:
|
|||||||
"message": "Eroare de conexiune"
|
"message": "Eroare de conexiune"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def switch_server(
|
||||||
|
self,
|
||||||
|
jwt_token: str,
|
||||||
|
oracle_username: str,
|
||||||
|
new_server_id: str,
|
||||||
|
oracle_password: str = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Switch the active Oracle server for the authenticated user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
jwt_token: Current JWT access token (used for authentication)
|
||||||
|
oracle_username: Oracle username of the current user
|
||||||
|
new_server_id: Target Oracle server ID
|
||||||
|
oracle_password: Oracle password on the new server (required if servers have different passwords)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success, access_token, refresh_token, message
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not self.client or self.client.is_closed:
|
||||||
|
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||||
|
|
||||||
|
payload = {"oracle_username": oracle_username, "new_server_id": new_server_id}
|
||||||
|
if oracle_password:
|
||||||
|
payload["oracle_password"] = oracle_password
|
||||||
|
|
||||||
|
response = await self.client.post(
|
||||||
|
"/api/telegram/auth/switch-server",
|
||||||
|
json=payload,
|
||||||
|
headers=self._get_auth_headers(jwt_token)
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(f"Switch server HTTP error: {e.response.status_code}")
|
||||||
|
try:
|
||||||
|
return {"success": False, "message": e.response.json().get("detail", "Eroare")}
|
||||||
|
except Exception:
|
||||||
|
return {"success": False, "message": "Eroare la schimbarea serverului"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Switch server error: {e}")
|
||||||
|
return {"success": False, "message": "Eroare de conexiune"}
|
||||||
|
|
||||||
async def get_user_companies(self, jwt_token: str) -> List[Dict[str, Any]]:
|
async def get_user_companies(self, jwt_token: str) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get list of companies the user has access to.
|
Get list of companies the user has access to.
|
||||||
|
|||||||
@@ -101,12 +101,13 @@ def is_valid_email_format(email: str) -> bool:
|
|||||||
return bool(re.match(pattern, email))
|
return bool(re.match(pattern, email))
|
||||||
|
|
||||||
|
|
||||||
async def verify_email_in_oracle(email: str) -> Optional[str]:
|
async def verify_email_in_oracle(email: str, server_id: Optional[str] = None) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Verify email exists in Oracle UTILIZATORI table via backend API
|
Verify email exists in Oracle UTILIZATORI table via backend API
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
email: Email address to check
|
email: Email address to check
|
||||||
|
server_id: Optional Oracle server ID (for multi-server mode)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Oracle username if found and active, None otherwise
|
Oracle username if found and active, None otherwise
|
||||||
@@ -118,8 +119,8 @@ async def verify_email_in_oracle(email: str) -> Optional[str]:
|
|||||||
|
|
||||||
backend_client = get_backend_client()
|
backend_client = get_backend_client()
|
||||||
|
|
||||||
# Call backend API to verify email
|
# Call backend API to verify email (on specific server if provided)
|
||||||
response = await backend_client.verify_email(email)
|
response = await backend_client.verify_email(email, server_id=server_id)
|
||||||
|
|
||||||
if response.get('success'):
|
if response.get('success'):
|
||||||
username = response.get('username')
|
username = response.get('username')
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ async def link_telegram_account(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
oracle_username = code_data.get('oracle_username')
|
oracle_username = code_data.get('oracle_username')
|
||||||
logger.info(f"Auth code valid for Oracle user: {oracle_username}")
|
server_id = code_data.get('server_id') # Extract server_id from the stored code
|
||||||
|
logger.info(f"Auth code valid for Oracle user: {oracle_username} (server_id={server_id})")
|
||||||
|
|
||||||
# Step 2: Create/update Telegram user record (basic info)
|
# Step 2: Create/update Telegram user record (basic info)
|
||||||
user_created = await create_or_update_user(
|
user_created = await create_or_update_user(
|
||||||
@@ -97,7 +98,8 @@ async def link_telegram_account(
|
|||||||
async with backend_client:
|
async with backend_client:
|
||||||
user_data = await backend_client.verify_user(
|
user_data = await backend_client.verify_user(
|
||||||
oracle_username=oracle_username,
|
oracle_username=oracle_username,
|
||||||
linking_code=auth_code
|
linking_code=auth_code,
|
||||||
|
server_id=server_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if not user_data or not user_data.get('success'):
|
if not user_data or not user_data.get('success'):
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from backend.modules.telegram.auth.email_auth import (
|
from backend.modules.telegram.auth.email_auth import (
|
||||||
is_valid_email_format,
|
is_valid_email_format,
|
||||||
verify_email_in_oracle,
|
verify_email_in_oracle,
|
||||||
@@ -22,6 +24,7 @@ from backend.modules.telegram.auth.email_auth import (
|
|||||||
check_rate_limit,
|
check_rate_limit,
|
||||||
clear_rate_limit
|
clear_rate_limit
|
||||||
)
|
)
|
||||||
|
from shared.auth.email_server_cache import email_server_cache
|
||||||
from backend.modules.telegram.utils.email_service import get_email_service
|
from backend.modules.telegram.utils.email_service import get_email_service
|
||||||
from backend.modules.telegram.db.operations import (
|
from backend.modules.telegram.db.operations import (
|
||||||
create_email_auth_code,
|
create_email_auth_code,
|
||||||
@@ -39,7 +42,7 @@ from backend.modules.telegram.api.client import get_backend_client
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Conversation states
|
# Conversation states
|
||||||
AWAITING_EMAIL, AWAITING_CODE, AWAITING_PASSWORD = range(3)
|
AWAITING_EMAIL, AWAITING_CODE, AWAITING_PASSWORD, AWAITING_SERVER_SELECTION = range(4)
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
MAX_CODE_ATTEMPTS = 3
|
MAX_CODE_ATTEMPTS = 3
|
||||||
@@ -261,57 +264,25 @@ async def web_login_info_callback(update: Update, context: ContextTypes.DEFAULT_
|
|||||||
# STATE: AWAITING_EMAIL
|
# STATE: AWAITING_EMAIL
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
async def _send_email_code(
|
||||||
"""Handler pentru primirea email-ului"""
|
context: ContextTypes.DEFAULT_TYPE,
|
||||||
email = update.message.text.strip().lower()
|
chat_id: int,
|
||||||
user_id = update.effective_user.id
|
email: str,
|
||||||
|
server_id: Optional[str],
|
||||||
# ȘTERG mesajul utilizatorului imediat (chat curat)
|
user_id: int
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Generate and send email verification code on the specified server.
|
||||||
|
Returns AWAITING_CODE on success or ConversationHandler.END on failure.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
await update.message.delete()
|
# Verify email in Oracle (on specific server if known)
|
||||||
except Exception as e:
|
username = await verify_email_in_oracle(email, server_id=server_id)
|
||||||
logger.warning(f"Could not delete email message: {e}")
|
|
||||||
|
|
||||||
# Validare format email
|
|
||||||
if not is_valid_email_format(email):
|
|
||||||
# Show error in main message
|
|
||||||
await edit_login_message(
|
|
||||||
context=context,
|
|
||||||
chat_id=update.effective_chat.id,
|
|
||||||
text="Email invalid\n\nIntrodu o adresă validă (nume@domeniu.ro)",
|
|
||||||
reply_markup=InlineKeyboardMarkup([
|
|
||||||
[InlineKeyboardButton("Anulează", callback_data="cancel")]
|
|
||||||
])
|
|
||||||
)
|
|
||||||
return AWAITING_EMAIL
|
|
||||||
|
|
||||||
# Check for existing pending code
|
|
||||||
existing_code = await get_pending_email_code(user_id)
|
|
||||||
if existing_code:
|
|
||||||
# Delete old pending code
|
|
||||||
await delete_user_email_codes(user_id)
|
|
||||||
logger.info(f"Deleted existing pending code for user {user_id}")
|
|
||||||
|
|
||||||
# EDIT login message to show loading
|
|
||||||
await edit_login_message(
|
|
||||||
context=context,
|
|
||||||
chat_id=update.effective_chat.id,
|
|
||||||
text="Verificare email...",
|
|
||||||
reply_markup=None
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Verifică email în Oracle
|
|
||||||
username = await verify_email_in_oracle(email)
|
|
||||||
|
|
||||||
# IMPORTANT: Generic response to prevent email enumeration
|
# IMPORTANT: Generic response to prevent email enumeration
|
||||||
# We always say "code sent" even if email doesn't exist
|
# We always say "code sent" even if email doesn't exist
|
||||||
|
|
||||||
if username:
|
if username:
|
||||||
# Email exists - generate and send code
|
|
||||||
code = generate_email_code()
|
code = generate_email_code()
|
||||||
|
|
||||||
# Save code in database
|
|
||||||
code_saved = await create_email_auth_code(
|
code_saved = await create_email_auth_code(
|
||||||
code=code,
|
code=code,
|
||||||
email=email,
|
email=email,
|
||||||
@@ -323,27 +294,26 @@ async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
if not code_saved:
|
if not code_saved:
|
||||||
await edit_login_message(
|
await edit_login_message(
|
||||||
context=context,
|
context=context,
|
||||||
chat_id=update.effective_chat.id,
|
chat_id=chat_id,
|
||||||
text="Eroare la salvarea codului.\n\nIncearcă din nou cu /login"
|
text="Eroare la salvarea codului.\n\nIncearcă din nou cu /login"
|
||||||
)
|
)
|
||||||
return ConversationHandler.END
|
return ConversationHandler.END
|
||||||
|
|
||||||
# Send email (async with retry)
|
|
||||||
email_service = get_email_service()
|
email_service = get_email_service()
|
||||||
email_sent = await email_service.send_auth_code(email, code, username)
|
email_sent = await email_service.send_auth_code(email, code, username)
|
||||||
|
|
||||||
if not email_sent:
|
if email_sent:
|
||||||
logger.error(f"Failed to send email to {email}")
|
logger.info(f"[EMAIL-AUTH] ✅ Code sent for {email[:3]}***@*** (user {user_id}, server={server_id})")
|
||||||
# Don't reveal this to user - they'll timeout naturally
|
else:
|
||||||
|
logger.error(f"[EMAIL-AUTH] ❌ Failed to send code (user {user_id}, server={server_id})")
|
||||||
|
|
||||||
# Wait 1 second for better UX (looks like verification happened)
|
# Wait 1 second for UX (looks like verification happened)
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
# ALWAYS show this message (prevent enumeration)
|
# ALWAYS show success (prevent enumeration)
|
||||||
# EDIT same message with success + buttons
|
|
||||||
await edit_login_message(
|
await edit_login_message(
|
||||||
context=context,
|
context=context,
|
||||||
chat_id=update.effective_chat.id,
|
chat_id=chat_id,
|
||||||
text=f"Cod trimis pe {email}\n\nIntrodu codul primit pe email:",
|
text=f"Cod trimis pe {email}\n\nIntrodu codul primit pe email:",
|
||||||
reply_markup=InlineKeyboardMarkup([
|
reply_markup=InlineKeyboardMarkup([
|
||||||
[InlineKeyboardButton("Retrimite Cod", callback_data=f"resend:{email}")],
|
[InlineKeyboardButton("Retrimite Cod", callback_data=f"resend:{email}")],
|
||||||
@@ -351,22 +321,135 @@ async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
])
|
])
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save email in context for resend functionality
|
|
||||||
context.user_data['pending_email'] = email
|
context.user_data['pending_email'] = email
|
||||||
context.user_data['pending_username'] = username
|
context.user_data['pending_username'] = username
|
||||||
|
|
||||||
return AWAITING_CODE
|
return AWAITING_CODE
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in receive_email: {e}", exc_info=True)
|
logger.error(f"Error sending email code: {e}", exc_info=True)
|
||||||
await edit_login_message(
|
await edit_login_message(
|
||||||
context=context,
|
context=context,
|
||||||
chat_id=update.effective_chat.id,
|
chat_id=chat_id,
|
||||||
text="Eroare internă.\n\nIncearcă din nou mai târziu."
|
text="Eroare internă.\n\nIncearcă din nou mai târziu."
|
||||||
)
|
)
|
||||||
return ConversationHandler.END
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
|
||||||
|
async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Handler pentru primirea email-ului"""
|
||||||
|
email = update.message.text.strip().lower()
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
chat_id = update.effective_chat.id
|
||||||
|
|
||||||
|
# ȘTERG mesajul utilizatorului imediat (chat curat)
|
||||||
|
try:
|
||||||
|
await update.message.delete()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not delete email message: {e}")
|
||||||
|
|
||||||
|
# Validare format email
|
||||||
|
if not is_valid_email_format(email):
|
||||||
|
await edit_login_message(
|
||||||
|
context=context,
|
||||||
|
chat_id=chat_id,
|
||||||
|
text="Email invalid\n\nIntrodu o adresă validă (nume@domeniu.ro)",
|
||||||
|
reply_markup=InlineKeyboardMarkup([
|
||||||
|
[InlineKeyboardButton("Anulează", callback_data="cancel")]
|
||||||
|
])
|
||||||
|
)
|
||||||
|
return AWAITING_EMAIL
|
||||||
|
|
||||||
|
# Clean up old pending codes
|
||||||
|
existing_code = await get_pending_email_code(user_id)
|
||||||
|
if existing_code:
|
||||||
|
await delete_user_email_codes(user_id)
|
||||||
|
logger.info(f"Deleted existing pending code for user {user_id}")
|
||||||
|
|
||||||
|
# Show loading
|
||||||
|
await edit_login_message(
|
||||||
|
context=context,
|
||||||
|
chat_id=chat_id,
|
||||||
|
text="Verificare email...",
|
||||||
|
reply_markup=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check server cache for multi-server routing
|
||||||
|
try:
|
||||||
|
await email_server_cache.refresh_if_needed()
|
||||||
|
servers = email_server_cache.get_servers_for_email(email)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not check email server cache: {e}")
|
||||||
|
servers = []
|
||||||
|
|
||||||
|
if len(servers) > 1:
|
||||||
|
# Multiple servers — ask user to select before sending code
|
||||||
|
context.user_data['pending_email'] = email
|
||||||
|
|
||||||
|
try:
|
||||||
|
from backend.config import settings
|
||||||
|
keyboard = []
|
||||||
|
for srv_id in servers:
|
||||||
|
srv = settings.get_oracle_server(srv_id)
|
||||||
|
srv_name = srv.name if srv else srv_id
|
||||||
|
keyboard.append([InlineKeyboardButton(srv_name, callback_data=f"select_server:{srv_id}")])
|
||||||
|
except Exception:
|
||||||
|
# Fallback: use server IDs as labels
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton(srv_id, callback_data=f"select_server:{srv_id}")]
|
||||||
|
for srv_id in servers
|
||||||
|
]
|
||||||
|
keyboard.append([InlineKeyboardButton("Anulează", callback_data="cancel")])
|
||||||
|
|
||||||
|
await edit_login_message(
|
||||||
|
context=context,
|
||||||
|
chat_id=chat_id,
|
||||||
|
text="Email identificat pe mai multe servere.\n\nSelectează serverul pentru autentificare:",
|
||||||
|
reply_markup=InlineKeyboardMarkup(keyboard)
|
||||||
|
)
|
||||||
|
return AWAITING_SERVER_SELECTION
|
||||||
|
|
||||||
|
# Single server or no cache hit — proceed directly
|
||||||
|
server_id = servers[0] if servers else None
|
||||||
|
context.user_data['server_id'] = server_id
|
||||||
|
|
||||||
|
return await _send_email_code(context, chat_id, email, server_id, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# STATE: AWAITING_SERVER_SELECTION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def handle_server_selected(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Callback pentru selectarea serverului Oracle (mod multi-server)"""
|
||||||
|
query = update.callback_query
|
||||||
|
user_id = update.effective_user.id
|
||||||
|
chat_id = update.effective_chat.id
|
||||||
|
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
# Extract server_id from callback data: "select_server:<id>"
|
||||||
|
server_id = query.data.split(":", 1)[1]
|
||||||
|
|
||||||
|
email = context.user_data.get('pending_email')
|
||||||
|
if not email:
|
||||||
|
await edit_login_message(
|
||||||
|
context=context,
|
||||||
|
chat_id=chat_id,
|
||||||
|
text="Sesiune expirată\n\nIncearcă din nou cu /login"
|
||||||
|
)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
# Save selected server to context
|
||||||
|
context.user_data['server_id'] = server_id
|
||||||
|
logger.info(f"[EMAIL-AUTH] User {user_id} selected server '{server_id}' for {email[:3]}***")
|
||||||
|
|
||||||
|
# Clean up old pending codes then send code on selected server
|
||||||
|
await delete_user_email_codes(user_id)
|
||||||
|
|
||||||
|
return await _send_email_code(context, chat_id, email, server_id, user_id)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# STATE: AWAITING_CODE
|
# STATE: AWAITING_CODE
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -593,12 +676,14 @@ async def receive_password(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
try:
|
try:
|
||||||
# Call backend endpoint pentru verificare parolă + JWT
|
# Call backend endpoint pentru verificare parolă + JWT
|
||||||
backend_client = get_backend_client()
|
backend_client = get_backend_client()
|
||||||
|
server_id = context.user_data.get('server_id')
|
||||||
|
|
||||||
response = await backend_client.login_with_email(
|
response = await backend_client.login_with_email(
|
||||||
email=email,
|
email=email,
|
||||||
password=password,
|
password=password,
|
||||||
telegram_user_id=user_id,
|
telegram_user_id=user_id,
|
||||||
session_token=session_token
|
session_token=session_token,
|
||||||
|
server_id=server_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response.get('success'):
|
if not response.get('success'):
|
||||||
@@ -749,6 +834,9 @@ email_login_handler = ConversationHandler(
|
|||||||
AWAITING_EMAIL: [
|
AWAITING_EMAIL: [
|
||||||
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_email)
|
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_email)
|
||||||
],
|
],
|
||||||
|
AWAITING_SERVER_SELECTION: [
|
||||||
|
CallbackQueryHandler(handle_server_selected, pattern='^select_server:')
|
||||||
|
],
|
||||||
AWAITING_CODE: [
|
AWAITING_CODE: [
|
||||||
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_code),
|
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_code),
|
||||||
CallbackQueryHandler(resend_code_callback, pattern='^resend:')
|
CallbackQueryHandler(resend_code_callback, pattern='^resend:')
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from backend.modules.telegram.auth.linking import (
|
|||||||
get_user_companies
|
get_user_companies
|
||||||
)
|
)
|
||||||
from backend.modules.telegram.agent.session import get_session_manager
|
from backend.modules.telegram.agent.session import get_session_manager
|
||||||
from backend.modules.telegram.db.operations import update_user_last_active
|
from backend.modules.telegram.db.operations import update_user_last_active, link_user_to_oracle
|
||||||
from backend.modules.telegram.api.client import get_backend_client
|
from backend.modules.telegram.api.client import get_backend_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -1279,9 +1279,82 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
|
|||||||
try:
|
try:
|
||||||
telegram_user = update.effective_user
|
telegram_user = update.effective_user
|
||||||
telegram_user_id = telegram_user.id
|
telegram_user_id = telegram_user.id
|
||||||
text = update.message.text.strip().upper()
|
text = update.message.text.strip()
|
||||||
|
|
||||||
logger.info(f"Text message from user {telegram_user_id}: {text}")
|
logger.info(f"Text message from user {telegram_user_id}")
|
||||||
|
|
||||||
|
# Check if user is awaiting password for server switch
|
||||||
|
pending_server_id = context.user_data.get('pending_switch_server_id')
|
||||||
|
if pending_server_id:
|
||||||
|
# Șterge IMEDIAT mesajul cu parola (securitate)
|
||||||
|
try:
|
||||||
|
await update.message.delete()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not delete password message: {e}")
|
||||||
|
|
||||||
|
oracle_password = text
|
||||||
|
jwt_token = context.user_data.pop('pending_switch_jwt_token', None)
|
||||||
|
username = context.user_data.pop('pending_switch_username', None)
|
||||||
|
context.user_data.pop('pending_switch_server_id', None)
|
||||||
|
|
||||||
|
if not jwt_token or not username:
|
||||||
|
await update.effective_chat.send_message("Sesiune expirată. Încearcă din nou.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await update.effective_chat.send_message("Se verifică parola și se schimbă serverul...")
|
||||||
|
|
||||||
|
client = get_backend_client()
|
||||||
|
async with client:
|
||||||
|
result = await client.switch_server(
|
||||||
|
jwt_token=jwt_token,
|
||||||
|
oracle_username=username,
|
||||||
|
new_server_id=pending_server_id,
|
||||||
|
oracle_password=oracle_password
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get('success'):
|
||||||
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
await update.effective_chat.send_message(
|
||||||
|
f"❌ {result.get('message', 'Eroare la schimbarea serverului')}\n\nReîncearcă cu /menu → Schimbă server.",
|
||||||
|
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("« Meniu", callback_data="action:menu")]])
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Salvează noul JWT în SQLite
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
token_expires_at = datetime.now() + timedelta(minutes=30)
|
||||||
|
await link_user_to_oracle(
|
||||||
|
telegram_user_id=telegram_user_id,
|
||||||
|
oracle_username=result.get('username', username),
|
||||||
|
jwt_token=result['access_token'],
|
||||||
|
jwt_refresh_token=result['refresh_token'],
|
||||||
|
token_expires_at=token_expires_at
|
||||||
|
)
|
||||||
|
|
||||||
|
# Curăță compania din sesiune — aparținea serverului vechi
|
||||||
|
session_manager = get_session_manager()
|
||||||
|
session = await session_manager.get_or_create_session(telegram_user_id)
|
||||||
|
session.clear_active_company()
|
||||||
|
await session_manager.save_session(telegram_user_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from backend.config import settings
|
||||||
|
srv = settings.get_oracle_server(pending_server_id)
|
||||||
|
srv_display = srv.name if srv else pending_server_id
|
||||||
|
except Exception:
|
||||||
|
srv_display = pending_server_id
|
||||||
|
|
||||||
|
await update.effective_chat.send_message(f"✅ Server schimbat: **{srv_display}**\nSelectează firma...", parse_mode=ParseMode.MARKDOWN)
|
||||||
|
|
||||||
|
await _handle_selectcompany_view(
|
||||||
|
query_or_update=update,
|
||||||
|
telegram_user_id=telegram_user_id,
|
||||||
|
jwt_token=result['access_token'],
|
||||||
|
is_callback=False,
|
||||||
|
page=0,
|
||||||
|
search_term=""
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Check if user is already linked
|
# Check if user is already linked
|
||||||
is_linked = await check_user_linked(telegram_user_id)
|
is_linked = await check_user_linked(telegram_user_id)
|
||||||
@@ -1291,6 +1364,8 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE
|
|||||||
# (could add natural language processing here in the future)
|
# (could add natural language processing here in the future)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
text = text.upper() # Only uppercase for linking code check
|
||||||
|
|
||||||
# User is NOT linked - check if text looks like a linking code
|
# User is NOT linked - check if text looks like a linking code
|
||||||
# Linking codes are exactly 8 alphanumeric characters
|
# Linking codes are exactly 8 alphanumeric characters
|
||||||
if len(text) == 8 and text.isalnum():
|
if len(text) == 8 and text.isalnum():
|
||||||
@@ -1740,6 +1815,43 @@ async def handle_menu_callback(query, telegram_user_id: int, callback_data: str)
|
|||||||
search_term=""
|
search_term=""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif action == "switch_server":
|
||||||
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from shared.auth.email_server_cache import email_server_cache
|
||||||
|
from backend.modules.telegram.bot.menus import pad_message_for_wide_buttons
|
||||||
|
|
||||||
|
username = auth_data['username']
|
||||||
|
|
||||||
|
try:
|
||||||
|
servers = await email_server_cache.get_servers_for_username(username)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Could not get servers for {username}: {e}")
|
||||||
|
await query.answer("Eroare la obținerea serverelor.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(servers) <= 1:
|
||||||
|
await query.answer("Ești pe singurul server disponibil.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build server selection keyboard
|
||||||
|
try:
|
||||||
|
from backend.config import settings
|
||||||
|
keyboard_rows = []
|
||||||
|
for srv_id in servers:
|
||||||
|
srv = settings.get_oracle_server(srv_id)
|
||||||
|
srv_name = srv.name if srv else srv_id
|
||||||
|
keyboard_rows.append([InlineKeyboardButton(srv_name, callback_data=f"switch_server_confirm:{srv_id}")])
|
||||||
|
except Exception:
|
||||||
|
keyboard_rows = [[InlineKeyboardButton(s, callback_data=f"switch_server_confirm:{s}")] for s in servers]
|
||||||
|
|
||||||
|
keyboard_rows.append([InlineKeyboardButton("« Înapoi", callback_data="action:menu")])
|
||||||
|
|
||||||
|
await query.edit_message_text(
|
||||||
|
pad_message_for_wide_buttons(f"Selectează serverul Oracle:\n\nUtilizator: {username}"),
|
||||||
|
reply_markup=InlineKeyboardMarkup(keyboard_rows),
|
||||||
|
parse_mode=ParseMode.MARKDOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def handle_action_callback(query, telegram_user_id: int, callback_data: str):
|
async def handle_action_callback(query, telegram_user_id: int, callback_data: str):
|
||||||
"""
|
"""
|
||||||
@@ -2099,6 +2211,241 @@ async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
parse_mode=ParseMode.MARKDOWN
|
parse_mode=ParseMode.MARKDOWN
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif callback_data == "select_company_alpha_menu":
|
||||||
|
# Show A-Z letter filter keyboard
|
||||||
|
from backend.modules.telegram.bot.helpers import create_alpha_filter_keyboard
|
||||||
|
await query.edit_message_text(
|
||||||
|
"**Selectează litera**\n\nAlege prima literă a firmei:",
|
||||||
|
reply_markup=create_alpha_filter_keyboard(),
|
||||||
|
parse_mode=ParseMode.MARKDOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
elif callback_data.startswith("select_company_alpha:"):
|
||||||
|
# Filter companies by starting letter and show page 0
|
||||||
|
letter = callback_data.split(":", 1)[1] # "A" or "ALL"
|
||||||
|
auth_data = await get_user_auth_data(telegram_user_id)
|
||||||
|
jwt_token = auth_data['jwt_token']
|
||||||
|
|
||||||
|
client = get_backend_client()
|
||||||
|
async with client:
|
||||||
|
all_companies = await client.get_user_companies(jwt_token=jwt_token)
|
||||||
|
|
||||||
|
if letter == "ALL":
|
||||||
|
filtered = all_companies
|
||||||
|
else:
|
||||||
|
filtered = [
|
||||||
|
c for c in all_companies
|
||||||
|
if c.get('name', c.get('nume_firma', '')).upper().startswith(letter)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not filtered:
|
||||||
|
await query.answer(f"Nicio firmă cu litera {letter}.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
from backend.modules.telegram.bot.helpers import create_company_selection_keyboard_paginated
|
||||||
|
keyboard = create_company_selection_keyboard_paginated(
|
||||||
|
filtered, page=0,
|
||||||
|
back_callback="select_company_alpha_menu",
|
||||||
|
page_callback_prefix="select_company_alpha_page",
|
||||||
|
page_callback_suffix=f":{letter}"
|
||||||
|
)
|
||||||
|
label = f"Firme cu litera {letter}" if letter != "ALL" else "Toate firmele"
|
||||||
|
await query.edit_message_text(
|
||||||
|
f"**{label}** ({len(filtered)}):",
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode=ParseMode.MARKDOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
elif callback_data.startswith("select_company_alpha_page:"):
|
||||||
|
# Paginate within an alpha-filtered company list
|
||||||
|
# Callback format: select_company_alpha_page:PAGE:LETTER
|
||||||
|
parts = callback_data.split(":")
|
||||||
|
page = int(parts[1])
|
||||||
|
letter = parts[2] # "A"–"Z" or "ALL"
|
||||||
|
|
||||||
|
auth_data = await get_user_auth_data(telegram_user_id)
|
||||||
|
jwt_token = auth_data['jwt_token']
|
||||||
|
|
||||||
|
client = get_backend_client()
|
||||||
|
async with client:
|
||||||
|
all_companies = await client.get_user_companies(jwt_token=jwt_token)
|
||||||
|
|
||||||
|
if letter == "ALL":
|
||||||
|
filtered = all_companies
|
||||||
|
else:
|
||||||
|
filtered = [
|
||||||
|
c for c in all_companies
|
||||||
|
if c.get('name', c.get('nume_firma', '')).upper().startswith(letter)
|
||||||
|
]
|
||||||
|
|
||||||
|
from backend.modules.telegram.bot.helpers import create_company_selection_keyboard_paginated
|
||||||
|
keyboard = create_company_selection_keyboard_paginated(
|
||||||
|
filtered, page=page,
|
||||||
|
back_callback="select_company_alpha_menu",
|
||||||
|
page_callback_prefix="select_company_alpha_page",
|
||||||
|
page_callback_suffix=f":{letter}"
|
||||||
|
)
|
||||||
|
label = f"Firme cu litera {letter}" if letter != "ALL" else "Toate firmele"
|
||||||
|
await query.edit_message_text(
|
||||||
|
f"**{label}** ({len(filtered)}):",
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode=ParseMode.MARKDOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
elif callback_data == "clients_alpha_menu":
|
||||||
|
# Show A-Z letter filter keyboard for clients
|
||||||
|
from backend.modules.telegram.bot.helpers import create_alpha_filter_keyboard_partner
|
||||||
|
await query.edit_message_text(
|
||||||
|
"**Selecteaza litera**\n\nAlege prima litera a clientului:",
|
||||||
|
reply_markup=create_alpha_filter_keyboard_partner("clients"),
|
||||||
|
parse_mode=ParseMode.MARKDOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
elif callback_data.startswith("clients_alpha:"):
|
||||||
|
# Filter clients by starting letter and show page 0
|
||||||
|
letter = callback_data.split(":", 1)[1] # "A" or "ALL"
|
||||||
|
auth_data = await get_user_auth_data(telegram_user_id)
|
||||||
|
jwt_token = auth_data['jwt_token']
|
||||||
|
|
||||||
|
session_manager = get_session_manager()
|
||||||
|
session = await session_manager.get_or_create_session(telegram_user_id)
|
||||||
|
company = session.get_active_company()
|
||||||
|
|
||||||
|
from backend.modules.telegram.bot.helpers import get_clients_with_maturity
|
||||||
|
clients_data = await get_clients_with_maturity(company['id'], jwt_token)
|
||||||
|
all_clients = clients_data['clients']
|
||||||
|
|
||||||
|
if letter == "ALL":
|
||||||
|
filtered = all_clients
|
||||||
|
else:
|
||||||
|
filtered = [
|
||||||
|
c for c in all_clients
|
||||||
|
if c.get('name', '').upper().startswith(letter)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not filtered:
|
||||||
|
await query.answer(f"Niciun client cu litera {letter}.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
from backend.modules.telegram.bot.menus import create_client_list_keyboard, format_response_with_company
|
||||||
|
from backend.modules.telegram.bot.formatters import format_clients_balance_response
|
||||||
|
content = format_clients_balance_response(filtered, clients_data['maturity'])
|
||||||
|
label = f"Clienti cu litera {letter}" if letter != "ALL" else "Toti clientii"
|
||||||
|
response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name'])
|
||||||
|
keyboard = create_client_list_keyboard(filtered, page=0, letter=letter)
|
||||||
|
await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN)
|
||||||
|
|
||||||
|
elif callback_data.startswith("clients_alpha_page:"):
|
||||||
|
# Paginate within an alpha-filtered client list
|
||||||
|
# Callback format: clients_alpha_page:PAGE:LETTER
|
||||||
|
parts = callback_data.split(":")
|
||||||
|
page = int(parts[1])
|
||||||
|
letter = parts[2] # "A"–"Z" or "ALL"
|
||||||
|
|
||||||
|
auth_data = await get_user_auth_data(telegram_user_id)
|
||||||
|
jwt_token = auth_data['jwt_token']
|
||||||
|
|
||||||
|
session_manager = get_session_manager()
|
||||||
|
session = await session_manager.get_or_create_session(telegram_user_id)
|
||||||
|
company = session.get_active_company()
|
||||||
|
|
||||||
|
from backend.modules.telegram.bot.helpers import get_clients_with_maturity
|
||||||
|
clients_data = await get_clients_with_maturity(company['id'], jwt_token)
|
||||||
|
all_clients = clients_data['clients']
|
||||||
|
|
||||||
|
if letter == "ALL":
|
||||||
|
filtered = all_clients
|
||||||
|
else:
|
||||||
|
filtered = [
|
||||||
|
c for c in all_clients
|
||||||
|
if c.get('name', '').upper().startswith(letter)
|
||||||
|
]
|
||||||
|
|
||||||
|
from backend.modules.telegram.bot.menus import create_client_list_keyboard, format_response_with_company
|
||||||
|
from backend.modules.telegram.bot.formatters import format_clients_balance_response
|
||||||
|
content = format_clients_balance_response(filtered, clients_data['maturity'])
|
||||||
|
label = f"Clienti cu litera {letter}" if letter != "ALL" else "Toti clientii"
|
||||||
|
response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name'])
|
||||||
|
keyboard = create_client_list_keyboard(filtered, page=page, letter=letter)
|
||||||
|
await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN)
|
||||||
|
|
||||||
|
elif callback_data == "suppliers_alpha_menu":
|
||||||
|
# Show A-Z letter filter keyboard for suppliers
|
||||||
|
from backend.modules.telegram.bot.helpers import create_alpha_filter_keyboard_partner
|
||||||
|
await query.edit_message_text(
|
||||||
|
"**Selecteaza litera**\n\nAlege prima litera a furnizorului:",
|
||||||
|
reply_markup=create_alpha_filter_keyboard_partner("suppliers"),
|
||||||
|
parse_mode=ParseMode.MARKDOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
elif callback_data.startswith("suppliers_alpha:"):
|
||||||
|
# Filter suppliers by starting letter and show page 0
|
||||||
|
letter = callback_data.split(":", 1)[1] # "A" or "ALL"
|
||||||
|
auth_data = await get_user_auth_data(telegram_user_id)
|
||||||
|
jwt_token = auth_data['jwt_token']
|
||||||
|
|
||||||
|
session_manager = get_session_manager()
|
||||||
|
session = await session_manager.get_or_create_session(telegram_user_id)
|
||||||
|
company = session.get_active_company()
|
||||||
|
|
||||||
|
from backend.modules.telegram.bot.helpers import get_suppliers_with_maturity
|
||||||
|
suppliers_data = await get_suppliers_with_maturity(company['id'], jwt_token)
|
||||||
|
all_suppliers = suppliers_data['suppliers']
|
||||||
|
|
||||||
|
if letter == "ALL":
|
||||||
|
filtered = all_suppliers
|
||||||
|
else:
|
||||||
|
filtered = [
|
||||||
|
s for s in all_suppliers
|
||||||
|
if s.get('name', '').upper().startswith(letter)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not filtered:
|
||||||
|
await query.answer(f"Niciun furnizor cu litera {letter}.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
from backend.modules.telegram.bot.menus import create_supplier_list_keyboard, format_response_with_company
|
||||||
|
from backend.modules.telegram.bot.formatters import format_suppliers_balance_response
|
||||||
|
content = format_suppliers_balance_response(filtered, suppliers_data['maturity'])
|
||||||
|
label = f"Furnizori cu litera {letter}" if letter != "ALL" else "Toti furnizorii"
|
||||||
|
response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name'])
|
||||||
|
keyboard = create_supplier_list_keyboard(filtered, page=0, letter=letter)
|
||||||
|
await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN)
|
||||||
|
|
||||||
|
elif callback_data.startswith("suppliers_alpha_page:"):
|
||||||
|
# Paginate within an alpha-filtered supplier list
|
||||||
|
# Callback format: suppliers_alpha_page:PAGE:LETTER
|
||||||
|
parts = callback_data.split(":")
|
||||||
|
page = int(parts[1])
|
||||||
|
letter = parts[2] # "A"–"Z" or "ALL"
|
||||||
|
|
||||||
|
auth_data = await get_user_auth_data(telegram_user_id)
|
||||||
|
jwt_token = auth_data['jwt_token']
|
||||||
|
|
||||||
|
session_manager = get_session_manager()
|
||||||
|
session = await session_manager.get_or_create_session(telegram_user_id)
|
||||||
|
company = session.get_active_company()
|
||||||
|
|
||||||
|
from backend.modules.telegram.bot.helpers import get_suppliers_with_maturity
|
||||||
|
suppliers_data = await get_suppliers_with_maturity(company['id'], jwt_token)
|
||||||
|
all_suppliers = suppliers_data['suppliers']
|
||||||
|
|
||||||
|
if letter == "ALL":
|
||||||
|
filtered = all_suppliers
|
||||||
|
else:
|
||||||
|
filtered = [
|
||||||
|
s for s in all_suppliers
|
||||||
|
if s.get('name', '').upper().startswith(letter)
|
||||||
|
]
|
||||||
|
|
||||||
|
from backend.modules.telegram.bot.menus import create_supplier_list_keyboard, format_response_with_company
|
||||||
|
from backend.modules.telegram.bot.formatters import format_suppliers_balance_response
|
||||||
|
content = format_suppliers_balance_response(filtered, suppliers_data['maturity'])
|
||||||
|
label = f"Furnizori cu litera {letter}" if letter != "ALL" else "Toti furnizorii"
|
||||||
|
response = format_response_with_company(f"**{label}** ({len(filtered)}):\n\n{content}", company['name'])
|
||||||
|
keyboard = create_supplier_list_keyboard(filtered, page=page, letter=letter)
|
||||||
|
await query.edit_message_text(response, reply_markup=keyboard, parse_mode=ParseMode.MARKDOWN)
|
||||||
|
|
||||||
elif callback_data.startswith("select_company:"):
|
elif callback_data.startswith("select_company:"):
|
||||||
# Handle company selection
|
# Handle company selection
|
||||||
company_id = int(callback_data.split(":")[1])
|
company_id = int(callback_data.split(":")[1])
|
||||||
@@ -2151,6 +2498,44 @@ async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
"Companie negasita sau nu ai acces la ea."
|
"Companie negasita sau nu ai acces la ea."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ========== SWITCH SERVER CALLBACKS ==========
|
||||||
|
|
||||||
|
elif callback_data.startswith("switch_server_confirm:"):
|
||||||
|
new_server_id = callback_data.split(":", 1)[1]
|
||||||
|
telegram_user_id = update.effective_user.id
|
||||||
|
|
||||||
|
auth_data = await get_user_auth_data(telegram_user_id)
|
||||||
|
if not auth_data:
|
||||||
|
await query.edit_message_text("Sesiune expirată. Re-autentifică-te cu /login")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Stochează serverul țintă și cere parola — servere diferite pot avea parole diferite
|
||||||
|
context.user_data['pending_switch_server_id'] = new_server_id
|
||||||
|
context.user_data['pending_switch_jwt_token'] = auth_data['jwt_token']
|
||||||
|
context.user_data['pending_switch_username'] = auth_data['username']
|
||||||
|
|
||||||
|
try:
|
||||||
|
from backend.config import settings
|
||||||
|
srv = settings.get_oracle_server(new_server_id)
|
||||||
|
srv_display = srv.name if srv else new_server_id
|
||||||
|
except Exception:
|
||||||
|
srv_display = new_server_id
|
||||||
|
|
||||||
|
from telegram import ForceReply
|
||||||
|
await query.edit_message_text(
|
||||||
|
f"🔐 **Schimbare server: {srv_display}**\n\n"
|
||||||
|
f"Introdu parola Oracle pentru acest server:\n"
|
||||||
|
f"_(Mesajul cu parola va fi șters automat)_",
|
||||||
|
parse_mode=ParseMode.MARKDOWN
|
||||||
|
)
|
||||||
|
# Trimite un mesaj separat cu ForceReply pentru a forța input-ul
|
||||||
|
await context.bot.send_message(
|
||||||
|
chat_id=update.effective_chat.id,
|
||||||
|
text="Parolă:",
|
||||||
|
reply_markup=ForceReply(selective=True, input_field_placeholder="Parola Oracle...")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# ========== LOGOUT CALLBACKS ==========
|
# ========== LOGOUT CALLBACKS ==========
|
||||||
|
|
||||||
elif callback_data == "logout_confirm":
|
elif callback_data == "logout_confirm":
|
||||||
@@ -2741,6 +3126,40 @@ async def _handle_selectcompany_view(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Auto-selectează dacă există exact o singură firmă
|
||||||
|
if len(companies) == 1:
|
||||||
|
selected = companies[0]
|
||||||
|
company_id = selected.get('id_firma', selected.get('id'))
|
||||||
|
company_name = selected.get('name', selected.get('nume_firma', 'N/A'))
|
||||||
|
company_cui = selected.get('fiscal_code', selected.get('cui'))
|
||||||
|
|
||||||
|
session_manager = get_session_manager()
|
||||||
|
session = await session_manager.get_or_create_session(telegram_user_id)
|
||||||
|
session.set_active_company(
|
||||||
|
company_id=company_id,
|
||||||
|
company_name=company_name,
|
||||||
|
company_cui=company_cui
|
||||||
|
)
|
||||||
|
await session_manager.save_session(telegram_user_id)
|
||||||
|
|
||||||
|
from backend.modules.telegram.bot.menus import create_main_menu, get_menu_message
|
||||||
|
keyboard = create_main_menu(company_name=company_name, company_cui=company_cui)
|
||||||
|
menu_text = f"✅ Firmă selectată automat: **{company_name}**\n\n" + get_menu_message(company_name, company_cui)
|
||||||
|
|
||||||
|
if is_callback:
|
||||||
|
await query_or_update.edit_message_text(
|
||||||
|
menu_text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode=ParseMode.MARKDOWN
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await query_or_update.message.reply_text(
|
||||||
|
menu_text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode=ParseMode.MARKDOWN
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
from backend.modules.telegram.bot.helpers import create_company_selection_keyboard_paginated
|
from backend.modules.telegram.bot.helpers import create_company_selection_keyboard_paginated
|
||||||
keyboard = create_company_selection_keyboard_paginated(companies, page=page)
|
keyboard = create_company_selection_keyboard_paginated(companies, page=page)
|
||||||
|
|
||||||
|
|||||||
@@ -169,7 +169,10 @@ def create_company_selection_keyboard(
|
|||||||
def create_company_selection_keyboard_paginated(
|
def create_company_selection_keyboard_paginated(
|
||||||
companies: List[Dict[str, Any]],
|
companies: List[Dict[str, Any]],
|
||||||
page: int = 0,
|
page: int = 0,
|
||||||
per_page: int = 10
|
per_page: int = 20,
|
||||||
|
back_callback: str = "action:menu",
|
||||||
|
page_callback_prefix: str = "select_company_page",
|
||||||
|
page_callback_suffix: str = ""
|
||||||
) -> InlineKeyboardMarkup:
|
) -> InlineKeyboardMarkup:
|
||||||
"""
|
"""
|
||||||
Create paginated inline keyboard for company selection.
|
Create paginated inline keyboard for company selection.
|
||||||
@@ -180,7 +183,10 @@ def create_company_selection_keyboard_paginated(
|
|||||||
Args:
|
Args:
|
||||||
companies: Full list of company dicts (with id, nume_firma, cui)
|
companies: Full list of company dicts (with id, nume_firma, cui)
|
||||||
page: Current page number (0-indexed)
|
page: Current page number (0-indexed)
|
||||||
per_page: Number of companies per page (default: 10)
|
per_page: Number of companies per page (default: 20)
|
||||||
|
back_callback: Callback data for the back button (default: "action:menu")
|
||||||
|
page_callback_prefix: Prefix for pagination callbacks (default: "select_company_page")
|
||||||
|
page_callback_suffix: Suffix appended after page number in pagination callbacks
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
InlineKeyboardMarkup with company buttons and pagination controls
|
InlineKeyboardMarkup with company buttons and pagination controls
|
||||||
@@ -221,8 +227,9 @@ def create_company_selection_keyboard_paginated(
|
|||||||
|
|
||||||
# Previous button
|
# Previous button
|
||||||
if page > 0:
|
if page > 0:
|
||||||
|
prev_cb = f"{page_callback_prefix}:{page-1}{page_callback_suffix}"
|
||||||
nav_buttons.append(
|
nav_buttons.append(
|
||||||
InlineKeyboardButton("< Anterior", callback_data=f"select_company_page:{page-1}")
|
InlineKeyboardButton("< Anterior", callback_data=prev_cb)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Page indicator (non-clickable)
|
# Page indicator (non-clickable)
|
||||||
@@ -232,20 +239,76 @@ def create_company_selection_keyboard_paginated(
|
|||||||
|
|
||||||
# Next button
|
# Next button
|
||||||
if page < total_pages - 1:
|
if page < total_pages - 1:
|
||||||
|
next_cb = f"{page_callback_prefix}:{page+1}{page_callback_suffix}"
|
||||||
nav_buttons.append(
|
nav_buttons.append(
|
||||||
InlineKeyboardButton("Urmator >", callback_data=f"select_company_page:{page+1}")
|
InlineKeyboardButton("Urmator >", callback_data=next_cb)
|
||||||
)
|
)
|
||||||
|
|
||||||
keyboard.append(nav_buttons)
|
keyboard.append(nav_buttons)
|
||||||
|
|
||||||
# Back to menu button
|
# A-Z filter + back button
|
||||||
keyboard.append([
|
keyboard.append([
|
||||||
InlineKeyboardButton("< Inapoi la Meniu", callback_data="action:menu")
|
InlineKeyboardButton("Filtrare A-Z", callback_data="select_company_alpha_menu")
|
||||||
|
])
|
||||||
|
keyboard.append([
|
||||||
|
InlineKeyboardButton("« Înapoi", callback_data=back_callback)
|
||||||
])
|
])
|
||||||
|
|
||||||
return InlineKeyboardMarkup(keyboard)
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
|
||||||
|
def create_alpha_filter_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Create inline keyboard with A–Z letter buttons for filtering companies.
|
||||||
|
|
||||||
|
Displays 26 letter buttons in rows of 6, plus a 'Toată lista' button
|
||||||
|
that shows all companies without filtering.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InlineKeyboardMarkup with letter buttons and navigation
|
||||||
|
"""
|
||||||
|
letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||||
|
keyboard = []
|
||||||
|
row_size = 6
|
||||||
|
for i in range(0, len(letters), row_size):
|
||||||
|
row = [
|
||||||
|
InlineKeyboardButton(l, callback_data=f"select_company_alpha:{l}")
|
||||||
|
for l in letters[i:i + row_size]
|
||||||
|
]
|
||||||
|
keyboard.append(row)
|
||||||
|
keyboard.append([
|
||||||
|
InlineKeyboardButton("Toată lista", callback_data="select_company_alpha:ALL"),
|
||||||
|
InlineKeyboardButton("« Meniu", callback_data="action:menu")
|
||||||
|
])
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
|
||||||
|
def create_alpha_filter_keyboard_partner(partner_type: str) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Create inline keyboard with A–Z letter buttons for filtering clients or suppliers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
partner_type: "clients" or "suppliers"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InlineKeyboardMarkup with letter buttons and navigation
|
||||||
|
"""
|
||||||
|
letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||||
|
keyboard = []
|
||||||
|
row_size = 6
|
||||||
|
for i in range(0, len(letters), row_size):
|
||||||
|
row = [
|
||||||
|
InlineKeyboardButton(l, callback_data=f"{partner_type}_alpha:{l}")
|
||||||
|
for l in letters[i:i + row_size]
|
||||||
|
]
|
||||||
|
keyboard.append(row)
|
||||||
|
keyboard.append([
|
||||||
|
InlineKeyboardButton("Toata lista", callback_data=f"{partner_type}_alpha:ALL"),
|
||||||
|
InlineKeyboardButton("« Meniu", callback_data="action:menu")
|
||||||
|
])
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
|
||||||
def format_company_context_footer(company_name: str) -> str:
|
def format_company_context_footer(company_name: str) -> str:
|
||||||
"""
|
"""
|
||||||
Format discrete footer with company context.
|
Format discrete footer with company context.
|
||||||
|
|||||||
@@ -260,7 +260,13 @@ def create_main_menu(
|
|||||||
InlineKeyboardButton("Clear Cache", callback_data="menu:clearcache")
|
InlineKeyboardButton("Clear Cache", callback_data="menu:clearcache")
|
||||||
])
|
])
|
||||||
|
|
||||||
# Row 6: Help/Logout buttons (authenticated) or Login button (non-authenticated)
|
# Row 6: Switch Server button (authenticated only)
|
||||||
|
if is_authenticated:
|
||||||
|
keyboard.append([
|
||||||
|
InlineKeyboardButton("Schimba Server", callback_data="menu:switch_server"),
|
||||||
|
])
|
||||||
|
|
||||||
|
# Row 7: Help/Logout buttons (authenticated) or Login button (non-authenticated)
|
||||||
if is_authenticated:
|
if is_authenticated:
|
||||||
keyboard.append([
|
keyboard.append([
|
||||||
InlineKeyboardButton("Help", callback_data="action:help"),
|
InlineKeyboardButton("Help", callback_data="action:help"),
|
||||||
@@ -334,7 +340,7 @@ def create_action_buttons(current_view: str, show_export: bool = True, show_back
|
|||||||
return InlineKeyboardMarkup(keyboard)
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
|
||||||
def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page: int = 0) -> InlineKeyboardMarkup:
|
def create_client_list_keyboard(clients: List[Dict], max_items: int = 20, page: int = 0, letter: str = None) -> InlineKeyboardMarkup:
|
||||||
"""
|
"""
|
||||||
Create client list keyboard (Level 2) with client buttons and pagination.
|
Create client list keyboard (Level 2) with client buttons and pagination.
|
||||||
|
|
||||||
@@ -344,6 +350,7 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page:
|
|||||||
clients: List of client dicts with keys: id, name, balance
|
clients: List of client dicts with keys: id, name, balance
|
||||||
max_items: Maximum number of clients per page (default: 10)
|
max_items: Maximum number of clients per page (default: 10)
|
||||||
page: Current page number (0-indexed)
|
page: Current page number (0-indexed)
|
||||||
|
letter: Optional letter filter (e.g. "A", "ALL") - when set, uses alpha pagination
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
InlineKeyboardMarkup with client list buttons and pagination
|
InlineKeyboardMarkup with client list buttons and pagination
|
||||||
@@ -387,10 +394,18 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page:
|
|||||||
if total_pages > 1:
|
if total_pages > 1:
|
||||||
nav_buttons = []
|
nav_buttons = []
|
||||||
|
|
||||||
|
# Choose pagination callback based on whether letter filter is active
|
||||||
|
if letter:
|
||||||
|
prev_cb = f"clients_alpha_page:{page-1}:{letter}"
|
||||||
|
next_cb = f"clients_alpha_page:{page+1}:{letter}"
|
||||||
|
else:
|
||||||
|
prev_cb = f"clients_page:{page-1}"
|
||||||
|
next_cb = f"clients_page:{page+1}"
|
||||||
|
|
||||||
# Previous button
|
# Previous button
|
||||||
if page > 0:
|
if page > 0:
|
||||||
nav_buttons.append(
|
nav_buttons.append(
|
||||||
InlineKeyboardButton("< Anterior", callback_data=f"clients_page:{page-1}")
|
InlineKeyboardButton("< Anterior", callback_data=prev_cb)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Page indicator (non-clickable)
|
# Page indicator (non-clickable)
|
||||||
@@ -401,20 +416,26 @@ def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page:
|
|||||||
# Next button
|
# Next button
|
||||||
if page < total_pages - 1:
|
if page < total_pages - 1:
|
||||||
nav_buttons.append(
|
nav_buttons.append(
|
||||||
InlineKeyboardButton("Următor >", callback_data=f"clients_page:{page+1}")
|
InlineKeyboardButton("Urmator >", callback_data=next_cb)
|
||||||
)
|
)
|
||||||
|
|
||||||
keyboard.append(nav_buttons)
|
keyboard.append(nav_buttons)
|
||||||
|
|
||||||
# Navigation row: Back button only
|
# Filtrare A-Z button
|
||||||
keyboard.append([
|
keyboard.append([
|
||||||
InlineKeyboardButton("< Înapoi", callback_data="action:menu")
|
InlineKeyboardButton("Filtrare A-Z", callback_data="clients_alpha_menu")
|
||||||
|
])
|
||||||
|
|
||||||
|
# Back button: to A-Z menu if filtering, otherwise to main menu
|
||||||
|
back_callback = "clients_alpha_menu" if letter else "action:menu"
|
||||||
|
keyboard.append([
|
||||||
|
InlineKeyboardButton("< Inapoi", callback_data=back_callback)
|
||||||
])
|
])
|
||||||
|
|
||||||
return InlineKeyboardMarkup(keyboard)
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
|
||||||
def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, page: int = 0) -> InlineKeyboardMarkup:
|
def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 20, page: int = 0, letter: str = None) -> InlineKeyboardMarkup:
|
||||||
"""
|
"""
|
||||||
Create supplier list keyboard (Level 2) with supplier buttons and pagination.
|
Create supplier list keyboard (Level 2) with supplier buttons and pagination.
|
||||||
|
|
||||||
@@ -424,6 +445,7 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa
|
|||||||
suppliers: List of supplier dicts with keys: id, name, balance
|
suppliers: List of supplier dicts with keys: id, name, balance
|
||||||
max_items: Maximum number of suppliers per page (default: 10)
|
max_items: Maximum number of suppliers per page (default: 10)
|
||||||
page: Current page number (0-indexed)
|
page: Current page number (0-indexed)
|
||||||
|
letter: Optional letter filter (e.g. "A", "ALL") - when set, uses alpha pagination
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
InlineKeyboardMarkup with supplier list buttons and pagination
|
InlineKeyboardMarkup with supplier list buttons and pagination
|
||||||
@@ -467,10 +489,18 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa
|
|||||||
if total_pages > 1:
|
if total_pages > 1:
|
||||||
nav_buttons = []
|
nav_buttons = []
|
||||||
|
|
||||||
|
# Choose pagination callback based on whether letter filter is active
|
||||||
|
if letter:
|
||||||
|
prev_cb = f"suppliers_alpha_page:{page-1}:{letter}"
|
||||||
|
next_cb = f"suppliers_alpha_page:{page+1}:{letter}"
|
||||||
|
else:
|
||||||
|
prev_cb = f"suppliers_page:{page-1}"
|
||||||
|
next_cb = f"suppliers_page:{page+1}"
|
||||||
|
|
||||||
# Previous button
|
# Previous button
|
||||||
if page > 0:
|
if page > 0:
|
||||||
nav_buttons.append(
|
nav_buttons.append(
|
||||||
InlineKeyboardButton("< Anterior", callback_data=f"suppliers_page:{page-1}")
|
InlineKeyboardButton("< Anterior", callback_data=prev_cb)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Page indicator (non-clickable)
|
# Page indicator (non-clickable)
|
||||||
@@ -481,14 +511,20 @@ def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, pa
|
|||||||
# Next button
|
# Next button
|
||||||
if page < total_pages - 1:
|
if page < total_pages - 1:
|
||||||
nav_buttons.append(
|
nav_buttons.append(
|
||||||
InlineKeyboardButton("Următor >", callback_data=f"suppliers_page:{page+1}")
|
InlineKeyboardButton("Urmator >", callback_data=next_cb)
|
||||||
)
|
)
|
||||||
|
|
||||||
keyboard.append(nav_buttons)
|
keyboard.append(nav_buttons)
|
||||||
|
|
||||||
# Navigation row: Back button only
|
# Filtrare A-Z button
|
||||||
keyboard.append([
|
keyboard.append([
|
||||||
InlineKeyboardButton("< Înapoi", callback_data="action:menu")
|
InlineKeyboardButton("Filtrare A-Z", callback_data="suppliers_alpha_menu")
|
||||||
|
])
|
||||||
|
|
||||||
|
# Back button: to A-Z menu if filtering, otherwise to main menu
|
||||||
|
back_callback = "suppliers_alpha_menu" if letter else "action:menu"
|
||||||
|
keyboard.append([
|
||||||
|
InlineKeyboardButton("< Inapoi", callback_data=back_callback)
|
||||||
])
|
])
|
||||||
|
|
||||||
return InlineKeyboardMarkup(keyboard)
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|||||||
@@ -148,6 +148,14 @@ async def init_database() -> None:
|
|||||||
ON email_auth_codes(expires_at)
|
ON email_auth_codes(expires_at)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Migration: add server_id column to telegram_auth_codes if missing
|
||||||
|
try:
|
||||||
|
await db.execute("ALTER TABLE telegram_auth_codes ADD COLUMN server_id TEXT")
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Migration: added server_id column to telegram_auth_codes")
|
||||||
|
except Exception:
|
||||||
|
pass # Column already exists
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Database initialized successfully")
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
|
|||||||
@@ -288,7 +288,8 @@ async def create_auth_code(
|
|||||||
code: str,
|
code: str,
|
||||||
telegram_user_id: int,
|
telegram_user_id: int,
|
||||||
oracle_username: str,
|
oracle_username: str,
|
||||||
expires_in_minutes: int = 5
|
expires_in_minutes: int = 5,
|
||||||
|
server_id: Optional[str] = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Create a new authentication code for linking.
|
Create a new authentication code for linking.
|
||||||
@@ -298,6 +299,7 @@ async def create_auth_code(
|
|||||||
telegram_user_id: Telegram user ID
|
telegram_user_id: Telegram user ID
|
||||||
oracle_username: Oracle username to link
|
oracle_username: Oracle username to link
|
||||||
expires_in_minutes: Code expiration time in minutes (default: 5)
|
expires_in_minutes: Code expiration time in minutes (default: 5)
|
||||||
|
server_id: Oracle server ID (for multi-server mode)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if successful
|
bool: True if successful
|
||||||
@@ -310,13 +312,13 @@ async def create_auth_code(
|
|||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
INSERT INTO telegram_auth_codes (
|
INSERT INTO telegram_auth_codes (
|
||||||
code, telegram_user_id, oracle_username, expires_at
|
code, telegram_user_id, oracle_username, expires_at, server_id
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
""", (code, telegram_user_id, oracle_username, expires_at))
|
""", (code, telegram_user_id, oracle_username, expires_at, server_id))
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info(f"Auth code created for user {telegram_user_id}")
|
logger.info(f"Auth code created for user {telegram_user_id} (server_id={server_id})")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
API Router pentru Telegram Bot Integration
|
API Router pentru Telegram Bot Integration
|
||||||
Furnizează endpoint-uri pentru autentificare, linking și export rapoarte pentru Telegram bot
|
Furnizează endpoint-uri pentru autentificare, linking și export rapoarte pentru Telegram bot
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
# import sys # Removed - no longer needed
|
# import sys # Removed - no longer needed
|
||||||
import os
|
import os
|
||||||
@@ -57,6 +57,7 @@ class VerifyUserRequest(BaseModel):
|
|||||||
oracle_username: Optional[str] = Field(default=None, description="Username Oracle (pentru auto-linking)")
|
oracle_username: Optional[str] = Field(default=None, description="Username Oracle (pentru auto-linking)")
|
||||||
username: Optional[str] = Field(default=None, description="Username pentru verificare completă")
|
username: Optional[str] = Field(default=None, description="Username pentru verificare completă")
|
||||||
password: Optional[str] = Field(default=None, description="Parolă pentru verificare completă")
|
password: Optional[str] = Field(default=None, description="Parolă pentru verificare completă")
|
||||||
|
server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)")
|
||||||
|
|
||||||
|
|
||||||
class VerifyUserResponse(BaseModel):
|
class VerifyUserResponse(BaseModel):
|
||||||
@@ -100,6 +101,7 @@ class ExportReportResponse(BaseModel):
|
|||||||
class VerifyEmailRequest(BaseModel):
|
class VerifyEmailRequest(BaseModel):
|
||||||
"""Request pentru verificarea email-ului în Oracle"""
|
"""Request pentru verificarea email-ului în Oracle"""
|
||||||
email: str = Field(description="Adresa de email Oracle")
|
email: str = Field(description="Adresa de email Oracle")
|
||||||
|
server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)")
|
||||||
|
|
||||||
|
|
||||||
class VerifyEmailResponse(BaseModel):
|
class VerifyEmailResponse(BaseModel):
|
||||||
@@ -115,6 +117,7 @@ class TelegramEmailLoginRequest(BaseModel):
|
|||||||
password: str = Field(description="Parola Oracle")
|
password: str = Field(description="Parola Oracle")
|
||||||
telegram_user_id: int = Field(description="ID-ul utilizatorului Telegram")
|
telegram_user_id: int = Field(description="ID-ul utilizatorului Telegram")
|
||||||
session_token: str = Field(description="Token de sesiune pentru preveni spoofing")
|
session_token: str = Field(description="Token de sesiune pentru preveni spoofing")
|
||||||
|
server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)")
|
||||||
|
|
||||||
|
|
||||||
class TelegramEmailLoginResponse(BaseModel):
|
class TelegramEmailLoginResponse(BaseModel):
|
||||||
@@ -129,6 +132,21 @@ class TelegramEmailLoginResponse(BaseModel):
|
|||||||
message: str = Field(description="Mesaj de status")
|
message: str = Field(description="Mesaj de status")
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchServerRequest(BaseModel):
|
||||||
|
"""Request pentru schimbarea serverului Oracle"""
|
||||||
|
oracle_username: str = Field(description="Username Oracle al utilizatorului curent")
|
||||||
|
new_server_id: str = Field(description="ID-ul noului server Oracle")
|
||||||
|
oracle_password: Optional[str] = Field(default=None, description="Parola Oracle pe noul server (obligatorie dacă servere diferite au parole diferite)")
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchServerResponse(BaseModel):
|
||||||
|
"""Response pentru schimbarea serverului Oracle"""
|
||||||
|
success: bool = Field(description="True dacă schimbarea a reușit")
|
||||||
|
access_token: Optional[str] = Field(default=None, description="Noul JWT access token")
|
||||||
|
refresh_token: Optional[str] = Field(default=None, description="Noul JWT refresh token")
|
||||||
|
message: str = Field(description="Mesaj de status")
|
||||||
|
|
||||||
|
|
||||||
# ==================== Helper Functions ====================
|
# ==================== Helper Functions ====================
|
||||||
|
|
||||||
# Rate limiting storage (in-memory)
|
# Rate limiting storage (in-memory)
|
||||||
@@ -212,7 +230,7 @@ def generate_linking_code(length: int = 8) -> str:
|
|||||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]:
|
async def get_oracle_user_by_username(username: str, server_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Obține informații despre utilizator din Oracle FĂRĂ verificare parolă.
|
Obține informații despre utilizator din Oracle FĂRĂ verificare parolă.
|
||||||
|
|
||||||
@@ -221,12 +239,13 @@ async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
username: Username-ul utilizatorului Oracle
|
username: Username-ul utilizatorului Oracle
|
||||||
|
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict cu informații despre utilizator sau None dacă nu există
|
Dict cu informații despre utilizator sau None dacă nu există
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with oracle_pool.get_connection() as connection:
|
async with oracle_pool.get_connection(server_id) as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
# Obține detalii utilizator
|
# Obține detalii utilizator
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
@@ -270,19 +289,20 @@ async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def verify_oracle_user(username: str, password: str) -> Optional[Dict[str, Any]]:
|
async def verify_oracle_user(username: str, password: str, server_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Verifică utilizatorul în Oracle folosind pack_drepturi.verificautilizator
|
Verifică utilizatorul în Oracle folosind pack_drepturi.verificautilizator
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
username: Username-ul utilizatorului
|
username: Username-ul utilizatorului
|
||||||
password: Parola utilizatorului
|
password: Parola utilizatorului
|
||||||
|
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict cu informații despre utilizator sau None dacă verificarea eșuează
|
Dict cu informații despre utilizator sau None dacă verificarea eșuează
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with oracle_pool.get_connection() as connection:
|
async with oracle_pool.get_connection(server_id) as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
# Verifică autentificarea
|
# Verifică autentificarea
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
@@ -344,6 +364,7 @@ async def verify_oracle_user(username: str, password: str) -> Optional[Dict[str,
|
|||||||
|
|
||||||
@router.post("/auth/generate-code", response_model=GenerateCodeResponse)
|
@router.post("/auth/generate-code", response_model=GenerateCodeResponse)
|
||||||
async def generate_linking_code_endpoint(
|
async def generate_linking_code_endpoint(
|
||||||
|
request: Request,
|
||||||
current_user: CurrentUser = Depends(get_current_user)
|
current_user: CurrentUser = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -360,8 +381,12 @@ async def generate_linking_code_endpoint(
|
|||||||
- Codul expiră după 15 minute
|
- Codul expiră după 15 minute
|
||||||
- Fiecare request generează un cod nou (codurile vechi devin invalide)
|
- 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)
|
- Nu este nevoie de telegram_user_id în acest moment (utilizatorul nu e încă conectat la Telegram)
|
||||||
|
- server_id este extras automat din JWT (setat de auth middleware în request.state)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Extrage server_id din JWT (setat de auth middleware)
|
||||||
|
server_id = getattr(request.state, 'server_id', None)
|
||||||
|
|
||||||
# Generează cod unic
|
# Generează cod unic
|
||||||
linking_code = generate_linking_code()
|
linking_code = generate_linking_code()
|
||||||
|
|
||||||
@@ -378,7 +403,8 @@ async def generate_linking_code_endpoint(
|
|||||||
"code": linking_code,
|
"code": linking_code,
|
||||||
"telegram_user_id": 0, # Not known yet (user hasn't linked)
|
"telegram_user_id": 0, # Not known yet (user hasn't linked)
|
||||||
"oracle_username": current_user.username,
|
"oracle_username": current_user.username,
|
||||||
"expires_in_minutes": expires_in_minutes
|
"expires_in_minutes": expires_in_minutes,
|
||||||
|
"server_id": server_id
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -442,7 +468,7 @@ async def verify_user_endpoint(request: VerifyUserRequest):
|
|||||||
try:
|
try:
|
||||||
# Flow A: Auto-linking (oracle_username provided, no password)
|
# Flow A: Auto-linking (oracle_username provided, no password)
|
||||||
if request.oracle_username and not request.password:
|
if request.oracle_username and not request.password:
|
||||||
user_data = await get_oracle_user_by_username(request.oracle_username)
|
user_data = await get_oracle_user_by_username(request.oracle_username, server_id=request.server_id)
|
||||||
|
|
||||||
if not user_data:
|
if not user_data:
|
||||||
return VerifyUserResponse(
|
return VerifyUserResponse(
|
||||||
@@ -452,7 +478,7 @@ async def verify_user_endpoint(request: VerifyUserRequest):
|
|||||||
|
|
||||||
# Flow B: Full verification (username + password provided)
|
# Flow B: Full verification (username + password provided)
|
||||||
elif request.username and request.password:
|
elif request.username and request.password:
|
||||||
user_data = await verify_oracle_user(request.username, request.password)
|
user_data = await verify_oracle_user(request.username, request.password, server_id=request.server_id)
|
||||||
|
|
||||||
if not user_data:
|
if not user_data:
|
||||||
return VerifyUserResponse(
|
return VerifyUserResponse(
|
||||||
@@ -467,17 +493,19 @@ async def verify_user_endpoint(request: VerifyUserRequest):
|
|||||||
message="Trebuie furnizat fie oracle_username (auto-linking) fie username+password (verificare completă)"
|
message="Trebuie furnizat fie oracle_username (auto-linking) fie username+password (verificare completă)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generează JWT tokens
|
# Generează JWT tokens (cu server_id pentru multi-server mode)
|
||||||
access_token = jwt_handler.create_access_token(
|
access_token = jwt_handler.create_access_token(
|
||||||
username=user_data['username'],
|
username=user_data['username'],
|
||||||
companies=user_data['companies'],
|
companies=user_data['companies'],
|
||||||
user_id=user_data['user_id'],
|
user_id=user_data['user_id'],
|
||||||
permissions=user_data['permissions']
|
permissions=user_data['permissions'],
|
||||||
|
server_id=request.server_id
|
||||||
)
|
)
|
||||||
|
|
||||||
refresh_token = jwt_handler.create_refresh_token(
|
refresh_token = jwt_handler.create_refresh_token(
|
||||||
username=user_data['username'],
|
username=user_data['username'],
|
||||||
user_id=user_data['user_id']
|
user_id=user_data['user_id'],
|
||||||
|
server_id=request.server_id
|
||||||
)
|
)
|
||||||
|
|
||||||
return VerifyUserResponse(
|
return VerifyUserResponse(
|
||||||
@@ -528,8 +556,8 @@ async def refresh_token_endpoint(request: RefreshTokenRequest):
|
|||||||
detail="Refresh token invalid sau expirat"
|
detail="Refresh token invalid sau expirat"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Obține companiile actualizate din Oracle
|
# Obține companiile actualizate din Oracle (folosind server_id din refresh token)
|
||||||
async with oracle_pool.get_connection() as connection:
|
async with oracle_pool.get_connection(token_data.server_id) as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT A.ID_FIRMA
|
SELECT A.ID_FIRMA
|
||||||
@@ -546,12 +574,13 @@ async def refresh_token_endpoint(request: RefreshTokenRequest):
|
|||||||
companies_result = cursor.fetchall()
|
companies_result = cursor.fetchall()
|
||||||
companies = [str(row[0]) for row in companies_result]
|
companies = [str(row[0]) for row in companies_result]
|
||||||
|
|
||||||
# Generează nou access token
|
# Generează nou access token (păstrează server_id din refresh token)
|
||||||
new_access_token = jwt_handler.create_access_token(
|
new_access_token = jwt_handler.create_access_token(
|
||||||
username=token_data.username,
|
username=token_data.username,
|
||||||
companies=companies,
|
companies=companies,
|
||||||
user_id=token_data.user_id,
|
user_id=token_data.user_id,
|
||||||
permissions=token_data.permissions
|
permissions=token_data.permissions,
|
||||||
|
server_id=token_data.server_id
|
||||||
)
|
)
|
||||||
|
|
||||||
return RefreshTokenResponse(
|
return RefreshTokenResponse(
|
||||||
@@ -580,7 +609,7 @@ async def verify_email_endpoint(request: VerifyEmailRequest):
|
|||||||
Security: Generic error messages to prevent email enumeration.
|
Security: Generic error messages to prevent email enumeration.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with oracle_pool.get_connection() as connection:
|
async with oracle_pool.get_connection(request.server_id) as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
# Query to find username by email
|
# Query to find username by email
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
@@ -649,7 +678,7 @@ async def login_with_email_endpoint(request: TelegramEmailLoginRequest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with oracle_pool.get_connection() as connection:
|
async with oracle_pool.get_connection(request.server_id) as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
# 3. Find username by email
|
# 3. Find username by email
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
@@ -719,18 +748,18 @@ async def login_with_email_endpoint(request: TelegramEmailLoginRequest):
|
|||||||
# 6. Get user permissions (default for Telegram)
|
# 6. Get user permissions (default for Telegram)
|
||||||
permissions = ['read', 'reports']
|
permissions = ['read', 'reports']
|
||||||
|
|
||||||
# 7. Generate JWT tokens
|
# 7. Generate JWT tokens (with server_id for multi-server routing)
|
||||||
token_data = {
|
access_token = jwt_handler.create_access_token(
|
||||||
"username": username,
|
username=username,
|
||||||
"user_id": user_id,
|
user_id=user_id,
|
||||||
"companies": company_ids,
|
companies=company_ids,
|
||||||
"permissions": permissions
|
permissions=permissions,
|
||||||
}
|
server_id=request.server_id
|
||||||
|
)
|
||||||
access_token = jwt_handler.create_access_token(**token_data)
|
|
||||||
refresh_token = jwt_handler.create_refresh_token(
|
refresh_token = jwt_handler.create_refresh_token(
|
||||||
username=username,
|
username=username,
|
||||||
user_id=user_id
|
user_id=user_id,
|
||||||
|
server_id=request.server_id
|
||||||
)
|
)
|
||||||
|
|
||||||
return TelegramEmailLoginResponse(
|
return TelegramEmailLoginResponse(
|
||||||
@@ -754,6 +783,82 @@ async def login_with_email_endpoint(request: TelegramEmailLoginRequest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/switch-server", response_model=SwitchServerResponse)
|
||||||
|
async def switch_server_endpoint(
|
||||||
|
request: SwitchServerRequest,
|
||||||
|
current_user: CurrentUser = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Schimbă serverul Oracle activ fără re-autentificare.
|
||||||
|
|
||||||
|
Flux:
|
||||||
|
1. Verifică că oracle_username din request == username din JWT (anti-impersonare)
|
||||||
|
2. Verifică că utilizatorul există pe noul server (fără parolă, prin get_oracle_user_by_username)
|
||||||
|
3. Generează JWT nou cu noul server_id
|
||||||
|
4. Returnează tokenurile noi
|
||||||
|
|
||||||
|
Securitate: endpoint protejat cu Bearer JWT valid (Depends(get_current_user)).
|
||||||
|
"""
|
||||||
|
# Anti-impersonare: utilizatorul poate schimba serverul doar pentru propriul cont
|
||||||
|
if request.oracle_username.upper() != current_user.username.upper():
|
||||||
|
raise HTTPException(status_code=403, detail="Acces interzis: username nepotrivit")
|
||||||
|
|
||||||
|
# Verifică că utilizatorul există pe noul server
|
||||||
|
user_data = await get_oracle_user_by_username(request.oracle_username, request.new_server_id)
|
||||||
|
if not user_data:
|
||||||
|
return SwitchServerResponse(
|
||||||
|
success=False,
|
||||||
|
message=f"Utilizatorul nu există pe serverul {request.new_server_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dacă parola e furnizată, verifică-o pe noul server înainte de a emite JWT
|
||||||
|
if request.oracle_password:
|
||||||
|
try:
|
||||||
|
async with oracle_pool.get_connection(request.new_server_id) as connection:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT pack_drepturi.verificautilizator(:username, :password)
|
||||||
|
FROM DUAL
|
||||||
|
""", {
|
||||||
|
"username": request.oracle_username.upper(),
|
||||||
|
"password": request.oracle_password
|
||||||
|
})
|
||||||
|
verification_result = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
if verification_result == -1:
|
||||||
|
return SwitchServerResponse(
|
||||||
|
success=False,
|
||||||
|
message="Parolă incorectă pentru acest server"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Password verification error during server switch: {e}")
|
||||||
|
return SwitchServerResponse(
|
||||||
|
success=False,
|
||||||
|
message="Eroare la verificarea parolei pe noul server"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generează JWT nou cu noul server_id
|
||||||
|
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'],
|
||||||
|
server_id=request.new_server_id
|
||||||
|
)
|
||||||
|
refresh_token = jwt_handler.create_refresh_token(
|
||||||
|
username=user_data['username'],
|
||||||
|
user_id=user_data['user_id'],
|
||||||
|
server_id=request.new_server_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return SwitchServerResponse(
|
||||||
|
success=True,
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
message="Server schimbat cu succes"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/export", response_model=ExportReportResponse)
|
@router.post("/export", response_model=ExportReportResponse)
|
||||||
async def export_report_endpoint(
|
async def export_report_endpoint(
|
||||||
request: ExportReportRequest,
|
request: ExportReportRequest,
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ class SaveAuthCodeRequest(BaseModel):
|
|||||||
ge=1,
|
ge=1,
|
||||||
le=60
|
le=60
|
||||||
)
|
)
|
||||||
|
server_id: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Oracle server ID (for multi-server mode)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SaveAuthCodeResponse(BaseModel):
|
class SaveAuthCodeResponse(BaseModel):
|
||||||
@@ -151,7 +155,8 @@ async def save_auth_code(request: SaveAuthCodeRequest):
|
|||||||
code=request.code,
|
code=request.code,
|
||||||
telegram_user_id=request.telegram_user_id,
|
telegram_user_id=request.telegram_user_id,
|
||||||
oracle_username=request.oracle_username,
|
oracle_username=request.oracle_username,
|
||||||
expires_in_minutes=request.expires_in_minutes
|
expires_in_minutes=request.expires_in_minutes,
|
||||||
|
server_id=request.server_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
Async SMTP Email Service with retry logic and proper error handling
|
Async SMTP Email Service with retry logic and proper error handling
|
||||||
"""
|
"""
|
||||||
import aiosmtplib
|
import aiosmtplib
|
||||||
from email.mime.text import MIMEText
|
from email.message import EmailMessage
|
||||||
from email.mime.multipart import MIMEMultipart
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -52,32 +51,33 @@ class EmailService:
|
|||||||
Raises:
|
Raises:
|
||||||
No exceptions - returns False on all failures
|
No exceptions - returns False on all failures
|
||||||
"""
|
"""
|
||||||
subject = "Codul tău de autentificare ROA2WEB"
|
subject = "Autentificare ROA2WEB"
|
||||||
html_body = self._create_email_template(code, username)
|
text_body = self._create_email_template(code, username)
|
||||||
|
|
||||||
for attempt in range(1, self.max_retries + 1):
|
for attempt in range(1, self.max_retries + 1):
|
||||||
try:
|
try:
|
||||||
await self._send_email(to_email, subject, html_body)
|
await self._send_email(to_email, subject, text_body)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Email sent successfully to {to_email} "
|
f"[EMAIL] ✅ Sent auth code to {to_email} "
|
||||||
f"(attempt {attempt}/{self.max_retries})"
|
f"(attempt {attempt}/{self.max_retries}) via {self.smtp_host}:{self.smtp_port}"
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except aiosmtplib.SMTPException as e:
|
except aiosmtplib.SMTPException as e:
|
||||||
logger.warning(
|
logger.error(
|
||||||
f"SMTP error on attempt {attempt}/{self.max_retries}: {e}"
|
f"[EMAIL] ❌ Attempt {attempt}/{self.max_retries} failed for {to_email}: "
|
||||||
|
f"{type(e).__name__}: {e}"
|
||||||
)
|
)
|
||||||
if attempt < self.max_retries:
|
if attempt < self.max_retries:
|
||||||
# Exponential backoff: 2s, 4s, 8s
|
# Exponential backoff: 2s, 4s, 8s
|
||||||
delay = self.retry_delay * (2 ** (attempt - 1))
|
delay = self.retry_delay * (2 ** (attempt - 1))
|
||||||
logger.info(f"Retrying in {delay}s...")
|
logger.info(f"[EMAIL] Retrying in {delay}s...")
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to send email to {to_email} after {self.max_retries} attempts")
|
logger.error(f"[EMAIL] ❌ All {self.max_retries} attempts failed for {to_email}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error sending email: {e}", exc_info=True)
|
logger.error(f"[EMAIL] ❌ Unexpected error on attempt {attempt}/{self.max_retries} for {to_email}: {type(e).__name__}: {e}", exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -86,29 +86,24 @@ class EmailService:
|
|||||||
self,
|
self,
|
||||||
to_email: str,
|
to_email: str,
|
||||||
subject: str,
|
subject: str,
|
||||||
html_body: str
|
text_body: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Internal async SMTP sender
|
Internal async SMTP sender (plain text to avoid spam filters)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
aiosmtplib.SMTPException: On SMTP errors
|
aiosmtplib.SMTPException: On SMTP errors
|
||||||
"""
|
"""
|
||||||
message = MIMEMultipart("alternative")
|
message = EmailMessage()
|
||||||
message["From"] = f"{self.from_name} <{self.from_email}>"
|
message["From"] = f"{self.from_name} <{self.from_email}>"
|
||||||
message["To"] = to_email
|
message["To"] = to_email
|
||||||
message["Subject"] = subject
|
message["Subject"] = subject
|
||||||
|
message.set_content(text_body)
|
||||||
|
|
||||||
# Attach HTML body
|
|
||||||
html_part = MIMEText(html_body, "html", "utf-8")
|
|
||||||
message.attach(html_part)
|
|
||||||
|
|
||||||
# Send via async SMTP with STARTTLS
|
|
||||||
# Using start_tls parameter for automatic STARTTLS handling
|
|
||||||
smtp = aiosmtplib.SMTP(
|
smtp = aiosmtplib.SMTP(
|
||||||
hostname=self.smtp_host,
|
hostname=self.smtp_host,
|
||||||
port=self.smtp_port,
|
port=self.smtp_port,
|
||||||
start_tls=self.use_tls, # Use start_tls instead of use_tls
|
start_tls=self.use_tls,
|
||||||
timeout=30
|
timeout=30
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -123,132 +118,15 @@ class EmailService:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _create_email_template(self, code: str, username: str) -> str:
|
def _create_email_template(self, code: str, username: str) -> str:
|
||||||
"""Generate HTML email template"""
|
"""Generate plain text email body (HTML blocked by spam filters)"""
|
||||||
return f"""
|
return (
|
||||||
<!DOCTYPE html>
|
f"Codul tau de autentificare ROA2WEB:\n\n"
|
||||||
<html>
|
f" {code}\n\n"
|
||||||
<head>
|
f"Introdu acest cod in Telegram. Expira in 5 minute.\n\n"
|
||||||
<meta charset="utf-8">
|
f"---\n"
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
f"Solicitat pentru: {username}\n"
|
||||||
<style>
|
f"Daca nu ai initiat aceasta autentificare, ignora acest email."
|
||||||
body {{
|
)
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}}
|
|
||||||
.container {{
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: white;
|
|
||||||
}}
|
|
||||||
.header {{
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 40px 20px;
|
|
||||||
text-align: center;
|
|
||||||
}}
|
|
||||||
.header h1 {{
|
|
||||||
margin: 0;
|
|
||||||
font-size: 28px;
|
|
||||||
}}
|
|
||||||
.content {{
|
|
||||||
padding: 40px 20px;
|
|
||||||
}}
|
|
||||||
.code-box {{
|
|
||||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
|
||||||
border: 3px solid #667eea;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 30px;
|
|
||||||
margin: 30px 0;
|
|
||||||
text-align: center;
|
|
||||||
}}
|
|
||||||
.code {{
|
|
||||||
font-size: 42px;
|
|
||||||
font-weight: bold;
|
|
||||||
letter-spacing: 12px;
|
|
||||||
color: #667eea;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
display: block;
|
|
||||||
margin: 15px 0;
|
|
||||||
}}
|
|
||||||
.warning {{
|
|
||||||
background-color: #fff3cd;
|
|
||||||
border-left: 4px solid #ffc107;
|
|
||||||
padding: 15px;
|
|
||||||
margin: 20px 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
}}
|
|
||||||
.footer {{
|
|
||||||
text-align: center;
|
|
||||||
color: #666;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}}
|
|
||||||
.button {{
|
|
||||||
display: inline-block;
|
|
||||||
padding: 12px 24px;
|
|
||||||
background-color: #667eea;
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>ROA2WEB</h1>
|
|
||||||
<p style="margin: 10px 0 0 0; opacity: 0.9;">Autentificare Telegram Bot</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<p>Salut <strong>{username}</strong>,</p>
|
|
||||||
|
|
||||||
<p>Ai solicitat autentificarea în aplicația ROA2WEB Telegram Bot.</p>
|
|
||||||
|
|
||||||
<div class="code-box">
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #666; font-weight: 500;">
|
|
||||||
Codul tău de autentificare:
|
|
||||||
</p>
|
|
||||||
<span class="code">{code}</span>
|
|
||||||
<p style="margin: 0; font-size: 12px; color: #888;">
|
|
||||||
Introdu acest cod în conversația Telegram
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="warning">
|
|
||||||
<strong>Important:</strong> Acest cod expiră în <strong>5 minute</strong>
|
|
||||||
și poate fi folosit o singură dată.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>După introducerea codului, vei fi solicitat să introduci parola ta Oracle.</p>
|
|
||||||
|
|
||||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 30px 0;">
|
|
||||||
|
|
||||||
<p style="font-size: 14px; color: #666;">
|
|
||||||
<strong>Nu ai solicitat acest cod?</strong><br>
|
|
||||||
Dacă nu ai inițiat această autentificare, poți ignora acest email în siguranță.
|
|
||||||
Nimeni nu va avea acces la contul tău fără parola ta Oracle.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<p><strong>ROA2WEB</strong> - ERP Reports Application</p>
|
|
||||||
<p>Acest email a fost trimis automat. Te rugăm să nu răspunzi.</p>
|
|
||||||
<p style="margin-top: 10px; color: #999;">
|
|
||||||
© 2025 ROA2WEB. All rights reserved.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ LOG_FILE=${2:-/tmp/unified_backend.log}
|
|||||||
MAX_RESTARTS=10
|
MAX_RESTARTS=10
|
||||||
RESTART_COUNT=0
|
RESTART_COUNT=0
|
||||||
RESTART_DELAY=3
|
RESTART_DELAY=3
|
||||||
|
UVICORN_PID=""
|
||||||
|
|
||||||
|
# On SIGTERM or SIGINT: kill uvicorn child and exit cleanly (no restart)
|
||||||
|
cleanup_and_exit() {
|
||||||
|
echo "[Backend Runner] Received termination signal, stopping..." | tee -a "$LOG_FILE"
|
||||||
|
if [ -n "$UVICORN_PID" ] && kill -0 "$UVICORN_PID" 2>/dev/null; then
|
||||||
|
kill "$UVICORN_PID" 2>/dev/null
|
||||||
|
wait "$UVICORN_PID" 2>/dev/null
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
trap cleanup_and_exit SIGTERM SIGINT
|
||||||
|
|
||||||
echo "[Backend Runner] Starting uvicorn with auto-restart (max $MAX_RESTARTS restarts)" | tee -a "$LOG_FILE"
|
echo "[Backend Runner] Starting uvicorn with auto-restart (max $MAX_RESTARTS restarts)" | tee -a "$LOG_FILE"
|
||||||
echo "[Backend Runner] Port: $PORT, Log: $LOG_FILE" | tee -a "$LOG_FILE"
|
echo "[Backend Runner] Port: $PORT, Log: $LOG_FILE" | tee -a "$LOG_FILE"
|
||||||
@@ -20,15 +32,27 @@ while [ $RESTART_COUNT -lt $MAX_RESTARTS ]; do
|
|||||||
echo "" | tee -a "$LOG_FILE"
|
echo "" | tee -a "$LOG_FILE"
|
||||||
echo "[Backend Runner] $(date '+%Y-%m-%d %H:%M:%S') - Starting uvicorn (attempt $((RESTART_COUNT + 1))/$MAX_RESTARTS)..." | tee -a "$LOG_FILE"
|
echo "[Backend Runner] $(date '+%Y-%m-%d %H:%M:%S') - Starting uvicorn (attempt $((RESTART_COUNT + 1))/$MAX_RESTARTS)..." | tee -a "$LOG_FILE"
|
||||||
|
|
||||||
# Run uvicorn - it will exit on crash (OOM, etc.)
|
# Run uvicorn in background so we can track its PID and trap signals properly
|
||||||
uvicorn main:app --host 0.0.0.0 --port "$PORT" 2>&1 | tee -a "$LOG_FILE"
|
uvicorn main:app --host 0.0.0.0 --port "$PORT" >> "$LOG_FILE" 2>&1 &
|
||||||
|
UVICORN_PID=$!
|
||||||
|
|
||||||
|
# Wait for uvicorn to exit
|
||||||
|
wait "$UVICORN_PID"
|
||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
|
UVICORN_PID=""
|
||||||
|
|
||||||
END_TIME=$(date +%s)
|
END_TIME=$(date +%s)
|
||||||
RUNTIME=$((END_TIME - START_TIME))
|
RUNTIME=$((END_TIME - START_TIME))
|
||||||
|
|
||||||
echo "[Backend Runner] $(date '+%Y-%m-%d %H:%M:%S') - uvicorn exited with code $EXIT_CODE after ${RUNTIME}s" | tee -a "$LOG_FILE"
|
echo "[Backend Runner] $(date '+%Y-%m-%d %H:%M:%S') - uvicorn exited with code $EXIT_CODE after ${RUNTIME}s" | tee -a "$LOG_FILE"
|
||||||
|
|
||||||
|
# Exit if trap already fired (cleanup_and_exit sets UVICORN_PID="")
|
||||||
|
# or if uvicorn exited due to a signal (130=SIGINT, 143=SIGTERM, 137=SIGKILL)
|
||||||
|
if [ $EXIT_CODE -eq 130 ] || [ $EXIT_CODE -eq 143 ] || [ $EXIT_CODE -eq 137 ]; then
|
||||||
|
echo "[Backend Runner] Received termination signal (code $EXIT_CODE), exiting..." | tee -a "$LOG_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# If it ran for more than 60 seconds, reset restart counter (was stable)
|
# If it ran for more than 60 seconds, reset restart counter (was stable)
|
||||||
if [ $RUNTIME -gt 60 ]; then
|
if [ $RUNTIME -gt 60 ]; then
|
||||||
RESTART_COUNT=0
|
RESTART_COUNT=0
|
||||||
@@ -38,12 +62,6 @@ while [ $RESTART_COUNT -lt $MAX_RESTARTS ]; do
|
|||||||
echo "[Backend Runner] Quick crash detected, restart count: $RESTART_COUNT/$MAX_RESTARTS" | tee -a "$LOG_FILE"
|
echo "[Backend Runner] Quick crash detected, restart count: $RESTART_COUNT/$MAX_RESTARTS" | tee -a "$LOG_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Exit if user sent SIGINT (Ctrl+C) or SIGTERM
|
|
||||||
if [ $EXIT_CODE -eq 130 ] || [ $EXIT_CODE -eq 143 ]; then
|
|
||||||
echo "[Backend Runner] Received termination signal, exiting..." | tee -a "$LOG_FILE"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Wait before restart
|
# Wait before restart
|
||||||
if [ $RESTART_COUNT -lt $MAX_RESTARTS ]; then
|
if [ $RESTART_COUNT -lt $MAX_RESTARTS ]; then
|
||||||
echo "[Backend Runner] Restarting in ${RESTART_DELAY}s..." | tee -a "$LOG_FILE"
|
echo "[Backend Runner] Restarting in ${RESTART_DELAY}s..." | tee -a "$LOG_FILE"
|
||||||
|
|||||||
@@ -46,8 +46,8 @@
|
|||||||
<remove fileExtension=".webmanifest" />
|
<remove fileExtension=".webmanifest" />
|
||||||
<mimeMap fileExtension=".webmanifest" mimeType="application/manifest+json" />
|
<mimeMap fileExtension=".webmanifest" mimeType="application/manifest+json" />
|
||||||
|
|
||||||
<!-- Client-side caching for static assets -->
|
<!-- No global client-side cache - outbound rules handle this per file type -->
|
||||||
<clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="365.00:00:00" />
|
<clientCache cacheControlMode="NoControl" />
|
||||||
</staticContent>
|
</staticContent>
|
||||||
|
|
||||||
<!-- Custom HTTP Headers (Security) -->
|
<!-- Custom HTTP Headers (Security) -->
|
||||||
@@ -132,6 +132,18 @@
|
|||||||
<action type="Rewrite" value="max-age=31536000; includeSubDomains" />
|
<action type="Rewrite" value="max-age=31536000; includeSubDomains" />
|
||||||
</rule>
|
</rule>
|
||||||
|
|
||||||
|
<!-- index.html, sw.js, manifest: NEVER cache - must always be fresh -->
|
||||||
|
<rule name="No Cache for HTML and SW" preCondition="IsHTMLorSW" stopProcessing="true">
|
||||||
|
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
|
||||||
|
<action type="Rewrite" value="no-store, no-cache, must-revalidate" />
|
||||||
|
</rule>
|
||||||
|
|
||||||
|
<!-- Hashed assets (Vite output in /assets/): cache 1 year immutable -->
|
||||||
|
<rule name="Cache Hashed Assets" preCondition="IsHashedAsset" stopProcessing="true">
|
||||||
|
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
|
||||||
|
<action type="Rewrite" value="public, max-age=31536000, immutable" />
|
||||||
|
</rule>
|
||||||
|
|
||||||
<!-- API responses: NO browser/proxy caching (backend cache handles this) -->
|
<!-- API responses: NO browser/proxy caching (backend cache handles this) -->
|
||||||
<rule name="No Cache for API" preCondition="IsAPIRequest">
|
<rule name="No Cache for API" preCondition="IsAPIRequest">
|
||||||
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
|
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
|
||||||
@@ -142,6 +154,12 @@
|
|||||||
<preCondition name="IsHTTPS">
|
<preCondition name="IsHTTPS">
|
||||||
<add input="{HTTPS}" pattern="on" />
|
<add input="{HTTPS}" pattern="on" />
|
||||||
</preCondition>
|
</preCondition>
|
||||||
|
<preCondition name="IsHTMLorSW">
|
||||||
|
<add input="{URL}" pattern="(index\.html|sw\.js|manifest\.json|\.webmanifest)$" />
|
||||||
|
</preCondition>
|
||||||
|
<preCondition name="IsHashedAsset">
|
||||||
|
<add input="{URL}" pattern="/assets/" />
|
||||||
|
</preCondition>
|
||||||
<preCondition name="IsAPIRequest">
|
<preCondition name="IsAPIRequest">
|
||||||
<add input="{URL}" pattern="^/api/" />
|
<add input="{URL}" pattern="^/api/" />
|
||||||
</preCondition>
|
</preCondition>
|
||||||
|
|||||||
24
index.html
24
index.html
@@ -8,8 +8,9 @@
|
|||||||
<!-- BUILD_TIMESTAMP placeholder for cache busting -->
|
<!-- BUILD_TIMESTAMP placeholder for cache busting -->
|
||||||
<meta name="build-time" content="BUILD_TIMESTAMP" />
|
<meta name="build-time" content="BUILD_TIMESTAMP" />
|
||||||
<!-- PWA Support -->
|
<!-- PWA Support -->
|
||||||
<link rel="manifest" href="/roa2web/manifest.json" />
|
<link rel="manifest" href="%BASE_URL%manifest.json" />
|
||||||
<meta name="theme-color" content="#2563eb" />
|
<meta name="theme-color" content="#2563eb" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="ROA2WEB" />
|
<meta name="apple-mobile-web-app-title" content="ROA2WEB" />
|
||||||
@@ -27,8 +28,25 @@
|
|||||||
<script>
|
<script>
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register('/roa2web/sw.js')
|
const swBase = document.querySelector('link[rel="manifest"]')
|
||||||
.then(reg => console.log('[PWA] Service Worker registered:', reg.scope))
|
?.getAttribute('href')?.replace('manifest.json', '') || '/';
|
||||||
|
navigator.serviceWorker.register(swBase + 'sw.js')
|
||||||
|
.then(reg => {
|
||||||
|
console.log('[PWA] Service Worker registered:', reg.scope);
|
||||||
|
|
||||||
|
// Auto-reload when a new SW takes control (picks up new deploy)
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
|
console.log('[PWA] New Service Worker active - reloading for fresh version');
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for SW updates each time user returns to the tab (handles PWA on mobile)
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
reg.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
.catch(err => console.error('[PWA] Service Worker registration failed:', err));
|
.catch(err => console.error('[PWA] Service Worker registration failed:', err));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
29
public/sw.js
29
public/sw.js
@@ -28,10 +28,24 @@ self.addEventListener('activate', (event) => {
|
|||||||
}),
|
}),
|
||||||
// Take control of all clients immediately
|
// Take control of all clients immediately
|
||||||
clients.claim()
|
clients.claim()
|
||||||
])
|
]).then(() => {
|
||||||
|
// Notify all clients that a new SW version is active
|
||||||
|
return clients.matchAll({ includeUncontrolled: true }).then(allClients => {
|
||||||
|
allClients.forEach(client => {
|
||||||
|
client.postMessage({ type: 'SW_UPDATED' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Message handler - allows pages to trigger SW actions
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch event - ALWAYS network first, no caching for HTML/JS/CSS
|
// Fetch event - ALWAYS network first, no caching for HTML/JS/CSS
|
||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
@@ -47,6 +61,19 @@ self.addEventListener('fetch', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// index.html - bypass HTTP cache entirely to always get fresh app shell
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith('index.html') ||
|
||||||
|
url.pathname === '/roa2web/' ||
|
||||||
|
url.pathname === '/roa2web'
|
||||||
|
) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request, { cache: 'no-cache' })
|
||||||
|
.catch(() => caches.match(event.request))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// HTML, JS, CSS - always fetch fresh from network
|
// HTML, JS, CSS - always fetch fresh from network
|
||||||
// This ensures PWA always loads latest version
|
// This ensures PWA always loads latest version
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
|
|||||||
@@ -59,25 +59,34 @@
|
|||||||
|
|
||||||
<!-- Outbound rules to set Cache-Control based on request path -->
|
<!-- Outbound rules to set Cache-Control based on request path -->
|
||||||
<outboundRules>
|
<outboundRules>
|
||||||
|
<!-- index.html, sw.js, manifest: NEVER cache - must always be fresh -->
|
||||||
|
<rule name="No Cache for HTML and SW" preCondition="IsHTMLorSW" stopProcessing="true">
|
||||||
|
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
|
||||||
|
<action type="Rewrite" value="no-store, no-cache, must-revalidate" />
|
||||||
|
</rule>
|
||||||
|
|
||||||
|
<!-- Hashed assets (Vite output in /assets/): cache 1 year immutable -->
|
||||||
|
<rule name="Cache Hashed Assets" preCondition="IsHashedAsset" stopProcessing="true">
|
||||||
|
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
|
||||||
|
<action type="Rewrite" value="public, max-age=31536000, immutable" />
|
||||||
|
</rule>
|
||||||
|
|
||||||
<!-- API responses: NO caching (let backend cache handle it) -->
|
<!-- API responses: NO caching (let backend cache handle it) -->
|
||||||
<rule name="No Cache for API" preCondition="IsAPIRequest">
|
<rule name="No Cache for API" preCondition="IsAPIRequest">
|
||||||
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
|
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
|
||||||
<action type="Rewrite" value="no-store, no-cache, must-revalidate, proxy-revalidate" />
|
<action type="Rewrite" value="no-store, no-cache, must-revalidate, proxy-revalidate" />
|
||||||
</rule>
|
</rule>
|
||||||
|
|
||||||
<!-- Static assets: cache for 1 year -->
|
|
||||||
<rule name="Cache Static Assets" preCondition="IsStaticAsset">
|
|
||||||
<match serverVariable="RESPONSE_Cache-Control" pattern=".*" />
|
|
||||||
<action type="Rewrite" value="public, max-age=31536000" />
|
|
||||||
</rule>
|
|
||||||
|
|
||||||
<preConditions>
|
<preConditions>
|
||||||
|
<preCondition name="IsHTMLorSW">
|
||||||
|
<add input="{URL}" pattern="(index\.html|sw\.js|manifest\.json|\.webmanifest)$" />
|
||||||
|
</preCondition>
|
||||||
|
<preCondition name="IsHashedAsset">
|
||||||
|
<add input="{URL}" pattern="/assets/" />
|
||||||
|
</preCondition>
|
||||||
<preCondition name="IsAPIRequest">
|
<preCondition name="IsAPIRequest">
|
||||||
<add input="{URL}" pattern="^/api/" />
|
<add input="{URL}" pattern="^/api/" />
|
||||||
</preCondition>
|
</preCondition>
|
||||||
<preCondition name="IsStaticAsset">
|
|
||||||
<add input="{URL}" pattern="\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2|ttf|ico|webmanifest)$" />
|
|
||||||
</preCondition>
|
|
||||||
</preConditions>
|
</preConditions>
|
||||||
</outboundRules>
|
</outboundRules>
|
||||||
</rewrite>
|
</rewrite>
|
||||||
|
|||||||
@@ -139,12 +139,11 @@ const toggleChartsExpanded = () => {
|
|||||||
chartsExpanded.value = !chartsExpanded.value;
|
chartsExpanded.value = !chartsExpanded.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format currency
|
// Format amount (without RON suffix)
|
||||||
const formatCurrency = (amount) => {
|
const formatCurrency = (amount) => {
|
||||||
if (!amount && amount !== 0) return "0 RON";
|
if (!amount && amount !== 0) return "0";
|
||||||
return new Intl.NumberFormat("ro-RO", {
|
return new Intl.NumberFormat("ro-RO", {
|
||||||
style: "currency",
|
style: "decimal",
|
||||||
currency: "RON",
|
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(Math.abs(amount));
|
}).format(Math.abs(amount));
|
||||||
@@ -303,8 +302,7 @@ const initializeInflowsChart = async () => {
|
|||||||
const value = context.parsed.y;
|
const value = context.parsed.y;
|
||||||
const label = context.dataset.label || "";
|
const label = context.dataset.label || "";
|
||||||
const formattedValue = new Intl.NumberFormat("ro-RO", {
|
const formattedValue = new Intl.NumberFormat("ro-RO", {
|
||||||
style: "currency",
|
style: "decimal",
|
||||||
currency: "RON",
|
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(value);
|
}).format(value);
|
||||||
@@ -513,8 +511,7 @@ const initializeOutflowsChart = async () => {
|
|||||||
const value = context.parsed.y;
|
const value = context.parsed.y;
|
||||||
const label = context.dataset.label || "";
|
const label = context.dataset.label || "";
|
||||||
const formattedValue = new Intl.NumberFormat("ro-RO", {
|
const formattedValue = new Intl.NumberFormat("ro-RO", {
|
||||||
style: "currency",
|
style: "decimal",
|
||||||
currency: "RON",
|
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(value);
|
}).format(value);
|
||||||
@@ -618,6 +615,18 @@ onBeforeUnmount(() => {
|
|||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Metric label and value typography */
|
||||||
|
.metric-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
/* Split layout: Încasări | Divider | Plăți */
|
/* Split layout: Încasări | Divider | Plăți */
|
||||||
.values-section {
|
.values-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -155,12 +155,11 @@ const toggleChartsExpanded = () => {
|
|||||||
chartsExpanded.value = !chartsExpanded.value;
|
chartsExpanded.value = !chartsExpanded.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format currency
|
// Format amount (without RON suffix)
|
||||||
const formatCurrency = (amount) => {
|
const formatCurrency = (amount) => {
|
||||||
if (!amount && amount !== 0) return "0 RON";
|
if (!amount && amount !== 0) return "0";
|
||||||
return new Intl.NumberFormat("ro-RO", {
|
return new Intl.NumberFormat("ro-RO", {
|
||||||
style: "currency",
|
style: "decimal",
|
||||||
currency: "RON",
|
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(Math.abs(amount));
|
}).format(Math.abs(amount));
|
||||||
@@ -370,8 +369,7 @@ const initializeChart = async () => {
|
|||||||
const value = context.parsed.y;
|
const value = context.parsed.y;
|
||||||
const label = context.dataset.label || "";
|
const label = context.dataset.label || "";
|
||||||
const formattedValue = new Intl.NumberFormat("ro-RO", {
|
const formattedValue = new Intl.NumberFormat("ro-RO", {
|
||||||
style: "currency",
|
style: "decimal",
|
||||||
currency: "RON",
|
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(value);
|
}).format(value);
|
||||||
@@ -496,7 +494,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-label {
|
.header-label {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-sm);
|
||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
@@ -508,11 +506,31 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-total {
|
.header-total {
|
||||||
font-size: var(--text-xl);
|
font-size: var(--text-sm);
|
||||||
font-weight: var(--font-bold);
|
font-weight: var(--font-bold);
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Breakdown section typography */
|
||||||
|
.breakdown-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-value {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-sublabel {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-subvalue {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
.header-total.positive {
|
.header-total.positive {
|
||||||
color: var(--green-600);
|
color: var(--green-600);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,12 +155,11 @@ const toggleChartsExpanded = () => {
|
|||||||
chartsExpanded.value = !chartsExpanded.value;
|
chartsExpanded.value = !chartsExpanded.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format currency
|
// Format amount (without RON suffix)
|
||||||
const formatCurrency = (amount) => {
|
const formatCurrency = (amount) => {
|
||||||
if (!amount && amount !== 0) return "0 RON";
|
if (!amount && amount !== 0) return "0";
|
||||||
return new Intl.NumberFormat("ro-RO", {
|
return new Intl.NumberFormat("ro-RO", {
|
||||||
style: "currency",
|
style: "decimal",
|
||||||
currency: "RON",
|
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(Math.abs(amount));
|
}).format(Math.abs(amount));
|
||||||
@@ -370,8 +369,7 @@ const initializeChart = async () => {
|
|||||||
const value = context.parsed.y;
|
const value = context.parsed.y;
|
||||||
const label = context.dataset.label || "";
|
const label = context.dataset.label || "";
|
||||||
const formattedValue = new Intl.NumberFormat("ro-RO", {
|
const formattedValue = new Intl.NumberFormat("ro-RO", {
|
||||||
style: "currency",
|
style: "decimal",
|
||||||
currency: "RON",
|
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(value);
|
}).format(value);
|
||||||
@@ -496,7 +494,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-label {
|
.header-label {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-sm);
|
||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
@@ -508,11 +506,31 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-total {
|
.header-total {
|
||||||
font-size: var(--text-xl);
|
font-size: var(--text-sm);
|
||||||
font-weight: var(--font-bold);
|
font-weight: var(--font-bold);
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Breakdown section typography */
|
||||||
|
.breakdown-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-value {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-sublabel {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-subvalue {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
.header-total.positive {
|
.header-total.positive {
|
||||||
color: var(--green-600);
|
color: var(--green-600);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- Treasury items - Casa și Bancă stacked vertical cu expand individual -->
|
<!-- Treasury items - Casa și Bancă stacked vertical cu expand individual -->
|
||||||
<div class="treasury-items">
|
<div class="treasury-items">
|
||||||
<!-- Casa -->
|
<!-- Casa -->
|
||||||
<div class="treasury-group" v-if="casaItems.length > 0 || casaTotal > 0">
|
<div class="treasury-group" v-if="casaItems.length > 0 || casaTotal !== 0">
|
||||||
<div class="treasury-header" @click="toggleCasaExpanded">
|
<div class="treasury-header" @click="toggleCasaExpanded">
|
||||||
<div class="treasury-header-left">
|
<div class="treasury-header-left">
|
||||||
<i
|
<i
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
></i>
|
></i>
|
||||||
<span class="treasury-label">Casa</span>
|
<span class="treasury-label">Casa</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="treasury-value text-success">{{ formatCurrency(casaTotal) }}</span>
|
<span class="treasury-value" :class="casaTotal >= 0 ? 'text-success' : 'text-danger'">{{ formatCurrency(casaTotal) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Casa Sub-items -->
|
<!-- Casa Sub-items -->
|
||||||
@@ -26,13 +26,13 @@
|
|||||||
{{ item.nume || `Cont ${item.cont}` }}
|
{{ item.nume || `Cont ${item.cont}` }}
|
||||||
<span v-if="item.cont" class="treasury-cont">({{ item.cont }})</span>
|
<span v-if="item.cont" class="treasury-cont">({{ item.cont }})</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="treasury-subvalue">{{ formatCurrency(item.sold) }}</span>
|
<span class="treasury-subvalue" :class="{ 'text-danger': item.sold < 0 }">{{ formatCurrency(item.sold) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bancă -->
|
<!-- Bancă -->
|
||||||
<div class="treasury-group" v-if="bancaItems.length > 0 || bancaTotal > 0">
|
<div class="treasury-group" v-if="bancaItems.length > 0 || bancaTotal !== 0">
|
||||||
<div class="treasury-header" @click="toggleBancaExpanded">
|
<div class="treasury-header" @click="toggleBancaExpanded">
|
||||||
<div class="treasury-header-left">
|
<div class="treasury-header-left">
|
||||||
<i
|
<i
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
></i>
|
></i>
|
||||||
<span class="treasury-label">Bancă</span>
|
<span class="treasury-label">Bancă</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="treasury-value text-primary">{{ formatCurrency(bancaTotal) }}</span>
|
<span class="treasury-value" :class="bancaTotal >= 0 ? 'text-primary' : 'text-danger'">{{ formatCurrency(bancaTotal) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bancă Sub-items -->
|
<!-- Bancă Sub-items -->
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
{{ item.nume || `Cont ${item.cont}` }}
|
{{ item.nume || `Cont ${item.cont}` }}
|
||||||
<span v-if="item.cont" class="treasury-cont">({{ item.cont }})</span>
|
<span v-if="item.cont" class="treasury-cont">({{ item.cont }})</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="treasury-subvalue">{{ formatCurrency(item.sold) }}</span>
|
<span class="treasury-subvalue" :class="{ 'text-danger': item.sold < 0 }">{{ formatCurrency(item.sold) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,15 +189,14 @@ const toggleChartsExpanded = () => {
|
|||||||
chartsExpanded.value = !chartsExpanded.value;
|
chartsExpanded.value = !chartsExpanded.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format currency
|
// Format amount (without RON suffix)
|
||||||
const formatCurrency = (amount) => {
|
const formatCurrency = (amount) => {
|
||||||
if (!amount && amount !== 0) return "0 RON";
|
if (!amount && amount !== 0) return "0";
|
||||||
return new Intl.NumberFormat("ro-RO", {
|
return new Intl.NumberFormat("ro-RO", {
|
||||||
style: "currency",
|
style: "decimal",
|
||||||
currency: "RON",
|
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(Math.abs(amount));
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if sparkline data exists
|
// Check if sparkline data exists
|
||||||
@@ -327,8 +326,7 @@ const initializeCasaChart = async () => {
|
|||||||
const value = context.parsed.y;
|
const value = context.parsed.y;
|
||||||
const label = context.dataset.label || "";
|
const label = context.dataset.label || "";
|
||||||
const formattedValue = new Intl.NumberFormat("ro-RO", {
|
const formattedValue = new Intl.NumberFormat("ro-RO", {
|
||||||
style: "currency",
|
style: "decimal",
|
||||||
currency: "RON",
|
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(value);
|
}).format(value);
|
||||||
@@ -511,8 +509,7 @@ const initializeBancaChart = async () => {
|
|||||||
const value = context.parsed.y;
|
const value = context.parsed.y;
|
||||||
const label = context.dataset.label || "";
|
const label = context.dataset.label || "";
|
||||||
const formattedValue = new Intl.NumberFormat("ro-RO", {
|
const formattedValue = new Intl.NumberFormat("ro-RO", {
|
||||||
style: "currency",
|
style: "decimal",
|
||||||
currency: "RON",
|
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(value);
|
}).format(value);
|
||||||
@@ -663,13 +660,13 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.treasury-label {
|
.treasury-label {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-sm);
|
||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.treasury-value {
|
.treasury-value {
|
||||||
font-size: var(--text-xl);
|
font-size: var(--text-sm);
|
||||||
font-weight: var(--font-bold);
|
font-weight: var(--font-bold);
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono, monospace);
|
||||||
}
|
}
|
||||||
@@ -775,10 +772,6 @@ onBeforeUnmount(() => {
|
|||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.treasury-value {
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sparkline-chart {
|
.sparkline-chart {
|
||||||
height: 130px;
|
height: 130px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="solduri-compact-card__content">
|
<div class="solduri-compact-card__content">
|
||||||
<span class="solduri-compact-card__label">{{ label }}</span>
|
<span class="solduri-compact-card__label">{{ label }}</span>
|
||||||
<span class="solduri-compact-card__value" :class="valueColorClass">
|
<span class="solduri-compact-card__value" :class="valueColorClass">
|
||||||
{{ formatCurrency(total) }}
|
{{ formatAmount(total) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<i
|
<i
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<!-- Casa Total -->
|
<!-- Casa Total -->
|
||||||
<div class="solduri-compact-card__breakdown-item">
|
<div class="solduri-compact-card__breakdown-item">
|
||||||
<span class="solduri-compact-card__breakdown-label">Casa</span>
|
<span class="solduri-compact-card__breakdown-label">Casa</span>
|
||||||
<span class="solduri-compact-card__breakdown-value">{{ formatCurrency(casaTotal) }}</span>
|
<span class="solduri-compact-card__breakdown-value">{{ formatAmount(casaTotal) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Sub-conturi Casa (imediat sub Casa) -->
|
<!-- Sub-conturi Casa (imediat sub Casa) -->
|
||||||
<template v-if="breakdown?.casa?.items?.length">
|
<template v-if="breakdown?.casa?.items?.length">
|
||||||
@@ -37,13 +37,13 @@
|
|||||||
<span class="solduri-compact-card__breakdown-sublabel">
|
<span class="solduri-compact-card__breakdown-sublabel">
|
||||||
{{ item.nume || `Cont ${item.cont}` }}
|
{{ item.nume || `Cont ${item.cont}` }}
|
||||||
</span>
|
</span>
|
||||||
<span class="solduri-compact-card__breakdown-subvalue">{{ formatCurrency(item.sold) }}</span>
|
<span class="solduri-compact-card__breakdown-subvalue">{{ formatAmount(item.sold) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- Bancă Total -->
|
<!-- Bancă Total -->
|
||||||
<div class="solduri-compact-card__breakdown-item">
|
<div class="solduri-compact-card__breakdown-item">
|
||||||
<span class="solduri-compact-card__breakdown-label">Bancă</span>
|
<span class="solduri-compact-card__breakdown-label">Bancă</span>
|
||||||
<span class="solduri-compact-card__breakdown-value">{{ formatCurrency(bancaTotal) }}</span>
|
<span class="solduri-compact-card__breakdown-value">{{ formatAmount(bancaTotal) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Sub-conturi Bancă (imediat sub Bancă) -->
|
<!-- Sub-conturi Bancă (imediat sub Bancă) -->
|
||||||
<template v-if="breakdown?.banca?.items?.length">
|
<template v-if="breakdown?.banca?.items?.length">
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<span class="solduri-compact-card__breakdown-sublabel">
|
<span class="solduri-compact-card__breakdown-sublabel">
|
||||||
{{ item.nume || `Cont ${item.cont}` }}
|
{{ item.nume || `Cont ${item.cont}` }}
|
||||||
</span>
|
</span>
|
||||||
<span class="solduri-compact-card__breakdown-subvalue">{{ formatCurrency(item.sold) }}</span>
|
<span class="solduri-compact-card__breakdown-subvalue">{{ formatAmount(item.sold) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -65,13 +65,13 @@
|
|||||||
<div class="solduri-compact-card__breakdown-item">
|
<div class="solduri-compact-card__breakdown-item">
|
||||||
<span class="solduri-compact-card__breakdown-label">În termen</span>
|
<span class="solduri-compact-card__breakdown-label">În termen</span>
|
||||||
<span class="solduri-compact-card__breakdown-value">
|
<span class="solduri-compact-card__breakdown-value">
|
||||||
{{ formatCurrency(breakdown?.in_termen?.total || 0) }}
|
{{ formatAmount(breakdown?.in_termen?.total || 0) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="solduri-compact-card__breakdown-item">
|
<div class="solduri-compact-card__breakdown-item">
|
||||||
<span class="solduri-compact-card__breakdown-label">Restant</span>
|
<span class="solduri-compact-card__breakdown-label">Restant</span>
|
||||||
<span class="solduri-compact-card__breakdown-value solduri-compact-card__breakdown-value--warning">
|
<span class="solduri-compact-card__breakdown-value solduri-compact-card__breakdown-value--warning">
|
||||||
{{ formatCurrency(breakdown?.restant?.total || 0) }}
|
{{ formatAmount(breakdown?.restant?.total || 0) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Perioade restante -->
|
<!-- Perioade restante -->
|
||||||
@@ -82,24 +82,50 @@
|
|||||||
class="solduri-compact-card__breakdown-subitem"
|
class="solduri-compact-card__breakdown-subitem"
|
||||||
>
|
>
|
||||||
<span class="solduri-compact-card__breakdown-sublabel">{{ formatPeriodLabel(key) }}</span>
|
<span class="solduri-compact-card__breakdown-sublabel">{{ formatPeriodLabel(key) }}</span>
|
||||||
<span class="solduri-compact-card__breakdown-subvalue">{{ formatCurrency(value) }}</span>
|
<span class="solduri-compact-card__breakdown-subvalue">{{ formatAmount(value) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- TVA: Simple display (no breakdown needed) -->
|
<!-- TVA / Datorii Buget: Breakdown pe grupe (TVA/BASS/CAM) cu sub-conturi -->
|
||||||
<template v-else-if="type === 'tva'">
|
<template v-else-if="type === 'tva'">
|
||||||
<div class="solduri-compact-card__breakdown-item">
|
<template v-if="Array.isArray(breakdown) && (breakdown as any[]).length">
|
||||||
<span class="solduri-compact-card__breakdown-label">
|
<div v-for="group in (breakdown as any[])" :key="group.key">
|
||||||
{{ total >= 0 ? 'TVA de recuperat' : 'TVA de plată' }}
|
<!-- Rând grup -->
|
||||||
</span>
|
<div
|
||||||
<span
|
class="solduri-compact-card__breakdown-item solduri-compact-card__breakdown-group"
|
||||||
class="solduri-compact-card__breakdown-value"
|
@click.stop="toggleGroup(group.key)"
|
||||||
:class="total >= 0 ? 'solduri-compact-card__breakdown-value--success' : 'solduri-compact-card__breakdown-value--danger'"
|
>
|
||||||
>
|
<span class="solduri-compact-card__breakdown-label solduri-compact-card__group-label">
|
||||||
{{ formatCurrency(Math.abs(total)) }}
|
<i class="pi pi-chevron-right solduri-compact-card__group-toggle"
|
||||||
</span>
|
:class="{ 'solduri-compact-card__group-toggle--expanded': expandedGroups.has(group.key) }"></i>
|
||||||
</div>
|
{{ group.label }}
|
||||||
|
</span>
|
||||||
|
<span class="solduri-compact-card__breakdown-value">
|
||||||
|
{{ group.precedent !== 0 ? formatAmount(group.precedent) : '-' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Sub-conturi -->
|
||||||
|
<div v-show="expandedGroups.has(group.key)">
|
||||||
|
<div
|
||||||
|
v-for="acc in group.sub_accounts"
|
||||||
|
:key="acc.cont"
|
||||||
|
class="solduri-compact-card__breakdown-subitem"
|
||||||
|
>
|
||||||
|
<span class="solduri-compact-card__breakdown-sublabel">{{ acc.label }}</span>
|
||||||
|
<span class="solduri-compact-card__breakdown-subvalue">
|
||||||
|
{{ acc.precedent !== 0 ? formatAmount(acc.precedent) : '-' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="solduri-compact-card__breakdown-item">
|
||||||
|
<span class="solduri-compact-card__breakdown-label">Fără date</span>
|
||||||
|
<span class="solduri-compact-card__breakdown-value">-</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,6 +170,15 @@ const props = defineProps<{
|
|||||||
|
|
||||||
// State
|
// State
|
||||||
const isExpanded = ref(false)
|
const isExpanded = ref(false)
|
||||||
|
const expandedGroups = ref(new Set<string>())
|
||||||
|
const toggleGroup = (key: string) => {
|
||||||
|
if (expandedGroups.value.has(key)) {
|
||||||
|
expandedGroups.value.delete(key)
|
||||||
|
} else {
|
||||||
|
expandedGroups.value.add(key)
|
||||||
|
}
|
||||||
|
expandedGroups.value = new Set(expandedGroups.value)
|
||||||
|
}
|
||||||
|
|
||||||
// Computed: Label based on type
|
// Computed: Label based on type
|
||||||
const label = computed(() => {
|
const label = computed(() => {
|
||||||
@@ -151,7 +186,7 @@ const label = computed(() => {
|
|||||||
trezorerie: 'TREZORERIE',
|
trezorerie: 'TREZORERIE',
|
||||||
clienti: 'CLIENȚI',
|
clienti: 'CLIENȚI',
|
||||||
furnizori: 'FURNIZORI',
|
furnizori: 'FURNIZORI',
|
||||||
tva: 'TVA'
|
tva: 'DATORII BUGET'
|
||||||
}
|
}
|
||||||
return labels[props.type] || props.type.toUpperCase()
|
return labels[props.type] || props.type.toUpperCase()
|
||||||
})
|
})
|
||||||
@@ -159,9 +194,11 @@ const label = computed(() => {
|
|||||||
// Computed: Value color class based on type and value
|
// Computed: Value color class based on type and value
|
||||||
const valueColorClass = computed(() => {
|
const valueColorClass = computed(() => {
|
||||||
if (props.type === 'tva') {
|
if (props.type === 'tva') {
|
||||||
return props.total >= 0
|
// total = tvaPreviousMonthNet = plata - recuperat
|
||||||
? 'solduri-compact-card__value--success'
|
// pozitiv = datorie la buget (roșu), zero/negativ = fără datorie (verde)
|
||||||
: 'solduri-compact-card__value--danger'
|
return props.total > 0
|
||||||
|
? 'solduri-compact-card__value--danger'
|
||||||
|
: 'solduri-compact-card__value--success'
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
@@ -175,7 +212,7 @@ const hasBreakdown = computed(() => {
|
|||||||
return props.breakdown !== null && props.breakdown !== undefined
|
return props.breakdown !== null && props.breakdown !== undefined
|
||||||
}
|
}
|
||||||
if (props.type === 'tva') {
|
if (props.type === 'tva') {
|
||||||
return true // TVA always shows status
|
return props.breakdown !== null && props.breakdown !== undefined
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -187,11 +224,10 @@ const toggleExpanded = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatCurrency = (amount: number | undefined | null): string => {
|
const formatAmount = (amount: number | undefined | null): string => {
|
||||||
if (amount === undefined || amount === null) return '0 RON'
|
if (amount === undefined || amount === null) return '0'
|
||||||
return new Intl.NumberFormat('ro-RO', {
|
return new Intl.NumberFormat('ro-RO', {
|
||||||
style: 'currency',
|
style: 'decimal',
|
||||||
currency: 'RON',
|
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0
|
maximumFractionDigits: 0
|
||||||
}).format(amount)
|
}).format(amount)
|
||||||
@@ -304,13 +340,13 @@ const formatPeriodLabel = (key: string): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.solduri-compact-card__breakdown-label {
|
.solduri-compact-card__breakdown-label {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-weight: var(--font-medium);
|
font-weight: var(--font-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.solduri-compact-card__breakdown-value {
|
.solduri-compact-card__breakdown-value {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-sm);
|
||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono, monospace);
|
||||||
@@ -349,6 +385,26 @@ const formatPeriodLabel = (key: string): string => {
|
|||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TVA grup toggle */
|
||||||
|
.solduri-compact-card__breakdown-group {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
}
|
||||||
|
.solduri-compact-card__breakdown-group:hover { background: var(--surface-hover); }
|
||||||
|
|
||||||
|
.solduri-compact-card__group-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.solduri-compact-card__group-toggle {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
.solduri-compact-card__group-toggle--expanded { transform: rotate(90deg); }
|
||||||
|
|
||||||
/* Responsive - Mobile */
|
/* Responsive - Mobile */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.solduri-compact-card {
|
.solduri-compact-card {
|
||||||
|
|||||||
@@ -41,8 +41,13 @@
|
|||||||
|
|
||||||
<!-- Company selection removed - now handled in header only -->
|
<!-- Company selection removed - now handled in header only -->
|
||||||
|
|
||||||
|
<!-- Loading Bar - non-blocking, subtle indicator while data loads -->
|
||||||
|
<div v-if="isLoading" class="loading-bar-container">
|
||||||
|
<div class="loading-bar"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Secțiune Carduri Noi - Adăugare -->
|
<!-- Secțiune Carduri Noi - Adăugare -->
|
||||||
<div class="metrics-cards-section" v-if="!isLoading">
|
<div class="metrics-cards-section">
|
||||||
<!-- Mobile: Swipeable KPI Cards Carousel -->
|
<!-- Mobile: Swipeable KPI Cards Carousel -->
|
||||||
<!-- US-2002: 6 pages - first page is 2x2 grid with solduri, pages 2-5 are original graph cards, page 6 is financial indicators -->
|
<!-- US-2002: 6 pages - first page is 2x2 grid with solduri, pages 2-5 are original graph cards, page 6 is financial indicators -->
|
||||||
<SwipeableCards v-if="isMobile" :totalCards="6" class="mobile-kpi-carousel">
|
<SwipeableCards v-if="isMobile" :totalCards="6" class="mobile-kpi-carousel">
|
||||||
@@ -68,7 +73,8 @@
|
|||||||
/>
|
/>
|
||||||
<SolduriCompactCard
|
<SolduriCompactCard
|
||||||
type="tva"
|
type="tva"
|
||||||
:total="tvaTotal"
|
:total="tvaPreviousMonthNet"
|
||||||
|
:breakdown="tvaBreakdown"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -220,12 +226,66 @@
|
|||||||
:cacheInfo="netBalanceCacheInfo"
|
:cacheInfo="netBalanceCacheInfo"
|
||||||
/>
|
/>
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
|
<CollapsibleCard
|
||||||
|
label="Datorii la buget"
|
||||||
|
:value="budgetDebtTotalPrecedent"
|
||||||
|
:value-class="budgetDebtTotalPrecedent > 0 ? 'negative' : 'positive'"
|
||||||
|
>
|
||||||
|
<div class="budget-debt-breakdown-desktop">
|
||||||
|
<div class="budget-debt-breakdown-header">
|
||||||
|
<span class="budget-debt-col-label">Categorie</span>
|
||||||
|
<span class="budget-debt-col-header-value">Luna prec.</span>
|
||||||
|
<span class="budget-debt-col-header-value">Luna curentă</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-for="group in budgetDebtBreakdown" :key="group.key">
|
||||||
|
<!-- Rând grup (clickabil pentru expand sub-conturi) -->
|
||||||
|
<div
|
||||||
|
class="budget-debt-breakdown-row budget-debt-group-row"
|
||||||
|
@click="toggleBudgetGroup(group.key)"
|
||||||
|
>
|
||||||
|
<span class="budget-debt-col-label budget-debt-group-label">
|
||||||
|
<i class="pi pi-chevron-right budget-debt-toggle"
|
||||||
|
:class="{ expanded: expandedBudgetGroups.has(group.key) }"></i>
|
||||||
|
{{ group.label }}
|
||||||
|
</span>
|
||||||
|
<span class="budget-debt-col-value">
|
||||||
|
{{ group.precedent !== 0 ? formatAmount(group.precedent) : '-' }}
|
||||||
|
</span>
|
||||||
|
<span class="budget-debt-col-value">
|
||||||
|
{{ group.curent !== 0 ? formatAmount(group.curent) : '-' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sub-conturi (expandabile) -->
|
||||||
|
<div v-show="expandedBudgetGroups.has(group.key)" class="budget-debt-sub-accounts">
|
||||||
|
<div
|
||||||
|
v-for="acc in group.sub_accounts"
|
||||||
|
:key="acc.cont"
|
||||||
|
class="budget-debt-breakdown-row budget-debt-subrow"
|
||||||
|
>
|
||||||
|
<span class="budget-debt-col-label budget-debt-sub-label">{{ acc.label }}</span>
|
||||||
|
<span class="budget-debt-col-value budget-debt-sub-value">
|
||||||
|
{{ acc.precedent !== 0 ? formatAmount(acc.precedent) : '-' }}
|
||||||
|
</span>
|
||||||
|
<span class="budget-debt-col-value budget-debt-sub-value">
|
||||||
|
{{ acc.curent !== 0 ? formatAmount(acc.curent) : '-' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="budgetDebtBreakdown.length === 0" class="budget-debt-breakdown-empty">
|
||||||
|
Nu există datorii înregistrate
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Financial Indicators Section - Desktop Only (US-014) -->
|
<!-- Financial Indicators Section - Desktop Only (US-014) -->
|
||||||
<div v-if="!isMobile && !isLoading" class="financial-indicators-section">
|
<div v-if="!isMobile" class="financial-indicators-section">
|
||||||
<FinancialIndicatorsCard
|
<FinancialIndicatorsCard
|
||||||
:loading="dashboardStore.financialIndicators.loading"
|
:loading="dashboardStore.financialIndicators.loading"
|
||||||
:error="dashboardStore.financialIndicators.error"
|
:error="dashboardStore.financialIndicators.error"
|
||||||
@@ -236,11 +296,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="isLoading" class="loading-state">
|
|
||||||
<div class="loading-spinner"></div>
|
|
||||||
<p>Se încarcă datele dashboard-ului...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -507,10 +562,63 @@ const totalTrezorerie = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const tvaTotal = computed(() => {
|
const tvaTotal = computed(() => {
|
||||||
// TVA from dashboard summary if available, otherwise default to 0
|
const s = dashboardStore.summary;
|
||||||
return dashboardStore.summary?.tva_sold || 0;
|
if (!s) return 0;
|
||||||
|
// Pozitiv = de recuperat, Negativ = de plată
|
||||||
|
return Number(s.tva_recuperat_curent || 0) - Number(s.tva_plata_curent || 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tvaPreviousMonth = computed(() => {
|
||||||
|
const s = dashboardStore.summary;
|
||||||
|
if (!s) return { plata: 0, recuperat: 0 };
|
||||||
|
return {
|
||||||
|
plata: Number(s.tva_plata_precedent || 0),
|
||||||
|
recuperat: Number(s.tva_recuperat_precedent || 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tvaCurrentMonth = computed(() => {
|
||||||
|
const s = dashboardStore.summary;
|
||||||
|
if (!s) return { plata: 0, recuperat: 0 };
|
||||||
|
return {
|
||||||
|
plata: Number(s.tva_plata_curent || 0),
|
||||||
|
recuperat: Number(s.tva_recuperat_curent || 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// TVA net luna precedentă (valoare pozitivă = datorie la buget)
|
||||||
|
const tvaPreviousMonthNet = computed(() => {
|
||||||
|
const prev = tvaPreviousMonth.value;
|
||||||
|
return (prev.plata || 0) - (prev.recuperat || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Breakdown pe grupe datorii buget (TVA/BASS/CAM cu sub-conturi)
|
||||||
|
const budgetDebtBreakdown = computed(() => {
|
||||||
|
return dashboardStore.summary?.budget_debt_breakdown || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Total precedent din toate grupurile (pentru header CollapsibleCard)
|
||||||
|
const budgetDebtTotalPrecedent = computed(() => {
|
||||||
|
const groups = dashboardStore.summary?.budget_debt_breakdown || [];
|
||||||
|
return groups.reduce((sum, g) => sum + Number(g.precedent || 0), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tvaBreakdown = computed(() => {
|
||||||
|
return dashboardStore.summary?.budget_debt_breakdown || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reactive state pentru expand/collapse grupe buget
|
||||||
|
const expandedBudgetGroups = ref(new Set());
|
||||||
|
const toggleBudgetGroup = (key) => {
|
||||||
|
if (expandedBudgetGroups.value.has(key)) {
|
||||||
|
expandedBudgetGroups.value.delete(key);
|
||||||
|
} else {
|
||||||
|
expandedBudgetGroups.value.add(key);
|
||||||
|
}
|
||||||
|
// Force reactivity update on Set
|
||||||
|
expandedBudgetGroups.value = new Set(expandedBudgetGroups.value);
|
||||||
|
};
|
||||||
|
|
||||||
// Net Cash Flow for CollapsibleCard header
|
// Net Cash Flow for CollapsibleCard header
|
||||||
const netCashFlow = computed(() => {
|
const netCashFlow = computed(() => {
|
||||||
return (monthlyInflows.value || 0) - (monthlyOutflows.value || 0);
|
return (monthlyInflows.value || 0) - (monthlyOutflows.value || 0);
|
||||||
@@ -716,18 +824,18 @@ const handleCompanyChanged = async (company) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount) => {
|
const formatAmount = (amount) => {
|
||||||
if (!amount && amount !== 0) return "0,00 RON";
|
if (!amount && amount !== 0) return "0";
|
||||||
const numAmount = typeof amount === "string" ? parseFloat(amount) : amount;
|
const numAmount = typeof amount === "string" ? parseFloat(amount) : amount;
|
||||||
if (isNaN(numAmount)) return "0,00 RON";
|
if (isNaN(numAmount)) return "0";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new Intl.NumberFormat("ro-RO", {
|
return new Intl.NumberFormat("ro-RO", {
|
||||||
style: "currency",
|
style: "decimal",
|
||||||
currency: "RON",
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
}).format(numAmount);
|
}).format(numAmount);
|
||||||
} catch (error) {
|
} catch {
|
||||||
return `${numAmount.toLocaleString("ro-RO", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} RON`;
|
return numAmount.toLocaleString("ro-RO", { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1100,6 +1208,14 @@ const loadDashboardData = async () => {
|
|||||||
const indicatorAn = prevPeriod?.an || null;
|
const indicatorAn = prevPeriod?.an || null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Fire financial indicators independently - it has its own loading state
|
||||||
|
// and should not block the main dashboard cards from appearing
|
||||||
|
dashboardStore.loadFinancialIndicators(
|
||||||
|
companyStore.selectedCompany.id_firma,
|
||||||
|
indicatorLuna,
|
||||||
|
indicatorAn,
|
||||||
|
);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
dashboardStore.loadDashboardSummary(
|
dashboardStore.loadDashboardSummary(
|
||||||
companyStore.selectedCompany.id_firma,
|
companyStore.selectedCompany.id_firma,
|
||||||
@@ -1111,12 +1227,6 @@ const loadDashboardData = async () => {
|
|||||||
loadMonthlyFlows(),
|
loadMonthlyFlows(),
|
||||||
loadTreasuryBreakdown(),
|
loadTreasuryBreakdown(),
|
||||||
loadNetBalanceBreakdown(),
|
loadNetBalanceBreakdown(),
|
||||||
// US-014: Load financial indicators for desktop card (luna anterioară)
|
|
||||||
dashboardStore.loadFinancialIndicators(
|
|
||||||
companyStore.selectedCompany.id_firma,
|
|
||||||
indicatorLuna,
|
|
||||||
indicatorAn,
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load dashboard data:", error);
|
console.error("Failed to load dashboard data:", error);
|
||||||
@@ -1447,15 +1557,26 @@ onUnmounted(() => {
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading State - Uses global .loading-spinner from interactive.css */
|
/* Loading Bar - non-blocking, subtle indicator at top of content */
|
||||||
.loading-state {
|
.loading-bar-container {
|
||||||
display: flex;
|
height: 3px;
|
||||||
flex-direction: column;
|
background: var(--surface-border);
|
||||||
align-items: center;
|
border-radius: var(--radius-full);
|
||||||
justify-content: center;
|
overflow: hidden;
|
||||||
padding: var(--space-xl);
|
margin-bottom: var(--space-md);
|
||||||
color: var(--color-text-secondary);
|
}
|
||||||
text-align: center;
|
|
||||||
|
.loading-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-primary);
|
||||||
|
animation: loading-progress 1.5s ease-in-out infinite;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading-progress {
|
||||||
|
0% { width: 0%; transform: translateX(0%); }
|
||||||
|
50% { width: 60%; transform: translateX(60%); }
|
||||||
|
100% { width: 30%; transform: translateX(300%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Design - Consolidated (Component-specific only) */
|
/* Responsive Design - Consolidated (Component-specific only) */
|
||||||
@@ -1537,6 +1658,104 @@ onUnmounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Budget Debt Breakdown in Desktop CollapsibleCard */
|
||||||
|
.budget-debt-breakdown-desktop {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-debt-breakdown-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-xs) 0 var(--space-sm) 0;
|
||||||
|
border-bottom: 1px solid var(--surface-border);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-debt-breakdown-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-xs) 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-debt-col-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-debt-breakdown-header .budget-debt-col-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-debt-col-value {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 130px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-debt-col-header-value {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 130px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-debt-breakdown-empty {
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grup row - clickabil, bold */
|
||||||
|
.budget-debt-group-row {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
background: var(--surface-ground);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.budget-debt-group-row:hover { background: var(--surface-hover); }
|
||||||
|
|
||||||
|
.budget-debt-group-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-debt-toggle {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
.budget-debt-toggle.expanded { transform: rotate(90deg); }
|
||||||
|
|
||||||
|
/* Sub-conturi indentate */
|
||||||
|
.budget-debt-sub-accounts {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-left: 2px solid var(--surface-border);
|
||||||
|
margin-left: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-debt-subrow { opacity: 0.85; }
|
||||||
|
.budget-debt-sub-label { padding-left: var(--space-md); font-size: var(--text-xs); }
|
||||||
|
.budget-debt-sub-value { font-size: var(--text-xs); }
|
||||||
|
|
||||||
/* Responsive - All breakpoints consolidated */
|
/* Responsive - All breakpoints consolidated */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.metrics-row {
|
.metrics-row {
|
||||||
|
|||||||
@@ -76,8 +76,7 @@ const formattedValue = computed(() => {
|
|||||||
|
|
||||||
if (props.formatCurrency && typeof props.value === 'number') {
|
if (props.formatCurrency && typeof props.value === 'number') {
|
||||||
return new Intl.NumberFormat('ro-RO', {
|
return new Intl.NumberFormat('ro-RO', {
|
||||||
style: 'currency',
|
style: 'decimal',
|
||||||
currency: 'RON',
|
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
maximumFractionDigits: 0
|
maximumFractionDigits: 0
|
||||||
}).format(props.value)
|
}).format(props.value)
|
||||||
@@ -138,12 +137,14 @@ const toggleExpanded = () => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Label */
|
/* Label - matches SolduriCompactCard style */
|
||||||
.collapsible-card__label {
|
.collapsible-card__label {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-color-secondary);
|
color: var(--text-color-secondary);
|
||||||
font-weight: var(--font-medium);
|
font-weight: var(--font-semibold);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Value */
|
/* Value */
|
||||||
@@ -223,10 +224,6 @@ const toggleExpanded = () => {
|
|||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapsible-card__label {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsible-card__value {
|
.collapsible-card__value {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
}
|
}
|
||||||
|
|||||||
27
start.sh
27
start.sh
@@ -38,6 +38,7 @@ LOG_DIR=""
|
|||||||
BACKEND_LOG=""
|
BACKEND_LOG=""
|
||||||
FRONTEND_LOG=""
|
FRONTEND_LOG=""
|
||||||
NEEDS_SSH_TUNNEL=false
|
NEEDS_SSH_TUNNEL=false
|
||||||
|
BACKEND_RESTART_PID=""
|
||||||
|
|
||||||
configure_environment() {
|
configure_environment() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -121,20 +122,23 @@ check_port() {
|
|||||||
cleanup() {
|
cleanup() {
|
||||||
print_message "Stopping all services..."
|
print_message "Stopping all services..."
|
||||||
|
|
||||||
# Stop run-with-restart.sh wrapper FIRST (prevents auto-restart)
|
# Stop run-with-restart.sh wrapper FIRST by saved PID (prevents auto-restart race)
|
||||||
print_message "Stopping backend restart wrapper..."
|
if [ -n "$BACKEND_RESTART_PID" ] && kill -0 "$BACKEND_RESTART_PID" 2>/dev/null; then
|
||||||
pkill -f "run-with-restart.sh" 2>/dev/null || true
|
print_message "Stopping backend restart wrapper (PID $BACKEND_RESTART_PID)..."
|
||||||
sleep 1
|
kill "$BACKEND_RESTART_PID" 2>/dev/null || true
|
||||||
|
|
||||||
# Stop Unified Backend (8000)
|
|
||||||
if check_port 8000; then
|
|
||||||
print_message "Stopping Unified Backend..."
|
|
||||||
pkill -f "uvicorn main:app" 2>/dev/null || true
|
|
||||||
sleep 2
|
sleep 2
|
||||||
pkill -9 -f "uvicorn main:app" 2>/dev/null || true
|
|
||||||
lsof -ti:8000 | xargs kill -KILL 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Kill ALL uvicorn instances unconditionally (regardless of port status)
|
||||||
|
# This handles cases where uvicorn is still starting up or has crashed
|
||||||
|
print_message "Stopping Unified Backend..."
|
||||||
|
pkill -f "uvicorn main:app" 2>/dev/null || true
|
||||||
|
pkill -f "run-with-restart.sh" 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
pkill -9 -f "uvicorn main:app" 2>/dev/null || true
|
||||||
|
pkill -9 -f "run-with-restart.sh" 2>/dev/null || true
|
||||||
|
lsof -ti:8000 | xargs kill -KILL 2>/dev/null || true
|
||||||
|
|
||||||
# Stop Unified Frontend (3000)
|
# Stop Unified Frontend (3000)
|
||||||
if check_port 3000; then
|
if check_port 3000; then
|
||||||
print_message "Stopping Unified Frontend..."
|
print_message "Stopping Unified Frontend..."
|
||||||
@@ -240,6 +244,7 @@ start_services() {
|
|||||||
# Start backend with auto-restart on crash (OOM protection)
|
# Start backend with auto-restart on crash (OOM protection)
|
||||||
print_message "Starting unified backend with auto-restart..."
|
print_message "Starting unified backend with auto-restart..."
|
||||||
nohup ./run-with-restart.sh 8000 "$BACKEND_LOG" > /dev/null 2>&1 &
|
nohup ./run-with-restart.sh 8000 "$BACKEND_LOG" > /dev/null 2>&1 &
|
||||||
|
BACKEND_RESTART_PID=$!
|
||||||
|
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user