feat(data-entry): Add unified receipt form with OCR confidence tracking

New unified receipt creation system with:
- UnifiedReceiptForm component with inline OCR preview and confidence indicators
- Compact upload zone with drag-drop and camera support
- TVA and Payment fields with dynamic add/remove
- Supplier dual-field with autocomplete and OCR hint
- Receipt form sections with collapsible auxiliary data

Backend OCR improvements:
- Add confidence_tva and confidence_payment to extraction results
- Update TVA extraction to return confidence scores
- Include TVA (15%) and payment (10%) in overall_confidence calculation

Also includes:
- CSS design system rules documentation
- Port check helper function for service scripts
- Expanded design tokens documentation in CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-01-08 21:48:37 +00:00
parent cc98d6f21f
commit b4a226409c
21 changed files with 4876 additions and 55 deletions

View File

@@ -96,3 +96,41 @@ color: var(--red-600); /* Error text */
- Use hardcoded backgrounds like `#ffffff` or `white` (use `var(--surface-card)`)
- Create scoped CSS for patterns that already exist in shared files
- **Skip theme testing** - ALWAYS test cu toggle (auto/light/dark) + DevTools dark mode
## PrimeVue Component Patterns
### Dropdown în Forme Compacte
**OBLIGATORIU:** Folosește clasa `dropdown-borderless` pentru toate dropdown-urile în forme compacte/inline:
```html
<Dropdown ... class="my-select dropdown-borderless" />
```
Clasa este definită global (în `ReceiptCreateView.vue` non-scoped) și elimină border, background și box-shadow pentru un look minimalist.
### Entry Chips / Inline Labels (Dark Mode)
Pentru elemente custom care afișează date inline (chips, tags, badges):
```css
/* ✅ CORECT - folosește semantic tokens */
.entry-chip {
background: var(--surface-hover);
color: var(--text-color);
}
[data-theme="dark"] .entry-chip {
background: var(--surface-100);
}
/* ❌ GREȘIT - culori hardcodate */
.entry-chip {
background: #f1f5f9; /* NU! */
color: #111827; /* NU! */
}
```
### Reguli Dark Mode pentru Componente Custom
1. **Text:** Folosește `var(--text-color)` sau `var(--text-color-secondary)` - se adaptează automat
2. **Background:** Folosește `var(--surface-hover)` pentru hover/chips în light mode
3. **Dark override:** Folosește `[data-theme="dark"]` cu `var(--surface-100)` sau `var(--surface-200)`
4. **NU hardcoda:** Niciodată `#d1d5db`, `#f9fafb`, `#334155` direct în CSS

View File

@@ -1,6 +1,6 @@
{
"statusLine": {
"type": "command",
"command": "input=$(cat); model=$(echo \"$input\" | jq -r '.model.display_name // \"Unknown\"'); usage=$(echo \"$input\" | jq '.context_window.current_usage'); if [ \"$usage\" != \"null\" ]; then current=$(echo \"$usage\" | jq '.input_tokens + .cache_creation_input_tokens + .cache_read_input_tokens'); size=$(echo \"$input\" | jq '.context_window.context_window_size'); pct=$((current * 100 / size)); ctx=$(printf '%d%% ctx' \"$pct\"); else ctx='0% ctx'; fi; branch=$(git -c core.useBuiltinFSMonitor=false branch --show-current 2>/dev/null || echo 'no-git'); cwd=$(basename \"$(pwd)\"); printf '\\033[36m%s\\033[0m | \\033[33m%s\\033[0m | \\033[32m%s\\033[0m | \\033[35m%s\\033[0m' \"$model\" \"$ctx\" \"$branch\" \"$cwd\""
"command": "/home/claude/.claude/statusline.sh"
}
}

View File

@@ -35,11 +35,24 @@ See `docs/ARCHITECTURE-DECISIONS.md` for:
### Starting Services
```bash
./start-prod.sh # Backend :8001 + Frontend :3000
./start-prod.sh # Backend :8000 + Frontend :3000 (PROD)
./start-prod.sh stop # Stop all services
./ssh-tunnel-prod.sh # Oracle DB tunnel (REQUIRED on Linux)
./status.sh # Check services
```
### Playwright Testing
```bash
# Pentru testare UI cu Playwright:
./start-test.sh # Pornește în mod TEST
./start-test.sh stop # Oprește serverele
# Credențiale TEST:
# User: MARIUS M
# Pass: 123
# Firma: MARIUSM AUTO
```
### Database
- **Schema**: `CONTAFIN_ORACLE`
- **Connection**: SSH tunnel required (Linux dev)
@@ -49,10 +62,25 @@ See `docs/ARCHITECTURE-DECISIONS.md` for:
## Key Rules (ALWAYS FOLLOW)
### Git Commits
- **NU da commit automat!** Întreabă utilizatorul ÎNAINTE de a face commit
- Arată ce fișiere s-au modificat (`git status`) și propune mesajul de commit
- Așteaptă confirmarea explicită înainte de `git commit`
### Frontend Development
**Before writing CSS**: Read `docs/ONBOARDING_CSS.md` (5 min)
- Use design tokens: `var(--color-primary)` not `#2563eb`
#### CSS Design Tokens - OBLIGATORIU ⚠️
- **CITEȘTE ÎNTÂI**: `docs/DESIGN_TOKENS.md` înainte de a scrie CSS
- Folosește **DOAR** design tokens, NICIODATĂ valori hardcodate:
- Spacing: `var(--space-xs)` (4px), `var(--space-sm)` (8px), `var(--space-md)` (16px)
- Font weight: `var(--font-medium)` (500), `var(--font-semibold)` (600), `var(--font-bold)` (700)
- Colors: `var(--green-50)`, `var(--blue-50)`, `var(--surface-card)`, etc.
- Radius: `var(--radius-sm)` (4px), `var(--radius-md)` (8px)
- ❌ GREȘIT: `padding: 8px`, `font-weight: 500`, `background: #f0fdf4`
- ✅ CORECT: `padding: var(--space-sm)`, `font-weight: var(--font-medium)`, `background: var(--green-50)`
#### Alte reguli CSS
- Use shared CSS from `src/assets/css/` - NEVER duplicate
- Check `docs/CSS_PATTERNS.md` for existing patterns
- **Theme toggle**: App has 3 modes (auto/light/dark) - test BOTH themes!

View File

@@ -618,6 +618,8 @@ def _dict_to_extraction_data(data: dict) -> ExtractionData:
confidence_date=data.get('confidence_date', 0.0),
confidence_vendor=data.get('confidence_vendor', 0.0),
confidence_client=data.get('confidence_client', 0.0),
confidence_tva=data.get('confidence_tva', 0.0),
confidence_payment=data.get('confidence_payment', 0.0),
overall_confidence=data.get('overall_confidence', 0.0),
raw_text=data.get('raw_text', ''),
raw_texts=data.get('raw_texts', []),

View File

@@ -60,6 +60,8 @@ class ExtractionData(BaseModel):
confidence_date: float = Field(default=0.0, ge=0, le=1, description="Date extraction confidence")
confidence_vendor: float = Field(default=0.0, ge=0, le=1, description="Vendor extraction confidence")
confidence_client: float = Field(default=0.0, ge=0, le=1, description="Client extraction confidence")
confidence_tva: float = Field(default=0.0, ge=0, le=1, description="TVA extraction confidence")
confidence_payment: float = Field(default=0.0, ge=0, le=1, description="Payment extraction confidence")
overall_confidence: float = Field(default=0.0, ge=0, le=1, description="Overall confidence score")
raw_text: str = Field(default="", description="Raw OCR text (primary)")
raw_texts: List[str] = Field(default=[], description="Raw OCR texts from all engine passes (for analysis)")

View File

@@ -901,12 +901,15 @@ def _merge_extractions(primary, secondary):
# Always use primary (docTR) - higher quality OCR
result.tva_entries = primary.tva_entries
result.tva_total = primary.tva_total
result.confidence_tva = getattr(primary, 'confidence_tva', 0.0)
elif primary.tva_entries:
result.tva_entries = primary.tva_entries
result.tva_total = primary.tva_total
result.confidence_tva = getattr(primary, 'confidence_tva', 0.0)
elif secondary.tva_entries:
result.tva_entries = secondary.tva_entries
result.tva_total = secondary.tva_total
result.confidence_tva = getattr(secondary, 'confidence_tva', 0.0)
# Other fields - prefer primary
result.receipt_number = primary.receipt_number or secondary.receipt_number
@@ -915,7 +918,13 @@ def _merge_extractions(primary, secondary):
result.partner_name = primary.partner_name or secondary.partner_name
result.address = primary.address or secondary.address
result.items_count = primary.items_count or secondary.items_count
result.payment_methods = primary.payment_methods or secondary.payment_methods
# Payment methods with confidence
if primary.payment_methods:
result.payment_methods = primary.payment_methods
result.confidence_payment = getattr(primary, 'confidence_payment', 0.0)
elif secondary.payment_methods:
result.payment_methods = secondary.payment_methods
result.confidence_payment = getattr(secondary, 'confidence_payment', 0.0)
result.suggested_payment_mode = getattr(primary, 'suggested_payment_mode', None) or getattr(secondary, 'suggested_payment_mode', None)
# Client fields
@@ -970,6 +979,11 @@ def _complement_extraction(primary, secondary):
if not primary.tva_entries and secondary.tva_entries:
primary.tva_entries = secondary.tva_entries
primary.tva_total = secondary.tva_total
primary.confidence_tva = getattr(secondary, 'confidence_tva', 0.0)
if not getattr(primary, 'payment_methods', None) and getattr(secondary, 'payment_methods', None):
primary.payment_methods = secondary.payment_methods
primary.confidence_payment = getattr(secondary, 'confidence_payment', 0.0)
if not primary.receipt_number and secondary.receipt_number:
primary.receipt_number = secondary.receipt_number
@@ -1024,6 +1038,8 @@ def _extraction_to_dict(extraction) -> dict:
"confidence_date": extraction.confidence_date,
"confidence_vendor": extraction.confidence_vendor,
"confidence_client": getattr(extraction, 'confidence_client', 0.0),
"confidence_tva": getattr(extraction, 'confidence_tva', 0.0),
"confidence_payment": getattr(extraction, 'confidence_payment', 0.0),
"overall_confidence": extraction.overall_confidence,
"raw_text": extraction.raw_text,
"ocr_engine": extraction.ocr_engine,

View File

