feat(email): add functionality to send emails with attachments from case tab
This commit is contained in:
parent
1d7107bff0
commit
695854a272
38
.github/skills/gui-starter/SKILL.md
vendored
Normal file
38
.github/skills/gui-starter/SKILL.md
vendored
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: gui-starter
|
||||||
|
description: "Use when building or updating BMC Hub GUI pages, templates, layout, styling, dark mode toggle, responsive Bootstrap 5 UI, or Nordic Top themed frontend components."
|
||||||
|
---
|
||||||
|
|
||||||
|
# BMC Hub GUI Starter
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Use this skill when implementing or refining frontend UI in BMC Hub.
|
||||||
|
|
||||||
|
## Project UI Rules
|
||||||
|
- Follow the Nordic Top style from `docs/design_reference/`.
|
||||||
|
- Keep a minimalist, clean layout with card-based sections.
|
||||||
|
- Use Deep Blue as default primary accent: `#0f4c75`.
|
||||||
|
- Support dark mode with a visible toggle.
|
||||||
|
- Use CSS variables so accent colors can be changed dynamically.
|
||||||
|
- Build mobile-first with Bootstrap 5 grid utilities.
|
||||||
|
|
||||||
|
## Preferred Workflow
|
||||||
|
1. Identify existing template/page and preserve established structure when present.
|
||||||
|
2. Define or update theme tokens as CSS variables (light + dark).
|
||||||
|
3. Implement responsive layout first, then enhance desktop spacing/typography.
|
||||||
|
4. Add or maintain dark mode toggle logic (persist preference in localStorage when relevant).
|
||||||
|
5. Reuse patterns from `docs/design_reference/components.html`, `docs/design_reference/index.html`, `docs/design_reference/customers.html`, and `docs/design_reference/form.html`.
|
||||||
|
6. Validate visual consistency and avoid introducing one-off styles unless necessary.
|
||||||
|
|
||||||
|
## Implementation Guardrails
|
||||||
|
- Do not hardcode colors repeatedly; map them to CSS variables.
|
||||||
|
- Do not remove dark mode support from existing pages.
|
||||||
|
- Do not break existing navigation/topbar behavior.
|
||||||
|
- Avoid large framework changes unless explicitly requested.
|
||||||
|
- Keep accessibility basics in place: color contrast, visible focus states, semantic HTML.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
When using this skill, provide:
|
||||||
|
- Updated frontend files (HTML/CSS/JS) with concise, intentional styling.
|
||||||
|
- A short summary of what changed and why.
|
||||||
|
- Notes about any remaining UI tradeoffs or follow-up refinements.
|
||||||
@ -105,6 +105,7 @@ class Settings(BaseSettings):
|
|||||||
EMAIL_AI_ENABLED: bool = False
|
EMAIL_AI_ENABLED: bool = False
|
||||||
EMAIL_AUTO_CLASSIFY: bool = True # Enable classification by default (uses keywords if AI disabled)
|
EMAIL_AUTO_CLASSIFY: bool = True # Enable classification by default (uses keywords if AI disabled)
|
||||||
EMAIL_AI_CONFIDENCE_THRESHOLD: float = 0.7
|
EMAIL_AI_CONFIDENCE_THRESHOLD: float = 0.7
|
||||||
|
EMAIL_REQUIRE_MANUAL_APPROVAL: bool = True # Phase 1: human approval before case creation/routing
|
||||||
EMAIL_MAX_FETCH_PER_RUN: int = 50
|
EMAIL_MAX_FETCH_PER_RUN: int = 50
|
||||||
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5
|
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5
|
||||||
EMAIL_WORKFLOWS_ENABLED: bool = True
|
EMAIL_WORKFLOWS_ENABLED: bool = True
|
||||||
|
|||||||
@ -142,10 +142,115 @@ class ProcessingStats(BaseModel):
|
|||||||
fetched: int = 0
|
fetched: int = 0
|
||||||
saved: int = 0
|
saved: int = 0
|
||||||
classified: int = 0
|
classified: int = 0
|
||||||
|
awaiting_user_action: int = 0
|
||||||
rules_matched: int = 0
|
rules_matched: int = 0
|
||||||
errors: int = 0
|
errors: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CreateSagFromEmailRequest(BaseModel):
|
||||||
|
titel: Optional[str] = None
|
||||||
|
beskrivelse: Optional[str] = None
|
||||||
|
customer_id: Optional[int] = None
|
||||||
|
contact_id: Optional[int] = None
|
||||||
|
case_type: str = "support"
|
||||||
|
secondary_label: Optional[str] = None
|
||||||
|
start_date: Optional[date] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
ansvarlig_bruger_id: Optional[int] = None
|
||||||
|
assigned_group_id: Optional[int] = None
|
||||||
|
created_by_user_id: int = 1
|
||||||
|
relation_type: str = "kommentar"
|
||||||
|
|
||||||
|
|
||||||
|
class LinkEmailToSagRequest(BaseModel):
|
||||||
|
sag_id: int
|
||||||
|
relation_type: str = "kommentar"
|
||||||
|
note: Optional[str] = None
|
||||||
|
forfatter: str = "E-mail Motor"
|
||||||
|
mark_processed: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/emails/sag-options")
|
||||||
|
async def get_sag_assignment_options():
|
||||||
|
"""Return users and groups for SAG assignment controls in email UI."""
|
||||||
|
try:
|
||||||
|
users = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT user_id AS id,
|
||||||
|
COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS name
|
||||||
|
FROM users
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY COALESCE(full_name, username, user_id::text)
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
|
||||||
|
groups = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, name
|
||||||
|
FROM groups
|
||||||
|
ORDER BY name
|
||||||
|
"""
|
||||||
|
) or []
|
||||||
|
|
||||||
|
return {"users": users, "groups": groups}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error loading SAG assignment options: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/emails/search-customers")
|
||||||
|
async def search_customers(q: str = Query(..., min_length=1), limit: int = Query(20, ge=1, le=100)):
|
||||||
|
"""Autocomplete customers for email-to-case flow."""
|
||||||
|
try:
|
||||||
|
like = f"%{q.strip()}%"
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id, name, email_domain
|
||||||
|
FROM customers
|
||||||
|
WHERE (
|
||||||
|
name ILIKE %s
|
||||||
|
OR COALESCE(email, '') ILIKE %s
|
||||||
|
OR COALESCE(email_domain, '') ILIKE %s
|
||||||
|
OR COALESCE(cvr_number, '') ILIKE %s
|
||||||
|
)
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(like, like, like, like, limit)
|
||||||
|
)
|
||||||
|
return rows or []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error searching customers from email router: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/emails/search-sager")
|
||||||
|
async def search_sager(q: str = Query(..., min_length=1), limit: int = Query(20, ge=1, le=100)):
|
||||||
|
"""Autocomplete SAG cases for linking emails to existing cases."""
|
||||||
|
try:
|
||||||
|
like = f"%{q.strip()}%"
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT s.id, s.titel, s.status, s.priority, c.name AS customer_name
|
||||||
|
FROM sag_sager s
|
||||||
|
LEFT JOIN customers c ON c.id = s.customer_id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
AND (
|
||||||
|
s.titel ILIKE %s
|
||||||
|
OR COALESCE(s.beskrivelse, '') ILIKE %s
|
||||||
|
OR CAST(s.id AS TEXT) ILIKE %s
|
||||||
|
)
|
||||||
|
ORDER BY s.updated_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(like, like, like, limit)
|
||||||
|
)
|
||||||
|
return rows or []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error searching sager from email router: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# Email Endpoints
|
# Email Endpoints
|
||||||
@router.get("/emails", response_model=List[EmailListItem])
|
@router.get("/emails", response_model=List[EmailListItem])
|
||||||
async def list_emails(
|
async def list_emails(
|
||||||
@ -375,6 +480,201 @@ async def link_email(email_id: int, payload: Dict):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/emails/{email_id}/create-sag")
|
||||||
|
async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailRequest):
|
||||||
|
"""Create a new SAG from an email and persist the email-case relation."""
|
||||||
|
try:
|
||||||
|
email_row = execute_query(
|
||||||
|
"SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL",
|
||||||
|
(email_id,)
|
||||||
|
)
|
||||||
|
if not email_row:
|
||||||
|
raise HTTPException(status_code=404, detail="Email not found")
|
||||||
|
|
||||||
|
email_data = email_row[0]
|
||||||
|
customer_id = payload.customer_id or email_data.get('customer_id')
|
||||||
|
if not customer_id:
|
||||||
|
raise HTTPException(status_code=400, detail="customer_id is required (missing on email and payload)")
|
||||||
|
|
||||||
|
titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip()
|
||||||
|
beskrivelse = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or ''
|
||||||
|
template_key = (payload.case_type or 'support').strip().lower()[:50]
|
||||||
|
priority = (payload.priority or 'normal').strip().lower()
|
||||||
|
|
||||||
|
if priority not in {'low', 'normal', 'high', 'urgent'}:
|
||||||
|
raise HTTPException(status_code=400, detail="priority must be one of: low, normal, high, urgent")
|
||||||
|
|
||||||
|
case_result = execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_sager
|
||||||
|
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, priority, start_date)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id, titel, customer_id, status, template_key, priority, start_date, created_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
titel,
|
||||||
|
beskrivelse,
|
||||||
|
template_key,
|
||||||
|
'åben',
|
||||||
|
customer_id,
|
||||||
|
payload.ansvarlig_bruger_id,
|
||||||
|
payload.assigned_group_id,
|
||||||
|
payload.created_by_user_id,
|
||||||
|
priority,
|
||||||
|
payload.start_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not case_result:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create SAG")
|
||||||
|
|
||||||
|
sag = case_result[0]
|
||||||
|
sag_id = sag['id']
|
||||||
|
|
||||||
|
# Link email to SAG (audit trail)
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_emails (sag_id, email_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT (sag_id, email_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(sag_id, email_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE email_messages
|
||||||
|
SET linked_case_id = %s,
|
||||||
|
status = 'processed',
|
||||||
|
folder = 'Processed',
|
||||||
|
processed_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(sag_id, email_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload.contact_id:
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_kontakter (sag_id, contact_id, role)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""",
|
||||||
|
(sag_id, payload.contact_id, 'primary')
|
||||||
|
)
|
||||||
|
|
||||||
|
relation_type = (payload.relation_type or 'kommentar').strip().lower()
|
||||||
|
if relation_type in {'kommentar', 'intern_note', 'kundeopdatering'}:
|
||||||
|
system_note = (
|
||||||
|
f"E-mail knyttet som {relation_type}.\n"
|
||||||
|
f"Emne: {email_data.get('subject') or '(ingen emne)'}\n"
|
||||||
|
f"Fra: {email_data.get('sender_email') or '(ukendt)'}"
|
||||||
|
)
|
||||||
|
if payload.secondary_label:
|
||||||
|
system_note += f"\nLabel: {payload.secondary_label.strip()[:60]}"
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(sag_id, 'E-mail Motor', system_note, True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"email_id": email_id,
|
||||||
|
"sag": sag,
|
||||||
|
"message": "SAG oprettet fra e-mail"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error creating SAG from email %s: %s", email_id, e)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/emails/{email_id}/link-sag")
|
||||||
|
async def link_email_to_sag(email_id: int, payload: LinkEmailToSagRequest):
|
||||||
|
"""Link an email to an existing SAG and optionally append a system note."""
|
||||||
|
try:
|
||||||
|
email_row = execute_query(
|
||||||
|
"SELECT id, subject, sender_email FROM email_messages WHERE id = %s AND deleted_at IS NULL",
|
||||||
|
(email_id,)
|
||||||
|
)
|
||||||
|
if not email_row:
|
||||||
|
raise HTTPException(status_code=404, detail="Email not found")
|
||||||
|
|
||||||
|
sag_row = execute_query(
|
||||||
|
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||||
|
(payload.sag_id,)
|
||||||
|
)
|
||||||
|
if not sag_row:
|
||||||
|
raise HTTPException(status_code=404, detail="SAG not found")
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_emails (sag_id, email_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT (sag_id, email_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
(payload.sag_id, email_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload.mark_processed:
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE email_messages
|
||||||
|
SET linked_case_id = %s,
|
||||||
|
status = 'processed',
|
||||||
|
folder = 'Processed',
|
||||||
|
processed_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(payload.sag_id, email_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE email_messages
|
||||||
|
SET linked_case_id = %s,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(payload.sag_id, email_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
relation_type = (payload.relation_type or 'kommentar').strip().lower()
|
||||||
|
if relation_type in {'kommentar', 'intern_note', 'kundeopdatering'}:
|
||||||
|
email_data = email_row[0]
|
||||||
|
note = payload.note or (
|
||||||
|
f"E-mail knyttet som {relation_type}. "
|
||||||
|
f"Emne: {email_data.get('subject') or '(ingen emne)'}"
|
||||||
|
)
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(payload.sag_id, payload.forfatter, note, True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"email_id": email_id,
|
||||||
|
"sag_id": payload.sag_id,
|
||||||
|
"message": "E-mail knyttet til SAG"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error linking email %s to SAG %s: %s", email_id, payload.sag_id, e)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/emails/{email_id}/extract-vendor-suggestion")
|
@router.post("/emails/{email_id}/extract-vendor-suggestion")
|
||||||
async def extract_vendor_suggestion(email_id: int):
|
async def extract_vendor_suggestion(email_id: int):
|
||||||
"""
|
"""
|
||||||
@ -1206,6 +1506,7 @@ async def get_processing_stats():
|
|||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total_emails,
|
COUNT(*) as total_emails,
|
||||||
COUNT(*) FILTER (WHERE status = 'new') as new_emails,
|
COUNT(*) FILTER (WHERE status = 'new') as new_emails,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'awaiting_user_action') as awaiting_user_action,
|
||||||
COUNT(*) FILTER (WHERE status = 'processed') as processed_emails,
|
COUNT(*) FILTER (WHERE status = 'processed') as processed_emails,
|
||||||
COUNT(*) FILTER (WHERE status = 'error') as error_emails,
|
COUNT(*) FILTER (WHERE status = 'error') as error_emails,
|
||||||
COUNT(*) FILTER (WHERE has_attachments = true) as with_attachments,
|
COUNT(*) FILTER (WHERE has_attachments = true) as with_attachments,
|
||||||
@ -1225,6 +1526,7 @@ async def get_processing_stats():
|
|||||||
return {
|
return {
|
||||||
"total_emails": 0,
|
"total_emails": 0,
|
||||||
"new_emails": 0,
|
"new_emails": 0,
|
||||||
|
"awaiting_user_action": 0,
|
||||||
"processed_emails": 0,
|
"processed_emails": 0,
|
||||||
"error_emails": 0,
|
"error_emails": 0,
|
||||||
"with_attachments": 0,
|
"with_attachments": 0,
|
||||||
@ -1494,6 +1796,7 @@ async def get_email_stats():
|
|||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total_emails,
|
COUNT(*) as total_emails,
|
||||||
COUNT(CASE WHEN status = 'new' THEN 1 END) as new_emails,
|
COUNT(CASE WHEN status = 'new' THEN 1 END) as new_emails,
|
||||||
|
COUNT(CASE WHEN status = 'awaiting_user_action' THEN 1 END) as awaiting_user_action,
|
||||||
COUNT(CASE WHEN status = 'processed' THEN 1 END) as processed_emails,
|
COUNT(CASE WHEN status = 'processed' THEN 1 END) as processed_emails,
|
||||||
COUNT(CASE WHEN classification = 'invoice' THEN 1 END) as invoices,
|
COUNT(CASE WHEN classification = 'invoice' THEN 1 END) as invoices,
|
||||||
COUNT(CASE WHEN classification = 'time_confirmation' THEN 1 END) as time_confirmations,
|
COUNT(CASE WHEN classification = 'time_confirmation' THEN 1 END) as time_confirmations,
|
||||||
|
|||||||
@ -436,6 +436,45 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.suggestion-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-field label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-field input,
|
||||||
|
.suggestion-field select {
|
||||||
|
border: 1px solid rgba(0,0,0,0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-body);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-field.full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
/* Responsive Design */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
@ -871,6 +910,9 @@
|
|||||||
<button class="filter-pill" data-filter="processed" onclick="setFilter('processed')">
|
<button class="filter-pill" data-filter="processed" onclick="setFilter('processed')">
|
||||||
Behandlet <span class="count" id="countProcessed">0</span>
|
Behandlet <span class="count" id="countProcessed">0</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="filter-pill" data-filter="awaiting_user_action" onclick="setFilter('awaiting_user_action')">
|
||||||
|
Afventer <span class="count" id="countAwaiting">0</span>
|
||||||
|
</button>
|
||||||
<button class="filter-pill" data-filter="case_notification" onclick="setFilter('case_notification')">
|
<button class="filter-pill" data-filter="case_notification" onclick="setFilter('case_notification')">
|
||||||
Sag <span class="count" id="countCase">0</span>
|
Sag <span class="count" id="countCase">0</span>
|
||||||
</button>
|
</button>
|
||||||
@ -1312,6 +1354,7 @@ let emails = [];
|
|||||||
let selectedEmails = new Set();
|
let selectedEmails = new Set();
|
||||||
let emailSearchTimeout = null;
|
let emailSearchTimeout = null;
|
||||||
let autoRefreshInterval = null;
|
let autoRefreshInterval = null;
|
||||||
|
let sagAssignmentOptions = { users: [], groups: [] };
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@ -1322,6 +1365,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadStats();
|
loadStats();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
setupKeyboardShortcuts();
|
setupKeyboardShortcuts();
|
||||||
|
preloadSagAssignmentOptions();
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1431,13 +1475,12 @@ async function loadEmails(searchQuery = '') {
|
|||||||
|
|
||||||
// Handle special filters
|
// Handle special filters
|
||||||
if (currentFilter === 'active') {
|
if (currentFilter === 'active') {
|
||||||
// Show only new, error, or flagged (pending review) emails
|
// Active queue includes both new and awaiting manual handling.
|
||||||
// If searching, ignore status filter to allow global search
|
// We fetch list data and filter client-side because API status filter is single-value.
|
||||||
if (!searchQuery) {
|
|
||||||
url += '&status=new';
|
|
||||||
}
|
|
||||||
} else if (currentFilter === 'processed') {
|
} else if (currentFilter === 'processed') {
|
||||||
url += '&status=processed';
|
url += '&status=processed';
|
||||||
|
} else if (currentFilter === 'awaiting_user_action') {
|
||||||
|
url += '&status=awaiting_user_action';
|
||||||
} else if (currentFilter !== 'all') {
|
} else if (currentFilter !== 'all') {
|
||||||
// Classification filter
|
// Classification filter
|
||||||
url += `&classification=${currentFilter}`;
|
url += `&classification=${currentFilter}`;
|
||||||
@ -1455,6 +1498,11 @@ async function loadEmails(searchQuery = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
emails = await response.json();
|
emails = await response.json();
|
||||||
|
|
||||||
|
if (currentFilter === 'active' && !searchQuery) {
|
||||||
|
emails = emails.filter((email) => ['new', 'awaiting_user_action'].includes(email.status || 'new'));
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Loaded emails:', emails.length, 'items');
|
console.log('Loaded emails:', emails.length, 'items');
|
||||||
|
|
||||||
renderEmailList(emails);
|
renderEmailList(emails);
|
||||||
@ -1757,52 +1805,131 @@ function renderEmailAnalysis(email) {
|
|||||||
console.error('aiAnalysisTab element not found in DOM');
|
console.error('aiAnalysisTab element not found in DOM');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const classification = email.classification || 'general';
|
const classification = email.classification || 'general';
|
||||||
const confidence = email.confidence_score || 0;
|
const confidence = email.confidence_score || 0;
|
||||||
|
const primaryType = suggestPrimaryType(email);
|
||||||
// Opdater kun AI Analysis tab indholdet - ikke hele sidebar
|
const secondaryLabel = suggestSecondaryLabel(email);
|
||||||
aiAnalysisTab.innerHTML = `
|
const selectedCustomerName = email.customer_name || '';
|
||||||
${!email.customer_id && !email.supplier_id ? `
|
|
||||||
<div class="analysis-card border border-warning">
|
|
||||||
<h6 class="text-warning"><i class="bi bi-person-question-fill me-2"></i>Ukendt Afsender</h6>
|
|
||||||
<div class="text-muted small mb-2">
|
|
||||||
<i class="bi bi-envelope me-1"></i>${escapeHtml(email.sender_email || '')}
|
|
||||||
${email.sender_name ? `<br><i class="bi bi-person me-1"></i>${escapeHtml(email.sender_name)}` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="d-flex flex-column gap-2">
|
|
||||||
<button class="btn btn-sm btn-outline-primary w-100"
|
|
||||||
onclick="quickCreateCustomer(${email.id}, '${escapeHtml(email.sender_name || '')}', '${escapeHtml(email.sender_email || '')}')">
|
|
||||||
<i class="bi bi-person-plus me-2"></i>Opret som Kunde
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary w-100"
|
|
||||||
onclick="quickCreateVendor(${email.id}, '${escapeHtml(email.sender_name || '')}', '${escapeHtml(email.sender_email || '')}')">
|
|
||||||
<i class="bi bi-shop me-2"></i>Opret som Leverandør
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
` : `
|
|
||||||
<div class="analysis-card">
|
|
||||||
<h6 class="text-success"><i class="bi bi-person-check-fill me-2"></i>Linket Til</h6>
|
|
||||||
<ul class="metadata-list">
|
|
||||||
${email.customer_id ? `<li class="metadata-item"><div class="metadata-label">Kunde</div><div class="metadata-value">${escapeHtml(email.customer_name || '#' + email.customer_id)}</div></li>` : ''}
|
|
||||||
${email.supplier_id ? `<li class="metadata-item"><div class="metadata-label">Leverandør</div><div class="metadata-value">${escapeHtml(email.supplier_name || '#' + email.supplier_id)}</div></li>` : ''}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
|
|
||||||
${getClassificationActions(email) ? `
|
const userOptions = (sagAssignmentOptions.users || []).map((user) =>
|
||||||
|
`<option value="${user.id}">${escapeHtml(user.name)}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const groupOptions = (sagAssignmentOptions.groups || []).map((group) =>
|
||||||
|
`<option value="${group.id}">${escapeHtml(group.name)}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
aiAnalysisTab.innerHTML = `
|
||||||
<div class="analysis-card">
|
<div class="analysis-card">
|
||||||
<h6><i class="bi bi-lightning-charge-fill me-2"></i>Hurtig Action</h6>
|
<h6><i class="bi bi-stars me-2"></i>System Forslag</h6>
|
||||||
<div class="d-flex flex-column gap-2">
|
<div class="quick-action-row mb-3">
|
||||||
${getClassificationActions(email)}
|
<button class="btn btn-sm btn-primary" onclick="confirmSuggestion()">
|
||||||
|
<i class="bi bi-check2-circle me-1"></i>Bekræft Forslag
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="focusTypeEditor()">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>Ret Type
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="markAsSpam()">
|
||||||
|
<i class="bi bi-slash-circle me-1"></i>Spam
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="suggestion-grid">
|
||||||
|
<div class="suggestion-field">
|
||||||
|
<label for="casePrimaryType">Primær Type</label>
|
||||||
|
<select id="casePrimaryType">
|
||||||
|
<option value="support" ${primaryType === 'support' ? 'selected' : ''}>Support</option>
|
||||||
|
<option value="bogholderi" ${primaryType === 'bogholderi' ? 'selected' : ''}>Bogholderi</option>
|
||||||
|
<option value="leverandor" ${primaryType === 'leverandor' ? 'selected' : ''}>Leverandør</option>
|
||||||
|
<option value="helhedsopgave" ${primaryType === 'helhedsopgave' ? 'selected' : ''}>Helhedsopgave</option>
|
||||||
|
<option value="andet" ${primaryType === 'andet' ? 'selected' : ''}>Andet</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="suggestion-field">
|
||||||
|
<label for="caseSecondaryLabel">Sekundær Label</label>
|
||||||
|
<input id="caseSecondaryLabel" type="text" maxlength="60" value="${escapeHtml(secondaryLabel)}" placeholder="fx Fakturaspørgsmål">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="suggestion-field full">
|
||||||
|
<label for="caseCustomerSearch">Kunde</label>
|
||||||
|
<input id="caseCustomerSearch" list="caseCustomerResults" value="${escapeHtml(selectedCustomerName)}" placeholder="Søg kunde..." oninput="searchCustomersForCurrentEmail(this.value)">
|
||||||
|
<datalist id="caseCustomerResults"></datalist>
|
||||||
|
<input id="caseCustomerId" type="hidden" value="${email.customer_id || ''}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="suggestion-field">
|
||||||
|
<label for="caseAssignee">Ansvarlig Bruger</label>
|
||||||
|
<select id="caseAssignee">
|
||||||
|
<option value="">Ingen bruger</option>
|
||||||
|
${userOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="suggestion-field">
|
||||||
|
<label for="caseGroup">Gruppe</label>
|
||||||
|
<select id="caseGroup">
|
||||||
|
<option value="">Ingen gruppe</option>
|
||||||
|
${groupOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="suggestion-field">
|
||||||
|
<label for="caseStartDate">Startdato</label>
|
||||||
|
<input id="caseStartDate" type="date" value="${todayAsDateString()}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="suggestion-field">
|
||||||
|
<label for="casePriority">Prioritet</label>
|
||||||
|
<select id="casePriority">
|
||||||
|
<option value="low">Lav</option>
|
||||||
|
<option value="normal" selected>Normal</option>
|
||||||
|
<option value="high">Høj</option>
|
||||||
|
<option value="urgent">Akut</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="suggestion-field full">
|
||||||
|
<label for="caseTitle">Titel</label>
|
||||||
|
<input id="caseTitle" type="text" value="${escapeHtml((email.subject || `E-mail fra ${email.sender_email || 'ukendt'}`).slice(0, 250))}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quick-action-row mt-3">
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm()">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Opret Ny Sag
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="toggleLinkExistingPanel()">
|
||||||
|
<i class="bi bi-link-45deg me-1"></i>Tilknyt Eksisterende Sag
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="setPrimaryType('support')">Support</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="setPrimaryType('leverandor')">Leverandør</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="linkExistingPanel" class="mt-3" style="display:none;">
|
||||||
|
<div class="suggestion-field full">
|
||||||
|
<label for="existingSagSearch">Søg Sag</label>
|
||||||
|
<input id="existingSagSearch" list="existingSagResults" placeholder="Søg på titel eller ID..." oninput="searchSagerForCurrentEmail(this.value)">
|
||||||
|
<datalist id="existingSagResults"></datalist>
|
||||||
|
<input id="existingSagId" type="hidden" value="">
|
||||||
|
</div>
|
||||||
|
<div class="suggestion-field full mt-2">
|
||||||
|
<label for="existingSagRelationType">Tilføj mail som</label>
|
||||||
|
<select id="existingSagRelationType">
|
||||||
|
<option value="kommentar">Kommentar</option>
|
||||||
|
<option value="intern_note">Intern note</option>
|
||||||
|
<option value="kundeopdatering">Kundeopdatering</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary mt-2" onclick="linkCurrentEmailToExistingSag()">
|
||||||
|
<i class="bi bi-link me-1"></i>Tilknyt Sag
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
|
||||||
|
|
||||||
<div class="analysis-card">
|
<div class="analysis-card">
|
||||||
<h6><i class="bi bi-robot me-2"></i>AI Klassificering</h6>
|
<h6><i class="bi bi-robot me-2"></i>AI Klassificering</h6>
|
||||||
|
|
||||||
<div class="confidence-meter">
|
<div class="confidence-meter">
|
||||||
<div class="confidence-bar">
|
<div class="confidence-bar">
|
||||||
<div class="confidence-fill" style="width: ${confidence * 100}%"></div>
|
<div class="confidence-fill" style="width: ${confidence * 100}%"></div>
|
||||||
@ -1812,7 +1939,7 @@ function renderEmailAnalysis(email) {
|
|||||||
<span><strong>${Math.round(confidence * 100)}%</strong></span>
|
<span><strong>${Math.round(confidence * 100)}%</strong></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select class="classification-select" id="classificationSelect" onchange="updateClassification()">
|
<select class="classification-select" id="classificationSelect" onchange="updateClassification()">
|
||||||
<option value="invoice" ${classification === 'invoice' ? 'selected' : ''}>📄 Faktura</option>
|
<option value="invoice" ${classification === 'invoice' ? 'selected' : ''}>📄 Faktura</option>
|
||||||
<option value="order_confirmation" ${classification === 'order_confirmation' ? 'selected' : ''}>📦 Ordrebekræftelse</option>
|
<option value="order_confirmation" ${classification === 'order_confirmation' ? 'selected' : ''}>📦 Ordrebekræftelse</option>
|
||||||
@ -1824,52 +1951,193 @@ function renderEmailAnalysis(email) {
|
|||||||
<option value="spam" ${classification === 'spam' ? 'selected' : ''}>🚫 Spam</option>
|
<option value="spam" ${classification === 'spam' ? 'selected' : ''}>🚫 Spam</option>
|
||||||
<option value="general" ${classification === 'general' ? 'selected' : ''}>📧 Generel</option>
|
<option value="general" ${classification === 'general' ? 'selected' : ''}>📧 Generel</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button class="btn btn-sm btn-primary w-100" onclick="saveClassification()">
|
<button class="btn btn-sm btn-primary w-100" onclick="saveClassification()">
|
||||||
<i class="bi bi-check-lg me-2"></i>Gem Klassificering
|
<i class="bi bi-check-lg me-2"></i>Gem Klassificering
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="analysis-card">
|
|
||||||
<h6><i class="bi bi-info-circle me-2"></i>Metadata</h6>
|
|
||||||
<ul class="metadata-list">
|
|
||||||
<li class="metadata-item">
|
|
||||||
<div class="metadata-label">Message ID</div>
|
|
||||||
<div class="metadata-value" style="font-size: 0.7rem; word-break: break-all;">${email.message_id || 'N/A'}</div>
|
|
||||||
</li>
|
|
||||||
<li class="metadata-item">
|
|
||||||
<div class="metadata-label">Modtaget</div>
|
|
||||||
<div class="metadata-value">${formatDateTime(email.received_date)}</div>
|
|
||||||
</li>
|
|
||||||
<li class="metadata-item">
|
|
||||||
<div class="metadata-label">Status</div>
|
|
||||||
<div class="metadata-value">${email.status || 'new'}</div>
|
|
||||||
</li>
|
|
||||||
${email.extracted_invoice_number ? `
|
|
||||||
<li class="metadata-item">
|
|
||||||
<div class="metadata-label">Fakturanummer</div>
|
|
||||||
<div class="metadata-value">${email.extracted_invoice_number}</div>
|
|
||||||
</li>
|
|
||||||
` : ''}
|
|
||||||
${email.extracted_amount ? `
|
|
||||||
<li class="metadata-item">
|
|
||||||
<div class="metadata-label">Beløb</div>
|
|
||||||
<div class="metadata-value">${email.extracted_amount} ${email.extracted_currency || 'DKK'}</div>
|
|
||||||
</li>
|
|
||||||
` : ''}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${email.matched_rules && email.matched_rules.length > 0 ? `
|
|
||||||
<div class="analysis-card">
|
|
||||||
<h6><i class="bi bi-lightning-charge me-2"></i>Matchede Regler</h6>
|
|
||||||
<div class="rules-indicator">
|
|
||||||
<i class="bi bi-check-circle-fill"></i>
|
|
||||||
<span>${email.matched_rules.length} regel(er) matchet</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const statusBadge = document.getElementById('emailActionStatus');
|
||||||
|
if (statusBadge) {
|
||||||
|
statusBadge.textContent = email.status || 'new';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayAsDateString() {
|
||||||
|
return new Date().toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestPrimaryType(email) {
|
||||||
|
const classification = (email.classification || '').toLowerCase();
|
||||||
|
if (classification === 'invoice') return 'bogholderi';
|
||||||
|
if (classification === 'time_confirmation') return 'support';
|
||||||
|
if (classification === 'case_notification') return 'support';
|
||||||
|
if (email.supplier_id) return 'leverandor';
|
||||||
|
return 'support';
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestSecondaryLabel(email) {
|
||||||
|
const classification = (email.classification || '').toLowerCase();
|
||||||
|
const mapping = {
|
||||||
|
invoice: 'Fakturasporgsmal',
|
||||||
|
time_confirmation: 'Tidsbekraftelse',
|
||||||
|
case_notification: 'Sag opdatering',
|
||||||
|
order_confirmation: 'Ordre bekraftelse',
|
||||||
|
freight_note: 'Fragt opdatering',
|
||||||
|
general: 'Generel henvendelse'
|
||||||
|
};
|
||||||
|
return mapping[classification] || 'Kunde henvendelse';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preloadSagAssignmentOptions() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/emails/sag-options');
|
||||||
|
if (!response.ok) return;
|
||||||
|
sagAssignmentOptions = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not preload sag assignment options:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusTypeEditor() {
|
||||||
|
document.getElementById('casePrimaryType')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPrimaryType(value) {
|
||||||
|
const el = document.getElementById('casePrimaryType');
|
||||||
|
if (el) el.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLinkExistingPanel() {
|
||||||
|
const panel = document.getElementById('linkExistingPanel');
|
||||||
|
if (!panel) return;
|
||||||
|
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedIdFromDatalist(inputId, datalistId) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
const datalist = document.getElementById(datalistId);
|
||||||
|
if (!input || !datalist) return null;
|
||||||
|
const option = [...datalist.options].find((opt) => opt.value === input.value);
|
||||||
|
return option ? option.dataset.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchCustomersForCurrentEmail(query) {
|
||||||
|
if (!query || query.length < 2) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/emails/search-customers?q=${encodeURIComponent(query)}`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const customers = await response.json();
|
||||||
|
const datalist = document.getElementById('caseCustomerResults');
|
||||||
|
if (!datalist) return;
|
||||||
|
datalist.innerHTML = customers.map((customer) => {
|
||||||
|
const display = `${customer.name} (#${customer.id})`;
|
||||||
|
return `<option value="${escapeHtml(display)}" data-id="${customer.id}"></option>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Customer search failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchSagerForCurrentEmail(query) {
|
||||||
|
if (!query || query.length < 2) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/emails/search-sager?q=${encodeURIComponent(query)}`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const sager = await response.json();
|
||||||
|
const datalist = document.getElementById('existingSagResults');
|
||||||
|
if (!datalist) return;
|
||||||
|
datalist.innerHTML = sager.map((sag) => {
|
||||||
|
const display = `SAG-${sag.id}: ${sag.titel || '(uden titel)'}`;
|
||||||
|
return `<option value="${escapeHtml(display)}" data-id="${sag.id}"></option>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('SAG search failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmSuggestion() {
|
||||||
|
createCaseFromCurrentForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCaseFormPayload() {
|
||||||
|
const customerIdHidden = document.getElementById('caseCustomerId')?.value;
|
||||||
|
const customerIdFromSearch = getSelectedIdFromDatalist('caseCustomerSearch', 'caseCustomerResults');
|
||||||
|
const resolvedCustomerId = customerIdFromSearch || customerIdHidden || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
titel: document.getElementById('caseTitle')?.value?.trim() || null,
|
||||||
|
customer_id: resolvedCustomerId ? Number(resolvedCustomerId) : null,
|
||||||
|
case_type: document.getElementById('casePrimaryType')?.value || 'support',
|
||||||
|
secondary_label: document.getElementById('caseSecondaryLabel')?.value?.trim() || null,
|
||||||
|
start_date: document.getElementById('caseStartDate')?.value || null,
|
||||||
|
priority: document.getElementById('casePriority')?.value || 'normal',
|
||||||
|
ansvarlig_bruger_id: document.getElementById('caseAssignee')?.value ? Number(document.getElementById('caseAssignee').value) : null,
|
||||||
|
assigned_group_id: document.getElementById('caseGroup')?.value ? Number(document.getElementById('caseGroup').value) : null,
|
||||||
|
relation_type: 'kommentar'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCaseFromCurrentForm() {
|
||||||
|
if (!currentEmailId) return;
|
||||||
|
|
||||||
|
const payload = getCaseFormPayload();
|
||||||
|
if (!payload.customer_id) {
|
||||||
|
showError('Vælg kunde før sag-oprettelse');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/emails/${currentEmailId}/create-sag`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Sag-oprettelse fejlede');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
showSuccess(`SAG-${result.sag.id} oprettet og e-mail linket`);
|
||||||
|
loadEmails();
|
||||||
|
await loadEmailDetail(currentEmailId);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error.message || 'Kunne ikke oprette sag');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkCurrentEmailToExistingSag() {
|
||||||
|
if (!currentEmailId) return;
|
||||||
|
const selectedSagId = getSelectedIdFromDatalist('existingSagSearch', 'existingSagResults') || document.getElementById('existingSagId')?.value;
|
||||||
|
if (!selectedSagId) {
|
||||||
|
showError('Vælg en eksisterende sag først');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/emails/${currentEmailId}/link-sag`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sag_id: Number(selectedSagId),
|
||||||
|
relation_type: document.getElementById('existingSagRelationType')?.value || 'kommentar'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Tilknytning fejlede');
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess(`E-mail knyttet til SAG-${selectedSagId}`);
|
||||||
|
loadEmails();
|
||||||
|
await loadEmailDetail(currentEmailId);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error.message || 'Kunne ikke knytte e-mail til sag');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showEmptyState() {
|
function showEmptyState() {
|
||||||
@ -1914,12 +2182,14 @@ async function loadStats() {
|
|||||||
const response = await fetch('/api/v1/emails/stats/summary');
|
const response = await fetch('/api/v1/emails/stats/summary');
|
||||||
const stats = await response.json();
|
const stats = await response.json();
|
||||||
|
|
||||||
// Calculate active emails (new + error + flagged)
|
const newCount = stats.new_emails || 0;
|
||||||
const activeCount = stats.new_emails || 0;
|
const awaitingCount = stats.awaiting_user_action || 0;
|
||||||
|
const activeCount = newCount + awaitingCount;
|
||||||
|
|
||||||
document.getElementById('countActive').textContent = activeCount;
|
document.getElementById('countActive').textContent = activeCount;
|
||||||
document.getElementById('countAll').textContent = stats.total_emails || 0;
|
document.getElementById('countAll').textContent = stats.total_emails || 0;
|
||||||
document.getElementById('countProcessed').textContent = stats.processed_emails || 0;
|
document.getElementById('countProcessed').textContent = stats.processed_emails || 0;
|
||||||
|
document.getElementById('countAwaiting').textContent = awaitingCount;
|
||||||
document.getElementById('countInvoice').textContent = stats.invoices || 0;
|
document.getElementById('countInvoice').textContent = stats.invoices || 0;
|
||||||
document.getElementById('countOrder').textContent = 0;
|
document.getElementById('countOrder').textContent = 0;
|
||||||
document.getElementById('countFreight').textContent = 0;
|
document.getElementById('countFreight').textContent = 0;
|
||||||
@ -2272,11 +2542,20 @@ async function createTimeEntry(emailId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createCase(emailId) {
|
async function createCase(emailId) {
|
||||||
showError('Sags-modul er ikke implementeret endnu');
|
if (currentEmailId !== emailId) {
|
||||||
|
await selectEmail(emailId);
|
||||||
|
}
|
||||||
|
setPrimaryType('support');
|
||||||
|
focusTypeEditor();
|
||||||
|
showInfo('Sagsforslag klar. Udfyld felter og klik Opret Ny Sag.');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function linkToCustomer(emailId) {
|
async function linkToCustomer(emailId) {
|
||||||
showError('Kunde-linking er ikke implementeret endnu');
|
if (currentEmailId !== emailId) {
|
||||||
|
await selectEmail(emailId);
|
||||||
|
}
|
||||||
|
document.getElementById('caseCustomerSearch')?.focus();
|
||||||
|
showInfo('Søg og vælg kunde i forslagspanelet.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Quick Create Customer ────────────────────────────────────────────────
|
// ─── Quick Create Customer ────────────────────────────────────────────────
|
||||||
@ -2688,6 +2967,10 @@ function formatClassification(classification) {
|
|||||||
function getStatusBadge(email) {
|
function getStatusBadge(email) {
|
||||||
const status = email.status || 'new';
|
const status = email.status || 'new';
|
||||||
const approvalStatus = email.approval_status;
|
const approvalStatus = email.approval_status;
|
||||||
|
|
||||||
|
if (status === 'awaiting_user_action') {
|
||||||
|
return '<span class="badge bg-warning text-dark badge-sm ms-1"><i class="bi bi-person-check me-1"></i>Afventer</span>';
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 'processed' || status === 'archived') {
|
if (status === 'processed' || status === 'archived') {
|
||||||
return '<span class="badge bg-success badge-sm ms-1"><i class="bi bi-check-circle me-1"></i>Behandlet</span>';
|
return '<span class="badge bg-success badge-sm ms-1"><i class="bi bi-check-circle me-1"></i>Behandlet</span>';
|
||||||
|
|||||||
@ -49,6 +49,7 @@ class EmailProcessorService:
|
|||||||
'fetched': 0,
|
'fetched': 0,
|
||||||
'saved': 0,
|
'saved': 0,
|
||||||
'classified': 0,
|
'classified': 0,
|
||||||
|
'awaiting_user_action': 0,
|
||||||
'rules_matched': 0,
|
'rules_matched': 0,
|
||||||
'errors': 0
|
'errors': 0
|
||||||
}
|
}
|
||||||
@ -86,6 +87,8 @@ class EmailProcessorService:
|
|||||||
|
|
||||||
if result.get('classified'):
|
if result.get('classified'):
|
||||||
stats['classified'] += 1
|
stats['classified'] += 1
|
||||||
|
if result.get('awaiting_user_action'):
|
||||||
|
stats['awaiting_user_action'] += 1
|
||||||
if result.get('rules_matched'):
|
if result.get('rules_matched'):
|
||||||
stats['rules_matched'] += 1
|
stats['rules_matched'] += 1
|
||||||
|
|
||||||
@ -109,6 +112,7 @@ class EmailProcessorService:
|
|||||||
email_id = email_data.get('id')
|
email_id = email_data.get('id')
|
||||||
stats = {
|
stats = {
|
||||||
'classified': False,
|
'classified': False,
|
||||||
|
'awaiting_user_action': False,
|
||||||
'workflows_executed': 0,
|
'workflows_executed': 0,
|
||||||
'rules_matched': False
|
'rules_matched': False
|
||||||
}
|
}
|
||||||
@ -123,6 +127,22 @@ class EmailProcessorService:
|
|||||||
if settings.EMAIL_AUTO_CLASSIFY:
|
if settings.EMAIL_AUTO_CLASSIFY:
|
||||||
await self._classify_and_update(email_data)
|
await self._classify_and_update(email_data)
|
||||||
stats['classified'] = True
|
stats['classified'] = True
|
||||||
|
|
||||||
|
# Step 3.5: Gate automation by manual-approval policy and confidence
|
||||||
|
# Phase-1 policy: suggestions are generated automatically, actions are user-approved.
|
||||||
|
classification = (email_data.get('classification') or '').strip().lower()
|
||||||
|
confidence = float(email_data.get('confidence_score') or 0.0)
|
||||||
|
require_manual_approval = getattr(settings, 'EMAIL_REQUIRE_MANUAL_APPROVAL', True)
|
||||||
|
|
||||||
|
if require_manual_approval:
|
||||||
|
await self._set_awaiting_user_action(email_id, reason='manual_approval_required')
|
||||||
|
stats['awaiting_user_action'] = True
|
||||||
|
return stats
|
||||||
|
|
||||||
|
if not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD:
|
||||||
|
await self._set_awaiting_user_action(email_id, reason='low_confidence')
|
||||||
|
stats['awaiting_user_action'] = True
|
||||||
|
return stats
|
||||||
|
|
||||||
# Step 4: Execute workflows based on classification
|
# Step 4: Execute workflows based on classification
|
||||||
workflow_processed = False
|
workflow_processed = False
|
||||||
@ -172,6 +192,25 @@ class EmailProcessorService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error in process_single_email for {email_id}: {e}")
|
logger.error(f"❌ Error in process_single_email for {email_id}: {e}")
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
async def _set_awaiting_user_action(self, email_id: Optional[int], reason: str):
|
||||||
|
"""Park an email for manual review before any automatic routing/action."""
|
||||||
|
if not email_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE email_messages
|
||||||
|
SET status = 'awaiting_user_action',
|
||||||
|
folder = COALESCE(folder, 'INBOX'),
|
||||||
|
auto_processed = false,
|
||||||
|
processed_at = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(email_id,)
|
||||||
|
)
|
||||||
|
logger.info("🛑 Email %s moved to awaiting_user_action (%s)", email_id, reason)
|
||||||
|
|
||||||
async def _classify_and_update(self, email_data: Dict):
|
async def _classify_and_update(self, email_data: Dict):
|
||||||
"""Classify email and update database"""
|
"""Classify email and update database"""
|
||||||
|
|||||||
37
backups/email_feature/README.md
Normal file
37
backups/email_feature/README.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Email Feature Backup
|
||||||
|
|
||||||
|
Backup artifact for current email handling implementation.
|
||||||
|
|
||||||
|
## Artifact
|
||||||
|
|
||||||
|
- `email_feature_backup_20260317_214413.zip`
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- `app/emails/`
|
||||||
|
- `app/services/email_service.py`
|
||||||
|
- `app/services/email_processor_service.py`
|
||||||
|
- `app/services/email_analysis_service.py`
|
||||||
|
- `app/services/email_workflow_service.py`
|
||||||
|
- `app/services/email_activity_logger.py`
|
||||||
|
- `app/modules/sag/templates/detail.html`
|
||||||
|
- `migrations/013_email_system.sql`
|
||||||
|
- `migrations/014_email_workflows.sql`
|
||||||
|
- `migrations/050_email_activity_log.sql`
|
||||||
|
- `migrations/056_email_import_method.sql`
|
||||||
|
- `migrations/084_sag_files_and_emails.sql`
|
||||||
|
- `migrations/140_email_extracted_vendor_fields.sql`
|
||||||
|
- `migrations/141_email_threading_headers.sql`
|
||||||
|
|
||||||
|
## Restore (code only)
|
||||||
|
|
||||||
|
From repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unzip -o backups/email_feature/email_feature_backup_20260317_214413.zip -d .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This restore only replaces code files included in the artifact.
|
||||||
|
- Database rollback must be handled separately if schema/data has changed.
|
||||||
BIN
backups/email_feature/email_feature_backup_20260317_214413.zip
Normal file
BIN
backups/email_feature/email_feature_backup_20260317_214413.zip
Normal file
Binary file not shown.
10
migrations/145_sag_start_date.sql
Normal file
10
migrations/145_sag_start_date.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
-- Migration 145: Add start date to SAG for email-driven case creation flow
|
||||||
|
|
||||||
|
ALTER TABLE sag_sager
|
||||||
|
ADD COLUMN IF NOT EXISTS start_date DATE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sag_sager_start_date
|
||||||
|
ON sag_sager(start_date)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN sag_sager.start_date IS 'Planned start date for case execution (used by email-to-case workflow).';
|
||||||
Loading…
Reference in New Issue
Block a user