Add centered live company search to the overview

A modern search bar at the top center of the index page filters the
applications table live by firma/stelle. Status group headers hide when
their group has no visible rows, per-group counts update with the
filtered result, and an empty-state row shows when nothing matches.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-03 19:17:54 +02:00
co-authored by Claude
parent 106b184fda
commit fd11facadd
+73 -3
View File
@@ -7,6 +7,21 @@
<%- include('partials/header') %> <%- include('partials/header') %>
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<!-- Search -->
<div class="flex justify-center mb-8">
<div class="relative w-full max-w-xl">
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<input id="firmaSearch" type="text" placeholder="Firma suchen …" autocomplete="off"
class="w-full pl-12 pr-10 py-3 rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition">
<button id="firmaSearchClear" type="button" aria-label="Suche zurücksetzen"
class="hidden absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
</div>
<!-- Filter Section --> <!-- Filter Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Filter</h2> <h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Filter</h2>
@@ -167,13 +182,13 @@
]; ];
%> %>
<% orderedKeys.forEach(statusKey => { %> <% orderedKeys.forEach(statusKey => { %>
<tbody class="bg-gray-100 dark:bg-gray-900/40 border-b border-gray-200 dark:border-gray-700"> <tbody class="status-group bg-gray-100 dark:bg-gray-900/40 border-b border-gray-200 dark:border-gray-700">
<tr> <tr>
<td colspan="6" class="px-6 py-3"> <td colspan="6" class="px-6 py-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="inline-block w-2.5 h-2.5 rounded-full <%= statusDot[statusKey] || 'bg-gray-300' %>"></span> <span class="inline-block w-2.5 h-2.5 rounded-full <%= statusDot[statusKey] || 'bg-gray-300' %>"></span>
<span class="text-sm font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-200"><%= statusKey %></span> <span class="text-sm font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-200"><%= statusKey %></span>
<span class="text-xs font-medium px-2 py-0.5 rounded-full bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300"><%= groups[statusKey].length %></span> <span data-group-count class="text-xs font-medium px-2 py-0.5 rounded-full bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300"><%= groups[statusKey].length %></span>
</div> </div>
</td> </td>
</tr> </tr>
@@ -186,7 +201,7 @@
const hatDetails = hatNotizen || hatInterne || hatVerlauf; const hatDetails = hatNotizen || hatInterne || hatVerlauf;
const padB = hatDetails ? 'pb-2' : 'pb-4'; const padB = hatDetails ? 'pb-2' : 'pb-4';
%> %>
<tbody class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"> <tbody class="app-row bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700" data-search="<%= ((app.firma || '') + ' ' + (app.stelle || '')).toLowerCase().replace(/"/g, '') %>">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"> <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm text-gray-900 dark:text-white"> <td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm text-gray-900 dark:text-white">
<%= new Date(app.datum).toLocaleDateString('de-DE') %> <%= new Date(app.datum).toLocaleDateString('de-DE') %>
@@ -279,6 +294,13 @@
</tbody> </tbody>
<% }); %> <% }); %>
<% }); %> <% }); %>
<tbody id="searchNoResults" class="bg-white dark:bg-gray-800" style="display:none">
<tr>
<td colspan="6" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<p>Keine Firma gefunden für „<span id="searchNoResultsTerm"></span>".</p>
</td>
</tr>
</tbody>
<% } else { %> <% } else { %>
<tbody class="bg-white dark:bg-gray-800"> <tbody class="bg-white dark:bg-gray-800">
<tr> <tr>
@@ -291,6 +313,54 @@
<% } %> <% } %>
</table> </table>
</div> </div>
<script>
// Live company search — filters the applications table by firma/stelle.
(function () {
var input = document.getElementById('firmaSearch');
if (!input) return;
var clear = document.getElementById('firmaSearchClear');
var appRows = Array.prototype.slice.call(document.querySelectorAll('tbody.app-row'));
var groupHeaders = Array.prototype.slice.call(document.querySelectorAll('tbody.status-group'));
var noResults = document.getElementById('searchNoResults');
var noResultsTerm = document.getElementById('searchNoResultsTerm');
function filter() {
var q = input.value.trim().toLowerCase();
if (clear) clear.classList.toggle('hidden', !q);
appRows.forEach(function (tb) {
var match = !q || (tb.dataset.search || '').indexOf(q) !== -1;
tb.style.display = match ? '' : 'none';
});
// Refresh each status group: hide headers with no visible rows, update counts.
groupHeaders.forEach(function (gh) {
var visible = 0, node = gh.nextElementSibling;
while (node && !node.classList.contains('status-group')) {
if (node.classList.contains('app-row') && node.style.display !== 'none') visible++;
node = node.nextElementSibling;
}
gh.style.display = (visible === 0 && q) ? 'none' : '';
var countEl = gh.querySelector('[data-group-count]');
if (countEl) countEl.textContent = visible;
});
var anyVisible = appRows.some(function (tb) { return tb.style.display !== 'none'; });
if (noResults) {
noResults.style.display = (!anyVisible && q) ? '' : 'none';
if (noResultsTerm) noResultsTerm.textContent = input.value.trim();
}
}
input.addEventListener('input', filter);
if (clear) clear.addEventListener('click', function () {
input.value = '';
filter();
input.focus();
});
})();
</script>
</main> </main>
<%- include('partials/footer') %> <%- include('partials/footer') %>