@@ -13,8 +13,9 @@ Usage:
CUI_LIST = ["22891860"]
NAME_PATTERNS = ["LIDL", "LDL"]
def extract_tva_entries(self, text: str) -> List[dict]:
def extract_tva_entries(self, text: str) -> Tuple[List[dict], float]:
# Custom Lidl TVA extraction logic
# Returns (entries_list, confidence_score)
...
"""
@@ -331,7 +332,7 @@ class BaseStoreProfile(ABC):
# Extraction methods - override in subclasses as needed
# -------------------------------------------------------------------------
def extract_tva_entries(self, text: str) -> List[dict]:
def extract_tva_entries(self, text: str) -> Tuple[List[dict], float]:
"""
Extract TVA entries from receipt text - GENERIC implementation.
@@ -346,37 +347,53 @@ class BaseStoreProfile(ABC):
text: Raw OCR text from receipt
Returns:
List of dicts with keys: code, percent, amount
Tuple of (List of dicts with keys: code, percent, amount, confidence float)
"""
entries = []
max_confidence = 0.0
text_upper = text.upper()
# Step 1: Check for known non-VAT payer (by class flag or text detection)
if self.IS_NON_VAT_PAYER or self._is_non_vat_payer(text_upper):
return [] # No TVA entries for non-VAT payers
return ([], 0.0) # No TVA entries for non-VAT payers
# Step 2: Normalize OCR spaces in numbers
normalized = re.sub(r'(\d+)[.,]\s+(\d{2})', r'\1.\2', text_upper)
lines = normalized.split('\n')
# Step 3: Try all formats, collect candidates
# Step 3: Try all formats, collect candidates with confidence
candidates = []
# Try inline multi-rate (Lidl-style)
candidates.extend(self._try_tva_inline(normalized))
inline_entries, inline_conf = self._try_tva_inline(normalized)
candidates.extend(inline_entries)
if inline_conf > max_confidence:
max_confidence = inline_conf
# Try reversed format (Stepout-style)
candidates.extend(self._try_tva_reversed(normalized, lines))
reversed_entries, reversed_conf = self._try_tva_reversed(normalized, lines)
candidates.extend(reversed_entries)
if reversed_conf > max_confidence:
max_confidence = reversed_conf
# Try multiline format (Brick/Electrobering)
candidates.extend(self._try_tva_multiline(normalized, lines))
multiline_entries, multiline_conf = self._try_tva_multiline(normalized, lines)
candidates.extend(multiline_entries)
if multiline_conf > max_confidence:
max_confidence = multiline_conf
# Try table format (OMV-style)
candidates.extend(self._try_tva_table(normalized))
table_entries, table_conf = self._try_tva_table(normalized)
candidates.extend(table_entries)
if table_conf > max_confidence:
max_confidence = table_conf
# Try standard/fallback patterns
if not candidates:
candidates.extend(self._try_tva_standard(normalized))
standard_entries, standard_conf = self._try_tva_standard(normalized)
candidates.extend(standard_entries)
if standard_conf > max_confidence:
max_confidence = standard_conf
# Step 4: Deduplicate and return
seen = set()
@@ -386,7 +403,7 @@ class BaseStoreProfile(ABC):
entries.append(entry)
seen.add(key)
return entries
return (entries, max_confidence if entries else 0.0)
def _is_non_vat_payer(self, text: str) -> bool:
"""Check if receipt is from non-VAT payer."""
@@ -395,9 +412,10 @@ class BaseStoreProfile(ABC):
return True
return False
def _try_tva_inline(self, text: str) -> List[dict]:
def _try_tva_inline(self, text: str) -> Tuple[List[dict], float]:
"""Try Lidl-style inline format: 'TVA A 21,00% 7.71'"""
entries = []
max_confidence = 0.0
# Pattern: "TVA A 21,00% 7.71" or "TVA B 11,00% 2.13"
for pattern, confidence, fmt in self.TVA_PATTERNS:
if fmt != 'inline':
@@ -415,13 +433,16 @@ class BaseStoreProfile(ABC):
'percent': percent,
'amount': amount
})
if confidence > max_confidence:
max_confidence = confidence
except (ValueError, InvalidOperation, IndexError):
continue
return entries
return (entries, max_confidence)
def _try_tva_reversed(self, text: str, lines: List[str]) -> List[dict]:
def _try_tva_reversed(self, text: str, lines: List[str]) -> Tuple[List[dict], float]:
"""Try Stepout-style reversed format: '5.00% TUA*B 2.00' (rate BEFORE TVA marker)"""
entries = []
confidence = 0.97 # Default confidence for reversed format
# Pattern: "5.00% TUA*B 2.00" - procent BEFORE TVA, amount same line or next
for i, line in enumerate(lines):
# Try pattern with amount on SAME line: "5.00% TUA*B 2.00"
@@ -462,11 +483,12 @@ class BaseStoreProfile(ABC):
})
except (ValueError, InvalidOperation, IndexError):
continue
return entries
return (entries, confidence if entries else 0.0)
def _try_tva_multiline(self, text: str, lines: List[str]) -> List[dict]:
def _try_tva_multiline(self, text: str, lines: List[str]) -> Tuple[List[dict], float]:
"""Try multiline format: 'TOTAL TVA A - 19%' + amount on next line"""
entries = []
confidence = 0.95 # Default confidence for multiline format
# Pattern: "TOTAL TVA A - 19%" or "TOTAL TVA A 19%" on one line, amount on next
multiline_patterns = [
r'TOTAL\s+TVA\s*([A-D])\s*[-\s]+(\d{1,2})\s*%',
@@ -491,14 +513,15 @@ class BaseStoreProfile(ABC):
'percent': percent,
'amount': amount
})
return entries
return (entries, confidence)
except (ValueError, InvalidOperation, IndexError):
continue
return entries
return (entries, 0.0)
def _try_tva_table(self, text: str) -> List[dict]:
def _try_tva_table(self, text: str) -> Tuple[List[dict], float]:
"""Try OMV-style table format: 'A-21,00% 285,66 49,58'"""
entries = []
confidence = 0.96 # Default confidence for table format
# Pattern: "A-21,00% 285,66 49,58" (code-percent base_amount tva_amount)
table_pattern = r'([A-D])\s*[-:]\s*(\d{1,2})[.,\s]*\d{0,2}\s*%\s+([\d.,\s]+)\s+([\d.,\s]+)'
for match in re.finditer(table_pattern, text, re.IGNORECASE):
@@ -530,13 +553,15 @@ class BaseStoreProfile(ABC):
'percent': 19, # Default rate
'amount': amount
})
confidence = 0.90 # Lower confidence for fallback
except (ValueError, InvalidOperation):
pass
return entries
return (entries, confidence if entries else 0.0)
def _try_tva_standard(self, text: str) -> List[dict]:
def _try_tva_standard(self, text: str) -> Tuple[List[dict], float]:
"""Try standard TVA patterns as fallback"""
entries = []
matched_confidence = 0.0
standard_fmts = ['standard', 'bon', 'percent', 'coded', 'fallback', 'books']
for pattern, confidence, fmt in self.TVA_PATTERNS:
if fmt not in standard_fmts:
@@ -563,7 +588,7 @@ class BaseStoreProfile(ABC):
'percent': percent,
'amount': amount
})
return entries
return (entries, confidence)
elif len(groups) == 1:
# Just amount
amount = self._parse_decimal(self._clean_ocr_number(groups[0]))
@@ -573,10 +598,10 @@ class BaseStoreProfile(ABC):
'percent': 19,
'amount': amount
})
return entries
return (entries, confidence)
except (ValueError, InvalidOperation, IndexError):
continue
return entries
return (entries, matched_confidence)
def _clean_ocr_number(self, value: str) -> str:
"""Remove OCR spaces from numbers (e.g., '55, 22' -> '55,22')."""

View File

@@ -38,6 +38,8 @@ class ExtractionResult:
confidence_date: float = 0.0
confidence_vendor: float = 0.0
confidence_client: float = 0.0
confidence_tva: float = 0.0
confidence_payment: float = 0.0
raw_text: str = ""
ocr_engine: str = "" # OCR engine used: paddleocr or tesseract
processing_time_ms: int = 0 # Processing time in milliseconds
@@ -51,12 +53,20 @@ class ExtractionResult:
@property
def overall_confidence(self) -> float:
"""Calculate weighted overall confidence score."""
weights = {'amount': 0.4, 'date': 0.3, 'vendor': 0.3}
"""Calculate weighted overall confidence score including TVA and payment."""
weights = {
'amount': 0.35, # Most important - receipt total
'date': 0.20, # Receipt date
'vendor': 0.20, # Vendor identification
'tva': 0.15, # TVA extraction accuracy
'payment': 0.10 # Payment method detection
}
return round(
self.confidence_amount * weights['amount'] +
self.confidence_date * weights['date'] +
self.confidence_vendor * weights['vendor'],
self.confidence_vendor * weights['vendor'] +
self.confidence_tva * weights['tva'] +
self.confidence_payment * weights['payment'],
2
)
@@ -468,9 +478,15 @@ class ReceiptExtractor:
result.amount, result.confidence_amount = store_profile.extract_total(text_upper)
result.receipt_date, result.confidence_date = store_profile.extract_date(text_upper)
result.receipt_number, _ = store_profile.extract_receipt_number(text_upper)
result.tva_entries = store_profile.extract_tva_entries(text_upper)
result.tva_total = sum(e['amount'] for e in result.tva_entries) if result.tva_entries else None
result.tva_entries, result.confidence_tva = store_profile.extract_tva_entries(text_upper)
result.tva_total = sum((e['amount'] for e in result.tva_entries), Decimal(0)) if result.tva_entries else None
result.payment_methods = store_profile.extract_payment_methods(text_upper)
# Calculate payment confidence from individual payment method confidences
if result.payment_methods:
payment_confidences = [pm.get('confidence', 0.0) for pm in result.payment_methods]
result.confidence_payment = max(payment_confidences) if payment_confidences else 0.0
else:
result.confidence_payment = 0.0
# Client data extraction via profile (CUI + name)
profile_client_cui, cui_confidence = store_profile.extract_client_cui(text_upper)
@@ -502,8 +518,8 @@ class ReceiptExtractor:
result.amount, result.confidence_amount = self._extract_amount(text_upper)
result.receipt_date, result.confidence_date = self._extract_date(text_upper)
result.receipt_number, _ = self._extract_number(text_upper)
result.tva_entries, result.tva_total = self._extract_tva_entries(text_upper)
result.payment_methods = self._extract_payment_methods(text_upper)
result.tva_entries, result.tva_total, result.confidence_tva = self._extract_tva_entries(text_upper)
result.payment_methods, result.confidence_payment = self._extract_payment_methods(text_upper)
# Generic client extraction
client_name, client_cui, client_address, confidence = self._extract_client_data(text_upper, text)
@@ -1289,7 +1305,7 @@ class ReceiptExtractor:
self,
candidates: List[tuple],
tva_bon_total: Optional[Decimal]
) -> Tuple[List[dict], Optional[Decimal]]:
) -> Tuple[List[dict], Optional[Decimal], float]:
"""
Select the best TVA candidate from collected candidates.
@@ -1303,10 +1319,10 @@ class ReceiptExtractor:
tva_bon_total: Authoritative TOTAL TVA BON value (if extracted)
Returns:
(best_entries, best_sum)
(best_entries, best_sum, confidence)
"""
if not candidates:
return [], None
return [], None, 0.0
# Score each candidate
scored = []
@@ -1334,9 +1350,9 @@ class ReceiptExtractor:
best = scored[0]
print(f"[TVA Select] Winner: {best[1]} (score={best[0]:.1f})", flush=True)
return best[3], best[4]
return best[3], best[4], best[2] # entries, sum, confidence
def _extract_tva_entries(self, text: str) -> Tuple[List[dict], Optional[Decimal]]:
def _extract_tva_entries(self, text: str) -> Tuple[List[dict], Optional[Decimal], float]:
"""
Extract multiple TVA (VAT) entries from text.
Romanian receipts can have multiple TVA rates (A=19%, B=9%, C=5%, D=0%).
@@ -1345,11 +1361,12 @@ class ReceiptExtractor:
- Try ALL patterns and collect candidates
- Select best candidate based on matching TOTAL TVA BON
Returns (tva_entries, tva_total) where tva_entries is a list of:
Returns (tva_entries, tva_total, confidence) where tva_entries is a list of:
{'code': 'A', 'percent': 19, 'amount': Decimal('15.20')}
"""
tva_entries = []
seen_entries = set() # To avoid duplicates
confidence = 0.0 # Track extraction confidence
# Check for non-VAT payer (NEPLATITOR DE TVA) - TVA = 0
# OCR variants: NEPLATTOR, NEPLATITOR, NEPLATOR, NEPLATTOR, ANEPLATHTOR, MEPLATITOR, etc.
@@ -1366,8 +1383,8 @@ class ReceiptExtractor:
]
for pattern in non_vat_patterns:
if re.search(pattern, text, re.IGNORECASE):
# Non-VAT payer - return TVA = 0
return [{'code': 'D', 'percent': 0, 'amount': Decimal('0.00')}], Decimal('0.00')
# Non-VAT payer - return TVA = 0, high confidence
return [{'code': 'D', 'percent': 0, 'amount': Decimal('0.00')}], Decimal('0.00'), 0.95
# Normalize spaces in numbers first (OCR may produce "32. 31" or "49, 58")
normalized_text = re.sub(r'(\d+)[.,]\s+(\d{2})', r'\1.\2', text)
@@ -1717,7 +1734,7 @@ class ReceiptExtractor:
# === CANDIDATE SELECTION ===
# Select best candidate using TOTAL TVA BON as authoritative reference
if all_candidates:
best_entries, best_sum = self._select_best_tva_candidate(all_candidates, tva_bon_total)
best_entries, best_sum, confidence = self._select_best_tva_candidate(all_candidates, tva_bon_total)
if best_entries:
tva_entries = best_entries
entries_sum = best_sum
@@ -1725,7 +1742,7 @@ class ReceiptExtractor:
# Calculate sum from entries (if not set by candidate selection)
entries_sum = None
if tva_entries:
entries_sum = sum(entry['amount'] for entry in tva_entries)
entries_sum = sum((entry['amount'] for entry in tva_entries), Decimal(0))
# Validate and correct TVA values
tva_entries, tva_total = self._validate_and_correct_tva(
@@ -1735,7 +1752,7 @@ class ReceiptExtractor:
# Sort by code (A, B, C, D)
tva_entries.sort(key=lambda x: x.get('code', 'Z'))
return tva_entries, tva_total
return tva_entries, tva_total, confidence if tva_entries else 0.0
def _get_tva_code_from_percent(self, percent: int) -> str:
"""Map TVA percentage to standard Romanian code.
@@ -1843,7 +1860,7 @@ class ReceiptExtractor:
tva_entries = corrected_entries
# Recalculate sum after corrections
entries_sum = sum(entry['amount'] for entry in tva_entries) if tva_entries else None
entries_sum = sum((entry['amount'] for entry in tva_entries), Decimal(0)) if tva_entries else None
# Validate sum against TOTAL TVA BON
if tva_bon_total and entries_sum:
@@ -1876,7 +1893,7 @@ class ReceiptExtractor:
seen.add(key)
unique_entries.append(entry)
tva_entries = unique_entries
entries_sum = sum(e['amount'] for e in tva_entries)
entries_sum = sum((e['amount'] for e in tva_entries), Decimal(0))
# Final total
tva_total = entries_sum if entries_sum else tva_bon_total
@@ -2032,15 +2049,16 @@ class ReceiptExtractor:
return None
def _extract_payment_methods(self, text: str) -> List[dict]:
def _extract_payment_methods(self, text: str) -> Tuple[List[dict], float]:
"""
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}
Returns tuple of: (list of {'method': 'CARD'/'NUMERAR', 'amount': Decimal}, confidence)
"""
payment_methods = []
seen_methods = set()
max_confidence = 0.0
# Normalize spaces in numbers
normalized_text = re.sub(r'(\d+)[.,]\s+(\d{2})', r'\1.\2', text)
@@ -2071,13 +2089,15 @@ class ReceiptExtractor:
'amount': amount
})
seen_methods.add(method)
print(f"[Payment] Found {method}: {amount} (pattern matched)", flush=True)
if confidence > max_confidence:
max_confidence = confidence
print(f"[Payment] Found {method}: {amount} (pattern matched, conf={confidence})", flush=True)
elif amount >= self.MAX_REASONABLE_PAYMENT:
print(f"[Payment] Rejected unreasonable amount {amount} for {method} (likely OCR error)", flush=True)
except (InvalidOperation, ValueError):
continue
return payment_methods
return payment_methods, max_confidence if payment_methods else 0.0
def _validate_payment_methods(
self, payment_methods: List[dict], total: Optional[Decimal]

View File

@@ -134,6 +134,21 @@ check_port_available() {
fi
}
# Check if port is in use (opposite of check_port_available)
# Usage: check_port_in_use <port>
# Returns 0 if port IS IN USE, 1 if FREE
check_port_in_use() {
local port=$1
if netstat -tuln 2>/dev/null | grep -q ":${port} " || \
ss -tuln 2>/dev/null | grep -q ":${port} " || \
lsof -ti:${port} &>/dev/null; then
return 0 # Port is in use
else
return 1 # Port is free
fi
}
# Display a nice header
# Usage: print_header "Title"
print_header() {

View File

@@ -0,0 +1,321 @@
<template>
<div class="auxiliary-section">
<!-- Expense Type -->
<div class="aux-field">
<label>Tip Cheltuiala</label>
<Dropdown
:modelValue="expenseType"
@update:modelValue="$emit('update:expenseType', $event)"
:options="expenseTypes"
optionLabel="name"
optionValue="code"
placeholder="Selecteaza tip cheltuiala"
:disabled="disabled"
class="expense-dropdown dropdown-borderless"
/>
</div>
<!-- Description -->
<div class="aux-field">
<label>Descriere</label>
<Textarea
:modelValue="description"
@update:modelValue="$emit('update:description', $event)"
rows="2"
placeholder="Descriere optionala..."
:disabled="disabled"
class="description-textarea"
/>
</div>
<!-- Attachments -->
<div class="aux-field attachments-field">
<div class="attachments-header">
<label>Atasamente</label>
<Button
v-if="!disabled"
icon="pi pi-plus"
label="Adauga"
size="small"
severity="secondary"
@click="triggerFileInput"
/>
<input
ref="fileInputRef"
type="file"
accept="image/*,application/pdf"
multiple
class="hidden-input"
@change="onFilesSelected"
/>
</div>
<!-- Existing attachments -->
<div v-if="attachments.length || newFiles.length" class="attachments-grid">
<!-- Existing attachments -->
<div
v-for="att in attachments"
:key="att.id"
class="attachment-item"
>
<div class="attachment-preview">
<i :class="att.mime_type?.startsWith('image/') ? 'pi pi-image' : 'pi pi-file-pdf'"></i>
<span class="attachment-name">{{ truncateFilename(att.filename) }}</span>
</div>
<div class="attachment-actions">
<Button
icon="pi pi-download"
size="small"
severity="secondary"
text
rounded
@click="$emit('download-attachment', att)"
/>
<Button
v-if="!disabled"
icon="pi pi-times"
size="small"
severity="danger"
text
rounded
@click="$emit('remove-attachment', att.id)"
/>
</div>
</div>
<!-- New files (pending upload) -->
<div
v-for="(file, idx) in newFiles"
:key="'new-' + idx"
class="attachment-item new-file"
>
<div class="attachment-preview">
<i :class="file.type?.startsWith('image/') ? 'pi pi-image' : 'pi pi-file-pdf'"></i>
<span class="attachment-name">{{ truncateFilename(file.name) }}</span>
<span class="file-size">({{ formatFileSize(file.size) }})</span>
</div>
<div class="attachment-actions">
<Button
v-if="!disabled"
icon="pi pi-times"
size="small"
severity="danger"
text
rounded
@click="$emit('remove-file', idx)"
/>
</div>
</div>
</div>
<!-- Empty state -->
<div v-else class="attachments-empty">
<i class="pi pi-image"></i>
<span>Niciun atasament</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import Dropdown from 'primevue/dropdown'
import Textarea from 'primevue/textarea'
import Button from 'primevue/button'
const props = defineProps({
expenseType: { type: String, default: null },
description: { type: String, default: '' },
expenseTypes: { type: Array, default: () => [] },
attachments: { type: Array, default: () => [] },
newFiles: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits([
'update:expenseType',
'update:description',
'add-files',
'remove-file',
'remove-attachment',
'download-attachment',
])
const fileInputRef = ref(null)
const triggerFileInput = () => {
fileInputRef.value?.click()
}
const onFilesSelected = async (event) => {
const files = Array.from(event.target?.files || [])
if (files.length === 0) return
// Clone files to avoid Android SnapshotState issue
const clonedFiles = await Promise.all(
files.map(async (file) => {
try {
const arrayBuffer = await file.arrayBuffer()
return new File([arrayBuffer], file.name, {
type: file.type,
lastModified: file.lastModified
})
} catch (e) {
console.warn('File clone failed:', e)
return file
}
})
)
emit('add-files', clonedFiles)
// Reset input
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
const truncateFilename = (name, maxLen = 20) => {
if (!name || name.length <= maxLen) return name
const ext = name.split('.').pop()
const base = name.substring(0, name.length - ext.length - 1)
const truncatedBase = base.substring(0, maxLen - ext.length - 4)
return `${truncatedBase}...${ext}`
}
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
</script>
<style scoped>
.auxiliary-section {
margin-top: 1rem;
padding: 1rem;
background: var(--surface-ground);
border: 1px solid var(--surface-border);
border-radius: 8px;
}
.aux-field {
margin-bottom: 0.75rem;
}
.aux-field:last-child {
margin-bottom: 0;
}
.aux-field label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color-secondary);
margin-bottom: 0.35rem;
}
.expense-dropdown {
width: 100%;
max-width: 300px;
}
.description-textarea {
width: 100%;
}
.hidden-input {
display: none;
}
/* Attachments */
.attachments-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.attachments-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.attachment-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 6px;
font-size: 0.8rem;
}
.attachment-item.new-file {
background: var(--blue-50);
border-color: var(--blue-200);
}
.attachment-preview {
display: flex;
align-items: center;
gap: 0.35rem;
}
.attachment-preview i {
color: var(--text-color-secondary);
}
.attachment-name {
color: var(--text-color);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 0.7rem;
color: var(--text-color-secondary);
}
.attachment-actions {
display: flex;
gap: 0.25rem;
}
.attachments-empty {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
color: var(--text-color-secondary);
font-size: 0.85rem;
font-style: italic;
}
.attachments-empty i {
font-size: 1.25rem;
opacity: 0.5;
}
/* Responsive */
@media (max-width: 640px) {
.attachments-grid {
flex-direction: column;
}
.attachment-item {
width: 100%;
}
}
/* Dark mode */
[data-theme="dark"] .attachment-item.new-file {
background: rgba(59, 130, 246, 0.1);
border-color: var(--blue-700);
}
</style>

View File

@@ -0,0 +1,511 @@
<template>
<div class="compact-upload-zone">
<div
class="upload-strip"
:class="{ 'dragging': isDragging, 'processing': processing, 'has-file': selectedFile }"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
@click="triggerFileInput"
>
<input
ref="fileInput"
type="file"
accept="image/*,application/pdf"
class="hidden-input"
@change="onFileSelected"
/>
<!-- Processing state -->
<div v-if="processing" class="strip-content processing-state">
<ProgressSpinner style="width: 24px; height: 24px" strokeWidth="4" />
<span class="processing-text">{{ processingMessage }}</span>
<span class="processing-subtext" v-if="processingSubtext">{{ processingSubtext }}</span>
</div>
<!-- File selected state -->
<div v-else-if="selectedFile" class="strip-content file-state">
<i class="pi pi-check-circle" style="color: var(--green-500);"></i>
<span class="file-name">{{ selectedFile.name }}</span>
<span class="file-size">({{ formatFileSize(selectedFile.size) }})</span>
</div>
<!-- Empty state -->
<div v-else class="strip-content empty-state">
<i class="pi pi-camera"></i>
<span v-if="isDragging">Elibereaza pentru a incarca</span>
<span v-else>Trage poza bonului aici sau click pentru a selecta</span>
</div>
</div>
<!-- Action bar (inline with file strip) -->
<div v-if="selectedFile && !processing" class="action-bar">
<Button
icon="pi pi-refresh"
severity="secondary"
size="small"
@click.stop="triggerFileInput"
v-tooltip.top="'Schimba fisier'"
/>
<Dropdown
v-model="selectedEngine"
:options="engineOptions"
optionLabel="label"
optionValue="value"
placeholder="OCR"
class="engine-selector dropdown-borderless"
@click.stop
/>
<Button
label="OCR"
icon="pi pi-play"
size="small"
@click.stop="processOCR"
/>
</div>
<!-- Error message -->
<Message v-if="error" severity="error" :closable="true" @close="error = null" class="error-message">
{{ error }}
</Message>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import Dropdown from 'primevue/dropdown'
import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import Button from 'primevue/button'
import api from '@data-entry/services/api'
import { useOCRSettingsStore } from '@data-entry/stores/ocrSettingsStore'
const emit = defineEmits(['ocr-result', 'file-selected', 'error'])
const fileInput = ref(null)
const selectedFile = ref(null)
const isDragging = ref(false)
const processing = ref(false)
const error = ref(null)
// OCR Settings Store
const ocrStore = useOCRSettingsStore()
const selectedEngine = ref('auto')
// Engine config
const engineConfig = {
'auto': { label: 'Auto', desc: 'docTR→Paddle→Tess' },
'doctr': { label: 'docTR', desc: 'Rapid, buna acuratete' },
'doctr_plus': { label: 'docTR Plus', desc: '2 treceri (recomandat)' },
'paddleocr': { label: 'PaddleOCR', desc: 'Cea mai buna calitate' },
'tesseract': { label: 'Tesseract', desc: 'Cel mai rapid' },
'hybrid': { label: 'Hybrid', desc: 'docTR+Tess paralel' },
'hybrid-quality': { label: 'Hybrid Calitate', desc: 'Acuratete max' },
}
const engineOptions = computed(() => {
return ocrStore.availableEngines.map(engine => ({
label: engineConfig[engine]?.label || engine,
desc: engineConfig[engine]?.desc || '',
value: engine
}))
})
// Job queue state
const jobId = ref(null)
const queuePosition = ref(null)
const estimatedWait = ref(null)
const jobStatus = ref(null)
const processingMessage = computed(() => {
if (jobStatus.value === 'pending' && queuePosition.value > 0) {
return `In coada (${queuePosition.value})`
}
if (jobStatus.value === 'processing') {
return 'Se proceseaza...'
}
return 'Se trimite...'
})
const processingSubtext = computed(() => {
if (jobStatus.value === 'pending' && estimatedWait.value > 0) {
return `~${estimatedWait.value}s`
}
return ''
})
// Load user preference on mount
onMounted(async () => {
await ocrStore.loadPreference()
selectedEngine.value = ocrStore.preferredEngine
})
// Save preference when changed
watch(selectedEngine, async (newEngine, oldEngine) => {
if (oldEngine && newEngine !== oldEngine && ocrStore.initialized) {
try {
await ocrStore.setPreference(newEngine)
} catch (err) {
console.error('[CompactUploadZone] Failed to save preference:', err)
}
}
})
const onDragOver = () => { isDragging.value = true }
const onDragLeave = () => { isDragging.value = false }
const onDrop = (event) => {
isDragging.value = false
const files = event.dataTransfer?.files
if (files?.length > 0) handleFile(files[0])
}
const triggerFileInput = () => { fileInput.value?.click() }
const onFileSelected = (event) => {
const files = event.target?.files
if (files?.length > 0) handleFile(files[0])
}
const handleFile = async (file) => {
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
if (!allowedTypes.includes(file.type)) {
error.value = 'Tip de fisier invalid. Acceptate: JPG, PNG, PDF'
return
}
if (file.size > 10 * 1024 * 1024) {
error.value = 'Fisier prea mare. Max 10MB.'
return
}
error.value = null
// Clone file to avoid Android SnapshotState issue
try {
const arrayBuffer = await file.arrayBuffer()
const clonedFile = new File([arrayBuffer], file.name, {
type: file.type,
lastModified: file.lastModified
})
selectedFile.value = clonedFile
emit('file-selected', clonedFile)
} catch (e) {
console.warn('File clone failed:', e)
selectedFile.value = file
emit('file-selected', file)
}
}
const processOCR = async () => {
if (!selectedFile.value) return
processing.value = true
error.value = null
jobId.value = null
queuePosition.value = null
estimatedWait.value = null
jobStatus.value = 'submitting'
try {
const formData = new FormData()
formData.append('file', selectedFile.value)
// Submit job
const submitResponse = await api.post(`/ocr/extract?engine=${selectedEngine.value}`, formData, {
timeout: 30000,
})
const job = submitResponse.data
jobId.value = job.job_id
queuePosition.value = job.queue_position
estimatedWait.value = job.estimated_wait_seconds
jobStatus.value = job.status
// Poll for result
await pollJobStatus(job.job_id)
} catch (err) {
const message = err.response?.data?.detail || err.message || 'Eroare OCR'
error.value = message
emit('error', message)
processing.value = false
}
}
const pollJobStatus = async (id) => {
const LONG_POLL_TIMEOUT = 30
const MAX_TOTAL_TIME = 120
const MIN_POLL_INTERVAL = 500
const startTime = Date.now()
const poll = async () => {
const elapsed = (Date.now() - startTime) / 1000
if (elapsed >= MAX_TOTAL_TIME) {
processing.value = false
error.value = 'Timeout - procesare prea lunga'
emit('error', error.value)
return
}
const pollStartTime = Date.now()
try {
const response = await api.get(`/ocr/jobs/${id}/wait`, {
params: { timeout: LONG_POLL_TIMEOUT, _t: Date.now() },
timeout: (LONG_POLL_TIMEOUT + 5) * 1000,
headers: { 'Cache-Control': 'no-cache' }
})
const job = response.data
jobStatus.value = job.status
queuePosition.value = job.queue_position
estimatedWait.value = job.estimated_wait_seconds
if (job.status === 'completed') {
processing.value = false
if (job.result) {
emit('ocr-result', {
...job.result,
_processing_time_ms: job.processing_time_ms
})
} else {
error.value = 'OCR completat dar fara rezultat'
emit('error', error.value)
}
return
}
if (job.status === 'failed') {
processing.value = false
error.value = job.error || 'Procesare OCR esuata'
emit('error', error.value)
return
}
// Still pending/processing
if (processing.value) {
const pollDuration = Date.now() - pollStartTime
if (pollDuration < MIN_POLL_INTERVAL) {
await new Promise(resolve => setTimeout(resolve, MIN_POLL_INTERVAL - pollDuration))
}
await poll()
}
} catch (err) {
if (err.code === 'ECONNABORTED' || err.message?.includes('timeout')) {
if (processing.value) await poll()
return
}
const elapsed = (Date.now() - startTime) / 1000
if (elapsed < MAX_TOTAL_TIME && processing.value) {
await new Promise(resolve => setTimeout(resolve, MIN_POLL_INTERVAL))
await poll()
return
}
processing.value = false
error.value = 'Eroare verificare job'
emit('error', error.value)
}
}
await poll()
}
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const reset = () => {
selectedFile.value = null
error.value = null
jobId.value = null
queuePosition.value = null
estimatedWait.value = null
jobStatus.value = null
processing.value = false
if (fileInput.value) fileInput.value.value = ''
}
defineExpose({ reset, processOCR })
</script>
<style scoped>
.compact-upload-zone {
margin-bottom: var(--space-xs);
}
.upload-strip {
display: flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 2px var(--space-sm); /* reduced vertical padding */
border: 1px dashed var(--surface-border);
border-radius: var(--radius-sm);
background: var(--surface-ground);
cursor: pointer;
transition: all var(--transition-fast);
}
.upload-strip:hover {
border-color: var(--primary-500);
background: var(--surface-hover);
}
.upload-strip.dragging {
border-color: var(--primary-500);
background: var(--primary-50);
}
.upload-strip.processing {
cursor: default;
background: var(--surface-card);
}
.upload-strip.has-file {
border-style: solid;
border-color: var(--green-300);
background: var(--green-50);
}
.hidden-input {
display: none;
}
.strip-content {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-sm); /* bigger - 0.875rem */
}
.strip-content i {
font-size: var(--text-sm);
}
.empty-state {
color: var(--text-color-secondary);
}
.empty-state i {
color: var(--primary-500);
}
.file-state {
color: var(--text-color);
}
.file-name {
font-weight: var(--font-medium);
max-width: 200px; /* wider */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: var(--text-xs);
color: var(--text-color-secondary);
}
.processing-state {
color: var(--text-color-secondary);
}
.processing-text {
font-weight: var(--font-medium);
}
.processing-subtext {
font-size: var(--text-xs);
color: var(--text-color-secondary);
}
/* Action bar - single line */
.action-bar {
display: flex;
flex-wrap: nowrap;
gap: var(--space-sm);
margin-top: 2px; /* minimal margin */
justify-content: center;
align-items: center;
}
.action-bar :deep(.p-button) {
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-xs);
height: 28px;
}
.action-bar :deep(.p-button .p-button-icon) {
font-size: var(--text-sm);
}
.engine-selector {
min-width: 100px;
}
.engine-selector :deep(.p-dropdown) {
padding: 0 var(--space-xs);
font-size: var(--text-xs);
min-height: 28px;
height: 28px;
}
.engine-selector :deep(.p-dropdown-label) {
padding: var(--space-xs);
}
.engine-selector :deep(.p-dropdown-trigger) {
width: 24px;
}
.engine-option {
display: flex;
flex-direction: column;
gap: 0;
padding: 2px 0;
}
.engine-label {
font-weight: var(--font-medium);
font-size: var(--text-xs); /* 12px - uniform */
}
.engine-desc {
font-size: var(--text-xs);
color: var(--text-color-secondary);
}
.error-message {
margin-top: var(--space-xs);
}
.error-message :deep(.p-message) {
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-xs); /* 12px - uniform */
}
/* Responsive */
@media (max-width: 480px) {
.strip-content span:not(.file-name) {
display: none;
}
.strip-content .file-name {
max-width: 150px;
}
.action-bar {
gap: var(--space-xs);
}
.engine-selector {
min-width: 90px;
}
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<div class="pay-section">
<!-- Payment fields - NO HEADER -->
<div class="pay-grid">
<!-- CARD -->
<div class="pay-item" :class="{ active: modelValue.CARD > 0 }">
<label class="pay-label">Card</label>
<InputNumber
v-model="modelValue.CARD"
:minFractionDigits="2"
:maxFractionDigits="2"
:disabled="disabled"
placeholder="0.00"
class="pay-input"
@input="emitUpdate"
/>
</div>
<!-- NUMERAR -->
<div class="pay-item" :class="{ active: modelValue.NUMERAR > 0 }">
<label class="pay-label">Cash</label>
<InputNumber
v-model="modelValue.NUMERAR"
:minFractionDigits="2"
:maxFractionDigits="2"
:disabled="disabled"
placeholder="0.00"
class="pay-input"
@input="emitUpdate"
/>
</div>
<!-- ALTE (simple label + input, no dropdown) -->
<div class="pay-item" :class="{ active: modelValue.ALTE?.amount > 0 }">
<label class="pay-label">Alte</label>
<InputNumber
v-model="modelValue.ALTE.amount"
:minFractionDigits="2"
:maxFractionDigits="2"
:disabled="disabled"
placeholder="0.00"
class="pay-input"
@input="emitUpdate"
/>
</div>
</div>
<!-- Mismatch warning message -->
<div v-if="showMismatch" class="mismatch-msg">
<i class="pi pi-exclamation-triangle"></i>
Plati ({{ formatAmount(computedTotal) }}) Total ({{ formatAmount(total) }})
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import InputNumber from 'primevue/inputnumber'
const props = defineProps({
modelValue: { type: Object, required: true },
total: { type: Number, default: null },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const computedTotal = computed(() => {
return (props.modelValue.CARD || 0) +
(props.modelValue.NUMERAR || 0) +
(props.modelValue.ALTE?.amount || 0)
})
const showMismatch = computed(() => {
if (!props.total || props.total === 0) return false
if (computedTotal.value === 0) return false
return Math.abs(computedTotal.value - props.total) > 0.02
})
const formatAmount = (amount) => {
return parseFloat(amount || 0).toLocaleString('ro-RO', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
const emitUpdate = () => {
emit('update:modelValue', { ...props.modelValue })
}
</script>
<style scoped>
/* === PAYMENT SECTION - simple grid === */
.pay-section {
padding: var(--space-sm) 0;
border-top: 1px solid var(--surface-border);
}
/* Payment grid - horizontal */
.pay-grid {
display: flex;
flex-wrap: wrap;
gap: var(--space-lg);
}
.pay-item {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.pay-item.active .pay-input :deep(.p-inputnumber-input) {
background: color-mix(in srgb, var(--blue-500) 8%, var(--surface-ground));
}
/* Label above input */
.pay-label {
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-color-secondary);
}
/* Payment input */
.pay-input {
width: 80px;
}
.pay-input :deep(.p-inputnumber-input) {
width: 100%;
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-sm);
text-align: right;
height: 28px;
background: var(--surface-ground);
border: 1px solid var(--surface-border);
border-radius: var(--radius-sm);
color: var(--text-color);
}
.pay-input :deep(.p-inputnumber-input:focus) {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px var(--primary-color);
}
/* Mismatch warning message */
.mismatch-msg {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-top: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
background: color-mix(in srgb, var(--yellow-500) 15%, var(--surface-card));
border: 1px solid var(--yellow-500);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
color: var(--yellow-600);
}
.mismatch-msg i {
font-size: var(--text-xs);
}
/* Responsive: stack on mobile */
@media (max-width: 400px) {
.pay-grid {
flex-direction: column;
gap: var(--space-sm);
}
.pay-item {
width: 100%;
}
.pay-input {
width: 100%;
}
}
</style>
<!-- NON-SCOPED: Force dark mode styling -->
<style>
[data-theme="dark"] .pay-section .p-inputnumber-input {
background: var(--surface-ground) !important;
color: var(--text-color) !important;
border-color: var(--surface-border) !important;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .pay-section .p-inputnumber-input {
background: var(--surface-ground) !important;
color: var(--text-color) !important;
border-color: var(--surface-border) !important;
}
}
</style>

View File

@@ -0,0 +1,247 @@
<template>
<div class="payment-fixed-fields">
<div class="payment-grid">
<!-- CARD -->
<div class="payment-field" :class="{ 'has-value': modelValue.CARD > 0 }">
<label class="payment-label">
<i class="pi pi-credit-card"></i>
CARD
</label>
<InputNumber
v-model="modelValue.CARD"
:minFractionDigits="2"
:maxFractionDigits="2"
:disabled="disabled"
placeholder="0.00"
class="amount-input"
@input="emitUpdate"
/>
</div>
<!-- NUMERAR -->
<div class="payment-field" :class="{ 'has-value': modelValue.NUMERAR > 0 }">
<label class="payment-label">
<i class="pi pi-wallet"></i>
NUMERAR
</label>
<InputNumber
v-model="modelValue.NUMERAR"
:minFractionDigits="2"
:maxFractionDigits="2"
:disabled="disabled"
placeholder="0.00"
class="amount-input"
@input="emitUpdate"
/>
</div>
<!-- ALTE (with type dropdown) -->
<div class="payment-field payment-field-other" :class="{ 'has-value': modelValue.ALTE.amount > 0 }">
<div class="payment-header">
<label class="payment-label">
<i class="pi pi-ticket"></i>
ALTE
</label>
<Dropdown
v-model="modelValue.ALTE.type"
:options="otherPaymentTypes"
optionLabel="label"
optionValue="value"
:disabled="disabled"
placeholder="Tip"
class="type-dropdown dropdown-borderless"
@change="emitUpdate"
/>
</div>
<InputNumber
v-model="modelValue.ALTE.amount"
:minFractionDigits="2"
:maxFractionDigits="2"
:disabled="disabled"
placeholder="0.00"
class="amount-input"
@input="emitUpdate"
/>
</div>
</div>
<!-- Validation warning: sum vs total -->
<div class="validation-row" v-if="showMismatchWarning">
<div class="warning-box">
<i class="pi pi-exclamation-triangle"></i>
<span>
Suma plati ({{ formatAmount(computedTotal) }}) {{ computedTotal > total ? '>' : '<' }} Total ({{ formatAmount(total) }})
</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Dropdown from 'primevue/dropdown'
import InputNumber from 'primevue/inputnumber'
const props = defineProps({
modelValue: {
type: Object,
required: true,
// Expected structure:
// { CARD: 0, NUMERAR: 0, ALTE: { amount: 0, type: null } }
},
total: { type: Number, default: null }, // Total amount for validation
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const otherPaymentTypes = [
{ value: 'tichete_masa', label: 'Tichete masa' },
{ value: 'voucher', label: 'Voucher' },
{ value: 'credit_magazin', label: 'Credit magazin' },
]
const computedTotal = computed(() => {
const pm = props.modelValue
return (pm.CARD || 0) + (pm.NUMERAR || 0) + (pm.ALTE?.amount || 0)
})
const showMismatchWarning = computed(() => {
if (!props.total || props.total === 0) return false
if (computedTotal.value === 0) return false
return Math.abs(computedTotal.value - props.total) > 0.02 // 2 bani tolerance
})
const formatAmount = (amount) => {
return parseFloat(amount || 0).toLocaleString('ro-RO', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
const emitUpdate = () => {
emit('update:modelValue', { ...props.modelValue })
}
</script>
<style scoped>
.payment-fixed-fields {
width: 100%;
}
.payment-grid {
display: grid;
grid-template-columns: 1fr 1fr 1.5fr;
gap: 0.75rem;
}
.payment-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem;
border-radius: 6px;
background: var(--surface-ground);
border: 1px solid var(--surface-border);
transition: all 0.2s;
}
.payment-field.has-value {
background: var(--blue-50);
border-color: var(--blue-200);
}
.payment-label {
display: flex;
align-items: center;
gap: 0.35rem;
font-weight: 600;
font-size: 0.8rem;
color: var(--text-color);
}
.payment-label i {
font-size: 0.9rem;
color: var(--text-color-secondary);
}
.payment-field.has-value .payment-label i {
color: var(--blue-500);
}
/* Other payment type with dropdown */
.payment-field-other .payment-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.type-dropdown {
max-width: 120px;
}
.type-dropdown :deep(.p-dropdown) {
padding: 0.2rem 0.35rem;
font-size: 0.75rem;
}
.amount-input {
width: 100%;
}
.amount-input :deep(.p-inputnumber-input) {
padding: 0.35rem 0.5rem;
font-size: 0.9rem;
text-align: right;
}
/* Validation warning */
.validation-row {
margin-top: 0.5rem;
}
.warning-box {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--yellow-100);
border: 1px solid var(--yellow-300);
border-radius: 6px;
font-size: 0.8rem;
color: var(--yellow-800);
}
.warning-box i {
color: var(--yellow-500);
}
/* Responsive - stack on mobile */
@media (max-width: 640px) {
.payment-grid {
grid-template-columns: 1fr 1fr;
}
.payment-field-other {
grid-column: 1 / -1;
}
}
@media (max-width: 480px) {
.payment-grid {
grid-template-columns: 1fr;
}
}
/* Dark mode adjustments */
[data-theme="dark"] .payment-field.has-value {
background: rgba(59, 130, 246, 0.1);
border-color: var(--blue-700);
}
[data-theme="dark"] .warning-box {
background: rgba(234, 179, 8, 0.1);
border-color: var(--yellow-700);
color: var(--yellow-200);
}
</style>

View File

@@ -0,0 +1,148 @@
<template>
<div class="receipt-section" :class="[variant, { collapsed: isCollapsed }]">
<div class="section-header" @click="toggleable && toggle()">
<span class="section-title">{{ title }}</span>
<div class="section-right">
<slot name="header-right"></slot>
<i
v-if="toggleable"
:class="isCollapsed ? 'pi pi-chevron-down' : 'pi pi-chevron-up'"
class="toggle-icon"
></i>
</div>
</div>
<div class="section-content" v-show="!isCollapsed">
<slot></slot>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
title: { type: String, required: true },
variant: {
type: String,
default: 'default',
validator: (v) => ['default', 'highlight', 'muted'].includes(v)
},
toggleable: { type: Boolean, default: false },
collapsed: { type: Boolean, default: false },
})
const emit = defineEmits(['update:collapsed'])
const isCollapsed = ref(props.collapsed)
watch(() => props.collapsed, (val) => {
isCollapsed.value = val
})
const toggle = () => {
isCollapsed.value = !isCollapsed.value
emit('update:collapsed', isCollapsed.value)
}
</script>
<style scoped>
.receipt-section {
background: rgba(255, 255, 255, 0.6);
border: 1px solid var(--surface-border);
border-radius: 8px;
margin-bottom: 0.75rem;
overflow: hidden;
}
.receipt-section:last-child {
margin-bottom: 0;
}
/* Variants */
.receipt-section.highlight {
background: var(--green-50);
border-color: var(--green-200);
}
.receipt-section.muted {
background: var(--surface-ground);
border-color: var(--surface-border);
}
/* Header */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px dashed var(--surface-border);
background: rgba(0, 0, 0, 0.02);
}
.receipt-section.highlight .section-header {
background: rgba(34, 197, 94, 0.08);
border-bottom-color: var(--green-300);
}
.receipt-section.collapsed .section-header {
border-bottom: none;
}
.receipt-section[class*="toggleable"] .section-header {
cursor: pointer;
}
.section-title {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-color-secondary);
}
.receipt-section.highlight .section-title {
color: var(--green-800);
}
.section-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.toggle-icon {
font-size: 0.7rem;
color: var(--text-color-secondary);
transition: transform 0.2s;
}
/* Content */
.section-content {
padding: 0.75rem;
}
/* Responsive */
@media (max-width: 640px) {
.section-content {
padding: 0.5rem;
}
}
/* Dark mode */
[data-theme="dark"] .receipt-section {
background: rgba(30, 41, 59, 0.6);
}
[data-theme="dark"] .receipt-section.highlight {
background: rgba(34, 197, 94, 0.08);
border-color: var(--green-800);
}
[data-theme="dark"] .section-header {
background: rgba(255, 255, 255, 0.02);
}
[data-theme="dark"] .receipt-section.highlight .section-header {
background: rgba(34, 197, 94, 0.05);
}
</style>

View File

@@ -0,0 +1,384 @@
<template>
<div class="supplier-dual-field">
<div class="dual-grid">
<!-- LEFT: OCR Data (read-only) -->
<div class="ocr-side" :class="{ 'has-data': ocrName || ocrCui }">
<div class="side-header">
<span class="side-label">Din OCR</span>
<OCRConfidenceIndicator
v-if="ocrConfidence"
:confidence="ocrConfidence"
size="small"
/>
</div>
<div class="side-content">
<div class="ocr-name" v-if="ocrName">{{ ocrName }}</div>
<div class="ocr-cui" v-if="ocrCui">CUI: {{ ocrCui }}</div>
<div class="ocr-empty" v-if="!ocrName && !ocrCui">
<i class="pi pi-info-circle"></i>
Procesati o imagine pentru extragere automata
</div>
</div>
</div>
<!-- RIGHT: DB Validated (editable) -->
<div class="db-side">
<div class="side-header">
<span class="side-label">Validat DB</span>
<Button
v-if="!disabled"
icon="pi pi-sync"
size="small"
text
rounded
:loading="syncingSuppliers"
@click="$emit('sync-suppliers')"
v-tooltip.top="'Re-sincronizeaza furnizorii din Oracle'"
class="sync-btn"
/>
</div>
<div class="side-content">
<div class="field-row">
<AutoComplete
v-model="localPartnerName"
:suggestions="filteredPartners"
optionLabel="name"
field="name"
@complete="searchPartners"
@item-select="onPartnerSelect"
placeholder="Cauta furnizor..."
dropdown
:forceSelection="false"
:disabled="disabled"
class="partner-autocomplete"
/>
</div>
<div class="field-row cui-row">
<label>CUI</label>
<InputText
v-model="localCui"
placeholder="RO12345678"
:disabled="disabled"
class="cui-input"
/>
</div>
</div>
<!-- Supplier status badges -->
<div class="supplier-status" v-if="supplierSource || showWarning">
<div v-if="supplierSource" class="status-badge success">
<i class="pi pi-check-circle"></i>
<span>Validat ({{ supplierSource }})</span>
</div>
<div v-if="showWarning" class="status-badge warning">
<i class="pi pi-exclamation-triangle"></i>
<span>Negasit - se va crea automat</span>
<Button
v-if="!disabled"
label="Creaza acum"
icon="pi pi-plus"
size="small"
severity="warning"
text
@click="$emit('create-supplier')"
class="create-btn"
/>
</div>
</div>
</div>
</div>
<!-- Address (collapsed by default) -->
<div class="address-section" v-if="localAddress">
<div class="address-toggle" @click="showAddress = !showAddress">
<i :class="showAddress ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"></i>
<span>{{ showAddress ? 'Ascunde adresa' : localAddress }}</span>
</div>
<div class="address-expanded" v-if="showAddress">
<InputText
v-model="localAddress"
placeholder="Adresa furnizor"
:disabled="disabled"
class="address-input"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import AutoComplete from 'primevue/autocomplete'
import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
import OCRConfidenceIndicator from '@data-entry/components/ocr/OCRConfidenceIndicator.vue'
const props = defineProps({
// OCR data (read-only display)
ocrName: { type: String, default: '' },
ocrCui: { type: String, default: '' },
ocrConfidence: { type: Number, default: null },
// Form values (two-way bound)
partnerName: { type: [String, Object], default: '' },
cui: { type: String, default: '' },
address: { type: String, default: '' },
// Supplier validation
supplierSource: { type: String, default: null }, // 'oracle' | 'local' | null
showWarning: { type: Boolean, default: false },
// Partners list for autocomplete
partners: { type: Array, default: () => [] },
// State
disabled: { type: Boolean, default: false },
syncingSuppliers: { type: Boolean, default: false },
})
const emit = defineEmits([
'update:partnerName',
'update:cui',
'update:address',
'partner-selected',
'sync-suppliers',
'create-supplier',
])
// Local refs with two-way binding
const localPartnerName = computed({
get: () => props.partnerName,
set: (val) => emit('update:partnerName', val)
})
const localCui = computed({
get: () => props.cui,
set: (val) => emit('update:cui', val)
})
const localAddress = computed({
get: () => props.address,
set: (val) => emit('update:address', val)
})
const showAddress = ref(false)
const filteredPartners = ref([])
const searchPartners = (event) => {
const query = event.query.toLowerCase()
filteredPartners.value = props.partners.filter(p =>
p.name.toLowerCase().includes(query) ||
(p.fiscal_code && p.fiscal_code.toLowerCase().includes(query))
)
}
const onPartnerSelect = (event) => {
const partner = event.value
if (partner) {
emit('update:cui', partner.fiscal_code || '')
if (partner.address) {
emit('update:address', partner.address)
}
emit('partner-selected', partner)
}
}
</script>
<style scoped>
.supplier-dual-field {
width: 100%;
}
.dual-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
/* Side styles */
.ocr-side,
.db-side {
padding: 0.75rem;
border-radius: 8px;
background: var(--surface-card);
border: 1px solid var(--surface-border);
}
.ocr-side {
background: var(--surface-ground);
}
.ocr-side.has-data {
background: var(--blue-50);
border-color: var(--blue-200);
}
.side-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
padding-bottom: 0.35rem;
border-bottom: 1px dashed var(--surface-border);
}
.side-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-color-secondary);
}
.sync-btn {
padding: 0.25rem;
width: 1.5rem;
height: 1.5rem;
}
/* OCR side content */
.ocr-name {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-color);
margin-bottom: 0.25rem;
}
.ocr-cui {
font-size: 0.85rem;
color: var(--text-color-secondary);
}
.ocr-empty {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--text-color-secondary);
font-style: italic;
}
.ocr-empty i {
color: var(--blue-400);
}
/* DB side content */
.field-row {
margin-bottom: 0.5rem;
}
.field-row:last-child {
margin-bottom: 0;
}
.cui-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cui-row label {
font-size: 0.8rem;
color: var(--text-color-secondary);
min-width: 30px;
}
.partner-autocomplete {
width: 100%;
}
.cui-input {
flex: 1;
max-width: 140px;
}
/* Supplier status */
.supplier-status {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--surface-border);
}
.status-badge {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
padding: 0.25rem 0;
}
.status-badge.success {
color: var(--green-700);
}
.status-badge.success i {
color: var(--green-500);
}
.status-badge.warning {
color: var(--yellow-800);
flex-wrap: wrap;
}
.status-badge.warning i {
color: var(--yellow-500);
}
.create-btn {
margin-left: auto;
font-size: 0.75rem;
}
/* Address section */
.address-section {
margin-top: 0.5rem;
}
.address-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
font-size: 0.8rem;
color: var(--text-color-secondary);
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.address-toggle:hover {
background: var(--surface-hover);
}
.address-toggle i {
font-size: 0.75rem;
}
.address-toggle span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.address-expanded {
padding: 0.5rem;
}
.address-input {
width: 100%;
font-size: 0.85rem;
}
/* Responsive */
@media (max-width: 768px) {
.dual-grid {
grid-template-columns: 1fr;
}
}
/* Dark mode adjustments */
[data-theme="dark"] .ocr-side.has-data {
background: rgba(59, 130, 246, 0.1);
border-color: var(--blue-700);
}
</style>

