feat: Add global search functionality and redirect root to dashboard

This commit is contained in:
Christian 2025-12-06 13:13:05 +01:00
parent 3a35042788
commit 3dfc5086c0
4 changed files with 152 additions and 141 deletions

View File

@ -62,3 +62,63 @@ async def get_dashboard_stats():
except Exception as e:
logger.error(f"❌ Error fetching dashboard stats: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/search", response_model=Dict[str, List[Any]])
async def global_search(q: str):
"""
Global search across customers, contacts, and vendors
"""
if not q or len(q) < 2:
return {"customers": [], "contacts": [], "vendors": []}
search_term = f"%{q}%"
try:
# Search Customers
customers = execute_query("""
SELECT id, name, email, 'Kunde' as type
FROM customers
WHERE deleted_at IS NULL AND (
name ILIKE %s OR
email ILIKE %s OR
cvr_number ILIKE %s OR
phone ILIKE %s OR
mobile_phone ILIKE %s
)
LIMIT 5
""", (search_term, search_term, search_term, search_term, search_term))
# Search Contacts
contacts = execute_query("""
SELECT id, first_name || ' ' || last_name as name, email, 'Kontakt' as type
FROM contacts
WHERE first_name ILIKE %s OR
last_name ILIKE %s OR
email ILIKE %s OR
phone ILIKE %s OR
mobile ILIKE %s
LIMIT 5
""", (search_term, search_term, search_term, search_term, search_term))
# Search Vendors
vendors = execute_query("""
SELECT id, name, email, 'Leverandør' as type
FROM vendors
WHERE is_active = true AND (
name ILIKE %s OR
email ILIKE %s OR
cvr_number ILIKE %s OR
phone ILIKE %s
)
LIMIT 5
""", (search_term, search_term, search_term, search_term))
return {
"customers": customers or [],
"contacts": contacts or [],
"vendors": vendors or []
}
except Exception as e:
logger.error(f"❌ Error performing global search: {e}", exc_info=True)
return {"customers": [], "contacts": [], "vendors": []}

View File

