You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

703 lines
28 KiB

<h1 class="mb-4">Meine Einträge</h1>
<!-- 🧭 Tabs-Navigation -->
<ul class="nav nav-tabs mt-4" id="dashboardTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="overview-tab" data-bs-toggle="tab" data-bs-target="#overview" type="button" role="tab">📈 Kombinationen</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="chart-tab" data-bs-toggle="tab" data-bs-target="#chart" type="button" role="tab">📊 Fortschritt</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="summary-tab" data-bs-toggle="tab" data-bs-target="#summary" type="button" role="tab">📋 Zusammenfassung</button>
</li>
</ul>
<!-- 📑 Tab-Inhalte -->
<div class="tab-content border rounded-bottom p-3 shadow-sm" id="dashboardTabsContent">
<!-- 📈 Kombinationen -->
<div class="tab-pane fade show active" id="overview" role="tabpanel">
<div class="mb-4 rounded border shadow-sm p-3">
<h4>📊 Übersicht je Kombination</h4>
<div class="table-responsive">
<table class="table table-bordered table-sm table-striped">
<thead class="table-light">
<tr>
<th>Typ</th>
<th>Art</th>
<th>Verbleibend</th>
<th>Erledigt</th>
<th>Soll (h)</th>
<th>Wöchentlich</th>
<th>Stunden/Woche</th>
<th>Vorauss. Ende</th>
<th>Fortschritt</th>
</tr>
</thead>
<tbody>
<% Entry::PRAKTIKUMSTYPEN.each do |typ| %>
<% Entry::ENTRY_ARTEN.each do |art| %>
<% remaining = @remaining_minutes_matrix.dig(typ, art) %>
<% spent = @spent_minutes_matrix.dig(typ, art) %>
<% next if spent.blank? %>
<% soll = current_user.required_hours_matrix.dig(typ, art) %>
<% next if soll.blank? %>
<% weekly = current_user.weekly_target_matrix.dig(typ, art) %>
<% ende = @estimated_end_by_typ_art.dig(typ, art) %>
<% if (remaining.present? || soll.present? || weekly.present?) %>
<tr>
<td><%= typ.capitalize %></td>
<td><%= art.capitalize %></td>
<td><%= remaining ? "#{remaining / 60} h #{remaining % 60} min" : "—" %></td>
<td><%= spent ? "#{spent / 60} h #{spent % 60} min" : "—" %></td>
<td><%= soll || "—" %> h</td>
<td><%= weekly || "—" %> h/Woche</td>
<td><%= @actual_hours_per_week[[typ, art]] ? "#{@actual_hours_per_week[[typ, art]]} h/Woche" : "—" %></td>
<td><%= ende.present? ? ende.strftime("%d.%m.%Y") : "—" %></td>
<td>
<% percent = @completion_percent_by_typ_art[[typ, art]] %>
<%= percent ? "#{percent} %" : "—" %>
</td>
</tr>
<% end %>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
<!-- 📊 Fortschritt (Bar Chart) -->
<div class="tab-pane fade" id="chart" role="tabpanel">
<div class="container my-4 rounded border shadow-sm py-3 px-4">
<%= bar_chart @progress_chart_data,
colors: @progress_colors.values,
suffix: "%",
donut: false,
height: "300px",
plugins: {
tooltip: {
callbacks: {
label: %|function(context) {
return context.dataset.label + ": " + context.raw + "%";
}|
}
},
legend: {
position: "bottom"
}
},
library: {
datasets: [
{
backgroundColor: %|function(context) {
const value = context.raw;
if (value < 40) return "rgba(54, 162, 235, 0.7)"; // blau
if (value < 80) return "rgba(75, 192, 192, 0.7)"; // grün
return "rgba(255, 99, 132, 0.7)"; // rot
}|
}
],
legend: { position: "bottom" },
tooltip: {
callbacks: {
label: %|function(context) {
return context.label + ": " + context.parsed + "%";
}|
}
}
} %>
</div>
</div>
<!-- 📋 Zusammenfassung -->
<div class="tab-pane fade" id="summary" role="tabpanel">
<div class="container my-4 rounded border shadow-sm py-3 px-4">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
<div class="col rounded border shadow-sm p-3">
<h5>🚗 Fahrtkosten (Kilometergeld)</h5>
<% @total_kilometer_costs_by_year.each do |year, sum| %>
<% rate = MileageRate.find_by(year: year)&.rate_per_km || 0.42 %>
<% km = rate > 0 ? (sum.to_f / rate).round(1) : "?" %>
<p><strong><%= year %>:</strong>
<%= number_to_currency(sum, unit: "€", separator: ",", delimiter: ".", precision: 2) %>
<i>(<%= km %> km bei <%= number_to_currency(rate, unit: "€", separator: ",", delimiter: ".", precision: 2) %>/km)</i>
</p>
<% end %>
</div>
<div class="col rounded border shadow-sm p-3">
<h5>💶 Fortbildungskosten</h5>
<% @fortbildungskosten_by_year.each do |year, sum| %>
<p><strong><%= year %>:</strong>
<%= number_to_currency(sum, unit: "€", separator: ",", delimiter: ".", precision: 2) %>
</p>
<% end %>
</div>
<div class="col rounded border shadow-sm p-3">
<h5>🧠 Selbsterfahrungskosten</h5>
<% @selbsterfahrungskosten_by_year.each do |year, sum| %>
<p><strong><%= year %>:</strong>
<%= number_to_currency(sum, unit: "€", separator: ",", delimiter: ".", precision: 2) %>
</p>
<% end %>
</div>
<div class="col rounded border shadow-sm p-3">
<h5>👨‍🏫 Supervision</h5>
<% @selbstsupervision_by_year.each do |year, sum| %>
<p><strong><%= year %>:</strong>
<%= number_to_currency(sum, unit: "€", separator: ",", delimiter: ".", precision: 2) %>
</p>
<% end %>
</div>
<div class="col rounded border shadow-sm p-3">
<h5>🧾 Gesamtkosten (Weiterbildungskosten + Kilometergeld)</h5>
<% @gesamtkosten_by_year.each do |year, sum| %>
<p><strong><%= year %>:</strong>
<%= number_to_currency(sum, unit: "€", separator: ",", delimiter: ".", precision: 2) %>
</p>
<% end %>
</div>
<div class="col rounded border shadow-sm p-3">
<h5>🕒 Gesamtzeit</h5>
<p><strong>Total: <%= @total_minutes / 60 %>h <%= @total_minutes % 60 %> min</strong></p>
<p>Propädeutikum: <%= @total_minutes_praktikum_prop / 60 %>h <%= @total_minutes_praktikum_prop % 60 %> min</p>
<p>Fachspezifikum: <%= @total_minutes_praktikum_fach / 60 %>h <%= @total_minutes_praktikum_fach % 60 %> min</p>
</div>
</div>
</div>
</div>
</div>
<div class="container my-4 rounded border shadow-sm p-3">
<h4 class="mb-4">⏱ Timer</h4>
<% if @running_entry.present? %>
<div class="alert alert-info d-flex justify-content-between align-items-center">
<div>
<strong>Laufender Eintrag:</strong>
<%= @running_entry.praktikums_typ %> – <%= @running_entry.entry_art %><br>
Gestartet: <%= l(@running_entry.start_time, format: :short) %><br>
Dauer: <span id="live-timer">Berechne …</span>
</div>
<div>
<%= form_with url: stop_timer_entry_path(@running_entry), method: :post, local: true do |f| %>
<div class="form-check mb-2">
<%= f.check_box :lunch_break, class: 'form-check-input', id: 'lunch_break' %>
<%= f.label :lunch_break, '30 Min Mittagspause abziehen', class: 'form-check-label' %>
</div>
<%= f.submit '⏹️ Stoppen', class: "btn btn-danger" %>
<% end %>
</div>
</div>
<% end %>
<div class="mb-4 row">
<%= form_with url: start_timer_entries_path, method: :post, local: true do %>
<div class="row g-2 align-items-end">
<div class="col-md-4">
<%= label_tag :typ, 'Typ' %>
<%= select_tag :typ,
options_for_select(
current_user.praepedeutikum_abgeschlossen? ?
Entry::PRAKTIKUMSTYPEN.reject { |typ| typ == 'propädeutikum' } :
Entry::PRAKTIKUMSTYPEN
),
class: "form-select" %>
</div>
<div class="col-md-4">
<%= label_tag :art, 'Art' %>
<%= select_tag :art, options_for_select(Entry::ENTRY_ARTEN), class: "form-select" %>
</div>
<div class="col-md-4">
<%= submit_tag '▶️ Start', class: "btn btn-success w-100", disabled: @running_entry.present? %>
</div>
</div>
<% end %>
</div>
</div>
<%= link_to '➕ Neuer Eintrag', new_entry_path, class: 'btn btn-outline-primary mt-3 w-100 w-md-auto mb-3' %>
<!-- 📋 Tabelle aller Einträge -->
<div class="mb-4 rounded border shadow-sm p-3">
<h4 class="mb-3">📋 Einträge</h4>
<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" 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| %>
<% 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 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 data-sort-value="<%= entry.distance_km.to_f %>">
<%= entry.distance_km.to_f %> km
</td>
<td data-sort-value="<%= entry.kilometer_pauschale.to_f %>">
<%= number_to_currency(entry.kilometer_pauschale, unit: "€", separator: ",", delimiter: ".") %>
</td>
<td data-sort-value="<%= entry.kosten.to_f %>">
<%= number_to_currency(entry.kosten, unit: "€", separator: ",", delimiter: ".") %>
</td>
<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 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>
</td>
</tr>
<% end %>
</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>
<!-- JS für Modal -->
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.open-delete-modal').forEach(button => {
button.addEventListener('click', function (e) {
e.preventDefault();
const targetUrl = this.getAttribute('href');
document.getElementById('modal-delete-form').setAttribute('action', targetUrl);
const modal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
modal.show();
});
});
});
</script>
<script>
(() => {
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", () => {
const table = document.getElementById("entriesTable");
const headers = table.querySelectorAll("th.sortable");
let sortDirection = 1;
headers.forEach((header, index) => {
header.style.cursor = 'pointer';
header.addEventListener("click", () => {
const tbody = table.querySelector("tbody");
const rows = Array.from(tbody.querySelectorAll("tr"));
const isNumeric = header.dataset.sort === 'km' || header.dataset.sort === 'pauschale';
const isDate = header.dataset.sort === 'date';
rows.sort((a, b) => {
const cellA = a.children[index].textContent.trim();
const cellB = b.children[index].textContent.trim();
if (isDate) {
return sortDirection * (new Date(cellA.split('.').reverse().join('-')) - new Date(cellB.split('.').reverse().join('-')));
}
if (isNumeric) {
const numA = parseFloat(cellA.replace(/[^\d,.-]/g, "").replace(",", "."));
const numB = parseFloat(cellB.replace(/[^\d,.-]/g, "").replace(",", "."));
return sortDirection * (numA - numB);
}
return sortDirection * cellA.localeCompare(cellB);
});
rows.forEach(row => tbody.appendChild(row));
sortDirection *= -1; // Toggle asc/desc
});
});
});
document.addEventListener('DOMContentLoaded', function () {
const timerElement = document.getElementById('live-timer');
if (!timerElement) return;
<% if @running_entry&.start_time.present? %>
const startedAt = new Date("<%= @running_entry.start_time.iso8601 %>");
<% end %>
if (!startedAt) return;
const updateTimer = () => {
const now = new Date();
const diffMs = now - startedAt;
const totalMinutes = Math.floor(diffMs / 60000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
timerElement.textContent = `${hours}h ${minutes}min (${totalMinutes}min)`;
};
updateTimer();
setInterval(updateTimer, 60000); // alle Minute aktualisieren
});
</script>