View File

@@ -0,0 +1,303 @@
<template>
<div class="tva-row">
<!-- TVA label like TOTAL -->
<span class="row-label">TVA</span>
<div class="tva-fields">
<div
v-for="(entry, index) in visibleEntries"
:key="entry.code"
class="tva-item"
:class="{ active: modelValue[entry.code]?.amount > 0 }"
>
<!-- Percent as InputNumber (not dropdown) -->
<InputNumber
v-model="modelValue[entry.code].percent"
suffix="%"
:min="0"
:max="100"
placeholder="21"
:disabled="disabled"
class="tva-percent"
@input="emitUpdate"
/>
<InputNumber
v-model="modelValue[entry.code].amount"
:minFractionDigits="2"
:maxFractionDigits="2"
:disabled="disabled"
placeholder="0.00"
class="tva-amount"
@input="emitUpdate"
/>
<!-- Remove button (if more than 1 visible) -->
<button
v-if="visibleEntries.length > 1 && !disabled"
type="button"
class="remove-btn"
@click="removeEntry(index)"
>
<i class="pi pi-times"></i>
</button>
</div>
<!-- Add button -->
<button
v-if="canAddMore && !disabled"
type="button"
class="add-btn"
@click="addEntry"
>
<i class="pi pi-plus"></i>
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import InputNumber from 'primevue/inputnumber'
const props = defineProps({
modelValue: { type: Object, required: true },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const ALL_CODES = ['A', 'B', 'C', 'D', 'E']
// Track which TVA codes are visible (default: just 'A')
const visibleCodes = ref(['A'])
// Computed: entries to display
const visibleEntries = computed(() => {
return visibleCodes.value.map(code => ({
code,
...props.modelValue[code]
}))
})
// Computed: can add more rows?
const canAddMore = computed(() => {
return visibleCodes.value.length < ALL_CODES.length
})
// Add next available code
const addEntry = () => {
const nextCode = ALL_CODES.find(code => !visibleCodes.value.includes(code))
if (nextCode) {
visibleCodes.value = [...visibleCodes.value, nextCode]
}
}
// Remove code at index
const removeEntry = (index) => {
if (visibleCodes.value.length > 1) {
const codeToRemove = visibleCodes.value[index]
visibleCodes.value = visibleCodes.value.filter((_, i) => i !== index)
// Reset the removed code's values
if (props.modelValue[codeToRemove]) {
props.modelValue[codeToRemove].percent = null
props.modelValue[codeToRemove].amount = 0
emitUpdate()
}
}
}
// Watch modelValue for OCR changes - auto-expand to show filled values
watch(() => props.modelValue, (newVal) => {
if (!newVal) return
const codesWithValues = ALL_CODES.filter(
code => newVal[code]?.amount > 0
)
if (codesWithValues.length > 0) {
// Ensure all codes with values are visible
const newVisibleCodes = [...new Set([...visibleCodes.value, ...codesWithValues])]
if (newVisibleCodes.length !== visibleCodes.value.length) {
visibleCodes.value = newVisibleCodes
}
}
}, { deep: true, immediate: true })
const emitUpdate = () => {
emit('update:modelValue', { ...props.modelValue })
}
</script>
<style scoped>
/* === TVA ROW - with label and green background === */
.tva-row {
display: flex;
align-items: center;
gap: var(--space-sm);
background: var(--green-100); /* different from TOTAL which uses green-50 */
border-radius: var(--radius-sm);
padding: var(--space-sm) var(--space-md);
margin: var(--space-xs) 0;
}
.row-label {
font-weight: var(--font-semibold);
font-size: var(--text-xs); /* 12px - uniform for labels */
color: var(--text-color);
flex-shrink: 0;
min-width: 40px;
}
.tva-fields {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm, 0.5rem);
align-items: center;
flex: 1;
}
.tva-item {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.tva-item.active .tva-amount :deep(.p-inputnumber-input) {
background: color-mix(in srgb, var(--green-500) 8%, var(--surface-ground));
}
/* TVA percent input (wider to see "21%") */
.tva-percent {
width: 70px; /* wider to show % suffix */
}
.tva-percent :deep(.p-inputnumber-input) {
width: 100%;
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-sm); /* 14px - uniform for inputs */
font-weight: var(--font-normal);
text-align: right;
height: 32px; /* uniform height */
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: var(--radius-sm);
color: var(--text-color);
}
.tva-percent :deep(.p-inputnumber-input:focus) {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px var(--primary-color);
}
/* TVA amount input (wider to see "1234.56") */
.tva-amount {
width: 90px; /* wider */
}
.tva-amount :deep(.p-inputnumber-input) {
width: 100%;
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-sm); /* 14px - uniform for inputs */
font-weight: var(--font-normal);
text-align: right;
height: 32px; /* uniform height */
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: var(--radius-sm);
color: var(--text-color);
}
.tva-amount :deep(.p-inputnumber-input:focus) {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px var(--primary-color);
}
/* Add/Remove buttons - icon only, compact */
.add-btn, .remove-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
border: 1px dashed var(--surface-border);
background: var(--surface-ground);
color: var(--text-color-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.add-btn:hover {
background: var(--primary-color);
border-color: var(--primary-color);
color: white;
border-style: solid;
}
.remove-btn {
border-style: solid;
border-color: transparent;
background: transparent;
}
.remove-btn:hover {
background: var(--red-500);
border-color: var(--red-500);
color: white;
}
.add-btn i, .remove-btn i {
font-size: var(--text-xs);
}
/* Responsive: stack on mobile */
@media (max-width: 480px) {
.tva-row {
flex-direction: column;
align-items: stretch;
gap: var(--space-xs);
}
.row-label {
margin-bottom: var(--space-xs);
}
.tva-fields {
width: 100%;
}
.tva-item {
width: 100%;
justify-content: flex-start;
}
.tva-percent {
flex: 0 0 70px;
}
.tva-amount {
flex: 1;
}
}
</style>
<!-- NON-SCOPED: Force dark mode styling -->
<style>
[data-theme="dark"] .tva-row {
background: rgba(34, 197, 94, 0.15); /* slightly more visible than TOTAL */
}
[data-theme="dark"] .tva-row .p-inputnumber-input {
background: var(--surface-ground) !important;
color: var(--text-color) !important;
border-color: var(--surface-border) !important;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .tva-row {
background: rgba(34, 197, 94, 0.15); /* slightly more visible than TOTAL */
}
:root:not([data-theme="light"]) .tva-row .p-inputnumber-input {
background: var(--surface-ground) !important;
color: var(--text-color) !important;
border-color: var(--surface-border) !important;
}
}
</style>

View File

@@ -0,0 +1,294 @@
<template>
<div class="tva-fixed-fields">
<div class="tva-grid">
<!-- TVA A -->
<div class="tva-field" :class="{ 'has-value': modelValue.A.amount > 0 }">
<div class="tva-header">
<span class="tva-code">A</span>
<Dropdown
v-model="modelValue.A.percent"
:options="percentOptions"
optionLabel="label"
optionValue="value"
:disabled="disabled"
class="percent-dropdown dropdown-borderless"
@change="emitUpdate"
/>
</div>
<InputNumber
v-model="modelValue.A.amount"
:minFractionDigits="2"
:maxFractionDigits="2"
:disabled="disabled"
placeholder="0.00"
class="amount-input"
@input="emitUpdate"
/>
</div>
<!-- TVA B -->
<div class="tva-field" :class="{ 'has-value': modelValue.B.amount > 0 }">
<div class="tva-header">
<span class="tva-code">B</span>
<Dropdown
v-model="modelValue.B.percent"
:options="percentOptions"
optionLabel="label"
optionValue="value"
:disabled="disabled"
class="percent-dropdown dropdown-borderless"
@change="emitUpdate"
/>
</div>
<InputNumber
v-model="modelValue.B.amount"
:minFractionDigits="2"
:maxFractionDigits="2"
:disabled="disabled"
placeholder="0.00"
class="amount-input"
@input="emitUpdate"
/>
</div>
<!-- TVA C -->
<div class="tva-field" :class="{ 'has-value': modelValue.C.amount > 0 }">
<div class="tva-header">
<span class="tva-code">C</span>
<Dropdown
v-model="modelValue.C.percent"
:options="percentOptions"
optionLabel="label"
optionValue="value"
:disabled="disabled"
class="percent-dropdown dropdown-borderless"
@change="emitUpdate"
/>
</div>
<InputNumber
v-model="modelValue.C.amount"
:minFractionDigits="2"
:maxFractionDigits="2"
:disabled="disabled"
placeholder="0.00"
class="amount-input"
@input="emitUpdate"
/>
</div>
<!-- TVA D -->
<div class="tva-field" :class="{ 'has-value': modelValue.D.amount > 0 }">
<div class="tva-header">
<span class="tva-code">D</span>
<Dropdown
v-model="modelValue.D.percent"
:options="percentOptions"
optionLabel="label"
optionValue="value"
:disabled="disabled"
class="percent-dropdown dropdown-borderless"
@change="emitUpdate"
/>
</div>
<InputNumber
v-model="modelValue.D.amount"
:minFractionDigits="2"
:maxFractionDigits="2"
:disabled="disabled"
placeholder="0.00"
class="amount-input"
@input="emitUpdate"
/>
</div>
<!-- TVA E (custom percent) -->
<div class="tva-field" :class="{ 'has-value': modelValue.E.amount > 0 }">
<div class="tva-header">
<span class="tva-code">E</span>
<Dropdown
v-model="modelValue.E.percent"
:options="percentOptionsWithCustom"
optionLabel="label"
optionValue="value"
:disabled="disabled"
class="percent-dropdown dropdown-borderless"
@change="emitUpdate"
/>
</div>
<InputNumber
v-model="modelValue.E.amount"
:minFractionDigits="2"
:maxFractionDigits="2"
:disabled="disabled"
placeholder="0.00"
class="amount-input"
@input="emitUpdate"
/>
</div>
<!-- TOTAL TVA -->
<div class="tva-total">
<span class="total-label">TOTAL TVA</span>
<span class="total-value">{{ formatAmount(computedTotal) }} LEI</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Dropdown from 'primevue/dropdown'
import InputNumber from 'primevue/inputnumber'
const props = defineProps({
modelValue: {
type: Object,
required: true,
// Expected structure:
// { A: { percent: 19, amount: 0 }, B: {...}, C: {...}, D: {...}, E: {...} }
},
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
// Standard VAT percentages in Romania
const percentOptions = [
{ value: 19, label: '19%' },
{ value: 9, label: '9%' },
{ value: 5, label: '5%' },
{ value: 0, label: '0%' },
]
// E field can have null (disabled) or custom values
const percentOptionsWithCustom = [
{ value: null, label: '--' },
...percentOptions,
]
const computedTotal = computed(() => {
const tva = props.modelValue
return (
(tva.A?.amount || 0) +
(tva.B?.amount || 0) +
(tva.C?.amount || 0) +
(tva.D?.amount || 0) +
(tva.E?.amount || 0)
)
})
const formatAmount = (amount) => {
return parseFloat(amount || 0).toLocaleString('ro-RO', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
const emitUpdate = () => {
emit('update:modelValue', { ...props.modelValue })
}
</script>
<style scoped>
.tva-fixed-fields {
width: 100%;
}
.tva-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.tva-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem;
border-radius: 6px;
background: var(--surface-ground);
border: 1px solid var(--surface-border);
transition: all 0.2s;
}
.tva-field.has-value {
background: var(--green-50);
border-color: var(--green-200);
}
.tva-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tva-code {
font-weight: 700;
font-size: 0.85rem;
color: var(--text-color);
min-width: 20px;
}
.percent-dropdown {
flex: 1;
max-width: 70px;
}
.percent-dropdown :deep(.p-dropdown) {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
.amount-input {
width: 100%;
}
.amount-input :deep(.p-inputnumber-input) {
padding: 0.35rem 0.5rem;
font-size: 0.9rem;
text-align: right;
}
/* Total row spans full width */
.tva-total {
grid-column: 1 / -1;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
margin-top: 0.25rem;
border-top: 2px dashed var(--surface-border);
}
.total-label {
font-weight: 600;
font-size: 0.85rem;
color: var(--text-color);
}
.total-value {
font-weight: 700;
font-size: 1rem;
color: var(--text-color);
}
/* Responsive - 3 columns on wider screens */
@media (min-width: 768px) {
.tva-grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* Stack on mobile */
@media (max-width: 480px) {
.tva-grid {
grid-template-columns: 1fr;
}
}
/* Dark mode adjustments */
[data-theme="dark"] .tva-field.has-value {
background: rgba(34, 197, 94, 0.1);
border-color: var(--green-700);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,282 @@
/**
* Conversion utilities for Receipt form data
*
* These functions convert between the API format (dynamic arrays) and
* the UI format (fixed fields for TVA and payments).
*/
/**
* Default fixed TVA structure for UI
*/
export const getDefaultTva = () => ({
A: { percent: 19, amount: 0 },
B: { percent: 9, amount: 0 },
C: { percent: 5, amount: 0 },
D: { percent: 0, amount: 0 },
E: { percent: null, amount: 0 },
})
/**
* Default fixed payments structure for UI
*/
export const getDefaultPayments = () => ({
CARD: 0,
NUMERAR: 0,
ALTE: { amount: 0, type: null },
})
/**
* Convert API tva_entries/tva_breakdown array → UI fixed fields
*
* @param {Array} entries - Array of {code, percent, amount}
* @returns {Object} Fixed TVA fields {A, B, C, D, E}
*/
export const apiToUiTva = (entries) => {
const ui = getDefaultTva()
if (!entries || !Array.isArray(entries)) {
return ui
}
entries.forEach(entry => {
const code = entry.code?.toUpperCase()
if (code && ui[code]) {
ui[code] = {
percent: entry.percent ?? ui[code].percent,
amount: parseFloat(entry.amount) || 0
}
}
})
return ui
}
/**
* Convert UI fixed TVA fields → API tva_breakdown array
* Only includes entries with amount > 0
*
* @param {Object} tvaUi - Fixed TVA fields {A, B, C, D, E}
* @returns {Array} Array of {code, percent, amount} or null if empty
*/
export const uiToApiTva = (tvaUi) => {
if (!tvaUi) return null
const entries = Object.entries(tvaUi)
.filter(([_, v]) => v.amount && v.amount > 0)
.map(([code, v]) => ({
code,
percent: v.percent ?? 0,
amount: parseFloat(v.amount) || 0
}))
return entries.length > 0 ? entries : null
}
/**
* Calculate total TVA from UI fixed fields
*
* @param {Object} tvaUi - Fixed TVA fields {A, B, C, D, E}
* @returns {number} Total TVA amount
*/
export const calculateTvaTotal = (tvaUi) => {
if (!tvaUi) return 0
return Object.values(tvaUi)
.reduce((sum, v) => sum + (parseFloat(v.amount) || 0), 0)
}
/**
* Convert API payment_methods array → UI fixed fields
*
* @param {Array} methods - Array of {method, amount}
* @returns {Object} Fixed payment fields {CARD, NUMERAR, ALTE}
*/
export const apiToUiPayments = (methods) => {
const ui = getDefaultPayments()
if (!methods || !Array.isArray(methods)) {
return ui
}
methods.forEach(pm => {
const method = pm.method?.toUpperCase()
const amount = parseFloat(pm.amount) || 0
if (method === 'CARD') {
ui.CARD = amount
} else if (method === 'NUMERAR' || method === 'CASH') {
ui.NUMERAR = amount
} else if (method) {
// Other payment types go to ALTE
ui.ALTE.amount += amount
// Try to determine type from method name
if (method.includes('TICH') || method.includes('MASA')) {
ui.ALTE.type = 'tichete_masa'
} else if (method.includes('VOUCHER')) {
ui.ALTE.type = 'voucher'
} else if (method.includes('CREDIT')) {
ui.ALTE.type = 'credit_magazin'
}
}
})
return ui
}
/**
* Convert UI fixed payment fields → API payment_methods array
* Only includes entries with amount > 0
*
* @param {Object} paymentsUi - Fixed payment fields {CARD, NUMERAR, ALTE}
* @returns {Array} Array of {method, amount} or null if empty
*/
export const uiToApiPayments = (paymentsUi) => {
if (!paymentsUi) return null
const methods = []
if (paymentsUi.CARD && paymentsUi.CARD > 0) {
methods.push({ method: 'CARD', amount: paymentsUi.CARD })
}
if (paymentsUi.NUMERAR && paymentsUi.NUMERAR > 0) {
methods.push({ method: 'NUMERAR', amount: paymentsUi.NUMERAR })
}
if (paymentsUi.ALTE?.amount && paymentsUi.ALTE.amount > 0) {
// Map type to method name
let methodName = 'ALTE'
if (paymentsUi.ALTE.type === 'tichete_masa') {
methodName = 'TICHETE_MASA'
} else if (paymentsUi.ALTE.type === 'voucher') {
methodName = 'VOUCHER'
} else if (paymentsUi.ALTE.type === 'credit_magazin') {
methodName = 'CREDIT_MAGAZIN'
}
methods.push({ method: methodName, amount: paymentsUi.ALTE.amount })
}
return methods.length > 0 ? methods : null
}
/**
* Calculate total payments from UI fixed fields
*
* @param {Object} paymentsUi - Fixed payment fields {CARD, NUMERAR, ALTE}
* @returns {number} Total payment amount
*/
export const calculatePaymentsTotal = (paymentsUi) => {
if (!paymentsUi) return 0
return (
(paymentsUi.CARD || 0) +
(paymentsUi.NUMERAR || 0) +
(paymentsUi.ALTE?.amount || 0)
)
}
/**
* Get default form state for unified receipt form
*/
export const getDefaultUnifiedFormState = () => ({
receipt_type: 'bon_fiscal',
receipt_date: new Date(),
receipt_number: '',
// Supplier (DB validated)
partner_name: null,
cui: '',
vendor_address: '',
// Total
amount: null,
// TVA (5 fixed fields)
tva: getDefaultTva(),
// Payments (3 fixed fields)
payments: getDefaultPayments(),
// Auxiliary
expense_type_code: null,
description: '',
// Metadata
ocr_raw_text: '',
items_count: null,
company_id: null,
})
/**
* Convert legacy form state to unified form state
*
* @param {Object} legacyForm - Old form format
* @returns {Object} Unified form format
*/
export const legacyToUnifiedForm = (legacyForm) => {
return {
receipt_type: legacyForm.receipt_type || 'bon_fiscal',
receipt_date: legacyForm.receipt_date instanceof Date
? legacyForm.receipt_date
: new Date(legacyForm.receipt_date),
receipt_number: legacyForm.receipt_number || '',
partner_name: legacyForm.partner_name || null,
cui: legacyForm.cui || '',
vendor_address: legacyForm.vendor_address || '',
amount: parseFloat(legacyForm.amount) || null,
tva: apiToUiTva(legacyForm.tva_breakdown),
payments: apiToUiPayments(legacyForm.payment_methods),
expense_type_code: legacyForm.expense_type_code || null,
description: legacyForm.description || '',
ocr_raw_text: legacyForm.ocr_raw_text || '',
items_count: legacyForm.items_count || null,
company_id: legacyForm.company_id || null,
}
}
/**
* Convert unified form state to API payload
*
* @param {Object} unifiedForm - Unified form format
* @returns {Object} API payload format
*/
export const unifiedFormToApiPayload = (unifiedForm) => {
return {
receipt_type: unifiedForm.receipt_type,
direction: 'cheltuiala', // Always expense (no more income)
receipt_date: unifiedForm.receipt_date instanceof Date
? unifiedForm.receipt_date.toISOString().split('T')[0]
: unifiedForm.receipt_date,
receipt_number: unifiedForm.receipt_number || null,
partner_name: typeof unifiedForm.partner_name === 'string'
? unifiedForm.partner_name
: unifiedForm.partner_name?.name || null,
cui: unifiedForm.cui || null,
vendor_address: unifiedForm.vendor_address || null,
amount: unifiedForm.amount || 0,
tva_breakdown: uiToApiTva(unifiedForm.tva),
tva_total: calculateTvaTotal(unifiedForm.tva) || null,
payment_methods: uiToApiPayments(unifiedForm.payments),
expense_type_code: unifiedForm.expense_type_code || null,
description: unifiedForm.description || null,
ocr_raw_text: unifiedForm.ocr_raw_text || null,
items_count: unifiedForm.items_count || null,
company_id: unifiedForm.company_id,
// Legacy fields (removed but kept for backwards compat)
payment_mode: null,
cash_register_id: null,
cash_register_name: null,
cash_register_account: null,
}
}

View File

@@ -0,0 +1,871 @@
<template>
<div class="receipt-unified-view">
<!-- Header -->
<div class="view-header">
<div class="header-left">
<Button
icon="pi pi-arrow-left"
severity="secondary"
text
rounded
@click="goBack"
/>
<h1 class="view-title">
<i :class="modeIcon"></i>
{{ modeTitle }}
</h1>
<Tag
v-if="isViewMode && receipt"
:value="getStatusLabel(receipt.status)"
:severity="getStatusSeverity(receipt.status)"
/>
</div>
<div class="header-right">
<!-- Create/Edit mode actions -->
<template v-if="!isViewMode">
<Button
icon="pi pi-save"
label="Salveaza"
:loading="saving"
@click="saveReceipt"
/>
<Button
v-if="isEditMode && receipt?.status === 'draft'"
icon="pi pi-send"
label="Trimite"
severity="success"
:loading="submitting"
@click="submitForReview"
/>
</template>
<!-- View mode actions -->
<template v-else>
<Button
v-if="receipt?.status === 'draft' || receipt?.status === 'rejected'"
icon="pi pi-pencil"
label="Editeaza"
@click="goToEdit"
/>
<Button
v-if="receipt?.status === 'draft'"
icon="pi pi-send"
label="Trimite"
severity="success"
@click="submitReceipt"
:loading="submitting"
/>
<Button
v-if="receipt?.status === 'pending_review'"
icon="pi pi-check"
label="Valideaza"
severity="success"
@click="approveReceipt"
:loading="approving"
/>
<Button
v-if="receipt?.status === 'pending_review'"
icon="pi pi-times"
label="Respinge"
severity="danger"
@click="openRejectDialog"
/>
</template>
</div>
</div>
<!-- Rejection Alert -->
<Message
v-if="receipt?.rejection_reason"
severity="warn"
:closable="false"
class="rejection-message"
>
<strong>Motiv respingere:</strong> {{ receipt.rejection_reason }}
<br>
<small>Respins de {{ receipt.reviewed_by }} la {{ formatDateTime(receipt.reviewed_at) }}</small>
</Message>
<!-- Unified Form -->
<UnifiedReceiptForm
ref="unifiedFormRef"
v-model="form"
:ocr-data="ocrData"
:partners="partners"
:expense-types="expenseTypes"
:supplier-source="supplierSource"
:supplier-warning="supplierWarning.show"
:syncing-suppliers="syncingSuppliers"
:existing-attachments="existingAttachments"
:selected-files="selectedFiles"
:readonly="isViewMode"
@ocr-result="onOCRResult"
@file-selected="onFileSelected"
@ocr-error="onOCRError"
@partner-selected="onPartnerSelected"
@sync-suppliers="syncSuppliers"
@create-supplier="createLocalSupplierFromWarning"
@add-files="onAddFiles"
@remove-file="onRemoveFile"
@remove-attachment="removeExistingAttachment"
@download-attachment="downloadAttachment"
/>
<!-- Validation Banners -->
<div class="validation-banners" v-if="!isViewMode && validationErrors.length > 0">
<Message
v-for="(error, idx) in validationErrors"
:key="idx"
severity="warn"
:closable="false"
>
{{ error }}
</Message>
</div>
<!-- Reject Dialog -->
<Dialog
v-model:visible="showRejectDialog"
header="Respinge Bon"
:modal="true"
:style="{ width: '450px' }"
>
<div class="dialog-content">
<p>Introduceti motivul respingerii:</p>
<Textarea
v-model="rejectReason"
rows="3"
class="w-full"
placeholder="Motiv respingere (minim 5 caractere)..."
/>
</div>
<template #footer>
<Button
label="Anuleaza"
severity="secondary"
@click="showRejectDialog = false"
/>
<Button
label="Respinge"
icon="pi pi-times"
severity="danger"
@click="rejectReceipt"
:loading="rejecting"
:disabled="!rejectReason || rejectReason.length < 5"
/>
</template>
</Dialog>
<!-- Create Supplier Dialog -->
<Dialog
v-model:visible="showCreateSupplierDialog"
header="Creaza Furnizor Local"
:modal="true"
:style="{ width: '400px' }"
>
<div class="dialog-content">
<p>Furnizorul nu a fost gasit. Creati un furnizor local?</p>
<div class="form-field">
<label>Nume</label>
<InputText v-model="pendingSupplierData.name" class="w-full" />
</div>
<div class="form-field">
<label>CUI</label>
<InputText v-model="pendingSupplierData.fiscal_code" class="w-full" disabled />
</div>
<div class="form-field">
<label>Adresa</label>
<InputText v-model="pendingSupplierData.address" class="w-full" />
</div>
</div>
<template #footer>
<Button label="Anuleaza" severity="secondary" @click="showCreateSupplierDialog = false" />
<Button label="Creaza" icon="pi pi-plus" @click="createLocalSupplier" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import Message from 'primevue/message'
import Dialog from 'primevue/dialog'
import Textarea from 'primevue/textarea'
import InputText from 'primevue/inputtext'
import { useReceiptsStore } from '@data-entry/stores/receiptsStore'
import { useCompanyStore } from '@data-entry/stores/sharedStores'
import UnifiedReceiptForm from '@data-entry/components/receipts/UnifiedReceiptForm.vue'
import {
getDefaultUnifiedFormState,
legacyToUnifiedForm,
unifiedFormToApiPayload,
apiToUiTva,
apiToUiPayments,
} from '@data-entry/utils/receiptConversions'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const store = useReceiptsStore()
const companyStore = useCompanyStore()
// Mode detection
const isViewMode = computed(() => !!route.params.id && !route.path.endsWith('/edit'))
const isEditMode = computed(() => !!route.params.id && route.path.endsWith('/edit'))
const isCreateMode = computed(() => !route.params.id)
const modeTitle = computed(() => {
if (isViewMode.value) return `Bon #${receipt.value?.id || ''}`
if (isEditMode.value) return 'Editare Bon'
return 'Bon Fiscal Nou'
})
const modeIcon = computed(() => {
if (isViewMode.value) return 'pi pi-receipt'
return 'pi pi-plus-circle'
})
// Form state
const form = ref(getDefaultUnifiedFormState())
const receipt = ref(null)
const unifiedFormRef = ref(null)
// OCR state
const ocrData = ref(null)
// Files state
const selectedFiles = ref([])
const existingAttachments = ref([])
// Loading states
const saving = ref(false)
const submitting = ref(false)
const approving = ref(false)
const rejecting = ref(false)
const syncingSuppliers = ref(false)
// Supplier state
const supplierSource = ref(null)
const supplierWarning = ref({ show: false, cui: '', name: '' })
const showCreateSupplierDialog = ref(false)
const pendingSupplierData = ref({ name: '', fiscal_code: '', address: '' })
// Reject dialog
const showRejectDialog = ref(false)
const rejectReason = ref('')
// Computed
const partners = computed(() => store.partners)
const expenseTypes = computed(() => store.expenseTypes)
const validationErrors = computed(() => {
const errors = []
if (!form.value.amount || form.value.amount <= 0) {
errors.push('Suma totala este obligatorie')
}
if (!form.value.receipt_date) {
errors.push('Data este obligatorie')
}
if (selectedFiles.value.length === 0 && existingAttachments.value.length === 0 && isCreateMode.value) {
errors.push('Atasati cel putin o imagine a bonului')
}
return errors
})
// Status helpers
const getStatusLabel = (status) => {
const labels = {
draft: 'Ciorna',
pending_review: 'In Asteptare',
approved: 'Aprobat',
rejected: 'Respins',
}
return labels[status] || status
}
const getStatusSeverity = (status) => {
const severities = {
draft: 'secondary',
pending_review: 'warning',
approved: 'success',
rejected: 'danger',
}
return severities[status] || 'info'
}
// Lifecycle
onMounted(async () => {
// Load nomenclatures
await store.fetchAllNomenclatures()
// Sync suppliers if empty
if (store.partners.length === 0) {
await syncSuppliers()
}
// Load receipt if edit/view mode
if (isEditMode.value || isViewMode.value) {
await loadReceipt()
} else {
// Set company ID for new receipts
form.value.company_id = companyStore.selectedCompanyId || 1
}
})
// Load existing receipt
const loadReceipt = async () => {
try {
receipt.value = await store.fetchReceiptById(route.params.id)
// Convert to unified form format
form.value = {
receipt_type: receipt.value.receipt_type || 'bon_fiscal',
receipt_date: new Date(receipt.value.receipt_date),
receipt_number: receipt.value.receipt_number || '',
partner_name: receipt.value.partner_name || null,
cui: receipt.value.cui || '',
vendor_address: receipt.value.vendor_address || '',
amount: parseFloat(receipt.value.amount) || null,
tva: apiToUiTva(receipt.value.tva_breakdown),
payments: apiToUiPayments(receipt.value.payment_methods),
expense_type_code: receipt.value.expense_type_code || null,
description: receipt.value.description || '',
ocr_raw_text: receipt.value.ocr_raw_text || '',
items_count: receipt.value.items_count || null,
company_id: receipt.value.company_id,
}
existingAttachments.value = receipt.value.attachments || []
// Set supplier source if CUI matches a known supplier
if (form.value.cui) {
const result = await store.searchSupplier(form.value.cui)
if (result.found) {
supplierSource.value = result.source
}
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-a putut incarca bonul',
life: 5000,
})
router.push('/data-entry')
}
}
// OCR handlers
const onOCRResult = async (data) => {
ocrData.value = data
toast.add({
severity: 'success',
summary: 'OCR Procesare',
detail: 'Datele au fost extrase',
life: 3000,
})
// Auto-apply OCR data to form
if (data.receipt_type) form.value.receipt_type = data.receipt_type
if (data.receipt_date) form.value.receipt_date = new Date(data.receipt_date)
if (data.receipt_number) form.value.receipt_number = data.receipt_number
if (data.amount) form.value.amount = parseFloat(data.amount)
if (data.address) form.value.vendor_address = data.address
if (data.raw_text) form.value.ocr_raw_text = data.raw_text
if (data.items_count) form.value.items_count = data.items_count
// Apply TVA
if (data.tva_entries?.length > 0) {
form.value.tva = apiToUiTva(data.tva_entries)
}
// Apply payments
if (data.payment_methods?.length > 0) {
form.value.payments = apiToUiPayments(data.payment_methods)
}
// Search for supplier by CUI
if (data.cui) {
form.value.cui = data.cui
const result = await store.searchSupplier(data.cui)
if (result.found && result.supplier) {
form.value.partner_name = result.supplier.name
form.value.cui = result.supplier.fiscal_code || data.cui
form.value.vendor_address = result.supplier.address || data.address || ''
supplierSource.value = result.source
supplierWarning.value = { show: false, cui: '', name: '' }
toast.add({
severity: 'success',
summary: 'Furnizor gasit',
detail: `${result.supplier.name} (${result.source})`,
life: 3000,
})
} else {
// Supplier not found - show warning
form.value.partner_name = data.partner_name || ''
supplierSource.value = null
supplierWarning.value = {
show: true,
cui: data.cui,
name: data.partner_name || ''
}
}
} else if (data.partner_name) {
form.value.partner_name = data.partner_name
}
}
const onFileSelected = (file) => {
// Add OCR file to selected files
if (!selectedFiles.value.some(f => f.name === file.name)) {
selectedFiles.value = [file, ...selectedFiles.value]
}
}
const onOCRError = (message) => {
toast.add({
severity: 'error',
summary: 'Eroare OCR',
detail: message,
life: 5000,
})
}
const onPartnerSelected = (partner) => {
if (partner) {
supplierSource.value = partner.source || 'local'
supplierWarning.value = { show: false, cui: '', name: '' }
}
}
// File handlers
const onAddFiles = (files) => {
selectedFiles.value = [...selectedFiles.value, ...files]
}
const onRemoveFile = (index) => {
selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index)
}
const removeExistingAttachment = async (attachmentId) => {
try {
await store.deleteAttachment(attachmentId)
existingAttachments.value = existingAttachments.value.filter(a => a.id !== attachmentId)
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Atasamentul a fost sters',
life: 2000,
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-a putut sterge atasamentul',
life: 5000,
})
}
}
const downloadAttachment = async (attachment) => {
try {
await store.downloadAttachment(attachment.id, attachment.filename)
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-a putut descarca fisierul',
life: 5000,
})
}
}
// Supplier handlers
const syncSuppliers = async () => {
syncingSuppliers.value = true
try {
await store.syncSuppliers()
toast.add({
severity: 'success',
summary: 'Sincronizare',
detail: 'Furnizorii au fost actualizati',
life: 2000,
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Sincronizare esuata',
life: 5000,
})
} finally {
syncingSuppliers.value = false
}
}
const createLocalSupplierFromWarning = () => {
pendingSupplierData.value = {
name: supplierWarning.value.name || form.value.partner_name || '',
fiscal_code: supplierWarning.value.cui || form.value.cui || '',
address: form.value.vendor_address || '',
}
showCreateSupplierDialog.value = true
}
const createLocalSupplier = async () => {
try {
await store.createLocalSupplier(pendingSupplierData.value)
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Furnizor local creat',
life: 2000,
})
showCreateSupplierDialog.value = false
supplierSource.value = 'local'
supplierWarning.value = { show: false, cui: '', name: '' }
// Update form with created supplier
form.value.partner_name = pendingSupplierData.value.name
form.value.cui = pendingSupplierData.value.fiscal_code
form.value.vendor_address = pendingSupplierData.value.address
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-a putut crea furnizorul',
life: 5000,
})
}
}
// Save handlers
const validateForm = () => {
if (!form.value.receipt_date) {
toast.add({
severity: 'error',
summary: 'Validare',
detail: 'Data este obligatorie',
life: 3000,
})
return false
}
if (!form.value.amount || form.value.amount <= 0) {
toast.add({
severity: 'error',
summary: 'Validare',
detail: 'Suma este obligatorie',
life: 3000,
})
return false
}
return true
}
const saveReceipt = async () => {
if (!validateForm()) return
saving.value = true
try {
// Auto-create supplier if warning shown
if (supplierWarning.value.show && form.value.cui) {
try {
await store.createLocalSupplier({
name: form.value.partner_name || `Furnizor ${form.value.cui}`,
fiscal_code: form.value.cui,
address: form.value.vendor_address || null,
})
supplierWarning.value = { show: false, cui: '', name: '' }
supplierSource.value = 'local'
} catch (e) {
console.warn('Auto-create supplier failed:', e)
}
}
// Convert form to API format
const payload = unifiedFormToApiPayload(form.value)
let savedReceipt
if (isEditMode.value) {
savedReceipt = await store.updateReceipt(route.params.id, payload)
} else {
savedReceipt = await store.createReceipt(payload)
}
// Upload new files
for (const file of selectedFiles.value) {
try {
await store.uploadAttachment(savedReceipt.id, file)
} catch (e) {
toast.add({
severity: 'warn',
summary: 'Atentie',
detail: `Nu s-a putut incarca: ${file.name}`,
life: 5000,
})
}
}
toast.add({
severity: 'success',
summary: 'Succes',
detail: isEditMode.value ? 'Bonul a fost actualizat' : 'Bonul a fost creat',
life: 3000,
})
router.push(`/data-entry/${savedReceipt.id}`)
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut salva bonul',
life: 5000,
})
} finally {
saving.value = false
}
}
const submitForReview = async () => {
if (!validateForm()) return
submitting.value = true
try {
await saveReceipt()
const result = await store.submitReceipt(route.params.id)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost trimis spre aprobare',
life: 3000,
})
router.push('/data-entry')
} else {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: result.message,
life: 5000,
})
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut trimite bonul',
life: 5000,
})
} finally {
submitting.value = false
}
}
const submitReceipt = async () => {
submitting.value = true
try {
const result = await store.submitReceipt(route.params.id)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost trimis',
life: 3000,
})
await loadReceipt()
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message,
life: 5000,
})
} finally {
submitting.value = false
}
}
const approveReceipt = async () => {
approving.value = true
try {
const result = await store.approveReceipt(route.params.id)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost aprobat',
life: 3000,
})
await loadReceipt()
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message,
life: 5000,
})
} finally {
approving.value = false
}
}
const openRejectDialog = () => {
rejectReason.value = ''
showRejectDialog.value = true
}
const rejectReceipt = async () => {
rejecting.value = true
try {
const result = await store.rejectReceipt(route.params.id, rejectReason.value)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost respins',
life: 3000,
})
showRejectDialog.value = false
await loadReceipt()
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message,
life: 5000,
})
} finally {
rejecting.value = false
}
}
// Navigation
const goBack = () => {
router.push('/data-entry')
}
const goToEdit = () => {
router.push(`/data-entry/${route.params.id}/edit`)
}
const formatDateTime = (dateStr) => {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString('ro-RO')
}
</script>
<style scoped>
.receipt-unified-view {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
}
/* Header */
.view-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
background: var(--surface-card);
border-radius: 8px;
border: 1px solid var(--surface-border);
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.view-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-right {
display: flex;
gap: 0.5rem;
}
/* Messages */
.rejection-message {
margin-bottom: 1rem;
}
/* Validation */
.validation-banners {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Dialog */
.dialog-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-field label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-color-secondary);
}
/* Responsive */
@media (max-width: 640px) {
.receipt-unified-view {
padding: 0.5rem;
}
.view-header {
flex-direction: column;
gap: 0.75rem;
align-items: stretch;
}
.header-left {
justify-content: flex-start;
}
.header-right {
justify-content: flex-end;
flex-wrap: wrap;
}
}
/* Dark mode */
[data-theme="dark"] .view-header {
background: var(--surface-card);
}
</style>

