534 lines
28 KiB
Rust
534 lines
28 KiB
Rust
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>
|
||
}
|
||
}
|