Files
toollist/src/components/tool_list.rs
T
Oliver Walter 5f43c19878 initial
2026-06-06 15:37:42 +02:00

534 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
}
}