View File

@@ -70,19 +70,25 @@ const routes = [
{
path: 'create',
name: 'ReceiptCreate',
component: () => import('@data-entry/views/receipts/ReceiptCreateView.vue'),
component: () => import('@data-entry/views/receipts/ReceiptCreateUnifiedView.vue'),
meta: { requiresAuth: true, title: 'Bon Nou - ROA2WEB' }
},
{
path: 'create-old',
name: 'ReceiptCreateOld',
component: () => import('@data-entry/views/receipts/ReceiptCreateView.vue'),
meta: { requiresAuth: true, title: 'Bon Nou (Legacy) - ROA2WEB' }
},
{
path: ':id',
name: 'ReceiptDetail',
component: () => import('@data-entry/views/receipts/ReceiptCreateView.vue'),
component: () => import('@data-entry/views/receipts/ReceiptCreateUnifiedView.vue'),
meta: { requiresAuth: true, title: 'Detalii Bon - ROA2WEB' }
},
{
path: ':id/edit',
name: 'ReceiptEdit',
component: () => import('@data-entry/views/receipts/ReceiptCreateView.vue'),
component: () => import('@data-entry/views/receipts/ReceiptCreateUnifiedView.vue'),
meta: { requiresAuth: true, title: 'Editare Bon - ROA2WEB' }
},
{