This commit is contained in:
Oliver Walter
2026-06-06 15:37:42 +02:00
commit 5f43c19878
26 changed files with 2881 additions and 0 deletions
+533
View File
@@ -0,0 +1,533 @@
use crate::components::filter_bar::{FilterBar, FilterZustand};
use crate::components::tool_form::{FormModus, ToolForm};
use crate::models::tool::{Prioritaet, Status, Tool};
use crate::server::api::{csv_importieren, tool_loeschen, tools_laden};
use leptos::html;
use leptos::prelude::*;
// ---------------------------------------------------------------------------
// Sortier-Zustand (rein UI-lokal, kein Serde nötig)
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq, Default)]
enum SortSpalte {
#[default]
Keine,
Status,
Prioritaet,
}
#[derive(Clone, Debug, PartialEq, Default)]
enum SortRichtung {
#[default]
Aufsteigend,
Absteigend,
}
impl SortRichtung {
fn umkehren(&self) -> Self {
match self {
SortRichtung::Aufsteigend => SortRichtung::Absteigend,
SortRichtung::Absteigend => SortRichtung::Aufsteigend,
}
}
}
#[derive(Clone, Debug, PartialEq, Default)]
struct SortZustand {
spalte: SortSpalte,
richtung: SortRichtung,
}
fn status_rang(s: &Status) -> u8 {
match s {
Status::Offen => 0,
Status::Organisiert => 1,
Status::VorOrt => 2,
}
}
fn prioritaet_rang(p: &Prioritaet) -> u8 {
match p {
Prioritaet::Hoch => 0,
Prioritaet::Mittel => 1,
Prioritaet::Niedrig => 2,
}
}
#[component]
pub fn ToolList() -> impl IntoView {
// Aktualisierungs-Zähler: hochzählen löst Resource-Reload aus
let version = RwSignal::new(0u32);
let neu_laden = move || version.update(|v| *v += 1);
// Daten vom Server wird NUR innerhalb <Suspense> gelesen
let tools_resource = Resource::new(move || version.get(), |_| tools_laden());
// Formular-Modus: None = Liste, Some(Neu/Bearbeiten)
let bearbeitungs_modus = RwSignal::new(Option::<FormModus>::None);
// Filter-Zustand
let filter = RwSignal::new(FilterZustand::default());
// Welche Zeile ist gerade aufgeklappt? (ID oder None)
let aufgeklappt = RwSignal::new(Option::<String>::None);
// Sortier-Zustand
let sortierung = RwSignal::new(SortZustand::default());
// NodeRef + Effect für den Modal-Dialog
let dialog_ref = NodeRef::<html::Dialog>::new();
Effect::new(move |_| {
if let Some(el) = dialog_ref.get() {
if bearbeitungs_modus.get().is_some() {
let _ = el.show_modal();
} else {
el.close();
}
}
});
// Tool-Cache: Tabelle rendert immer aus diesem Signal, nie direkt aus der Resource.
// → Suspense-Fallback beim Reload lässt die Tabelle sichtbar (kein Scroll-Reset).
let tools_cache = RwSignal::new(Vec::<Tool>::new());
let ist_geladen = RwSignal::new(false);
// CSV-Import-Meldungen
let csv_fehler = RwSignal::new(Option::<String>::None);
let csv_erfolg = RwSignal::new(Option::<String>::None);
// Löschen-Aktion
let loeschen_aktion = Action::new(|id: &String| {
let id = id.clone();
async move { tool_loeschen(id).await }
});
let neu_laden_nach_loeschen = neu_laden.clone();
Effect::new(move |_| {
if let Some(ergebnis) = loeschen_aktion.value().get() {
if ergebnis.is_ok() {
neu_laden_nach_loeschen();
}
}
});
// CSV-Datei einlesen
let csv_import = move |ev: leptos::ev::Event| {
use wasm_bindgen::JsCast;
let input = ev
.target()
.unwrap()
.dyn_into::<web_sys::HtmlInputElement>()
.unwrap();
let files = input.files().unwrap();
if files.length() == 0 {
return;
}
let file = files.get(0).unwrap();
let reader = web_sys::FileReader::new().unwrap();
let reader_clone = reader.clone();
let neu_laden_csv = neu_laden.clone();
let onload = wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::Event| {
let inhalt = reader_clone
.result()
.unwrap()
.as_string()
.unwrap_or_default();
let inhalt_clone = inhalt.clone();
let neu_laden_inner = neu_laden_csv.clone();
leptos::task::spawn_local(async move {
match csv_importieren(inhalt_clone).await {
Ok(n) => {
csv_erfolg.set(Some(format!("{n} Werkzeuge importiert.")));
csv_fehler.set(None);
neu_laden_inner();
}
Err(e) => {
csv_fehler.set(Some(e.to_string()));
csv_erfolg.set(None);
}
}
});
}) as Box<dyn FnMut(_)>);
reader.set_onload(Some(onload.as_ref().unchecked_ref()));
onload.forget();
reader.read_as_text(&file).unwrap();
};
view! {
<div class="seite">
// ---- Kopfzeile ----
<header class="kopfzeile">
<h1>"🔧 Werkzeugliste"</h1>
<div class="kopf-aktionen">
<button
class="btn btn-primary"
on:click=move |_| bearbeitungs_modus.set(Some(FormModus::Neu))
>
"+ Neu"
</button>
<a href="/export/tools.csv" class="btn btn-secondary">
"⬇ CSV Export"
</a>
<label class="btn btn-secondary csv-label">
"⬆ CSV Import"
<input
type="file"
accept=".csv,.txt"
class="csv-input-versteckt"
on:change=csv_import
/>
</label>
</div>
</header>
// ---- CSV-Meldungen ----
{move || csv_erfolg.get().map(|m| view! {
<div class="meldung meldung-erfolg">{m}</div>
})}
{move || csv_fehler.get().map(|e| view! {
<div class="meldung meldung-fehler">{e}</div>
})}
// ---- Suspense: nur zum Signal-Update beim (Re-)Laden ----
// Die Tabelle selbst ist NICHT Teil des Suspense-Blocks.
// Beim Reload bleibt sie sichtbar (alter Cache-Wert) → kein Scroll-Reset.
<Suspense fallback=move || {
// Nur beim ersten Laden sichtbar; beim Reload unsichtbar
if ist_geladen.get() {
view! { <span></span> }.into_any()
} else {
view! { <p class="lade-text">"Lade Werkzeuge…"</p> }.into_any()
}
}>
{move || tools_resource.get().map(|result| {
tools_cache.set(result.unwrap_or_default());
ist_geladen.set(true);
})}
</Suspense>
// ---- Filter + Tabelle (rendert aus Cache, nie aus Resource direkt) ----
{move || {
if !ist_geladen.get() {
return view! { <span></span> }.into_any();
}
let alle_tools = tools_cache.get();
let f = filter.get();
let mut alle_tags: Vec<String> = alle_tools
.iter()
.flat_map(|t| t.tags.iter().cloned())
.collect();
alle_tags.sort();
alle_tags.dedup();
let tools: Vec<Tool> = alle_tools
.into_iter()
.filter(|t| {
if !f.suche.is_empty() {
let s = f.suche.to_lowercase();
let treffer = t.name.to_lowercase().contains(&s)
|| t.tags.iter().any(|tag| tag.to_lowercase().contains(&s))
|| t.notizen
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&s);
if !treffer {
return false;
}
}
if let Some(ref st) = f.status {
if &t.status != st {
return false;
}
}
if let Some(ref p) = f.prioritaet {
if &t.prioritaet != p {
return false;
}
}
if let Some(ref b) = f.beschaffung {
if &t.beschaffung.art != b {
return false;
}
}
if !f.tags_einschliessen.is_empty()
&& !f.tags_einschliessen.iter().any(|tag| t.tags.contains(tag))
{
return false;
}
if f.tags_ausschliessen.iter().any(|tag| t.tags.contains(tag)) {
return false;
}
true
})
.collect();
// Sortierung anwenden
let sort = sortierung.get();
let mut tools = tools;
if sort.spalte != SortSpalte::Keine {
tools.sort_by(|a, b| {
let ord = match sort.spalte {
SortSpalte::Status => status_rang(&a.status).cmp(&status_rang(&b.status)),
SortSpalte::Prioritaet => prioritaet_rang(&a.prioritaet).cmp(&prioritaet_rang(&b.prioritaet)),
SortSpalte::Keine => std::cmp::Ordering::Equal,
};
if sort.richtung == SortRichtung::Absteigend { ord.reverse() } else { ord }
});
}
view! {
<FilterBar filter=filter alle_tags=alle_tags />
{if tools.is_empty() {
view! {
<p class="leer-text">"Keine Werkzeuge gefunden."</p>
}.into_any()
} else {
view! {
<div class="tabellen-wrapper">
<table class="tool-tabelle">
<thead>
<tr>
<th class="toggle-th"></th>
<th>"Name"</th>
<th>"Anzahl"</th>
<th>"Beschaffung"</th>
<th
class="th-sortierbar"
on:click=move |_| {
sortierung.update(|s| {
if s.spalte == SortSpalte::Status {
s.richtung = s.richtung.umkehren();
} else {
s.spalte = SortSpalte::Status;
s.richtung = SortRichtung::Aufsteigend;
}
});
}
>
"Status"
{move || match sortierung.get() {
SortZustand { spalte: SortSpalte::Status, richtung: SortRichtung::Aufsteigend } => "",
SortZustand { spalte: SortSpalte::Status, richtung: SortRichtung::Absteigend } => "",
_ => "",
}}
</th>
<th
class="th-sortierbar"
on:click=move |_| {
sortierung.update(|s| {
if s.spalte == SortSpalte::Prioritaet {
s.richtung = s.richtung.umkehren();
} else {
s.spalte = SortSpalte::Prioritaet;
s.richtung = SortRichtung::Aufsteigend;
}
});
}
>
"Priorität"
{move || match sortierung.get() {
SortZustand { spalte: SortSpalte::Prioritaet, richtung: SortRichtung::Aufsteigend } => "",
SortZustand { spalte: SortSpalte::Prioritaet, richtung: SortRichtung::Absteigend } => "",
_ => "",
}}
</th>
<th>"Tags"</th>
<th>"Aktionen"</th>
</tr>
</thead>
<tbody>
{tools.into_iter().map(|tool| {
// Drei separate ID-Klone für drei verschiedene Closures
let id_toggle = tool.id.clone();
let id_arrow = tool.id.clone();
let id_style = tool.id.clone();
let tool_id = tool.id.clone();
let tool_edit = tool.clone();
// Daten für die Detail-Zeile (statisch geklont)
let verwendung = tool.verwendung.clone();
let notizen = tool.notizen.clone();
let verantwortlich = tool.verantwortlich.clone();
let erstellt_am = tool.erstellt_am.clone();
view! {
<>
// --- Hauptzeile ---
<tr class="tool-zeile">
<td class="toggle-zelle">
<button
class="btn-toggle"
on:click=move |_| {
let id = id_toggle.clone();
aufgeklappt.update(|a| {
if a.as_deref() == Some(&id) {
*a = None;
} else {
*a = Some(id);
}
});
}
>
{move || if aufgeklappt.get().as_deref() == Some(&id_arrow) { "" } else { "" }}
</button>
</td>
<td class="tool-name">
<strong>{tool.name.clone()}</strong>
</td>
<td>{tool.anzahl.to_string()}</td>
<td>{tool.beschaffung.to_string()}</td>
<td>
<span class={format!("status-badge status-{}", tool.status.to_string().to_lowercase().replace(' ', "-"))}>
{tool.status.to_string()}
</span>
</td>
<td>
<span class={format!("prio-badge prio-{}", tool.prioritaet.to_string().to_lowercase())}>
{tool.prioritaet.to_string()}
</span>
</td>
<td class="tag-zelle">
{tool.tags.iter().map(|tag| view! {
<span class="tag-chip">{tag.clone()}</span>
}).collect::<Vec<_>>()}
</td>
<td class="aktionen-zelle">
<button
class="btn btn-klein btn-sekundaer"
on:click=move |_| {
bearbeitungs_modus.set(Some(
FormModus::Bearbeiten(tool_edit.clone())
));
}
>
""
</button>
<button
class="btn btn-klein btn-gefahr"
on:click=move |_| {
if web_sys::window()
.unwrap()
.confirm_with_message(
"Werkzeug wirklich löschen?"
)
.unwrap_or(false)
{
loeschen_aktion.dispatch(tool_id.clone());
}
}
>
"🗑"
</button>
</td>
</tr>
// --- Detail-Zeile (immer im DOM, per style ein-/ausgeblendet) ---
<tr
class="detail-zeile"
style=move || {
if aufgeklappt.get().as_deref() == Some(&id_style) {
"display: table-row;"
} else {
"display: none;"
}
}
>
<td colspan="8" class="detail-inhalt">
<div class="detail-grid">
// Verwendung
{if !verwendung.is_empty() {
view! {
<div class="detail-block">
<span class="detail-label">"Verwendung"</span>
<ul class="detail-verwendung">
{verwendung.iter().map(|v| view! {
<li>{v.clone()}</li>
}).collect::<Vec<_>>()}
</ul>
</div>
}.into_any()
} else {
view! { <span></span> }.into_any()
}}
// Notizen
{notizen.map(|n| view! {
<div class="detail-block">
<span class="detail-label">"Notizen"</span>
<p class="detail-text">{n}</p>
</div>
})}
// Verantwortlich
{verantwortlich.map(|v| view! {
<div class="detail-block">
<span class="detail-label">"Verantwortlich"</span>
<p class="detail-text">{v}</p>
</div>
})}
// Erstellt am
<div class="detail-block detail-meta">
<span class="detail-label">"Erstellt am"</span>
<p class="detail-text detail-datum">{erstellt_am}</p>
</div>
</div>
</td>
</tr>
</>
}
}).collect::<Vec<_>>()}
</tbody>
</table>
</div>
}.into_any()
}}
}.into_any()
}}
// ---- Modal-Dialog ----
<dialog
node_ref=dialog_ref
class="tool-dialog"
on:close=move |_| bearbeitungs_modus.set(None)
on:click=move |ev| {
use wasm_bindgen::JsCast;
// Klick direkt auf <dialog> = Klick auf den Backdrop
let auf_backdrop = ev.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlDialogElement>().ok())
.is_some();
if auf_backdrop {
bearbeitungs_modus.set(None);
}
}
>
{move || {
bearbeitungs_modus.get().map(|modus| view! {
<ToolForm
modus=modus
on_fertig=Callback::new(move |_| {
bearbeitungs_modus.set(None);
neu_laden();
})
on_abbrechen=Callback::new(move |_| {
bearbeitungs_modus.set(None);
})
/>
})
}}
</dialog>
</div>
}
}