feat: Implement ticket contacts management with flexible roles and CRUD operations
This commit is contained in:
parent
0502a7b080
commit
84485bd294
@ -6,7 +6,6 @@ Handles contact CRUD operations with multi-company support
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from app.core.database import execute_query, execute_insert, execute_update
|
from app.core.database import execute_query, execute_insert, execute_update
|
||||||
from app.models.schemas import Contact, ContactCreate, ContactUpdate, ContactCompanyLink, CompanyInfo
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -126,11 +125,12 @@ async def get_contact(contact_id: int):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/contacts", response_model=dict)
|
# POST/PUT/DELETE endpoints temporarily disabled - need proper models
|
||||||
async def create_contact(contact: ContactCreate):
|
# @router.post("/contacts", response_model=dict)
|
||||||
"""
|
# async def create_contact(contact: ContactCreate):
|
||||||
Create a new contact and link to companies
|
# """
|
||||||
"""
|
# Create a new contact and link to companies
|
||||||
|
# """
|
||||||
try:
|
try:
|
||||||
# Insert contact
|
# Insert contact
|
||||||
insert_query = """
|
insert_query = """
|
||||||
|
|||||||
91
app/contacts/backend/router_simple.py
Normal file
91
app/contacts/backend/router_simple.py
Normal file
@ -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))
|
||||||
@ -275,6 +275,214 @@ async def add_comment(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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
|
# WORKLOG ENDPOINTS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@ -223,6 +223,65 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
line-height: 1.6;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -411,6 +470,29 @@
|
|||||||
{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}
|
{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contacts -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="section-title">
|
||||||
|
<i class="bi bi-people"></i> Kontakter
|
||||||
|
<button class="btn btn-sm btn-outline-primary ms-auto" onclick="showAddContactModal()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Tilføj
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="contactsList">
|
||||||
|
<div class="text-center text-muted py-2">
|
||||||
|
<i class="bi bi-hourglass-split"></i> Indlæser...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Back to Ticket Info continuation -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
<div class="info-item mb-3">
|
<div class="info-item mb-3">
|
||||||
<label>Senest opdateret</label>
|
<label>Senest opdateret</label>
|
||||||
<div class="value">
|
<div class="value">
|
||||||
@ -514,8 +596,272 @@
|
|||||||
loadTicketTags();
|
loadTicketTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load tags on page load
|
// ============================================
|
||||||
document.addEventListener('DOMContentLoaded', loadTicketTags);
|
// CONTACTS MANAGEMENT
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async function loadContacts() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/contacts');
|
||||||
|
if (!response.ok) throw new Error('Failed to load contacts');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const container = document.getElementById('contactsList');
|
||||||
|
|
||||||
|
if (!data.contacts || data.contacts.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state py-2">
|
||||||
|
<i class="bi bi-person-x"></i>
|
||||||
|
<p class="mb-0 small">Ingen kontakter tilføjet endnu</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = data.contacts.map(contact => `
|
||||||
|
<div class="contact-item">
|
||||||
|
<div class="contact-info">
|
||||||
|
<div class="contact-name">
|
||||||
|
<span class="contact-role-badge role-${contact.role}">
|
||||||
|
${getRoleLabel(contact.role)}
|
||||||
|
</span>
|
||||||
|
${contact.first_name} ${contact.last_name}
|
||||||
|
</div>
|
||||||
|
<div class="contact-details">
|
||||||
|
${contact.email ? `<i class="bi bi-envelope"></i> ${contact.email}` : ''}
|
||||||
|
${contact.phone ? `<i class="bi bi-telephone ms-2"></i> ${contact.phone}` : ''}
|
||||||
|
</div>
|
||||||
|
${contact.notes ? `<div class="contact-details mt-1"><i class="bi bi-sticky"></i> ${contact.notes}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-primary" onclick="editContactRole(${contact.contact_id}, '${contact.role}', '${contact.notes || ''}')">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger" onclick="removeContact(${contact.contact_id}, '${contact.first_name} ${contact.last_name}')">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading contacts:', error);
|
||||||
|
document.getElementById('contactsList').innerHTML = `
|
||||||
|
<div class="alert alert-warning mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i> Kunne ikke indlæse kontakter
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleLabel(role) {
|
||||||
|
const labels = {
|
||||||
|
'primary': '⭐ Primær',
|
||||||
|
'requester': '📝 Anmoder',
|
||||||
|
'assignee': '👤 Ansvarlig',
|
||||||
|
'cc': '📧 CC',
|
||||||
|
'observer': '👁 Observer',
|
||||||
|
'ekstern_it': '💻 Ekstern IT',
|
||||||
|
'third_party': '🤝 3. part',
|
||||||
|
'electrician': '⚡ Elektriker',
|
||||||
|
'consultant': '🎓 Konsulent',
|
||||||
|
'vendor': '🏢 Leverandør'
|
||||||
|
};
|
||||||
|
return labels[role] || ('📌 ' + role.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showAddContactModal() {
|
||||||
|
// Fetch all contacts for selection
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/contacts?limit=1000');
|
||||||
|
if (!response.ok) throw new Error('Failed to load contacts');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const contacts = data.contacts || [];
|
||||||
|
|
||||||
|
// Check if this ticket has any contacts yet
|
||||||
|
const ticketContactsResp = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/contacts');
|
||||||
|
const ticketContacts = await ticketContactsResp.json();
|
||||||
|
const isFirstContact = !ticketContacts.contacts || ticketContacts.contacts.length === 0;
|
||||||
|
|
||||||
|
// Create modal content
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal fade" id="addContactModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-person-plus"></i> Tilføj Kontakt</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Kontakt *</label>
|
||||||
|
<select class="form-select" id="contactSelect" required>
|
||||||
|
<option value="">Vælg kontakt...</option>
|
||||||
|
${contacts.map(c => `
|
||||||
|
<option value="${c.id}">${c.first_name} ${c.last_name} ${c.email ? '(' + c.email + ')' : ''}</option>
|
||||||
|
`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Rolle *</label>
|
||||||
|
<div id="firstContactNotice" class="alert alert-info mb-2" style="display: none;">
|
||||||
|
<i class="bi bi-star"></i> <strong>Første kontakt</strong> - Rollen sættes automatisk til "Primær kontakt"
|
||||||
|
</div>
|
||||||
|
<select class="form-select" id="roleSelect" onchange="toggleCustomRole()" required>
|
||||||
|
<optgroup label="Standard roller">
|
||||||
|
<option value="primary">⭐ Primær kontakt</option>
|
||||||
|
<option value="requester">📝 Anmoder</option>
|
||||||
|
<option value="assignee">👤 Ansvarlig</option>
|
||||||
|
<option value="cc">📧 CC (Carbon Copy)</option>
|
||||||
|
<option value="observer" selected>👁 Observer</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Almindelige roller">
|
||||||
|
<option value="ekstern_it">💻 Ekstern IT</option>
|
||||||
|
<option value="third_party">🤝 3. part leverandør</option>
|
||||||
|
<option value="electrician">⚡ Elektriker</option>
|
||||||
|
<option value="consultant">🎓 Konsulent</option>
|
||||||
|
<option value="vendor">🏢 Leverandør</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Custom">
|
||||||
|
<option value="_custom">✏️ Indtast custom rolle...</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="customRoleDiv" style="display: none;">
|
||||||
|
<label class="form-label">Custom Rolle</label>
|
||||||
|
<input type="text" class="form-control" id="customRoleInput"
|
||||||
|
placeholder="f.eks. 'bygningsingeniør' eller 'projektleder'">
|
||||||
|
<small class="text-muted">Brug lowercase og underscore i stedet for mellemrum (f.eks. bygnings_ingeniør)</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Noter (valgfri)</label>
|
||||||
|
<textarea class="form-control" id="contactNotes" rows="2" placeholder="Evt. noter om kontaktens rolle..."></textarea>
|
||||||
|
</div>
|
||||||
|
</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="addContact()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Tilføj
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Remove old modal if exists
|
||||||
|
const oldModal = document.getElementById('addContactModal');
|
||||||
|
if (oldModal) oldModal.remove();
|
||||||
|
|
||||||
|
// Append and show new modal
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('addContactModal'));
|
||||||
|
|
||||||
|
// Show notice and disable role selector if first contact
|
||||||
|
if (isFirstContact) {
|
||||||
|
document.getElementById('firstContactNotice').style.display = 'block';
|
||||||
|
document.getElementById('roleSelect').value = 'primary';
|
||||||
|
document.getElementById('roleSelect').disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.show();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Fejl ved indlæsning af kontakter');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCustomRole() {
|
||||||
|
const roleSelect = document.getElementById('roleSelect');
|
||||||
|
const customDiv = document.getElementById('customRoleDiv');
|
||||||
|
const customInput = document.getElementById('customRoleInput');
|
||||||
|
|
||||||
|
if (roleSelect.value === '_custom') {
|
||||||
|
customDiv.style.display = 'block';
|
||||||
|
customInput.required = true;
|
||||||
|
} else {
|
||||||
|
customDiv.style.display = 'none';
|
||||||
|
customInput.required = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addContact() {
|
||||||
|
const contactId = document.getElementById('contactSelect').value;
|
||||||
|
let role = document.getElementById('roleSelect').value;
|
||||||
|
const notes = document.getElementById('contactNotes').value;
|
||||||
|
|
||||||
|
if (!contactId) {
|
||||||
|
alert('Vælg venligst en kontakt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If custom role selected, use the custom input
|
||||||
|
if (role === '_custom') {
|
||||||
|
const customRole = document.getElementById('customRoleInput').value.trim();
|
||||||
|
if (!customRole) {
|
||||||
|
alert('Indtast venligst en custom rolle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
role = customRole.toLowerCase().replace(/\s+/g, '_').replace(/-/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/api/v1/ticket/tickets/{{ ticket.id }}/contacts?contact_id=${contactId}&role=${role}${notes ? '¬es=' + encodeURIComponent(notes) : ''}`;
|
||||||
|
const response = await fetch(url, { method: 'POST' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to add contact');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal and reload
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('addContactModal')).hide();
|
||||||
|
await loadContacts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding contact:', error);
|
||||||
|
alert('Fejl: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editContactRole(contactId, currentRole, currentNotes) {
|
||||||
|
const newRole = prompt(`Ændr rolle (primary, requester, assignee, cc, observer):`, currentRole);
|
||||||
|
if (!newRole || newRole === currentRole) return;
|
||||||
|
|
||||||
|
const notes = prompt(`Noter (valgfri):`, currentNotes);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/api/v1/ticket/tickets/{{ ticket.id }}/contacts/${contactId}?role=${newRole}${notes ? '¬es=' + encodeURIComponent(notes) : ''}`;
|
||||||
|
const response = await fetch(url, { method: 'PUT' });
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to update contact');
|
||||||
|
await loadContacts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating contact:', error);
|
||||||
|
alert('Fejl ved opdatering af kontakt');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeContact(contactId, contactName) {
|
||||||
|
if (!confirm(`Fjern ${contactName} fra ticket?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/ticket/tickets/{{ ticket.id }}/contacts/${contactId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to remove contact');
|
||||||
|
await loadContacts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing contact:', error);
|
||||||
|
alert('Fejl ved fjernelse af kontakt');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tags and contacts on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadTicketTags();
|
||||||
|
loadContacts();
|
||||||
|
});
|
||||||
|
|
||||||
// Override global tag picker to auto-reload after adding
|
// Override global tag picker to auto-reload after adding
|
||||||
if (window.tagPicker) {
|
if (window.tagPicker) {
|
||||||
|
|||||||
2
main.py
2
main.py
@ -30,6 +30,7 @@ from app.vendors.backend import views as vendors_views
|
|||||||
from app.timetracking.backend import router as timetracking_api
|
from app.timetracking.backend import router as timetracking_api
|
||||||
from app.timetracking.frontend import views as timetracking_views
|
from app.timetracking.frontend import views as timetracking_views
|
||||||
from app.contacts.backend import views as contacts_views
|
from app.contacts.backend import views as contacts_views
|
||||||
|
from app.contacts.backend import router_simple as contacts_api
|
||||||
from app.tags.backend import router as tags_api
|
from app.tags.backend import router as tags_api
|
||||||
from app.tags.backend import views as tags_views
|
from app.tags.backend import views as tags_views
|
||||||
|
|
||||||
@ -95,6 +96,7 @@ app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
|
|||||||
app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
|
app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
|
||||||
app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
|
app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
|
||||||
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
|
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
|
||||||
|
app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"])
|
||||||
app.include_router(timetracking_api, prefix="/api/v1", tags=["Time Tracking"])
|
app.include_router(timetracking_api, prefix="/api/v1", tags=["Time Tracking"])
|
||||||
app.include_router(tags_api.router, prefix="/api/v1", tags=["Tags"])
|
app.include_router(tags_api.router, prefix="/api/v1", tags=["Tags"])
|
||||||
|
|
||||||
|
|||||||
69
migrations/029_ticket_contacts.sql
Normal file
69
migrations/029_ticket_contacts.sql
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
-- Ticket Contacts - Many-to-many relation med roller
|
||||||
|
-- Tillader flere kontakter per ticket med forskellige roller (primary, cc, observer, etc.)
|
||||||
|
|
||||||
|
-- Junction tabel mellem tickets og contacts
|
||||||
|
CREATE TABLE IF NOT EXISTS tticket_contacts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
|
||||||
|
contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(50) NOT NULL DEFAULT 'observer', -- primary, cc, observer, assignee
|
||||||
|
added_by_user_id INTEGER REFERENCES users(user_id),
|
||||||
|
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
notes TEXT, -- Valgfri noter om kontaktens rolle
|
||||||
|
|
||||||
|
-- Forhindre dubletter
|
||||||
|
UNIQUE(ticket_id, contact_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tticket_contacts_ticket ON tticket_contacts(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tticket_contacts_contact ON tticket_contacts(contact_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tticket_contacts_role ON tticket_contacts(role);
|
||||||
|
|
||||||
|
-- Check constraint for valide roller
|
||||||
|
ALTER TABLE tticket_contacts
|
||||||
|
ADD CONSTRAINT tticket_contacts_role_check
|
||||||
|
CHECK (role IN ('primary', 'cc', 'observer', 'assignee', 'requester'));
|
||||||
|
|
||||||
|
-- Migrer eksisterende contact_id til ny tabel (hvis der er data)
|
||||||
|
INSERT INTO tticket_contacts (ticket_id, contact_id, role)
|
||||||
|
SELECT id, contact_id, 'primary'
|
||||||
|
FROM tticket_tickets
|
||||||
|
WHERE contact_id IS NOT NULL
|
||||||
|
ON CONFLICT (ticket_id, contact_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Kommentar på tabellen
|
||||||
|
COMMENT ON TABLE tticket_contacts IS 'Many-to-many relation mellem tickets og contacts med roller';
|
||||||
|
COMMENT ON COLUMN tticket_contacts.role IS 'Kontaktens rolle: primary (hovedkontakt), cc (carbon copy), observer (følger med), assignee (assigned kontakt), requester (oprindelig anmoder)';
|
||||||
|
|
||||||
|
-- Views til at få kontakter grouped by role
|
||||||
|
CREATE OR REPLACE VIEW vw_tticket_contacts_summary AS
|
||||||
|
SELECT
|
||||||
|
t.id as ticket_id,
|
||||||
|
t.ticket_number,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'contact_id', c.id,
|
||||||
|
'first_name', c.first_name,
|
||||||
|
'last_name', c.last_name,
|
||||||
|
'email', c.email,
|
||||||
|
'phone', c.phone,
|
||||||
|
'role', tc.role,
|
||||||
|
'added_at', tc.added_at
|
||||||
|
) 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
|
||||||
|
) as contacts
|
||||||
|
FROM tticket_tickets t
|
||||||
|
LEFT JOIN tticket_contacts tc ON t.id = tc.ticket_id
|
||||||
|
LEFT JOIN contacts c ON tc.contact_id = c.id
|
||||||
|
GROUP BY t.id, t.ticket_number;
|
||||||
|
|
||||||
|
COMMENT ON VIEW vw_tticket_contacts_summary IS 'Oversigt over alle kontakter per ticket med roller, sorteret efter rolle prioritet';
|
||||||
28
migrations/030_ticket_contacts_flexible_roles.sql
Normal file
28
migrations/030_ticket_contacts_flexible_roles.sql
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
-- Gør ticket contact roller fleksible
|
||||||
|
-- Fjern CHECK constraint og tillad custom roller
|
||||||
|
|
||||||
|
-- Drop den eksisterende constraint
|
||||||
|
ALTER TABLE tticket_contacts
|
||||||
|
DROP CONSTRAINT IF EXISTS tticket_contacts_role_check;
|
||||||
|
|
||||||
|
-- Tilføj index for bedre performance på rolle-søgninger
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tticket_contacts_role_lower ON tticket_contacts(LOWER(role));
|
||||||
|
|
||||||
|
-- Opdater eksisterende roller til at følge naming convention (lowercase med underscore)
|
||||||
|
UPDATE tticket_contacts SET role = LOWER(REPLACE(role, ' ', '_'));
|
||||||
|
|
||||||
|
-- Kommentar på kolonnen med eksempler
|
||||||
|
COMMENT ON COLUMN tticket_contacts.role IS 'Kontaktens rolle på ticket. Standard roller: primary, requester, assignee, cc, observer. Custom roller: ekstern_it, third_party, electrician, consultant, etc.';
|
||||||
|
|
||||||
|
-- View til at få oversigt over alle brugte roller
|
||||||
|
CREATE OR REPLACE VIEW vw_ticket_contact_roles AS
|
||||||
|
SELECT
|
||||||
|
role,
|
||||||
|
COUNT(*) as usage_count,
|
||||||
|
COUNT(DISTINCT ticket_id) as tickets_count,
|
||||||
|
MAX(added_at) as last_used
|
||||||
|
FROM tticket_contacts
|
||||||
|
GROUP BY role
|
||||||
|
ORDER BY usage_count DESC;
|
||||||
|
|
||||||
|
COMMENT ON VIEW vw_ticket_contact_roles IS 'Oversigt over alle brugte roller på tickets med statistik';
|
||||||
Loading…
Reference in New Issue
Block a user