diff --git a/reports-app/backend/app/models/treasury.py b/reports-app/backend/app/models/treasury.py index fc3d199..c1e8b2e 100644 --- a/reports-app/backend/app/models/treasury.py +++ b/reports-app/backend/app/models/treasury.py @@ -3,25 +3,32 @@ from decimal import Decimal from datetime import datetime from typing import Optional, List +class AccountingPeriod(BaseModel): + """Model pentru perioada contabilă""" + an: Optional[int] = None + luna: Optional[int] = None + class BankCashRegister(BaseModel): """Model pentru Registrul de Casă și Bancă""" nume: str - nract: int - dataact: datetime + nract: Optional[int] = None + dataact: Optional[datetime] = None nume_cont_bancar: str # din vbalanta_parteneri.nume incasari: Decimal plati: Decimal sold: Decimal - valuta: str + valuta: Optional[str] = None tip_registru: str # "BANCA LEI", "CASA VALUTA" etc explicatia: str class RegisterFilter(BaseModel): """Filtre pentru registrul de casă și bancă""" company: str + register_type: Optional[str] = None # BANCA_LEI, BANCA_VALUTA, CASA_LEI, CASA_VALUTA sau None pentru toate date_from: Optional[datetime] = None date_to: Optional[datetime] = None partner_name: Optional[str] = None + bank_account: Optional[str] = None # Filter for specific bank/cash account (bancasa) page: int = 1 page_size: int = 50 @@ -34,4 +41,5 @@ class RegisterListResponse(BaseModel): total_plati: Decimal page: int page_size: int - has_more: bool \ No newline at end of file + has_more: bool + accounting_period: Optional[AccountingPeriod] = None \ No newline at end of file diff --git a/reports-app/backend/app/routers/treasury.py b/reports-app/backend/app/routers/treasury.py index 6f3d55a..97913fb 100644 --- a/reports-app/backend/app/routers/treasury.py +++ b/reports-app/backend/app/routers/treasury.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, Query -from typing import Optional +from typing import Optional, List from datetime import date import sys import os @@ -15,53 +15,100 @@ router = APIRouter() @router.get("/bank-cash-register", response_model=RegisterListResponse) async def get_bank_cash_register( company: str = Query(description="Codul firmei"), + register_type: Optional[str] = Query(None, description="Tipul registrului: BANCA_LEI, BANCA_VALUTA, CASA_LEI, CASA_VALUTA sau None pentru toate"), date_from: Optional[str] = Query(None, description="Data început (YYYY-MM-DD)"), date_to: Optional[str] = Query(None, description="Data sfârșit (YYYY-MM-DD)"), partner_name: Optional[str] = Query(None, description="Filtru nume partener"), + bank_account: Optional[str] = Query(None, description="Filtru cont bancă/casă (bancasa)"), page: int = Query(1, ge=1, description="Pagina"), page_size: int = Query(50, ge=1, le=10000000, description="Mărimea paginii"), current_user: CurrentUser = Depends(get_current_user) ): """ Obține registrul de casă și bancă - + - Necesită autentificare JWT + - Suportă filtrare pe tip registru: BANCA_LEI, BANCA_VALUTA, CASA_LEI, CASA_VALUTA - Suportă filtrare și paginare """ try: # Verifică dacă utilizatorul are acces la firma specificată if company not in current_user.companies: raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}") - + + # Validează register_type dacă e specificat + valid_types = ['BANCA_LEI', 'BANCA_VALUTA', 'CASA_LEI', 'CASA_VALUTA'] + if register_type and register_type not in valid_types: + raise HTTPException( + status_code=400, + detail=f"Tip registru invalid. Valori acceptate: {', '.join(valid_types)}" + ) + # Convertește datele date_from_obj = None date_to_obj = None - + if date_from: try: date_from_obj = date.fromisoformat(date_from) except ValueError: raise HTTPException(status_code=400, detail="Format dată început invalid") - + if date_to: try: date_to_obj = date.fromisoformat(date_to) except ValueError: raise HTTPException(status_code=400, detail="Format dată sfârșit invalid") - + filter_params = RegisterFilter( company=company, + register_type=register_type, date_from=date_from_obj, date_to=date_to_obj, partner_name=partner_name, + bank_account=bank_account, page=page, page_size=page_size ) - + result = await TreasuryService.get_bank_cash_register(filter_params, current_user.username) return result - + except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: - raise HTTPException(status_code=500, detail=f"Eroare la obținerea registrului: {str(e)}") \ No newline at end of file + raise HTTPException(status_code=500, detail=f"Eroare la obținerea registrului: {str(e)}") + + +@router.get("/bank-cash-accounts", response_model=List[str]) +async def get_bank_cash_accounts( + company: str = Query(description="Codul firmei"), + register_type: str = Query(description="Tipul registrului: BANCA_LEI, BANCA_VALUTA, CASA_LEI, CASA_VALUTA"), + current_user: CurrentUser = Depends(get_current_user) +): + """ + Obține lista distinctă de conturi bancă/casă pentru dropdown + + - Necesită autentificare JWT + - Returnează lista de valori bancasa pentru tipul de registru selectat + """ + try: + # Verifică dacă utilizatorul are acces la firma specificată + if company not in current_user.companies: + raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}") + + # Validează register_type + valid_types = ['BANCA_LEI', 'BANCA_VALUTA', 'CASA_LEI', 'CASA_VALUTA'] + if register_type not in valid_types: + raise HTTPException( + status_code=400, + detail=f"Tip registru invalid. Valori acceptate: {', '.join(valid_types)}" + ) + + result = await TreasuryService.get_bank_cash_accounts(int(company), register_type) + return result + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Eroare la obținerea conturilor: {str(e)}") \ No newline at end of file diff --git a/reports-app/backend/app/services/treasury_service.py b/reports-app/backend/app/services/treasury_service.py index 76c015c..8c3f6b4 100644 --- a/reports-app/backend/app/services/treasury_service.py +++ b/reports-app/backend/app/services/treasury_service.py @@ -2,10 +2,12 @@ import sys import os sys.path.append(os.path.join(os.path.dirname(__file__), '../../../../shared')) +import oracledb from database.oracle_pool import oracle_pool -from ..models.treasury import BankCashRegister, RegisterFilter, RegisterListResponse +from ..models.treasury import BankCashRegister, RegisterFilter, RegisterListResponse, AccountingPeriod from ..cache.decorators import cached from decimal import Decimal +from typing import Optional, List, Tuple, Any import logging logger = logging.getLogger(__name__) @@ -32,137 +34,229 @@ class TreasuryService: return schema_result[0] + @staticmethod + def _get_view_query(schema: str, register_type: Optional[str] = None) -> str: + """ + Construiește query-ul pentru view-ul vbancasa corespunzător. + Dacă register_type este None, returnează UNION ALL pentru toate tipurile. + NU se filtrează pe incasari/plati > 0 - se afișează TOATE înregistrările! + """ + view_configs = { + 'BANCA_LEI': { + 'view': f'{schema}.vbancasa_5121_cum', + 'incasari_col': 'incasari', + 'plati_col': 'plati', + 'valuta': "'RON'", + 'tip': "'BANCA LEI'" + }, + 'BANCA_VALUTA': { + 'view': f'{schema}.vbancasa_5124_cum', + 'incasari_col': 'incasval', + 'plati_col': 'platival', + 'valuta': "COALESCE(numeval, 'EUR')", + 'tip': "'BANCA VALUTA'" + }, + 'CASA_LEI': { + 'view': f'{schema}.vbancasa_5311_cum', + 'incasari_col': 'incasari', + 'plati_col': 'plati', + 'valuta': "'RON'", + 'tip': "'CASA LEI'" + }, + 'CASA_VALUTA': { + 'view': f'{schema}.vbancasa_5314_cum', + 'incasari_col': 'incasval', + 'plati_col': 'platival', + 'valuta': "COALESCE(numeval, 'EUR')", + 'tip': "'CASA VALUTA'" + } + } + + def build_select(config): + # NU se filtrează - se afișează TOATE înregistrările + # SOLD CUMULAT: Running balance per bancasa using window function + # NULL-date rows (opening balance) come first due to NULLS FIRST + return f""" + SELECT + nume, nract, dataact, bancasa, + {config['incasari_col']} as incasari, + {config['plati_col']} as plati, + SUM({config['incasari_col']} - {config['plati_col']}) OVER ( + PARTITION BY bancasa + ORDER BY dataact ASC NULLS FIRST, nract ASC NULLS FIRST + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) as sold, + {config['valuta']} as valuta, + {config['tip']} as tip_registru, + explicatia + FROM {config['view']} + """ + + if register_type and register_type in view_configs: + return build_select(view_configs[register_type]) + else: + # UNION ALL pentru toate tipurile + queries = [build_select(cfg) for cfg in view_configs.values()] + return " UNION ALL ".join(queries) + @staticmethod @cached(cache_type='treasury', key_params=['filter_params', 'username']) async def get_bank_cash_register(filter_params: RegisterFilter, username: str) -> RegisterListResponse: """ Obține registrul de casă și bancă din vbancasa views (CACHED 10 min) + + IMPORTANT: PACK_SESIUNE.SETAN și SETLUNA trebuie executate în ACEEAȘI + tranzacție cu SELECT-ul din vbancasa* views! + + Folosim un bloc PL/SQL anonim care: + 1. Obține anul și luna curentă din calendar + 2. Apelează PACK_SESIUNE.SETAN și SETLUNA + 3. Execută SELECT-ul din vbancasa* + Toate în aceeași tranzacție! """ company_id = int(filter_params.company) schema = await TreasuryService._get_schema(company_id) async with oracle_pool.get_connection() as connection: with connection.cursor() as cursor: - - # Query pentru registrele de bancă și casă - union_queries = [] - - # BANCA LEI (5121) - union_queries.append(f""" - SELECT - vb.nume, vb.nract, vb.dataact, vb.bancasa, - vb.incasari, vb.plati, - vb.incasari - vb.plati as sold, - 'RON' as valuta, - 'BANCA LEI' as tip_registru, - vb.explicatia - FROM {schema}.vbancasa_5121_cum vb - WHERE (vb.incasari > 0 OR vb.plati > 0) - """) - - # BANCA VALUTA (5124) - union_queries.append(f""" - SELECT - vb.nume, vb.nract, vb.dataact, vb.bancasa, - vb.incasval, vb.platival, - vb.incasval - vb.platival as sold, - COALESCE(vb.numeval, 'EUR') as valuta, - 'BANCA VALUTA' as tip_registru, - vb.explicatia - FROM {schema}.vbancasa_5124_cum vb - WHERE (vb.incasval > 0 OR vb.platival > 0) - """) - - # CASA LEI (5311) - union_queries.append(f""" - SELECT - vb.nume, vb.nract, vb.dataact, vb.bancasa, - vb.incasari, vb.plati, - vb.incasari - vb.plati as sold, - 'RON' as valuta, - 'CASA LEI' as tip_registru, - vb.explicatia - FROM {schema}.vbancasa_5311_cum vb - WHERE (vb.incasari > 0 OR vb.plati > 0) - """) - - # CASA VALUTA (5314) - union_queries.append(f""" - SELECT - vb.nume, vb.nract, vb.dataact, vb.bancasa, - vb.incasval, vb.platival, - vb.incasval - vb.platival as sold, - COALESCE(vb.numeval, 'EUR') as valuta, - 'CASA VALUTA' as tip_registru, - vb.explicatia - FROM {schema}.vbancasa_5314_cum vb - WHERE (vb.incasval > 0 OR vb.platival > 0) - """) - - base_query = " UNION ALL ".join(union_queries) - - params = {} + + # Construiește query-ul pentru tipul de registru selectat + base_select = TreasuryService._get_view_query(schema, filter_params.register_type) + + # Construiește WHERE conditions where_conditions = [] - - if filter_params.date_from: - where_conditions.append("dataact >= :date_from") - params['date_from'] = filter_params.date_from - - if filter_params.date_to: - where_conditions.append("dataact <= :date_to") - params['date_to'] = filter_params.date_to - + + # Date filter preserves NULL-date rows (opening balance) + # for correct cumulative sum calculation + if filter_params.date_from and filter_params.date_to: + where_conditions.append(f"(dataact IS NULL OR (dataact >= TO_DATE('{filter_params.date_from.strftime('%Y-%m-%d')}', 'YYYY-MM-DD') AND dataact <= TO_DATE('{filter_params.date_to.strftime('%Y-%m-%d')}', 'YYYY-MM-DD')))") + elif filter_params.date_from: + where_conditions.append(f"(dataact IS NULL OR dataact >= TO_DATE('{filter_params.date_from.strftime('%Y-%m-%d')}', 'YYYY-MM-DD'))") + elif filter_params.date_to: + where_conditions.append(f"(dataact IS NULL OR dataact <= TO_DATE('{filter_params.date_to.strftime('%Y-%m-%d')}', 'YYYY-MM-DD'))") + if filter_params.partner_name: - where_conditions.append("UPPER(nume) LIKE UPPER(:partner_name)") - params['partner_name'] = f"%{filter_params.partner_name}%" - + # Escape single quotes pentru SQL + partner_escaped = filter_params.partner_name.replace("'", "''") + where_conditions.append(f"UPPER(nume) LIKE UPPER('%{partner_escaped}%')") + + if filter_params.bank_account: + # Escape single quotes pentru SQL + bank_escaped = filter_params.bank_account.replace("'", "''") + where_conditions.append(f"bancasa = '{bank_escaped}'") + + where_clause = "" if where_conditions: - base_query = f"SELECT * FROM ({base_query}) WHERE {' AND '.join(where_conditions)}" - - # Count pentru paginare - count_query = f"SELECT COUNT(*) FROM ({base_query})" - cursor.execute(count_query, params) - total_count = cursor.fetchone()[0] - - # Query cu paginare - base_query += " ORDER BY dataact DESC, nract" - + where_clause = " WHERE " + " AND ".join(where_conditions) + + # Paginare Oracle offset = (filter_params.page - 1) * filter_params.page_size - limit = offset + filter_params.page_size - paginated_query = f""" - SELECT * FROM ( - SELECT ROWNUM as rn, t.* FROM ({base_query}) t WHERE ROWNUM <= :limit - ) WHERE rn > :offset + limit_val = filter_params.page_size + + # Bloc PL/SQL anonim care face totul într-o singură tranzacție: + # 1. Obține anul și luna din calendar + # 2. Setează PACK_SESIUNE.SETAN și SETLUNA + # 3. Returnează datele prin REF CURSOR + # IMPORTANT: Folosim ROW_NUMBER() pentru paginare corectă cu ORDER BY NULLS FIRST + plsql_block = f""" + DECLARE + v_an NUMBER; + v_luna NUMBER; + v_cursor SYS_REFCURSOR; + BEGIN + -- Obține anul și luna curentă din calendar + SELECT anul, luna INTO v_an, v_luna + FROM {schema}.calendar + WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar); + + -- Setează contextul de sesiune (OBLIGATORIU înainte de SELECT din vbancasa*) + {schema}.PACK_SESIUNE.SETAN(v_an); + {schema}.PACK_SESIUNE.SETLUNA(v_luna); + + -- Return accounting period + :out_an := v_an; + :out_luna := v_luna; + + -- Returnează datele prin cursor cu ROW_NUMBER pentru paginare corectă + -- Pentru rânduri cu dataact=NULL (solduri precedente), sortare după bancasa + -- Pentru rânduri cu date, sortare după data, număr, bancasa + OPEN :result_cursor FOR + SELECT * FROM ( + SELECT t.*, ROW_NUMBER() OVER ( + ORDER BY dataact ASC NULLS FIRST, + CASE WHEN dataact IS NULL THEN bancasa END ASC, + nract ASC NULLS FIRST, + bancasa ASC + ) as rn + FROM ({base_select}) t{where_clause} + ) WHERE rn > {offset} AND rn <= {offset + limit_val}; + END; """ - params['offset'] = offset - params['limit'] = limit - - cursor.execute(paginated_query, params) - rows = cursor.fetchall() - + + # Creează cursor pentru rezultate (oracledb.CURSOR pentru REF CURSOR) + result_cursor = cursor.var(oracledb.CURSOR) + out_an = cursor.var(int) + out_luna = cursor.var(int) + + # Execută blocul PL/SQL cu REF CURSOR + cursor.execute(plsql_block, {'result_cursor': result_cursor, 'out_an': out_an, 'out_luna': out_luna}) + + # Get accounting period values + accounting_year = out_an.getvalue() + accounting_month = out_luna.getvalue() + + # Obține rezultatele din cursor + ref_cursor = result_cursor.getvalue() + rows = ref_cursor.fetchall() + ref_cursor.close() + + # Pentru count total, executăm alt bloc PL/SQL + count_plsql = f""" + DECLARE + v_an NUMBER; + v_luna NUMBER; + BEGIN + SELECT anul, luna INTO v_an, v_luna + FROM {schema}.calendar + WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar); + + {schema}.PACK_SESIUNE.SETAN(v_an); + {schema}.PACK_SESIUNE.SETLUNA(v_luna); + + SELECT COUNT(*) INTO :total_count FROM ({base_select}) sub{where_clause}; + END; + """ + + total_count_var = cursor.var(int) + cursor.execute(count_plsql, {'total_count': total_count_var}) + total_count = total_count_var.getvalue() + # Procesare rezultate registers = [] total_incasari = Decimal('0.00') total_plati = Decimal('0.00') - + for row in rows: - # Skip ROWNUM + # Coloane: nume, nract, dataact, bancasa, incasari, plati, sold, valuta, tip_registru, explicatia, rn + # row[0-9] = date, row[10] = rn (ROW_NUMBER la final) register_data = BankCashRegister( - nume=row[1] or '', - nract=row[2] or 0, - dataact=row[3], - nume_cont_bancar=row[4] or '', - incasari=Decimal(str(row[5] or 0)), - plati=Decimal(str(row[6] or 0)), - sold=Decimal(str(row[7] or 0)), - valuta=row[8], - tip_registru=row[9], - explicatia=row[10] or '' + nume=row[0] or '', + nract=row[1], + dataact=row[2], + nume_cont_bancar=row[3] or '', + incasari=Decimal(str(row[4] or 0)), + plati=Decimal(str(row[5] or 0)), + sold=Decimal(str(row[6] or 0)), + valuta=row[7], + tip_registru=row[8], + explicatia=row[9] or '' ) registers.append(register_data) total_incasari += register_data.incasari total_plati += register_data.plati - + + logger.info(f"Treasury query for company {company_id}, type={filter_params.register_type}: {len(registers)} records, total={total_count}") + return RegisterListResponse( registers=registers, total_count=total_count, @@ -171,5 +265,66 @@ class TreasuryService: total_plati=total_plati, page=filter_params.page, page_size=filter_params.page_size, - has_more=len(registers) == filter_params.page_size - ) \ No newline at end of file + has_more=len(registers) == filter_params.page_size, + accounting_period=AccountingPeriod(an=accounting_year, luna=accounting_month) + ) + + @staticmethod + @cached(cache_type='treasury', key_params=['company_id', 'register_type']) + async def get_bank_cash_accounts(company_id: int, register_type: str) -> List[str]: + """ + Obține lista distinctă de conturi bancă/casă (bancasa) pentru dropdown. + Cached pentru performanță. + IMPORTANT: Trebuie să setăm contextul PACK_SESIUNE înainte de a accesa vbancasa views! + """ + schema = await TreasuryService._get_schema(company_id) + + # Map register_type to view + view_map = { + 'BANCA_LEI': f'{schema}.vbancasa_5121_cum', + 'BANCA_VALUTA': f'{schema}.vbancasa_5124_cum', + 'CASA_LEI': f'{schema}.vbancasa_5311_cum', + 'CASA_VALUTA': f'{schema}.vbancasa_5314_cum' + } + + if register_type not in view_map: + return [] + + view_name = view_map[register_type] + + async with oracle_pool.get_connection() as connection: + with connection.cursor() as cursor: + # PL/SQL block to set session context and get accounts + plsql_block = f""" + DECLARE + v_an NUMBER; + v_luna NUMBER; + BEGIN + -- Get current year and month from calendar + SELECT anul, luna INTO v_an, v_luna + FROM {schema}.calendar + WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar); + + -- Set session context (REQUIRED before accessing vbancasa* views) + {schema}.PACK_SESIUNE.SETAN(v_an); + {schema}.PACK_SESIUNE.SETLUNA(v_luna); + + -- Return accounts via cursor + OPEN :result_cursor FOR + SELECT DISTINCT bancasa + FROM {view_name} + WHERE bancasa IS NOT NULL + ORDER BY bancasa; + END; + """ + + result_cursor = cursor.var(oracledb.CURSOR) + cursor.execute(plsql_block, {'result_cursor': result_cursor}) + + ref_cursor = result_cursor.getvalue() + rows = ref_cursor.fetchall() + ref_cursor.close() + + accounts = [row[0] for row in rows if row[0]] + logger.info(f"Found {len(accounts)} bank/cash accounts for company {company_id}, type={register_type}") + return accounts diff --git a/reports-app/frontend/src/stores/treasury.js b/reports-app/frontend/src/stores/treasury.js index cee72d4..e1b9be7 100644 --- a/reports-app/frontend/src/stores/treasury.js +++ b/reports-app/frontend/src/stores/treasury.js @@ -15,6 +15,7 @@ export const useTreasuryStore = defineStore("treasury", () => { total_incasari: 0, total_plati: 0, }); + const accountingPeriod = ref({ an: null, luna: null }); const loadBankCashRegister = async (companyId, filters = {}) => { isLoading.value = true; @@ -39,6 +40,11 @@ export const useTreasuryStore = defineStore("treasury", () => { total_plati: response.data.total_plati, }; + // Store accounting period if available + if (response.data.accounting_period) { + accountingPeriod.value = response.data.accounting_period; + } + return { success: true }; } catch (err) { error.value = err.response?.data?.detail || "Failed to load register"; @@ -57,6 +63,7 @@ export const useTreasuryStore = defineStore("treasury", () => { registers.value = []; isLoading.value = false; error.value = null; + accountingPeriod.value = { an: null, luna: null }; pagination.value = { page: 0, rows: 50, @@ -70,6 +77,7 @@ export const useTreasuryStore = defineStore("treasury", () => { error, pagination, totals, + accountingPeriod, loadBankCashRegister, setPagination, reset, diff --git a/reports-app/frontend/src/utils/exportUtils.js b/reports-app/frontend/src/utils/exportUtils.js index 03f5ea2..e32480b 100644 --- a/reports-app/frontend/src/utils/exportUtils.js +++ b/reports-app/frontend/src/utils/exportUtils.js @@ -55,7 +55,7 @@ const formatNumberForPDF = (value) => { * @param {Array} data - Array of objects to export * @param {Array} columns - Column definitions [{field: 'key', header: 'Display Name', type: 'text|number|currency', width: 30}] * @param {String} filename - Name of the file (without extension) - * @param {Object} header - Header configuration {companyName: '', title: '', period: ''} + * @param {Object} header - Header configuration {companyName: '', title: '', period: '', subtitle2: '', initialBalances: [], totalInitialBalance: 0} */ export const exportToPDF = (data, columns, filename, header) => { try { @@ -78,6 +78,9 @@ export const exportToPDF = (data, columns, filename, header) => { const marginRight = 8; const contentWidth = pageWidth - marginLeft - marginRight; + // Check if there are initial balances to display + const hasInitialBalances = header.initialBalances && header.initialBalances.length > 0; + // Function to add header (called for each page) const addHeader = () => { // Line 1: Company name (left aligned, bold, larger font) @@ -100,12 +103,31 @@ export const exportToPDF = (data, columns, filename, header) => { const periodWidth = doc.getTextWidth(periodText); const periodX = marginLeft + (contentWidth - periodWidth) / 2; doc.text(periodText, periodX, 32); + + // Line 4: Subtitle2 - filters (left aligned, below period) - optional + let currentY = 32; + if (header.subtitle2) { + currentY = 39; + doc.setFontSize(10); + doc.setFont(undefined, "normal"); + doc.text(header.subtitle2, marginLeft, currentY); + } + + // Initial Balances section - rendered just before table, closer to it + // This is handled in didDrawPage for first page only }; - // Prepare table data + // Prepare table data and track total rows const tableColumns = columns.map((col) => col.header); - const tableRows = data.map((row) => - columns.map((col) => { + const totalRowIndices = new Set(); // Track which rows are totals + + const tableRows = data.map((row, rowIndex) => { + // Track total rows for special styling + if (row._isTotal) { + totalRowIndices.add(rowIndex); + } + + return columns.map((col) => { const value = row[col.field]; if (col.type === "currency") { return formatCurrency(value); @@ -113,8 +135,8 @@ export const exportToPDF = (data, columns, filename, header) => { return formatNumberForPDF(value); } return value || "-"; - }), - ); + }); + }); // Function to add footer (called for each page) const addFooter = (pageNum, totalPages) => { @@ -183,7 +205,64 @@ export const exportToPDF = (data, columns, filename, header) => { } }); - const tableStartY = 36; + // Start table lower based on header content + let tableStartY = 36; + if (header.subtitle2) tableStartY = 43; + if (hasInitialBalances) { + // Initial balances rendered close to table (just 3mm above table header) + const balancesCount = header.initialBalances.length; + const hasTotal = balancesCount > 1; + const balancesHeight = (balancesCount * 5) + (hasTotal ? 7 : 0); + // Base position after header content + const baseY = header.subtitle2 ? 43 : 36; + tableStartY = baseY + balancesHeight + 5; // balances + small gap before table + } + + // Function to draw initial balances (called only on first page) + const drawInitialBalances = (tableY) => { + if (!hasInitialBalances) return; + + const valueRightEdge = pageWidth - marginRight; + const balancesCount = header.initialBalances.length; + const hasTotal = balancesCount > 1; + const balancesHeight = (balancesCount * 5) + (hasTotal ? 7 : 0); + + // Start position: just above table header (3mm gap) + let y = tableY - 3 - (hasTotal ? 7 : 0) - (balancesCount * 5); + + doc.setFont(undefined, "normal"); + doc.setFontSize(9); + + // Draw each balance line: "AccountName sold precedent: VALUE" + header.initialBalances.forEach((item) => { + const value = formatNumberForPDF(item.sold); + const valueWidth = doc.getTextWidth(value); + const label = `${item.accountName} sold precedent:`; + + doc.text(label, valueRightEdge - valueWidth - doc.getTextWidth(" sold precedent:") - doc.getTextWidth(item.accountName) - 2, y); + doc.text(value, valueRightEdge - valueWidth, y); + y += 5; + }); + + // Total only if multiple accounts + if (hasTotal) { + // Separator line + doc.setDrawColor(150, 150, 150); + doc.line(valueRightEdge - 40, y - 2, valueRightEdge, y - 2); + + // Total line + doc.setFont(undefined, "bold"); + const totalValue = formatNumberForPDF(header.totalInitialBalance || 0); + const totalValueWidth = doc.getTextWidth(totalValue); + const totalLabel = "TOTAL sold precedent:"; + const totalLabelWidth = doc.getTextWidth(totalLabel); + + doc.text(totalLabel, valueRightEdge - totalValueWidth - 3 - totalLabelWidth, y + 2); + doc.text(totalValue, valueRightEdge - totalValueWidth, y + 2); + } + }; + + let isFirstPage = true; // Add table using autoTable (call as function, not method) autoTable(doc, { @@ -218,16 +297,28 @@ export const exportToPDF = (data, columns, filename, header) => { }, tableWidth: pageWidth - marginLeft - marginRight, // Use full page width theme: "grid", - didDrawPage: function (data) { + didDrawPage: function () { // Add header to each page addHeader(); + // Draw initial balances only on first page + if (isFirstPage && hasInitialBalances) { + drawInitialBalances(tableStartY); + isFirstPage = false; + } }, didParseCell: function (data) { // Force alignment based on column type (body cells only) if (data.section === "body") { + const rowIndex = data.row.index; const colIndex = data.column.index; const column = columns[colIndex]; + // Style total rows differently (bold, light gray background) + if (totalRowIndices.has(rowIndex)) { + data.cell.styles.fontStyle = "bold"; + data.cell.styles.fillColor = [230, 230, 230]; // Light gray + } + if (column) { if (column.type === "number" || column.type === "currency") { data.cell.styles.halign = "right"; @@ -351,6 +442,375 @@ export const exportSoldNetBreakdown = (summaryData) => { return data; }; +/** + * Export Bank Cash Register to PDF with grouped format + * Matches the Romanian standard format with: + * - Bank name + Sold precedent on same line + * - Daily totals (Total zi) + * - Cumulative totals (Total cumulat) + * + * @param {Array} data - Array of register entries + * @param {Object} header - Header configuration + * @param {String} filename - Output filename + */ +export const exportBankCashRegisterPDF = (data, header, filename) => { + try { + if (!data || data.length === 0) { + console.error("No data to export"); + return { success: false, error: "No data available" }; + } + + const doc = new jsPDF("landscape", "mm", "a4"); + const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); + const marginLeft = 8; + const marginRight = 8; + const contentWidth = pageWidth - marginLeft - marginRight; + + // Remove diacritics helper + const removeDiacritics = (text) => { + if (!text) return ""; + return text + .replace(/[ăâ]/gi, (m) => (m === m.toLowerCase() ? "a" : "A")) + .replace(/[î]/gi, (m) => (m === m.toLowerCase() ? "i" : "I")) + .replace(/[ș]/gi, (m) => (m === m.toLowerCase() ? "s" : "S")) + .replace(/[ț]/gi, (m) => (m === m.toLowerCase() ? "t" : "T")); + }; + + // Group data by bank account (bancasa) + const groupedByBank = {}; + const initialBalances = {}; + + data.forEach((row) => { + const bankName = row.nume_cont_bancar || "Necunoscut"; + if (!groupedByBank[bankName]) { + groupedByBank[bankName] = []; + initialBalances[bankName] = 0; + } + + if (!row.dataact) { + // Initial balance row (null date) - sold precedent + initialBalances[bankName] = parseFloat(row.sold) || 0; + } else { + // Transaction row with date + groupedByBank[bankName].push(row); + } + }); + + // Table columns definition + const tableColumns = [ + "Data act", + "Nr.act", + "Explicatia", + "Incasari", + "Plati", + "Sold", + ]; + + const columnWidths = { + 0: contentWidth * 0.10, // Data act + 1: contentWidth * 0.08, // Nr.act + 2: contentWidth * 0.42, // Explicatia + 3: contentWidth * 0.13, // Incasari + 4: contentWidth * 0.13, // Plati + 5: contentWidth * 0.14, // Sold + }; + + const columnStyles = {}; + Object.keys(columnWidths).forEach((idx) => { + columnStyles[idx] = { cellWidth: columnWidths[idx] }; + if (idx >= 3) { + columnStyles[idx].halign = "right"; + } + }); + + let currentY = 15; + let pageNum = 1; + + // Function to add page header + const addPageHeader = () => { + // Company name (left) + doc.setFontSize(12); + doc.setFont(undefined, "bold"); + doc.text(removeDiacritics(header.companyName || ""), marginLeft, 12); + + // Luna: MM / YYYY (right) + doc.setFontSize(10); + doc.setFont(undefined, "normal"); + const lunaText = `Luna: ${header.luna || ""} / ${header.an || ""}`; + const lunaWidth = doc.getTextWidth(lunaText); + doc.text(lunaText, pageWidth - marginRight - lunaWidth, 12); + + // Title centered + doc.setFontSize(13); + doc.setFont(undefined, "bold"); + const titleWidth = doc.getTextWidth(header.title || ""); + doc.text(header.title || "", marginLeft + (contentWidth - titleWidth) / 2, 20); + }; + + // Function to check if we need a new page (for tables spanning multiple pages within a bank) + const checkNewPage = (neededHeight = 20) => { + if (currentY + neededHeight > pageHeight - 15) { + doc.addPage(); + pageNum++; + addPageHeader(); + currentY = 28; + return true; + } + return false; + }; + + // Process each bank account - each on a new page (sorted alphabetically) + const bankNames = Object.keys(groupedByBank).sort((a, b) => a.localeCompare(b, 'ro')); + + bankNames.forEach((bankName, bankIndex) => { + const bankRows = groupedByBank[bankName]; + const soldPrecedent = initialBalances[bankName] || 0; + + // Start each bank/casa on a new page (except first one which is already on page 1) + if (bankIndex > 0) { + doc.addPage(); + pageNum++; + } + + // Add full page header (company, title, luna/an) + addPageHeader(); + currentY = 28; + + // Bank/Casa header: "Banca: NAME" (left) + "Sold precedent: VALUE" (right) + doc.setFontSize(10); + doc.setFont(undefined, "bold"); + const bankLabel = header.isBanca ? "Banca:" : "Casa:"; + const bankHeaderText = `${bankLabel} ${removeDiacritics(bankName)}`; + doc.text(bankHeaderText, marginLeft, currentY); + + const soldPrecedentText = `Sold precedent: ${formatNumberForPDF(soldPrecedent)}`; + const soldPrecedentWidth = doc.getTextWidth(soldPrecedentText); + doc.text(soldPrecedentText, pageWidth - marginRight - soldPrecedentWidth, currentY); + + currentY += 6; + + // Handle case when there are no transactions (only initial balance) + if (bankRows.length === 0) { + // Draw empty table with header only + autoTable(doc, { + head: [tableColumns], + body: [], + startY: currentY, + styles: { + fontSize: 8, + cellPadding: 1.5, + lineColor: [200, 200, 200], + lineWidth: 0.1, + }, + headStyles: { + fillColor: [41, 128, 185], + textColor: 255, + fontStyle: "bold", + halign: "center", + fontSize: 8, + }, + columnStyles: columnStyles, + margin: { left: marginLeft, right: marginRight }, + tableWidth: contentWidth, + theme: "grid", + }); + + currentY = doc.lastAutoTable.finalY; + + // Show total with sold precedent (no transactions) + const totalRows = [ + [ + "", + "", + "Total:", + formatNumberForPDF(0), + formatNumberForPDF(0), + formatNumberForPDF(soldPrecedent), + ], + ]; + + const totalsStartY = currentY; + + autoTable(doc, { + body: totalRows, + startY: currentY, + styles: { + fontSize: 8, + cellPadding: 1.5, + fontStyle: "bold", + lineWidth: 0, + }, + columnStyles: columnStyles, + margin: { left: marginLeft, right: marginRight }, + tableWidth: contentWidth, + theme: "plain", + }); + + // Draw outer border for totals box + const totalsEndY = doc.lastAutoTable.finalY; + doc.setDrawColor(200, 200, 200); + doc.setLineWidth(0.1); + doc.rect(marginLeft, totalsStartY, contentWidth, totalsEndY - totalsStartY); + + currentY = doc.lastAutoTable.finalY + 3; + } else { + // Group bank rows by date + const groupedByDate = {}; + bankRows.forEach((row) => { + const dateKey = row.dataact; + if (!groupedByDate[dateKey]) { + groupedByDate[dateKey] = []; + } + groupedByDate[dateKey].push(row); + }); + + // Cumulative totals for the bank + let cumulativeIncasari = 0; + let cumulativePlati = 0; + let lastSold = soldPrecedent; + + const dates = Object.keys(groupedByDate).sort(); + + dates.forEach((dateKey, dateIndex) => { + const dateRows = groupedByDate[dateKey]; + const dateFormatted = dateKey + ? new Date(dateKey).toLocaleDateString("ro-RO") + : ""; + + checkNewPage(30); + + // Prepare rows for this date + const tableRows = []; + let dailyIncasari = 0; + let dailyPlati = 0; + + dateRows.forEach((row) => { + const incasari = parseFloat(row.incasari) || 0; + const plati = parseFloat(row.plati) || 0; + + dailyIncasari += incasari; + dailyPlati += plati; + lastSold = parseFloat(row.sold) || lastSold; + + tableRows.push([ + dateFormatted, + row.nract || "", + removeDiacritics(row.explicatia || row.nume || ""), + formatNumberForPDF(incasari), + formatNumberForPDF(plati), + formatNumberForPDF(row.sold), + ]); + }); + + cumulativeIncasari += dailyIncasari; + cumulativePlati += dailyPlati; + + // Draw table for this date group + autoTable(doc, { + head: dateIndex === 0 ? [tableColumns] : [], + body: tableRows, + startY: currentY, + styles: { + fontSize: 8, + cellPadding: 1.5, + lineColor: [200, 200, 200], + lineWidth: 0.1, + overflow: "linebreak", + }, + headStyles: { + fillColor: [41, 128, 185], + textColor: 255, + fontStyle: "bold", + halign: "center", + fontSize: 8, + }, + columnStyles: columnStyles, + margin: { left: marginLeft, right: marginRight }, + tableWidth: contentWidth, + theme: "grid", + showHead: dateIndex === 0 ? "firstPage" : "never", + }); + + currentY = doc.lastAutoTable.finalY; + + // Daily total + Cumulative total rows in same box + checkNewPage(16); + + const totalRows = [ + [ + "", + "", + `Total zi: ${dateFormatted}`, + formatNumberForPDF(dailyIncasari), + formatNumberForPDF(dailyPlati), + "Sold", + ], + [ + "", + "", + "Total cumulat:", + formatNumberForPDF(cumulativeIncasari), + formatNumberForPDF(cumulativePlati), + formatNumberForPDF(lastSold), + ], + ]; + + const totalsStartY = currentY; + + autoTable(doc, { + body: totalRows, + startY: currentY, + styles: { + fontSize: 8, + cellPadding: 1.5, + fontStyle: "bold", + lineWidth: 0, + }, + columnStyles: columnStyles, + margin: { left: marginLeft, right: marginRight }, + tableWidth: contentWidth, + theme: "plain", + }); + + // Draw outer border for totals box (no internal lines) + const totalsEndY = doc.lastAutoTable.finalY; + doc.setDrawColor(200, 200, 200); + doc.setLineWidth(0.1); + doc.rect(marginLeft, totalsStartY, contentWidth, totalsEndY - totalsStartY); + + currentY = doc.lastAutoTable.finalY + 3; + }); + } + + }); + + // Add footer to all pages (Generat: DATE on left, Pagina X din Y on right) + const totalPages = doc.internal.getNumberOfPages(); + const generatedText = `Generat: ${new Date().toLocaleString("ro-RO")}`; + for (let i = 1; i <= totalPages; i++) { + doc.setPage(i); + doc.setFontSize(8); + doc.setFont(undefined, "normal"); + const footerY = pageHeight - 8; + + // Left: Generated date + doc.text(generatedText, marginLeft, footerY); + + // Right: Page number + const pageText = `Pagina ${i} din ${totalPages}`; + const pageTextWidth = doc.getTextWidth(pageText); + doc.text(pageText, pageWidth - marginRight - pageTextWidth, footerY); + } + + doc.save(`${filename}_${new Date().toISOString().split("T")[0]}.pdf`); + return { success: true }; + } catch (error) { + console.error("Bank Cash Register PDF export error:", error); + return { success: false, error: error.message || "PDF generation failed" }; + } +}; + /** * Export Trend Data */ diff --git a/reports-app/frontend/src/views/BankCashRegisterView.vue b/reports-app/frontend/src/views/BankCashRegisterView.vue index 54ff4ce..3317d01 100644 --- a/reports-app/frontend/src/views/BankCashRegisterView.vue +++ b/reports-app/frontend/src/views/BankCashRegisterView.vue @@ -5,85 +5,154 @@ - - + + + + + + + - -
- - - - - - + +
+
+ Total Încasări: + {{ formatCurrency(treasuryStore.totals.total_incasari) }} +
+
+ Total Plăți: + {{ formatCurrency(treasuryStore.totals.total_plati) }} +
+
+ Total Sold: + {{ formatCurrency(totalSold) }} +
- + @@ -165,21 +225,39 @@ diff --git a/reports-app/frontend/src/views/DashboardView.vue b/reports-app/frontend/src/views/DashboardView.vue index 8b84c77..bbe1450 100644 --- a/reports-app/frontend/src/views/DashboardView.vue +++ b/reports-app/frontend/src/views/DashboardView.vue @@ -797,7 +797,15 @@ const searchData = () => { } }; -// Watchers - removed unused watchers +// Watch for company changes - reload dashboard when company changes +watch( + () => companyStore.selectedCompany, + async (newCompany) => { + if (newCompany) { + await loadDashboardData(); + } + }, +); // Lifecycle onMounted(async () => {