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:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error fetching dashboard stats: {e}", exc_info=True)
|
logger.error(f"❌ Error fetching dashboard stats: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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="d-flex gap-3">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search"></i></span>
|
<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>
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="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);
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -427,61 +427,58 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock search data
|
let searchTimeout;
|
||||||
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' },
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// Search function
|
// Search function
|
||||||
searchInput.addEventListener('input', (e) => {
|
searchInput.addEventListener('input', (e) => {
|
||||||
const query = e.target.value.toLowerCase().trim();
|
const query = e.target.value.trim();
|
||||||
|
|
||||||
if (query.length === 0) {
|
clearTimeout(searchTimeout);
|
||||||
document.getElementById('emptyState').style.display = 'block';
|
|
||||||
|
// 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('crmResults').style.display = 'none';
|
||||||
document.getElementById('supportResults').style.display = 'none';
|
document.getElementById('supportResults').style.display = 'none';
|
||||||
document.getElementById('salesResults').style.display = 'none';
|
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
||||||
document.getElementById('financeResults').style.display = 'none';
|
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
||||||
return;
|
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
|
emptyState.style.display = 'none';
|
||||||
const crmMatches = mockData.crm.filter(item =>
|
|
||||||
item.name.toLowerCase().includes(query) ||
|
|
||||||
item.type.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// CRM Results (Customers + Contacts + Vendors)
|
||||||
const crmSection = document.getElementById('crmResults');
|
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.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;">
|
<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 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>
|
<div>
|
||||||
<p class="mb-1 fw-bold" style="color: var(--text-primary);">${item.name}</p>
|
<p class="mb-0 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 small text-muted">${item.type} ${item.email ? '• ' + item.email : ''}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<i class="bi bi-arrow-right" style="color: var(--accent);"></i>
|
<i class="bi bi-arrow-right" style="color: var(--accent);"></i>
|
||||||
</div>
|
</div>
|
||||||
@ -489,91 +486,22 @@
|
|||||||
`).join('');
|
`).join('');
|
||||||
} else {
|
} else {
|
||||||
crmSection.style.display = 'none';
|
crmSection.style.display = 'none';
|
||||||
}
|
emptyState.style.display = 'block';
|
||||||
|
emptyState.innerHTML = `
|
||||||
// Filter Support results
|
<i class="bi bi-search text-muted" style="font-size: 4rem; opacity: 0.3;"></i>
|
||||||
const supportMatches = mockData.support.filter(item =>
|
<p class="text-muted mt-3">Ingen resultater fundet for "${query}"</p>
|
||||||
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>
|
|
||||||
`;
|
`;
|
||||||
}).join('');
|
|
||||||
} else {
|
|
||||||
supportSection.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter Sales results
|
// Hide other sections for now as we don't have real data for them yet
|
||||||
const salesMatches = mockData.sales.filter(item =>
|
document.getElementById('supportResults').style.display = 'none';
|
||||||
item.title.toLowerCase().includes(query) ||
|
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
||||||
item.customer.toLowerCase().includes(query)
|
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
||||||
);
|
|
||||||
|
|
||||||
const salesSection = document.getElementById('salesResults');
|
} catch (error) {
|
||||||
if (salesMatches.length > 0) {
|
console.error('Search error:', error);
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
|
}, 300); // Debounce 300ms
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hover effects for result items
|
// Hover effects for result items
|
||||||
@ -594,8 +522,8 @@
|
|||||||
document.getElementById('emptyState').style.display = 'block';
|
document.getElementById('emptyState').style.display = 'block';
|
||||||
document.getElementById('crmResults').style.display = 'none';
|
document.getElementById('crmResults').style.display = 'none';
|
||||||
document.getElementById('supportResults').style.display = 'none';
|
document.getElementById('supportResults').style.display = 'none';
|
||||||
document.getElementById('salesResults').style.display = 'none';
|
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
|
||||||
document.getElementById('financeResults').style.display = 'none';
|
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
|
|||||||
5
main.py
5
main.py
@ -75,6 +75,11 @@ app = FastAPI(
|
|||||||
openapi_url="/api/openapi.json"
|
openapi_url="/api/openapi.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Redirect root to dashboard"""
|
||||||
|
return RedirectResponse(url="/dashboard")
|
||||||
|
|
||||||
# CORS middleware
|
# CORS middleware
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user