feat: Enhance invoice management with PDF optimization and date fixes
Optimize PDF export layout with compact columns and more space for partner names. Add accounting period display to invoices matching Trial Balance format. Fix date filtering to use local timezone instead of UTC. Update invoice ordering to chronological sequence (DATAACT, NRACT, NUME). **Backend changes:** - Add accounting period query from calendar table - Add currency (valuta) and cont filter support - Change invoice ordering to chronological (DATAACT ASC, NRACT ASC, NUME) - Add accounting_period field to InvoiceListResponse model **Frontend changes:** - Optimize PDF column widths (37% for partner names, compact numeric columns) - Add custom column width support in exportUtils - Fix date conversion from UTC to local timezone (prevents day shift) - Add accounting period display in PDF exports - Enhance E2E test coverage **Cleanup:** - Remove obsolete Trial Balance feature documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,587 +0,0 @@
|
||||
# Feature: Balanță de Verificare Sintetică (Trial Balance)
|
||||
|
||||
**Branch**: `feature/trial-balance`
|
||||
**Status**: 📋 Planning
|
||||
**Created**: 2025-11-20
|
||||
**Assigned to**: Claude Code
|
||||
|
||||
---
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
Implementare pagină nouă în frontend pentru afișarea balanței de verificare sintetice simplificate din tabelul Oracle `VBAL`, cu filtrare dinamică și integrare completă în aplicație.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Business Requirements
|
||||
|
||||
### Functional Requirements
|
||||
1. **Afișare Date Balanță de Verificare**:
|
||||
- Solduri precedente (debit/credit)
|
||||
- Rulaje lunare (debit/credit)
|
||||
- Solduri finale (debit/credit)
|
||||
- Cont contabil (cod)
|
||||
- Denumire cont
|
||||
|
||||
2. **Filtrare și Căutare**:
|
||||
- Filtrare după **număr cont** (ex: "512", "4111")
|
||||
- Filtrare după **denumire cont** (căutare parțială, case-insensitive)
|
||||
- Filtrare combinată (și după cont și după denumire)
|
||||
- Clear filters option
|
||||
|
||||
3. **Navigare și UX**:
|
||||
- Adăugare în meniul hamburger (secțiune Rapoarte / Reports)
|
||||
- Ordonare pe coloane (ascendent/descendent)
|
||||
- Paginare pentru volume mari de date
|
||||
- Loading states și error handling
|
||||
- Responsive design (mobile-friendly)
|
||||
|
||||
4. **Date Contextualizate**:
|
||||
- Afișare pentru compania activă curentă
|
||||
- Afișare pentru luna/anul curent (sau filtru de perioadă - opțional faza 2)
|
||||
|
||||
### Non-Functional Requirements
|
||||
- **Performance**: Răspuns API < 2s pentru 10,000 înregistrări
|
||||
- **Security**: Autentificare JWT obligatorie
|
||||
- **Accessibility**: Conform design system existent
|
||||
- **CSS**: Respectare strictă a arhitecturii CSS (ONBOARDING_CSS.md, CSS_PATTERNS.md)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Technical Architecture
|
||||
|
||||
### Database Schema (Oracle `VBAL` table)
|
||||
```sql
|
||||
-- Structura tabelului VBAL (să fie verificată în Oracle)
|
||||
SELECT
|
||||
CONT, -- Număr cont contabil
|
||||
DCONT, -- Denumire cont
|
||||
SD_PREC, -- Sold precedent debit
|
||||
SC_PREC, -- Sold precedent credit
|
||||
RD_LUNA, -- Rulaj lunar debit
|
||||
RC_LUNA, -- Rulaj lunar credit
|
||||
SD_FINAL, -- Sold final debit
|
||||
SC_FINAL, -- Sold final credit
|
||||
COD_FIRMA, -- Cod firmă (pentru filtering)
|
||||
LUNA, -- Luna (1-12)
|
||||
AN -- An
|
||||
FROM VBAL
|
||||
WHERE COD_FIRMA = :cod_firma
|
||||
AND AN = :an
|
||||
AND LUNA = :luna
|
||||
AND (CONT LIKE :cont_filter OR DCONT LIKE :denumire_filter)
|
||||
ORDER BY CONT
|
||||
```
|
||||
|
||||
### Backend API Endpoint
|
||||
|
||||
**Endpoint**: `GET /api/trial-balance`
|
||||
|
||||
**Request Parameters**:
|
||||
```json
|
||||
{
|
||||
"cod_firma": "string (required, from JWT)",
|
||||
"luna": "integer (1-12, optional, default: current month)",
|
||||
"an": "integer (optional, default: current year)",
|
||||
"cont_filter": "string (optional, partial match)",
|
||||
"denumire_filter": "string (optional, partial match, case-insensitive)",
|
||||
"sort_by": "string (optional, default: 'CONT')",
|
||||
"sort_order": "string (optional, 'asc' | 'desc', default: 'asc')",
|
||||
"page": "integer (optional, default: 1)",
|
||||
"page_size": "integer (optional, default: 50)"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"cont": "4111",
|
||||
"dcont": "Furnizori interni",
|
||||
"sold_precedent_debit": 0.00,
|
||||
"sold_precedent_credit": 15000.00,
|
||||
"rulaj_lunar_debit": 5000.00,
|
||||
"rulaj_lunar_credit": 8000.00,
|
||||
"sold_final_debit": 0.00,
|
||||
"sold_final_credit": 18000.00
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"total_items": 150,
|
||||
"total_pages": 3,
|
||||
"current_page": 1,
|
||||
"page_size": 50
|
||||
},
|
||||
"filters_applied": {
|
||||
"luna": 11,
|
||||
"an": 2025,
|
||||
"cont_filter": null,
|
||||
"denumire_filter": "furnizori"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses**:
|
||||
- `401 Unauthorized`: Missing/invalid JWT token
|
||||
- `403 Forbidden`: User doesn't have access to company
|
||||
- `500 Internal Server Error`: Database connection/query error
|
||||
|
||||
---
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
**Location**: `reports-app/frontend/src/views/TrialBalanceView.vue`
|
||||
|
||||
**Store**: `reports-app/frontend/src/stores/trialBalanceStore.js`
|
||||
|
||||
**Route**: `/trial-balance` (added to `router/index.js`)
|
||||
|
||||
**Menu Integration**:
|
||||
- Add to hamburger menu in `src/components/layout/HamburgerMenu.vue`
|
||||
- Section: "Rapoarte" / "Reports"
|
||||
- Icon: Calculator/Table icon (PrimeVue icon)
|
||||
- Label: "Balanță de Verificare"
|
||||
|
||||
**Component Structure**:
|
||||
```vue
|
||||
<template>
|
||||
<div class="trial-balance-view">
|
||||
<!-- Page Header -->
|
||||
<div class="roa-page-header">
|
||||
<h1>Balanță de Verificare</h1>
|
||||
<p class="roa-subtitle">Luna: {{ currentMonth }}/{{ currentYear }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters Section -->
|
||||
<div class="roa-card roa-filters-card">
|
||||
<!-- Cont filter -->
|
||||
<!-- Denumire filter -->
|
||||
<!-- Clear filters button -->
|
||||
</div>
|
||||
|
||||
<!-- Data Table -->
|
||||
<div class="roa-card">
|
||||
<DataTable
|
||||
:value="trialBalanceData"
|
||||
:loading="loading"
|
||||
paginator
|
||||
:rows="50"
|
||||
sortField="cont"
|
||||
:sortOrder="1"
|
||||
>
|
||||
<!-- Columns definition -->
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Empty State / Error State -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**CSS Patterns to Use**:
|
||||
- `.roa-page-header` - Page header (CSS_PATTERNS.md)
|
||||
- `.roa-card` - Card container (CSS_PATTERNS.md)
|
||||
- `.roa-filters-card` - Filters section (CSS_PATTERNS.md)
|
||||
- Design tokens from DESIGN_TOKENS.md (colors, spacing)
|
||||
- **NO `:deep()`** - Use `src/assets/css/vendor/primevue/datatable.css` for PrimeVue overrides
|
||||
|
||||
**State Management** (Pinia store):
|
||||
```javascript
|
||||
// src/stores/trialBalanceStore.js
|
||||
export const useTrialBalanceStore = defineStore('trialBalance', {
|
||||
state: () => ({
|
||||
trialBalanceData: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
filters: {
|
||||
cont: '',
|
||||
denumire: '',
|
||||
luna: new Date().getMonth() + 1,
|
||||
an: new Date().getFullYear()
|
||||
},
|
||||
pagination: {
|
||||
totalItems: 0,
|
||||
currentPage: 1,
|
||||
pageSize: 50
|
||||
}
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchTrialBalance() { /* ... */ },
|
||||
async applyFilters(filters) { /* ... */ },
|
||||
clearFilters() { /* ... */ }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Implementation Plan (Phases)
|
||||
|
||||
### ✅ Phase 0: Preparation & Branch Setup
|
||||
**Status**: 🔲 Not Started
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Create feature branch `feature/trial-balance` from `main`
|
||||
- [ ] Verify Oracle `VBAL` table structure and test queries
|
||||
- [ ] Review existing code patterns (InvoicesView, DashboardView)
|
||||
- [ ] Read CSS documentation (ONBOARDING_CSS.md, CSS_PATTERNS.md)
|
||||
|
||||
**Estimated Time**: 30 minutes
|
||||
|
||||
---
|
||||
|
||||
### ✅ Phase 1: Backend API Implementation
|
||||
**Status**: 🔲 Not Started
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Create Pydantic schema: `backend/app/schemas/trial_balance.py`
|
||||
- `TrialBalanceItem` model
|
||||
- `TrialBalanceRequest` model
|
||||
- `TrialBalanceResponse` model
|
||||
- [ ] Create router: `backend/app/routers/trial_balance.py`
|
||||
- `GET /api/trial-balance` endpoint
|
||||
- Query parameters validation
|
||||
- Oracle stored procedure/query execution
|
||||
- Pagination logic
|
||||
- Filtering logic (cont, denumire)
|
||||
- Sorting logic
|
||||
- [ ] Register router in `backend/app/main.py`
|
||||
- [ ] Test endpoint manually with Postman/curl
|
||||
- Test with valid JWT token
|
||||
- Test filtering by cont
|
||||
- Test filtering by denumire
|
||||
- Test pagination
|
||||
- Test sorting
|
||||
- Test error cases (invalid token, DB errors)
|
||||
|
||||
**Files to Create/Modify**:
|
||||
- `reports-app/backend/app/schemas/trial_balance.py` (NEW)
|
||||
- `reports-app/backend/app/routers/trial_balance.py` (NEW)
|
||||
- `reports-app/backend/app/main.py` (MODIFY)
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- ✅ API returns correct data from `VBAL` table
|
||||
- ✅ Filtering works for cont and denumire
|
||||
- ✅ Pagination returns correct page counts
|
||||
- ✅ Sorting works for all columns
|
||||
- ✅ Proper error handling and status codes
|
||||
|
||||
**Estimated Time**: 2-3 hours
|
||||
|
||||
---
|
||||
|
||||
### ✅ Phase 2: Frontend Store Implementation
|
||||
**Status**: 🔲 Not Started
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Create Pinia store: `frontend/src/stores/trialBalanceStore.js`
|
||||
- State definition (data, loading, error, filters, pagination)
|
||||
- `fetchTrialBalance()` action with Axios
|
||||
- `applyFilters()` action
|
||||
- `clearFilters()` action
|
||||
- Getters for computed values
|
||||
- [ ] Add API service: `frontend/src/services/trialBalanceService.js` (optional)
|
||||
- [ ] Test store in browser DevTools
|
||||
|
||||
**Files to Create**:
|
||||
- `reports-app/frontend/src/stores/trialBalanceStore.js` (NEW)
|
||||
- `reports-app/frontend/src/services/trialBalanceService.js` (NEW, optional)
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- ✅ Store successfully fetches data from API
|
||||
- ✅ Filters update correctly
|
||||
- ✅ Loading states work properly
|
||||
- ✅ Error handling displays user-friendly messages
|
||||
|
||||
**Estimated Time**: 1-2 hours
|
||||
|
||||
---
|
||||
|
||||
### ✅ Phase 3: Frontend View Component
|
||||
**Status**: 🔲 Not Started
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Create Vue component: `frontend/src/views/TrialBalanceView.vue`
|
||||
- Page header with title and period info
|
||||
- Filters card (cont input, denumire input, clear button)
|
||||
- PrimeVue DataTable with columns:
|
||||
- Cont
|
||||
- Denumire Cont
|
||||
- Sold Precedent (Debit/Credit)
|
||||
- Rulaj Lunar (Debit/Credit)
|
||||
- Sold Final (Debit/Credit)
|
||||
- Loading spinner
|
||||
- Empty state (no data)
|
||||
- Error state
|
||||
- [ ] Use **existing CSS patterns** (NO new custom CSS unless necessary)
|
||||
- `.roa-page-header`
|
||||
- `.roa-card`
|
||||
- `.roa-filters-card`
|
||||
- Design tokens (spacing, colors)
|
||||
- [ ] Implement responsive design (mobile breakpoints)
|
||||
- [ ] Add number formatting (Romanian locale, 2 decimals)
|
||||
|
||||
**Files to Create**:
|
||||
- `reports-app/frontend/src/views/TrialBalanceView.vue` (NEW)
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- ✅ Page displays correctly with data
|
||||
- ✅ Filters work and update table
|
||||
- ✅ Sorting on columns works
|
||||
- ✅ Pagination works
|
||||
- ✅ Loading/error states display correctly
|
||||
- ✅ CSS follows design system (NO `:deep()`, NO custom colors)
|
||||
- ✅ Responsive on mobile devices
|
||||
|
||||
**Estimated Time**: 3-4 hours
|
||||
|
||||
---
|
||||
|
||||
### ✅ Phase 4: Routing & Navigation
|
||||
**Status**: 🔲 Not Started
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Add route in `frontend/src/router/index.js`:
|
||||
```javascript
|
||||
{
|
||||
path: '/trial-balance',
|
||||
name: 'TrialBalance',
|
||||
component: () => import('@/views/TrialBalanceView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
```
|
||||
- [ ] Add menu item in `frontend/src/components/layout/HamburgerMenu.vue`:
|
||||
- Section: "Rapoarte"
|
||||
- Icon: `pi-calculator` or `pi-table`
|
||||
- Label: "Balanță de Verificare"
|
||||
- Route: `/trial-balance`
|
||||
- [ ] Test navigation from menu
|
||||
|
||||
**Files to Modify**:
|
||||
- `reports-app/frontend/src/router/index.js` (MODIFY)
|
||||
- `reports-app/frontend/src/components/layout/HamburgerMenu.vue` (MODIFY)
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- ✅ Route is accessible at `/trial-balance`
|
||||
- ✅ Menu item appears in hamburger menu
|
||||
- ✅ Clicking menu item navigates to page
|
||||
- ✅ Auth guard prevents unauthorized access
|
||||
|
||||
**Estimated Time**: 30 minutes
|
||||
|
||||
---
|
||||
|
||||
### ✅ Phase 5: Testing & Refinement
|
||||
**Status**: 🔲 Not Started
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Manual testing:
|
||||
- Test with different companies
|
||||
- Test with different date ranges
|
||||
- Test filtering edge cases (empty results, special characters)
|
||||
- Test sorting on all columns
|
||||
- Test pagination with large datasets
|
||||
- Test on mobile devices (responsive)
|
||||
- Test error scenarios (API down, invalid token)
|
||||
- [ ] Add Playwright E2E tests (optional, recommended):
|
||||
- `frontend/tests/e2e/trial-balance/trial-balance.spec.js`
|
||||
- Test navigation
|
||||
- Test filtering
|
||||
- Test data display
|
||||
- [ ] Code review & refinement:
|
||||
- Check CSS adherence (no `:deep()`, use patterns)
|
||||
- Check error handling
|
||||
- Check loading states
|
||||
- Check accessibility (keyboard navigation, screen readers)
|
||||
- [ ] Performance testing:
|
||||
- Test with 10,000+ rows
|
||||
- Check API response time
|
||||
- Check frontend rendering performance
|
||||
|
||||
**Files to Create** (optional):
|
||||
- `reports-app/frontend/tests/e2e/trial-balance/trial-balance.spec.js` (NEW)
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- ✅ All manual tests pass
|
||||
- ✅ E2E tests pass (if implemented)
|
||||
- ✅ Performance benchmarks met (API < 2s)
|
||||
- ✅ Code follows project conventions
|
||||
- ✅ CSS follows design system strictly
|
||||
|
||||
**Estimated Time**: 2-3 hours
|
||||
|
||||
---
|
||||
|
||||
### ✅ Phase 6: Documentation & PR
|
||||
**Status**: 🔲 Not Started
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Update `README.md` - add Trial Balance to features list
|
||||
- [ ] Update `CLAUDE.md` - add Trial Balance to documentation index (if needed)
|
||||
- [ ] Add API documentation in backend README
|
||||
- [ ] Create PR from `feature/trial-balance` to `main`:
|
||||
- Title: "feat: Add Trial Balance (Balanță de Verificare) page"
|
||||
- Description: Link to this feature file, list phases completed
|
||||
- Screenshots of UI
|
||||
- API endpoint documentation
|
||||
- [ ] Request code review
|
||||
- [ ] Address review comments
|
||||
- [ ] Merge to `main`
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- ✅ All documentation updated
|
||||
- ✅ PR created with complete description
|
||||
- ✅ Code review approved
|
||||
- ✅ Branch merged successfully
|
||||
|
||||
**Estimated Time**: 1 hour
|
||||
|
||||
---
|
||||
|
||||
## 📊 Progress Tracking
|
||||
|
||||
### Overall Progress
|
||||
- **Phase 0**: 🔲 Not Started (0%)
|
||||
- **Phase 1**: 🔲 Not Started (0%)
|
||||
- **Phase 2**: 🔲 Not Started (0%)
|
||||
- **Phase 3**: 🔲 Not Started (0%)
|
||||
- **Phase 4**: 🔲 Not Started (0%)
|
||||
- **Phase 5**: 🔲 Not Started (0%)
|
||||
- **Phase 6**: 🔲 Not Started (0%)
|
||||
|
||||
**Total Progress**: 0% Complete
|
||||
|
||||
### Status Legend
|
||||
- 🔲 Not Started
|
||||
- 🔄 In Progress
|
||||
- ✅ Completed
|
||||
- ⚠️ Blocked
|
||||
- ❌ Cancelled
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
1. SSH tunnel running (`./ssh_tunnel.sh status`)
|
||||
2. Backend running (`cd reports-app/backend && uvicorn app.main:app --reload --port 8001`)
|
||||
3. Frontend running (`cd reports-app/frontend && npm run dev`)
|
||||
4. Valid JWT token for testing API
|
||||
|
||||
### Starting Development
|
||||
```bash
|
||||
# 1. Create feature branch
|
||||
git checkout main
|
||||
git pull origin main
|
||||
git checkout -b feature/trial-balance
|
||||
|
||||
# 2. Verify Oracle VBAL table
|
||||
# Connect to Oracle and run:
|
||||
# SELECT * FROM VBAL WHERE ROWNUM <= 10;
|
||||
|
||||
# 3. Start backend (Phase 1)
|
||||
cd reports-app/backend
|
||||
# Create schemas/trial_balance.py
|
||||
# Create routers/trial_balance.py
|
||||
# Test with curl/Postman
|
||||
|
||||
# 4. Start frontend (Phases 2-4)
|
||||
cd reports-app/frontend
|
||||
# Create stores/trialBalanceStore.js
|
||||
# Create views/TrialBalanceView.vue
|
||||
# Update router and menu
|
||||
|
||||
# 5. Test and refine (Phase 5)
|
||||
# Manual testing
|
||||
# E2E tests (optional)
|
||||
|
||||
# 6. Create PR (Phase 6)
|
||||
git add .
|
||||
git commit -m "feat: Add Trial Balance page with filtering and pagination"
|
||||
git push origin feature/trial-balance
|
||||
# Create PR on GitHub/GitLab
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Reference Documentation
|
||||
|
||||
**MUST READ before coding**:
|
||||
- `docs/ONBOARDING_CSS.md` - CSS quick start (5 min read)
|
||||
- `docs/CSS_PATTERNS.md` - All available CSS patterns
|
||||
- `docs/DESIGN_TOKENS.md` - Colors, spacing, typography
|
||||
- `reports-app/frontend/README.md` - Frontend architecture
|
||||
- `reports-app/backend/README.md` - Backend architecture
|
||||
|
||||
**Similar implementations to reference**:
|
||||
- `reports-app/frontend/src/views/InvoicesView.vue` - Table + filters pattern
|
||||
- `reports-app/frontend/src/views/DashboardView.vue` - Cards + data display
|
||||
- `reports-app/backend/app/routers/invoices.py` - API with filtering/pagination
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Important Notes
|
||||
|
||||
### CSS Architecture Compliance
|
||||
**CRITICAL**: This feature MUST follow the established CSS architecture:
|
||||
|
||||
✅ **DO**:
|
||||
- Use existing `.roa-*` patterns from `CSS_PATTERNS.md`
|
||||
- Use design tokens (`var(--color-primary)`, `var(--spacing-4)`)
|
||||
- Use vendor overrides in `src/assets/css/vendor/primevue/` for PrimeVue
|
||||
- Keep component styles minimal and semantic
|
||||
- Follow BEM naming if creating new components
|
||||
|
||||
❌ **DON'T**:
|
||||
- Use `:deep()` in Vue components
|
||||
- Hardcode colors or spacing values
|
||||
- Duplicate existing patterns
|
||||
- Create custom CSS without checking patterns first
|
||||
- Override PrimeVue styles in component `<style>` blocks
|
||||
|
||||
### Database Considerations
|
||||
- **Performance**: Use indexes on `VBAL(COD_FIRMA, AN, LUNA, CONT)`
|
||||
- **Data Volume**: Consider pagination for companies with many accounts
|
||||
- **Filters**: Use Oracle `LIKE` with wildcards efficiently: `CONT LIKE :cont || '%'`
|
||||
|
||||
### Security
|
||||
- **JWT Auth**: Required for all API calls
|
||||
- **Company Access**: Verify user has access to `cod_firma` in request
|
||||
- **SQL Injection**: Use parameterized queries (`:param` syntax)
|
||||
|
||||
### Future Enhancements (Not in Initial Scope)
|
||||
- Export to Excel/PDF
|
||||
- Drill-down to account details (sold și rulaj pe luni)
|
||||
- Comparison between periods (current vs previous month/year)
|
||||
- Charts/visualizations (rulaj pe conturi)
|
||||
- Advanced filters (by account class/category)
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Known Issues / Risks
|
||||
|
||||
1. **Oracle VBAL table structure unknown**: Verify column names and types in Phase 0
|
||||
2. **Performance with large datasets**: Monitor query performance, add indexes if needed
|
||||
3. **Decimal precision**: Ensure proper handling of Romanian decimal separator (comma vs dot)
|
||||
4. **Company switching**: Ensure data refreshes when user switches active company
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For questions or blockers, refer to:
|
||||
- **Architecture**: `docs/ARCHITECTURE_SCHEMA.md`, `docs/MICROSERVICES_GUIDE.md`
|
||||
- **CSS Issues**: `docs/ONBOARDING_CSS.md`, `docs/STYLING_GUIDELINES.md`
|
||||
- **Backend Issues**: `reports-app/backend/README.md`, `README.md` (troubleshooting)
|
||||
- **Frontend Issues**: `reports-app/frontend/README.md`
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-11-20
|
||||
**Feature Owner**: Claude Code
|
||||
**Reviewers**: TBD
|
||||
@@ -1,106 +0,0 @@
|
||||
# 🎯 Prompt pentru Implementare Balanță de Verificare
|
||||
|
||||
## Promptul de folosit:
|
||||
|
||||
```
|
||||
Implementează feature-ul "Balanță de Verificare" conform specificațiilor din features/TRIAL_BALANCE_FEATURE.md.
|
||||
|
||||
Cerințe:
|
||||
1. Creează branch nou: feature/trial-balance
|
||||
2. Implementează toate fazele (0-6) în ordine
|
||||
3. Urmărește progresul actualizând statusul fazelor în TRIAL_BALANCE_FEATURE.md
|
||||
4. Respectă STRICT arhitectura CSS (ONBOARDING_CSS.md, CSS_PATTERNS.md) - fără :deep(), doar pattern-uri existente
|
||||
5. Testează manual după fiecare fază înainte de a trece la următoarea
|
||||
|
||||
Prioritate: Phases 0-4 (backend + frontend + navigare)
|
||||
Phases 5-6 (testing + PR) - opțional pentru MVP
|
||||
|
||||
Start cu Phase 0: Preparation & Branch Setup.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sau versiunea extinsă:
|
||||
|
||||
```
|
||||
# Task: Implementare completă Balanță de Verificare (Trial Balance)
|
||||
|
||||
## Context
|
||||
Implementează o pagină nouă în frontend pentru afișarea balanței de verificare sintetice din Oracle VBAL cu filtrare, paginare și integrare completă în aplicație.
|
||||
|
||||
## Specificații
|
||||
Vezi: features/TRIAL_BALANCE_FEATURE.md
|
||||
|
||||
## Plan de lucru
|
||||
1. **Phase 0**: Setup branch feature/trial-balance + verificare VBAL table
|
||||
2. **Phase 1**: Backend API (schemas + router + endpoint GET /api/trial-balance)
|
||||
3. **Phase 2**: Frontend Store (Pinia trialBalanceStore)
|
||||
4. **Phase 3**: Frontend View (TrialBalanceView.vue cu DataTable)
|
||||
5. **Phase 4**: Routing + Menu (router.js + HamburgerMenu.vue)
|
||||
6. **Phase 5**: Testing manual + E2E (opțional)
|
||||
7. **Phase 6**: Documentation + PR
|
||||
|
||||
## Reguli CRITICE
|
||||
✅ Respectă CSS architecture: DOAR pattern-uri din CSS_PATTERNS.md, FĂRĂ :deep()
|
||||
✅ Folosește design tokens (var(--color-primary), var(--spacing-4))
|
||||
✅ Testează manual după fiecare fază
|
||||
✅ Actualizează progresul în TRIAL_BALANCE_FEATURE.md la finalul fiecărei faze
|
||||
|
||||
## Start
|
||||
Începe cu Phase 0 și confirmă structura tabelului VBAL înainte de a trece la Phase 1.
|
||||
|
||||
Urmărește progresul în features/TRIAL_BALANCE_FEATURE.md actualizând:
|
||||
- Status fază: 🔲 → 🔄 → ✅
|
||||
- Checkboxes task-uri: [ ] → [x]
|
||||
- Overall Progress: X% Complete
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alternative scurte pentru faze individuale:
|
||||
|
||||
### Pentru Phase 1 (Backend):
|
||||
```
|
||||
Implementează Phase 1 din features/TRIAL_BALANCE_FEATURE.md:
|
||||
- Creează schemas/trial_balance.py (TrialBalanceItem, Request, Response)
|
||||
- Creează routers/trial_balance.py (GET /api/trial-balance cu filtering/pagination)
|
||||
- Testează cu curl/Postman
|
||||
- Marchează task-urile ca [x] în feature file
|
||||
```
|
||||
|
||||
### Pentru Phase 3 (Frontend View):
|
||||
```
|
||||
Implementează Phase 3 din features/TRIAL_BALANCE_FEATURE.md:
|
||||
- Creează TrialBalanceView.vue
|
||||
- Folosește DOAR pattern-uri CSS existente (.roa-card, .roa-page-header)
|
||||
- FĂRĂ :deep(), FĂRĂ custom CSS
|
||||
- DataTable cu coloane: Cont, Denumire, Sold Prec (D/C), Rulaj Lunar (D/C), Sold Final (D/C)
|
||||
- Marchează task-urile ca [x] în feature file
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips pentru urmărire progres:
|
||||
|
||||
Actualizează manual în TRIAL_BALANCE_FEATURE.md:
|
||||
```markdown
|
||||
### ✅ Phase 1: Backend API Implementation
|
||||
**Status**: ✅ Completed
|
||||
|
||||
**Tasks**:
|
||||
- [x] Create Pydantic schema
|
||||
- [x] Create router
|
||||
- [x] Register router in main.py
|
||||
- [x] Test endpoint manually
|
||||
```
|
||||
|
||||
Apoi actualizează Overall Progress:
|
||||
```markdown
|
||||
### Overall Progress
|
||||
- **Phase 0**: ✅ Completed (100%)
|
||||
- **Phase 1**: ✅ Completed (100%)
|
||||
- **Phase 2**: 🔄 In Progress (50%)
|
||||
...
|
||||
|
||||
**Total Progress**: 35% Complete
|
||||
```
|
||||
@@ -15,6 +15,8 @@ class InvoiceBase(BaseModel):
|
||||
contract: Optional[str] = Field(description="Numărul contractului")
|
||||
cod_fiscal: Optional[str] = Field(description="Codul fiscal")
|
||||
reg_comert: Optional[str] = Field(description="Registrul comerțului")
|
||||
cont: Optional[str] = Field(description="Contul contabil")
|
||||
valuta: str = Field(default="RON", description="Valuta (RON, EUR, USD, etc.)")
|
||||
|
||||
class Invoice(InvoiceBase):
|
||||
"""Model complet pentru factură cu calcule financiare"""
|
||||
@@ -45,11 +47,12 @@ class InvoiceFilter(BaseModel):
|
||||
date_from: Optional[date] = Field(description="Data de început")
|
||||
date_to: Optional[date] = Field(description="Data de sfârșit")
|
||||
partner_name: Optional[str] = Field(description="Filtru după nume")
|
||||
cont: Optional[str] = Field(description="Filtru după cont contabil")
|
||||
only_unpaid: bool = Field(default=True, description="Doar neachitate")
|
||||
min_amount: Optional[Decimal] = Field(description="Suma minimă")
|
||||
max_amount: Optional[Decimal] = Field(description="Suma maximă")
|
||||
page: int = Field(default=1, ge=1, description="Pagina")
|
||||
page_size: int = Field(default=50, ge=1, le=1000, description="Mărimea paginii")
|
||||
page_size: int = Field(default=50, ge=1, le=10000000, description="Mărimea paginii")
|
||||
|
||||
class InvoiceListResponse(BaseModel):
|
||||
"""Răspuns pentru lista de facturi"""
|
||||
@@ -60,6 +63,7 @@ class InvoiceListResponse(BaseModel):
|
||||
page: int
|
||||
page_size: int
|
||||
has_more: bool
|
||||
accounting_period: Optional[dict] = Field(default=None, description="Perioada contabilă (an, luna)")
|
||||
|
||||
class InvoiceSummary(BaseModel):
|
||||
"""Rezumat pentru facturi - pentru dashboard"""
|
||||
|
||||
@@ -22,11 +22,12 @@ async def get_invoices(
|
||||
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"),
|
||||
cont: Optional[str] = Query(None, description="Filtru după cont contabil"),
|
||||
only_unpaid: bool = Query(True, description="Doar facturile neachitate"),
|
||||
min_amount: Optional[float] = Query(None, description="Suma minimă"),
|
||||
max_amount: Optional[float] = Query(None, description="Suma maximă"),
|
||||
page: int = Query(1, ge=1, description="Pagina"),
|
||||
page_size: int = Query(50, ge=1, le=1000, description="Mărimea paginii"),
|
||||
page_size: int = Query(50, ge=1, le=10000000, description="Mărimea paginii"),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
@@ -63,6 +64,7 @@ async def get_invoices(
|
||||
date_from=date_from_obj,
|
||||
date_to=date_to_obj,
|
||||
partner_name=partner_name,
|
||||
cont=cont,
|
||||
only_unpaid=only_unpaid,
|
||||
min_amount=min_amount,
|
||||
max_amount=max_amount,
|
||||
@@ -89,10 +91,10 @@ async def get_invoices_summary(
|
||||
# Verifică dacă utilizatorul are acces la firma specificată
|
||||
if company not in current_user.companies:
|
||||
raise HTTPException(status_code=403, detail=f"Nu aveți acces la firma {company}")
|
||||
|
||||
|
||||
result = await InvoiceService.get_invoice_summary(company, partner_type, current_user.username)
|
||||
return result
|
||||
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Eroare la obținerea rezumatului facturilor: {str(e)}")
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ async def get_bank_cash_register(
|
||||
date_to: Optional[str] = Query(None, description="Data sfârșit (YYYY-MM-DD)"),
|
||||
partner_name: Optional[str] = Query(None, description="Filtru nume partener"),
|
||||
page: int = Query(1, ge=1, description="Pagina"),
|
||||
page_size: int = Query(50, ge=1, le=1000, description="Mărimea paginii"),
|
||||
page_size: int = Query(50, ge=1, le=10000000, description="Mărimea paginii"),
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -58,7 +58,7 @@ class InvoiceService:
|
||||
|
||||
# Query cu calculele corecte pentru solduri
|
||||
base_query = f"""
|
||||
SELECT
|
||||
SELECT
|
||||
vp.NUME,
|
||||
vp.NRACT,
|
||||
vp.DATAACT,
|
||||
@@ -66,22 +66,23 @@ class InvoiceService:
|
||||
vp.CONTRACT,
|
||||
vp.COD_FISCAL,
|
||||
vp.REG_COMERT,
|
||||
CASE
|
||||
CASE
|
||||
WHEN vp.CONT IN ('4111','461') THEN vp.PRECDEB + vp.DEBIT -- Total facturat clienți
|
||||
WHEN vp.CONT IN ('401','404','462') THEN vp.PRECCRED + vp.CREDIT -- Total facturat furnizori
|
||||
END as total_facturat,
|
||||
CASE
|
||||
CASE
|
||||
WHEN vp.CONT IN ('4111','461') THEN vp.PRECCRED + vp.CREDIT -- Încasat clienți
|
||||
WHEN vp.CONT IN ('401','404','462') THEN vp.PRECDEB + vp.DEBIT -- Achitat furnizori
|
||||
END as achitat,
|
||||
CASE
|
||||
WHEN vp.CONT IN ('4111','461') THEN
|
||||
CASE
|
||||
WHEN vp.CONT IN ('4111','461') THEN
|
||||
(vp.PRECDEB + vp.DEBIT) - (vp.PRECCRED + vp.CREDIT) -- Sold clienți
|
||||
WHEN vp.CONT IN ('401','404','462') THEN
|
||||
WHEN vp.CONT IN ('401','404','462') THEN
|
||||
(vp.PRECCRED + vp.CREDIT) - (vp.PRECDEB + vp.DEBIT) -- Sold furnizori
|
||||
END as sold,
|
||||
vp.CONT,
|
||||
CASE
|
||||
NVL(vp.NUME_VAL, 'RON') as valuta,
|
||||
CASE
|
||||
WHEN vp.DATASCAD < SYSDATE THEN 'restant'
|
||||
ELSE 'in_termen'
|
||||
END as status
|
||||
@@ -109,7 +110,11 @@ class InvoiceService:
|
||||
if filter_params.partner_name:
|
||||
base_query += " AND UPPER(vp.nume) LIKE UPPER(:partner_name)"
|
||||
params['partner_name'] = f"%{filter_params.partner_name}%"
|
||||
|
||||
|
||||
if filter_params.cont:
|
||||
base_query += " AND vp.cont = :cont"
|
||||
params['cont'] = filter_params.cont
|
||||
|
||||
if filter_params.min_amount:
|
||||
base_query += " AND total_facturat >= :min_amount"
|
||||
params['min_amount'] = filter_params.min_amount
|
||||
@@ -134,9 +139,22 @@ class InvoiceService:
|
||||
cursor.execute(count_query, params)
|
||||
total_count = cursor.fetchone()[0]
|
||||
|
||||
# Adaugă ORDER BY și paginare
|
||||
base_query += " ORDER BY vp.DATAACT DESC, vp.NUME, vp.NRACT"
|
||||
|
||||
# Get accounting period (luna, an) from calendar
|
||||
period_query = f"""
|
||||
SELECT anul, luna
|
||||
FROM {schema}.calendar
|
||||
WHERE anul*12+luna = (SELECT MAX(anul*12+luna) FROM {schema}.calendar)
|
||||
"""
|
||||
cursor.execute(period_query)
|
||||
period_result = cursor.fetchone()
|
||||
accounting_period = {
|
||||
'an': period_result[0] if period_result else None,
|
||||
'luna': period_result[1] if period_result else None
|
||||
}
|
||||
|
||||
# Adaugă ORDER BY și paginare - Ordonare cronologică (DATAACT, NRACT, NUME)
|
||||
base_query += " ORDER BY vp.DATAACT ASC, vp.NRACT ASC, vp.NUME"
|
||||
|
||||
# Paginare Oracle
|
||||
offset = (filter_params.page - 1) * filter_params.page_size
|
||||
limit = offset + filter_params.page_size
|
||||
@@ -147,7 +165,7 @@ class InvoiceService:
|
||||
"""
|
||||
params['offset'] = offset
|
||||
params['limit'] = limit
|
||||
|
||||
|
||||
cursor.execute(paginated_query, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
@@ -168,8 +186,9 @@ class InvoiceService:
|
||||
achitat = Decimal(str(row[9] or 0))
|
||||
sold = Decimal(str(row[10] or 0))
|
||||
cont = row[11]
|
||||
status = row[12]
|
||||
|
||||
valuta = row[12] or 'RON'
|
||||
status = row[13]
|
||||
|
||||
invoice_data = {
|
||||
'nume': nume or '',
|
||||
'nract': nract or 0,
|
||||
@@ -178,9 +197,11 @@ class InvoiceService:
|
||||
'contract': contract,
|
||||
'cod_fiscal': cod_fiscal,
|
||||
'reg_comert': reg_comert,
|
||||
'cont': cont,
|
||||
'totctva': total_facturat,
|
||||
'achitat': achitat,
|
||||
'soldfinal': sold
|
||||
'soldfinal': sold,
|
||||
'valuta': valuta
|
||||
}
|
||||
|
||||
invoice = Invoice(**invoice_data)
|
||||
@@ -194,7 +215,8 @@ class InvoiceService:
|
||||
total_amount=total_amount,
|
||||
page=filter_params.page,
|
||||
page_size=filter_params.page_size,
|
||||
has_more=len(invoices) == filter_params.page_size
|
||||
has_more=len(invoices) == filter_params.page_size,
|
||||
accounting_period=accounting_period
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -4,6 +4,7 @@ import PrimeVue from "primevue/config";
|
||||
// import Aura from '@primevue/themes/aura'
|
||||
import ToastService from "primevue/toastservice";
|
||||
import ConfirmationService from "primevue/confirmationservice";
|
||||
import Tooltip from "primevue/tooltip";
|
||||
|
||||
// Core components
|
||||
import Button from "primevue/button";
|
||||
@@ -54,6 +55,9 @@ app.use(PrimeVue, {
|
||||
app.use(ToastService);
|
||||
app.use(ConfirmationService);
|
||||
|
||||
// PrimeVue directives
|
||||
app.directive("tooltip", Tooltip);
|
||||
|
||||
// Global PrimeVue components
|
||||
app.component("Button", Button);
|
||||
app.component("InputText", InputText);
|
||||
|
||||
@@ -7,6 +7,7 @@ export const useInvoicesStore = defineStore("invoices", () => {
|
||||
const invoices = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
const accountingPeriod = ref({ an: null, luna: null });
|
||||
const filters = ref({
|
||||
company: null,
|
||||
type: "CLIENTI", // CLIENTI or FURNIZORI
|
||||
@@ -15,7 +16,7 @@ export const useInvoicesStore = defineStore("invoices", () => {
|
||||
searchTerm: "",
|
||||
});
|
||||
const pagination = ref({
|
||||
page: 0,
|
||||
page: 1,
|
||||
rows: 50,
|
||||
totalRecords: 0,
|
||||
});
|
||||
@@ -57,16 +58,32 @@ export const useInvoicesStore = defineStore("invoices", () => {
|
||||
try {
|
||||
const params = {
|
||||
partner_type: filters.value.type,
|
||||
page: pagination.value.page + 1,
|
||||
size: pagination.value.rows,
|
||||
page: pagination.value.page,
|
||||
page_size: pagination.value.rows,
|
||||
...options,
|
||||
};
|
||||
|
||||
if (filters.value.dateFrom) {
|
||||
params.date_from = filters.value.dateFrom;
|
||||
// Convert Date object to YYYY-MM-DD string format (LOCAL date, not UTC)
|
||||
if (filters.value.dateFrom instanceof Date) {
|
||||
const year = filters.value.dateFrom.getFullYear();
|
||||
const month = String(filters.value.dateFrom.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(filters.value.dateFrom.getDate()).padStart(2, '0');
|
||||
params.date_from = `${year}-${month}-${day}`;
|
||||
} else {
|
||||
params.date_from = filters.value.dateFrom;
|
||||
}
|
||||
}
|
||||
if (filters.value.dateTo) {
|
||||
params.date_to = filters.value.dateTo;
|
||||
// Convert Date object to YYYY-MM-DD string format (LOCAL date, not UTC)
|
||||
if (filters.value.dateTo instanceof Date) {
|
||||
const year = filters.value.dateTo.getFullYear();
|
||||
const month = String(filters.value.dateTo.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(filters.value.dateTo.getDate()).padStart(2, '0');
|
||||
params.date_to = `${year}-${month}-${day}`;
|
||||
} else {
|
||||
params.date_to = filters.value.dateTo;
|
||||
}
|
||||
}
|
||||
if (filters.value.searchTerm) {
|
||||
params.search = filters.value.searchTerm;
|
||||
@@ -83,6 +100,11 @@ export const useInvoicesStore = defineStore("invoices", () => {
|
||||
invoices.value = response.data.invoices || [];
|
||||
pagination.value.totalRecords = response.data.total_count || 0;
|
||||
|
||||
// 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 invoices";
|
||||
@@ -123,9 +145,10 @@ export const useInvoicesStore = defineStore("invoices", () => {
|
||||
invoices.value = [];
|
||||
isLoading.value = false;
|
||||
error.value = null;
|
||||
accountingPeriod.value = { an: null, luna: null };
|
||||
clearFilters();
|
||||
pagination.value = {
|
||||
page: 0,
|
||||
page: 1,
|
||||
rows: 50,
|
||||
totalRecords: 0,
|
||||
};
|
||||
@@ -140,6 +163,7 @@ export const useInvoicesStore = defineStore("invoices", () => {
|
||||
invoices,
|
||||
isLoading,
|
||||
error,
|
||||
accountingPeriod,
|
||||
filters,
|
||||
pagination,
|
||||
|
||||
|
||||
@@ -137,22 +137,34 @@ export const exportToPDF = (data, columns, filename, header) => {
|
||||
// Total usable width: pageWidth - marginLeft - marginRight
|
||||
const totalWidth = pageWidth - marginLeft - marginRight; // ~281mm for A4 landscape
|
||||
|
||||
// Define width allocation (proportional)
|
||||
// Cont: 7%, Denumire: 33%, Number columns (6x): 10% each = 100%
|
||||
const widthAllocations = {
|
||||
0: totalWidth * 0.07, // Cont: ~20mm
|
||||
1: totalWidth * 0.33, // Denumire: ~93mm
|
||||
2: totalWidth * 0.10, // Sold Prec D: ~28mm
|
||||
3: totalWidth * 0.10, // Sold Prec C: ~28mm
|
||||
4: totalWidth * 0.10, // Rulaj D: ~28mm
|
||||
5: totalWidth * 0.10, // Rulaj C: ~28mm
|
||||
6: totalWidth * 0.10, // Sold Final D: ~28mm
|
||||
7: totalWidth * 0.10, // Sold Final C: ~28mm
|
||||
};
|
||||
// Define width allocation (proportional) - support custom widths from columns
|
||||
const widthAllocations = {};
|
||||
|
||||
columns.forEach((col, index) => {
|
||||
// Use custom width if provided, otherwise auto
|
||||
if (col.width && typeof col.width === 'number') {
|
||||
widthAllocations[index] = totalWidth * col.width;
|
||||
} else if (col.width === 'auto') {
|
||||
widthAllocations[index] = 'auto';
|
||||
} else {
|
||||
// Default width allocation for Trial Balance (8 columns)
|
||||
const defaultWidths = {
|
||||
0: totalWidth * 0.07, // Cont: ~20mm
|
||||
1: totalWidth * 0.33, // Denumire: ~93mm
|
||||
2: totalWidth * 0.10, // Sold Prec D: ~28mm
|
||||
3: totalWidth * 0.10, // Sold Prec C: ~28mm
|
||||
4: totalWidth * 0.10, // Rulaj D: ~28mm
|
||||
5: totalWidth * 0.10, // Rulaj C: ~28mm
|
||||
6: totalWidth * 0.10, // Sold Final D: ~28mm
|
||||
7: totalWidth * 0.10, // Sold Final C: ~28mm
|
||||
};
|
||||
widthAllocations[index] = defaultWidths[index] || 'auto';
|
||||
}
|
||||
});
|
||||
|
||||
columns.forEach((col, index) => {
|
||||
columnStyles[index] = {
|
||||
cellWidth: widthAllocations[index] || 'auto'
|
||||
cellWidth: widthAllocations[index]
|
||||
};
|
||||
|
||||
// Set alignment based on type
|
||||
|
||||
@@ -53,6 +53,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Status -->
|
||||
<div class="form-col">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status Plată</label>
|
||||
<Dropdown
|
||||
v-model="filters.paymentStatus"
|
||||
:options="paymentStatusOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Status plată"
|
||||
class="w-full"
|
||||
@change="handleFilterChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Range -->
|
||||
<div class="form-col">
|
||||
<div class="form-group">
|
||||
@@ -81,7 +97,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="form-col search-col">
|
||||
<div class="form-col">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Căutare</label>
|
||||
<InputText
|
||||
@@ -92,6 +108,19 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cont Filter -->
|
||||
<div class="form-col">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Cont</label>
|
||||
<InputText
|
||||
v-model="filters.cont"
|
||||
placeholder="Filtru cont (ex: 4111)"
|
||||
class="w-full"
|
||||
@input="handleSearchChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters-actions">
|
||||
@@ -101,6 +130,20 @@
|
||||
class="p-button-outlined p-button-secondary"
|
||||
@click="clearFilters"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-file-excel"
|
||||
label="Export Excel"
|
||||
class="p-button-outlined p-button-success"
|
||||
@click="exportExcel"
|
||||
:disabled="!invoicesStore.hasInvoices"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-file-pdf"
|
||||
label="Export PDF"
|
||||
class="p-button-outlined p-button-danger"
|
||||
@click="exportPDF"
|
||||
:disabled="!invoicesStore.hasInvoices"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
label="Actualizează"
|
||||
@@ -112,64 +155,6 @@
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Summary Statistics -->
|
||||
<div
|
||||
v-if="companyStore.selectedCompany && invoicesStore.hasInvoices"
|
||||
class="summary-stats"
|
||||
>
|
||||
<Card class="stat-card stat-total">
|
||||
<template #content>
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<i class="pi pi-file-text"></i>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<h3 class="stat-value">{{ invoicesStore.totalInvoices }}</h3>
|
||||
<p class="stat-label">Total Facturi</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="stat-card stat-paid">
|
||||
<template #content>
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<i class="pi pi-check-circle"></i>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<h3 class="stat-value">
|
||||
{{ invoicesStore.paidInvoices.length }}
|
||||
</h3>
|
||||
<p class="stat-label">Achitate</p>
|
||||
<small class="stat-amount">{{
|
||||
formatCurrency(invoicesStore.totalAmountPaid)
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="stat-card stat-overdue">
|
||||
<template #content>
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<h3 class="stat-value">
|
||||
{{ invoicesStore.overdueInvoices.length }}
|
||||
</h3>
|
||||
<p class="stat-label">Restante</p>
|
||||
<small class="stat-amount">{{
|
||||
formatCurrency(invoicesStore.totalAmountOverdue)
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Invoices Table -->
|
||||
<Card v-if="companyStore.selectedCompany" class="table-card">
|
||||
<template #content>
|
||||
@@ -180,10 +165,10 @@
|
||||
:rows="pagination.rows"
|
||||
:total-records="invoicesStore.totalInvoices"
|
||||
:lazy="true"
|
||||
:striped-rows="true"
|
||||
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
:rows-per-page-options="[25, 50, 100]"
|
||||
current-page-report-template="Afișare {first} - {last} din {totalRecords} înregistrări"
|
||||
:row-class="getRowClass"
|
||||
responsive-layout="scroll"
|
||||
@page="onPageChange"
|
||||
@sort="onSort"
|
||||
@@ -202,119 +187,76 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column field="numar_document" header="Număr Document" sortable>
|
||||
<Column field="cont" header="Cont" sortable>
|
||||
<template #body="slotProps">
|
||||
<strong>{{ slotProps.data.numar_document }}</strong>
|
||||
{{ slotProps.data.cont || '-' }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="data_document" header="Data Document" sortable>
|
||||
<Column field="nract" header="Numar Doc." sortable>
|
||||
<template #body="slotProps">
|
||||
{{ formatDate(slotProps.data.data_document) }}
|
||||
{{ slotProps.data.nract }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="nume_partener" header="Partener" sortable>
|
||||
<Column field="dataact" header="Data Doc." sortable>
|
||||
<template #body="slotProps">
|
||||
<div class="partner-info">
|
||||
<span class="partner-name">{{
|
||||
slotProps.data.nume_partener
|
||||
}}</span>
|
||||
<small
|
||||
v-if="slotProps.data.cod_partener"
|
||||
class="partner-code"
|
||||
>
|
||||
{{ slotProps.data.cod_partener }}
|
||||
</small>
|
||||
{{ formatDate(slotProps.data.dataact) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="datascad" header="Data Scadenta" sortable>
|
||||
<template #body="slotProps">
|
||||
{{ formatDate(slotProps.data.datascad) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="nume" header="Partener" sortable>
|
||||
<template #body="slotProps">
|
||||
{{ slotProps.data.nume }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="totctva" header="Facturat" sortable>
|
||||
<template #body="slotProps">
|
||||
<div class="text-right">
|
||||
{{ formatNumber(slotProps.data.totctva) }}
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="suma" header="Sumă" sortable>
|
||||
<Column field="achitat" header="Achitat" sortable>
|
||||
<template #body="slotProps">
|
||||
<span class="amount" :class="getAmountClass(slotProps.data)">
|
||||
{{ formatCurrency(slotProps.data.suma) }}
|
||||
</span>
|
||||
<div class="text-right">
|
||||
{{ formatNumber(slotProps.data.achitat) }}
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="css_class" header="Status">
|
||||
<Column field="soldfinal" header="Sold" sortable>
|
||||
<template #body="slotProps">
|
||||
<Tag
|
||||
:value="getStatusText(slotProps.data.css_class)"
|
||||
:severity="getStatusSeverity(slotProps.data.css_class)"
|
||||
/>
|
||||
<div class="text-right">
|
||||
{{ formatNumber(slotProps.data.soldfinal) }}
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="data_scadenta" header="Data Scadență" sortable>
|
||||
<Column field="valuta" header="Valuta" sortable :style="{ width: '8%' }">
|
||||
<template #body="slotProps">
|
||||
{{ formatDate(slotProps.data.data_scadenta) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Acțiuni" :exportable="false">
|
||||
<template #body="slotProps">
|
||||
<div class="table-actions">
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
class="p-button-rounded p-button-text p-button-sm"
|
||||
v-tooltip="'Vezi detalii'"
|
||||
@click="viewInvoiceDetails(slotProps.data)"
|
||||
/>
|
||||
<div class="text-center">
|
||||
{{ slotProps.data.valuta || 'RON' }}
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<!-- Total Sold -->
|
||||
<div v-if="invoicesStore.hasInvoices" class="total-sold">
|
||||
<span class="total-sold-label">Total Sold:</span>
|
||||
<span class="total-sold-value">{{ formatCurrency(totalSold) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Invoice Details Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="showDetailsDialog"
|
||||
:header="`Detalii Factură ${selectedInvoice?.numar_document}`"
|
||||
:modal="true"
|
||||
:style="{ width: '50vw' }"
|
||||
:breakpoints="{ '960px': '75vw', '641px': '90vw' }"
|
||||
>
|
||||
<div v-if="selectedInvoice" class="invoice-details">
|
||||
<div class="details-grid">
|
||||
<div class="detail-item">
|
||||
<label>Număr Document:</label>
|
||||
<span>{{ selectedInvoice.numar_document }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Data Document:</label>
|
||||
<span>{{ formatDate(selectedInvoice.data_document) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Partener:</label>
|
||||
<span>{{ selectedInvoice.nume_partener }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Cod Partener:</label>
|
||||
<span>{{ selectedInvoice.cod_partener || "-" }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Sumă:</label>
|
||||
<span class="amount">{{
|
||||
formatCurrency(selectedInvoice.suma)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Status:</label>
|
||||
<Tag
|
||||
:value="getStatusText(selectedInvoice.css_class)"
|
||||
:severity="getStatusSeverity(selectedInvoice.css_class)"
|
||||
/>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>Data Scadență:</label>
|
||||
<span>{{ formatDate(selectedInvoice.data_scadenta) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -326,6 +268,7 @@ import { useCompanyStore } from "../stores/companies";
|
||||
import { useInvoicesStore } from "../stores/invoices";
|
||||
import { format } from "date-fns";
|
||||
import { ro } from "date-fns/locale";
|
||||
import { exportToExcel, exportToPDF } from "../utils/exportUtils";
|
||||
|
||||
const toast = useToast();
|
||||
const companyStore = useCompanyStore();
|
||||
@@ -333,19 +276,38 @@ const invoicesStore = useInvoicesStore();
|
||||
|
||||
// State
|
||||
const selectedCompanyId = ref(companyStore.selectedCompany?.id_firma || null);
|
||||
const showDetailsDialog = ref(false);
|
||||
const selectedInvoice = ref(null);
|
||||
|
||||
const filters = ref({
|
||||
type: "CLIENTI",
|
||||
paymentStatus: "neachitate", // Default to unpaid invoices
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
searchTerm: "",
|
||||
cont: "",
|
||||
});
|
||||
|
||||
const pagination = ref({
|
||||
page: 0,
|
||||
rows: 50,
|
||||
page: 1,
|
||||
rows: 100, // Changed from 50 to 100
|
||||
});
|
||||
|
||||
// Computed
|
||||
const totalSold = computed(() => {
|
||||
return invoicesStore.invoiceList.reduce((sum, invoice) => {
|
||||
return sum + (parseFloat(invoice.soldfinal) || 0);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const accountingPeriodText = computed(() => {
|
||||
const months = [
|
||||
"Ianuarie", "Februarie", "Martie", "Aprilie", "Mai", "Iunie",
|
||||
"Iulie", "August", "Septembrie", "Octombrie", "Noiembrie", "Decembrie"
|
||||
];
|
||||
const luna = invoicesStore.accountingPeriod.luna;
|
||||
const an = invoicesStore.accountingPeriod.an;
|
||||
if (!luna || !an) return "";
|
||||
const monthName = months[luna - 1] || "";
|
||||
return `${monthName} ${an}`;
|
||||
});
|
||||
|
||||
// Options
|
||||
@@ -354,6 +316,11 @@ const invoiceTypes = [
|
||||
{ label: "Furnizori", value: "FURNIZORI" },
|
||||
];
|
||||
|
||||
const paymentStatusOptions = [
|
||||
{ label: "Neachitate", value: "neachitate" },
|
||||
{ label: "Toate", value: "toate" },
|
||||
];
|
||||
|
||||
// Methods
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount) return "0,00 RON";
|
||||
@@ -363,6 +330,14 @@ const formatCurrency = (amount) => {
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatNumber = (amount) => {
|
||||
if (!amount || amount === 0) return "0,00";
|
||||
return new Intl.NumberFormat("ro-RO", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return "";
|
||||
try {
|
||||
@@ -372,43 +347,6 @@ const formatDate = (dateString) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (cssClass) => {
|
||||
switch (cssClass) {
|
||||
case "invoice-paid":
|
||||
return "Achitat";
|
||||
case "invoice-overdue":
|
||||
return "Restant";
|
||||
default:
|
||||
return "Neutru";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusSeverity = (cssClass) => {
|
||||
switch (cssClass) {
|
||||
case "invoice-paid":
|
||||
return "success";
|
||||
case "invoice-overdue":
|
||||
return "danger";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
};
|
||||
|
||||
const getRowClass = (data) => {
|
||||
return data.css_class || "";
|
||||
};
|
||||
|
||||
const getAmountClass = (invoice) => {
|
||||
switch (invoice.css_class) {
|
||||
case "invoice-paid":
|
||||
return "amount-paid";
|
||||
case "invoice-overdue":
|
||||
return "amount-overdue";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompanyChange = async () => {
|
||||
if (!selectedCompanyId.value) return;
|
||||
|
||||
@@ -420,7 +358,7 @@ const handleCompanyChange = async () => {
|
||||
};
|
||||
|
||||
const handleFilterChange = async () => {
|
||||
pagination.value.page = 0;
|
||||
pagination.value.page = 1;
|
||||
await loadInvoices();
|
||||
};
|
||||
|
||||
@@ -429,7 +367,7 @@ const handleSearchChange = (() => {
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(async () => {
|
||||
pagination.value.page = 0;
|
||||
pagination.value.page = 1;
|
||||
await loadInvoices();
|
||||
}, 500);
|
||||
};
|
||||
@@ -438,11 +376,13 @@ const handleSearchChange = (() => {
|
||||
const clearFilters = async () => {
|
||||
filters.value = {
|
||||
type: "CLIENTI",
|
||||
paymentStatus: "neachitate",
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
searchTerm: "",
|
||||
cont: "",
|
||||
};
|
||||
pagination.value.page = 0;
|
||||
pagination.value.page = 1;
|
||||
await loadInvoices();
|
||||
};
|
||||
|
||||
@@ -460,17 +400,38 @@ const loadInvoices = async () => {
|
||||
if (!companyStore.selectedCompany) return;
|
||||
|
||||
try {
|
||||
// Set filters in store FIRST
|
||||
invoicesStore.setFilters(filters.value);
|
||||
invoicesStore.setPagination(pagination.value);
|
||||
|
||||
await invoicesStore.loadInvoices(companyStore.selectedCompany.id_firma, {
|
||||
tip: filters.value.type,
|
||||
date_from: filters.value.dateFrom?.toISOString().split("T")[0],
|
||||
date_to: filters.value.dateTo?.toISOString().split("T")[0],
|
||||
search: filters.value.searchTerm,
|
||||
const params = {
|
||||
partner_type: filters.value.type, // FIX: Add partner_type filter
|
||||
page: pagination.value.page,
|
||||
size: pagination.value.rows,
|
||||
});
|
||||
page_size: pagination.value.rows,
|
||||
only_unpaid: filters.value.paymentStatus === "neachitate", // Use filter value
|
||||
};
|
||||
|
||||
// Add optional filters (use LOCAL date, not UTC)
|
||||
if (filters.value.dateFrom) {
|
||||
const year = filters.value.dateFrom.getFullYear();
|
||||
const month = String(filters.value.dateFrom.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(filters.value.dateFrom.getDate()).padStart(2, '0');
|
||||
params.date_from = `${year}-${month}-${day}`;
|
||||
}
|
||||
if (filters.value.dateTo) {
|
||||
const year = filters.value.dateTo.getFullYear();
|
||||
const month = String(filters.value.dateTo.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(filters.value.dateTo.getDate()).padStart(2, '0');
|
||||
params.date_to = `${year}-${month}-${day}`;
|
||||
}
|
||||
if (filters.value.searchTerm) {
|
||||
params.partner_name = filters.value.searchTerm; // FIX: Use partner_name not search
|
||||
}
|
||||
if (filters.value.cont) {
|
||||
params.cont = filters.value.cont;
|
||||
}
|
||||
|
||||
await invoicesStore.loadInvoices(companyStore.selectedCompany.id_firma, params);
|
||||
} catch (error) {
|
||||
console.error("Failed to load invoices:", error);
|
||||
toast.add({
|
||||
@@ -483,7 +444,8 @@ const loadInvoices = async () => {
|
||||
};
|
||||
|
||||
const onPageChange = async (event) => {
|
||||
pagination.value.page = event.page;
|
||||
// PrimeVue pagination is 0-indexed, backend expects 1-indexed
|
||||
pagination.value.page = event.page + 1;
|
||||
pagination.value.rows = event.rows;
|
||||
await loadInvoices();
|
||||
};
|
||||
@@ -493,9 +455,213 @@ const onSort = async (event) => {
|
||||
await loadInvoices();
|
||||
};
|
||||
|
||||
const viewInvoiceDetails = (invoice) => {
|
||||
selectedInvoice.value = invoice;
|
||||
showDetailsDialog.value = true;
|
||||
// Export methods - Fetch ALL data (not just current page)
|
||||
const fetchAllInvoicesData = async () => {
|
||||
if (!companyStore.selectedCompany) return [];
|
||||
|
||||
try {
|
||||
const params = {
|
||||
company: companyStore.selectedCompany.id_firma,
|
||||
partner_type: filters.value.type, // FIX: Correctly pass partner_type
|
||||
page: 1,
|
||||
page_size: 999999, // Get all data
|
||||
only_unpaid: filters.value.paymentStatus === "neachitate", // Use filter value for export
|
||||
};
|
||||
|
||||
// Add optional filters (use LOCAL date, not UTC)
|
||||
if (filters.value.dateFrom) {
|
||||
const year = filters.value.dateFrom.getFullYear();
|
||||
const month = String(filters.value.dateFrom.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(filters.value.dateFrom.getDate()).padStart(2, '0');
|
||||
params.date_from = `${year}-${month}-${day}`;
|
||||
}
|
||||
if (filters.value.dateTo) {
|
||||
const year = filters.value.dateTo.getFullYear();
|
||||
const month = String(filters.value.dateTo.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(filters.value.dateTo.getDate()).padStart(2, '0');
|
||||
params.date_to = `${year}-${month}-${day}`;
|
||||
}
|
||||
if (filters.value.searchTerm) {
|
||||
params.partner_name = filters.value.searchTerm; // FIX: Use partner_name not search
|
||||
}
|
||||
if (filters.value.cont) {
|
||||
params.cont = filters.value.cont;
|
||||
}
|
||||
|
||||
const apiService = (await import("../services/api")).apiService;
|
||||
const response = await apiService.get("/invoices/", { params });
|
||||
|
||||
return response.data.invoices || [];
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch all invoices data:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const exportExcel = async () => {
|
||||
if (!invoicesStore.hasInvoices) {
|
||||
toast.add({
|
||||
severity: "warn",
|
||||
summary: "Nu există date",
|
||||
detail: "Nu există facturi de exportat",
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch ALL data for export (not just current page)
|
||||
toast.add({
|
||||
severity: "info",
|
||||
summary: "Se pregătește exportul",
|
||||
detail: "Se încarcă toate datele...",
|
||||
life: 2000,
|
||||
});
|
||||
|
||||
const allData = await fetchAllInvoicesData();
|
||||
|
||||
if (allData.length === 0) {
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: "Eroare",
|
||||
detail: "Nu s-au putut prelua datele pentru export",
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare data for export - Format dates as strings for Excel
|
||||
const exportData = allData.map((row) => ({
|
||||
"Cont": row.cont || "",
|
||||
"Numar Doc.": row.nract,
|
||||
"Data Doc.": row.dataact ? formatDate(row.dataact) : "",
|
||||
"Data Scadenta": row.datascad ? formatDate(row.datascad) : "",
|
||||
"Partener": row.nume,
|
||||
"Facturat": parseFloat(row.totctva) || 0,
|
||||
"Achitat": parseFloat(row.achitat) || 0,
|
||||
"Sold": parseFloat(row.soldfinal) || 0,
|
||||
"Valuta": row.valuta || "RON",
|
||||
}));
|
||||
|
||||
const invoiceType = filters.value.type === "CLIENTI" ? "Clienti" : "Furnizori";
|
||||
const result = exportToExcel(
|
||||
exportData,
|
||||
`facturi_${invoiceType}_${companyStore.selectedCompany.name.replace(/\s+/g, "_")}`,
|
||||
`Facturi ${invoiceType}`
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.add({
|
||||
severity: "success",
|
||||
summary: "Export reușit",
|
||||
detail: `${allData.length} facturi exportate cu succes`,
|
||||
life: 3000,
|
||||
});
|
||||
} else {
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: "Eroare la export",
|
||||
detail: "Nu s-a putut genera fișierul Excel",
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const exportPDF = async () => {
|
||||
if (!invoicesStore.hasInvoices) {
|
||||
toast.add({
|
||||
severity: "warn",
|
||||
summary: "Nu există date",
|
||||
detail: "Nu există facturi de exportat",
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch ALL data for export (not just current page)
|
||||
toast.add({
|
||||
severity: "info",
|
||||
summary: "Se pregătește exportul",
|
||||
detail: "Se încarcă toate datele...",
|
||||
life: 2000,
|
||||
});
|
||||
|
||||
const allData = await fetchAllInvoicesData();
|
||||
|
||||
if (allData.length === 0) {
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: "Eroare",
|
||||
detail: "Nu s-au putut prelua datele pentru export",
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare data for export - Format dates as strings for PDF
|
||||
const exportData = allData.map((row) => ({
|
||||
cont: row.cont || "",
|
||||
nract: row.nract,
|
||||
dataact: row.dataact ? formatDate(row.dataact) : "",
|
||||
datascad: row.datascad ? formatDate(row.datascad) : "",
|
||||
nume: row.nume,
|
||||
totctva: row.totctva,
|
||||
achitat: row.achitat,
|
||||
soldfinal: row.soldfinal,
|
||||
valuta: row.valuta || "RON",
|
||||
}));
|
||||
|
||||
// Define columns for PDF with optimized widths (proportional percentages)
|
||||
// Compact numeric columns, more space for Partener (company names)
|
||||
const columns = [
|
||||
{ field: "cont", header: "Cont", type: "text", width: 0.06 }, // 6% - Compact account numbers
|
||||
{ field: "nract", header: "Numar Doc.", type: "text", width: 0.08 }, // 8% - Document numbers
|
||||
{ field: "dataact", header: "Data Doc.", type: "text", width: 0.08 }, // 8% - Dates
|
||||
{ field: "datascad", header: "Data Scadenta", type: "text", width: 0.09 }, // 9% - Due dates
|
||||
{ field: "nume", header: "Partener", type: "text", width: 0.37 }, // 37% - MORE SPACE for company names
|
||||
{ field: "totctva", header: "Facturat", type: "number", width: 0.09 }, // 9% - Compact numbers
|
||||
{ field: "achitat", header: "Achitat", type: "number", width: 0.09 }, // 9% - Compact numbers
|
||||
{ field: "soldfinal", header: "Sold", type: "number", width: 0.09 }, // 9% - Compact numbers
|
||||
{ field: "valuta", header: "Valuta", type: "text", width: 0.05 }, // 5% - Very compact (just "RON")
|
||||
];
|
||||
|
||||
const invoiceType = filters.value.type === "CLIENTI" ? "Clienti" : "Furnizori";
|
||||
|
||||
// Build period string - ALWAYS show accounting period (like Trial Balance)
|
||||
let periodText = accountingPeriodText.value || "";
|
||||
|
||||
// Optionally add date filter range if applied
|
||||
if (filters.value.dateFrom || filters.value.dateTo) {
|
||||
const fromDate = filters.value.dateFrom ? formatDate(filters.value.dateFrom) : "început";
|
||||
const toDate = filters.value.dateTo ? formatDate(filters.value.dateTo) : "prezent";
|
||||
periodText += periodText ? ` | Filtru dată: ${fromDate} - ${toDate}` : `Filtru dată: ${fromDate} - ${toDate}`;
|
||||
}
|
||||
|
||||
const result = exportToPDF(
|
||||
exportData,
|
||||
columns,
|
||||
`facturi-${invoiceType.toLowerCase()}-${companyStore.selectedCompany.name.replace(/\s+/g, "-")}`,
|
||||
{
|
||||
companyName: companyStore.selectedCompany?.name || "",
|
||||
title: `Facturi ${invoiceType}`,
|
||||
period: periodText
|
||||
}
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.add({
|
||||
severity: "success",
|
||||
summary: "Export reușit",
|
||||
detail: `${allData.length} facturi exportate cu succes`,
|
||||
life: 3000,
|
||||
});
|
||||
} else {
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: "Eroare la export",
|
||||
detail: "Nu s-a putut genera fișierul PDF",
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
@@ -566,98 +732,10 @@ watch(
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.stat-card.stat-total {
|
||||
border-left-color: var(--blue-500);
|
||||
}
|
||||
|
||||
.stat-card.stat-paid {
|
||||
border-left-color: var(--green-500);
|
||||
}
|
||||
|
||||
.stat-card.stat-overdue {
|
||||
border-left-color: var(--red-500);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.stat-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.stat-amount {
|
||||
color: var(--text-color-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.partner-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.partner-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.partner-code {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amount-paid {
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.amount-overdue {
|
||||
color: var(--red-600);
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.no-data,
|
||||
.loading-table {
|
||||
display: flex;
|
||||
@@ -674,34 +752,58 @@ watch(
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.invoice-details {
|
||||
padding: 1rem 0;
|
||||
.text-right {
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
.text-center {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
.total-sold {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
border-top: 2px solid var(--surface-border);
|
||||
background-color: var(--surface-50);
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
.total-sold-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detail-item span {
|
||||
font-weight: 500;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Row styling based on status - Defined globally in App.vue */
|
||||
.total-sold-value {
|
||||
font-weight: 700;
|
||||
font-size: 1.3rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Enhanced striped rows with better contrast - same as Trial Balance */
|
||||
.table-card :deep(.p-datatable .p-datatable-tbody > tr) {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.table-card :deep(.p-datatable .p-datatable-tbody > tr:nth-child(odd)) {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.table-card :deep(.p-datatable .p-datatable-tbody > tr:nth-child(even)) {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.table-card :deep(.p-datatable .p-datatable-tbody > tr:hover) {
|
||||
background-color: #e3f2fd !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
@@ -717,16 +819,8 @@ watch(
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filters-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,12 +36,29 @@ test.describe('Invoices View', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Mock invoices endpoint
|
||||
await page.route('**/api/invoices/COMP1', async route => {
|
||||
// Mock invoices endpoint - FIX: Use query parameters instead of path parameter
|
||||
await page.route('**/api/invoices**', async route => {
|
||||
const url = route.request().url();
|
||||
const urlParams = new URL(url).searchParams;
|
||||
const partnerType = urlParams.get('partner_type') || 'CLIENTI';
|
||||
|
||||
// Return different data based on partner_type
|
||||
const invoicesData = partnerType === 'CLIENTI'
|
||||
? mockInvoices.filter(inv => inv.type === 'client')
|
||||
: mockInvoices.filter(inv => inv.type === 'supplier');
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockInvoices),
|
||||
body: JSON.stringify({
|
||||
invoices: invoicesData,
|
||||
total_count: invoicesData.length,
|
||||
filtered_count: invoicesData.length,
|
||||
total_amount: invoicesData.reduce((sum, inv) => sum + inv.totctva, 0),
|
||||
page: parseInt(urlParams.get('page') || '1'),
|
||||
page_size: parseInt(urlParams.get('page_size') || '50'),
|
||||
has_more: false
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,12 +220,225 @@ test.describe('Invoices View', () => {
|
||||
await invoicesPage.waitForPageLoad();
|
||||
await invoicesPage.selectCompany('Compania Test 1');
|
||||
await page.waitForSelector(invoicesPage.invoicesTable);
|
||||
|
||||
|
||||
// Click refresh button
|
||||
await invoicesPage.clickRefreshButton();
|
||||
await invoicesPage.waitForLoadingToFinish();
|
||||
|
||||
|
||||
// Table should still be visible after refresh
|
||||
expect(await invoicesPage.isInvoicesTableVisible()).toBe(true);
|
||||
});
|
||||
|
||||
// NEW TESTS for fixed issues
|
||||
|
||||
test('should filter by invoice type (CLIENTI/FURNIZORI)', async ({ page }) => {
|
||||
let capturedPartnerType = null;
|
||||
|
||||
// Intercept API requests to verify partner_type parameter
|
||||
await page.route('**/api/invoices**', async route => {
|
||||
const url = route.request().url();
|
||||
const urlParams = new URL(url).searchParams;
|
||||
capturedPartnerType = urlParams.get('partner_type');
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
invoices: [],
|
||||
total_count: 0,
|
||||
filtered_count: 0,
|
||||
total_amount: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
has_more: false
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await invoicesPage.waitForPageLoad();
|
||||
await invoicesPage.selectCompany('Compania Test 1');
|
||||
await page.waitForSelector(invoicesPage.invoicesTable);
|
||||
|
||||
// Select FURNIZORI from dropdown
|
||||
await page.locator('[placeholder="Tip factură"]').click();
|
||||
await page.locator('.p-dropdown-item').filter({ hasText: 'Furnizori' }).click();
|
||||
await page.waitForTimeout(1000); // Wait for API call
|
||||
|
||||
// Verify partner_type parameter was sent correctly
|
||||
expect(capturedPartnerType).toBe('FURNIZORI');
|
||||
});
|
||||
|
||||
test('should filter by cont (account number)', async ({ page }) => {
|
||||
let capturedCont = null;
|
||||
|
||||
// Intercept API requests to verify cont parameter
|
||||
await page.route('**/api/invoices**', async route => {
|
||||
const url = route.request().url();
|
||||
const urlParams = new URL(url).searchParams;
|
||||
capturedCont = urlParams.get('cont');
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
invoices: [],
|
||||
total_count: 0,
|
||||
filtered_count: 0,
|
||||
total_amount: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
has_more: false
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await invoicesPage.waitForPageLoad();
|
||||
await invoicesPage.selectCompany('Compania Test 1');
|
||||
await page.waitForSelector(invoicesPage.invoicesTable);
|
||||
|
||||
// Enter cont filter
|
||||
await page.locator('[placeholder="Filtru cont (ex: 4111)"]').fill('4111');
|
||||
await page.waitForTimeout(1000); // Wait for debounced API call
|
||||
|
||||
// Verify cont parameter was sent correctly
|
||||
expect(capturedCont).toBe('4111');
|
||||
});
|
||||
|
||||
test('should use partner_name parameter for search', async ({ page }) => {
|
||||
let capturedPartnerName = null;
|
||||
let capturedSearchParam = null;
|
||||
|
||||
// Intercept API requests to verify correct parameter name
|
||||
await page.route('**/api/invoices**', async route => {
|
||||
const url = route.request().url();
|
||||
const urlParams = new URL(url).searchParams;
|
||||
capturedPartnerName = urlParams.get('partner_name');
|
||||
capturedSearchParam = urlParams.get('search');
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
invoices: [],
|
||||
total_count: 0,
|
||||
filtered_count: 0,
|
||||
total_amount: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
has_more: false
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await invoicesPage.waitForPageLoad();
|
||||
await invoicesPage.selectCompany('Compania Test 1');
|
||||
await page.waitForSelector(invoicesPage.invoicesTable);
|
||||
|
||||
// Search for partner name
|
||||
await page.locator('[placeholder="Căutați după număr, partener..."]').fill('Test Partner');
|
||||
await page.waitForTimeout(1000); // Wait for debounced API call
|
||||
|
||||
// Verify partner_name parameter was sent (not search)
|
||||
expect(capturedPartnerName).toBe('Test Partner');
|
||||
expect(capturedSearchParam).toBeNull();
|
||||
});
|
||||
|
||||
test('should export XLSX with all filters applied', async ({ page }) => {
|
||||
let exportRequestParams = null;
|
||||
|
||||
// Intercept export API request
|
||||
await page.route('**/api/invoices**', async route => {
|
||||
const url = route.request().url();
|
||||
const urlParams = new URL(url).searchParams;
|
||||
|
||||
// Capture params if it's the export request (page_size = 999999)
|
||||
if (urlParams.get('page_size') === '999999') {
|
||||
exportRequestParams = {
|
||||
partner_type: urlParams.get('partner_type'),
|
||||
partner_name: urlParams.get('partner_name'),
|
||||
cont: urlParams.get('cont'),
|
||||
page_size: urlParams.get('page_size')
|
||||
};
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
invoices: [
|
||||
{
|
||||
cont: '4111',
|
||||
nract: 'INV001',
|
||||
dataact: '2024-01-01',
|
||||
datascad: '2024-02-01',
|
||||
nume: 'Test Client',
|
||||
totctva: 1000,
|
||||
achitat: 500,
|
||||
soldfinal: 500
|
||||
}
|
||||
],
|
||||
total_count: 1,
|
||||
filtered_count: 1,
|
||||
total_amount: 1000,
|
||||
page: 1,
|
||||
page_size: 999999,
|
||||
has_more: false
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await invoicesPage.waitForPageLoad();
|
||||
await invoicesPage.selectCompany('Compania Test 1');
|
||||
await page.waitForSelector(invoicesPage.invoicesTable);
|
||||
|
||||
// Apply filters before export
|
||||
await page.locator('[placeholder="Tip factură"]').click();
|
||||
await page.locator('.p-dropdown-item').filter({ hasText: 'Furnizori' }).click();
|
||||
await page.locator('[placeholder="Filtru cont (ex: 4111)"]').fill('4111');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Click Excel export
|
||||
const downloadPromise = page.waitForEvent('download', { timeout: 10000 }).catch(() => null);
|
||||
await page.locator('button:has-text("Export Excel")').click();
|
||||
await page.waitForTimeout(2000); // Wait for export to complete
|
||||
|
||||
// Verify export request included all filters
|
||||
expect(exportRequestParams).toBeTruthy();
|
||||
expect(exportRequestParams.partner_type).toBe('FURNIZORI');
|
||||
expect(exportRequestParams.cont).toBe('4111');
|
||||
expect(exportRequestParams.page_size).toBe('999999');
|
||||
|
||||
// Download may or may not occur due to mock, but we verified the API call
|
||||
await downloadPromise;
|
||||
});
|
||||
|
||||
test('should have hover effect on table rows', async ({ page }) => {
|
||||
await invoicesPage.waitForPageLoad();
|
||||
await invoicesPage.selectCompany('Compania Test 1');
|
||||
await page.waitForSelector(invoicesPage.invoicesTable);
|
||||
|
||||
// Wait for table rows to load
|
||||
const firstRow = page.locator('.p-datatable-tbody tr').first();
|
||||
await firstRow.waitFor();
|
||||
|
||||
// Get initial background color
|
||||
const initialBgColor = await firstRow.evaluate(el =>
|
||||
window.getComputedStyle(el).backgroundColor
|
||||
);
|
||||
|
||||
// Hover over the row
|
||||
await firstRow.hover();
|
||||
await page.waitForTimeout(300); // Wait for transition
|
||||
|
||||
// Get background color after hover
|
||||
const hoverBgColor = await firstRow.evaluate(el =>
|
||||
window.getComputedStyle(el).backgroundColor
|
||||
);
|
||||
|
||||
// Background color should change on hover
|
||||
expect(hoverBgColor).not.toBe(initialBgColor);
|
||||
|
||||
// Verify hover color is the expected blue (#e3f2fd = rgb(227, 242, 253))
|
||||
expect(hoverBgColor).toBe('rgb(227, 242, 253)');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user