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:
17
.claude/rules/authentication.md
Normal file
17
.claude/rules/authentication.md
Normal 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()`
|
||||||
44
.claude/rules/backend-patterns.md
Normal file
44
.claude/rules/backend-patterns.md
Normal 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)
|
||||||
|
```
|
||||||
19
.claude/rules/company-period.md
Normal file
19
.claude/rules/company-period.md
Normal 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)
|
||||||
26
.claude/rules/css-design-system.md
Normal file
26
.claude/rules/css-design-system.md
Normal 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
|
||||||
31
.claude/rules/frontend-stores.md
Normal file
31
.claude/rules/frontend-stores.md
Normal 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
|
||||||
386
IMPLEMENTATION_PLAN_RECEIPT_OCR.md
Normal file
386
IMPLEMENTATION_PLAN_RECEIPT_OCR.md
Normal 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
|
||||||
|
```
|
||||||
@@ -57,6 +57,89 @@ data-entry-app/
|
|||||||
- `shared/frontend/stores/auth.js` - Pinia auth store factory
|
- `shared/frontend/stores/auth.js` - Pinia auth store factory
|
||||||
- `shared/frontend/styles/login.css` - Stiluri login
|
- `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
|
## Comenzi Dezvoltare
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -135,6 +218,15 @@ cd frontend && npm run test
|
|||||||
|
|
||||||
## Common Issues
|
## 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
|
### SQLite locked
|
||||||
- Asigura-te ca nu ai multiple procese care acceseaza DB-ul
|
- Asigura-te ca nu ai multiple procese care acceseaza DB-ul
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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`
|
|
||||||
@@ -59,7 +59,9 @@ class ReceiptCRUD:
|
|||||||
session.add(receipt)
|
session.add(receipt)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(receipt)
|
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
|
@staticmethod
|
||||||
async def get_by_id(
|
async def get_by_id(
|
||||||
@@ -175,7 +177,9 @@ class ReceiptCRUD:
|
|||||||
session.add(receipt)
|
session.add(receipt)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(receipt)
|
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
|
@staticmethod
|
||||||
async def update_status(
|
async def update_status(
|
||||||
@@ -206,7 +210,9 @@ class ReceiptCRUD:
|
|||||||
session.add(receipt)
|
session.add(receipt)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(receipt)
|
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
|
@staticmethod
|
||||||
async def delete(session: AsyncSession, receipt: Receipt) -> bool:
|
async def delete(session: AsyncSession, receipt: Receipt) -> bool:
|
||||||
|
|||||||
@@ -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
|
auth_router = create_auth_router(prefix="") # No prefix - we set it in include_router
|
||||||
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
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
|
# Root endpoint
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Nomenclature API endpoints."""
|
"""Nomenclature API endpoints."""
|
||||||
|
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Annotated
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -20,6 +20,38 @@ from auth.models import CurrentUser
|
|||||||
router = APIRouter()
|
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
|
# Request/Response Models
|
||||||
class SupplierSearchResult(BaseModel):
|
class SupplierSearchResult(BaseModel):
|
||||||
found: bool
|
found: bool
|
||||||
@@ -70,14 +102,13 @@ async def search_supplier(
|
|||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
company_id: Optional[int] = None,
|
company_id: Optional[int] = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
current_user: CurrentUser = Depends(get_current_user),
|
selected_company: SelectedCompany = None,
|
||||||
):
|
):
|
||||||
"""Search for supplier by fiscal code or name."""
|
"""Search for supplier by fiscal code or name."""
|
||||||
if not fiscal_code and not name:
|
if not fiscal_code and not name:
|
||||||
raise HTTPException(status_code=400, detail="Provide fiscal_code or name")
|
raise HTTPException(status_code=400, detail="Provide fiscal_code or name")
|
||||||
|
|
||||||
# Use provided company_id or first from user
|
cid = company_id or selected_company
|
||||||
cid = company_id or (current_user.companies[0] if current_user.companies else 1)
|
|
||||||
|
|
||||||
found, supplier, source = await SyncService.search_supplier(
|
found, supplier, source = await SyncService.search_supplier(
|
||||||
session, cid, fiscal_code, name
|
session, cid, fiscal_code, name
|
||||||
@@ -91,10 +122,10 @@ async def get_suppliers(
|
|||||||
search: Optional[str] = None,
|
search: Optional[str] = None,
|
||||||
company_id: Optional[int] = None,
|
company_id: Optional[int] = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
current_user: CurrentUser = Depends(get_current_user),
|
selected_company: SelectedCompany = None,
|
||||||
):
|
):
|
||||||
"""Get all suppliers (synced + local) for dropdown/autocomplete."""
|
"""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)
|
suppliers = await SyncService.get_all_suppliers(session, cid, search)
|
||||||
|
|
||||||
@@ -115,10 +146,11 @@ async def create_local_supplier(
|
|||||||
data: LocalSupplierCreate,
|
data: LocalSupplierCreate,
|
||||||
company_id: Optional[int] = None,
|
company_id: Optional[int] = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
selected_company: SelectedCompany = None,
|
||||||
current_user: CurrentUser = Depends(get_current_user),
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Create a local supplier from OCR data."""
|
"""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(
|
supplier = await SyncService.create_local_supplier(
|
||||||
session, cid, data.name, data.fiscal_code, data.address, current_user.username
|
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(
|
async def get_cash_registers(
|
||||||
company_id: Optional[int] = None,
|
company_id: Optional[int] = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
current_user: CurrentUser = Depends(get_current_user),
|
selected_company: SelectedCompany = None,
|
||||||
):
|
):
|
||||||
"""Get all cash registers for a company."""
|
"""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)
|
registers = await SyncService.get_all_cash_registers(session, cid)
|
||||||
|
|
||||||
@@ -159,10 +191,10 @@ async def get_cash_registers(
|
|||||||
async def sync_suppliers(
|
async def sync_suppliers(
|
||||||
company_id: Optional[int] = None,
|
company_id: Optional[int] = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
current_user: CurrentUser = Depends(get_current_user),
|
selected_company: SelectedCompany = None,
|
||||||
):
|
):
|
||||||
"""Manually trigger supplier sync from Oracle."""
|
"""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)
|
synced, errors = await SyncService.sync_suppliers(session, cid)
|
||||||
|
|
||||||
@@ -177,10 +209,10 @@ async def sync_suppliers(
|
|||||||
async def sync_cash_registers(
|
async def sync_cash_registers(
|
||||||
company_id: Optional[int] = None,
|
company_id: Optional[int] = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
current_user: CurrentUser = Depends(get_current_user),
|
selected_company: SelectedCompany = None,
|
||||||
):
|
):
|
||||||
"""Manually trigger cash register sync from Oracle."""
|
"""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)
|
synced, errors = await SyncService.sync_cash_registers(session, cid)
|
||||||
|
|
||||||
@@ -195,10 +227,10 @@ async def sync_cash_registers(
|
|||||||
async def sync_all_nomenclatures(
|
async def sync_all_nomenclatures(
|
||||||
company_id: Optional[int] = None,
|
company_id: Optional[int] = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
current_user: CurrentUser = Depends(get_current_user),
|
selected_company: SelectedCompany = None,
|
||||||
):
|
):
|
||||||
"""Sync all nomenclatures (suppliers + cash registers) from Oracle."""
|
"""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
|
# Sync suppliers
|
||||||
suppliers_synced, suppliers_errors = await SyncService.sync_suppliers(session, cid)
|
suppliers_synced, suppliers_errors = await SyncService.sync_suppliers(session, cid)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""API endpoints for receipts."""
|
"""API endpoints for receipts."""
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional, Annotated
|
||||||
from pathlib import Path
|
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 fastapi.responses import FileResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -39,20 +39,69 @@ from auth.models import CurrentUser
|
|||||||
router = APIRouter()
|
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:
|
def get_current_user_company(current_user: CurrentUser) -> int:
|
||||||
"""
|
"""
|
||||||
Get current user's active company.
|
DEPRECATED: Use get_selected_company() dependency instead.
|
||||||
|
This function returns the first company, ignoring X-Selected-Company header.
|
||||||
Returns the first company from the user's companies list.
|
|
||||||
In future, this can be enhanced to use a session-based active company.
|
|
||||||
"""
|
"""
|
||||||
if current_user.companies:
|
if current_user.companies:
|
||||||
# For data-entry-app, we assume company ID is numeric
|
try:
|
||||||
# If companies are stored as strings, convert to int
|
return int(current_user.companies[0])
|
||||||
# For now, return 1 as default (Phase 1)
|
except (ValueError, IndexError):
|
||||||
return 1
|
return 1
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
@@ -80,16 +129,14 @@ async def list_receipts(
|
|||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
page_size: int = Query(default=20, ge=1, le=100),
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
current_user: CurrentUser = Depends(get_current_user),
|
selected_company: SelectedCompany = None,
|
||||||
):
|
):
|
||||||
"""Get paginated list of receipts with filters."""
|
"""Get paginated list of receipts with filters."""
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
|
|
||||||
current_company = get_current_user_company(current_user)
|
|
||||||
|
|
||||||
filters = ReceiptFilter(
|
filters = ReceiptFilter(
|
||||||
status=status,
|
status=status,
|
||||||
company_id=company_id or current_company,
|
company_id=company_id or selected_company,
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
date_from=date_type.fromisoformat(date_from) if date_from else None,
|
date_from=date_type.fromisoformat(date_from) if date_from else None,
|
||||||
date_to=date_type.fromisoformat(date_to) if date_to 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(
|
async def list_pending_receipts(
|
||||||
company_id: Optional[int] = None,
|
company_id: Optional[int] = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
current_user: CurrentUser = Depends(get_current_user),
|
selected_company: SelectedCompany = None,
|
||||||
):
|
):
|
||||||
"""Get all receipts pending review (for accountant view)."""
|
"""Get all receipts pending review (for accountant view)."""
|
||||||
current_company = get_current_user_company(current_user)
|
|
||||||
receipts = await ReceiptCRUD.get_pending_review(
|
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]
|
return [ReceiptResponse.model_validate(r) for r in receipts]
|
||||||
|
|
||||||
@@ -120,13 +166,13 @@ async def get_receipt_stats(
|
|||||||
company_id: Optional[int] = None,
|
company_id: Optional[int] = None,
|
||||||
my_receipts: bool = False,
|
my_receipts: bool = False,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
selected_company: SelectedCompany = None,
|
||||||
current_user: CurrentUser = Depends(get_current_user),
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Get receipt statistics."""
|
"""Get receipt statistics."""
|
||||||
current_company = get_current_user_company(current_user)
|
|
||||||
return await ReceiptCRUD.get_stats(
|
return await ReceiptCRUD.get_stats(
|
||||||
session,
|
session,
|
||||||
company_id or current_company,
|
company_id or selected_company,
|
||||||
created_by=current_user.username if my_receipts else None,
|
created_by=current_user.username if my_receipts else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -415,12 +461,11 @@ async def get_partners(
|
|||||||
search: Optional[str] = None,
|
search: Optional[str] = None,
|
||||||
company_id: Optional[int] = None,
|
company_id: Optional[int] = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
current_user: CurrentUser = Depends(get_current_user),
|
selected_company: SelectedCompany = None,
|
||||||
):
|
):
|
||||||
"""Get partners (suppliers/customers) for dropdown."""
|
"""Get partners (suppliers/customers) for dropdown."""
|
||||||
current_company = get_current_user_company(current_user)
|
|
||||||
return await NomenclatureService.get_partners(
|
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(
|
async def get_accounts(
|
||||||
prefix: Optional[str] = None,
|
prefix: Optional[str] = None,
|
||||||
company_id: Optional[int] = None,
|
company_id: Optional[int] = None,
|
||||||
current_user: CurrentUser = Depends(get_current_user),
|
selected_company: SelectedCompany = None,
|
||||||
):
|
):
|
||||||
"""Get chart of accounts for dropdown."""
|
"""Get chart of accounts for dropdown."""
|
||||||
current_company = get_current_user_company(current_user)
|
|
||||||
return await NomenclatureService.get_accounts(
|
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(
|
async def get_cash_registers(
|
||||||
company_id: Optional[int] = None,
|
company_id: Optional[int] = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
current_user: CurrentUser = Depends(get_current_user),
|
selected_company: SelectedCompany = None,
|
||||||
):
|
):
|
||||||
"""Get cash registers and bank accounts for dropdown."""
|
"""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 selected_company, session)
|
||||||
return await NomenclatureService.get_cash_registers(company_id or current_company, session)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/nomenclature/expense-types", response_model=List[ExpenseTypeOption])
|
@router.get("/nomenclature/expense-types", response_model=List[ExpenseTypeOption])
|
||||||
|
|||||||
@@ -18,29 +18,54 @@ from app.db.models.nomenclature import SyncedSupplier, LocalSupplier, SyncedCash
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Company ID to Oracle Schema mapping
|
# Cache for schema lookups (populated dynamically from Oracle)
|
||||||
# TODO: This should come from a config table or environment variable
|
_schema_cache: dict[int, str] = {}
|
||||||
COMPANY_SCHEMAS = {
|
|
||||||
1: "CONTAFIN", # Example mapping - update with real schema names
|
|
||||||
2: "CONTAFIN2",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SyncService:
|
class SyncService:
|
||||||
"""Service for syncing nomenclatures from Oracle."""
|
"""Service for syncing nomenclatures from Oracle."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_schema_for_company(company_id: int) -> Optional[str]:
|
async def get_schema_for_company(company_id: int) -> Optional[str]:
|
||||||
"""Get Oracle schema for company ID."""
|
"""
|
||||||
return COMPANY_SCHEMAS.get(company_id)
|
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
|
@staticmethod
|
||||||
async def sync_suppliers(session: AsyncSession, company_id: int) -> Tuple[int, int]:
|
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).
|
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:
|
if not schema:
|
||||||
logger.warning(f"No schema mapping for company {company_id}")
|
logger.warning(f"No schema mapping for company {company_id}")
|
||||||
return 0, 0
|
return 0, 0
|
||||||
@@ -51,11 +76,17 @@ class SyncService:
|
|||||||
try:
|
try:
|
||||||
async with oracle_pool.get_connection() as connection:
|
async with oracle_pool.get_connection() as connection:
|
||||||
with connection.cursor() as cursor:
|
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"""
|
cursor.execute(f"""
|
||||||
SELECT ID_PART, DEN_PART, COD_FISCAL, ADRESA
|
SELECT B.ID_PART, B.DENUMIRE, B.COD_FISCAL, B.ADRESA
|
||||||
FROM {schema}.NOM_PARTENERI
|
FROM {schema}.CORESP_TIP_PART A
|
||||||
WHERE ACTIV = 1
|
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()
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
@@ -110,10 +141,16 @@ class SyncService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def sync_cash_registers(session: AsyncSession, company_id: int) -> Tuple[int, int]:
|
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).
|
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:
|
if not schema:
|
||||||
logger.warning(f"No schema mapping for company {company_id}")
|
logger.warning(f"No schema mapping for company {company_id}")
|
||||||
return 0, 0
|
return 0, 0
|
||||||
@@ -121,25 +158,40 @@ class SyncService:
|
|||||||
synced = 0
|
synced = 0
|
||||||
errors = 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:
|
try:
|
||||||
async with oracle_pool.get_connection() as connection:
|
async with oracle_pool.get_connection() as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
# Fetch cash registers (both cash and bank)
|
# Fetch cash/bank partners from CORESP_TIP_PART
|
||||||
# Assuming similar structure to NOM_PARTENERI
|
|
||||||
# TODO: Verify actual table name and structure in Oracle
|
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
SELECT ID_CASA, DEN_CASA, CONT
|
SELECT B.ID_PART, B.DENUMIRE, A.ID_TIP_PART
|
||||||
FROM {schema}.NOM_CASE
|
FROM {schema}.CORESP_TIP_PART A
|
||||||
WHERE ACTIV = 1
|
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()
|
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:
|
for row in rows:
|
||||||
try:
|
try:
|
||||||
oracle_id, name, account_code = row
|
oracle_id, name, tip_part_id = row
|
||||||
|
|
||||||
# Determine type based on account code
|
# Determine type based on partner type
|
||||||
register_type = "cash" if account_code.startswith("531") else "bank"
|
register_type, account_code = type_mapping.get(tip_part_id, ("cash", "UNKNOWN"))
|
||||||
|
|
||||||
# Check if already exists
|
# Check if already exists
|
||||||
stmt = select(SyncedCashRegister).where(
|
stmt = select(SyncedCashRegister).where(
|
||||||
@@ -152,7 +204,7 @@ class SyncService:
|
|||||||
if existing:
|
if existing:
|
||||||
# Update existing record
|
# Update existing record
|
||||||
existing.name = name or ""
|
existing.name = name or ""
|
||||||
existing.account_code = account_code or ""
|
existing.account_code = account_code
|
||||||
existing.register_type = register_type
|
existing.register_type = register_type
|
||||||
existing.synced_at = datetime.utcnow()
|
existing.synced_at = datetime.utcnow()
|
||||||
logger.debug(f"Updated cash register {oracle_id}: {name}")
|
logger.debug(f"Updated cash register {oracle_id}: {name}")
|
||||||
@@ -162,7 +214,7 @@ class SyncService:
|
|||||||
oracle_id=oracle_id,
|
oracle_id=oracle_id,
|
||||||
company_id=company_id,
|
company_id=company_id,
|
||||||
name=name or "",
|
name=name or "",
|
||||||
account_code=account_code or "",
|
account_code=account_code,
|
||||||
register_type=register_type,
|
register_type=register_type,
|
||||||
)
|
)
|
||||||
session.add(cash_register)
|
session.add(cash_register)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Alembic environment configuration."""
|
"""Alembic environment configuration."""
|
||||||
|
|
||||||
|
import os
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from sqlalchemy import engine_from_config
|
from sqlalchemy import engine_from_config
|
||||||
from sqlalchemy import pool
|
from sqlalchemy import pool
|
||||||
@@ -8,14 +10,22 @@ from sqlalchemy import pool
|
|||||||
from alembic import context
|
from alembic import context
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
# Load environment variables from .env file
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
# Import all models to ensure they're registered with SQLModel
|
# Import all models to ensure they're registered with SQLModel
|
||||||
from app.db.models.receipt import Receipt, ReceiptAttachment
|
from app.db.models.receipt import Receipt, ReceiptAttachment
|
||||||
from app.db.models.accounting_entry import AccountingEntry
|
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
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# access to the values within the .ini file in use.
|
||||||
config = context.config
|
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.
|
# Interpret the config file for Python logging.
|
||||||
# This line sets up loggers basically.
|
# This line sets up loggers basically.
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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/` |
|
|
||||||
@@ -1,38 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<header v-if="authStore.isAuthenticated" class="app-header">
|
<AppHeader
|
||||||
<div class="header-content">
|
v-if="authStore.isAuthenticated"
|
||||||
<h1 class="app-title">
|
title="Data Entry"
|
||||||
<i class="pi pi-receipt"></i>
|
brand-link="/"
|
||||||
Data Entry - Bonuri Fiscale
|
header-class="header-container--gradient"
|
||||||
</h1>
|
:menu-open="menuOpen"
|
||||||
<nav class="app-nav">
|
:companies-store="companyStore"
|
||||||
<router-link to="/" class="nav-link">
|
:period-store="periodStore"
|
||||||
<i class="pi pi-list"></i> Lista Bonuri
|
:current-user="authStore.currentUser"
|
||||||
</router-link>
|
:show-user="false"
|
||||||
<router-link to="/create" class="nav-link">
|
@menu-toggle="menuOpen = !menuOpen"
|
||||||
<i class="pi pi-plus"></i> Bon Nou
|
@company-changed="onCompanyChanged"
|
||||||
</router-link>
|
@period-changed="onPeriodChanged"
|
||||||
<router-link to="/approval" class="nav-link">
|
>
|
||||||
<i class="pi pi-check-circle"></i> Aprobare
|
<template #brand>
|
||||||
<Badge v-if="pendingCount > 0" :value="pendingCount" severity="danger" />
|
<i class="pi pi-receipt"></i>
|
||||||
</router-link>
|
<span>Data Entry</span>
|
||||||
<div class="user-menu">
|
</template>
|
||||||
<span class="user-name">
|
</AppHeader>
|
||||||
<i class="pi pi-user"></i>
|
|
||||||
{{ authStore.currentUser?.username || 'User' }}
|
<SlideMenu
|
||||||
</span>
|
v-if="authStore.isAuthenticated"
|
||||||
<Button
|
:is-open="menuOpen"
|
||||||
icon="pi pi-sign-out"
|
:menu-items="dataEntryMenuItems"
|
||||||
label="Ieșire"
|
:current-user="authStore.currentUser"
|
||||||
class="logout-button"
|
@close="menuOpen = false"
|
||||||
@click="handleLogout"
|
@logout="handleLogout"
|
||||||
text
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<router-view />
|
<router-view />
|
||||||
@@ -44,23 +39,74 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
|
import { useCompanyStore } from './stores/companies'
|
||||||
|
import { useAccountingPeriodStore } from './stores/accountingPeriod'
|
||||||
import { useReceiptsStore } from './stores/receiptsStore'
|
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 router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const companyStore = useCompanyStore()
|
||||||
|
const periodStore = useAccountingPeriodStore()
|
||||||
const receiptsStore = useReceiptsStore()
|
const receiptsStore = useReceiptsStore()
|
||||||
|
const menuOpen = ref(false)
|
||||||
const pendingCount = ref(0)
|
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 = () => {
|
const handleLogout = () => {
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
|
companyStore.reset()
|
||||||
|
periodStore.reset()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
const onCompanyChanged = async (company) => {
|
||||||
if (authStore.isAuthenticated) {
|
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 {
|
try {
|
||||||
const stats = await receiptsStore.fetchStats()
|
const stats = await receiptsStore.fetchStats()
|
||||||
pendingCount.value = stats?.pending_review?.count || 0
|
pendingCount.value = stats?.pending_review?.count || 0
|
||||||
@@ -68,7 +114,38 @@ onMounted(async () => {
|
|||||||
console.error('Failed to fetch stats:', error)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -78,85 +155,6 @@ onMounted(async () => {
|
|||||||
flex-direction: column;
|
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 {
|
.app-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
@@ -164,16 +162,6 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header-content {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-nav {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
/* Global styles for Data Entry App */
|
/* 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 {
|
: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) */
|
/* Colors - Primary palette (matching reports-app) */
|
||||||
--color-primary: #2563eb;
|
--color-primary: #2563eb;
|
||||||
--color-primary-dark: #1d4ed8;
|
--color-primary-dark: #1d4ed8;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import axios from 'axios'
|
import api from '@/services/api'
|
||||||
|
|
||||||
const emit = defineEmits(['ocr-result', 'file-selected', 'error'])
|
const emit = defineEmits(['ocr-result', 'file-selected', 'error'])
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ const processOCR = async () => {
|
|||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', selectedFile.value)
|
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' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
timeout: 60000, // 60 second timeout for OCR
|
timeout: 60000, // 60 second timeout for OCR
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import Badge from 'primevue/badge'
|
|||||||
import Toolbar from 'primevue/toolbar'
|
import Toolbar from 'primevue/toolbar'
|
||||||
import Divider from 'primevue/divider'
|
import Divider from 'primevue/divider'
|
||||||
import Tooltip from 'primevue/tooltip'
|
import Tooltip from 'primevue/tooltip'
|
||||||
|
import Message from 'primevue/message'
|
||||||
|
|
||||||
// PrimeVue styles
|
// PrimeVue styles
|
||||||
import 'primevue/resources/themes/lara-light-blue/theme.css'
|
import 'primevue/resources/themes/lara-light-blue/theme.css'
|
||||||
@@ -80,6 +81,7 @@ app.component('ProgressSpinner', ProgressSpinner)
|
|||||||
app.component('Badge', Badge)
|
app.component('Badge', Badge)
|
||||||
app.component('Toolbar', Toolbar)
|
app.component('Toolbar', Toolbar)
|
||||||
app.component('Divider', Divider)
|
app.component('Divider', Divider)
|
||||||
|
app.component('Message', Message)
|
||||||
|
|
||||||
// Register PrimeVue directives
|
// Register PrimeVue directives
|
||||||
app.directive('tooltip', Tooltip)
|
app.directive('tooltip', Tooltip)
|
||||||
|
|||||||
@@ -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(
|
apiService.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${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;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
17
data-entry-app/frontend/src/stores/accountingPeriod.js
Normal file
17
data-entry-app/frontend/src/stores/accountingPeriod.js
Normal 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
|
||||||
|
);
|
||||||
12
data-entry-app/frontend/src/stores/companies.js
Normal file
12
data-entry-app/frontend/src/stores/companies.js
Normal 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);
|
||||||
@@ -372,6 +372,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useToast } from 'primevue/usetoast'
|
import { useToast } from 'primevue/usetoast'
|
||||||
import { useReceiptsStore } from '../../stores/receiptsStore'
|
import { useReceiptsStore } from '../../stores/receiptsStore'
|
||||||
|
import { useCompanyStore } from '../../stores/companies'
|
||||||
import OCRUploadZone from '../../components/ocr/OCRUploadZone.vue'
|
import OCRUploadZone from '../../components/ocr/OCRUploadZone.vue'
|
||||||
import OCRPreview from '../../components/ocr/OCRPreview.vue'
|
import OCRPreview from '../../components/ocr/OCRPreview.vue'
|
||||||
import Dialog from 'primevue/dialog'
|
import Dialog from 'primevue/dialog'
|
||||||
@@ -380,11 +381,17 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const store = useReceiptsStore()
|
const store = useReceiptsStore()
|
||||||
|
const companyStore = useCompanyStore()
|
||||||
|
|
||||||
const isEditMode = computed(() => !!route.params.id)
|
const isEditMode = computed(() => !!route.params.id)
|
||||||
const receiptId = computed(() => route.params.id)
|
const receiptId = computed(() => route.params.id)
|
||||||
const receipt = ref(null)
|
const receipt = ref(null)
|
||||||
|
|
||||||
|
// Get selected company ID from store
|
||||||
|
const getSelectedCompanyId = () => {
|
||||||
|
return companyStore.selectedCompanyId || 1
|
||||||
|
}
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
receipt_type: 'bon_fiscal',
|
receipt_type: 'bon_fiscal',
|
||||||
direction: 'cheltuiala',
|
direction: 'cheltuiala',
|
||||||
@@ -398,7 +405,7 @@ const form = ref({
|
|||||||
cash_register_account: null,
|
cash_register_account: null,
|
||||||
receipt_number: '',
|
receipt_number: '',
|
||||||
description: '',
|
description: '',
|
||||||
company_id: 1, // Default company for Phase 1
|
company_id: getSelectedCompanyId(),
|
||||||
// TVA info (multiple entries support)
|
// TVA info (multiple entries support)
|
||||||
tva_breakdown: [], // Array of {code, percent, amount}
|
tva_breakdown: [], // Array of {code, percent, amount}
|
||||||
tva_total: null,
|
tva_total: null,
|
||||||
@@ -429,6 +436,9 @@ onMounted(async () => {
|
|||||||
|
|
||||||
if (isEditMode.value) {
|
if (isEditMode.value) {
|
||||||
await loadReceipt()
|
await loadReceipt()
|
||||||
|
} else {
|
||||||
|
// For new receipts, ensure company_id is set from the current selected company
|
||||||
|
form.value.company_id = companyStore.selectedCompanyId || 1
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 3010,
|
port: 3010,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api/auth': {
|
|
||||||
target: 'http://localhost:8001',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8003',
|
target: 'http://localhost:8003',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
@@ -1,25 +1,79 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<!-- New Navigation System -->
|
<!-- Shared Header Component -->
|
||||||
<DashboardHeader
|
<AppHeader
|
||||||
v-if="authStore.isAuthenticated"
|
v-if="authStore.isAuthenticated"
|
||||||
|
title="ROA2WEB"
|
||||||
|
brand-link="/dashboard"
|
||||||
:menu-open="menuOpen"
|
: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"
|
@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 -->
|
<!-- User Dropdown Menu -->
|
||||||
<HamburgerMenu
|
<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"
|
v-if="authStore.isAuthenticated"
|
||||||
:is-open="menuOpen"
|
: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 Content -->
|
||||||
<main
|
<main class="main-content" :class="{ 'with-navbar': authStore.isAuthenticated }">
|
||||||
class="main-content"
|
|
||||||
:class="{ 'with-navbar': authStore.isAuthenticated }"
|
|
||||||
>
|
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -36,24 +90,49 @@ import { ref, onMounted } from "vue";
|
|||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { useAuthStore } from "./stores/auth";
|
import { useAuthStore } from "./stores/auth";
|
||||||
import { useCompanyStore } from "./stores/companies";
|
import { useCompanyStore } from "./stores/companies";
|
||||||
import DashboardHeader from "./components/layout/DashboardHeader.vue";
|
import { useAccountingPeriodStore } from "./stores/accountingPeriod";
|
||||||
import HamburgerMenu from "./components/layout/HamburgerMenu.vue";
|
import AppHeader from "../../../shared/frontend/components/layout/AppHeader.vue";
|
||||||
|
import SlideMenu from "../../../shared/frontend/components/layout/SlideMenu.vue";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const companyStore = useCompanyStore();
|
const companyStore = useCompanyStore();
|
||||||
|
const periodStore = useAccountingPeriodStore();
|
||||||
|
|
||||||
// Menu state
|
// Menu state
|
||||||
const menuOpen = ref(false);
|
const menuOpen = ref(false);
|
||||||
|
const userMenuOpen = ref(false);
|
||||||
|
|
||||||
// Handle menu toggle
|
// Menu items configuration for reports-app
|
||||||
const handleMenuToggle = () => {
|
const reportsMenuItems = [
|
||||||
menuOpen.value = !menuOpen.value;
|
{
|
||||||
|
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 closeUserMenu = () => {
|
||||||
const handleMenuClose = () => {
|
userMenuOpen.value = false;
|
||||||
menuOpen.value = false;
|
};
|
||||||
|
|
||||||
|
const handleUserMenuToggle = () => {
|
||||||
|
toggleUserMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle company change
|
// Handle company change
|
||||||
@@ -61,6 +140,35 @@ const handleCompanyChanged = (company) => {
|
|||||||
console.log("Company changed in App:", 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
|
// Initialize app
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Check authentication on app start
|
// Check authentication on app start
|
||||||
@@ -93,6 +201,146 @@ onMounted(async () => {
|
|||||||
.main-content:not(.with-navbar) {
|
.main-content:not(.with-navbar) {
|
||||||
min-height: 100vh;
|
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>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
/* Import order is critical for proper CSS cascade */
|
/* 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 */
|
/* 1. Core Foundation */
|
||||||
@import "./core/variables.css";
|
@import "./core/variables.css";
|
||||||
@import "./core/tokens.css"; /* NEW - Extended design tokens */
|
@import "./core/tokens.css"; /* NEW - Extended design tokens */
|
||||||
|
|||||||
@@ -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 { apiService } from "../services/api";
|
||||||
import { useAuthStore } from "./auth";
|
import { useAuthStore } from "./auth";
|
||||||
import { useCompanyStore } from "./companies";
|
import { useCompanyStore } from "./companies";
|
||||||
|
|
||||||
export const useAccountingPeriodStore = defineStore("accountingPeriod", () => {
|
export const useAccountingPeriodStore = createAccountingPeriodStore(
|
||||||
// State
|
apiService,
|
||||||
const periods = ref([]);
|
useAuthStore,
|
||||||
const selectedPeriod = ref(null);
|
useCompanyStore
|
||||||
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
|
|
||||||
);
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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 { apiService } from "../services/api";
|
||||||
import { useAuthStore } from "./auth";
|
import { useAuthStore } from "./auth";
|
||||||
|
|
||||||
export const useCompanyStore = defineStore("companies", () => {
|
export const useCompanyStore = createCompaniesStore(apiService, useAuthStore);
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|||||||
555
shared/frontend/components/CompanySelector.vue
Normal file
555
shared/frontend/components/CompanySelector.vue
Normal 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>
|
||||||
441
shared/frontend/components/PeriodSelector.vue
Normal file
441
shared/frontend/components/PeriodSelector.vue
Normal 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>
|
||||||
132
shared/frontend/components/layout/AppHeader.vue
Normal file
132
shared/frontend/components/layout/AppHeader.vue
Normal 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>
|
||||||
101
shared/frontend/components/layout/SlideMenu.vue
Normal file
101
shared/frontend/components/layout/SlideMenu.vue
Normal 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>
|
||||||
158
shared/frontend/stores/accountingPeriod.js
Normal file
158
shared/frontend/stores/accountingPeriod.js
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
196
shared/frontend/stores/companies.js
Normal file
196
shared/frontend/stores/companies.js
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
167
shared/frontend/styles/layout/header.css
Normal file
167
shared/frontend/styles/layout/header.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
151
shared/frontend/styles/layout/navigation.css
Normal file
151
shared/frontend/styles/layout/navigation.css
Normal 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
11
shared/models/__init__.py
Normal 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
18
shared/models/calendar.py
Normal 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
19
shared/models/company.py
Normal 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
21
shared/routes/__init__.py
Normal 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
136
shared/routes/calendar.py
Normal 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
175
shared/routes/companies.py
Normal 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
224
start-data-entry-dev.sh
Normal 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
224
start-data-entry-test.sh
Normal 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
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user