feat: Enhance billing frontend with Jinja2 templates and improve invoice handling
- Updated billing frontend views to use Jinja2 templates for rendering HTML pages. - Added support for displaying supplier invoices, template builder, and templates list with titles. - Introduced a new configuration setting for company CVR number. - Enhanced OllamaService to support credit notes in invoice extraction, including detailed JSON output format. - Improved PDF text extraction using pdfplumber for better layout handling. - Added a modal for editing vendor details with comprehensive fields and validation. - Implemented invoice loading and display functionality in vendor detail view. - Updated vendor management to remove priority handling and improve error messaging. - Added tests for AI analyze endpoint and CVR filtering to ensure correct behavior. - Created migration script to support credit notes in the database schema.
This commit is contained in:
parent
dcb4d8a280
commit
18b0fe9c05
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -3,25 +3,36 @@ Billing Frontend Views
|
|||||||
Serves HTML pages for billing features
|
Serves HTML pages for billing features
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="app")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/billing/supplier-invoices")
|
@router.get("/billing/supplier-invoices", response_class=HTMLResponse)
|
||||||
async def supplier_invoices_page():
|
async def supplier_invoices_page(request: Request):
|
||||||
"""Supplier invoices (kassekladde) page"""
|
"""Supplier invoices (kassekladde) page"""
|
||||||
return FileResponse("app/billing/frontend/supplier_invoices.html")
|
return templates.TemplateResponse("billing/frontend/supplier_invoices.html", {
|
||||||
|
"request": request,
|
||||||
|
"title": "Kassekladde"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/billing/template-builder")
|
@router.get("/billing/template-builder", response_class=HTMLResponse)
|
||||||
async def template_builder_page():
|
async def template_builder_page(request: Request):
|
||||||
"""Template builder for supplier invoice extraction"""
|
"""Template builder for supplier invoice extraction"""
|
||||||
return FileResponse("app/billing/frontend/template_builder.html")
|
return templates.TemplateResponse("billing/frontend/template_builder.html", {
|
||||||
|
"request": request,
|
||||||
|
"title": "Template Builder"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/billing/templates")
|
@router.get("/billing/templates", response_class=HTMLResponse)
|
||||||
async def templates_list_page():
|
async def templates_list_page(request: Request):
|
||||||
"""Templates list and management page"""
|
"""Templates list and management page"""
|
||||||
return FileResponse("app/billing/frontend/templates_list.html")
|
return templates.TemplateResponse("billing/frontend/templates_list.html", {
|
||||||
|
"request": request,
|
||||||
|
"title": "Templates"
|
||||||
|
})
|
||||||
|
|||||||
@ -37,6 +37,9 @@ class Settings(BaseSettings):
|
|||||||
OLLAMA_ENDPOINT: str = "http://ai_direct.cs.blaahund.dk"
|
OLLAMA_ENDPOINT: str = "http://ai_direct.cs.blaahund.dk"
|
||||||
OLLAMA_MODEL: str = "qwen2.5:3b" # Hurtigere model til JSON extraction
|
OLLAMA_MODEL: str = "qwen2.5:3b" # Hurtigere model til JSON extraction
|
||||||
|
|
||||||
|
# Company Info
|
||||||
|
OWN_CVR: str = "29522790" # BMC Denmark ApS - ignore when detecting vendors
|
||||||
|
|
||||||
# File Upload
|
# File Upload
|
||||||
UPLOAD_DIR: str = "uploads"
|
UPLOAD_DIR: str = "uploads"
|
||||||
MAX_FILE_SIZE_MB: int = 50
|
MAX_FILE_SIZE_MB: int = 50
|
||||||
|
|||||||
@ -27,37 +27,52 @@ class OllamaService:
|
|||||||
|
|
||||||
def _build_system_prompt(self) -> str:
|
def _build_system_prompt(self) -> str:
|
||||||
"""Build Danish system prompt for invoice extraction with CVR"""
|
"""Build Danish system prompt for invoice extraction with CVR"""
|
||||||
return """Du er en ekspert i at læse og udtrække strukturerede data fra danske fakturaer og leverandørdokumenter.
|
return """Du er en ekspert i at læse og udtrække strukturerede data fra danske fakturaer, kreditnotaer og leverandørdokumenter.
|
||||||
|
|
||||||
VIGTIGE REGLER:
|
VIGTIGE REGLER:
|
||||||
1. Returner KUN gyldig JSON - ingen forklaring eller ekstra tekst
|
1. Returner KUN gyldig JSON - ingen forklaring eller ekstra tekst
|
||||||
2. Hvis et felt ikke findes, sæt det til null
|
2. Hvis et felt ikke findes, sæt det til null
|
||||||
3. Beregn confidence baseret på hvor sikker du er på hvert felt (0.0-1.0)
|
3. Beregn confidence baseret på hvor sikker du er på hvert felt (0.0-1.0)
|
||||||
4. Datoer skal være i format YYYY-MM-DD
|
4. Datoer skal være i format YYYY-MM-DD
|
||||||
5. Tal skal være decimaler (brug . som decimalseparator)
|
5. DANSKE PRISFORMATER:
|
||||||
|
- Tusind-separator kan være . (punkt) eller mellemrum: "5.965,18" eller "5 965,18"
|
||||||
|
- Decimal-separator er , (komma): "1.234,56 kr"
|
||||||
|
- I JSON output skal du bruge . (punkt) som decimal: 1234.56
|
||||||
|
- Eksempel: "5.965,18 kr" → 5965.18 i JSON
|
||||||
|
- Eksempel: "1.234,56 DKK" → 1234.56 i JSON
|
||||||
6. CVR-nummer skal være 8 cifre uden mellemrum
|
6. CVR-nummer skal være 8 cifre uden mellemrum
|
||||||
7. Moms/VAT skal udtrækkes fra hver linje hvis muligt
|
7. Moms/VAT skal udtrækkes fra hver linje hvis muligt
|
||||||
|
8. DOKUMENTTYPE DETEKTION:
|
||||||
|
- "invoice" = Almindelig faktura
|
||||||
|
- "credit_note" = Kreditnota (refusion, tilbagebetaling, korrektion)
|
||||||
|
- Kig efter ord som: "Kreditnota", "Credit Note", "Refusion", "Tilbagebetaling", "Godtgørelse"
|
||||||
|
9. BELØB OG FORTEGN (ABSOLUT KRITISK):
|
||||||
|
- **ALMINDELIGE FAKTURAER**: Alle beløb skal være POSITIVE tal (total_amount > 0, line_total > 0)
|
||||||
|
- **KREDITNOTAER**: Alle beløb skal være NEGATIVE tal (total_amount < 0, line_total < 0)
|
||||||
|
- Hvis dokumentet siger "Faktura" → document_type: "invoice" → POSITIVE beløb
|
||||||
|
- Hvis dokumentet siger "Kreditnota" → document_type: "credit_note" → NEGATIVE beløb
|
||||||
|
|
||||||
JSON format skal være:
|
JSON format skal være:
|
||||||
{
|
{
|
||||||
"document_type": "invoice",
|
"document_type": "invoice" eller "credit_note",
|
||||||
"invoice_number": "fakturanummer",
|
"invoice_number": "fakturanummer eller kreditnota nummer",
|
||||||
"vendor_name": "leverandør firmanavn",
|
"vendor_name": "leverandør firmanavn",
|
||||||
"vendor_cvr": "12345678",
|
"vendor_cvr": "12345678",
|
||||||
"invoice_date": "YYYY-MM-DD",
|
"invoice_date": "YYYY-MM-DD",
|
||||||
"due_date": "YYYY-MM-DD",
|
"due_date": "YYYY-MM-DD",
|
||||||
"currency": "DKK",
|
"currency": "DKK",
|
||||||
"total_amount": 1234.56,
|
"total_amount": 1234.56 (NEGATIVT for kreditnotaer),
|
||||||
"vat_amount": 123.45,
|
"vat_amount": 123.45 (NEGATIVT for kreditnotaer),
|
||||||
|
"original_invoice_reference": "reference til original faktura (kun for kreditnotaer)",
|
||||||
"lines": [
|
"lines": [
|
||||||
{
|
{
|
||||||
"line_number": 1,
|
"line_number": 1,
|
||||||
"description": "beskrivelse af varen/ydelsen",
|
"description": "beskrivelse af varen/ydelsen",
|
||||||
"quantity": antal_som_tal,
|
"quantity": antal_som_tal,
|
||||||
"unit_price": pris_per_stk,
|
"unit_price": pris_per_stk (NEGATIVT for kreditnotaer),
|
||||||
"line_total": total_for_linjen,
|
"line_total": total_for_linjen (NEGATIVT for kreditnotaer),
|
||||||
"vat_rate": 25.00,
|
"vat_rate": 25.00,
|
||||||
"vat_amount": moms_beløb,
|
"vat_amount": moms_beløb (NEGATIVT for kreditnotaer),
|
||||||
"confidence": 0.0_til_1.0
|
"confidence": 0.0_til_1.0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -65,24 +80,48 @@ JSON format skal være:
|
|||||||
"raw_text_snippet": "første 200 tegn fra dokumentet"
|
"raw_text_snippet": "første 200 tegn fra dokumentet"
|
||||||
}
|
}
|
||||||
|
|
||||||
EKSEMPEL:
|
EKSEMPEL PÅ FAKTURA (POSITIVE BELØB):
|
||||||
Input: "FAKTURA 2025-001\\nGlobalConnect A/S\\nCVR: 12345678\\n1 stk Fiber 100/100 Mbit @ 299,00 DKK\\nMoms (25%): 74,75 DKK\\nTotal: 373,75 DKK"
|
Input: "FAKTURA 2025-001\\nGlobalConnect A/S\\nCVR: 12345678\\n1 stk iPhone 16 @ 5.965,18 DKK\\nMoms (25%): 1.491,30 DKK\\nTotal: 7.456,48 DKK"
|
||||||
|
|
||||||
Output: {
|
Output: {
|
||||||
"document_type": "invoice",
|
"document_type": "invoice",
|
||||||
"invoice_number": "2025-001",
|
"invoice_number": "2025-001",
|
||||||
"vendor_name": "GlobalConnect A/S",
|
"vendor_name": "GlobalConnect A/S",
|
||||||
"vendor_cvr": "12345678",
|
"vendor_cvr": "12345678",
|
||||||
"total_amount": 373.75,
|
"total_amount": 7456.48,
|
||||||
"vat_amount": 74.75,
|
"vat_amount": 1491.30,
|
||||||
"lines": [{
|
"lines": [{
|
||||||
"line_number": 1,
|
"line_number": 1,
|
||||||
"description": "Fiber 100/100 Mbit",
|
"description": "iPhone 16",
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"unit_price": 299.00,
|
"unit_price": 5965.18,
|
||||||
"line_total": 299.00,
|
"line_total": 5965.18,
|
||||||
"vat_rate": 25.00,
|
"vat_rate": 25.00,
|
||||||
"vat_amount": 74.75,
|
"vat_amount": 1491.30,
|
||||||
|
"confidence": 0.95
|
||||||
|
}],
|
||||||
|
"confidence": 0.95
|
||||||
|
}
|
||||||
|
|
||||||
|
EKSEMPEL PÅ KREDITNOTA (NEGATIVE BELØB):
|
||||||
|
Input: "KREDITNOTA CN-2025-042\\nGlobalConnect A/S\\nCVR: 12345678\\nReference: Faktura 2025-001\\nTilbagebetaling:\\n1 stk iPhone 16 returneret @ -5.965,18 DKK\\nMoms (25%): -1.491,30 DKK\\nTotal: -7.456,48 DKK"
|
||||||
|
|
||||||
|
Output: {
|
||||||
|
"document_type": "credit_note",
|
||||||
|
"invoice_number": "CN-2025-042",
|
||||||
|
"vendor_name": "GlobalConnect A/S",
|
||||||
|
"vendor_cvr": "12345678",
|
||||||
|
"original_invoice_reference": "2025-001",
|
||||||
|
"total_amount": -7456.48,
|
||||||
|
"vat_amount": -1491.30,
|
||||||
|
"lines": [{
|
||||||
|
"line_number": 1,
|
||||||
|
"description": "iPhone 16 returneret",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit_price": -5965.18,
|
||||||
|
"line_total": -5965.18,
|
||||||
|
"vat_rate": 25.00,
|
||||||
|
"vat_amount": -1491.30,
|
||||||
"confidence": 0.95
|
"confidence": 0.95
|
||||||
}],
|
}],
|
||||||
"confidence": 0.95
|
"confidence": 0.95
|
||||||
@ -99,11 +138,8 @@ Output: {
|
|||||||
Extracted data as dict with CVR, invoice number, amounts, etc.
|
Extracted data as dict with CVR, invoice number, amounts, etc.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Truncate text if too long (keep first 4000 chars)
|
# No truncation - send full text to AI
|
||||||
if len(text) > 4000:
|
prompt = f"{self.system_prompt}\n\nNU SKAL DU UDTRÆKKE DATA FRA DENNE FAKTURA:\n{text}\n\nReturner kun gyldig JSON:"
|
||||||
text = text[:4000] + "\\n[... tekst afkortet ...]"
|
|
||||||
|
|
||||||
prompt = f"{self.system_prompt}\\n\\nNU SKAL DU UDTRÆKKE DATA FRA DENNE FAKTURA:\\n{text}\\n\\nReturner kun gyldig JSON:"
|
|
||||||
|
|
||||||
logger.info(f"🤖 Extracting invoice data from text (length: {len(text)})")
|
logger.info(f"🤖 Extracting invoice data from text (length: {len(text)})")
|
||||||
|
|
||||||
@ -136,6 +172,48 @@ Output: {
|
|||||||
# Parse JSON from response
|
# Parse JSON from response
|
||||||
extraction = self._parse_json_response(raw_response)
|
extraction = self._parse_json_response(raw_response)
|
||||||
|
|
||||||
|
# CRITICAL: Fix amount signs based on document_type
|
||||||
|
# LLM sometimes returns negative amounts for invoices - fix this!
|
||||||
|
document_type = extraction.get('document_type', 'invoice')
|
||||||
|
|
||||||
|
if document_type == 'invoice':
|
||||||
|
# Normal invoices should have POSITIVE amounts
|
||||||
|
if extraction.get('total_amount') and extraction['total_amount'] < 0:
|
||||||
|
logger.warning(f"⚠️ Fixing negative total_amount for invoice: {extraction['total_amount']} → {abs(extraction['total_amount'])}")
|
||||||
|
extraction['total_amount'] = abs(extraction['total_amount'])
|
||||||
|
|
||||||
|
if extraction.get('vat_amount') and extraction['vat_amount'] < 0:
|
||||||
|
extraction['vat_amount'] = abs(extraction['vat_amount'])
|
||||||
|
|
||||||
|
# Fix line totals
|
||||||
|
if 'lines' in extraction:
|
||||||
|
for line in extraction['lines']:
|
||||||
|
if line.get('unit_price') and line['unit_price'] < 0:
|
||||||
|
line['unit_price'] = abs(line['unit_price'])
|
||||||
|
if line.get('line_total') and line['line_total'] < 0:
|
||||||
|
line['line_total'] = abs(line['line_total'])
|
||||||
|
if line.get('vat_amount') and line['vat_amount'] < 0:
|
||||||
|
line['vat_amount'] = abs(line['vat_amount'])
|
||||||
|
|
||||||
|
elif document_type == 'credit_note':
|
||||||
|
# Credit notes should have NEGATIVE amounts
|
||||||
|
if extraction.get('total_amount') and extraction['total_amount'] > 0:
|
||||||
|
logger.warning(f"⚠️ Fixing positive total_amount for credit_note: {extraction['total_amount']} → {-abs(extraction['total_amount'])}")
|
||||||
|
extraction['total_amount'] = -abs(extraction['total_amount'])
|
||||||
|
|
||||||
|
if extraction.get('vat_amount') and extraction['vat_amount'] > 0:
|
||||||
|
extraction['vat_amount'] = -abs(extraction['vat_amount'])
|
||||||
|
|
||||||
|
# Fix line totals
|
||||||
|
if 'lines' in extraction:
|
||||||
|
for line in extraction['lines']:
|
||||||
|
if line.get('unit_price') and line['unit_price'] > 0:
|
||||||
|
line['unit_price'] = -abs(line['unit_price'])
|
||||||
|
if line.get('line_total') and line['line_total'] > 0:
|
||||||
|
line['line_total'] = -abs(line['line_total'])
|
||||||
|
if line.get('vat_amount') and line['vat_amount'] > 0:
|
||||||
|
line['vat_amount'] = -abs(line['vat_amount'])
|
||||||
|
|
||||||
# Add raw response for debugging
|
# Add raw response for debugging
|
||||||
extraction['_raw_llm_response'] = raw_response
|
extraction['_raw_llm_response'] = raw_response
|
||||||
|
|
||||||
@ -237,18 +315,22 @@ Output: {
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
async def _extract_text_from_pdf(self, file_path: Path) -> str:
|
async def _extract_text_from_pdf(self, file_path: Path) -> str:
|
||||||
"""Extract text from PDF using PyPDF2"""
|
"""Extract text from PDF using pdfplumber (better table/layout support)"""
|
||||||
try:
|
try:
|
||||||
from PyPDF2 import PdfReader
|
import pdfplumber
|
||||||
|
|
||||||
reader = PdfReader(file_path)
|
all_text = []
|
||||||
text = ""
|
with pdfplumber.open(file_path) as pdf:
|
||||||
|
for page_num, page in enumerate(pdf.pages):
|
||||||
|
# Strategy: Use regular text extraction (includes tables)
|
||||||
|
# pdfplumber's extract_text() handles tables better than PyPDF2
|
||||||
|
page_text = page.extract_text(layout=True, x_tolerance=2, y_tolerance=2)
|
||||||
|
|
||||||
for page_num, page in enumerate(reader.pages):
|
if page_text:
|
||||||
page_text = page.extract_text()
|
all_text.append(page_text)
|
||||||
text += f"\\n--- Side {page_num + 1} ---\\n{page_text}"
|
|
||||||
|
|
||||||
logger.info(f"📄 Extracted {len(text)} chars from PDF with {len(reader.pages)} pages")
|
text = "\\n".join(all_text)
|
||||||
|
logger.info(f"📄 Extracted {len(text)} chars from PDF with pdfplumber")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -237,3 +237,29 @@ async def reset_user_password(user_id: int, new_password: str):
|
|||||||
|
|
||||||
logger.info(f"✅ Reset password for user: {user_id}")
|
logger.info(f"✅ Reset password for user: {user_id}")
|
||||||
return {"message": "Password reset successfully"}
|
return {"message": "Password reset successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
# AI Prompts Endpoint
|
||||||
|
@router.get("/ai-prompts", tags=["Settings"])
|
||||||
|
async def get_ai_prompts():
|
||||||
|
"""Get all AI prompts used in the system"""
|
||||||
|
from app.services.ollama_service import OllamaService
|
||||||
|
|
||||||
|
ollama_service = OllamaService()
|
||||||
|
|
||||||
|
prompts = {
|
||||||
|
"invoice_extraction": {
|
||||||
|
"name": "Faktura Udtrækning (Invoice Extraction)",
|
||||||
|
"description": "System prompt brugt til at udtrække data fra fakturaer og kreditnotaer via Ollama LLM",
|
||||||
|
"model": ollama_service.model,
|
||||||
|
"endpoint": ollama_service.endpoint,
|
||||||
|
"prompt": ollama_service._build_system_prompt(),
|
||||||
|
"parameters": {
|
||||||
|
"temperature": 0.1,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"num_predict": 2000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prompts
|
||||||
|
|||||||
@ -89,6 +89,9 @@
|
|||||||
<a class="nav-link" href="#users" data-tab="users">
|
<a class="nav-link" href="#users" data-tab="users">
|
||||||
<i class="bi bi-people me-2"></i>Brugere
|
<i class="bi bi-people me-2"></i>Brugere
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-link" href="#ai-prompts" data-tab="ai-prompts">
|
||||||
|
<i class="bi bi-robot me-2"></i>AI Prompts
|
||||||
|
</a>
|
||||||
<a class="nav-link" href="#system" data-tab="system">
|
<a class="nav-link" href="#system" data-tab="system">
|
||||||
<i class="bi bi-gear me-2"></i>System
|
<i class="bi bi-gear me-2"></i>System
|
||||||
</a>
|
</a>
|
||||||
@ -177,6 +180,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Prompts -->
|
||||||
|
<div class="tab-pane fade" id="ai-prompts">
|
||||||
|
<div class="card p-4">
|
||||||
|
<h5 class="mb-4 fw-bold">
|
||||||
|
<i class="bi bi-robot me-2"></i>AI System Prompts
|
||||||
|
</h5>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Her kan du se de prompts der bruges til forskellige AI funktioner i systemet.
|
||||||
|
</p>
|
||||||
|
<div id="aiPromptsContent">
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- System Settings -->
|
<!-- System Settings -->
|
||||||
<div class="tab-pane fade" id="system">
|
<div class="tab-pane fade" id="system">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
@ -459,6 +479,76 @@ function getInitials(name) {
|
|||||||
return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase();
|
return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load AI Prompts
|
||||||
|
async function loadAIPrompts() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/ai-prompts');
|
||||||
|
const prompts = await response.json();
|
||||||
|
|
||||||
|
const container = document.getElementById('aiPromptsContent');
|
||||||
|
container.innerHTML = Object.entries(prompts).map(([key, prompt]) => `
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1 fw-bold">${escapeHtml(prompt.name)}</h6>
|
||||||
|
<small class="text-muted">${escapeHtml(prompt.description)}</small>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="copyPrompt('${key}')">
|
||||||
|
<i class="bi bi-clipboard me-1"></i>Kopier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<small class="text-muted">Model:</small>
|
||||||
|
<div><code>${escapeHtml(prompt.model)}</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<small class="text-muted">Endpoint:</small>
|
||||||
|
<div><code>${escapeHtml(prompt.endpoint)}</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<small class="text-muted">Parametre:</small>
|
||||||
|
<div><code>${JSON.stringify(prompt.parameters)}</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small class="text-muted fw-bold d-block mb-2">System Prompt:</small>
|
||||||
|
<pre id="prompt_${key}" class="border rounded p-3 bg-light" style="max-height: 400px; overflow-y: auto; font-size: 0.85rem; white-space: pre-wrap;">${escapeHtml(prompt.prompt)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading AI prompts:', error);
|
||||||
|
document.getElementById('aiPromptsContent').innerHTML =
|
||||||
|
'<div class="alert alert-danger">Kunne ikke indlæse AI prompts</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPrompt(key) {
|
||||||
|
const promptElement = document.getElementById(`prompt_${key}`);
|
||||||
|
const text = promptElement.textContent;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
// Show success feedback
|
||||||
|
const btn = event.target.closest('button');
|
||||||
|
const originalHtml = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i class="bi bi-check me-1"></i>Kopieret!';
|
||||||
|
btn.classList.remove('btn-outline-primary');
|
||||||
|
btn.classList.add('btn-success');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = originalHtml;
|
||||||
|
btn.classList.remove('btn-success');
|
||||||
|
btn.classList.add('btn-outline-primary');
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
@ -495,6 +585,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
|||||||
// Load data for tab
|
// Load data for tab
|
||||||
if (tab === 'users') {
|
if (tab === 'users') {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
} else if (tab === 'ai-prompts') {
|
||||||
|
loadAIPrompts();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -451,7 +451,8 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global Search Modal (Cmd+K)
|
// Global Search Modal (Cmd+K) - Initialize after DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
|
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
|
||||||
const searchInput = document.getElementById('globalSearchInput');
|
const searchInput = document.getElementById('globalSearchInput');
|
||||||
|
|
||||||
@ -459,6 +460,7 @@
|
|||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
console.log('Cmd+K pressed - opening search modal'); // Debug
|
||||||
searchModal.show();
|
searchModal.show();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
searchInput.focus();
|
searchInput.focus();
|
||||||
@ -473,6 +475,19 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset search when modal is closed
|
||||||
|
document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => {
|
||||||
|
searchInput.value = '';
|
||||||
|
selectedEntity = null;
|
||||||
|
document.getElementById('emptyState').style.display = 'block';
|
||||||
|
document.getElementById('workflowActions').style.display = 'none';
|
||||||
|
document.getElementById('crmResults').style.display = 'none';
|
||||||
|
document.getElementById('supportResults').style.display = 'none';
|
||||||
|
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
||||||
|
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Load live statistics for the three boxes
|
// Load live statistics for the three boxes
|
||||||
async function loadLiveStats() {
|
async function loadLiveStats() {
|
||||||
try {
|
try {
|
||||||
@ -742,18 +757,6 @@
|
|||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset search when modal is closed
|
|
||||||
document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => {
|
|
||||||
searchInput.value = '';
|
|
||||||
selectedEntity = null;
|
|
||||||
document.getElementById('emptyState').style.display = 'block';
|
|
||||||
document.getElementById('workflowActions').style.display = 'none';
|
|
||||||
document.getElementById('crmResults').style.display = 'none';
|
|
||||||
document.getElementById('supportResults').style.display = 'none';
|
|
||||||
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
|
||||||
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
294
app/vendors/frontend/vendor_detail.html
vendored
294
app/vendors/frontend/vendor_detail.html
vendored
@ -196,8 +196,31 @@
|
|||||||
<!-- Fakturaer Tab -->
|
<!-- Fakturaer Tab -->
|
||||||
<div class="tab-pane fade" id="fakturaer">
|
<div class="tab-pane fade" id="fakturaer">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<h5 class="mb-4 fw-bold">Leverandør Fakturaer</h5>
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<p class="text-muted">Faktura oversigt kommer snart...</p>
|
<h5 class="mb-0 fw-bold">Leverandør Fakturaer</h5>
|
||||||
|
<span class="badge bg-primary" id="invoiceCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Fakturanr.</th>
|
||||||
|
<th>Dato</th>
|
||||||
|
<th>Forfald</th>
|
||||||
|
<th>Beløb</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Handling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="invoicesTableBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -213,6 +236,96 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Vendor Modal -->
|
||||||
|
<div class="modal fade" id="editVendorModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-pencil me-2"></i>Rediger Leverandør</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="editVendorForm">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label">Navn *</label>
|
||||||
|
<input type="text" class="form-control" id="editName" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">CVR-nummer</label>
|
||||||
|
<input type="text" class="form-control" id="editCvr" maxlength="8" pattern="[0-9]{8}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Kategori</label>
|
||||||
|
<select class="form-select" id="editCategory">
|
||||||
|
<option value="IT & Software">IT & Software</option>
|
||||||
|
<option value="Telefoni & Internet">Telefoni & Internet</option>
|
||||||
|
<option value="Hardware">Hardware</option>
|
||||||
|
<option value="Cloud Services">Cloud Services</option>
|
||||||
|
<option value="Hosting">Hosting</option>
|
||||||
|
<option value="Security">Security</option>
|
||||||
|
<option value="Consulting">Consulting</option>
|
||||||
|
<option value="Andet">Andet</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Domæne</label>
|
||||||
|
<input type="text" class="form-control" id="editDomain" placeholder="example.com">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" id="editEmail">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Telefon</label>
|
||||||
|
<input type="tel" class="form-control" id="editPhone">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label">Adresse</label>
|
||||||
|
<input type="text" class="form-control" id="editAddress">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Postnr. & By</label>
|
||||||
|
<input type="text" class="form-control" id="editCity">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">e-conomic Leverandør Nr.</label>
|
||||||
|
<input type="text" class="form-control" id="editEconomicNumber">
|
||||||
|
<div class="form-text">Til integration med e-conomic</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Noter</label>
|
||||||
|
<textarea class="form-control" id="editNotes" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="editIsActive">
|
||||||
|
<label class="form-check-label" for="editIsActive">Aktiv leverandør</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveVendor()">
|
||||||
|
<i class="bi bi-save me-2"></i>Gem Ændringer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
@ -261,14 +374,6 @@ function displayVendor(vendor) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-row">
|
|
||||||
<div class="info-label">Prioritet</div>
|
|
||||||
<div class="info-value">
|
|
||||||
<div class="priority-indicator ${getPriorityClass(vendor.priority)}">
|
|
||||||
${vendor.priority}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${vendor.economic_supplier_number ? `
|
${vendor.economic_supplier_number ? `
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<div class="info-label">e-conomic Leverandør Nr.</div>
|
<div class="info-label">e-conomic Leverandør Nr.</div>
|
||||||
@ -351,6 +456,102 @@ function displayVendor(vendor) {
|
|||||||
<div class="info-value"><code>#${vendor.id}</code></div>
|
<div class="info-value"><code>#${vendor.id}</code></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Load invoices
|
||||||
|
loadVendorInvoices();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVendorInvoices() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/supplier-invoices?vendor_id=${vendorId}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load invoices');
|
||||||
|
|
||||||
|
const invoices = await response.json();
|
||||||
|
displayInvoices(invoices);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading invoices:', error);
|
||||||
|
document.getElementById('invoicesTableBody').innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center text-muted py-4">
|
||||||
|
Kunne ikke indlæse fakturaer
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayInvoices(invoices) {
|
||||||
|
const tbody = document.getElementById('invoicesTableBody');
|
||||||
|
const count = document.getElementById('invoiceCount');
|
||||||
|
|
||||||
|
count.textContent = invoices.length;
|
||||||
|
|
||||||
|
if (invoices.length === 0) {
|
||||||
|
tbody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center text-muted py-4">
|
||||||
|
Ingen fakturaer fundet for denne leverandør
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = invoices.map(invoice => {
|
||||||
|
const statusClass = getInvoiceStatusClass(invoice.status);
|
||||||
|
const statusText = getInvoiceStatusText(invoice.status);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(invoice.invoice_number)}</strong></td>
|
||||||
|
<td>${formatDateShort(invoice.invoice_date)}</td>
|
||||||
|
<td>${formatDateShort(invoice.due_date)}</td>
|
||||||
|
<td><strong>${formatCurrency(invoice.total_amount, invoice.currency)}</strong></td>
|
||||||
|
<td><span class="badge ${statusClass}">${statusText}</span></td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="/billing/supplier-invoices?invoice=${invoice.id}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInvoiceStatusClass(status) {
|
||||||
|
const classes = {
|
||||||
|
'unpaid': 'bg-warning text-dark',
|
||||||
|
'paid': 'bg-success',
|
||||||
|
'overdue': 'bg-danger',
|
||||||
|
'cancelled': 'bg-secondary',
|
||||||
|
'pending': 'bg-info'
|
||||||
|
};
|
||||||
|
return classes[status] || 'bg-secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInvoiceStatusText(status) {
|
||||||
|
const texts = {
|
||||||
|
'unpaid': 'Ubetalt',
|
||||||
|
'paid': 'Betalt',
|
||||||
|
'overdue': 'Forfalden',
|
||||||
|
'cancelled': 'Annulleret',
|
||||||
|
'pending': 'Afventer'
|
||||||
|
};
|
||||||
|
return texts[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateShort(dateString) {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount, currency = 'DKK') {
|
||||||
|
if (!amount) return '-';
|
||||||
|
return new Intl.NumberFormat('da-DK', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency
|
||||||
|
}).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCategoryIcon(category) {
|
function getCategoryIcon(category) {
|
||||||
@ -376,6 +577,74 @@ function getInitials(name) {
|
|||||||
return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase();
|
return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function editVendor() {
|
||||||
|
// Get current vendor data and populate form
|
||||||
|
fetch(`/api/v1/vendors/${vendorId}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(vendor => {
|
||||||
|
document.getElementById('editName').value = vendor.name || '';
|
||||||
|
document.getElementById('editCvr').value = vendor.cvr_number || '';
|
||||||
|
document.getElementById('editCategory').value = vendor.category || 'Andet';
|
||||||
|
document.getElementById('editDomain').value = vendor.domain || '';
|
||||||
|
document.getElementById('editEmail').value = vendor.email || '';
|
||||||
|
document.getElementById('editPhone').value = vendor.phone || '';
|
||||||
|
document.getElementById('editAddress').value = vendor.address || '';
|
||||||
|
document.getElementById('editCity').value = vendor.city || '';
|
||||||
|
document.getElementById('editEconomicNumber').value = vendor.economic_supplier_number || '';
|
||||||
|
document.getElementById('editNotes').value = vendor.notes || '';
|
||||||
|
document.getElementById('editIsActive').checked = vendor.is_active;
|
||||||
|
|
||||||
|
new bootstrap.Modal(document.getElementById('editVendorModal')).show();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading vendor for edit:', error);
|
||||||
|
alert('Kunne ikke hente leverandør data');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveVendor() {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
name: document.getElementById('editName').value.trim(),
|
||||||
|
cvr_number: document.getElementById('editCvr').value.trim() || null,
|
||||||
|
category: document.getElementById('editCategory').value,
|
||||||
|
domain: document.getElementById('editDomain').value.trim() || null,
|
||||||
|
email: document.getElementById('editEmail').value.trim() || null,
|
||||||
|
phone: document.getElementById('editPhone').value.trim() || null,
|
||||||
|
address: document.getElementById('editAddress').value.trim() || null,
|
||||||
|
city: document.getElementById('editCity').value.trim() || null,
|
||||||
|
economic_supplier_number: document.getElementById('editEconomicNumber').value.trim() || null,
|
||||||
|
notes: document.getElementById('editNotes').value.trim() || null,
|
||||||
|
is_active: document.getElementById('editIsActive').checked
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.name) {
|
||||||
|
alert('Navn er påkrævet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/vendors/${vendorId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Kunne ikke gemme ændringer');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal and reload vendor
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('editVendorModal')).hide();
|
||||||
|
await loadVendor();
|
||||||
|
alert('✅ Leverandør opdateret!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving vendor:', error);
|
||||||
|
alert('Fejl: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
@ -393,11 +662,6 @@ function formatDate(dateString) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function editVendor() {
|
|
||||||
// TODO: Implement edit modal
|
|
||||||
alert('Edit funktion kommer snart!');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab navigation
|
// Tab navigation
|
||||||
document.querySelectorAll('.vertical-nav .nav-link').forEach(link => {
|
document.querySelectorAll('.vertical-nav .nav-link').forEach(link => {
|
||||||
link.addEventListener('click', (e) => {
|
link.addEventListener('click', (e) => {
|
||||||
|
|||||||
37
app/vendors/frontend/vendors.html
vendored
37
app/vendors/frontend/vendors.html
vendored
@ -95,14 +95,13 @@
|
|||||||
<th>Kontakt Info</th>
|
<th>Kontakt Info</th>
|
||||||
<th>CVR</th>
|
<th>CVR</th>
|
||||||
<th>Kategori</th>
|
<th>Kategori</th>
|
||||||
<th>Prioritet</th>
|
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th class="text-end">Handlinger</th>
|
<th class="text-end">Handlinger</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="vendorsTableBody">
|
<tbody id="vendorsTableBody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="text-center py-5">
|
<td colspan="6" class="text-center py-5">
|
||||||
<div class="spinner-border text-primary" role="status">
|
<div class="spinner-border text-primary" role="status">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<span class="visually-hidden">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
@ -186,10 +185,6 @@
|
|||||||
<option value="hosting">Hosting</option>
|
<option value="hosting">Hosting</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-12">
|
|
||||||
<label class="form-label">Prioritet (1-100)</label>
|
|
||||||
<input type="number" class="form-control" id="priority" value="50" min="1" max="100">
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label">Noter</label>
|
<label class="form-label">Noter</label>
|
||||||
<textarea class="form-control" id="notes" rows="3"></textarea>
|
<textarea class="form-control" id="notes" rows="3"></textarea>
|
||||||
@ -231,17 +226,25 @@ async function loadVendors() {
|
|||||||
params.append('category', currentFilter);
|
params.append('category', currentFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('🔄 Loading vendors from:', `/api/v1/vendors?${params}`);
|
||||||
const response = await fetch(`/api/v1/vendors?${params}`);
|
const response = await fetch(`/api/v1/vendors?${params}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
const vendors = await response.json();
|
const vendors = await response.json();
|
||||||
|
console.log('✅ Loaded vendors:', vendors.length);
|
||||||
|
|
||||||
displayVendors(vendors);
|
displayVendors(vendors);
|
||||||
updatePagination(vendors.length);
|
updatePagination(vendors.length);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading vendors:', error);
|
console.error('❌ Error loading vendors:', error);
|
||||||
document.getElementById('vendorsTableBody').innerHTML = `
|
document.getElementById('vendorsTableBody').innerHTML = `
|
||||||
<tr><td colspan="7" class="text-center text-danger py-5">
|
<tr><td colspan="7" class="text-center text-danger py-5">
|
||||||
<i class="bi bi-exclamation-triangle fs-2 d-block mb-2"></i>
|
<i class="bi bi-exclamation-triangle fs-2 d-block mb-2"></i>
|
||||||
Kunne ikke indlæse leverandører
|
<strong>Kunne ikke indlæse leverandører</strong><br>
|
||||||
|
<small class="text-muted">${error.message}</small>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -282,11 +285,6 @@ function displayVendors(vendors) {
|
|||||||
${getCategoryIcon(vendor.category)} ${escapeHtml(vendor.category)}
|
${getCategoryIcon(vendor.category)} ${escapeHtml(vendor.category)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<div class="priority-badge ${getPriorityClass(vendor.priority)}">
|
|
||||||
${vendor.priority}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
<span class="badge ${vendor.is_active ? 'bg-success' : 'bg-secondary'}">
|
<span class="badge ${vendor.is_active ? 'bg-success' : 'bg-secondary'}">
|
||||||
${vendor.is_active ? 'Aktiv' : 'Inaktiv'}
|
${vendor.is_active ? 'Aktiv' : 'Inaktiv'}
|
||||||
@ -313,13 +311,6 @@ function getCategoryIcon(category) {
|
|||||||
return icons[category] || '📦';
|
return icons[category] || '📦';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPriorityClass(priority) {
|
|
||||||
if (priority >= 80) return 'bg-danger text-white';
|
|
||||||
if (priority >= 60) return 'bg-warning';
|
|
||||||
if (priority >= 40) return 'bg-info';
|
|
||||||
return 'bg-secondary text-white';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitials(name) {
|
function getInitials(name) {
|
||||||
return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase();
|
return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
@ -407,10 +398,10 @@ async function createVendor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
let searchTimeout;
|
let vendorSearchTimeout;
|
||||||
document.getElementById('searchInput').addEventListener('input', (e) => {
|
document.getElementById('searchInput').addEventListener('input', (e) => {
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(vendorSearchTimeout);
|
||||||
searchTimeout = setTimeout(() => {
|
vendorSearchTimeout = setTimeout(() => {
|
||||||
searchTerm = e.target.value;
|
searchTerm = e.target.value;
|
||||||
currentPage = 0;
|
currentPage = 0;
|
||||||
loadVendors();
|
loadVendors();
|
||||||
|
|||||||
19
migrations/008_credit_notes.sql
Normal file
19
migrations/008_credit_notes.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
-- Migration 008: Add support for credit notes
|
||||||
|
-- Add invoice_type column to distinguish between invoices and credit notes
|
||||||
|
|
||||||
|
ALTER TABLE supplier_invoices
|
||||||
|
ADD COLUMN IF NOT EXISTS invoice_type VARCHAR(20) DEFAULT 'invoice' CHECK (invoice_type IN ('invoice', 'credit_note'));
|
||||||
|
|
||||||
|
-- Update existing records to be 'invoice' type
|
||||||
|
UPDATE supplier_invoices SET invoice_type = 'invoice' WHERE invoice_type IS NULL;
|
||||||
|
|
||||||
|
-- Add index for filtering by type
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_type ON supplier_invoices(invoice_type);
|
||||||
|
|
||||||
|
-- Add document_type to extractions table
|
||||||
|
ALTER TABLE extractions
|
||||||
|
ADD COLUMN IF NOT EXISTS document_type_detected VARCHAR(20) CHECK (document_type_detected IN ('invoice', 'credit_note', 'receipt', 'other'));
|
||||||
|
|
||||||
|
-- Update system prompt context
|
||||||
|
COMMENT ON COLUMN supplier_invoices.invoice_type IS 'Type of document: invoice or credit_note';
|
||||||
|
COMMENT ON COLUMN extractions.document_type_detected IS 'AI-detected document type from extraction';
|
||||||
@ -12,5 +12,6 @@ aiohttp==3.10.10
|
|||||||
# AI & Document Processing
|
# AI & Document Processing
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
PyPDF2==3.0.1
|
PyPDF2==3.0.1
|
||||||
|
pdfplumber==0.11.4
|
||||||
pytesseract==0.3.13
|
pytesseract==0.3.13
|
||||||
Pillow==11.0.0
|
Pillow==11.0.0
|
||||||
|
|||||||
48
test_ai_analyze.py
Normal file
48
test_ai_analyze.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test AI analyze endpoint med CVR filter"""
|
||||||
|
import requests
|
||||||
|
import pdfplumber
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Extract PDF text
|
||||||
|
pdf_path = Path("uploads/5082481.pdf")
|
||||||
|
all_text = []
|
||||||
|
with pdfplumber.open(pdf_path) as pdf:
|
||||||
|
for page in pdf.pages:
|
||||||
|
page_text = page.extract_text(layout=True, x_tolerance=2, y_tolerance=2)
|
||||||
|
if page_text:
|
||||||
|
all_text.append(page_text)
|
||||||
|
|
||||||
|
full_text = "\n".join(all_text)
|
||||||
|
|
||||||
|
# Call AI analyze endpoint
|
||||||
|
print("🧪 Testing AI analyze endpoint...")
|
||||||
|
response = requests.post(
|
||||||
|
'http://localhost:8000/api/v1/supplier-invoices/ai/analyze',
|
||||||
|
json={'pdf_text': full_text[:2000]}, # First 2000 chars as in the actual code
|
||||||
|
headers={'Content-Type': 'application/json'}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Status: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
print(f"\n✅ AI Analysis Result:")
|
||||||
|
print(f" CVR: {result.get('cvr')}")
|
||||||
|
print(f" Invoice Number: {result.get('invoice_number')}")
|
||||||
|
print(f" Date: {result.get('invoice_date')}")
|
||||||
|
print(f" Total: {result.get('total_amount')}")
|
||||||
|
print(f" Detection Patterns: {result.get('detection_patterns')}")
|
||||||
|
|
||||||
|
# Check CVR filter
|
||||||
|
found_cvr = result.get('cvr')
|
||||||
|
OWN_CVR = "44687369"
|
||||||
|
if found_cvr == OWN_CVR:
|
||||||
|
print(f"\n❌ FAIL: AI returned OWN_CVR {OWN_CVR} - filter didn't work!")
|
||||||
|
elif found_cvr == "29522790":
|
||||||
|
print(f"\n✅ PASS: AI found correct vendor CVR {found_cvr} (DCS ApS)")
|
||||||
|
else:
|
||||||
|
print(f"\n⚠️ WARNING: AI found CVR {found_cvr} - unexpected value")
|
||||||
|
else:
|
||||||
|
print(f"❌ Error: {response.text}")
|
||||||
46
test_cvr_filter.py
Normal file
46
test_cvr_filter.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test CVR filter i AI analyze"""
|
||||||
|
import pdfplumber
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Extract PDF text
|
||||||
|
pdf_path = Path("/app/uploads/5082481.pdf")
|
||||||
|
all_text = []
|
||||||
|
with pdfplumber.open(pdf_path) as pdf:
|
||||||
|
for page in pdf.pages:
|
||||||
|
page_text = page.extract_text(layout=True, x_tolerance=2, y_tolerance=2)
|
||||||
|
if page_text:
|
||||||
|
all_text.append(page_text)
|
||||||
|
|
||||||
|
full_text = "\n".join(all_text)
|
||||||
|
|
||||||
|
print("=== PDF TEXT (first 1500 chars) ===")
|
||||||
|
print(full_text[:1500])
|
||||||
|
print("\n")
|
||||||
|
|
||||||
|
# Find CVR numbers (support both CVR and DK prefix)
|
||||||
|
cvr_pattern = r'(?:CVR|Momsnr\.?|DK)[:\s-]*(\d{8})'
|
||||||
|
cvr_matches = re.findall(cvr_pattern, full_text)
|
||||||
|
print(f"=== Found CVR numbers: {cvr_matches}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Check for OWN_CVR
|
||||||
|
OWN_CVR = "44687369"
|
||||||
|
if OWN_CVR in cvr_matches:
|
||||||
|
print(f"⚠️ WARNING: Found OWN_CVR {OWN_CVR} - should be filtered!")
|
||||||
|
else:
|
||||||
|
print(f"✅ OWN_CVR {OWN_CVR} not in CVR list (good)")
|
||||||
|
|
||||||
|
# Check if we found DCS CVR
|
||||||
|
DCS_CVR = "29522790"
|
||||||
|
if DCS_CVR in cvr_matches:
|
||||||
|
print(f"✅ Found vendor CVR {DCS_CVR} (DCS ApS) - correct!")
|
||||||
|
else:
|
||||||
|
print(f"❌ Did NOT find vendor CVR {DCS_CVR}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== Expected behavior ===")
|
||||||
|
print("AI should find CVR 29522790 (DCS ApS)")
|
||||||
|
print(f"AI should IGNORE CVR {OWN_CVR} (BMC Denmark)")
|
||||||
Loading…
Reference in New Issue
Block a user