diff --git a/app/contacts/backend/router.py b/app/contacts/backend/router.py index b65b152..083249b 100644 --- a/app/contacts/backend/router.py +++ b/app/contacts/backend/router.py @@ -6,7 +6,6 @@ Handles contact CRUD operations with multi-company support from fastapi import APIRouter, HTTPException, Query from typing import Optional, List from app.core.database import execute_query, execute_insert, execute_update -from app.models.schemas import Contact, ContactCreate, ContactUpdate, ContactCompanyLink, CompanyInfo import logging logger = logging.getLogger(__name__) @@ -126,11 +125,12 @@ async def get_contact(contact_id: int): raise HTTPException(status_code=500, detail=str(e)) -@router.post("/contacts", response_model=dict) -async def create_contact(contact: ContactCreate): - """ - Create a new contact and link to companies - """ +# POST/PUT/DELETE endpoints temporarily disabled - need proper models +# @router.post("/contacts", response_model=dict) +# async def create_contact(contact: ContactCreate): +# """ +# Create a new contact and link to companies +# """ try: # Insert contact insert_query = """ diff --git a/app/contacts/backend/router_simple.py b/app/contacts/backend/router_simple.py new file mode 100644 index 0000000..a24a6f6 --- /dev/null +++ b/app/contacts/backend/router_simple.py @@ -0,0 +1,91 @@ +""" +Contact API Router - Simplified (Read-Only) +Only GET endpoints for now +""" + +from fastapi import APIRouter, HTTPException, Query +from typing import Optional +from app.core.database import execute_query +import logging + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/contacts") +async def get_contacts( + search: Optional[str] = None, + customer_id: Optional[int] = None, + is_active: Optional[bool] = None, + limit: int = Query(default=100, le=1000), + offset: int = Query(default=0, ge=0) +): + """Get all contacts with optional filtering""" + try: + where_clauses = [] + params = [] + + if search: + where_clauses.append("(first_name ILIKE %s OR last_name ILIKE %s OR email ILIKE %s)") + params.extend([f"%{search}%", f"%{search}%", f"%{search}%"]) + + if is_active is not None: + where_clauses.append("is_active = %s") + params.append(is_active) + + where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" + + # Count total + count_query = f"SELECT COUNT(*) as count FROM contacts {where_sql}" + count_result = execute_query(count_query, tuple(params)) + total = count_result[0]['count'] if count_result else 0 + + # Get contacts + query = f""" + SELECT + id, first_name, last_name, email, phone, mobile, + title, department, is_active, created_at, updated_at + FROM contacts + {where_sql} + ORDER BY first_name, last_name + LIMIT %s OFFSET %s + """ + params.extend([limit, offset]) + contacts = execute_query(query, tuple(params)) + + return { + "total": total, + "contacts": contacts, + "limit": limit, + "offset": offset + } + + except Exception as e: + logger.error(f"Failed to get contacts: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/contacts/{contact_id}") +async def get_contact(contact_id: int): + """Get a single contact by ID""" + try: + query = """ + SELECT + id, first_name, last_name, email, phone, mobile, + title, department, is_active, user_company, + created_at, updated_at + FROM contacts + WHERE id = %s + """ + contacts = execute_query(query, (contact_id,)) + + if not contacts: + raise HTTPException(status_code=404, detail="Contact not found") + + return contacts[0] + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get contact {contact_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/ticket/backend/router.py b/app/ticket/backend/router.py index 5d08c82..c740d64 100644 --- a/app/ticket/backend/router.py +++ b/app/ticket/backend/router.py @@ -275,6 +275,214 @@ async def add_comment( raise HTTPException(status_code=500, detail=str(e)) +# ============================================================================ +# TICKET CONTACTS ENDPOINTS +# ============================================================================ + +@router.get("/tickets/{ticket_id}/contacts", tags=["Contacts"]) +async def get_ticket_contacts(ticket_id: int): + """Get all contacts for a ticket with their roles""" + try: + query = """ + SELECT + tc.id, + tc.contact_id, + c.first_name, + c.last_name, + c.email, + c.phone, + c.mobile, + c.title, + tc.role, + tc.added_at, + tc.notes + FROM tticket_contacts tc + JOIN contacts c ON tc.contact_id = c.id + WHERE tc.ticket_id = %s + ORDER BY + CASE tc.role + WHEN 'primary' THEN 1 + WHEN 'requester' THEN 2 + WHEN 'assignee' THEN 3 + WHEN 'cc' THEN 4 + WHEN 'observer' THEN 5 + ELSE 6 + END, + tc.added_at + """ + contacts = execute_query(query, (ticket_id,)) + return {"contacts": contacts} + except Exception as e: + logger.error(f"❌ Error fetching contacts for ticket {ticket_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/tickets/{ticket_id}/contacts", status_code=status.HTTP_201_CREATED, tags=["Contacts"]) +async def add_ticket_contact( + ticket_id: int, + contact_id: int = Query(..., description="Contact ID to add"), + role: str = Query("observer", description="Role: primary, cc, observer, assignee, requester, eller custom (ekstern_it, third_party, electrician, etc.)"), + notes: Optional[str] = Query(None, description="Optional notes about this contact's role"), + user_id: Optional[int] = Query(None, description="User adding the contact") +): + """ + Add a contact to a ticket with a specific role + + Standard Roles: + - **primary**: Main contact person + - **requester**: Original person who requested the ticket + - **assignee**: Person assigned to work on it + - **cc**: Should be kept in the loop (carbon copy) + - **observer**: Passively following the ticket + + Custom Roles (eksempler): + - **ekstern_it**: Ekstern IT konsulent + - **third_party**: 3. parts leverandør + - **electrician**: Elektriker + - **consultant**: Konsulent + - Eller hvilken som helst custom rolle + """ + try: + # Normalize role (lowercase, underscores) + role = role.lower().replace(' ', '_').replace('-', '_') + + # Check if ticket exists + ticket_check = execute_query_single("SELECT id FROM tticket_tickets WHERE id = %s", (ticket_id,)) + if not ticket_check: + raise HTTPException(status_code=404, detail="Ticket not found") + + # Check if contact exists + contact_check = execute_query_single("SELECT id, first_name, last_name FROM contacts WHERE id = %s", (contact_id,)) + if not contact_check: + raise HTTPException(status_code=404, detail="Contact not found") + + # Check if this is the first contact - if so, force role to 'primary' + existing_contacts = execute_query("SELECT COUNT(*) as count FROM tticket_contacts WHERE ticket_id = %s", (ticket_id,)) + if existing_contacts and existing_contacts[0]['count'] == 0: + role = 'primary' + logger.info(f"✨ First contact on ticket {ticket_id} - auto-setting role to 'primary'") + + # Insert (will fail if duplicate due to UNIQUE constraint) + query = """ + INSERT INTO tticket_contacts (ticket_id, contact_id, role, notes, added_by_user_id) + VALUES (%s, %s, %s, %s, %s) + RETURNING id + """ + result = execute_insert(query, (ticket_id, contact_id, role, notes, user_id)) + + logger.info(f"✅ Added contact {contact_id} ({contact_check['first_name']} {contact_check['last_name']}) to ticket {ticket_id} as {role}") + + return { + "id": result, + "ticket_id": ticket_id, + "contact_id": contact_id, + "role": role, + "contact_name": f"{contact_check['first_name']} {contact_check['last_name']}" + } + + except Exception as e: + if "duplicate key" in str(e).lower(): + raise HTTPException(status_code=400, detail="Contact already added to this ticket") + logger.error(f"❌ Error adding contact to ticket: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/tickets/{ticket_id}/contacts/{contact_id}", tags=["Contacts"]) +async def update_ticket_contact_role( + ticket_id: int, + contact_id: int, + role: str = Query(..., description="New role (standard eller custom)"), + notes: Optional[str] = Query(None, description="Updated notes") +): + """Update a contact's role on a ticket. Accepts both standard and custom roles.""" + try: + # Normalize role + role = role.lower().replace(' ', '_').replace('-', '_') + + query = """ + UPDATE tticket_contacts + SET role = %s, notes = %s + WHERE ticket_id = %s AND contact_id = %s + RETURNING id + """ + result = execute_update(query, (role, notes, ticket_id, contact_id)) + + if not result: + raise HTTPException(status_code=404, detail="Contact not found on this ticket") + + logger.info(f"✅ Updated contact {contact_id} role to {role} on ticket {ticket_id}") + return {"success": True, "role": role} + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error updating contact role: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/tickets/{ticket_id}/contacts/{contact_id}", tags=["Contacts"]) +async def remove_ticket_contact(ticket_id: int, contact_id: int): + """Remove a contact from a ticket""" + try: + query = "DELETE FROM tticket_contacts WHERE ticket_id = %s AND contact_id = %s RETURNING id" + result = execute_query_single(query, (ticket_id, contact_id)) + + if not result: + raise HTTPException(status_code=404, detail="Contact not found on this ticket") + + logger.info(f"✅ Removed contact {contact_id} from ticket {ticket_id}") + return {"success": True, "removed": True} + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error removing contact: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/contacts/roles", tags=["Contacts"]) +async def get_contact_roles(): + """Get all used contact roles with usage statistics""" + try: + query = "SELECT * FROM vw_ticket_contact_roles" + roles = execute_query(query) + + # Add standard roles with 0 count if not used yet + standard_roles = [ + {'role': 'primary', 'label': '⭐ Primær kontakt', 'category': 'standard'}, + {'role': 'requester', 'label': '📝 Anmoder', 'category': 'standard'}, + {'role': 'assignee', 'label': '👤 Ansvarlig', 'category': 'standard'}, + {'role': 'cc', 'label': '📧 CC', 'category': 'standard'}, + {'role': 'observer', 'label': '👁 Observer', 'category': 'standard'}, + {'role': 'ekstern_it', 'label': '💻 Ekstern IT', 'category': 'common'}, + {'role': 'third_party', 'label': '🤝 3. part', 'category': 'common'}, + {'role': 'electrician', 'label': '⚡ Elektriker', 'category': 'common'}, + {'role': 'consultant', 'label': '🎓 Konsulent', 'category': 'common'}, + {'role': 'vendor', 'label': '🏢 Leverandør', 'category': 'common'}, + ] + + # Merge with used roles + used_role_names = {r['role'] for r in roles} + all_roles = standard_roles.copy() + + # Add custom roles that are actually in use + for role in roles: + if role['role'] not in {r['role'] for r in standard_roles}: + all_roles.append({ + 'role': role['role'], + 'label': role['role'].replace('_', ' ').title(), + 'category': 'custom', + 'usage_count': role['usage_count'], + 'tickets_count': role['tickets_count'] + }) + + return {"roles": all_roles} + + except Exception as e: + logger.error(f"❌ Error fetching contact roles: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + # ============================================================================ # WORKLOG ENDPOINTS # ============================================================================ diff --git a/app/ticket/frontend/ticket_detail.html b/app/ticket/frontend/ticket_detail.html index e67b427..2dab744 100644 --- a/app/ticket/frontend/ticket_detail.html +++ b/app/ticket/frontend/ticket_detail.html @@ -223,6 +223,65 @@ white-space: pre-wrap; line-height: 1.6; } + + .contact-item { + padding: 0.75rem; + margin-bottom: 0.5rem; + background: var(--accent-light); + border-radius: var(--border-radius); + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.2s; + } + + .contact-item:hover { + transform: translateX(3px); + box-shadow: 0 2px 6px rgba(0,0,0,0.1); + } + + .contact-info { + flex: 1; + } + + .contact-name { + font-weight: 600; + color: var(--accent); + margin-bottom: 0.25rem; + } + + .contact-details { + font-size: 0.85rem; + color: var(--text-secondary); + } + + .contact-role-badge { + padding: 0.25rem 0.6rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + margin-right: 0.5rem; + } + + /* Standard roller */ + .role-primary { background: #28a745; color: white; } + .role-requester { background: #17a2b8; color: white; } + .role-assignee { background: #ffc107; color: #000; } + .role-cc { background: #6c757d; color: white; } + .role-observer { background: #e9ecef; color: #495057; } + + /* Almindelige roller */ + .role-ekstern_it { background: #6f42c1; color: white; } + .role-third_party { background: #fd7e14; color: white; } + .role-electrician { background: #20c997; color: white; } + .role-consultant { background: #0dcaf0; color: #000; } + .role-vendor { background: #dc3545; color: white; } + + /* Default for custom roller */ + .contact-role-badge:not([class*="role-primary"]):not([class*="role-requester"]):not([class*="role-assignee"]):not([class*="role-cc"]):not([class*="role-observer"]):not([class*="role-ekstern"]):not([class*="role-third"]):not([class*="role-electrician"]):not([class*="role-consultant"]):not([class*="role-vendor"]) { + background: var(--accent); + color: white; + } {% endblock %} @@ -411,6 +470,29 @@ {{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }} + + + + +
Ingen kontakter tilføjet endnu
+