feat: Add shared components, refactor stores, improve data-entry workflow

Shared Components:
- Add CompanySelector.vue and PeriodSelector.vue components
- Add AppHeader.vue and SlideMenu.vue layout components
- Add shared stores factories (companies.js, accountingPeriod.js)
- Add shared routes factories (companies.py, calendar.py)
- Add shared models (company.py, calendar.py)
- Add shared layout styles (header.css, navigation.css)

Data Entry App:
- Update CLAUDE.md with prod/test server documentation
- Improve nomenclature sync service with better error handling
- Update receipts router and CRUD operations
- Add company/period stores using shared factories
- Update App.vue layout with shared components
- Fix OCRUploadZone file handling

Reports App:
- Refactor stores to use shared factories
- Update App.vue to use shared layout components

Infrastructure:
- Replace start-data-entry.sh with separate dev/test scripts
- Add .claude/rules for authentication, backend patterns, etc.
- Add implementation plan for OCR receipt improvements
- Clean up old documentation files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 15:00:45 +02:00
parent c5fde510a8
commit 1a6e9b17d2
47 changed files with 4079 additions and 2595 deletions

View File

@@ -0,0 +1,17 @@
# Authentication Rules
## JWT Token Structure (IMMUTABLE)
All apps use the same token payload:
- `username`, `user_id`, `companies[]`, `permissions[]`, `exp`, `iat`, `type`
## Backend Rules
- Use `AuthenticationMiddleware` from `shared/auth/middleware.py`
- Use `get_current_user` dependency from `shared/auth/dependencies.py`
- Never implement custom auth logic in routers
- Rate limiting: 5 req/5 min for /auth/* paths
## Frontend Rules
- Use `createAuthStore(apiService)` factory from `shared/frontend/stores/auth.js`
- Use `LoginView.vue` component from `shared/frontend/components/`
- Store tokens in localStorage: `access_token`, `refresh_token`, `user`
- Initialize auth on app startup with `initializeAuth()`

View File

@@ -0,0 +1,44 @@
---
paths: {reports-app,data-entry-app}/backend/**/*.py
---
# Backend Patterns
## Router Factory Pattern
Use shared router factories instead of custom implementations:
```python
from shared.routes.companies import create_companies_router
from shared.routes.calendar import create_calendar_router
companies_router = create_companies_router(oracle_pool, cache_decorator=cached)
app.include_router(companies_router, prefix="/api/companies")
```
## Database Queries
- All queries use parameterized Oracle queries (no SQL injection)
- Schema lookup: `SELECT SCHEMA FROM CONTAFIN_ORACLE.V_NOM_FIRME`
- Company access: Join V_NOM_FIRME with VDEF_UTIL_FIRME
## Caching (reports-app)
- Use `@cached` decorator from `app/cache/decorators`
- Place logic in services, not routers
- Cache schema lookups (24h TTL)
- Cache user data (10-30 min TTL)
## Error Handling
```python
try:
# Business logic
except HTTPException:
raise # Re-raise HTTP exceptions
except Exception as e:
logger.error(...)
raise HTTPException(500, "Internal error")
```
## Oracle Pool Pattern
```python
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(query, params)
```

View File

@@ -0,0 +1,19 @@
# Company & Period Selection Rules
## Store Factories (MANDATORY)
- Companies: Use `createCompaniesStore()` from `shared/frontend/stores/companies.js`
- Periods: Use `createAccountingPeriodStore()` from `shared/frontend/stores/accountingPeriod.js`
- Never implement custom company/period stores
## Components
- Use `CompanySelector.vue` from `shared/frontend/components/`
- Use `PeriodSelector.vue` from `shared/frontend/components/`
- Use `AppHeader.vue` layout from `shared/frontend/components/layout/`
## Backend Endpoints
- Use `create_companies_router()` factory from `shared/routes/companies.py`
- Use `create_calendar_router()` factory from `shared/routes/calendar.py`
## Company Access Validation (2-step)
1. Check `current_user.companies` (fast, from JWT)
2. Validate against Oracle DB (authoritative)

View File

@@ -0,0 +1,26 @@
---
paths: {reports-app,data-entry-app}/frontend/**/*.{vue,css}
---
# CSS Design System Rules
## Documentation (READ FIRST)
- **Quick Start**: `docs/ONBOARDING_CSS.md` (5 min read)
- **Complete Patterns**: `docs/CSS_PATTERNS.md` (cards, forms, buttons, tables, etc.)
- **Design Tokens**: `docs/DESIGN_TOKENS.md` (colors, spacing, typography variables)
## Core Principles
- Use CSS variables from design tokens, NEVER hardcoded values
- Check `CSS_PATTERNS.md` BEFORE writing any CSS
- Import shared styles from `shared/frontend/styles/`
## Shared Styles to Import
- Login: `@import 'shared/frontend/styles/login.css'`
- Header: `@import 'shared/frontend/styles/layout/header.css'`
- Navigation: `@import 'shared/frontend/styles/layout/navigation.css'`
## NEVER
- Use `:deep()` for PrimeVue overrides (use `vendor/` files)
- Duplicate patterns that exist in CSS_PATTERNS.md
- Use hardcoded colors like `#2563eb` (use `var(--color-primary)`)
- Create scoped CSS for patterns that already exist in shared files

View File

@@ -0,0 +1,31 @@
---
paths: {reports-app,data-entry-app}/frontend/src/stores/**/*.js
---
# Frontend Store Rules
## Factory Pattern (MANDATORY)
All stores MUST use shared factories:
```javascript
// Correct - use factory
import { createAuthStore } from '@shared/frontend/stores/auth';
export const useAuthStore = createAuthStore(apiService);
// Correct - use factory
import { createCompaniesStore } from '@shared/frontend/stores/companies';
export const useCompaniesStore = createCompaniesStore(apiService, useAuthStore);
// WRONG - custom implementation
export const useCompaniesStore = defineStore('companies', () => { ... });
```
## Available Factories
- `createAuthStore(apiService)` - Authentication state
- `createCompaniesStore(apiService, useAuthStore)` - Company selection
- `createAccountingPeriodStore(apiService, useAuthStore, useCompanyStore)` - Period selection
## LocalStorage Keys (RESERVED)
- `access_token`, `refresh_token`, `user` - Auth
- `selected_company_id` - Company selection
- `selected_period_id` - Period selection

View File

@@ -0,0 +1,386 @@
# Plan: Receipt Scanning Workflow Improvements
> **Context Handover Document** - Created for session continuity
> **Date**: 2025-12-15
> **Status**: Ready for implementation
## Overview
Improve the data-entry-app receipt scanning to:
1. Save supplier name, CUI, and OCR text in drafts
2. Make supplier validation assistive (not blocking)
3. Unify create/edit forms with OCR rescan capability
4. Fix image resize bug (>4000px)
5. **NEW: Extract payment methods (CARD/NUMERAR) from OCR**
## Requirements Summary
- **Drafts**: Save `cui` + `partner_name` + `ocr_raw_text` + `payment_methods` from OCR
- **Supplier match**: Auto-fill but editable (for assistance, not validation)
- **No match**: Show warning only, allow saving draft
- **Edit mode**: Allow OCR rescan on existing drafts
- **Approval**: Requires valid `cui` only (NOT partner_id) - ROA has stored procedure for supplier lookup
- **Image resize**: Cap at 4000px BEFORE upscaling
- **Payment methods**: Extract CARD/NUMERAR amounts (after TOTAL LEI, before TOTAL TVA)
---
## Part 1: Backend Model & Database
### 1.1 Add Fields to Receipt Model
**File**: `data-entry-app/backend/app/db/models/receipt.py`
Add after line 66 (after `partner_name`):
```python
cui: Optional[str] = Field(default=None, max_length=20) # Fiscal code from OCR
ocr_raw_text: Optional[str] = Field(default=None) # Raw OCR text for debugging
payment_methods: Optional[str] = Field(default=None, max_length=500) # JSON: [{"method":"CARD","amount":"50.00"}]
```
### 1.2 Create Alembic Migration
**File**: `data-entry-app/backend/migrations/versions/XXXX_add_ocr_fields.py`
```python
def upgrade():
with op.batch_alter_table('receipts') as batch_op:
batch_op.add_column(sa.Column('cui', sa.String(20), nullable=True))
batch_op.add_column(sa.Column('ocr_raw_text', sa.Text(), nullable=True))
batch_op.add_column(sa.Column('payment_methods', sa.String(500), nullable=True))
```
### 1.3 Update Pydantic Schemas
**File**: `data-entry-app/backend/app/schemas/receipt.py`
**Add PaymentMethodSchema** (after TvaEntrySchema ~line 75):
```python
class PaymentMethodSchema(BaseModel):
"""Payment method entry (CARD/NUMERAR)."""
method: str = Field(description="Payment method: CARD or NUMERAR")
amount: Decimal = Field(description="Amount paid with this method")
```
**ReceiptBase** (after line 97):
```python
cui: Optional[str] = Field(default=None, max_length=20)
ocr_raw_text: Optional[str] = Field(default=None)
payment_methods: Optional[List[PaymentMethodSchema]] = Field(default=None, description="Payment methods from OCR")
```
**ReceiptUpdate** (after line 125):
```python
cui: Optional[str] = Field(default=None, max_length=20)
ocr_raw_text: Optional[str] = Field(default=None)
payment_methods: Optional[List[PaymentMethodSchema]] = Field(default=None)
```
**ReceiptResponse**: Add validator to parse `payment_methods` from JSON (similar to `parse_tva_breakdown`)
---
## Part 2: Fix Image Resize Bug
**File**: `data-entry-app/backend/app/services/image_preprocessor.py`
### 2.1 Update `preprocess_light()` (after line 55)
Add downscale BEFORE upscale:
```python
# 2a. Scale DOWN if any side exceeds 4000px (PaddleOCR limit)
height, width = gray.shape
max_side = max(height, width)
if max_side > 4000:
scale = 4000 / max_side
gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
height, width = gray.shape
# 2b. Scale UP if too small
if width < 1500:
scale = 1500 / width
gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
```
### 2.2 Update `preprocess_heavy()` (after line 82)
Same downscale logic before the existing upscale at lines 85-88.
---
## Part 3: Backend OCR Endpoint - Return Raw Text
**File**: `data-entry-app/backend/app/routers/ocr.py`
Ensure the OCR extraction endpoint returns `raw_text` in the response (verify this is already included in the OCR service output).
---
## Part 4: Frontend Form Unification
### 4.1 Unify OCR Zone for Create & Edit
**File**: `data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue`
**Change line 19** from:
```vue
<div class="upload-section" v-if="!isEditMode">
```
to:
```vue
<div class="upload-section">
```
**Update header text** (around line 23):
```vue
<h3>
<i class="pi pi-camera"></i>
{{ isEditMode ? 'Re-scanare OCR (opțional)' : 'Poză Bon (obligatoriu)' }}
</h3>
```
### 4.2 Add CUI Field to Form State
**File**: `data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue`
Add to form ref initialization:
```javascript
cui: '',
ocr_raw_text: '',
```
### 4.3 Add CUI Display Field
Add after Furnizor dropdown (around line 210):
```vue
<div class="form-field">
<label>CUI (Cod Fiscal)</label>
<InputText v-model="form.cui" placeholder="Ex: RO12345678" />
<small v-if="form.cui && !form.partner_id" class="p-text-warning">
<i class="pi pi-exclamation-triangle"></i>
CUI negăsit în nomenclator
</small>
</div>
```
### 4.4 Change Supplier Dialog to Warning Banner
**Current behavior** (lines 555-563): When CUI not found, opens blocking dialog.
**New behavior**: Show non-blocking warning message.
Replace the `else` block in `applyOCRData()`:
```javascript
} else {
// Not found - show warning but allow continuing
supplierWarning.value = {
show: true,
cui: data.cui,
name: data.partner_name || ''
}
// Still set form values from OCR
form.value.cui = data.cui
form.value.partner_name = data.partner_name || ''
toast.add({
severity: 'warn',
summary: 'Furnizor negăsit',
detail: `CUI ${data.cui} nu a fost găsit în nomenclator`,
life: 5000
})
}
```
Add ref for warning state:
```javascript
const supplierWarning = ref({ show: false, cui: '', name: '' })
```
### 4.5 Update `applyOCRData()` to Save Raw Text
Add to the function:
```javascript
if (data.cui) form.value.cui = data.cui
if (data.raw_text) form.value.ocr_raw_text = data.raw_text
```
### 4.6 Update `loadReceipt()` for Edit Mode
Add to existing field mapping:
```javascript
cui: receipt.value.cui || '',
ocr_raw_text: receipt.value.ocr_raw_text || '',
```
---
## Part 5: Backend Approval Validation
**File**: `data-entry-app/backend/app/services/receipt_service.py`
In `approve_receipt()` method, add validation:
```python
if not receipt.cui:
return False, "Trebuie completat codul fiscal (CUI) pentru aprobare", None
```
**Note**: At approval, only `cui` (fiscal code) is required, NOT `partner_id`.
The ROA ERP has a stored procedure that searches/creates suppliers based on `cui`.
The `partner_id` is only populated later during Oracle import phase.
---
## Part 6: OCR Payment Methods Extraction
### 6.1 Update ExtractionResult Dataclass
**File**: `data-entry-app/backend/app/services/ocr_extractor.py`
Add to `ExtractionResult` (after line 24, after `items_count`):
```python
payment_methods: List[dict] = field(default_factory=list) # [{"method":"CARD","amount":Decimal}]
```
### 6.2 Add Payment Method Patterns
**File**: `data-entry-app/backend/app/services/ocr_extractor.py`
Add new patterns (after TVA_PATTERNS ~line 184):
```python
# Payment method patterns - appears after TOTAL LEI, before TOTAL TVA
# Format: "CARD: 50.00" or "NUMERAR 100.00" or "PLATA CARD: 50.00"
PAYMENT_METHOD_PATTERNS = [
# CARD with amount
(r'(?:PLATA\s+)?CARD\s*:?\s*([\d\s.,]+)', 'CARD', 0.95),
# NUMERAR (cash) with amount
(r'NUMERAR\s*:?\s*([\d\s.,]+)', 'NUMERAR', 0.95),
# CASH alternative spelling
(r'CASH\s*:?\s*([\d\s.,]+)', 'NUMERAR', 0.90),
]
```
### 6.3 Add Extraction Method
**File**: `data-entry-app/backend/app/services/ocr_extractor.py`
Add new method `_extract_payment_methods()` (after `_extract_address` ~line 996):
```python
def _extract_payment_methods(self, text: str) -> List[dict]:
"""
Extract payment methods (CARD/NUMERAR) from receipt.
These appear after TOTAL LEI and before TOTAL TVA section.
Returns list of: {'method': 'CARD'/'NUMERAR', 'amount': Decimal}
"""
payment_methods = []
seen_methods = set()
# Normalize spaces in numbers
normalized_text = re.sub(r'(\d+)[.,]\s+(\d{2})', r'\1.\2', text)
# Find the region between TOTAL LEI and TOTAL TVA
total_lei_match = re.search(r'TOTAL\s+LEI\s*([\d\s.,]+)', normalized_text, re.IGNORECASE)
total_tva_match = re.search(r'TOTAL\s+T[VU][AR]', normalized_text, re.IGNORECASE)
# Define search region (after TOTAL LEI, before TOTAL TVA if exists)
if total_lei_match:
start_pos = total_lei_match.end()
end_pos = total_tva_match.start() if total_tva_match else len(normalized_text)
search_region = normalized_text[start_pos:end_pos]
else:
search_region = normalized_text # Fallback to full text
for pattern, method, confidence in self.PAYMENT_METHOD_PATTERNS:
for match in re.finditer(pattern, search_region, re.IGNORECASE):
try:
amount_str = match.group(1).replace(' ', '')
amount_str = self._normalize_number(re.sub(r'[^\d.,]', '', amount_str))
amount = Decimal(amount_str)
if amount > 0 and method not in seen_methods:
payment_methods.append({
'method': method,
'amount': amount
})
seen_methods.add(method)
except (InvalidOperation, ValueError):
continue
return payment_methods
```
### 6.4 Call Extraction in `extract()` Method
**File**: `data-entry-app/backend/app/services/ocr_extractor.py`
Add to `extract()` method (after line 255, after `result.address = ...`):
```python
result.payment_methods = self._extract_payment_methods(text_upper)
```
### 6.5 Frontend - Add Payment Methods Display
**File**: `data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue`
Add to form ref:
```javascript
payment_methods: [],
```
Add to `applyOCRData()`:
```javascript
if (data.payment_methods) form.value.payment_methods = data.payment_methods
```
Add UI display (after TVA breakdown section):
```vue
<!-- Payment Methods (from OCR) -->
<div class="form-field" v-if="form.payment_methods && form.payment_methods.length > 0">
<label>Modalități Plată</label>
<div class="payment-methods-display">
<Tag v-for="pm in form.payment_methods" :key="pm.method"
:severity="pm.method === 'CARD' ? 'info' : 'success'"
:value="`${pm.method}: ${formatCurrency(pm.amount)}`" />
</div>
</div>
```
---
## Implementation Order
| Step | Task | Files |
|------|------|-------|
| 1 | Add `cui`, `ocr_raw_text`, `payment_methods` to model | `models/receipt.py` |
| 2 | Create migration | `migrations/versions/...` |
| 3 | Update schemas | `schemas/receipt.py` |
| 4 | Fix image resize | `services/image_preprocessor.py` |
| 5 | Add payment methods extraction to OCR | `services/ocr_extractor.py` |
| 6 | Unify frontend form + add new fields | `views/receipts/ReceiptCreateView.vue` |
| 7 | Add approval validation | `services/receipt_service.py` |
| 8 | Test full workflow | Manual testing |
---
## Files to Modify
### Backend
- `data-entry-app/backend/app/db/models/receipt.py` - Add cui, ocr_raw_text, payment_methods fields
- `data-entry-app/backend/app/schemas/receipt.py` - Add PaymentMethodSchema, update schemas
- `data-entry-app/backend/app/services/image_preprocessor.py` - Fix resize bug (cap at 4000px)
- `data-entry-app/backend/app/services/ocr_extractor.py` - Add payment methods extraction
- `data-entry-app/backend/app/services/receipt_service.py` - Add approval validation
- `data-entry-app/backend/migrations/versions/` - New migration
### Frontend
- `data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue` - Unify form, add CUI + payment methods fields, change dialog to warning
---
## Expected Behavior After Implementation
1. **OCR Scan**: Extracts supplier name, CUI, raw text, payment methods → all saved to draft
2. **Payment Methods**: CARD/NUMERAR amounts extracted (after TOTAL LEI, before TOTAL TVA)
3. **CUI Match**: Auto-fills supplier name from ROA, user can edit
4. **CUI No Match**: Shows warning toast, allows saving draft with OCR data
5. **Edit Mode**: Can re-scan OCR to update extracted data
6. **Approval**: Requires valid `cui` (fiscal code) - NOT partner_id
7. **Oracle Import** (later): Uses `cui` to find/create supplier via ROA stored procedure
8. **Large Images**: Automatically resized to max 4000px before OCR
---
## Romanian Receipt Structure Reference
```
NUME FIRMA S.R.L.
CIF: RO12345678
STR. EXEMPLU NR. 1
[Product lines...]
TOTAL LEI 150.00 ← Total amount
CARD 50.00 ← Payment method 1 (NEW)
NUMERAR 100.00 ← Payment method 2 (NEW)
TOTAL TVA A-19% 23.95 ← TVA breakdown
```

View File

