|
|
|
@ -249,62 +249,107 @@ |
|
|
|
<div class="mb-4 rounded border shadow-sm p-3"> |
|
|
|
<h4 class="mb-3">📋 Einträge</h4> |
|
|
|
|
|
|
|
<div class="mb-3"> |
|
|
|
<input type="text" id="entryFilter" class="form-control" placeholder="🔍 Filter nach Datum, Typ, Art …"> |
|
|
|
<div class="row g-3 mb-3 align-items-end"> |
|
|
|
<div class="col-12 col-lg-4"> |
|
|
|
<label for="entryFilter" class="form-label">Suche</label> |
|
|
|
<input type="text" id="entryFilter" class="form-control" placeholder="🔍 Filter nach Datum, Typ, Art …"> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div class="col-6 col-md-3 col-lg-2"> |
|
|
|
<label for="minYearFilter" class="form-label">Min. Jahr</label> |
|
|
|
<select id="minYearFilter" class="form-select"> |
|
|
|
<option value="">Alle</option> |
|
|
|
</select> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div class="col-6 col-md-3 col-lg-2"> |
|
|
|
<label for="maxYearFilter" class="form-label">Max. Jahr</label> |
|
|
|
<select id="maxYearFilter" class="form-select"> |
|
|
|
<option value="">Alle</option> |
|
|
|
</select> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div class="col-12 col-md-6 col-lg-4"> |
|
|
|
<label class="form-label d-block">Spalten</label> |
|
|
|
<div class="dropdown" data-bs-auto-close="outside"> |
|
|
|
<button |
|
|
|
class="btn btn-outline-secondary dropdown-toggle w-100 text-start" |
|
|
|
type="button" |
|
|
|
data-bs-toggle="dropdown" |
|
|
|
aria-expanded="false" |
|
|
|
> |
|
|
|
Spalten ein-/ausblenden |
|
|
|
</button> |
|
|
|
<div class="dropdown-menu p-3 shadow-sm" id="columnToggleMenu" style="min-width: 320px;"> |
|
|
|
<!-- wird per JS befüllt --> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
<div class="table-responsive"> |
|
|
|
<table class="table table-striped table-hover table-bordered" id="entriesTable"> |
|
|
|
<thead> |
|
|
|
<tr> |
|
|
|
<th data-sort-index="0" class="sortable">Datum</th> |
|
|
|
<th data-sort-index="1" class="sortable">Zeit</th> |
|
|
|
<th data-sort-index="2" class="sortable">Typ</th> |
|
|
|
<th data-sort-index="3" class="sortable">Art</th> |
|
|
|
<th data-sort-index="4" class="sortable">Beschreibung</th> |
|
|
|
<th data-sort-index="5" class="sortable">Kilometer</th> |
|
|
|
<th data-sort-index="6" class="sortable">Pauschale</th> |
|
|
|
<th data-sort-index="7" class="sortable">Kosten</th> |
|
|
|
<th data-sort-index="8" class="sortable">Zählt als Fortbildung</th> |
|
|
|
<th>Aktionen</th> |
|
|
|
<th data-sort-index="0" data-sort="date" class="sortable" data-column-key="datum">Datum</th> |
|
|
|
<th data-sort-index="1" data-sort="numeric" class="sortable" data-column-key="zeit">Zeit</th> |
|
|
|
<th data-sort-index="2" data-sort="text" class="sortable" data-column-key="typ">Typ</th> |
|
|
|
<th data-sort-index="3" data-sort="text" class="sortable" data-column-key="art">Art</th> |
|
|
|
<th data-sort-index="4" data-sort="text" class="sortable" data-column-key="beschreibung">Beschreibung</th> |
|
|
|
<th data-sort-index="5" data-sort="numeric" class="sortable" data-column-key="kilometer">Kilometer</th> |
|
|
|
<th data-sort-index="6" data-sort="numeric" class="sortable" data-column-key="pauschale">Pauschale</th> |
|
|
|
<th data-sort-index="7" data-sort="numeric" class="sortable" data-column-key="kosten">Kosten</th> |
|
|
|
<th data-sort-index="8" data-sort="boolean" class="sortable" data-column-key="fortbildung">Zählt als Fortbildung</th> |
|
|
|
<th data-column-key="aktionen">Aktionen</th> |
|
|
|
</tr> |
|
|
|
</thead> |
|
|
|
<tbody> |
|
|
|
<% @entries.each do |entry| %> |
|
|
|
<tr> |
|
|
|
<td> |
|
|
|
<% duration_in_minutes = entry.hours.to_i * 60 + entry.minutes.to_i %> |
|
|
|
|
|
|
|
<tr data-year="<%= entry.date.year %>"> |
|
|
|
<td data-sort-value="<%= entry.date.iso8601 %>"> |
|
|
|
<%= entry.date.strftime('%d.%m.%Y') %> |
|
|
|
</td> |
|
|
|
<td> |
|
|
|
<% if entry.hours.to_i > 0 || entry.minutes.to_i > 0 %> |
|
|
|
|
|
|
|
<td data-sort-value="<%= duration_in_minutes %>"> |
|
|
|
<% if duration_in_minutes > 0 %> |
|
|
|
<%= formatted_duration(entry) %> |
|
|
|
<% else %> |
|
|
|
— |
|
|
|
<% end %> |
|
|
|
</td> |
|
|
|
|
|
|
|
<td> |
|
|
|
<%= ["Fortbildung", "Semesterkosten"].include?(entry.entry_art) ? entry.entry_art : entry.praktikums_typ.capitalize %> |
|
|
|
</td> |
|
|
|
|
|
|
|
<td> |
|
|
|
<%= ["Fortbildung", "Semesterkosten"].include?(entry.entry_art) ? entry.beschreibung : entry.entry_art.capitalize %> |
|
|
|
</td> |
|
|
|
|
|
|
|
<td> |
|
|
|
<%= entry.beschreibung %> |
|
|
|
</td> |
|
|
|
<td> |
|
|
|
|
|
|
|
<td data-sort-value="<%= entry.distance_km.to_f %>"> |
|
|
|
<%= entry.distance_km.to_f %> km |
|
|
|
</td> |
|
|
|
<td> |
|
|
|
|
|
|
|
<td data-sort-value="<%= entry.kilometer_pauschale.to_f %>"> |
|
|
|
<%= number_to_currency(entry.kilometer_pauschale, unit: "€", separator: ",", delimiter: ".") %> |
|
|
|
</td> |
|
|
|
<td> |
|
|
|
|
|
|
|
<td data-sort-value="<%= entry.kosten.to_f %>"> |
|
|
|
<%= number_to_currency(entry.kosten, unit: "€", separator: ",", delimiter: ".") %> |
|
|
|
</td> |
|
|
|
<td class="text-center"> |
|
|
|
|
|
|
|
<td class="text-center" data-sort-value="<%= entry.zaehlt_als_fortbildung ? 1 : 0 %>"> |
|
|
|
<%= check_box_tag "fortbildung_#{entry.id}", '1', entry.zaehlt_als_fortbildung, disabled: true %> |
|
|
|
</td> |
|
|
|
|
|
|
|
<td class="text-end"> |
|
|
|
<div class="d-flex justify-content-between"> |
|
|
|
<div class="d-flex justify-content-between gap-2"> |
|
|
|
<%= link_to 'Bearbeiten', edit_entry_path(entry), class: 'btn btn-sm btn-outline-primary' %> |
|
|
|
<%= link_to 'Löschen', entry_path(entry), class: 'btn btn-sm btn-outline-danger open-delete-modal' %> |
|
|
|
</div> |
|
|
|
@ -314,10 +359,9 @@ |
|
|
|
</tbody> |
|
|
|
</table> |
|
|
|
</div> |
|
|
|
|
|
|
|
<%= link_to "Export als CSV", export_csv_entries_path(format: :csv), class: "btn btn-outline-secondary mt-3 w-100 w-md-auto" %> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- JS für Modal --> |
|
|
|
@ -337,21 +381,265 @@ |
|
|
|
</script> |
|
|
|
|
|
|
|
<script> |
|
|
|
document.addEventListener("DOMContentLoaded", function () { |
|
|
|
const filterInput = document.getElementById("entryFilter"); |
|
|
|
const table = document.getElementById("entriesTable"); |
|
|
|
const tableRows = table?.querySelectorAll("tbody tr"); |
|
|
|
if (filterInput && table && tableRows) { |
|
|
|
filterInput.addEventListener("keyup", function () { |
|
|
|
const query = this.value.toLowerCase(); |
|
|
|
console.log(query); |
|
|
|
tableRows.forEach(row => { |
|
|
|
const text = row.textContent.toLowerCase(); |
|
|
|
row.style.display = text.includes(query) ? "" : "none"; |
|
|
|
(() => { |
|
|
|
const initEntriesTable = () => { |
|
|
|
const table = document.getElementById("entriesTable"); |
|
|
|
if (!table || table.dataset.enhanced === "true") return; |
|
|
|
|
|
|
|
table.dataset.enhanced = "true"; |
|
|
|
|
|
|
|
const tbody = table.querySelector("tbody"); |
|
|
|
const textFilter = document.getElementById("entryFilter"); |
|
|
|
const minYearFilter = document.getElementById("minYearFilter"); |
|
|
|
const maxYearFilter = document.getElementById("maxYearFilter"); |
|
|
|
const columnToggleMenu = document.getElementById("columnToggleMenu"); |
|
|
|
|
|
|
|
if (!tbody || !textFilter || !minYearFilter || !maxYearFilter || !columnToggleMenu) return; |
|
|
|
|
|
|
|
const headers = Array.from(table.querySelectorAll("thead th")); |
|
|
|
const sortableHeaders = headers.filter(header => header.classList.contains("sortable")); |
|
|
|
const storageKey = "entriesTableColumnVisibility"; |
|
|
|
|
|
|
|
let currentSortIndex = null; |
|
|
|
let currentSortDirection = 1; |
|
|
|
|
|
|
|
const getRows = () => Array.from(tbody.querySelectorAll("tr")); |
|
|
|
|
|
|
|
const loadColumnVisibility = () => { |
|
|
|
try { |
|
|
|
return JSON.parse(localStorage.getItem(storageKey)) || {}; |
|
|
|
} catch (_) { |
|
|
|
return {}; |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
const saveColumnVisibility = (state) => { |
|
|
|
try { |
|
|
|
localStorage.setItem(storageKey, JSON.stringify(state)); |
|
|
|
} catch (_) { |
|
|
|
// localStorage evtl. nicht verfügbar – dann einfach ohne Persistenz weiter |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
const buildYearOptions = () => { |
|
|
|
const years = [...new Set( |
|
|
|
getRows() |
|
|
|
.map(row => Number(row.dataset.year)) |
|
|
|
.filter(year => Number.isFinite(year)) |
|
|
|
)].sort((a, b) => a - b); |
|
|
|
|
|
|
|
[minYearFilter, maxYearFilter].forEach(select => { |
|
|
|
const currentValue = select.value; |
|
|
|
select.innerHTML = '<option value="">Alle</option>'; |
|
|
|
|
|
|
|
years.forEach(year => { |
|
|
|
const option = document.createElement("option"); |
|
|
|
option.value = year; |
|
|
|
option.textContent = year; |
|
|
|
if (String(year) === currentValue) option.selected = true; |
|
|
|
select.appendChild(option); |
|
|
|
}); |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
const buildColumnToggleMenu = () => { |
|
|
|
const savedVisibility = loadColumnVisibility(); |
|
|
|
columnToggleMenu.innerHTML = ""; |
|
|
|
|
|
|
|
headers.forEach((header, index) => { |
|
|
|
const key = header.dataset.columnKey || `col_${index}`; |
|
|
|
header.dataset.columnKey = key; |
|
|
|
|
|
|
|
const wrapper = document.createElement("div"); |
|
|
|
wrapper.className = "form-check mb-2"; |
|
|
|
|
|
|
|
const input = document.createElement("input"); |
|
|
|
input.type = "checkbox"; |
|
|
|
input.className = "form-check-input"; |
|
|
|
input.id = `toggle-column-${key}`; |
|
|
|
input.dataset.columnKey = key; |
|
|
|
input.dataset.columnIndex = index; |
|
|
|
input.checked = savedVisibility[key] !== false; |
|
|
|
|
|
|
|
const label = document.createElement("label"); |
|
|
|
label.className = "form-check-label"; |
|
|
|
label.setAttribute("for", input.id); |
|
|
|
label.textContent = header.textContent.trim(); |
|
|
|
|
|
|
|
input.addEventListener("change", () => { |
|
|
|
const state = getColumnVisibilityState(); |
|
|
|
|
|
|
|
const checkedCount = Object.values(state).filter(Boolean).length; |
|
|
|
if (checkedCount === 0) { |
|
|
|
input.checked = true; |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
applyColumnVisibility(); |
|
|
|
saveColumnVisibility(getColumnVisibilityState()); |
|
|
|
applyFilters(); |
|
|
|
}); |
|
|
|
|
|
|
|
wrapper.appendChild(input); |
|
|
|
wrapper.appendChild(label); |
|
|
|
columnToggleMenu.appendChild(wrapper); |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
const getColumnVisibilityState = () => { |
|
|
|
const state = {}; |
|
|
|
columnToggleMenu.querySelectorAll('input[type="checkbox"]').forEach(input => { |
|
|
|
state[input.dataset.columnKey] = input.checked; |
|
|
|
}); |
|
|
|
return state; |
|
|
|
}; |
|
|
|
|
|
|
|
const applyColumnVisibility = () => { |
|
|
|
const state = getColumnVisibilityState(); |
|
|
|
|
|
|
|
headers.forEach((header, index) => { |
|
|
|
const key = header.dataset.columnKey; |
|
|
|
const visible = state[key] !== false; |
|
|
|
|
|
|
|
header.style.display = visible ? "" : "none"; |
|
|
|
|
|
|
|
getRows().forEach(row => { |
|
|
|
if (row.children[index]) { |
|
|
|
row.children[index].style.display = visible ? "" : "none"; |
|
|
|
} |
|
|
|
}); |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
const getVisibleRowText = (row) => { |
|
|
|
return Array.from(row.children) |
|
|
|
.filter(cell => cell.style.display !== "none") |
|
|
|
.map(cell => cell.textContent.toLowerCase()) |
|
|
|
.join(" "); |
|
|
|
}; |
|
|
|
|
|
|
|
const applyFilters = () => { |
|
|
|
const query = textFilter.value.trim().toLowerCase(); |
|
|
|
const minYear = minYearFilter.value ? Number(minYearFilter.value) : null; |
|
|
|
const maxYear = maxYearFilter.value ? Number(maxYearFilter.value) : null; |
|
|
|
|
|
|
|
getRows().forEach(row => { |
|
|
|
const rowYear = Number(row.dataset.year); |
|
|
|
const matchesQuery = !query || getVisibleRowText(row).includes(query); |
|
|
|
const matchesMinYear = !minYear || rowYear >= minYear; |
|
|
|
const matchesMaxYear = !maxYear || rowYear <= maxYear; |
|
|
|
|
|
|
|
row.style.display = (matchesQuery && matchesMinYear && matchesMaxYear) ? "" : "none"; |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
const parseNumeric = (value) => { |
|
|
|
if (value === null || value === undefined || value === "") return null; |
|
|
|
const cleaned = String(value).replace(/[^\d,.-]/g, "").replace(",", "."); |
|
|
|
const number = parseFloat(cleaned); |
|
|
|
return Number.isNaN(number) ? null : number; |
|
|
|
}; |
|
|
|
|
|
|
|
const getComparableValue = (cell, sortType) => { |
|
|
|
const explicitValue = cell.dataset.sortValue; |
|
|
|
|
|
|
|
if (explicitValue !== undefined) { |
|
|
|
if (sortType === "date") { |
|
|
|
const time = Date.parse(explicitValue); |
|
|
|
return Number.isNaN(time) ? null : time; |
|
|
|
} |
|
|
|
|
|
|
|
if (["numeric", "boolean"].includes(sortType)) { |
|
|
|
const num = Number(explicitValue); |
|
|
|
return Number.isNaN(num) ? null : num; |
|
|
|
} |
|
|
|
|
|
|
|
return String(explicitValue).toLowerCase(); |
|
|
|
} |
|
|
|
|
|
|
|
const text = cell.textContent.trim(); |
|
|
|
if (!text || text === "—") return null; |
|
|
|
|
|
|
|
if (sortType === "date") { |
|
|
|
const parts = text.split("."); |
|
|
|
if (parts.length === 3) { |
|
|
|
const isoLike = `${parts[2]}-${parts[1]}-${parts[0]}`; |
|
|
|
const time = Date.parse(isoLike); |
|
|
|
return Number.isNaN(time) ? null : time; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
if (sortType === "numeric") { |
|
|
|
return parseNumeric(text); |
|
|
|
} |
|
|
|
|
|
|
|
if (sortType === "boolean") { |
|
|
|
const checkbox = cell.querySelector('input[type="checkbox"]'); |
|
|
|
if (checkbox) return checkbox.checked ? 1 : 0; |
|
|
|
return null; |
|
|
|
} |
|
|
|
|
|
|
|
return text.toLowerCase(); |
|
|
|
}; |
|
|
|
|
|
|
|
const compareRows = (rowA, rowB, index, sortType, direction) => { |
|
|
|
const cellA = rowA.children[index]; |
|
|
|
const cellB = rowB.children[index]; |
|
|
|
|
|
|
|
const valueA = getComparableValue(cellA, sortType); |
|
|
|
const valueB = getComparableValue(cellB, sortType); |
|
|
|
|
|
|
|
const emptyA = valueA === null || valueA === ""; |
|
|
|
const emptyB = valueB === null || valueB === ""; |
|
|
|
|
|
|
|
if (emptyA && emptyB) return 0; |
|
|
|
if (emptyA) return 1; |
|
|
|
if (emptyB) return -1; |
|
|
|
|
|
|
|
if (sortType === "text") { |
|
|
|
return direction * String(valueA).localeCompare(String(valueB), "de", { sensitivity: "base" }); |
|
|
|
} |
|
|
|
|
|
|
|
return direction * (valueA - valueB); |
|
|
|
}; |
|
|
|
|
|
|
|
const applySorting = (header) => { |
|
|
|
const index = Number(header.dataset.sortIndex); |
|
|
|
const sortType = header.dataset.sort || "text"; |
|
|
|
|
|
|
|
if (currentSortIndex === index) { |
|
|
|
currentSortDirection *= -1; |
|
|
|
} else { |
|
|
|
currentSortIndex = index; |
|
|
|
currentSortDirection = 1; |
|
|
|
} |
|
|
|
|
|
|
|
const sortedRows = getRows().sort((a, b) => |
|
|
|
compareRows(a, b, index, sortType, currentSortDirection) |
|
|
|
); |
|
|
|
|
|
|
|
sortedRows.forEach(row => tbody.appendChild(row)); |
|
|
|
applyFilters(); |
|
|
|
}; |
|
|
|
|
|
|
|
buildYearOptions(); |
|
|
|
buildColumnToggleMenu(); |
|
|
|
applyColumnVisibility(); |
|
|
|
applyFilters(); |
|
|
|
|
|
|
|
textFilter.addEventListener("input", applyFilters); |
|
|
|
minYearFilter.addEventListener("change", applyFilters); |
|
|
|
maxYearFilter.addEventListener("change", applyFilters); |
|
|
|
|
|
|
|
sortableHeaders.forEach(header => { |
|
|
|
header.style.cursor = "pointer"; |
|
|
|
header.addEventListener("click", () => applySorting(header)); |
|
|
|
}); |
|
|
|
} |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
document.addEventListener("DOMContentLoaded", initEntriesTable); |
|
|
|
document.addEventListener("turbo:load", initEntriesTable); |
|
|
|
})(); |
|
|
|
</script> |
|
|
|
<script> |
|
|
|
document.addEventListener("DOMContentLoaded", () => { |
|
|
|
|