feat: Add global search functionality and redirect root to dashboard
This commit is contained in:
parent
3a35042788
commit
3dfc5086c0
@ -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": []}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -427,153 +427,81 @@
|
||||
}
|
||||
});
|
||||
|
||||
// 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';
|
||||
|
||||
// Filter CRM results
|
||||
const crmMatches = mockData.crm.filter(item =>
|
||||
item.name.toLowerCase().includes(query) ||
|
||||
item.type.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
const crmSection = document.getElementById('crmResults');
|
||||
if (crmMatches.length > 0) {
|
||||
crmSection.style.display = 'block';
|
||||
crmSection.querySelector('.result-items').innerHTML = crmMatches.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>
|
||||
<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>
|
||||
</div>
|
||||
<i class="bi bi-arrow-right" style="color: var(--accent);"></i>
|
||||
</div>
|
||||
</a>
|
||||
`).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>
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/dashboard/search?q=${encodeURIComponent(query)}`);
|
||||
const data = await response.json();
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
// CRM Results (Customers + Contacts + Vendors)
|
||||
const crmSection = document.getElementById('crmResults');
|
||||
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 = 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-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>
|
||||
<span class="badge bg-${statusColor} bg-opacity-10 text-${statusColor}">${item.status}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}).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)
|
||||
);
|
||||
|
||||
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';
|
||||
}
|
||||
</a>
|
||||
`).join('');
|
||||
} else {
|
||||
crmSection.style.display = 'none';
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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';
|
||||
|
||||
} 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 %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user