@@ -57,6 +57,89 @@ data-entry-app/
- `shared/frontend/stores/auth.js` - Pinia auth store factory
- `shared/frontend/styles/login.css` - Stiluri login
## Servere Oracle (Producție vs Test)
**IMPORTANT**: Există două servere Oracle separate. Verifică întotdeauna la care ești conectat!
| Server | IP Oracle | Tunel SSH | Schema Verificare | Company ID |
|--------|-----------|-----------|-------------------|------------|
| **PRODUCȚIE** | `10.0.20.36` | `./ssh_tunnel.sh` | `ROMFAST` | 114 |
| **TEST** | `10.0.20.121` | `./ssh-tunnel-test.sh` | `MARIUSM_AUTO` | 110 |
### Scripturi de Pornire
**IMPORTANT**: Folosește scriptul corespunzător mediului dorit!
```bash
# Pentru PRODUCȚIE (10.0.20.36)
./start-data-entry-dev.sh # Pornește tot (tunel + backend + frontend)
./start-data-entry-dev.sh stop # Oprește tot
./start-data-entry-dev.sh status # Verifică status
# Pentru TEST (10.0.20.121)
./start-data-entry-test.sh # Pornește tot (tunel + backend + frontend)
./start-data-entry-test.sh stop # Oprește tot
./start-data-entry-test.sh status # Verifică status
```
Scripturile fac automat:
1. Opresc tunelul celuilalt mediu (dacă rulează)
2. Pornesc tunelul corect
3. Copiază `.env.prod` sau `.env.test` în `.env`
4. Rulează migrările pe baza de date corectă
5. Pornesc frontend și backend
### Fișiere .env
| Fișier | Server | Database | Schema Test |
|--------|--------|----------|-------------|
| `.env.prod` | PRODUCȚIE (10.0.20.36) | `receipts_prod.db` | ROMFAST (id=114) |
| `.env.test` | TEST (10.0.20.121) | `receipts_test.db` | MARIUSM_AUTO (id=110) |
### Verificare conexiune
```bash
# Verifică la ce server ești conectat
./ssh_tunnel.sh status # Dacă rulează = PRODUCȚIE
./ssh-tunnel-test.sh status # Dacă rulează = TEST
# Verifică schema disponibilă (din backend/)
python3 -c "
import asyncio
from dotenv import load_dotenv
load_dotenv()
import sys; sys.path.insert(0, '../../shared')
from database.oracle_pool import oracle_pool
async def check():
await oracle_pool.initialize()
async with oracle_pool.get_connection() as conn:
with conn.cursor() as cur:
cur.execute('SELECT SCHEMA, NUME FROM CONTAFIN_ORACLE.V_NOM_FIRME ORDER BY NUME')
for row in cur.fetchall()[:10]:
print(f'{row[0]}: {row[1]}')
asyncio.run(check())
"
```
### Sincronizare Nomenclatoare
Query-ul pentru furnizori folosește `CORESP_TIP_PART`:
- `id_tip_part = 17` → Furnizori
- `id_tip_part = 22` → Casa LEI
- `id_tip_part = 23` → Casa Valută
- `id_tip_part = 24` → Bancă LEI
- `id_tip_part = 25` → Bancă Valută
```sql
-- Exemplu query furnizori pentru schema MARIUSM_AUTO (TEST)
SELECT B.ID_PART, B.DENUMIRE, B.COD_FISCAL, B.ADRESA
FROM MARIUSM_AUTO.CORESP_TIP_PART A
INNER JOIN MARIUSM_AUTO.VNOM_PARTENERI B ON A.ID_PART = B.ID_PART
WHERE A.ID_TIP_PART = 17 AND (B.INACTIV = 0 OR B.INACTIV IS NULL)
ORDER BY B.DENUMIRE;
```
## Comenzi Dezvoltare
```bash
@@ -135,6 +218,15 @@ cd frontend && npm run test
## Common Issues
### Nomenclatoare goale / furnizori lipsă
- Verifică la ce server Oracle ești conectat: `./ssh_tunnel.sh status`
- Verifică dacă schema firmei selectate există pe acel server
- Sincronizează manual: `POST /api/nomenclature/sync/all`
### Conectat la serverul greșit
- PRODUCȚIE are schema `ROMFAST`, TEST are schema `MARIUSM_AUTO`
- Oprește tunelul curent și pornește cel corect (vezi secțiunea "Servere Oracle")
### SQLite locked
- Asigura-te ca nu ai multiple procese care acceseaza DB-ul

View File

@@ -1,455 +0,0 @@
# Implementation Summary: Nomenclature Sync (FAZA 3)
**Date**: 2025-12-13
**Status**: COMPLETED
**Developer**: Claude Code
---
## Overview
Successfully implemented FAZA 3: Nomenclature Sync for the data-entry-app. This feature enables the application to maintain a local SQLite cache of nomenclatures (suppliers, cash registers) from Oracle, reducing latency and improving performance.
## Files Created
### 1. Models
**File**: `/app/db/models/nomenclature.py`
- `SyncedSupplier` - Suppliers synced from Oracle NOM_PARTENERI
- `LocalSupplier` - Suppliers created locally from OCR (not yet in Oracle)
- `SyncedCashRegister` - Cash registers and bank accounts synced from Oracle
### 2. Service Layer
**File**: `/app/services/sync_service.py`
- `SyncService.sync_suppliers()` - Sync suppliers from Oracle to SQLite
- `SyncService.sync_cash_registers()` - Sync cash registers from Oracle to SQLite
- `SyncService.search_supplier()` - Search in synced + local suppliers
- `SyncService.create_local_supplier()` - Create local supplier from OCR data
- `SyncService.get_all_suppliers()` - Get all suppliers for dropdown
- `SyncService.get_all_cash_registers()` - Get all cash registers for dropdown
- `SyncService.get_schema_for_company()` - Map company ID to Oracle schema
**Company-to-Schema Mapping**:
```python
COMPANY_SCHEMAS = {
1: "CONTAFIN",
2: "CONTAFIN2",
}
```
> **TODO**: Move to config table or environment variable
### 3. API Router
**File**: `/app/routers/nomenclature.py`
New endpoints:
- `GET /api/nomenclature/suppliers` - Get all suppliers (synced + local)
- `GET /api/nomenclature/suppliers/search` - Search supplier by fiscal code or name
- `POST /api/nomenclature/suppliers/local` - Create local supplier from OCR
- `GET /api/nomenclature/cash-registers` - Get all cash registers
- `POST /api/nomenclature/sync/suppliers` - Manual supplier sync
- `POST /api/nomenclature/sync/cash-registers` - Manual cash register sync
- `POST /api/nomenclature/sync/all` - Sync all nomenclatures
### 4. Database Migration
**File**: `/migrations/versions/20251213_002805_add_nomenclature_tables.py`
- Creates `synced_suppliers` table with indexes
- Creates `local_suppliers` table with indexes
- Creates `synced_cash_registers` table with indexes
**Applied**: Yes (migration revision: 3a653da79002)
### 5. Documentation
**File**: `NOMENCLATURE_SYNC.md`
- Complete implementation guide
- Architecture overview
- API reference
- Usage examples
- Troubleshooting guide
**File**: `IMPLEMENTATION_SUMMARY_NOMENCLATURE_SYNC.md` (this file)
- Implementation summary
- Files changed
- Testing checklist
## Files Modified
### 1. `/app/db/models/__init__.py`
**Change**: Added imports for nomenclature models
```python
from .nomenclature import SyncedSupplier, LocalSupplier, SyncedCashRegister
```
### 2. `/app/services/nomenclature_service.py`
**Changes**:
- Updated `get_partners()` to accept optional `session` parameter
- Added SQLite fallback: returns synced/local suppliers if available
- Falls back to mock data if no synced data
- Updated `get_cash_registers()` to accept optional `session` parameter
- Added SQLite fallback for cash registers
### 3. `/app/routers/receipts.py`
**Changes**:
- Updated `get_partners()` endpoint to pass `session` to service
- Updated `get_cash_registers()` endpoint to pass `session` to service
### 4. `/app/routers/__init__.py`
**Change**: Added nomenclature router to exports
```python
from . import receipts, nomenclature
__all__ = ["receipts", "nomenclature"]
```
### 5. `/app/main.py`
**Change**: Registered nomenclature router
```python
from app.routers import receipts, ocr, nomenclature
app.include_router(nomenclature.router, prefix="/api/nomenclature", tags=["nomenclature"])
```
## Database Schema
### synced_suppliers
```sql
CREATE TABLE synced_suppliers (
id INTEGER PRIMARY KEY,
oracle_id INTEGER NOT NULL,
company_id INTEGER NOT NULL,
name VARCHAR(200) NOT NULL,
fiscal_code VARCHAR(50),
address VARCHAR(500),
synced_at DATETIME NOT NULL
);
CREATE INDEX ix_synced_suppliers_oracle_id ON synced_suppliers(oracle_id);
CREATE INDEX ix_synced_suppliers_company_id ON synced_suppliers(company_id);
CREATE INDEX ix_synced_suppliers_fiscal_code ON synced_suppliers(fiscal_code);
```
### local_suppliers
```sql
CREATE TABLE local_suppliers (
id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL,
name VARCHAR(200) NOT NULL,
fiscal_code VARCHAR(50),
address VARCHAR(500),
created_by VARCHAR(100) NOT NULL,
created_at DATETIME NOT NULL,
pending_oracle_sync BOOLEAN NOT NULL
);
CREATE INDEX ix_local_suppliers_company_id ON local_suppliers(company_id);
CREATE INDEX ix_local_suppliers_fiscal_code ON local_suppliers(fiscal_code);
```
### synced_cash_registers
```sql
CREATE TABLE synced_cash_registers (
id INTEGER PRIMARY KEY,
oracle_id INTEGER NOT NULL,
company_id INTEGER NOT NULL,
name VARCHAR(100) NOT NULL,
account_code VARCHAR(20) NOT NULL,
register_type VARCHAR(10) NOT NULL,
synced_at DATETIME NOT NULL
);
CREATE INDEX ix_synced_cash_registers_oracle_id ON synced_cash_registers(oracle_id);
CREATE INDEX ix_synced_cash_registers_company_id ON synced_cash_registers(company_id);
```
## API Endpoints Summary
### Nomenclature Endpoints
#### GET /api/nomenclature/suppliers
Get all suppliers (synced + local) for dropdown/autocomplete.
**Query Params**:
- `search` (optional) - Filter by name or fiscal code
- `company_id` (optional) - Company ID (defaults to user's first company)
**Response**:
```json
[
{
"id": 1,
"oracle_id": 123,
"name": "OMV Petrom",
"fiscal_code": "RO123456",
"source": "synced"
},
{
"id": 2,
"name": "Local Supplier SRL",
"fiscal_code": "RO789012",
"source": "local"
}
]
```
#### GET /api/nomenclature/suppliers/search
Search for supplier by fiscal code or name.
**Query Params**:
- `fiscal_code` (optional) - Fiscal code to search
- `name` (optional) - Name to search (partial match)
- `company_id` (optional) - Company ID
**Response**:
```json
{
"found": true,
"supplier": {
"id": 1,
"oracle_id": 123,
"name": "OMV Petrom",
"fiscal_code": "RO123456",
"address": "Str. Example 123"
},
"source": "synced"
}
```
#### POST /api/nomenclature/suppliers/local
Create a local supplier from OCR data.
**Body**:
```json
{
"name": "New Supplier SRL",
"fiscal_code": "RO12345678",
"address": "Str. Example 123"
}
```
**Response**:
```json
{
"id": 5,
"name": "New Supplier SRL",
"fiscal_code": "RO12345678",
"address": "Str. Example 123",
"is_local": true
}
```
#### GET /api/nomenclature/cash-registers
Get all cash registers for a company.
**Query Params**:
- `company_id` (optional) - Company ID
**Response**:
```json
[
{
"id": 1,
"oracle_id": 10,
"name": "Casa principala",
"account_code": "5311",
"register_type": "cash"
},
{
"id": 2,
"oracle_id": 20,
"name": "Cont BCR",
"account_code": "5121",
"register_type": "bank"
}
]
```
#### POST /api/nomenclature/sync/suppliers
Manually trigger supplier sync from Oracle.
**Response**:
```json
{
"synced": 150,
"errors": 0,
"message": "Synced 150 suppliers with 0 errors"
}
```
#### POST /api/nomenclature/sync/cash-registers
Manually trigger cash register sync from Oracle.
**Response**:
```json
{
"synced": 5,
"errors": 0,
"message": "Synced 5 cash registers with 0 errors"
}
```
#### POST /api/nomenclature/sync/all
Sync all nomenclatures (suppliers + cash registers).
**Response**:
```json
{
"suppliers": {
"synced": 150,
"errors": 0
},
"cash_registers": {
"synced": 5,
"errors": 0
},
"total_synced": 155,
"total_errors": 0,
"message": "Synced 150 suppliers and 5 cash registers"
}
```
## Testing Checklist
### Unit Tests
- [ ] Test `SyncService.sync_suppliers()` with mock Oracle data
- [ ] Test `SyncService.sync_cash_registers()` with mock Oracle data
- [ ] Test `SyncService.search_supplier()` for synced suppliers
- [ ] Test `SyncService.search_supplier()` for local suppliers
- [ ] Test `SyncService.create_local_supplier()`
- [ ] Test upsert logic (update existing vs insert new)
### Integration Tests
- [ ] Test nomenclature router endpoints with authentication
- [ ] Test `/api/nomenclature/suppliers` endpoint
- [ ] Test `/api/nomenclature/suppliers/search` endpoint
- [ ] Test `/api/nomenclature/suppliers/local` endpoint
- [ ] Test `/api/nomenclature/cash-registers` endpoint
- [ ] Test `/api/nomenclature/sync/suppliers` endpoint
- [ ] Test `/api/nomenclature/sync/all` endpoint
### Manual Testing
- [ ] Start backend: `uvicorn app.main:app --reload --port 8003`
- [ ] Verify `/docs` shows new nomenclature endpoints
- [ ] Test sync endpoint (requires Oracle connection)
- [ ] Test search endpoint with various queries
- [ ] Test create local supplier endpoint
- [ ] Verify existing `/api/receipts/nomenclature/partners` still works
- [ ] Verify existing `/api/receipts/nomenclature/cash-registers` still works
### Oracle Connection Testing
- [ ] Verify SSH tunnel is running (dev/Linux)
- [ ] Test Oracle connection via health endpoint
- [ ] Verify company schema mapping is correct
- [ ] Test sync with real Oracle data
- [ ] Verify table names match actual Oracle schema
### Error Handling Testing
- [ ] Test sync with invalid company ID
- [ ] Test sync with Oracle connection error
- [ ] Test search with no results
- [ ] Test create local supplier with duplicate fiscal code
- [ ] Test endpoints with missing authentication token
## Dependencies
All required dependencies are already in `requirements.txt`:
- `oracledb>=2.0.1` - Oracle database connection
- `sqlmodel>=0.0.14` - ORM for SQLite
- `alembic>=1.13.1` - Database migrations
## Deployment Notes
### Development
1. Ensure SSH tunnel is running: `./ssh_tunnel.sh start`
2. Apply migration: `alembic upgrade head`
3. Run initial sync: `POST /api/nomenclature/sync/all`
4. Start backend: `uvicorn app.main:app --reload --port 8003`
### Production
1. Update `COMPANY_SCHEMAS` in `sync_service.py` with production mappings
2. Apply migration: `alembic upgrade head`
3. Set up cron job for periodic sync (daily recommended)
4. Configure Oracle connection (no SSH tunnel needed on Windows prod)
### Migration Commands
```bash
# Check current version
alembic current
# Apply migration
alembic upgrade head
# Rollback migration
alembic downgrade -1
# View migration history
alembic history
```
## Known Issues / TODOs
1. **Company Schema Mapping**: Currently hardcoded in `sync_service.py`
- TODO: Move to config table or environment variable
2. **Oracle Table Names**: Assumes `NOM_PARTENERI` and `NOM_CASE` exist
- TODO: Verify actual table names in production Oracle schema
- TODO: Add error handling for missing tables
3. **Sync Scheduling**: No automatic periodic sync implemented
- TODO: Add background task or cron job for daily sync
4. **Conflict Resolution**: No logic to handle local supplier matching synced supplier
- TODO: Implement merge logic when OCR supplier matches Oracle supplier
5. **Bidirectional Sync**: Local suppliers not pushed to Oracle
- TODO: Implement sync from SQLite to Oracle for approved local suppliers
6. **Performance**: Sync loads all records at once
- TODO: Implement batch processing for large datasets
- TODO: Add incremental sync (requires Oracle last_modified timestamp)
7. **Validation**: No validation for duplicate fiscal codes
- TODO: Add uniqueness constraint and conflict resolution
8. **Testing**: No unit tests written yet
- TODO: Add comprehensive test suite
## Success Criteria
**Completed**:
- SQLite tables created for synced nomenclatures
- Sync service implemented with Oracle integration
- API endpoints for sync and query operations
- Updated existing nomenclature service to use synced data
- Database migration created and applied
- All files have correct Python syntax
- Documentation created
**Pending**:
- Unit tests
- Integration tests
- Manual testing with real Oracle data
- Production deployment
- Scheduled sync setup
## Next Steps
1. **Testing Phase**:
- Write unit tests for sync service
- Write integration tests for API endpoints
- Manual testing with real Oracle connection
- Performance testing with large datasets
2. **Production Readiness**:
- Update company schema mappings for production
- Verify Oracle table names
- Set up cron job for periodic sync
- Add monitoring and alerting
3. **Enhancements**:
- Implement scheduled background sync
- Add sync status dashboard in frontend
- Implement conflict resolution
- Add bidirectional sync (SQLite → Oracle)
## Related Documentation
- Complete Guide: `NOMENCLATURE_SYNC.md`
- Architecture: `docs/data-entry/ARCHITECTURE.md`
- API Docs: Available at `/docs` when app is running
---
**Implementation completed successfully!** All core features are in place and ready for testing.

View File

@@ -1,273 +0,0 @@
# Nomenclature Sync - Implementation Guide
## Overview
This document describes the implementation of FAZA 3: Nomenclature Sync for the Data Entry App.
The nomenclature sync system allows the data-entry-app to maintain a local SQLite cache of nomenclatures (suppliers, cash registers) from Oracle, reducing the need for live Oracle queries and improving performance.
## Architecture
### Database Tables
Three new SQLite tables were added:
1. **synced_suppliers** - Suppliers synced from Oracle NOM_PARTENERI
- `oracle_id` - Original Oracle ID
- `company_id` - Company this supplier belongs to
- `name` - Supplier name
- `fiscal_code` - CUI/CIF
- `address` - Supplier address
- `synced_at` - Last sync timestamp
2. **local_suppliers** - Suppliers created locally from OCR (not in Oracle)
- `company_id` - Company ID
- `name` - Supplier name
- `fiscal_code` - CUI/CIF
- `address` - Supplier address
- `created_by` - Username who created it
- `pending_oracle_sync` - Flag for future Oracle sync
3. **synced_cash_registers** - Cash registers and bank accounts from Oracle
- `oracle_id` - Original Oracle ID
- `company_id` - Company ID
- `name` - Register name
- `account_code` - Account code (5311, 5121, etc.)
- `register_type` - 'cash' or 'bank'
- `synced_at` - Last sync timestamp
### Components
#### 1. Models (`app/db/models/nomenclature.py`)
SQLModel models for the three tables above.
#### 2. Sync Service (`app/services/sync_service.py`)
Core business logic for syncing nomenclatures:
- `sync_suppliers()` - Sync suppliers from Oracle to SQLite
- `sync_cash_registers()` - Sync cash registers from Oracle to SQLite
- `search_supplier()` - Search in synced + local suppliers
- `create_local_supplier()` - Create local supplier from OCR data
- `get_all_suppliers()` - Get all suppliers for dropdown
- `get_all_cash_registers()` - Get all cash registers for dropdown
#### 3. API Router (`app/routers/nomenclature.py`)
New API endpoints:
**GET /api/nomenclature/suppliers**
- Get all suppliers (synced + local) for dropdown/autocomplete
- Query params: `search`, `company_id`
- Returns: List of SupplierOption
**GET /api/nomenclature/suppliers/search**
- Search for supplier by fiscal code or name
- Query params: `fiscal_code`, `name`, `company_id`
- Returns: SupplierSearchResult (found, supplier, source)
**POST /api/nomenclature/suppliers/local**
- Create a local supplier from OCR data
- Body: LocalSupplierCreate (name, fiscal_code, address)
- Returns: LocalSupplierResponse
**GET /api/nomenclature/cash-registers**
- Get all cash registers for a company
- Query params: `company_id`
- Returns: List of CashRegisterOption
**POST /api/nomenclature/sync/suppliers**
- Manually trigger supplier sync from Oracle
- Returns: SyncResult (synced count, errors)
**POST /api/nomenclature/sync/cash-registers**
- Manually trigger cash register sync from Oracle
- Returns: SyncResult (synced count, errors)
**POST /api/nomenclature/sync/all**
- Sync all nomenclatures (suppliers + cash registers)
- Returns: Combined sync results
#### 4. Updated Services
**nomenclature_service.py** was updated to use synced data:
- `get_partners()` - Now accepts optional `session` parameter, returns synced data if available, falls back to mock
- `get_cash_registers()` - Now accepts optional `session` parameter, returns synced data if available, falls back to mock
**receipts.py router** was updated to pass session to nomenclature service.
## Company Schema Mapping
The sync service needs to know which Oracle schema to query for each company. This is configured in `sync_service.py`:
```python
COMPANY_SCHEMAS = {
1: "CONTAFIN",
2: "CONTAFIN2",
}
```
**TODO**: Move this to a config table or environment variable for production.
## Oracle Integration
The sync service connects to Oracle using the shared `oracle_pool` from `/shared/database/oracle_pool.py`.
**Prerequisites**:
- SSH tunnel must be running (development/Linux)
- Oracle connection pool must be initialized
- Environment variables must be set (ORACLE_USER, ORACLE_PASSWORD, ORACLE_HOST, ORACLE_PORT, ORACLE_SID)
**Oracle Tables Used**:
- `{schema}.NOM_PARTENERI` - Suppliers (WHERE ACTIV = 1)
- `{schema}.NOM_CASE` - Cash registers (WHERE ACTIV = 1)
**Note**: Table and column names may need adjustment based on actual Oracle schema.
## Usage Flow
### Initial Setup (One-time)
1. Ensure Oracle connection is available:
```bash
# Start SSH tunnel (if on Linux/dev)
./ssh_tunnel.sh start
```
2. Run initial sync:
```bash
# Via API (authenticated request)
POST /api/nomenclature/sync/all
```
Or programmatically:
```python
from app.services.sync_service import SyncService
# Sync for company 1
synced, errors = await SyncService.sync_suppliers(session, company_id=1)
synced, errors = await SyncService.sync_cash_registers(session, company_id=1)
```
### Periodic Sync
Set up a cron job or scheduled task to sync nomenclatures periodically (e.g., daily):
```python
# Example: Add to app lifespan or background task
async def sync_all_companies():
"""Sync nomenclatures for all companies."""
async with get_db_session() as session:
for company_id in [1, 2]: # All company IDs
await SyncService.sync_suppliers(session, company_id)
await SyncService.sync_cash_registers(session, company_id)
```
### Using Synced Data
The existing endpoints (`/api/receipts/nomenclature/partners`, `/api/receipts/nomenclature/cash-registers`) now automatically use synced data when available.
**Frontend** - No changes needed! Existing code continues to work:
```javascript
// Get suppliers (now from synced data)
const response = await api.get('/api/receipts/nomenclature/partners?search=OMV');
```
### Creating Local Suppliers from OCR
When OCR extracts a supplier not in Oracle:
```javascript
// Create local supplier
const response = await api.post('/api/nomenclature/suppliers/local', {
name: "New Supplier SRL",
fiscal_code: "RO12345678",
address: "Str. Example 123"
});
```
The local supplier will be:
- Available immediately in dropdowns
- Flagged for future Oracle sync (`pending_oracle_sync = True`)
- Created by current user (`created_by = username`)
## Migration
Migration: `20251213_002805_add_nomenclature_tables.py`
Applied with:
```bash
alembic upgrade head
```
To rollback:
```bash
alembic downgrade -1
```
## Testing
### Manual Testing
1. Test sync endpoint:
```bash
curl -X POST http://localhost:8003/api/nomenclature/sync/suppliers \
-H "Authorization: Bearer YOUR_TOKEN"
```
2. Test search:
```bash
curl "http://localhost:8003/api/nomenclature/suppliers/search?name=OMV" \
-H "Authorization: Bearer YOUR_TOKEN"
```
3. Test get all suppliers:
```bash
curl "http://localhost:8003/api/nomenclature/suppliers" \
-H "Authorization: Bearer YOUR_TOKEN"
```
### Unit Tests
TODO: Add unit tests in `tests/test_sync_service.py`:
- Test supplier sync
- Test cash register sync
- Test search functionality
- Test local supplier creation
## Troubleshooting
### Sync fails with "No schema mapping"
- Update `COMPANY_SCHEMAS` in `sync_service.py` with correct company-to-schema mappings
### Sync fails with Oracle connection error
- Verify SSH tunnel is running: `./ssh_tunnel.sh status`
- Check Oracle credentials in `.env`
- Test Oracle connection: `curl http://localhost:8003/health`
### Tables not found in Oracle
- Verify table names in Oracle (may differ from NOM_PARTENERI, NOM_CASE)
- Update SQL queries in `sync_service.py` to match actual schema
### Duplicate suppliers after sync
- The sync uses upsert logic (update if exists, insert if new)
- Check `oracle_id` + `company_id` uniqueness in synced_suppliers table
## Future Enhancements
1. **Scheduled Background Sync** - Add cron job or Celery task for automatic daily sync
2. **Sync Status Dashboard** - UI to show last sync time, sync statistics
3. **Conflict Resolution** - Handle cases where local supplier matches synced supplier
4. **Bidirectional Sync** - Push local suppliers to Oracle when approved
5. **Incremental Sync** - Only sync changed records (requires last_modified timestamp in Oracle)
6. **Multi-Company Support** - Auto-detect user's companies and sync all
7. **Sync Notifications** - Notify users when sync completes or fails
8. **Audit Log** - Track all sync operations for compliance
## Related Files
- Models: `/app/db/models/nomenclature.py`
- Service: `/app/services/sync_service.py`
- Router: `/app/routers/nomenclature.py`
- Migration: `/migrations/versions/20251213_002805_add_nomenclature_tables.py`
- Updated: `/app/services/nomenclature_service.py`
- Updated: `/app/routers/receipts.py`
- Updated: `/app/main.py`

View File