@ -11,7 +11,7 @@
<div class="d-flex gap-3">
<div class="input-group">
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search"></i></span>
<input type="text" class="form-control border-start-0 ps-0" placeholder="Søg i alt..." style="max-width: 250px;">
<input type="text" id="dashboardSearchInput" class="form-control border-start-0 ps-0" placeholder="Søg i alt... (⌘K)" style="max-width: 250px;" role="button">
</div>
<div class="dropdown">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
@ -203,5 +203,23 @@ async function loadDashboardStats() {
}
document.addEventListener('DOMContentLoaded', loadDashboardStats);
// Connect dashboard search input to global search modal
document.getElementById('dashboardSearchInput').addEventListener('click', () => {
const modalEl = document.getElementById('globalSearchModal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
// Focus input when modal opens
modalEl.addEventListener('shown.bs.modal', () => {
document.getElementById('globalSearchInput').focus();
}, { once: true });
});
// Also handle focus (e.g. via tab navigation)
document.getElementById('dashboardSearchInput').addEventListener('focus', (e) => {
e.target.click();
e.target.blur(); // Remove focus from this input so we don't get stuck in a loop or keep cursor here
});
</script>
{% endblock %}

View File

@ -427,61 +427,58 @@
}
});
// Mock search data
const mockData = {
crm: [
{ name: 'Advokatgruppen A/S', type: 'Kunde', id: '001', url: '/customers/001' },
{ name: 'Byg & Bo ApS', type: 'Kunde', id: '002', url: '/customers/002' },
{ name: 'Cafe Møller', type: 'Kunde', id: '003', url: '/customers/003' },
{ name: 'ABC Leverandør', type: 'Leverandør', id: 'v001', url: '/vendors/v001' },
{ name: 'Tech Solutions A/S', type: 'Leverandør', id: 'v002', url: '/vendors/v002' },
],
support: [
{ title: 'Netværksnedbrud - Cafe Møller', status: 'Kritisk', id: '#1234', created: '2 timer siden' },
{ title: 'Licens fornyelse - Byg & Bo', status: 'Afventer', id: '#1235', created: 'I går' },
{ title: 'Firewall config - Advokatgruppen', status: 'Løst', id: '#1236', created: 'I går' },
],
sales: [
{ title: 'Tilbud - Firewall Upgrade', customer: 'Advokatgruppen A/S', amount: '45.000 kr', status: 'Afventer' },
{ title: 'Ordre - Switch 48-port', customer: 'Byg & Bo ApS', amount: '12.000 kr', status: 'Godkendt' },
],
finance: [
{ title: 'Faktura #5678', customer: 'Advokatgruppen A/S', amount: '25.000 kr', status: 'Betalt' },
{ title: 'Faktura #5679', customer: 'Cafe Møller', amount: '8.500 kr', status: 'Ubetalt' },
{ title: 'Abonnement - Monitoring', customer: 'Byg & Bo ApS', amount: '2.500 kr/md', status: 'Aktiv' },
]
};
let searchTimeout;
// Search function
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase().trim();
const query = e.target.value.trim();
if (query.length === 0) {
document.getElementById('emptyState').style.display = 'block';
clearTimeout(searchTimeout);
// Reset empty state text
const emptyState = document.getElementById('emptyState');
emptyState.innerHTML = `
<i class="bi bi-search text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
<p class="text-muted mt-3">Begynd at skrive for at søge...</p>
`;
if (query.length < 2) {
emptyState.style.display = 'block';
document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none';
document.getElementById('salesResults').style.display = 'none';
document.getElementById('financeResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
return;
}
document.getElementById('emptyState').style.display = 'none';
searchTimeout = setTimeout(async () => {
try {
const response = await fetch(`/api/v1/dashboard/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
// Filter CRM results
const crmMatches = mockData.crm.filter(item =>
item.name.toLowerCase().includes(query) ||
item.type.toLowerCase().includes(query)
);
emptyState.style.display = 'none';
// CRM Results (Customers + Contacts + Vendors)
const crmSection = document.getElementById('crmResults');
if (crmMatches.length > 0) {
const allResults = [
...(data.customers || []).map(c => ({...c, url: `/customers/${c.id}`, icon: 'building'})),
...(data.contacts || []).map(c => ({...c, url: `/contacts/${c.id}`, icon: 'person'})),
...(data.vendors || []).map(c => ({...c, url: `/vendors/${c.id}`, icon: 'shop'}))
];
if (allResults.length > 0) {
crmSection.style.display = 'block';
crmSection.querySelector('.result-items').innerHTML = crmMatches.map(item => `
crmSection.querySelector('.result-items').innerHTML = allResults.map(item => `
<a href="${item.url}" class="result-item d-block p-3 mb-2 rounded text-decoration-none" style="background: var(--bg-card); border: 1px solid transparent; transition: all 0.2s;">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center me-3" style="width: 32px; height: 32px;">
<i class="bi bi-${item.icon} text-primary"></i>
</div>
<div>
<p class="mb-1 fw-bold" style="color: var(--text-primary);">${item.name}</p>
<p class="mb-0 small text-muted">${item.type} • ID: ${item.id}</p>
<p class="mb-0 fw-bold" style="color: var(--text-primary);">${item.name}</p>
<p class="mb-0 small text-muted">${item.type} ${item.email ? '• ' + item.email : ''}</p>
</div>
</div>
<i class="bi bi-arrow-right" style="color: var(--accent);"></i>
</div>
@ -489,91 +486,22 @@
`).join('');
} else {
crmSection.style.display = 'none';
}
// Filter Support results
const supportMatches = mockData.support.filter(item =>
item.title.toLowerCase().includes(query) ||
item.status.toLowerCase().includes(query)
);
const supportSection = document.getElementById('supportResults');
if (supportMatches.length > 0) {
supportSection.style.display = 'block';
supportSection.querySelector('.result-items').innerHTML = supportMatches.map(item => {
const statusColor = item.status === 'Kritisk' ? 'danger' : item.status === 'Afventer' ? 'warning' : 'success';
return `
<a href="/support/${item.id}" class="result-item d-block p-3 mb-2 rounded text-decoration-none" style="background: var(--bg-card); border: 1px solid transparent; transition: all 0.2s;">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<p class="mb-1 fw-bold" style="color: var(--text-primary);">${item.title}</p>
<p class="mb-0 small text-muted">${item.id} • ${item.created}</p>
</div>
<span class="badge bg-${statusColor} bg-opacity-10 text-${statusColor}">${item.status}</span>
</div>
</a>
emptyState.style.display = 'block';
emptyState.innerHTML = `
<i class="bi bi-search text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
<p class="text-muted mt-3">Ingen resultater fundet for "${query}"</p>
`;
}).join('');
} else {
supportSection.style.display = 'none';
}
// Filter Sales results
const salesMatches = mockData.sales.filter(item =>
item.title.toLowerCase().includes(query) ||
item.customer.toLowerCase().includes(query)
);
// Hide other sections for now as we don't have real data for them yet
document.getElementById('supportResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
const salesSection = document.getElementById('salesResults');
if (salesMatches.length > 0) {
salesSection.style.display = 'block';
salesSection.querySelector('.result-items').innerHTML = salesMatches.map(item => `
<a href="/sales" class="result-item d-block p-3 mb-2 rounded text-decoration-none" style="background: var(--bg-card); border: 1px solid transparent; transition: all 0.2s;">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<p class="mb-1 fw-bold" style="color: var(--text-primary);">${item.title}</p>
<p class="mb-0 small text-muted">${item.customer}</p>
</div>
<div class="text-end">
<p class="mb-0 fw-bold" style="color: var(--accent);">${item.amount}</p>
<p class="mb-0 small text-muted">${item.status}</p>
</div>
</div>
</a>
`).join('');
} else {
salesSection.style.display = 'none';
}
// Filter Finance results
const financeMatches = mockData.finance.filter(item =>
item.title.toLowerCase().includes(query) ||
item.customer.toLowerCase().includes(query)
);
const financeSection = document.getElementById('financeResults');
if (financeMatches.length > 0) {
financeSection.style.display = 'block';
financeSection.querySelector('.result-items').innerHTML = financeMatches.map(item => {
const statusColor = item.status === 'Betalt' ? 'success' : item.status === 'Ubetalt' ? 'danger' : 'primary';
return `
<a href="/finance" class="result-item d-block p-3 mb-2 rounded text-decoration-none" style="background: var(--bg-card); border: 1px solid transparent; transition: all 0.2s;">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<p class="mb-1 fw-bold" style="color: var(--text-primary);">${item.title}</p>
<p class="mb-0 small text-muted">${item.customer}</p>
</div>
<div class="text-end">
<p class="mb-0 fw-bold" style="color: var(--accent);">${item.amount}</p>
<span class="badge bg-${statusColor} bg-opacity-10 text-${statusColor}">${item.status}</span>
</div>
</div>
</a>
`;
}).join('');
} else {
financeSection.style.display = 'none';
} catch (error) {
console.error('Search error:', error);
}
}, 300); // Debounce 300ms
});
// Hover effects for result items
@ -594,8 +522,8 @@
document.getElementById('emptyState').style.display = 'block';
document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none';
document.getElementById('salesResults').style.display = 'none';
document.getElementById('financeResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
});
</script>
{% block extra_js %}{% endblock %}

View File

@ -75,6 +75,11 @@ app = FastAPI(
openapi_url="/api/openapi.json"
)
@app.get("/")
async def root():
"""Redirect root to dashboard"""
return RedirectResponse(url="/dashboard")
# CORS middleware
app.add_middleware(
CORSMiddleware,