@@ -59,7 +59,9 @@ class ReceiptCRUD:
session.add(receipt)
await session.commit()
await session.refresh(receipt)
return receipt
# Reload with relationships to avoid lazy loading issues with async
return await ReceiptCRUD.get_by_id(session, receipt.id, include_relations=True)
@staticmethod
async def get_by_id(
@@ -175,7 +177,9 @@ class ReceiptCRUD:
session.add(receipt)
await session.commit()
await session.refresh(receipt)
return receipt
# Reload with relationships to avoid lazy loading issues with async
return await ReceiptCRUD.get_by_id(session, receipt.id, include_relations=True)
@staticmethod
async def update_status(
@@ -206,7 +210,9 @@ class ReceiptCRUD:
session.add(receipt)
await session.commit()
await session.refresh(receipt)
return receipt
# Reload with relationships to avoid lazy loading issues with async
return await ReceiptCRUD.get_by_id(session, receipt.id, include_relations=True)
@staticmethod
async def delete(session: AsyncSession, receipt: Receipt) -> bool:

View File

@@ -132,6 +132,16 @@ from auth.routes import create_auth_router
auth_router = create_auth_router(prefix="") # No prefix - we set it in include_router
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
# Shared routes (companies, calendar)
from routes.companies import create_companies_router
from routes.calendar import create_calendar_router
companies_router = create_companies_router(oracle_pool) # No cache for data-entry
calendar_router = create_calendar_router(oracle_pool)
app.include_router(companies_router, prefix="/api/companies", tags=["companies"])
app.include_router(calendar_router, prefix="/api/calendar", tags=["calendar"])
# Root endpoint
@app.get("/")

View File

@@ -1,7 +1,7 @@
"""Nomenclature API endpoints."""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from typing import Optional, List, Annotated
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
@@ -20,6 +20,38 @@ from auth.models import CurrentUser
router = APIRouter()
# ============ Selected Company Dependency ============
async def get_selected_company(
current_user: CurrentUser = Depends(get_current_user),
x_selected_company: Annotated[Optional[str], Header()] = None
) -> int:
"""
Get selected company from X-Selected-Company header.
Validates user access. Falls back to first company if no header.
"""
if x_selected_company:
try:
company_id = int(x_selected_company)
except ValueError:
raise HTTPException(400, f"Invalid company ID: {x_selected_company}")
if str(company_id) in current_user.companies:
return company_id
raise HTTPException(403, f"Nu aveți acces la firma {company_id}")
if current_user.companies:
try:
return int(current_user.companies[0])
except (ValueError, IndexError):
pass
raise HTTPException(400, "Nu aveți nicio firmă asignată")
SelectedCompany = Annotated[int, Depends(get_selected_company)]
# Request/Response Models
class SupplierSearchResult(BaseModel):
found: bool
@@ -70,14 +102,13 @@ async def search_supplier(
name: Optional[str] = None,
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Search for supplier by fiscal code or name."""
if not fiscal_code and not name:
raise HTTPException(status_code=400, detail="Provide fiscal_code or name")
# Use provided company_id or first from user
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
cid = company_id or selected_company
found, supplier, source = await SyncService.search_supplier(
session, cid, fiscal_code, name
@@ -91,10 +122,10 @@ async def get_suppliers(
search: Optional[str] = None,
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Get all suppliers (synced + local) for dropdown/autocomplete."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
cid = company_id or selected_company
suppliers = await SyncService.get_all_suppliers(session, cid, search)
@@ -115,10 +146,11 @@ async def create_local_supplier(
data: LocalSupplierCreate,
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
selected_company: SelectedCompany = None,
current_user: CurrentUser = Depends(get_current_user),
):
"""Create a local supplier from OCR data."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
cid = company_id or selected_company
supplier = await SyncService.create_local_supplier(
session, cid, data.name, data.fiscal_code, data.address, current_user.username
@@ -136,10 +168,10 @@ async def create_local_supplier(
async def get_cash_registers(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Get all cash registers for a company."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
cid = company_id or selected_company
registers = await SyncService.get_all_cash_registers(session, cid)
@@ -159,10 +191,10 @@ async def get_cash_registers(
async def sync_suppliers(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Manually trigger supplier sync from Oracle."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
cid = company_id or selected_company
synced, errors = await SyncService.sync_suppliers(session, cid)
@@ -177,10 +209,10 @@ async def sync_suppliers(
async def sync_cash_registers(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Manually trigger cash register sync from Oracle."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
cid = company_id or selected_company
synced, errors = await SyncService.sync_cash_registers(session, cid)
@@ -195,10 +227,10 @@ async def sync_cash_registers(
async def sync_all_nomenclatures(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Sync all nomenclatures (suppliers + cash registers) from Oracle."""
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
cid = company_id or selected_company
# Sync suppliers
suppliers_synced, suppliers_errors = await SyncService.sync_suppliers(session, cid)

View File

@@ -1,9 +1,9 @@
"""API endpoints for receipts."""
from typing import List, Optional
from typing import List, Optional, Annotated
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Header
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -39,19 +39,68 @@ from auth.models import CurrentUser
router = APIRouter()
# ============ Helper for current user's company ============
# ============ Helper for selected company from header ============
async def get_selected_company(
current_user: CurrentUser = Depends(get_current_user),
x_selected_company: Annotated[Optional[str], Header()] = None
) -> int:
"""
Get selected company from X-Selected-Company header.
Validates that the user has access to the specified company.
Falls back to user's first company if no header is provided.
Raises:
HTTPException 403: If user doesn't have access to specified company
HTTPException 400: If user has no companies assigned
"""
if x_selected_company:
try:
company_id = int(x_selected_company)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid company ID format: {x_selected_company}"
)
# Validate user has access to this company
# Auth stores companies as strings
if str(company_id) in current_user.companies:
return company_id
raise HTTPException(
status_code=403,
detail=f"Nu aveți acces la firma {company_id}"
)
# No header - use first company from user's list
if current_user.companies:
try:
return int(current_user.companies[0])
except (ValueError, IndexError):
pass
raise HTTPException(
status_code=400,
detail="Nu aveți nicio firmă asignată"
)
# Dependency for injection
SelectedCompany = Annotated[int, Depends(get_selected_company)]
# Legacy function for backwards compatibility (deprecated)
def get_current_user_company(current_user: CurrentUser) -> int:
"""
Get current user's active company.
Returns the first company from the user's companies list.
In future, this can be enhanced to use a session-based active company.
DEPRECATED: Use get_selected_company() dependency instead.
This function returns the first company, ignoring X-Selected-Company header.
"""
if current_user.companies:
# For data-entry-app, we assume company ID is numeric
# If companies are stored as strings, convert to int
# For now, return 1 as default (Phase 1)
try:
return int(current_user.companies[0])
except (ValueError, IndexError):
return 1
return 1
@@ -80,16 +129,14 @@ async def list_receipts(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Get paginated list of receipts with filters."""
from datetime import date as date_type
current_company = get_current_user_company(current_user)
filters = ReceiptFilter(
status=status,
company_id=company_id or current_company,
company_id=company_id or selected_company,
created_by=created_by,
date_from=date_type.fromisoformat(date_from) if date_from else None,
date_to=date_type.fromisoformat(date_to) if date_to else None,
@@ -105,12 +152,11 @@ async def list_receipts(
async def list_pending_receipts(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Get all receipts pending review (for accountant view)."""
current_company = get_current_user_company(current_user)
receipts = await ReceiptCRUD.get_pending_review(
session, company_id or current_company
session, company_id or selected_company
)
return [ReceiptResponse.model_validate(r) for r in receipts]
@@ -120,13 +166,13 @@ async def get_receipt_stats(
company_id: Optional[int] = None,
my_receipts: bool = False,
session: AsyncSession = Depends(get_session),
selected_company: SelectedCompany = None,
current_user: CurrentUser = Depends(get_current_user),
):
"""Get receipt statistics."""
current_company = get_current_user_company(current_user)
return await ReceiptCRUD.get_stats(
session,
company_id or current_company,
company_id or selected_company,
created_by=current_user.username if my_receipts else None,
)
@@ -415,12 +461,11 @@ async def get_partners(
search: Optional[str] = None,
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Get partners (suppliers/customers) for dropdown."""
current_company = get_current_user_company(current_user)
return await NomenclatureService.get_partners(
company_id or current_company, search, session
company_id or selected_company, search, session
)
@@ -428,12 +473,11 @@ async def get_partners(
async def get_accounts(
prefix: Optional[str] = None,
company_id: Optional[int] = None,
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Get chart of accounts for dropdown."""
current_company = get_current_user_company(current_user)
return await NomenclatureService.get_accounts(
company_id or current_company, prefix
company_id or selected_company, prefix
)
@@ -441,11 +485,10 @@ async def get_accounts(
async def get_cash_registers(
company_id: Optional[int] = None,
session: AsyncSession = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
selected_company: SelectedCompany = None,
):
"""Get cash registers and bank accounts for dropdown."""
current_company = get_current_user_company(current_user)
return await NomenclatureService.get_cash_registers(company_id or current_company, session)
return await NomenclatureService.get_cash_registers(company_id or selected_company, session)
@router.get("/nomenclature/expense-types", response_model=List[ExpenseTypeOption])

View File

@@ -18,29 +18,54 @@ from app.db.models.nomenclature import SyncedSupplier, LocalSupplier, SyncedCash
logger = logging.getLogger(__name__)
# Company ID to Oracle Schema mapping
# TODO: This should come from a config table or environment variable
COMPANY_SCHEMAS = {
1: "CONTAFIN", # Example mapping - update with real schema names
2: "CONTAFIN2",
}
# Cache for schema lookups (populated dynamically from Oracle)
_schema_cache: dict[int, str] = {}
class SyncService:
"""Service for syncing nomenclatures from Oracle."""
@staticmethod
def get_schema_for_company(company_id: int) -> Optional[str]:
"""Get Oracle schema for company ID."""
return COMPANY_SCHEMAS.get(company_id)
async def get_schema_for_company(company_id: int) -> Optional[str]:
"""
Get Oracle schema for company ID from V_NOM_FIRME view.
Results are cached in memory for performance.
"""
# Check cache first
if company_id in _schema_cache:
return _schema_cache[company_id]
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT SCHEMA
FROM CONTAFIN_ORACLE.V_NOM_FIRME
WHERE ID_FIRMA = :company_id
""", {'company_id': company_id})
result = cursor.fetchone()
if result:
schema = result[0]
_schema_cache[company_id] = schema
logger.info(f"Resolved schema for company {company_id}: {schema}")
return schema
else:
logger.warning(f"No schema found for company {company_id}")
return None
except Exception as e:
logger.error(f"Error fetching schema for company {company_id}: {e}")
return None
@staticmethod
async def sync_suppliers(session: AsyncSession, company_id: int) -> Tuple[int, int]:
"""
Sync suppliers from Oracle NOM_PARTENERI to SQLite.
Sync suppliers (furnizori, id_tip_part=17) from Oracle to SQLite.
Uses CORESP_TIP_PART joined with VNOM_PARTENERI view.
Returns (synced_count, error_count).
"""
schema = SyncService.get_schema_for_company(company_id)
schema = await SyncService.get_schema_for_company(company_id)
if not schema:
logger.warning(f"No schema mapping for company {company_id}")
return 0, 0
@@ -51,11 +76,17 @@ class SyncService:
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Fetch active partners from Oracle
# Fetch active suppliers from Oracle
# id_tip_part = 17 means "furnizori" (suppliers)
# Using CORESP_TIP_PART to filter by partner type
cursor.execute(f"""
SELECT ID_PART, DEN_PART, COD_FISCAL, ADRESA
FROM {schema}.NOM_PARTENERI
WHERE ACTIV = 1
SELECT B.ID_PART, B.DENUMIRE, B.COD_FISCAL, B.ADRESA
FROM {schema}.CORESP_TIP_PART A
INNER JOIN {schema}.VNOM_PARTENERI B ON A.ID_PART = B.ID_PART
WHERE A.ID_TIP_PART = 17
AND (B.INACTIV = 0 OR B.INACTIV IS NULL)
AND B.ID_PART IS NOT NULL
ORDER BY B.DENUMIRE
""")
rows = cursor.fetchall()
@@ -110,10 +141,16 @@ class SyncService:
@staticmethod
async def sync_cash_registers(session: AsyncSession, company_id: int) -> Tuple[int, int]:
"""
Sync cash registers from Oracle to SQLite.
Sync cash registers and bank accounts from Oracle to SQLite.
Returns (synced_count, error_count).
Uses CORESP_TIP_PART with:
- id_tip_part = 22: CASA LEI
- id_tip_part = 23: CASA VALUTA
- id_tip_part = 24: BANCA LEI
- id_tip_part = 25: BANCA VALUTA
"""
schema = SyncService.get_schema_for_company(company_id)
schema = await SyncService.get_schema_for_company(company_id)
if not schema:
logger.warning(f"No schema mapping for company {company_id}")
return 0, 0
@@ -121,25 +158,40 @@ class SyncService:
synced = 0
errors = 0
# Partner types mapping
# 22=CASA LEI, 23=CASA VALUTA -> cash
# 24=BANCA LEI, 25=BANCA VALUTA -> bank
partner_types = [22, 23, 24, 25]
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Fetch cash registers (both cash and bank)
# Assuming similar structure to NOM_PARTENERI
# TODO: Verify actual table name and structure in Oracle
# Fetch cash/bank partners from CORESP_TIP_PART
cursor.execute(f"""
SELECT ID_CASA, DEN_CASA, CONT
FROM {schema}.NOM_CASE
WHERE ACTIV = 1
SELECT B.ID_PART, B.DENUMIRE, A.ID_TIP_PART
FROM {schema}.CORESP_TIP_PART A
INNER JOIN {schema}.VNOM_PARTENERI B ON A.ID_PART = B.ID_PART
WHERE A.ID_TIP_PART IN (22, 23, 24, 25)
AND (B.INACTIV = 0 OR B.INACTIV IS NULL)
AND B.ID_PART IS NOT NULL
ORDER BY A.ID_TIP_PART, B.DENUMIRE
""")
rows = cursor.fetchall()
# Type mapping: 22=CASA LEI, 23=CASA VALUTA -> cash; 24=BANCA LEI, 25=BANCA VALUTA -> bank
type_mapping = {
22: ("cash", "CASA_LEI"),
23: ("cash", "CASA_VALUTA"),
24: ("bank", "BANCA_LEI"),
25: ("bank", "BANCA_VALUTA"),
}
for row in rows:
try:
oracle_id, name, account_code = row
oracle_id, name, tip_part_id = row
# Determine type based on account code
register_type = "cash" if account_code.startswith("531") else "bank"
# Determine type based on partner type
register_type, account_code = type_mapping.get(tip_part_id, ("cash", "UNKNOWN"))
# Check if already exists
stmt = select(SyncedCashRegister).where(
@@ -152,7 +204,7 @@ class SyncService:
if existing:
# Update existing record
existing.name = name or ""
existing.account_code = account_code or ""
existing.account_code = account_code
existing.register_type = register_type
existing.synced_at = datetime.utcnow()
logger.debug(f"Updated cash register {oracle_id}: {name}")
@@ -162,7 +214,7 @@ class SyncService:
oracle_id=oracle_id,
company_id=company_id,
name=name or "",
account_code=account_code or "",
account_code=account_code,
register_type=register_type,
)
session.add(cash_register)

View File

@@ -1,6 +1,8 @@
"""Alembic environment configuration."""
import os
from logging.config import fileConfig
from dotenv import load_dotenv
from sqlalchemy import engine_from_config
from sqlalchemy import pool
@@ -8,14 +10,22 @@ from sqlalchemy import pool
from alembic import context
from sqlmodel import SQLModel
# Load environment variables from .env file
load_dotenv()
# Import all models to ensure they're registered with SQLModel
from app.db.models.receipt import Receipt, ReceiptAttachment
from app.db.models.accounting_entry import AccountingEntry
from app.db.models.nomenclature import SyncedSupplier, LocalSupplier, SyncedCashRegister
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Override sqlalchemy.url from environment variable if set
db_path = os.getenv("SQLITE_DATABASE_PATH", "data/receipts.db")
config.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:

View File

@@ -1,346 +0,0 @@
# Plan: Implementare Auth SSO + Nomenclatoare Sync
> **Plan Handover Document** - Salvat pentru continuare în altă sesiune
> **Data**: 2025-12-13 | **Branch**: `feature/data-entry-receipts`
## Obiectiv
Integrare autentificare SSO și sincronizare nomenclatoare Oracle în data-entry-app conform `IMPLEMENTATION_PLAN_AUTH_UNITAR.md`.
---
## Instrucțiuni Implementare
### Metodologie
1. **Execută fazele în paralel** unde e posibil (Faza 1+2 pot rula simultan, Faza 3+4 pot rula simultan)
2. **Folosește agenți Task** pentru viteza - lansează agenți în paralel pentru task-uri independente
3. **Testează după fiecare fază** - nu trece la următoarea fără validare
4. **Urmărește progresul** în acest fișier - marchează task-urile completate cu ✅
### Comenzi de Start
```bash
# Asigură-te că SSH tunnel rulează (pentru Oracle)
./ssh_tunnel.sh start
# Backend reports (pentru auth API - port 8001)
cd reports-app/backend && uvicorn app.main:app --reload --port 8001
# Backend data-entry (port 8003)
cd data-entry-app/backend && uvicorn app.main:app --reload --port 8003
# Frontend data-entry (port 3010)
cd data-entry-app/frontend && npm run dev
```
### Progres Implementare
- [x] **FAZA 1**: Auth Backend - ✅ 6/6 task-uri COMPLETE
- [x] **FAZA 2**: Auth Frontend - ✅ 6/6 task-uri COMPLETE
- [x] **FAZA 3**: Nomenclatoare Sync - ✅ 6/6 task-uri COMPLETE
- [x] **FAZA 4**: OCR + Supplier Search - ✅ 2/2 task-uri COMPLETE
> **Status**: ✅ **IMPLEMENTARE COMPLETĂ** - 2025-12-13
---
## Stare Curentă (IMPLEMENTAT)
### Backend Data-Entry ✅
- ✅ Models: Receipt, ReceiptAttachment, AccountingEntry - complete
- ✅ CRUD operations - complete
- ✅ API Routers: receipts.py, ocr.py, **nomenclature.py**
- ✅ Services: receipt_service, ocr_service, **sync_service**
- ✅ Workflow: DRAFT → PENDING → APPROVED/REJECTED
-**Auth**: Integrare shared/auth (middleware + CurrentUser)
-**Nomenclatoare**: SQLite sync (SyncedSupplier, LocalSupplier, SyncedCashRegister)
-`sys.path.insert` pentru shared/ în main.py
### Frontend Data-Entry ✅
- ✅ Views: List, Create, Detail, Approval, **LoginView**
- ✅ Components: OCR components + **Create Supplier Dialog**
- ✅ Store: receiptsStore.js + **auth.js**
- ✅ Router: routes + **auth guards + /login**
-**Auth Store**: `src/stores/auth.js` - creat
-**Login View**: `src/views/LoginView.vue` - creat
-**Router Guards**: beforeEach cu requiresAuth
-**API Service**: `src/services/api.js` - creat cu interceptors
### Shared Auth (disponibil pentru integrare)
-`shared/auth/routes.py` - `create_auth_router()` (linia 39-430)
-`shared/auth/middleware.py` - `AuthenticationMiddleware`
-`shared/auth/dependencies.py` - `get_current_user`
-`shared/auth/models.py` - `CurrentUser`, `TokenResponse`
### Referință Reports-App (pentru copiere)
- `reports-app/frontend/src/stores/auth.js` - 119 linii
- `reports-app/frontend/src/services/api.js` - 141 linii
- `reports-app/frontend/src/views/LoginView.vue` - 367 linii
- `reports-app/frontend/src/router/index.js` - auth guard la liniile 96-114
---
## Faze Implementare
### FAZA 1: Auth Backend (6 task-uri)
#### Task 1.1: Adaugă AuthenticationMiddleware în main.py
**Fișier**: `data-entry-app/backend/app/main.py`
**Acțiune**: După CORS middleware (linia 75), adaugă:
```python
from auth.middleware import AuthenticationMiddleware
app.add_middleware(
AuthenticationMiddleware,
excluded_paths=["/docs", "/redoc", "/openapi.json", "/health", "/", "/api/auth/login", "/api/auth/refresh"]
)
```
#### Task 1.2: Adaugă Auth Router în main.py
**Fișier**: `data-entry-app/backend/app/main.py`
**Acțiune**: După include_router pentru ocr (linia 98), adaugă:
```python
from auth.routes import create_auth_router
auth_router = create_auth_router()
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
```
#### Task 1.3: Înlocuiește get_current_user în receipts.py
**Fișier**: `data-entry-app/backend/app/routers/receipts.py`
**Acțiune**: Șterge liniile 38-59 și înlocuiește cu:
```python
from auth.dependencies import get_current_user
from auth.models import CurrentUser
```
Apoi actualizează type hints: `current_user: str``current_user: CurrentUser`
Și accesează `current_user.username` în loc de `current_user`
#### Task 1.4: Înlocuiește get_current_user în ocr.py
**Fișier**: `data-entry-app/backend/app/routers/ocr.py`
**Acțiune**: Similar cu receipts.py, adaugă importurile auth și folosește `CurrentUser`
#### Task 1.5: Actualizează type hints în toate endpoint-urile
Actualizează toate funcțiile care folosesc `current_user: str` să folosească `current_user: CurrentUser`
#### Task 1.6: Testare backend auth
```bash
cd data-entry-app/backend
uvicorn app.main:app --reload --port 8003
# Test: curl http://localhost:8003/api/receipts/ → 401 Unauthorized
```
---
### FAZA 2: Auth Frontend (6 task-uri)
#### Task 2.1: Crează API service
**Fișier NOU**: `data-entry-app/frontend/src/services/api.js`
**Acțiune**: Copiază din `reports-app/frontend/src/services/api.js` cu modificări:
- Schimbă BASE_URL pentru a funcționa cu proxy-ul
- Modifică refresh token URL
#### Task 2.2: Crează Auth Store
**Fișier NOU**: `data-entry-app/frontend/src/stores/auth.js`
**Acțiune**: Copiază din `reports-app/frontend/src/stores/auth.js`
- Modifică import apiService din `../services/api`
#### Task 2.3: Crează LoginView
**Fișier NOU**: `data-entry-app/frontend/src/views/LoginView.vue`
**Acțiune**: Copiază din `reports-app/frontend/src/views/LoginView.vue`
- Schimbă titlul: "ROA Reports" → "Data Entry"
- Schimbă subtitle: "Rapoarte ERP" → "Introducere Bonuri Fiscale"
- Schimbă redirect după login: "/dashboard" → "/"
#### Task 2.4: Actualizează Router cu auth guards
**Fișier**: `data-entry-app/frontend/src/router/index.js`
**Acțiune**: Adaugă auth guard similar cu reports-app (liniile 96-114)
```javascript
import { useAuthStore } from '@/stores/auth'
// Adaugă rută login
// Adaugă meta: { requiresAuth: true } la rutele protejate
// Adaugă beforeEach guard
```
#### Task 2.5: Actualizează vite.config.js pentru auth proxy
**Fișier**: `data-entry-app/frontend/vite.config.js`
**Acțiune**: Adaugă proxy pentru auth:
```javascript
'/api/auth': {
target: 'http://localhost:8001',
changeOrigin: true,
}
```
#### Task 2.6: Testare frontend auth
```bash
cd data-entry-app/frontend
npm run dev
# Test: Accesează http://localhost:3010 → Redirect la /login
# Login cu credențiale Oracle → Redirect la /
```
---
### FAZA 3: Nomenclatoare Oracle→SQLite (6 task-uri)
#### Task 3.1: Crează modele SQLModel
**Fișier NOU**: `data-entry-app/backend/app/db/models/nomenclature.py`
- `SyncedSupplier` - furnizori sincronizați din Oracle
- `LocalSupplier` - furnizori creați local (din OCR)
- `SyncedCashRegister` - case/bănci sincronizate
#### Task 3.2: Crează Alembic migration
```bash
cd data-entry-app/backend
alembic revision --autogenerate -m "add nomenclature tables"
alembic upgrade head
```
#### Task 3.3: Crează Sync Service
**Fișier NOU**: `data-entry-app/backend/app/services/sync_service.py`
- `sync_suppliers(company_id, schema)` - sync furnizori Oracle→SQLite
- `sync_cash_registers(company_id, schema)` - sync case/bănci
- `get_schema_for_company(company_id)` - lookup schema
#### Task 3.4: Crează Nomenclature Router
**Fișier NOU**: `data-entry-app/backend/app/routers/nomenclature.py`
- `GET /suppliers/search` - căutare furnizor (SQLite + Oracle live)
- `POST /suppliers/local` - creare furnizor local
- `POST /sync/suppliers` - trigger manual sync
#### Task 3.5: Înregistrează router în main.py
```python
from app.routers import nomenclature
app.include_router(nomenclature.router, prefix="/api/nomenclature", tags=["nomenclature"])
```
#### Task 3.6: Actualizare nomenclature_service.py existent
Înlocuiește mock data cu query-uri din tabelele SQLite sincronizate
---
### FAZA 4: Integrare OCR + Supplier Search (2 task-uri)
#### Task 4.1: Actualizare ReceiptCreateView.vue
**Fișier**: `data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue`
**Acțiune**: După OCR result, caută automat furnizor după CUI:
```javascript
async function handleOCRResult(ocrData) {
if (ocrData.cui) {
const result = await receiptsStore.searchSupplier(ocrData.cui);
if (result.found) {
form.partner_id = result.supplier.id;
form.partner_name = result.supplier.name;
} else {
showCreateSupplierDialog(ocrData);
}
}
}
```
#### Task 4.2: Adaugă supplier search în receiptsStore.js
**Fișier**: `data-entry-app/frontend/src/stores/receiptsStore.js`
**Acțiune**: Adaugă action `searchSupplier(fiscalCode)` și `createLocalSupplier(data)`
---
## Sumar Fișiere
### De Modificat
| Fișier | Faza |
|--------|------|
| `data-entry-app/backend/app/main.py` | 1, 3 |
| `data-entry-app/backend/app/routers/receipts.py` | 1 |
| `data-entry-app/backend/app/routers/ocr.py` | 1 |
| `data-entry-app/frontend/src/router/index.js` | 2 |
| `data-entry-app/frontend/vite.config.js` | 2 |
| `data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue` | 4 |
| `data-entry-app/frontend/src/stores/receiptsStore.js` | 4 |
### De Creat (NOU)
| Fișier | Faza |
|--------|------|
| `data-entry-app/frontend/src/services/api.js` | 2 |
| `data-entry-app/frontend/src/stores/auth.js` | 2 |
| `data-entry-app/frontend/src/views/LoginView.vue` | 2 |
| `data-entry-app/backend/app/db/models/nomenclature.py` | 3 |
| `data-entry-app/backend/app/services/sync_service.py` | 3 |
| `data-entry-app/backend/app/routers/nomenclature.py` | 3 |
| `migrations/versions/xxx_nomenclature.py` | 3 |
---
## Ordine Execuție
**Faza 1 + 2 (Auth)****Faza 3 + 4 (Nomenclatoare)**
Fazele 1-2 sunt blocante pentru funcționalitatea completă, dar Faza 3-4 poate fi amânată dacă e nevoie (nomenclatoarele rămân mock data temporar).
---
## Strategie Execuție cu Agenți
### Agenți Paraleli Recomandați
**Round 1 - Auth (Backend + Frontend simultan):**
```
Agent A: Faza 1 - Task 1.1-1.5 (Backend auth)
Agent B: Faza 2 - Task 2.1-2.3 (Frontend auth files)
```
După Round 1, testare manuală auth flow.
**Round 2 - Finalizare Auth + Start Nomenclatoare:**
```
Agent A: Faza 2 - Task 2.4-2.5 (Router guards, vite config)
Agent B: Faza 3 - Task 3.1-3.2 (Modele SQLModel + migration)
```
**Round 3 - Nomenclatoare + Integration:**
```
Agent A: Faza 3 - Task 3.3-3.6 (Sync service + router)
Agent B: Faza 4 - Task 4.1-4.2 (Frontend OCR supplier)
```
### Validare După Fiecare Fază
**După Faza 1:**
```bash
curl http://localhost:8003/api/receipts/
# Expected: 401 Unauthorized
```
**După Faza 2:**
```bash
# Browser: http://localhost:3010
# Expected: Redirect to /login
# Login cu credențiale Oracle → Redirect la /
```
**După Faza 3:**
```bash
curl http://localhost:8003/api/nomenclature/suppliers/search?fiscal_code=RO12345678
# Expected: Search result sau sugestie creare local
```
**După Faza 4:**
```
# Browser: Crează bon nou → Upload poză → OCR
# Expected: Furnizor găsit automat sau dialog creare
```
---
## Context pentru Sesiune Următoare
### Fișiere Cheie de Citit
1. Acest plan: `/home/marius/.claude/plans/unified-orbiting-sonnet.md`
2. CLAUDE.md principal: `/mnt/e/proiecte/roa2web/CLAUDE.md`
3. CLAUDE.md data-entry: `/mnt/e/proiecte/roa2web/data-entry-app/CLAUDE.md`
### Comenzi Quick Start
```bash
cd /mnt/e/proiecte/roa2web
git status # Verifică branch feature/data-entry-receipts
./ssh_tunnel.sh start # SSH tunnel pentru Oracle
```
### Dependențe Servicii
- **reports-backend:8001** - NECESAR pentru auth API (login, refresh)
- **data-entry-backend:8003** - Backend principal
- **Oracle DB** - Via SSH tunnel, necesar pentru auth + nomenclatoare

View File

@@ -1,484 +0,0 @@
# Plan: Sincronizare Nomenclatoare Oracle + Auth SSO + OCR Furnizori
## Obiective
1. **Sincronizare nomenclatoare din Oracle în SQLite** (furnizori, casa/banca)
2. **Auth pentru data-entry-app** cu SSO (frontend-uri separate pe path)
3. **OCR: căutare furnizor după CUI** + creare locală dacă nu există
4. **Deploy Windows IIS** cu path routing
---
## Arhitectura Aleasă
```
roa2web.romfast.ro (IIS + ARR)
├── /reports/ → reports-app/frontend/
├── /data/ → data-entry-app/frontend/
├── /api/reports/* → reports-backend:8001
├── /api/data/* → data-entry-backend:8003
└── /api/auth/* → reports-backend (auth provider)
```
**URL-uri compacte:**
- `roa2web.romfast.ro/reports/` - Rapoarte
- `roa2web.romfast.ro/data/` - Introducere date (bonuri fiscale)
- `roa2web.romfast.ro/api/reports/` - API rapoarte
- `roa2web.romfast.ro/api/data/` - API introducere date
**SSO**: Același domeniu = localStorage partajat = token JWT valid pentru ambele
---
## Faza 1: Auth pentru Data-Entry-App
### 1.1 Backend - Integrare shared/auth/
**Fișiere de modificat:**
- `data-entry-app/backend/app/main.py`
- `data-entry-app/backend/app/routers/receipts.py`
- `data-entry-app/backend/app/core/config.py`
**Acțiuni:**
```python
# main.py - Adăugare middleware
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent / "shared"))
from auth.middleware import AuthenticationMiddleware
from auth.dependencies import get_current_user
app.add_middleware(
AuthenticationMiddleware,
excluded_paths=["/docs", "/redoc", "/openapi.json", "/health", "/"]
)
```
```python
# receipts.py - Înlocuire placeholder
from auth.dependencies import get_current_user
from auth.models import CurrentUser
@router.get("/")
async def list_receipts(
current_user: CurrentUser = Depends(get_current_user)
):
# folosește current_user.username
```
### 1.2 Frontend - Auth Store + Login Page
**Fișiere de creat/copiat din reports-app:**
- `data-entry-app/frontend/src/stores/auth.js` (copiat)
- `data-entry-app/frontend/src/views/LoginView.vue` (copiat)
- `data-entry-app/frontend/src/router/index.js` (adăugat guard)
- `data-entry-app/frontend/src/services/api.js` (axios interceptor)
**Decizie SSO:**
- Frontend data-entry folosește `/api/auth/login` de pe reports-backend
- Sau: redirect la `/login` (reports-app) care setează token în localStorage
- Token valid pentru ambele (același JWT_SECRET_KEY)
---
## Faza 2: Sincronizare Nomenclatoare Oracle → SQLite
### 2.1 Noi Modele SQLModel
**Fișier:** `data-entry-app/backend/app/db/models/nomenclature.py`
```python
class SyncedSupplier(SQLModel, table=True):
"""Furnizori sincronizați din Oracle"""
__tablename__ = "synced_suppliers"
id: int = Field(primary_key=True) # ID din Oracle (ID_PART)
company_id: int = Field(index=True)
name: str = Field(max_length=200) # DEN_PART
fiscal_code: Optional[str] = Field(max_length=20, index=True) # COD_FISCAL
address: Optional[str] = Field(max_length=500)
synced_at: datetime = Field(default_factory=datetime.utcnow)
class LocalSupplier(SQLModel, table=True):
"""Furnizori creați local din OCR (neexistenți în Oracle)"""
__tablename__ = "local_suppliers"
id: Optional[int] = Field(default=None, primary_key=True)
company_id: int = Field(index=True)
name: str = Field(max_length=200)
fiscal_code: str = Field(max_length=20, unique=True, index=True)
address: Optional[str] = Field(max_length=500)
created_by: str = Field(max_length=100)
created_at: datetime = Field(default_factory=datetime.utcnow)
oracle_synced: bool = Field(default=False) # True când e creat în Oracle
class SyncedCashRegister(SQLModel, table=True):
"""Case/Bănci sincronizate din Oracle"""
__tablename__ = "synced_cash_registers"
id: int = Field(primary_key=True) # ID din Oracle
company_id: int = Field(index=True)
name: str = Field(max_length=100)
account_code: str = Field(max_length=20) # 5311, 5121 etc.
register_type: str = Field(max_length=20) # CASA sau BANCA
synced_at: datetime = Field(default_factory=datetime.utcnow)
```
### 2.2 Alembic Migration
**Fișier:** `data-entry-app/backend/migrations/versions/xxx_add_nomenclature_tables.py`
### 2.3 Sync Service
**Fișier:** `data-entry-app/backend/app/services/sync_service.py`
```python
class NomenclatureSyncService:
"""Sincronizare nomenclatoare din Oracle în SQLite"""
@staticmethod
async def sync_suppliers(company_id: int, schema: str) -> int:
"""Sincronizează furnizori pentru o companie"""
async with oracle_pool.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(f"""
SELECT ID_PART, DEN_PART, COD_FISCAL, ADRESA
FROM {schema}.NOM_PARTENERI
WHERE TIP_PART IN ('F', 'A') -- Furnizori sau Ambele
""")
# Upsert în SQLite
@staticmethod
async def sync_cash_registers(company_id: int, schema: str) -> int:
"""Sincronizează case și bănci"""
# Similar pentru NOM_CASE și NOM_BANCI
@staticmethod
async def get_schema_for_company(company_id: int) -> str:
"""Obține schema Oracle pentru o companie"""
# Folosește cache din shared sau query V_NOM_FIRME
```
### 2.4 Strategia de Sync Hibrid
1. **La startup app**: Sync automat (background task)
2. **Periodic**: Task programat la 4h
3. **On-demand**: Căutare live în Oracle când CUI nu există local
**Fișier:** `data-entry-app/backend/app/main.py`
```python
@app.on_event("startup")
async def startup_sync():
# Background sync pentru company-urile active
asyncio.create_task(sync_nomenclatures_background())
```
---
## Faza 3: OCR + Căutare Furnizor după CUI
### 3.1 Flow Căutare Furnizor
```
OCR extrage CUI
Căutare în SyncedSupplier (SQLite)
↓ (nu găsit)
Căutare în LocalSupplier (SQLite)
↓ (nu găsit)
Căutare LIVE în Oracle (NOM_PARTENERI)
↓ (nu găsit)
Creare LocalSupplier cu date OCR
Utilizator poate edita înainte de submit
```
### 3.2 Endpoint Căutare Furnizor
**Fișier:** `data-entry-app/backend/app/routers/nomenclature.py`
```python
@router.get("/suppliers/search")
async def search_supplier(
company_id: int,
fiscal_code: Optional[str] = None,
name: Optional[str] = None,
current_user: CurrentUser = Depends(get_current_user)
) -> SupplierSearchResult:
"""
Caută furnizor:
1. În SQLite (synced + local)
2. Live în Oracle dacă nu găsit
3. Returnează sugestie creare dacă nu există
"""
@router.post("/suppliers/local")
async def create_local_supplier(
supplier: LocalSupplierCreate,
current_user: CurrentUser = Depends(get_current_user)
) -> LocalSupplier:
"""Crează furnizor local din date OCR"""
```
### 3.3 Modificare OCR Flow în Frontend
**Fișier:** `data-entry-app/frontend/src/views/ReceiptCreateView.vue`
```javascript
// După OCR, caută automat furnizor
async function handleOCRResult(ocrData) {
if (ocrData.cui) {
const result = await api.get('/api/data-entry/suppliers/search', {
params: { company_id: selectedCompany.id, fiscal_code: ocrData.cui }
});
if (result.found) {
form.partner_id = result.supplier.id;
form.partner_name = result.supplier.name;
} else {
// Afișează opțiune creare locală
showCreateSupplierDialog(ocrData);
}
}
}
```
---
## Faza 4: Deploy Windows IIS
### 4.1 Serviciu Windows pentru data-entry-backend
**Fișier:** `deployment/windows/scripts/Install-DataEntry.ps1`
Similar cu Install-ROA2WEB.ps1 dar:
- ServiceName: `ROA2WEB-DataEntry`
- Port: 8003
- BackendPath: `C:\inetpub\wwwroot\roa2web\data-entry-app\backend`
- FrontendPath: `C:\inetpub\wwwroot\roa2web\data-entry-app\frontend`
**Actualizare Install-ROA2WEB.ps1** pentru structura unitară:
- BackendPath: `C:\inetpub\wwwroot\roa2web\reports-app\backend`
- FrontendPath: `C:\inetpub\wwwroot\roa2web\reports-app\frontend`
### 4.2 Actualizare web.config
**Fișier:** `deployment/windows/config/web.config`
Reguli URL compacte (`/reports/`, `/data/`, `/api/reports/`, `/api/data/`):
```xml
<!-- API Auth (comun) -->
<rule name="Auth API" stopProcessing="true">
<match url="^api/auth/(.*)" />
<action type="Rewrite" url="http://localhost:8001/api/auth/{R:1}" />
</rule>
<!-- API Data Entry -->
<rule name="Data Entry API" stopProcessing="true">
<match url="^api/data/(.*)" />
<action type="Rewrite" url="http://localhost:8003/api/{R:1}" />
</rule>
<!-- API Reports -->
<rule name="Reports API" stopProcessing="true">
<match url="^api/reports/(.*)" />
<action type="Rewrite" url="http://localhost:8001/api/{R:1}" />
</rule>
<!-- Frontend Data Entry SPA (/data/) -->
<rule name="Data Entry SPA" stopProcessing="true">
<match url="^data($|/.*)" />
<conditions>
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
</conditions>
<action type="Rewrite" url="/data/index.html" />
</rule>
<!-- Frontend Reports SPA (/reports/) -->
<rule name="Reports SPA" stopProcessing="true">
<match url="^reports($|/.*)" />
<conditions>
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
</conditions>
<action type="Rewrite" url="/reports/index.html" />
</rule>
<!-- Root redirect la /reports/ -->
<rule name="Root Redirect" stopProcessing="true">
<match url="^$" />
<action type="Redirect" url="/reports/" redirectType="Found" />
</rule>
```
**IIS Virtual Directories (pentru URL-uri compacte):**
```powershell
# /reports/ → reports-app/frontend/
New-WebVirtualDirectory -Site "Default Web Site" -Name "reports" `
-PhysicalPath "C:\inetpub\wwwroot\roa2web\reports-app\frontend"
# /data/ → data-entry-app/frontend/
New-WebVirtualDirectory -Site "Default Web Site" -Name "data" `
-PhysicalPath "C:\inetpub\wwwroot\roa2web\data-entry-app\frontend"
```
### 4.3 Structura Foldere (UNITARĂ - identică dev/prod)
**În development (git repo):**
```
roa2web/
├── reports-app/
│ ├── backend/ # FastAPI port 8001
│ ├── frontend/ # Vue.js port 3000
│ └── telegram-bot/ # Bot Telegram
├── data-entry-app/
│ ├── backend/ # FastAPI port 8003
│ └── frontend/ # Vue.js port 3010
└── shared/ # Cod partajat (auth, database)
```
**În producție (Windows IIS) - IDENTIC:**
```
C:\inetpub\wwwroot\roa2web\
├── reports-app/
│ ├── backend/ # Serviciu Windows port 8001
│ └── frontend/ # Servit de IIS pe /
├── data-entry-app/
│ ├── backend/ # Serviciu Windows port 8003
│ └── frontend/ # Servit de IIS pe /data-entry/
├── telegram-bot/ # Serviciu Windows port 8002
└── shared/ # Cod partajat
```
**Avantaje structură unitară:**
- Deploy simplu: `xcopy /E /Y source\reports-app dest\reports-app`
- Path-uri identice în cod (no surprises)
- Un singur script de deploy pentru ambele medii
---
## Faza 5: Configurare Dev (identic cu prod)
### 5.1 Vite Config pentru URL-uri Compacte
**Fișier:** `data-entry-app/frontend/vite.config.js`
```javascript
export default defineConfig({
base: '/data/', // URL compact în producție
server: {
proxy: {
'/api/auth': 'http://localhost:8001',
'/api/data': 'http://localhost:8003'
}
}
})
```
**Fișier:** `reports-app/frontend/vite.config.js` (ACTUALIZAT)
```javascript
export default defineConfig({
base: '/reports/', // URL compact în producție (era '/')
server: {
proxy: {
'/api/auth': 'http://localhost:8001',
'/api/reports': 'http://localhost:8001'
}
}
})
```
**IMPORTANT:** Actualizare API calls în frontend:
- Reports: `/api/reports/companies`, `/api/reports/invoices`, etc.
- Data Entry: `/api/data/receipts`, `/api/data/suppliers`, etc.
- Auth (comun): `/api/auth/login`, `/api/auth/refresh`
### 5.2 Script Start Unificat
**Fișier:** `start-all.sh` (nou)
```bash
#!/bin/bash
# Pornește toate serviciile pentru dev
# SSH tunnel
./ssh_tunnel.sh start
# Reports backend
cd reports-app/backend && uvicorn app.main:app --port 8001 &
# Data entry backend
cd data-entry-app/backend && uvicorn app.main:app --port 8003 &
# Reports frontend
cd reports-app/frontend && npm run dev -- --port 3000 &
# Data entry frontend
cd data-entry-app/frontend && npm run dev -- --port 3010 &
wait
```
---
## Ordine Implementare
| # | Task | Efort | Dependențe |
|---|------|-------|------------|
| 1 | Modele SQLModel nomenclatoare | 30 min | - |
| 2 | Alembic migration | 15 min | #1 |
| 3 | Sync service (Oracle → SQLite) | 2h | #2 |
| 4 | Auth middleware în data-entry-backend | 1h | - |
| 5 | Auth store + login în data-entry-frontend | 1h | #4 |
| 6 | Endpoint căutare furnizor | 1h | #3 |
| 7 | Frontend OCR + furnizor flow | 1.5h | #6 |
| 8 | web.config IIS actualizat | 30 min | - |
| 9 | Script deploy data-entry Windows | 1h | #8 |
| 10 | Testare end-to-end | 1h | all |
**Total estimat: ~10h**
---
## Fișiere Critice de Modificat/Creat
### Backend data-entry-app:
- `app/main.py` - middleware auth + startup sync
- `app/db/models/nomenclature.py` - noi modele (CREARE)
- `app/services/sync_service.py` - sync Oracle (CREARE)
- `app/services/nomenclature_service.py` - refactorizare
- `app/routers/nomenclature.py` - endpoint-uri noi (CREARE)
- `app/routers/receipts.py` - auth dependencies
- `migrations/versions/xxx_nomenclature.py` - migrare (CREARE)
### Frontend data-entry-app:
- `src/stores/auth.js` - copiat din reports-app
- `src/views/LoginView.vue` - copiat + adaptat
- `src/router/index.js` - auth guard
- `src/services/api.js` - axios config
- `src/views/ReceiptCreateView.vue` - OCR + supplier flow
### Deploy (structură unitară):
- `deployment/windows/config/web.config` - reguli noi + actualizate
- `deployment/windows/scripts/Install-ROA2WEB.ps1` - ACTUALIZAT pentru structura unitară
- `deployment/windows/scripts/Install-DataEntry.ps1` - NOU
- `deployment/windows/scripts/Build-ROA2WEB.ps1` - ACTUALIZAT pentru ambele apps
- `deployment/windows/docs/WINDOWS_DEPLOYMENT.md` - ACTUALIZAT cu noua structură
### Shared:
- Nu necesită modificări (refolosim exact ce există)
---
## Întrebări Rezolvate
| Întrebare | Răspuns |
|-----------|---------|
| Furnizor nou din OCR? | Creare automată în SQLite (LocalSupplier) |
| Sync strategy? | Hibrid: startup + periodic 4h + on-demand |
| Auth sharing? | Frontend-uri separate pe path, același token JWT (SSO via localStorage) |
| Deployment? | IIS path routing, servicii Windows separate |
| Structura directoare? | **UNITARĂ** - grupat pe app (`{app}/backend`, `{app}/frontend`) identic dev/prod |
| SSO cum funcționează? | Același domeniu IIS → localStorage partajat → token valid pentru ambele API-uri |
| URL-uri? | **COMPACTE**: `/reports/`, `/data/`, `/api/reports/`, `/api/data/` |
| Root (/)? | Redirect automat la `/reports/` |

View File

@@ -1,38 +1,33 @@
<template>
<div class="app-container">
<header v-if="authStore.isAuthenticated" class="app-header">
<div class="header-content">
<h1 class="app-title">
<AppHeader
v-if="authStore.isAuthenticated"
title="Data Entry"
brand-link="/"
header-class="header-container--gradient"
:menu-open="menuOpen"
:companies-store="companyStore"
:period-store="periodStore"
:current-user="authStore.currentUser"
:show-user="false"
@menu-toggle="menuOpen = !menuOpen"
@company-changed="onCompanyChanged"
@period-changed="onPeriodChanged"
>
<template #brand>
<i class="pi pi-receipt"></i>
Data Entry - Bonuri Fiscale
</h1>
<nav class="app-nav">
<router-link to="/" class="nav-link">
<i class="pi pi-list"></i> Lista Bonuri
</router-link>
<router-link to="/create" class="nav-link">
<i class="pi pi-plus"></i> Bon Nou
</router-link>
<router-link to="/approval" class="nav-link">
<i class="pi pi-check-circle"></i> Aprobare
<Badge v-if="pendingCount > 0" :value="pendingCount" severity="danger" />
</router-link>
<div class="user-menu">
<span class="user-name">
<i class="pi pi-user"></i>
{{ authStore.currentUser?.username || 'User' }}
</span>
<Button
icon="pi pi-sign-out"
label="Ieșire"
class="logout-button"
@click="handleLogout"
text
<span>Data Entry</span>
</template>
</AppHeader>
<SlideMenu
v-if="authStore.isAuthenticated"
:is-open="menuOpen"
:menu-items="dataEntryMenuItems"
:current-user="authStore.currentUser"
@close="menuOpen = false"
@logout="handleLogout"
/>
</div>
</nav>
</div>
</header>
<main class="app-main">
<router-view />
@@ -44,23 +39,74 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { useCompanyStore } from './stores/companies'
import { useAccountingPeriodStore } from './stores/accountingPeriod'
import { useReceiptsStore } from './stores/receiptsStore'
import apiService from './services/api'
import AppHeader from '../../../shared/frontend/components/layout/AppHeader.vue'
import SlideMenu from '../../../shared/frontend/components/layout/SlideMenu.vue'
const router = useRouter()
const authStore = useAuthStore()
const companyStore = useCompanyStore()
const periodStore = useAccountingPeriodStore()
const receiptsStore = useReceiptsStore()
const menuOpen = ref(false)
const pendingCount = ref(0)
// Menu items configuration
const dataEntryMenuItems = computed(() => [
{
title: 'Navigare',
items: [
{ to: '/', icon: 'pi pi-list', label: 'Lista Bonuri' },
{ to: '/create', icon: 'pi pi-plus', label: 'Bon Nou' },
{
to: '/approval',
icon: 'pi pi-check-circle',
label: 'Aprobare',
badge: pendingCount.value > 0 ? pendingCount.value : null
},
]
}
])
const handleLogout = () => {
authStore.logout()
companyStore.reset()
periodStore.reset()
router.push('/login')
}
onMounted(async () => {
if (authStore.isAuthenticated) {
const onCompanyChanged = async (company) => {
console.log('[App] Company changed:', company?.name)
// Trigger nomenclature sync for the selected company (non-blocking)
if (company?.id_firma) {
apiService.post('/nomenclature/sync/all', null, {
headers: { 'X-Selected-Company': company.id_firma }
}).then(() => {
console.log('[App] Nomenclature sync completed for company:', company.name)
}).catch(e => {
console.warn('[App] Nomenclature sync failed:', e.message || e)
})
}
// Refresh stats when company changes
await refreshStats()
}
const onPeriodChanged = (period) => {
console.log('[App] Period changed:', period?.display_name)
// Refresh data when period changes
refreshStats()
}
const refreshStats = async () => {
if (authStore.isAuthenticated && companyStore.selectedCompany) {
try {
const stats = await receiptsStore.fetchStats()
pendingCount.value = stats?.pending_review?.count || 0
@@ -68,7 +114,38 @@ onMounted(async () => {
console.error('Failed to fetch stats:', error)
}
}
}
onMounted(async () => {
if (authStore.isAuthenticated) {
// Load companies first
await companyStore.loadCompanies()
// If company is selected, trigger initial sync and load stats
if (companyStore.selectedCompany) {
// Sync nomenclatures for current company (background, non-blocking)
apiService.post('/nomenclature/sync/all', null, {
headers: { 'X-Selected-Company': companyStore.selectedCompany.id_firma }
}).then(() => {
console.log('[App] Initial nomenclature sync completed')
}).catch(e => {
console.warn('[App] Initial nomenclature sync skipped:', e.message || e)
})
await refreshStats()
}
}
})
// Watch for company selection to refresh stats
watch(
() => companyStore.selectedCompany,
async (newCompany) => {
if (newCompany) {
await refreshStats()
}
}
)
</script>
<style scoped>
@@ -78,85 +155,6 @@ onMounted(async () => {
flex-direction: column;
}
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 2rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header-content {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.app-title {
font-size: 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.app-nav {
display: flex;
gap: 0.5rem;
align-items: center;
}
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 6px;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background-color 0.2s;
font-weight: 500;
}
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.nav-link.router-link-active {
background-color: rgba(255, 255, 255, 0.3);
}
.user-menu {
display: flex;
align-items: center;
gap: 1rem;
margin-left: 1rem;
padding-left: 1rem;
border-left: 1px solid rgba(255, 255, 255, 0.3);
}
.user-name {
display: flex;
align-items: center;
gap: 0.5rem;
color: white;
font-weight: 500;
font-size: 0.9rem;
}
.logout-button {
color: white !important;
padding: 0.5rem 1rem;
}
.logout-button:hover {
background-color: rgba(255, 255, 255, 0.2) !important;
}
.app-main {
flex: 1;
padding: 2rem;
@@ -164,16 +162,6 @@ onMounted(async () => {
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
text-align: center;
}
.app-nav {
flex-wrap: wrap;
justify-content: center;
}
.app-main {
padding: 1rem;
}

View File

@@ -1,6 +1,42 @@
/* Global styles for Data Entry App */
/* Import shared layout styles */
@import '../../../../../shared/frontend/styles/layout/header.css';
@import '../../../../../shared/frontend/styles/layout/navigation.css';
:root {
/* Layout variables */
--header-height: 60px;
--sidebar-width: 280px;
--z-header: 100;
--z-modal: 1000;
--z-modal-backdrop: 999;
/* Shadows */
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
/* Transitions */
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
/* Typography */
--font-semibold: 600;
--font-medium: 500;
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 18px;
/* Radius */
--radius-md: 6px;
--radius-full: 9999px;
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 24px;
/* Colors - Primary palette (matching reports-app) */
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;

View File

@@ -71,7 +71,7 @@
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import api from '@/services/api'
const emit = defineEmits(['ocr-result', 'file-selected', 'error'])
@@ -137,7 +137,7 @@ const processOCR = async () => {
const formData = new FormData()
formData.append('file', selectedFile.value)
const response = await axios.post('/api/ocr/extract', formData, {
const response = await api.post('/ocr/extract', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000, // 60 second timeout for OCR
})

View File

@@ -33,6 +33,7 @@ import Badge from 'primevue/badge'
import Toolbar from 'primevue/toolbar'
import Divider from 'primevue/divider'
import Tooltip from 'primevue/tooltip'
import Message from 'primevue/message'
// PrimeVue styles
import 'primevue/resources/themes/lara-light-blue/theme.css'
@@ -80,6 +81,7 @@ app.component('ProgressSpinner', ProgressSpinner)
app.component('Badge', Badge)
app.component('Toolbar', Toolbar)
app.component('Divider', Divider)
app.component('Message', Message)
// Register PrimeVue directives
app.directive('tooltip', Tooltip)

View File

@@ -9,13 +9,31 @@ const apiService = axios.create({
},
});
// Request interceptor to add auth token
// Request interceptor to add auth token and selected company
apiService.interceptors.request.use(
(config) => {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Add X-Selected-Company header from localStorage
// The company store saves the selected company per user
const user = JSON.parse(localStorage.getItem("user") || "null");
if (user?.username) {
const savedCompany = localStorage.getItem(`selected_company_${user.username}`);
if (savedCompany) {
try {
const company = JSON.parse(savedCompany);
if (company?.id_firma) {
config.headers["X-Selected-Company"] = company.id_firma;
}
} catch (e) {
// Invalid JSON, ignore
}
}
}
return config;
},
(error) => {

View File

@@ -0,0 +1,17 @@
/**
* Accounting Period Store for Data Entry App
*
* Uses the shared accounting period store factory from shared/frontend/stores/accountingPeriod.js
* Configured with the data-entry API service (port 8003)
*/
import { createAccountingPeriodStore } from "../../../../shared/frontend/stores/accountingPeriod";
import { apiService } from "../services/api";
import { useAuthStore } from "./auth";
import { useCompanyStore } from "./companies";
export const useAccountingPeriodStore = createAccountingPeriodStore(
apiService,
useAuthStore,
useCompanyStore
);

View File

@@ -0,0 +1,12 @@
/**
* Companies Store for Data Entry App
*
* Uses the shared companies store factory from shared/frontend/stores/companies.js
* Configured with the data-entry API service (port 8003)
*/
import { createCompaniesStore } from "../../../../shared/frontend/stores/companies";
import { apiService } from "../services/api";
import { useAuthStore } from "./auth";
export const useCompanyStore = createCompaniesStore(apiService, useAuthStore);

View File

@@ -372,6 +372,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useReceiptsStore } from '../../stores/receiptsStore'
import { useCompanyStore } from '../../stores/companies'
import OCRUploadZone from '../../components/ocr/OCRUploadZone.vue'
import OCRPreview from '../../components/ocr/OCRPreview.vue'
import Dialog from 'primevue/dialog'
@@ -380,11 +381,17 @@ const route = useRoute()
const router = useRouter()
const toast = useToast()
const store = useReceiptsStore()
const companyStore = useCompanyStore()
const isEditMode = computed(() => !!route.params.id)
const receiptId = computed(() => route.params.id)
const receipt = ref(null)
// Get selected company ID from store
const getSelectedCompanyId = () => {
return companyStore.selectedCompanyId || 1
}
const form = ref({
receipt_type: 'bon_fiscal',
direction: 'cheltuiala',
@@ -398,7 +405,7 @@ const form = ref({
cash_register_account: null,
receipt_number: '',
description: '',
company_id: 1, // Default company for Phase 1
company_id: getSelectedCompanyId(),
// TVA info (multiple entries support)
tva_breakdown: [], // Array of {code, percent, amount}
tva_total: null,
@@ -429,6 +436,9 @@ onMounted(async () => {
if (isEditMode.value) {
await loadReceipt()
} else {
// For new receipts, ensure company_id is set from the current selected company
form.value.company_id = companyStore.selectedCompanyId || 1
}
})

View File

@@ -13,10 +13,6 @@ export default defineConfig({
server: {
port: 3010,
proxy: {
'/api/auth': {
target: 'http://localhost:8001',
changeOrigin: true,
},
'/api': {
target: 'http://localhost:8003',
changeOrigin: true,

View File

@@ -1,25 +1,79 @@
<template>
<div id="app">
<!-- New Navigation System -->
<DashboardHeader
<!-- Shared Header Component -->
<AppHeader
v-if="authStore.isAuthenticated"
title="ROA2WEB"
brand-link="/dashboard"
:menu-open="menuOpen"
@menu-toggle="handleMenuToggle"
:companies-store="companyStore"
:period-store="periodStore"
:current-user="authStore.currentUser"
:show-user="true"
@menu-toggle="menuOpen = !menuOpen"
@company-changed="handleCompanyChanged"
/>
@period-changed="handlePeriodChanged"
@user-menu-toggle="handleUserMenuToggle"
>
<template #user-menu>
<div class="user-menu-container mobile-hide">
<div class="header-user" @click="toggleUserMenu">
<i class="pi pi-user"></i>
<span class="desktop-only">{{ authStore.currentUser?.username || "User" }}</span>
<i class="pi pi-chevron-down" :class="{ 'rotate-180': userMenuOpen }"></i>
</div>
<!-- Hamburger Menu -->
<HamburgerMenu
<!-- User Dropdown Menu -->
<div v-if="userMenuOpen" class="user-dropdown">
<div class="user-dropdown-header">
<div class="user-info">
<div class="user-name">{{ authStore.currentUser?.username || "User" }}</div>
<div class="user-email">{{ authStore.currentUser?.email || "" }}</div>
</div>
</div>
<div class="user-dropdown-divider"></div>
<button class="user-dropdown-item" @click="navigateToTelegram">
<i class="pi pi-telegram"></i>
<span>Telegram Bot</span>
</button>
<div class="user-dropdown-divider"></div>
<button class="user-dropdown-item" @click="handleLogout">
<i class="pi pi-sign-out"></i>
<span>Logout</span>
</button>
</div>
</div>
</template>
</AppHeader>
<!-- Shared Slide Menu -->
<SlideMenu
v-if="authStore.isAuthenticated"
:is-open="menuOpen"
@close="handleMenuClose"
/>
:menu-items="reportsMenuItems"
:current-user="authStore.currentUser"
@close="menuOpen = false"
@logout="handleLogout"
>
<template #profile-items>
<li class="menu-item">
<router-link
to="/telegram"
class="menu-link"
@click="menuOpen = false"
>
<i class="menu-icon pi pi-telegram"></i>
<span>Telegram Bot</span>
</router-link>
</li>
</template>
</SlideMenu>
<!-- User Menu Overlay -->
<div v-if="userMenuOpen" class="user-menu-overlay" @click="closeUserMenu"></div>
<!-- Main Content -->
<main
class="main-content"
:class="{ 'with-navbar': authStore.isAuthenticated }"
>
<main class="main-content" :class="{ 'with-navbar': authStore.isAuthenticated }">
<router-view />
</main>
@@ -36,24 +90,49 @@ import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "./stores/auth";
import { useCompanyStore } from "./stores/companies";
import DashboardHeader from "./components/layout/DashboardHeader.vue";
import HamburgerMenu from "./components/layout/HamburgerMenu.vue";
import { useAccountingPeriodStore } from "./stores/accountingPeriod";
import AppHeader from "../../../shared/frontend/components/layout/AppHeader.vue";
import SlideMenu from "../../../shared/frontend/components/layout/SlideMenu.vue";
const router = useRouter();
const authStore = useAuthStore();
const companyStore = useCompanyStore();
const periodStore = useAccountingPeriodStore();
// Menu state
const menuOpen = ref(false);
const userMenuOpen = ref(false);
// Handle menu toggle
const handleMenuToggle = () => {
menuOpen.value = !menuOpen.value;
// Menu items configuration for reports-app
const reportsMenuItems = [
{
title: 'Navigare',
items: [
{ to: '/dashboard', icon: 'pi pi-home', label: 'Dashboard' },
{ to: '/invoices', icon: 'pi pi-file', label: 'Facturi' },
{ to: '/bank-cash-register', icon: 'pi pi-money-bill', label: 'Casa și Banca' },
{ to: '/trial-balance', icon: 'pi pi-calculator', label: 'Balanță de Verificare' },
]
},
{
title: 'Sistem',
items: [
{ to: '/cache-stats', icon: 'pi pi-chart-bar', label: 'Statistici cache' },
]
}
];
// User menu handlers
const toggleUserMenu = () => {
userMenuOpen.value = !userMenuOpen.value;
};
// Handle menu close
const handleMenuClose = () => {
menuOpen.value = false;
const closeUserMenu = () => {
userMenuOpen.value = false;
};
const handleUserMenuToggle = () => {
toggleUserMenu();
};
// Handle company change
@@ -61,6 +140,35 @@ const handleCompanyChanged = (company) => {
console.log("Company changed in App:", company);
};
// Handle period change
const handlePeriodChanged = (period) => {
console.log("Period changed in App:", period);
};
// Navigate to Telegram
const navigateToTelegram = async () => {
try {
closeUserMenu();
await router.push("/telegram");
} catch (error) {
console.error("Navigation error:", error);
}
};
// Handle logout
const handleLogout = async () => {
try {
authStore.logout();
companyStore.reset();
periodStore.reset();
closeUserMenu();
menuOpen.value = false;
await router.push("/login");
} catch (error) {
console.error("Logout error:", error);
}
};
// Initialize app
onMounted(async () => {
// Check authentication on app start
@@ -93,6 +201,146 @@ onMounted(async () => {
.main-content:not(.with-navbar) {
min-height: 100vh;
}
/* User Menu Container */
.user-menu-container {
position: relative;
}
/* Header User Button */
.header-user {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: transparent;
border: none;
color: var(--color-primary);
cursor: pointer;
border-radius: var(--radius-md);
transition: background-color var(--transition-fast);
}
.header-user:hover {
background: var(--color-bg-secondary);
}
/* User Dropdown */
.user-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 220px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: var(--z-dropdown, 1000);
overflow: hidden;
}
.user-dropdown-header {
padding: var(--space-md);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
.user-info {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.user-name {
font-weight: var(--font-semibold);
color: var(--color-text);
font-size: var(--text-sm);
}
.user-email {
color: var(--color-text-secondary);
font-size: var(--text-xs);
}
.user-dropdown-divider {
height: 1px;
background: var(--color-border);
}
.user-dropdown-item {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: none;
border: none;
color: var(--color-text);
font-size: var(--text-sm);
text-align: left;
cursor: pointer;
transition: background-color var(--transition-fast);
}
.user-dropdown-item:hover {
background: var(--color-bg-secondary);
}
.user-dropdown-item:focus {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
background: var(--color-bg-secondary);
}
/* User Menu Overlay */
.user-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
background: transparent;
}
/* Chevron rotation animation */
.rotate-180 {
transform: rotate(180deg);
transition: transform var(--transition-fast);
}
.pi-chevron-down {
transition: transform var(--transition-fast);
}
/* Desktop only class */
.desktop-only {
display: inline;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.user-dropdown {
min-width: 200px;
}
.user-dropdown-header {
padding: var(--space-sm);
}
.user-dropdown-item {
padding: var(--space-sm);
}
/* Hide profile menu on mobile - use hamburger menu instead */
.mobile-hide {
display: none !important;
}
.desktop-only {
display: none;
}
}
</style>
<style>

View File

@@ -2,6 +2,10 @@
/* Import order is critical for proper CSS cascade */
/* 0. Shared Layout Styles (from shared/frontend/styles) */
@import '../../../../../shared/frontend/styles/layout/header.css';
@import '../../../../../shared/frontend/styles/layout/navigation.css';
/* 1. Core Foundation */
@import "./core/variables.css";
@import "./core/tokens.css"; /* NEW - Extended design tokens */

View File

@@ -1,138 +1,17 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
/**
* Accounting Period Store for Reports App
*
* Uses the shared accounting period store factory from shared/frontend/stores/accountingPeriod.js
* Configured with the reports API service (port 8001)
*/
import { createAccountingPeriodStore } from "@shared/frontend/stores/accountingPeriod";
import { apiService } from "../services/api";
import { useAuthStore } from "./auth";
import { useCompanyStore } from "./companies";
export const useAccountingPeriodStore = defineStore("accountingPeriod", () => {
// State
const periods = ref([]);
const selectedPeriod = ref(null);
const isLoading = ref(false);
const error = ref(null);
// Getters
const hasPeriods = computed(() => periods.value.length > 0);
const currentPeriod = computed(() => selectedPeriod.value);
// Computed date range for current period (first/last day of month)
const dateRange = computed(() => {
if (!selectedPeriod.value) return { dateFrom: null, dateTo: null };
const { an, luna } = selectedPeriod.value;
const firstDay = new Date(an, luna - 1, 1);
const lastDay = new Date(an, luna, 0);
return {
dateFrom: firstDay,
dateTo: lastDay,
};
});
// Actions
const loadPeriods = async (companyId) => {
if (!companyId) return { success: false };
isLoading.value = true;
error.value = null;
try {
const response = await apiService.get("/calendar/periods", {
params: { company: companyId },
});
periods.value = response.data.periods || [];
// Try to restore saved period or use most recent
const saved = initializeSelectedPeriod();
if (saved) {
const exists = periods.value.find(
(p) => p.an === saved.an && p.luna === saved.luna
export const useAccountingPeriodStore = createAccountingPeriodStore(
apiService,
useAuthStore,
useCompanyStore
);
if (exists) {
selectedPeriod.value = exists;
} else if (response.data.current_period) {
setSelectedPeriod(response.data.current_period);
}
} else if (response.data.current_period) {
setSelectedPeriod(response.data.current_period);
}
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load periods";
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const setSelectedPeriod = (period) => {
selectedPeriod.value = period;
persistSelectedPeriod(period);
};
const resetToLatest = () => {
if (periods.value.length > 0) {
setSelectedPeriod(periods.value[0]);
}
};
const reset = () => {
periods.value = [];
selectedPeriod.value = null;
isLoading.value = false;
error.value = null;
};
// localStorage helpers
const getStorageKey = () => {
const authStore = useAuthStore();
const companyStore = useCompanyStore();
const username = authStore.user?.username;
const companyId = companyStore.selectedCompany?.id_firma;
if (!username || !companyId) return null;
return `selected_period_${username}_${companyId}`;
};
const initializeSelectedPeriod = () => {
const key = getStorageKey();
if (!key) return null;
const saved = localStorage.getItem(key);
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
localStorage.removeItem(key);
}
}
return null;
};
const persistSelectedPeriod = (period) => {
const key = getStorageKey();
if (key && period) {
localStorage.setItem(key, JSON.stringify(period));
}
};
return {
// State
periods,
selectedPeriod,
isLoading,
error,
// Getters
hasPeriods,
currentPeriod,
dateRange,
// Actions
loadPeriods,
setSelectedPeriod,
resetToLatest,
reset,
};
});

View File

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

View File

@@ -0,0 +1,555 @@
<template>
<div :class="selectorClass" ref="dropdownContainer">
<div class="company-dropdown" ref="dropdown">
<button
class="company-trigger"
@click="toggleDropdown"
:aria-expanded="dropdownOpen"
aria-label="Selectare firma"
title="Alt+Q pentru selectare rapida"
>
<div class="company-info">
<span class="company-name">{{ selectedCompanyName }}</span>
<span v-if="showFiscalCode" class="company-code">{{ selectedCompanyCode }}</span>
</div>
<i
class="pi pi-chevron-down"
:class="{ 'rotate-180': dropdownOpen }"
></i>
</button>
<div
v-show="dropdownOpen"
class="company-dropdown-panel"
:class="{ 'panel-open': dropdownOpen }"
>
<div class="dropdown-search">
<div class="search-wrapper">
<i class="pi pi-search search-icon"></i>
<input
ref="searchInput"
type="text"
v-model="searchQuery"
placeholder="Cauta firma..."
class="search-input"
@keydown="handleKeyDown"
/>
</div>
</div>
<div class="company-list">
<div
v-for="(company, index) in filteredCompanies"
:key="company.id_firma"
class="company-item"
:class="{
active: company.id_firma === selectedCompany?.id_firma,
'keyboard-highlighted': isHighlighted(index),
}"
@click="selectCompany(company)"
@mouseenter="highlightedIndex = index"
>
<div class="company-details">
<div class="company-main-name">{{ company.name }}</div>
<div v-if="showFiscalCode" class="company-sub-info">
<span class="company-cui">CUI: {{ company.fiscal_code || '-' }}</span>
</div>
</div>
<i
v-if="company.id_firma === selectedCompany?.id_firma"
class="pi pi-check company-selected-icon"
></i>
</div>
</div>
<div v-if="filteredCompanies.length === 0" class="no-results">
<i class="pi pi-info-circle"></i>
<span>Nu s-au gasit firme</span>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
export default {
name: "CompanySelector",
props: {
// The companies store instance
companiesStore: {
type: Object,
required: true,
},
// Optional v-model binding
modelValue: {
type: Object,
default: null,
},
// Show fiscal code in display
showFiscalCode: {
type: Boolean,
default: true,
},
// Variant: 'default' (white background) or 'header' (transparent for dark headers)
variant: {
type: String,
default: "default",
validator: (value) => ['default', 'header'].includes(value),
},
},
emits: ["update:modelValue", "company-changed"],
setup(props, { emit }) {
const dropdown = ref(null);
const dropdownContainer = ref(null);
const searchInput = ref(null);
const dropdownOpen = ref(false);
const searchQuery = ref("");
const highlightedIndex = ref(-1);
const selectedCompany = computed({
get: () => props.modelValue || props.companiesStore.selectedCompany,
set: (value) => {
emit("update:modelValue", value);
props.companiesStore.setSelectedCompany(value);
},
});
const selectedCompanyName = computed(() => {
return selectedCompany.value?.name || "Selectare firma";
});
const selectedCompanyCode = computed(() => {
return selectedCompany.value?.fiscal_code
? `CUI: ${selectedCompany.value.fiscal_code}`
: "";
});
const selectorClass = computed(() => ({
'company-selector': true,
'company-selector--header': props.variant === 'header'
}));
const filteredCompanies = computed(() => {
const companies = props.companiesStore.companies || [];
if (!searchQuery.value || searchQuery.value.trim() === "") {
return companies;
}
const query = searchQuery.value.toLowerCase().trim();
return companies.filter(
(company) =>
company.name?.toLowerCase().includes(query) ||
company.fiscal_code?.toLowerCase().includes(query)
);
});
const toggleDropdown = async () => {
dropdownOpen.value = !dropdownOpen.value;
if (dropdownOpen.value) {
searchQuery.value = "";
highlightedIndex.value = -1;
await nextTick();
searchInput.value?.focus();
}
};
const closeDropdown = () => {
dropdownOpen.value = false;
searchQuery.value = "";
};
const selectCompany = (company) => {
selectedCompany.value = company;
emit("company-changed", company);
closeDropdown();
};
const scrollToHighlighted = () => {
nextTick(() => {
const highlightedElement = document.querySelector(
".company-item.keyboard-highlighted"
);
if (highlightedElement) {
highlightedElement.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
});
};
const handleKeyDown = (event) => {
if (!dropdownOpen.value || filteredCompanies.value.length === 0) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
highlightedIndex.value =
(highlightedIndex.value + 1) % filteredCompanies.value.length;
scrollToHighlighted();
break;
case "ArrowUp":
event.preventDefault();
if (highlightedIndex.value <= 0) {
highlightedIndex.value = filteredCompanies.value.length - 1;
} else {
highlightedIndex.value--;
}
scrollToHighlighted();
break;
case "Enter":
event.preventDefault();
if (
highlightedIndex.value >= 0 &&
highlightedIndex.value < filteredCompanies.value.length
) {
selectCompany(filteredCompanies.value[highlightedIndex.value]);
}
break;
case "Escape":
closeDropdown();
break;
}
};
const isHighlighted = (index) => {
return index === highlightedIndex.value;
};
const openWithShortcut = async () => {
if (dropdownContainer.value) {
dropdownContainer.value.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
await new Promise((resolve) => setTimeout(resolve, 300));
if (!dropdownOpen.value) {
dropdownOpen.value = true;
highlightedIndex.value = -1;
searchQuery.value = "";
await nextTick();
searchInput.value?.focus();
} else {
searchInput.value?.focus();
}
};
const handleGlobalKeyDown = (event) => {
if (event.altKey && event.key === "q") {
event.preventDefault();
openWithShortcut();
}
};
const handleClickOutside = (event) => {
if (dropdown.value && !dropdown.value.contains(event.target)) {
closeDropdown();
}
};
watch(searchQuery, () => {
highlightedIndex.value = -1;
});
onMounted(() => {
document.addEventListener("click", handleClickOutside);
document.addEventListener("keydown", handleGlobalKeyDown);
// Load companies if not already loaded
if (props.companiesStore.companies.length === 0) {
props.companiesStore.loadCompanies();
}
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
document.removeEventListener("keydown", handleGlobalKeyDown);
});
return {
dropdown,
dropdownContainer,
searchInput,
dropdownOpen,
searchQuery,
highlightedIndex,
selectedCompany,
selectedCompanyName,
selectedCompanyCode,
selectorClass,
filteredCompanies,
toggleDropdown,
closeDropdown,
selectCompany,
handleKeyDown,
isHighlighted,
};
},
};
</script>
<style scoped>
.company-selector {
position: relative;
max-width: 450px;
}
.company-dropdown {
position: relative;
}
.company-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 6px);
cursor: pointer;
transition: all 0.15s ease;
width: 100%;
text-align: left;
min-width: 300px;
}
.company-trigger:hover {
border-color: var(--color-primary, #2563eb);
background: var(--color-bg-secondary, #f9fafb);
}
.company-info {
flex: 1;
min-width: 0;
}
.company-name {
display: block;
font-size: var(--text-sm, 14px);
font-weight: 500;
color: var(--color-text, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.company-code {
display: block;
font-size: var(--text-xs, 12px);
color: var(--color-text-secondary, #6b7280);
margin-top: 2px;
}
.pi-chevron-down {
transition: transform 0.15s ease;
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-xs, 12px);
}
.rotate-180 {
transform: rotate(180deg);
}
.company-dropdown-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 6px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
z-index: 1000;
max-height: 300px;
overflow: hidden;
opacity: 0;
transform: translateY(-10px);
transition: all 0.15s ease;
}
.panel-open {
opacity: 1;
transform: translateY(0);
}
.dropdown-search {
padding: var(--space-sm, 8px);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.search-wrapper {
position: relative;
}
.search-icon {
position: absolute;
left: var(--space-sm, 8px);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-sm, 14px);
pointer-events: none;
}
.search-input {
width: 100%;
padding: var(--space-sm, 8px) var(--space-sm, 8px) var(--space-sm, 8px) var(--space-xl, 32px);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-sm, 4px);
font-size: var(--text-sm, 14px);
background: var(--color-bg, #fff);
color: var(--color-text, #111827);
transition: border-color 0.15s ease;
}
.search-input:focus {
outline: none;
border-color: var(--color-primary, #2563eb);
}
.company-list {
max-height: 200px;
overflow-y: auto;
}
.company-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md, 12px);
cursor: pointer;
transition: background-color 0.15s ease;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
}
.company-item:last-child {
border-bottom: none;
}
.company-item:hover {
background: var(--color-bg-secondary, #f9fafb);
}
.company-item.active {
background: var(--color-primary, #2563eb);
color: #fff;
}
.company-item.keyboard-highlighted {
background: var(--color-bg-secondary, #f9fafb);
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: -2px;
}
.company-item.active.keyboard-highlighted {
outline: 2px solid rgba(255, 255, 255, 0.5);
}
.company-details {
flex: 1;
min-width: 0;
}
.company-main-name {
font-size: var(--text-sm, 14px);
font-weight: 500;
color: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.company-sub-info {
display: flex;
align-items: center;
gap: var(--space-xs, 4px);
font-size: var(--text-xs, 12px);
opacity: 0.8;
}
.company-selected-icon {
color: inherit;
font-size: var(--text-sm, 14px);
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm, 8px);
padding: var(--space-xl, 24px);
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-sm, 14px);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.company-selector {
max-width: 200px;
width: auto;
}
.company-trigger {
min-width: auto;
max-width: 200px;
padding: var(--space-xs, 4px) var(--space-sm, 8px);
}
.company-info {
max-width: 140px;
}
.company-name {
font-size: var(--text-xs, 12px);
max-width: 140px;
}
.company-code {
font-size: 10px;
}
.company-dropdown-panel {
position: fixed;
left: 8px;
right: 8px;
top: 60px;
width: auto;
max-height: 70vh;
}
}
/* Header variant - transparent background, white text for dark headers */
.company-selector--header .company-trigger {
background: transparent;
border-color: rgba(255, 255, 255, 0.3);
}
.company-selector--header .company-trigger:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
.company-selector--header .company-name {
color: white;
}
.company-selector--header .company-code {
color: rgba(255, 255, 255, 0.8);
}
.company-selector--header .pi-chevron-down {
color: rgba(255, 255, 255, 0.8);
}
/* Dropdown panel keeps default styling (white background) */
</style>

View File

@@ -0,0 +1,441 @@
<template>
<div :class="selectorClass" ref="dropdownContainer">
<div class="period-dropdown" ref="dropdown">
<button
class="period-trigger"
@click="toggleDropdown"
:disabled="!hasSelectedCompany"
:aria-expanded="dropdownOpen"
aria-label="Selectare perioada contabila"
>
<div class="period-info">
<span class="period-label">Perioada:</span>
<span class="period-name">{{ selectedPeriodDisplay }}</span>
</div>
<i
class="pi pi-chevron-down"
:class="{ 'rotate-180': dropdownOpen }"
></i>
</button>
<div
v-show="dropdownOpen"
class="period-dropdown-panel"
:class="{ 'panel-open': dropdownOpen }"
>
<div class="period-list">
<div
v-for="(period, index) in periods"
:key="`${period.an}-${period.luna}`"
class="period-item"
:class="{
active: isSelected(period),
'keyboard-highlighted': isHighlighted(index),
}"
@click="selectPeriod(period)"
@mouseenter="highlightedIndex = index"
>
<div class="period-details">
{{ period.display_name }}
</div>
<i v-if="isSelected(period)" class="pi pi-check period-selected-icon"></i>
</div>
</div>
<div v-if="periods.length === 0" class="no-results">
<i class="pi pi-info-circle"></i>
<span>Nu sunt perioade disponibile</span>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
export default {
name: "PeriodSelector",
props: {
// The accounting period store instance
periodStore: {
type: Object,
required: true,
},
// The company store instance (to check if company is selected)
companiesStore: {
type: Object,
required: true,
},
// Variant: 'default' (white background) or 'header' (transparent for dark headers)
variant: {
type: String,
default: "default",
validator: (value) => ['default', 'header'].includes(value),
},
},
emits: ["period-changed"],
setup(props, { emit }) {
const dropdown = ref(null);
const dropdownContainer = ref(null);
const dropdownOpen = ref(false);
const highlightedIndex = ref(-1);
const hasSelectedCompany = computed(() => {
return !!props.companiesStore.selectedCompany;
});
const periods = computed(() => {
return props.periodStore.periods || [];
});
const selectedPeriodDisplay = computed(() => {
return props.periodStore.selectedPeriod?.display_name || "Selectare perioada";
});
const selectorClass = computed(() => ({
'period-selector': true,
'period-selector--header': props.variant === 'header'
}));
const isSelected = (period) => {
if (!props.periodStore.selectedPeriod) return false;
return (
period.an === props.periodStore.selectedPeriod.an &&
period.luna === props.periodStore.selectedPeriod.luna
);
};
const isHighlighted = (index) => {
return index === highlightedIndex.value;
};
const toggleDropdown = async () => {
if (!hasSelectedCompany.value) return;
dropdownOpen.value = !dropdownOpen.value;
if (dropdownOpen.value) {
highlightedIndex.value = -1;
}
};
const closeDropdown = () => {
dropdownOpen.value = false;
};
const selectPeriod = (period) => {
props.periodStore.setSelectedPeriod(period);
emit("period-changed", period);
closeDropdown();
};
const scrollToHighlighted = () => {
nextTick(() => {
const highlightedElement = document.querySelector(
".period-item.keyboard-highlighted"
);
if (highlightedElement) {
highlightedElement.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
});
};
const handleKeyDown = (event) => {
if (!dropdownOpen.value || periods.value.length === 0) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
highlightedIndex.value =
(highlightedIndex.value + 1) % periods.value.length;
scrollToHighlighted();
break;
case "ArrowUp":
event.preventDefault();
if (highlightedIndex.value <= 0) {
highlightedIndex.value = periods.value.length - 1;
} else {
highlightedIndex.value--;
}
scrollToHighlighted();
break;
case "Enter":
event.preventDefault();
if (
highlightedIndex.value >= 0 &&
highlightedIndex.value < periods.value.length
) {
selectPeriod(periods.value[highlightedIndex.value]);
}
break;
case "Escape":
closeDropdown();
break;
}
};
const handleClickOutside = (event) => {
if (dropdown.value && !dropdown.value.contains(event.target)) {
closeDropdown();
}
};
// Watch for company changes - load periods and reset
watch(
() => props.companiesStore.selectedCompany,
async (newCompany) => {
if (newCompany) {
await props.periodStore.loadPeriods(newCompany.id_firma);
} else {
props.periodStore.reset();
}
},
{ immediate: true }
);
onMounted(() => {
document.addEventListener("click", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
});
return {
dropdown,
dropdownContainer,
dropdownOpen,
highlightedIndex,
hasSelectedCompany,
periods,
selectedPeriodDisplay,
selectorClass,
isSelected,
isHighlighted,
toggleDropdown,
closeDropdown,
selectPeriod,
};
},
};
</script>
<style scoped>
.period-selector {
position: relative;
max-width: 220px;
}
.period-dropdown {
position: relative;
}
.period-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 6px);
cursor: pointer;
transition: all 0.15s ease;
width: 100%;
text-align: left;
min-width: 200px;
}
.period-trigger:hover:not(:disabled) {
border-color: var(--color-primary, #2563eb);
background: var(--color-bg-secondary, #f9fafb);
}
.period-trigger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.period-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.period-label {
font-size: var(--text-xs, 12px);
color: var(--color-text-secondary, #6b7280);
}
.period-name {
font-size: var(--text-sm, 14px);
font-weight: 500;
color: var(--color-text, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pi-chevron-down {
transition: transform 0.15s ease;
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-xs, 12px);
}
.rotate-180 {
transform: rotate(180deg);
}
.period-dropdown-panel {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 6px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
z-index: 1000;
max-height: 300px;
overflow: hidden;
opacity: 0;
transform: translateY(-10px);
transition: all 0.15s ease;
}
.panel-open {
opacity: 1;
transform: translateY(0);
}
.period-list {
max-height: 280px;
overflow-y: auto;
}
.period-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm, 8px) var(--space-md, 12px);
cursor: pointer;
transition: background-color 0.15s ease;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
}
.period-item:last-child {
border-bottom: none;
}
.period-item:hover {
background: var(--color-bg-secondary, #f9fafb);
}
.period-item.active {
background: var(--color-primary, #2563eb);
color: #fff;
}
.period-item.keyboard-highlighted {
background: var(--color-bg-secondary, #f9fafb);
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: -2px;
}
.period-item.active.keyboard-highlighted {
outline: 2px solid rgba(255, 255, 255, 0.5);
}
.period-details {
flex: 1;
font-size: var(--text-sm, 14px);
}
.period-selected-icon {
color: inherit;
font-size: var(--text-sm, 14px);
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm, 8px);
padding: var(--space-xl, 24px);
color: var(--color-text-secondary, #6b7280);
font-size: var(--text-sm, 14px);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.period-selector {
max-width: 140px;
width: auto;
}
.period-trigger {
min-width: auto;
padding: var(--space-xs, 4px) var(--space-sm, 8px);
}
.period-info {
flex-direction: row;
align-items: center;
gap: var(--space-xs, 4px);
}
.period-label {
display: none;
}
.period-name {
font-size: var(--text-xs, 12px);
}
.period-dropdown-panel {
position: fixed;
left: 8px;
right: 8px;
top: 60px;
width: auto;
max-height: 70vh;
}
}
/* Header variant - transparent background, white text for dark headers */
.period-selector--header .period-trigger {
background: transparent;
border-color: rgba(255, 255, 255, 0.3);
}
.period-selector--header .period-trigger:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
.period-selector--header .period-trigger:disabled {
border-color: rgba(255, 255, 255, 0.15);
}
.period-selector--header .period-label {
color: rgba(255, 255, 255, 0.7);
}
.period-selector--header .period-name {
color: white;
}
.period-selector--header .pi-chevron-down {
color: rgba(255, 255, 255, 0.8);
}
/* Dropdown panel keeps default styling (white background) */
</style>

View File

@@ -0,0 +1,132 @@
<template>
<header class="header-container" :class="headerClass">
<nav class="header-nav">
<!-- Left side: Hamburger + Brand -->
<div class="header-left">
<button
class="hamburger-btn"
:class="{ active: menuOpen }"
@click="$emit('menu-toggle')"
aria-label="Toggle navigation menu"
>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
<router-link :to="brandLink" class="header-brand">
<slot name="brand">
<span>{{ title }}</span>
</slot>
</router-link>
</div>
<!-- Right side: Period + Company + User -->
<div class="header-actions">
<PeriodSelector
v-if="showPeriod && selectedCompany"
:period-store="periodStore"
:companies-store="companiesStore"
variant="header"
@period-changed="onPeriodChanged"
/>
<CompanySelector
v-if="showCompany"
:companies-store="companiesStore"
:show-fiscal-code="true"
variant="header"
@company-changed="onCompanyChanged"
/>
<slot name="user-menu">
<div v-if="showUser && currentUser" class="header-user" @click="$emit('user-menu-toggle')">
<i class="pi pi-user"></i>
<span class="desktop-only">{{ currentUser?.username || 'User' }}</span>
</div>
</slot>
</div>
</nav>
</header>
</template>
<script>
import { computed } from "vue";
import CompanySelector from "../CompanySelector.vue";
import PeriodSelector from "../PeriodSelector.vue";
export default {
name: "AppHeader",
components: {
CompanySelector,
PeriodSelector,
},
props: {
// Header title/brand text
title: {
type: String,
default: "ROA2WEB",
},
// Router link for brand click
brandLink: {
type: String,
default: "/",
},
// Additional CSS class for header (e.g., 'header-container--gradient')
headerClass: {
type: String,
default: "",
},
// Is hamburger menu open?
menuOpen: {
type: Boolean,
default: false,
},
// Companies store instance (required for selectors)
companiesStore: {
type: Object,
required: true,
},
// Period store instance (required for period selector)
periodStore: {
type: Object,
required: true,
},
// Current user object for display
currentUser: {
type: Object,
default: null,
},
// Show/hide period selector
showPeriod: {
type: Boolean,
default: true,
},
// Show/hide company selector
showCompany: {
type: Boolean,
default: true,
},
// Show/hide user info
showUser: {
type: Boolean,
default: true,
},
},
emits: ["menu-toggle", "company-changed", "period-changed", "user-menu-toggle"],
setup(props, { emit }) {
const selectedCompany = computed(() => props.companiesStore.selectedCompany);
const onCompanyChanged = (company) => {
emit("company-changed", company);
};
const onPeriodChanged = (period) => {
emit("period-changed", period);
};
return {
selectedCompany,
onCompanyChanged,
onPeriodChanged,
};
},
};
</script>

View File

@@ -0,0 +1,101 @@
<template>
<div>
<!-- Menu Overlay -->
<div
class="slide-menu-overlay"
:class="{ open: isOpen }"
@click="$emit('close')"
></div>
<!-- Slide Menu -->
<nav class="slide-menu" :class="{ open: isOpen }">
<!-- Dynamic Menu Sections -->
<div
v-for="section in menuItems"
:key="section.title"
class="menu-section"
>
<h3 class="menu-title">{{ section.title }}</h3>
<ul class="menu-list">
<li
v-for="item in section.items"
:key="item.to"
class="menu-item"
>
<router-link
:to="item.to"
class="menu-link"
:class="{ active: isRouteActive(item.to) }"
@click="$emit('close')"
>
<i :class="['menu-icon', item.icon]"></i>
<span>{{ item.label }}</span>
<span v-if="item.badge" class="menu-badge">{{ item.badge }}</span>
</router-link>
</li>
</ul>
</div>
<!-- Profile Section (at bottom) -->
<div class="menu-section menu-profile">
<div class="profile-info">
<i class="pi pi-user"></i>
<span>{{ currentUser?.username || 'Utilizator' }}</span>
</div>
<ul class="menu-list">
<slot name="profile-items"></slot>
<li class="menu-item">
<a href="#" class="menu-link" @click.prevent="handleLogout">
<i class="menu-icon pi pi-sign-out"></i>
<span>Deconectare</span>
</a>
</li>
</ul>
</div>
</nav>
</div>
</template>
<script>
import { useRoute } from "vue-router";
export default {
name: "SlideMenu",
props: {
// Is menu open?
isOpen: {
type: Boolean,
default: false,
},
// Menu items configuration
// Format: [{ title: 'Section', items: [{ to: '/path', icon: 'pi pi-icon', label: 'Label', badge: null }] }]
menuItems: {
type: Array,
default: () => [],
},
// Current user object
currentUser: {
type: Object,
default: null,
},
},
emits: ["close", "logout"],
setup(props, { emit }) {
const route = useRoute();
const isRouteActive = (path) => {
return route.path === path;
};
const handleLogout = () => {
emit("logout");
emit("close");
};
return {
isRouteActive,
handleLogout,
};
},
};
</script>

View File

@@ -0,0 +1,158 @@
/**
* Shared Accounting Period Store Factory
*
* Creates a Pinia store for accounting period selection that can be used by any ROA2WEB application.
* Each app passes its own apiService and store references.
*
* Usage:
* import { createAccountingPeriodStore } from '@shared/frontend/stores/accountingPeriod';
* import { apiService } from '../services/api';
* import { useAuthStore } from './auth';
* import { useCompanyStore } from './companies';
* export const useAccountingPeriodStore = createAccountingPeriodStore(apiService, useAuthStore, useCompanyStore);
*/
import { defineStore } from "pinia";
import { ref, computed } from "vue";
/**
* Factory function to create an accounting period store
* @param {Object} apiService - Axios instance configured for the app's API
* @param {Function} useAuthStore - Reference to the auth store function
* @param {Function} useCompanyStore - Reference to the company store function
* @returns {Function} Pinia store definition
*/
export function createAccountingPeriodStore(apiService, useAuthStore, useCompanyStore) {
return defineStore("accountingPeriod", () => {
// State
const periods = ref([]);
const selectedPeriod = ref(null);
const isLoading = ref(false);
const error = ref(null);
// Getters
const hasPeriods = computed(() => periods.value.length > 0);
const currentPeriod = computed(() => selectedPeriod.value);
// Computed date range for current period (first/last day of month)
const dateRange = computed(() => {
if (!selectedPeriod.value) return { dateFrom: null, dateTo: null };
const { an, luna } = selectedPeriod.value;
const firstDay = new Date(an, luna - 1, 1);
const lastDay = new Date(an, luna, 0);
return {
dateFrom: firstDay,
dateTo: lastDay,
};
});
// localStorage helpers
const getStorageKey = () => {
const authStore = useAuthStore();
const companyStore = useCompanyStore();
const username = authStore.user?.username;
const companyId = companyStore.selectedCompany?.id_firma;
if (!username || !companyId) return null;
return `selected_period_${username}_${companyId}`;
};
const initializeSelectedPeriod = () => {
const key = getStorageKey();
if (!key) return null;
const saved = localStorage.getItem(key);
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
localStorage.removeItem(key);
}
}
return null;
};
const persistSelectedPeriod = (period) => {
const key = getStorageKey();
if (key && period) {
localStorage.setItem(key, JSON.stringify(period));
}
};
// Actions
const loadPeriods = async (companyId) => {
if (!companyId) return { success: false };
isLoading.value = true;
error.value = null;
try {
const response = await apiService.get("/calendar/periods", {
params: { company: companyId },
});
periods.value = response.data.periods || [];
// Try to restore saved period or use most recent
const saved = initializeSelectedPeriod();
if (saved) {
const exists = periods.value.find(
(p) => p.an === saved.an && p.luna === saved.luna
);
if (exists) {
selectedPeriod.value = exists;
} else if (response.data.current_period) {
setSelectedPeriod(response.data.current_period);
}
} else if (response.data.current_period) {
setSelectedPeriod(response.data.current_period);
}
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load periods";
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const setSelectedPeriod = (period) => {
selectedPeriod.value = period;
persistSelectedPeriod(period);
};
const resetToLatest = () => {
if (periods.value.length > 0) {
setSelectedPeriod(periods.value[0]);
}
};
const reset = () => {
periods.value = [];
selectedPeriod.value = null;
isLoading.value = false;
error.value = null;
};
return {
// State
periods,
selectedPeriod,
isLoading,
error,
// Getters
hasPeriods,
currentPeriod,
dateRange,
// Actions
loadPeriods,
setSelectedPeriod,
resetToLatest,
reset,
};
});
}

View File

@@ -0,0 +1,196 @@
/**
* Shared Companies Store Factory
*
* Creates a Pinia store for company selection that can be used by any ROA2WEB application.
* Each app passes its own apiService and auth store instances.
*
* Usage:
* import { createCompaniesStore } from '@shared/frontend/stores/companies';
* import { apiService } from '../services/api';
* import { useAuthStore } from './auth';
* export const useCompanyStore = createCompaniesStore(apiService, useAuthStore);
*/
import { defineStore } from "pinia";
import { ref, computed, watch } from "vue";
/**
* Factory function to create a companies store
* @param {Object} apiService - Axios instance configured for the app's API
* @param {Function} useAuthStore - Reference to the auth store function
* @returns {Function} Pinia store definition
*/
export function createCompaniesStore(apiService, useAuthStore) {
return defineStore("companies", () => {
// State
const companies = ref([]);
const selectedCompany = ref(null);
const isLoading = ref(false);
const error = ref(null);
// Initialize from localStorage - per user
const initializeSelectedCompany = () => {
const authStore = useAuthStore();
const username = authStore.user?.username;
if (!username) {
console.log("[Companies] No username available for initialization");
return null;
}
const key = `selected_company_${username}`;
const saved = localStorage.getItem(key);
if (saved) {
try {
const company = JSON.parse(saved);
console.log(`[Companies] Loaded saved company for ${username}:`, company.name);
return company;
} catch (e) {
console.error("Failed to parse saved company", e);
localStorage.removeItem(key);
}
}
return null;
};
// Watch for auth user changes to restore selected company
const authStore = useAuthStore();
watch(
() => authStore.user,
(newUser) => {
if (newUser && newUser.username && !selectedCompany.value) {
const restoredCompany = initializeSelectedCompany();
if (restoredCompany) {
selectedCompany.value = restoredCompany;
console.log("[Companies] Restored selected company:", restoredCompany.name);
}
}
},
{ immediate: true }
);
// Getters
const companyList = computed(() => companies.value);
const hasCompanies = computed(() => companies.value.length > 0);
const selectedCompanyId = computed(() => selectedCompany.value?.id_firma || null);
const companyListFormatted = computed(() => {
return companies.value.map((company) => ({
...company,
displayName: company.fiscal_code
? `${company.name} (${company.fiscal_code})`
: company.name,
}));
});
// Actions
const loadCompanies = async () => {
isLoading.value = true;
error.value = null;
try {
console.log("[Companies] Loading companies...");
const response = await apiService.get("/companies");
companies.value = response.data.companies || [];
console.log("[Companies] Loaded", companies.value.length, "companies");
// Validate saved company is still accessible
if (selectedCompany.value) {
const exists = companies.value.find(
(c) => c.id_firma === selectedCompany.value.id_firma
);
if (!exists) {
console.warn("[Companies] Saved company not accessible, clearing");
clearSelectedCompany();
}
}
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Failed to load companies";
console.error("Failed to load companies:", err);
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const setSelectedCompany = (company) => {
selectedCompany.value = company;
const authStore = useAuthStore();
const username = authStore.user?.username;
if (!username) {
console.warn("[Companies] Cannot save - no username");
return;
}
const key = `selected_company_${username}`;
if (company) {
localStorage.setItem(key, JSON.stringify(company));
console.log(`[Companies] Saved company for ${username}:`, company.name);
} else {
localStorage.removeItem(key);
}
};
const clearSelectedCompany = () => {
selectedCompany.value = null;
const authStore = useAuthStore();
const username = authStore.user?.username;
if (username) {
const key = `selected_company_${username}`;
localStorage.removeItem(key);
}
};
const getCompanyById = (id_firma) => {
return companies.value.find(
(company) => company.id_firma === parseInt(id_firma)
);
};
const clearError = () => {
error.value = null;
};
const reset = () => {
companies.value = [];
selectedCompany.value = null;
isLoading.value = false;
error.value = null;
const authStore = useAuthStore();
const username = authStore.user?.username;
if (username) {
const key = `selected_company_${username}`;
localStorage.removeItem(key);
}
};
return {
// State
companies,
selectedCompany,
isLoading,
error,
// Getters
companyList,
companyListFormatted,
hasCompanies,
selectedCompanyId,
// Actions
loadCompanies,
setSelectedCompany,
clearSelectedCompany,
getCompanyById,
clearError,
reset,
};
});
}

View File

@@ -0,0 +1,167 @@
/* Shared Header Styles - ROA2WEB */
/* Header Container */
.header-container {
position: sticky;
top: 0;
z-index: var(--z-header, 100);
background: var(--color-bg, #fff);
border-bottom: 1px solid var(--color-border, #e5e7eb);
height: var(--header-height, 60px);
padding: 0 var(--space-lg, 24px);
}
/* Gradient Header Variant */
.header-container--gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-bottom: none;
}
.header-container--gradient .header-brand {
color: white;
}
.header-container--gradient .hamburger-line {
background-color: white;
}
/* Header Navigation */
.header-nav {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
max-width: 1600px;
margin: 0 auto;
}
/* Header Left Section */
.header-left {
display: flex;
align-items: center;
gap: var(--space-md, 16px);
}
/* Brand/Logo */
.header-brand {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
font-size: var(--text-lg, 18px);
font-weight: var(--font-semibold, 600);
color: var(--color-primary, #2563eb);
text-decoration: none;
white-space: nowrap;
}
.header-brand:hover {
opacity: 0.9;
}
/* Header Actions (right side) */
.header-actions {
display: flex;
align-items: center;
gap: var(--space-md, 16px);
}
/* Hamburger Button */
.hamburger-btn {
display: flex;
flex-direction: column;
justify-content: space-around;
width: 32px;
height: 32px;
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
z-index: 10;
transition: all 0.3s ease;
}
.hamburger-btn:hover {
opacity: 0.7;
}
.hamburger-line {
width: 100%;
height: 3px;
background-color: var(--color-primary, #2563eb);
border-radius: 2px;
transition: all 0.3s ease;
transform-origin: center;
}
/* Hamburger Animation - X state */
.hamburger-btn.active .hamburger-line:nth-child(1) {
transform: translateY(9px) rotate(45deg);
}
.hamburger-btn.active .hamburger-line:nth-child(2) {
opacity: 0;
}
.hamburger-btn.active .hamburger-line:nth-child(3) {
transform: translateY(-9px) rotate(-45deg);
}
/* Header User Menu */
.header-user {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px);
border-radius: var(--radius-md, 6px);
cursor: pointer;
transition: background-color 0.15s ease;
color: var(--color-text, #111827);
}
.header-user:hover {
background-color: var(--color-bg-secondary, #f9fafb);
}
/* Gradient header user menu */
.header-container--gradient .header-user {
color: white;
}
.header-container--gradient .header-user:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.header-container {
padding: 0 var(--space-md, 12px);
}
.header-left {
gap: var(--space-sm, 8px);
}
.header-actions {
gap: var(--space-sm, 8px);
}
.header-brand {
font-size: var(--text-base, 16px);
}
/* Hide text-only elements on mobile */
.desktop-only {
display: none;
}
}
@media (max-width: 480px) {
.header-brand span {
display: none;
}
.header-brand i {
font-size: 1.5rem;
}
}

View File

@@ -0,0 +1,151 @@
/* Shared Navigation Styles - ROA2WEB */
/* Slide-out Menu */
.slide-menu {
position: fixed;
top: var(--header-height, 60px);
left: 0;
width: var(--sidebar-width, 280px);
height: calc(100vh - var(--header-height, 60px));
background: var(--color-bg, #fff);
border-right: 1px solid var(--color-border, #e5e7eb);
box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1));
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: var(--z-modal, 1000);
overflow-y: auto;
/* Flex container for profile section at bottom */
display: flex;
flex-direction: column;
}
.slide-menu.open {
transform: translateX(0);
}
/* Menu Overlay */
.slide-menu-overlay {
position: fixed;
top: var(--header-height, 60px);
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
z-index: var(--z-modal-backdrop, 999);
}
.slide-menu-overlay.open {
opacity: 1;
visibility: visible;
}
/* Menu Sections */
.menu-section {
padding: var(--space-lg, 24px);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.menu-section:last-child {
border-bottom: none;
}
/* Profile section at bottom */
.menu-section.menu-profile {
margin-top: auto;
border-top: 1px solid var(--color-border, #e5e7eb);
border-bottom: none;
}
.menu-title {
font-size: var(--text-sm, 14px);
font-weight: var(--font-semibold, 600);
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-md, 12px);
}
.menu-list {
list-style: none;
margin: 0;
padding: 0;
}
.menu-item {
margin-bottom: var(--space-xs, 4px);
}
.menu-link {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
color: var(--color-text, #111827);
text-decoration: none;
border-radius: var(--radius-md, 6px);
transition: all 0.15s ease;
font-size: var(--text-sm, 14px);
}
.menu-link:hover,
.menu-link.active {
background-color: var(--color-bg-secondary, #f9fafb);
color: var(--color-primary, #2563eb);
}
.menu-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
font-size: 16px;
}
/* Profile Info */
.profile-info {
display: flex;
align-items: center;
gap: var(--space-sm, 8px);
padding: var(--space-sm, 8px) var(--space-md, 12px);
margin-bottom: var(--space-sm, 8px);
font-weight: var(--font-medium, 500);
color: var(--color-text, #111827);
}
.profile-info i {
font-size: 1.25rem;
color: var(--color-primary, #2563eb);
}
/* Badge for menu items */
.menu-badge {
margin-left: auto;
background: var(--color-danger, #ef4444);
color: white;
font-size: var(--text-xs, 12px);
font-weight: var(--font-semibold, 600);
padding: 2px 6px;
border-radius: var(--radius-full, 9999px);
min-width: 20px;
text-align: center;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.slide-menu {
width: 280px;
}
.menu-section {
padding: var(--space-md, 12px);
}
}
@media (max-width: 480px) {
.slide-menu {
width: 100vw;
max-width: 320px;
}
}

11
shared/models/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
"""Shared Pydantic models for ROA2WEB applications."""
from .company import Company, CompanyListResponse
from .calendar import CalendarPeriod, CalendarPeriodsResponse
__all__ = [
"Company",
"CompanyListResponse",
"CalendarPeriod",
"CalendarPeriodsResponse",
]

18
shared/models/calendar.py Normal file
View File

@@ -0,0 +1,18 @@
"""Calendar/accounting period models for ROA2WEB applications."""
from typing import List, Optional
from pydantic import BaseModel
class CalendarPeriod(BaseModel):
"""Model for an accounting period."""
an: int # Year
luna: int # Month (1-12)
display_name: str # Format: "Decembrie 2025"
class CalendarPeriodsResponse(BaseModel):
"""Response model for calendar periods list."""
periods: List[CalendarPeriod]
current_period: Optional[CalendarPeriod] = None
total_count: int

19
shared/models/company.py Normal file
View File

@@ -0,0 +1,19 @@
"""Company models for ROA2WEB applications."""
from typing import List, Optional
from pydantic import BaseModel
class Company(BaseModel):
"""Model for a company/firma."""
id_firma: int
name: str
schema_name: str
fiscal_code: Optional[str] = None
is_active: bool = True
class CompanyListResponse(BaseModel):
"""Response model for list of companies."""
companies: List[Company]
total_count: int

21
shared/routes/__init__.py Normal file
View File

@@ -0,0 +1,21 @@
"""
Shared Routes for ROA2WEB Applications
This module provides factory functions for creating common API routers
that can be mounted in both reports-app and data-entry-app.
Usage:
from shared.routes import create_companies_router, create_calendar_router
# In main.py
companies_router = create_companies_router(oracle_pool)
app.include_router(companies_router, prefix="/api/companies")
"""
from .companies import create_companies_router
from .calendar import create_calendar_router
__all__ = [
"create_companies_router",
"create_calendar_router",
]

136
shared/routes/calendar.py Normal file
View File

@@ -0,0 +1,136 @@
"""
Shared Calendar Router Factory for ROA2WEB Applications
Creates a FastAPI router for /api/calendar endpoints that can be used
by both reports-app and data-entry-app.
Usage:
from shared.routes.calendar import create_calendar_router
calendar_router = create_calendar_router(oracle_pool, cache_decorator=cached)
app.include_router(calendar_router, prefix="/api/calendar")
"""
import logging
from typing import Optional, Callable, List
from fastapi import APIRouter, Depends, HTTPException, Query
from auth.dependencies import get_current_user
from auth.models import CurrentUser
from models.calendar import CalendarPeriod, CalendarPeriodsResponse
logger = logging.getLogger(__name__)
# Romanian month names
MONTH_NAMES_RO = [
"Ianuarie", "Februarie", "Martie", "Aprilie", "Mai", "Iunie",
"Iulie", "August", "Septembrie", "Octombrie", "Noiembrie", "Decembrie"
]
def create_calendar_router(
oracle_pool,
cache_decorator: Optional[Callable] = None,
tags: Optional[List[str]] = None
) -> APIRouter:
"""
Factory function to create a calendar router.
Args:
oracle_pool: The Oracle connection pool instance
cache_decorator: Optional caching decorator (e.g., @cached)
tags: OpenAPI tags for the router
Returns:
Configured FastAPI router for calendar endpoints
"""
router = APIRouter(
redirect_slashes=False,
tags=tags or ["calendar"]
)
# Helper to get schema for company
async def _get_schema_for_company(company_id: int) -> Optional[str]:
"""Get Oracle schema for company ID."""
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT SCHEMA FROM CONTAFIN_ORACLE.V_NOM_FIRME
WHERE ID_FIRMA = :company_id
""", {'company_id': company_id})
result = cursor.fetchone()
return result[0] if result else None
# Apply cache to schema lookup if decorator provided
if cache_decorator:
_get_schema_for_company = cache_decorator(
cache_type='schema',
key_params=['company_id']
)(_get_schema_for_company)
# Helper to get periods - can be cached
async def _get_available_periods(company_id: int) -> CalendarPeriodsResponse:
"""Get available accounting periods for a company."""
schema = await _get_schema_for_company(company_id)
if not schema:
logger.warning(f"Schema not found for company {company_id}")
return CalendarPeriodsResponse(periods=[], current_period=None, total_count=0)
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(f"""
SELECT ANUL, LUNA
FROM {schema}.CALENDAR
ORDER BY ANUL DESC, LUNA DESC
""")
rows = cursor.fetchall()
periods = []
for row in rows:
an, luna = row[0], row[1]
month_name = MONTH_NAMES_RO[luna - 1]
periods.append(CalendarPeriod(
an=an,
luna=luna,
display_name=f"{month_name} {an}"
))
current_period = periods[0] if periods else None
logger.info(f"Loaded {len(periods)} periods for company {company_id}")
return CalendarPeriodsResponse(
periods=periods,
current_period=current_period,
total_count=len(periods)
)
except Exception as e:
logger.error(f"Error fetching periods for company {company_id}: {e}")
return CalendarPeriodsResponse(periods=[], current_period=None, total_count=0)
# Apply cache decorator if provided
if cache_decorator:
_get_available_periods = cache_decorator(
cache_type='calendar_periods',
key_params=['company_id']
)(_get_available_periods)
@router.get("/periods", response_model=CalendarPeriodsResponse)
async def get_calendar_periods(
company: int = Query(..., description="Company ID"),
current_user: CurrentUser = Depends(get_current_user)
) -> CalendarPeriodsResponse:
"""
Get available accounting periods for a company.
Returns periods ordered by year DESC, month DESC with Romanian month names.
"""
# Validate company access
if str(company) not in current_user.companies:
raise HTTPException(403, f"Nu aveți acces la firma {company}")
return await _get_available_periods(company)
return router

175
shared/routes/companies.py Normal file
View File

@@ -0,0 +1,175 @@
"""
Shared Companies Router Factory for ROA2WEB Applications
Creates a FastAPI router for /api/companies endpoints that can be used
by both reports-app and data-entry-app.
Usage:
from shared.routes.companies import create_companies_router
companies_router = create_companies_router(oracle_pool, cache_decorator=cached)
app.include_router(companies_router, prefix="/api/companies")
"""
import logging
from typing import Optional, Callable, List
from fastapi import APIRouter, Depends, HTTPException, Request
from auth.dependencies import get_current_user
from auth.models import CurrentUser
from models.company import Company, CompanyListResponse
logger = logging.getLogger(__name__)
def create_companies_router(
oracle_pool,
cache_decorator: Optional[Callable] = None,
tags: Optional[List[str]] = None
) -> APIRouter:
"""
Factory function to create a companies router.
Args:
oracle_pool: The Oracle connection pool instance
cache_decorator: Optional caching decorator (e.g., @cached)
tags: OpenAPI tags for the router
Returns:
Configured FastAPI router for company endpoints
"""
router = APIRouter(
redirect_slashes=False,
tags=tags or ["companies"]
)
# Helper function to get companies - can be cached
async def _get_user_companies_data(username: str) -> List[Company]:
"""
Get list of companies for a user from Oracle.
"""
companies = []
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
try:
# Get user ID
cursor.execute("""
SELECT ID_UTIL, UTILIZATOR
FROM UTILIZATORI
WHERE UPPER(UTILIZATOR) = :username
""", {'username': username.upper()})
user_row = cursor.fetchone()
if not user_row:
logger.warning(f"User {username} not found in UTILIZATORI")
return []
user_id = user_row[0]
# Get companies for user (program 2 = data entry/reports)
cursor.execute("""
SELECT A.ID_FIRMA, A.FIRMA, A.SCHEMA, A.COD_FISCAL
FROM V_NOM_FIRME A
WHERE A.ID_FIRMA IN (
SELECT ID_FIRMA
FROM VDEF_UTIL_FIRME
WHERE ID_PROGRAM = 2 AND ID_UTIL = :user_id
)
ORDER BY A.FIRMA
""", {'user_id': user_id})
for row in cursor.fetchall():
companies.append(Company(
id_firma=row[0],
name=row[1],
schema_name=row[2],
fiscal_code=row[3],
is_active=True
))
logger.info(f"Found {len(companies)} companies for user {username}")
except Exception as e:
logger.error(f"Error fetching companies: {e}")
return companies
# Apply cache decorator if provided
if cache_decorator:
_get_user_companies_data = cache_decorator(
cache_type='companies',
key_params=['username']
)(_get_user_companies_data)
@router.get("", response_model=CompanyListResponse)
@router.get("/", response_model=CompanyListResponse)
async def get_user_companies(
request: Request,
current_user: CurrentUser = Depends(get_current_user)
):
"""Get list of companies the user has access to."""
try:
companies = await _get_user_companies_data(current_user.username)
return CompanyListResponse(
companies=companies,
total_count=len(companies)
)
except Exception as e:
logger.error(f"Error in get_user_companies: {e}")
raise HTTPException(500, f"Eroare la obținerea listei de firme: {str(e)}")
@router.get("/{company_id}", response_model=Company)
async def get_company_details(
company_id: str,
current_user: CurrentUser = Depends(get_current_user)
):
"""Get details of a specific company."""
# Validate access
if company_id not in current_user.companies:
raise HTTPException(403, f"Nu aveți acces la firma {company_id}")
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT ID_FIRMA, FIRMA, SCHEMA, COD_FISCAL
FROM V_NOM_FIRME
WHERE ID_FIRMA = :company_id
""", {'company_id': int(company_id)})
row = cursor.fetchone()
if not row:
raise HTTPException(404, f"Firma {company_id} nu a fost găsită")
return Company(
id_firma=row[0],
name=row[1],
schema_name=row[2],
fiscal_code=row[3] or "",
is_active=True
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, f"Eroare la obținerea detaliilor firmei: {str(e)}")
@router.get("/{company_id}/validate")
async def validate_company_access(
company_id: str,
current_user: CurrentUser = Depends(get_current_user)
):
"""Validate if user has access to a company."""
has_access = company_id in current_user.companies
return {
"company_id": company_id,
"has_access": has_access,
"user": current_user.username,
"message": "Acces validat" if has_access else "Acces refuzat"
}
return router

224
start-data-entry-dev.sh Normal file
View File

@@ -0,0 +1,224 @@
#!/bin/bash
# Data Entry App - PRODUCTION Starter Script
# Oracle Server: 10.0.20.36 (via ssh_tunnel.sh)
# Database: receipts_prod.db
# Schema test: ROMFAST (company_id=114)
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
NC='\033[0m'
print_message() { echo -e "${BLUE}[DATA-ENTRY-PROD]${NC} $1"; }
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
check_port() {
local port=$1
lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1
}
cleanup() {
print_message "Stopping services..."
[[ -n $BACKEND_PID ]] && kill $BACKEND_PID 2>/dev/null || true
[[ -n $FRONTEND_PID ]] && kill $FRONTEND_PID 2>/dev/null || true
./ssh_tunnel.sh stop 2>/dev/null || true
print_success "All services stopped."
exit 0
}
stop_services() {
print_message "Stopping all Data Entry PRODUCTION services..."
if check_port 8003; then
lsof -ti:8003 | xargs kill -TERM 2>/dev/null || true
sleep 2
lsof -ti:8003 | xargs kill -KILL 2>/dev/null || true
print_success "Backend stopped"
fi
if check_port 3010; then
lsof -ti:3010 | xargs kill -TERM 2>/dev/null || true
sleep 2
lsof -ti:3010 | xargs kill -KILL 2>/dev/null || true
print_success "Frontend stopped"
fi
./ssh_tunnel.sh stop 2>/dev/null || true
pkill -f "uvicorn.*data-entry" 2>/dev/null || true
pkill -f "vite.*3010" 2>/dev/null || true
print_success "All Data Entry PRODUCTION services stopped!"
exit 0
}
show_status() {
echo -e "${MAGENTA}═══════════════════════════════════════════${NC}"
echo -e "${MAGENTA} Data Entry App - PRODUCTION Status${NC}"
echo -e "${MAGENTA}═══════════════════════════════════════════${NC}"
echo
if pgrep -f "ssh.*1526.*10.0.20.36" > /dev/null 2>&1 || ./ssh_tunnel.sh status 2>&1 | grep -q "10.0.20.36"; then
echo -e " SSH Tunnel: ${GREEN}✓ PRODUCTION (10.0.20.36)${NC}"
elif pgrep -f "ssh.*1526" > /dev/null 2>&1; then
echo -e " SSH Tunnel: ${YELLOW}⚠ Running (check which server)${NC}"
else
echo -e " SSH Tunnel: ${RED}✗ Stopped${NC}"
fi
if check_port 8003; then
echo -e " Backend: ${GREEN}✓ Running${NC} (http://localhost:8003)"
else
echo -e " Backend: ${RED}✗ Stopped${NC}"
fi
if check_port 3010; then
echo -e " Frontend: ${GREEN}✓ Running${NC} (http://localhost:3010)"
else
echo -e " Frontend: ${RED}✗ Stopped${NC}"
fi
echo
echo -e " Database: ${BLUE}receipts_prod.db${NC}"
echo -e " Oracle: ${BLUE}10.0.20.36 (PRODUCTION)${NC}"
echo -e " Test Schema: ${BLUE}ROMFAST (company_id=114)${NC}"
echo
}
show_usage() {
echo -e "${MAGENTA}Data Entry App - PRODUCTION Starter${NC}"
echo
echo "Usage:"
echo " ./start-data-entry-dev.sh Start all services"
echo " ./start-data-entry-dev.sh stop Stop all services"
echo " ./start-data-entry-dev.sh status Show status"
echo
echo "Environment:"
echo " Oracle Server: 10.0.20.36 (PRODUCTION)"
echo " SSH Tunnel: ./ssh_tunnel.sh"
echo " Config: .env.prod"
echo " Database: receipts_prod.db"
echo
}
# Parse arguments
case $1 in
stop) stop_services ;;
status) show_status; exit 0 ;;
help|--help|-h) show_usage; exit 0 ;;
"") ;; # Continue to start
*) print_error "Unknown: $1"; show_usage; exit 1 ;;
esac
trap cleanup SIGINT SIGTERM
echo -e "${MAGENTA}═══════════════════════════════════════════${NC}"
echo -e "${MAGENTA} Data Entry App - PRODUCTION Environment${NC}"
echo -e "${MAGENTA} Oracle: 10.0.20.36 | DB: receipts_prod.db${NC}"
echo -e "${MAGENTA}═══════════════════════════════════════════${NC}"
echo
# Step 1: Stop any TEST tunnel and start PRODUCTION tunnel
print_message "1. Setting up SSH Tunnel (PRODUCTION)..."
./ssh-tunnel-test.sh stop 2>/dev/null || true
sleep 1
if ./ssh_tunnel.sh start; then
print_success "SSH Tunnel to PRODUCTION (10.0.20.36) started"
else
print_error "Failed to start SSH tunnel"
exit 1
fi
sleep 2
# Step 2: Copy PRODUCTION .env
print_message "2. Loading PRODUCTION environment..."
cp data-entry-app/backend/.env.prod data-entry-app/backend/.env
print_success "Loaded .env.prod (receipts_prod.db)"
# Step 3: Start Frontend
print_message "3. Starting Frontend (Vue.js)..."
cd data-entry-app/frontend/
if [ ! -d "node_modules" ] || [ -f "node_modules/.bin/vite.cmd" ]; then
print_message "Installing frontend dependencies..."
rm -rf node_modules package-lock.json 2>/dev/null
npm install
fi
npm run dev -- --port 3010 > /tmp/data_entry_frontend.log 2>&1 &
FRONTEND_PID=$!
for i in {1..15}; do
if check_port 3010; then
print_success "Frontend started on http://localhost:3010"
break
fi
[ $i -eq 15 ] && { print_error "Frontend failed"; cat /tmp/data_entry_frontend.log; cleanup; }
sleep 1
done
# Step 4: Start Backend
print_message "4. Starting Backend (FastAPI)..."
cd ../backend/
if [ ! -d "venv" ]; then
print_message "Creating virtual environment..."
python3 -m venv venv
fi
source venv/bin/activate
if ! python -c "import fastapi, uvicorn, sqlmodel" 2>/dev/null; then
print_message "Installing backend dependencies..."
pip install --upgrade pip > /dev/null 2>&1
pip install -r requirements.txt
fi
mkdir -p data/uploads
print_message "Running migrations..."
alembic upgrade head 2>/dev/null || print_warning "Migrations may already be applied"
uvicorn app.main:app --host 0.0.0.0 --port 8003 --reload > /tmp/data_entry_backend.log 2>&1 &
BACKEND_PID=$!
for i in {1..20}; do
if check_port 8003; then
print_success "Backend started on http://localhost:8003"
break
fi
[ $i -eq 20 ] && { print_error "Backend failed"; cat /tmp/data_entry_backend.log; cleanup; }
sleep 1
done
# Summary
echo
echo -e "${GREEN}═══════════════════════════════════════════${NC}"
echo -e "${GREEN} Data Entry PRODUCTION Environment Ready!${NC}"
echo -e "${GREEN}═══════════════════════════════════════════${NC}"
echo
echo -e "${BLUE}Services:${NC}"
echo " • SSH Tunnel: 10.0.20.36 (PRODUCTION)"
echo " • Backend: http://localhost:8003"
echo " • Frontend: http://localhost:3010"
echo " • API Docs: http://localhost:8003/docs"
echo
echo -e "${BLUE}Database:${NC}"
echo " • SQLite: data/receipts_prod.db"
echo " • Test Company: ROMFAST (company_id=114)"
echo
echo -e "${YELLOW}Press Ctrl+C to stop all services${NC}"
echo
wait

224
start-data-entry-test.sh Normal file
View File

@@ -0,0 +1,224 @@
#!/bin/bash
# Data Entry App - TEST Starter Script
# Oracle Server: 10.0.20.121 (via ssh-tunnel-test.sh)
# Database: receipts_test.db
# Schema test: MARIUSM_AUTO (company_id=110)
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
print_message() { echo -e "${CYAN}[DATA-ENTRY-TEST]${NC} $1"; }
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
check_port() {
local port=$1
lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1
}
cleanup() {
print_message "Stopping services..."
[[ -n $BACKEND_PID ]] && kill $BACKEND_PID 2>/dev/null || true
[[ -n $FRONTEND_PID ]] && kill $FRONTEND_PID 2>/dev/null || true
./ssh-tunnel-test.sh stop 2>/dev/null || true
print_success "All services stopped."
exit 0
}
stop_services() {
print_message "Stopping all Data Entry TEST services..."
if check_port 8003; then
lsof -ti:8003 | xargs kill -TERM 2>/dev/null || true
sleep 2
lsof -ti:8003 | xargs kill -KILL 2>/dev/null || true
print_success "Backend stopped"
fi
if check_port 3010; then
lsof -ti:3010 | xargs kill -TERM 2>/dev/null || true
sleep 2
lsof -ti:3010 | xargs kill -KILL 2>/dev/null || true
print_success "Frontend stopped"
fi
./ssh-tunnel-test.sh stop 2>/dev/null || true
pkill -f "uvicorn.*data-entry" 2>/dev/null || true
pkill -f "vite.*3010" 2>/dev/null || true
print_success "All Data Entry TEST services stopped!"
exit 0
}
show_status() {
echo -e "${CYAN}═══════════════════════════════════════════${NC}"
echo -e "${CYAN} Data Entry App - TEST Status${NC}"
echo -e "${CYAN}═══════════════════════════════════════════${NC}"
echo
if pgrep -f "ssh.*10.0.20.121" > /dev/null 2>&1 || ./ssh-tunnel-test.sh status 2>&1 | grep -q "10.0.20.121"; then
echo -e " SSH Tunnel: ${GREEN}✓ TEST (10.0.20.121)${NC}"
elif pgrep -f "ssh.*1526" > /dev/null 2>&1; then
echo -e " SSH Tunnel: ${YELLOW}⚠ Running (check which server)${NC}"
else
echo -e " SSH Tunnel: ${RED}✗ Stopped${NC}"
fi
if check_port 8003; then
echo -e " Backend: ${GREEN}✓ Running${NC} (http://localhost:8003)"
else
echo -e " Backend: ${RED}✗ Stopped${NC}"
fi
if check_port 3010; then
echo -e " Frontend: ${GREEN}✓ Running${NC} (http://localhost:3010)"
else
echo -e " Frontend: ${RED}✗ Stopped${NC}"
fi
echo
echo -e " Database: ${CYAN}receipts_test.db${NC}"
echo -e " Oracle: ${CYAN}10.0.20.121 (TEST)${NC}"
echo -e " Test Schema: ${CYAN}MARIUSM_AUTO (company_id=110)${NC}"
echo
}
show_usage() {
echo -e "${CYAN}Data Entry App - TEST Starter${NC}"
echo
echo "Usage:"
echo " ./start-data-entry-test.sh Start all services"
echo " ./start-data-entry-test.sh stop Stop all services"
echo " ./start-data-entry-test.sh status Show status"
echo
echo "Environment:"
echo " Oracle Server: 10.0.20.121 (TEST)"
echo " SSH Tunnel: ./ssh-tunnel-test.sh"
echo " Config: .env.test"
echo " Database: receipts_test.db"
echo
}
# Parse arguments
case $1 in
stop) stop_services ;;
status) show_status; exit 0 ;;
help|--help|-h) show_usage; exit 0 ;;
"") ;; # Continue to start
*) print_error "Unknown: $1"; show_usage; exit 1 ;;
esac
trap cleanup SIGINT SIGTERM
echo -e "${CYAN}═══════════════════════════════════════════${NC}"
echo -e "${CYAN} Data Entry App - TEST Environment${NC}"
echo -e "${CYAN} Oracle: 10.0.20.121 | DB: receipts_test.db${NC}"
echo -e "${CYAN}═══════════════════════════════════════════${NC}"
echo
# Step 1: Stop any PRODUCTION tunnel and start TEST tunnel
print_message "1. Setting up SSH Tunnel (TEST)..."
./ssh_tunnel.sh stop 2>/dev/null || true
sleep 1
if ./ssh-tunnel-test.sh start; then
print_success "SSH Tunnel to TEST (10.0.20.121) started"
else
print_error "Failed to start SSH tunnel"
exit 1
fi
sleep 2
# Step 2: Copy TEST .env
print_message "2. Loading TEST environment..."
cp data-entry-app/backend/.env.test data-entry-app/backend/.env
print_success "Loaded .env.test (receipts_test.db)"
# Step 3: Start Frontend
print_message "3. Starting Frontend (Vue.js)..."
cd data-entry-app/frontend/
if [ ! -d "node_modules" ] || [ -f "node_modules/.bin/vite.cmd" ]; then
print_message "Installing frontend dependencies..."
rm -rf node_modules package-lock.json 2>/dev/null
npm install
fi
npm run dev -- --port 3010 > /tmp/data_entry_frontend.log 2>&1 &
FRONTEND_PID=$!
for i in {1..15}; do
if check_port 3010; then
print_success "Frontend started on http://localhost:3010"
break
fi
[ $i -eq 15 ] && { print_error "Frontend failed"; cat /tmp/data_entry_frontend.log; cleanup; }
sleep 1
done
# Step 4: Start Backend
print_message "4. Starting Backend (FastAPI)..."
cd ../backend/
if [ ! -d "venv" ]; then
print_message "Creating virtual environment..."
python3 -m venv venv
fi
source venv/bin/activate
if ! python -c "import fastapi, uvicorn, sqlmodel" 2>/dev/null; then
print_message "Installing backend dependencies..."
pip install --upgrade pip > /dev/null 2>&1
pip install -r requirements.txt
fi
mkdir -p data/uploads
print_message "Running migrations..."
alembic upgrade head 2>/dev/null || print_warning "Migrations may already be applied"
uvicorn app.main:app --host 0.0.0.0 --port 8003 --reload > /tmp/data_entry_backend.log 2>&1 &
BACKEND_PID=$!
for i in {1..20}; do
if check_port 8003; then
print_success "Backend started on http://localhost:8003"
break
fi
[ $i -eq 20 ] && { print_error "Backend failed"; cat /tmp/data_entry_backend.log; cleanup; }
sleep 1
done
# Summary
echo
echo -e "${GREEN}═══════════════════════════════════════════${NC}"
echo -e "${GREEN} Data Entry TEST Environment Ready!${NC}"
echo -e "${GREEN}═══════════════════════════════════════════${NC}"
echo
echo -e "${CYAN}Services:${NC}"
echo " • SSH Tunnel: 10.0.20.121 (TEST)"
echo " • Backend: http://localhost:8003"
echo " • Frontend: http://localhost:3010"
echo " • API Docs: http://localhost:8003/docs"
echo
echo -e "${CYAN}Database:${NC}"
echo " • SQLite: data/receipts_test.db"
echo " • Test Company: MARIUSM_AUTO (company_id=110)"
echo
echo -e "${YELLOW}Press Ctrl+C to stop all services${NC}"
echo
wait

View File

@@ -1,472 +0,0 @@
#!/bin/bash
# Data Entry App Starter Script
# Starts backend and frontend services for the data entry application
set -e # Exit on any error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored messages
print_message() {
echo -e "${BLUE}[DATA-ENTRY]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Function to check if port is in use
check_port() {
local port=$1
if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1; then
return 0
else
return 1
fi
}
# Function to cleanup processes on exit
cleanup() {
print_message "Stopping services..."
# Kill background processes
if [[ -n $BACKEND_PID ]]; then
kill $BACKEND_PID 2>/dev/null || true
fi
if [[ -n $FRONTEND_PID ]]; then
kill $FRONTEND_PID 2>/dev/null || true
fi
print_success "All services stopped."
exit 0
}
# Function to stop all services
stop_services() {
print_message "Stopping all Data Entry services..."
# Stop backend on port 8003
print_message "Checking for backend processes on port 8003..."
if check_port 8003; then
BACKEND_PIDS=$(lsof -ti:8003)
if [[ -n $BACKEND_PIDS ]]; then
echo $BACKEND_PIDS | xargs kill -TERM 2>/dev/null || true
sleep 2
echo $BACKEND_PIDS | xargs kill -KILL 2>/dev/null || true
print_success "Backend processes stopped"
fi
else
print_message "No backend processes found on port 8003"
fi
# Stop frontend on port 3010
print_message "Checking for frontend processes on port 3010..."
if check_port 3010; then
FRONTEND_PIDS=$(lsof -ti:3010)
if [[ -n $FRONTEND_PIDS ]]; then
echo $FRONTEND_PIDS | xargs kill -TERM 2>/dev/null || true
sleep 2
echo $FRONTEND_PIDS | xargs kill -KILL 2>/dev/null || true
print_success "Frontend processes stopped"
fi
else
print_message "No frontend processes found on port 3010"
fi
# Kill any remaining processes related to data-entry
pkill -f "uvicorn.*data-entry" 2>/dev/null || true
pkill -f "node.*data-entry" 2>/dev/null || true
pkill -f "vite.*3010" 2>/dev/null || true
print_success "✅ All Data Entry services have been stopped!"
exit 0
}
# Function to stop individual service
stop_service() {
local service=$1
case $service in
backend)
print_message "Stopping backend..."
if check_port 8003; then
BACKEND_PIDS=$(lsof -ti:8003)
if [[ -n $BACKEND_PIDS ]]; then
echo $BACKEND_PIDS | xargs kill -TERM 2>/dev/null || true
sleep 2
echo $BACKEND_PIDS | xargs kill -KILL 2>/dev/null || true
print_success "Backend stopped"
else
print_message "Backend not running"
fi
else
print_message "Backend not running on port 8003"
fi
;;
frontend)
print_message "Stopping frontend..."
if check_port 3010; then
FRONTEND_PIDS=$(lsof -ti:3010)
if [[ -n $FRONTEND_PIDS ]]; then
echo $FRONTEND_PIDS | xargs kill -TERM 2>/dev/null || true
sleep 2
echo $FRONTEND_PIDS | xargs kill -KILL 2>/dev/null || true
print_success "Frontend stopped"
fi
else
print_message "Frontend not running on port 3010"
fi
;;
*)
print_error "Unknown service: $service"
print_message "Valid services: backend, frontend"
exit 1
;;
esac
}
# Function to start individual service
start_service() {
local service=$1
case $service in
backend)
print_message "Starting backend..."
if check_port 8003; then
print_warning "Port 8003 already in use. Backend might be running."
return 1
fi
cd data-entry-app/backend/
# Check if virtual environment exists
if [ ! -d "venv" ]; then
print_message "Creating Python virtual environment..."
python3 -m venv venv
fi
# Activate virtual environment
source venv/bin/activate
# Check if dependencies are installed
if ! python -c "import fastapi, uvicorn, sqlmodel" 2>/dev/null; then
print_message "Installing backend dependencies..."
pip install --upgrade pip > /dev/null 2>&1
pip install -r requirements.txt
fi
# Run Alembic migrations
print_message "Running database migrations..."
alembic upgrade head 2>/dev/null || print_warning "Migration may have already been applied"
print_message "Starting uvicorn server..."
nohup uvicorn app.main:app --host 0.0.0.0 --port 8003 --reload > /tmp/data_entry_backend.log 2>&1 &
sleep 2
for i in {1..10}; do
if check_port 8003; then
print_success "Backend started on http://localhost:8003"
cd - > /dev/null
return 0
fi
sleep 1
done
print_error "Backend failed to start"
cat /tmp/data_entry_backend.log
cd - > /dev/null
exit 1
;;
frontend)
print_message "Starting frontend..."
if check_port 3010; then
print_warning "Port 3010 already in use. Frontend might be running."
return 1
fi
cd data-entry-app/frontend/
# Check node_modules
if [ ! -d "node_modules" ] || [ ! -f "node_modules/.bin/vite" ]; then
print_message "Installing frontend dependencies..."
npm install
fi
print_message "Starting Vite development server..."
nohup npm run dev -- --port 3010 > /tmp/data_entry_frontend.log 2>&1 &
sleep 3
for i in {1..10}; do
if check_port 3010; then
print_success "Frontend started on http://localhost:3010"
cd - > /dev/null
return 0
fi
sleep 1
done
print_error "Frontend failed to start"
cat /tmp/data_entry_frontend.log
cd - > /dev/null
exit 1
;;
*)
print_error "Unknown service: $service"
print_message "Valid services: backend, frontend"
exit 1
;;
esac
}
# Function to restart individual service
restart_service() {
local service=$1
print_message "Restarting $service..."
stop_service $service
sleep 2
start_service $service
print_success "$service restarted successfully"
}
# Function to show service status
show_status() {
echo -e "${BLUE}Data Entry Services Status${NC}"
echo
# Check backend
if check_port 8003; then
echo -e " Backend: ${GREEN}✓ Running${NC} (http://localhost:8003)"
else
echo -e " Backend: ${RED}✗ Stopped${NC}"
fi
# Check frontend
if check_port 3010; then
echo -e " Frontend: ${GREEN}✓ Running${NC} (http://localhost:3010)"
else
echo -e " Frontend: ${RED}✗ Stopped${NC}"
fi
echo
}
# Function to show usage
show_usage() {
echo -e "${BLUE}Data Entry App Starter Script${NC}"
echo
echo "Usage:"
echo " ./start-data-entry.sh Start all services"
echo " ./start-data-entry.sh start Start all services"
echo " ./start-data-entry.sh stop Stop all services"
echo " ./start-data-entry.sh status Show services status"
echo
echo " ./start-data-entry.sh restart <service> Restart specific service"
echo " ./start-data-entry.sh start <service> Start specific service"
echo " ./start-data-entry.sh stop <service> Stop specific service"
echo
echo "Services:"
echo " backend - FastAPI (port 8003)"
echo " frontend - Vue.js/Vite (port 3010)"
echo
echo "Examples:"
echo " ./start-data-entry.sh restart backend Restart only backend"
echo " ./start-data-entry.sh stop frontend Stop only frontend"
echo
}
# Check command line arguments
case $1 in
stop)
if [[ -n $2 ]]; then
stop_service $2
exit 0
else
stop_services
fi
;;
start)
if [[ -n $2 ]]; then
start_service $2
exit 0
else
true # Continue with normal start process
fi
;;
restart)
if [[ -z $2 ]]; then
print_error "Please specify which service to restart"
echo
show_usage
exit 1
fi
restart_service $2
exit 0
;;
status)
show_status
exit 0
;;
help|--help|-h)
show_usage
exit 0
;;
"")
# No parameter - start all services
true
;;
*)
print_error "Unknown parameter: $1"
echo
show_usage
exit 1
;;
esac
# Set up signal handlers
trap cleanup SIGINT SIGTERM
print_message "Starting Data Entry Development Environment..."
echo
# Step 1: Start Frontend FIRST (for fast UI availability)
print_message "1. Starting Frontend (Vue.js)..."
cd data-entry-app/frontend/
# Check if node_modules exists
if [ ! -d "node_modules" ]; then
print_message "Installing frontend dependencies..."
npm install
fi
# Check for WSL compatibility
if [ -f "node_modules/.bin/vite.cmd" ] && [ ! -f "node_modules/.bin/vite" ]; then
print_warning "Windows node_modules detected, reinstalling for WSL..."
rm -rf node_modules package-lock.json
npm install
fi
# Start frontend in background
print_message "Starting Vite development server..."
npm run dev -- --port 3010 > /tmp/data_entry_frontend.log 2>&1 &
FRONTEND_PID=$!
# Wait for frontend to start
sleep 3
for i in {1..10}; do
if check_port 3010; then
print_success "Frontend started on http://localhost:3010"
break
fi
if [ $i -eq 10 ]; then
print_error "Frontend failed to start after 10 attempts"
print_message "Check log at /tmp/data_entry_frontend.log"
cat /tmp/data_entry_frontend.log
cleanup
fi
sleep 1
done
# Step 2: Start Backend (with OCR loading in background)
print_message "2. Starting Backend (FastAPI + OCR)..."
# Check if backend port is already in use
if check_port 8003; then
print_warning "Port 8003 is already in use. Backend might already be running."
read -p "Continue anyway? (y/n): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
cleanup
fi
fi
cd ../backend/
# Check if .env file exists
if [ ! -f ".env" ]; then
if [ -f ".env.example" ]; then
print_message "Creating .env from .env.example..."
cp .env.example .env
else
print_error ".env file not found!"
exit 1
fi
fi
# Check if virtual environment exists
if [ ! -d "venv" ]; then
print_message "Creating Python virtual environment..."
python3 -m venv venv
fi
# Activate virtual environment
source venv/bin/activate
# Check if dependencies are installed
if ! python -c "import fastapi, uvicorn, sqlmodel" 2>/dev/null; then
print_message "Installing backend dependencies..."
pip install --upgrade pip > /dev/null 2>&1
pip install -r requirements.txt
fi
# Ensure data directories exist
mkdir -p data/uploads
# Run Alembic migrations
print_message "Running database migrations..."
alembic upgrade head 2>/dev/null || print_warning "Migration may have already been applied or first run"
# Start backend in background (OCR loads asynchronously)
print_message "Starting uvicorn server (OCR loads in background)..."
uvicorn app.main:app --host 0.0.0.0 --port 8003 --reload > /tmp/data_entry_backend.log 2>&1 &
BACKEND_PID=$!
# Wait for backend to start (uvicorn --reload takes longer to bind)
sleep 3
for i in {1..20}; do
if check_port 8003; then
print_success "Backend started on http://localhost:8003"
print_message "Note: OCR engine loading in background (first OCR request may be slow)"
break
fi
if [ $i -eq 20 ]; then
print_error "Backend failed to start after 20 attempts"
cat /tmp/data_entry_backend.log
cleanup
fi
sleep 1
done
# Summary
echo
print_success "🚀 Data Entry Development Environment is now running!"
echo
echo -e "${BLUE}Services:${NC}"
echo " • Backend: http://localhost:8003"
echo " • Frontend: http://localhost:3010"
echo " • API Docs: http://localhost:8003/docs"
echo
echo -e "${BLUE}Quick Links:${NC}"
echo " • Lista Bonuri: http://localhost:3010/"
echo " • Bon Nou: http://localhost:3010/create"
echo " • Aprobare: http://localhost:3010/approval"
echo
echo -e "${YELLOW}Press Ctrl+C to stop all services${NC}"
echo
# Keep script running and wait for user interrupt
wait