From 5f43c19878ac54c8081684c2ce1241d805302c17 Mon Sep 17 00:00:00 2001 From: Oliver Walter Date: Sat, 6 Jun 2026 15:37:42 +0200 Subject: [PATCH] initial --- .gitignore | 14 + Cargo.toml | 119 ++++++++ LICENSE | 24 ++ README.md | 174 +++++++++++ documentation.md | 357 +++++++++++++++++++++++ end2end/.gitignore | 3 + end2end/package.json | 15 + end2end/playwright.config.ts | 105 +++++++ end2end/tests/example.spec.ts | 9 + end2end/tsconfig.json | 109 +++++++ public/favicon.ico | Bin 0 -> 15406 bytes src/app.rs | 43 +++ src/components/filter_bar.rs | 168 +++++++++++ src/components/mod.rs | 3 + src/components/tool_form.rs | 366 +++++++++++++++++++++++ src/components/tool_list.rs | 533 ++++++++++++++++++++++++++++++++++ src/lib.rs | 12 + src/main.rs | 46 +++ src/models/mod.rs | 1 + src/models/tool.rs | 235 +++++++++++++++ src/server/api.rs | 123 ++++++++ src/server/export.rs | 72 +++++ src/server/mod.rs | 5 + src/server/store.rs | 67 +++++ style/main.scss | 270 +++++++++++++++++ toollist_data.md | 8 + 26 files changed, 2881 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 documentation.md create mode 100644 end2end/.gitignore create mode 100644 end2end/package.json create mode 100644 end2end/playwright.config.ts create mode 100644 end2end/tests/example.spec.ts create mode 100644 end2end/tsconfig.json create mode 100644 public/favicon.ico create mode 100644 src/app.rs create mode 100644 src/components/filter_bar.rs create mode 100644 src/components/mod.rs create mode 100644 src/components/tool_form.rs create mode 100644 src/components/tool_list.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/tool.rs create mode 100644 src/server/api.rs create mode 100644 src/server/export.rs create mode 100644 src/server/mod.rs create mode 100644 src/server/store.rs create mode 100644 style/main.scss create mode 100644 toollist_data.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6985cf1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fcaa11f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,119 @@ +[package] +name = "toollist" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +leptos = { version = "0.8.0" } +leptos_router = { version = "0.8.0" } +leptos_meta = { version = "0.8.0" } +serde = { version = "1", features = ["derive"] } +axum = { version = "0.8.0", optional = true } +console_error_panic_hook = { version = "0.1", optional = true } +leptos_axum = { version = "0.8.0", optional = true } +tokio = { version = "1", features = ["rt-multi-thread"], optional = true } +wasm-bindgen = { version = "=0.2.106" } +wasm-bindgen-futures = { version = "0.4" } +web-sys = { version = "0.3", features = [ + "Event", + "EventTarget", + "File", + "FileList", + "FileReader", + "HtmlDialogElement", + "HtmlInputElement", + "Window", +] } +uuid = { version = "1", features = ["v4"], optional = true } +chrono = { version = "0.4", optional = true } +toml = { version = "0.8", optional = true } + +[features] +hydrate = [ + "leptos/hydrate", + "dep:console_error_panic_hook", +] +ssr = [ + "dep:axum", + "dep:tokio", + "dep:leptos_axum", + "dep:uuid", + "dep:chrono", + "dep:toml", + "leptos/ssr", + "leptos_meta/ssr", + "leptos_router/ssr", +] + +# Defines a size-optimized profile for the WASM bundle in release mode +[profile.wasm-release] +inherits = "release" +opt-level = 'z' +lto = true +codegen-units = 1 +panic = "abort" + +[package.metadata.leptos] +# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name +output-name = "toollist" + +# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup. +site-root = "target/site" + +# The site-root relative folder where all compiled output (JS, WASM and CSS) is written +# Defaults to pkg +site-pkg-dir = "pkg" + +# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to //toollist.css +style-file = "style/main.scss" +# Assets source dir. All files found here will be copied and synchronized to site-root. +# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir. +# +# Optional. Env: LEPTOS_ASSETS_DIR. +assets-dir = "public" + +# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup. +site-addr = "0.0.0.0:3000" + +# The port to use for automatic reload monitoring +reload-port = 3001 + +# [Optional] Command to use when running end2end tests. It will run in the end2end dir. +# [Windows] for non-WSL use "npx.cmd playwright test" +# This binary name can be checked in Powershell with Get-Command npx +end2end-cmd = "npx playwright test" +end2end-dir = "end2end" + +# The browserlist query used for optimizing the CSS. +browserquery = "defaults" + +# The environment Leptos will run in, usually either "DEV" or "PROD" +env = "DEV" + +# The features to use when compiling the bin target +# +# Optional. Can be over-ridden with the command line parameter --bin-features +bin-features = ["ssr"] + +# If the --no-default-features flag should be used when compiling the bin target +# +# Optional. Defaults to false. +bin-default-features = false + +# The features to use when compiling the lib target +# +# Optional. Can be over-ridden with the command line parameter --lib-features +lib-features = ["hydrate"] + +# If the --no-default-features flag should be used when compiling the lib target +# +# Optional. Defaults to false. +lib-default-features = false + +# The profile to use for the lib target when compiling for release +# +# Optional. Defaults to "release". +lib-profile-release = "wasm-release" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/README.md b/README.md new file mode 100644 index 0000000..4718ce3 --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# 🔧 Toollist + +Verwaltung von Werkzeugen und Material fĂŒr ein Renovierungs- / Sanierungsprojekt. + +--- + +## Tech Stack + +| Schicht | Technologie | +|---|---| +| Sprache | Rust | +| Framework | [Leptos 0.8](https://leptos.dev) – Full-Stack SSR + Hydration | +| Server | Axum 0.8 | +| Datenspeicher | TOML-Datei (`data/tools.toml`) | +| Styles | SCSS (`style/main.scss`) | +| Build-Tool | [cargo-leptos](https://github.com/leptos-rs/cargo-leptos) | + +--- + +## Entwicklung + +```bash +# Entwicklungsserver mit Hot-Reload starten +cargo leptos watch + +# Browser öffnen +http://localhost:3000 + +# Im Heimnetz erreichbar unter +http://:3000 +``` + +> **Hinweis:** `site-addr` ist auf `0.0.0.0:3000` gesetzt, die App ist also +> direkt im lokalen Netz erreichbar – kein Login, keine Auth. + +### Voraussetzungen + +```bash +cargo install cargo-leptos +rustup target add wasm32-unknown-unknown +``` + +> **wasm-bindgen Version:** Die Cargo.toml pinnt `wasm-bindgen = "=0.2.106"`, +> damit es mit dem in cargo-leptos 0.3.6 gebĂŒndelten CLI ĂŒbereinstimmt. +> Falls cargo-leptos aktualisiert wird, ggf. beide Versionen anpassen. + +--- + +## Projektstruktur + +``` +toollist/ +├── Cargo.toml AbhĂ€ngigkeiten + cargo-leptos Konfiguration +├── data/ +│ └── tools.toml Datenspeicher (wird automatisch angelegt) +├── public/ Statische Dateien +├── style/ +│ └── main.scss Globales Stylesheet +└── src/ + ├── main.rs Server-Einstiegspunkt (Axum) + ├── lib.rs Crate-Root, Hydration-Einstieg + ├── app.rs App-Komponente + Routing + ├── models/ + │ └── tool.rs Datenmodell (Structs & Enums) + ├── server/ + │ ├── store.rs TOML lesen/schreiben [nur SSR] + │ └── api.rs Server Functions (HTTP-Endpunkte) + └── components/ + ├── tool_list.rs Hauptansicht – Liste, Filter, CSV-Import + ├── tool_form.rs Erstellen / Bearbeiten Formular + └── filter_bar.rs Filterleiste (Suche, Status, PrioritĂ€t, Tag) +``` + +--- + +## Datenmodell + +### `Tool` (zentrale EntitĂ€t) + +| Feld | Typ | Beschreibung | +|---|---|---| +| `id` | `String` (UUID) | Eindeutige ID, serverseitig generiert | +| `name` | `String` | Name des Werkzeugs | +| `anzahl` | `Anzahl` | Konkrete Zahl oder „Mehrere" | +| `beschaffung` | `Beschaffung` | Wie das Werkzeug beschafft wird | +| `verwendung` | `Vec` | Liste von Verwendungszwecken | +| `tags` | `Vec` | Stichwörter (z.B. `garten`, `schwer`) | +| `status` | `Status` | `Offen` · `Organisiert` · `VorOrt` | +| `prioritaet` | `Prioritaet` | `Hoch` · `Mittel` · `Niedrig` | +| `notizen` | `Option` | Freitext | +| `verantwortlich` | `Option` | Wer kĂŒmmert sich darum? | +| `erstellt_am` | `String` (ISO-8601) | Erstellungszeitpunkt | + +### `Beschaffung` + +| Art | Zusatzfelder | +|---|---| +| `Unbekannt` | – | +| `InBesitz` | – | +| `Leihen` | `von: Option` | +| `Mieten` | `wo: Option`, `preis: Option` | +| `Kaufen` | `wo: Option`, `preis: Option` | + +### TOML-Format (Beispiel) + +```toml +[[tools]] +id = "3f2a1b..." +name = "Schubkarren" +status = "Offen" +prioritaet = "Mittel" +erstellt_am = "2025-01-01T10:00:00Z" +verwendung = ["Schutt abtransportieren"] +tags = ["transport", "garten"] + +[tools.anzahl] +zahl = 2 + +[tools.beschaffung] +art = "InBesitz" +``` + +--- + +## Server Functions (API) + +Alle Endpunkte unter `/api/` – automatisch von Leptos registriert. + +| Funktion | Beschreibung | +|---|---| +| `tools_laden()` | Alle Werkzeuge laden | +| `tool_erstellen(input)` | Neues Werkzeug anlegen | +| `tool_aktualisieren(id, input)` | Bestehendes Werkzeug aktualisieren | +| `tool_loeschen(id)` | Werkzeug löschen | +| `csv_importieren(inhalt)` | CSV-Text importieren (Name + Anzahl) | + +--- + +## CSV-Import + +Einfaches Format – nur `name` und `anzahl` werden ausgewertet. +Fehlende oder unbekannte Werte werden toleriert. + +```csv +name,anzahl +Schubkarren,2 +Betonmischer,1 +Nadelrolle,mehrere +Besen, +``` + +Importierte EintrĂ€ge erhalten Standardwerte: +`Status = Offen`, `PrioritĂ€t = Mittel`, `Beschaffung = Unbekannt`. + +--- + +## Features (Stand) + +- [x] Werkzeuge anlegen, bearbeiten, löschen +- [x] Listenansicht (Tabelle) +- [x] Filter: Status, PrioritĂ€t, Tag, Freitextsuche +- [x] Dynamisches Formular (Felder je nach Beschaffungsart) +- [x] CSV-Import (Name + Anzahl) +- [x] Datenspeicherung in `data/tools.toml` +- [x] Im Heimnetz erreichbar (`0.0.0.0:3000`) + +## Mögliche nĂ€chste Schritte + +- [ ] Detailansicht / Klappreihe (Verwendungsliste, alle Felder) +- [ ] Verwendungsliste im Formular als dynamische Liste (statt Textarea) +- [ ] Bauabschnitt als eigenes Feld (oder konsequent via Tags) +- [ ] Druckansicht / Export +- [ ] Sortierung der Tabellenspalten +- [ ] ZĂ€hler in der Kopfzeile (z.B. „3 von 12 offen") diff --git a/documentation.md b/documentation.md new file mode 100644 index 0000000..6911420 --- /dev/null +++ b/documentation.md @@ -0,0 +1,357 @@ +# Toollist – Design-Dokumentation + +Dieses Dokument hĂ€lt die HintergrĂŒnde und AbwĂ€gungen fest, die wĂ€hrend der +Planungsphase getroffen wurden. Es dient als Nachschlagewerk, wenn zukĂŒnftige +Erweiterungen Fragen ĂŒber den ursprĂŒnglichen Kontext aufwerfen. + +--- + +## 1. Kontext & Ziel + +Das Tool entstand aus einer einfachen Textdatei (`toollist_notes.txt`), in der +Werkzeuge fĂŒr eine Haus-Renovierung grob notiert waren – ohne einheitliche +Struktur, ohne Status-Tracking, ohne Möglichkeit fĂŒr mehrere Personen gleichzeitig +darauf zuzugreifen. + +**Kernziel:** Eine einfache, im Heimnetz zugĂ€ngliche Web-App, mit der mehrere +Personen gemeinsam verwalten können, welche Werkzeuge fĂŒr die Renovierung benötigt +werden, wie sie beschafft werden und ob sie bereits organisiert sind. + +--- + +## 2. Technologie-Entscheidungen + +### 2.1 Warum Full-Stack Rust mit Leptos? + +Das Projekt startete als Rust-Crate (`Cargo.toml` war bereits vorhanden). +FĂŒr ein Web-Tool mit mehreren Nutzern wurden drei Optionen verglichen: + +| Option | Beschreibung | Verworfen weil | +|---|---|---| +| **CLI** | Befehle im Terminal | Kein Mehrbenutzerzugriff, keine Übersicht | +| **TUI** | Interaktive Terminal-UI | Kein Netzwerkzugriff ohne zusĂ€tzlichen Server | +| **Web** | Browser-basiert | ✅ GewĂ€hlt | + +Innerhalb der Web-Option: + +| Option | Beschreibung | Entscheidung | +|---|---|---| +| Axum + Vanilla JS | Rust-Backend, JS-Frontend | Zwei Sprachen, mehr Aufwand | +| Axum + React/Svelte | Beste UX, zwei Codebasen | Zu aufwĂ€ndig fĂŒr ein internes Tool | +| **Leptos SSR** | Full-Stack Rust, WASM | ✅ GewĂ€hlt – alles in einer Sprache | + +**Leptos SSR + Hydration** wurde gewĂ€hlt, weil: +- Alles bleibt in Rust (Typsicherheit zwischen Server und Client) +- Server-seitiges Rendering fĂŒr den initialen Load +- Die ReaktivitĂ€t (Signale, Ressourcen) ersetzt ein separates State-Management + +### 2.2 Warum TOML als Datenspeicher? + +Es wurden drei Optionen diskutiert: + +| Option | Vorteile | Nachteile | +|---|---|---| +| **TOML-Datei** | Menschenlesbar, versionierbar mit Git, kein DB-Server | Kein Query-Layer, Concurrent Writes brauchen Locking | +| JSON-Datei | Besser fĂŒr komplexe Typen, weit verbreitet | Weniger lesbar als TOML | +| SQLite | Besser fĂŒr Abfragen, bewĂ€hrt fĂŒr gleichzeitigen Zugriff | Overhead fĂŒr ein kleines internes Tool | + +**TOML wurde gewĂ€hlt**, da die Datei direkt lesbar und editierbar sein soll, +das Projekt nur fĂŒr wenige Nutzer im Heimnetz ausgelegt ist (kein +Hochlast-Concurrent-Access), und die Werkzeug-Liste manuell wartbar bleiben soll. + +> **Bekannte EinschrĂ€nkung:** Bei gleichzeitigen Schreibzugriffen mehrerer +> Nutzer gibt es kein Locking. FĂŒr den Heimnetz-Einsatz mit wenigen Personen +> ist das akzeptabel. Bei Bedarf kann ein `tokio::sync::Mutex` in den Axum-State +> eingebaut werden. + +### 2.3 Warum kein Login / keine Auth? + +Bewusste Entscheidung: Die App ist ausschließlich im lokalen Heimnetz erreichbar +und wird von einer kleinen, bekannten Personengruppe genutzt. Eine +Nutzerverwaltung wĂŒrde den Aufwand signifikant erhöhen, ohne einen echten +Mehrwert fĂŒr diesen Kontext zu liefern. + +Das Feld `verantwortlich` (wer kĂŒmmert sich um ein Werkzeug) ist ein +Freitextfeld – kein Verweis auf einen eingeloggten Nutzer. + +--- + +## 3. Datenmodell – Designentscheidungen + +### 3.1 Werkzeug-Felder + +**Kernfelder** (aus der ursprĂŒnglichen Anforderung): + +| Feld | Entscheidung | BegrĂŒndung | +|---|---|---| +| `name` | `String` | Freitext, keine Normalisierung nötig | +| `anzahl` | `Anzahl`-Struct | Entweder konkrete Zahl oder „Mehrere" (z.B. bei Eimern, Handschuhen) | +| `beschaffung` | Flache `Beschaffung`-Struct | KontextabhĂ€ngige Zusatzfelder je nach Art (s.u.) | +| `verwendung` | `Vec` | Mehrere Verwendungszwecke möglich | +| `tags` | `Vec` | Flexible Kategorisierung ohne fixe Taxonomie | + +**Zusatzfelder** (im Design-GesprĂ€ch ergĂ€nzt): + +| Feld | Entscheidung | BegrĂŒndung | +|---|---|---| +| `status` | Enum `Offen/Organisiert/VorOrt` | Wichtigster Fortschrittsindikator auf einen Blick | +| `prioritaet` | Enum `Hoch/Mittel/Niedrig` | Fokussierung auf kritische Werkzeuge | +| `notizen` | `Option` | Freitext fĂŒr alles was sonst nirgends passt | +| `verantwortlich` | `Option` | Optional – wer beschafft das Werkzeug | +| `erstellt_am` | `String` (ISO-8601) | NĂŒtzlich fĂŒr Sortierung und Nachvollziehbarkeit | + +**Bewusst nicht aufgenommen (beim MVP):** +- Bauabschnitt als eigenes Feld → wird ĂŒber Tags abgebildet (z.B. `tag: estrich`, `tag: elektro`) +- VollstĂ€ndiges Changelog / Audit-Log → zu viel Overhead fĂŒr diesen Kontext + +### 3.2 Warum `Anzahl` als Struct statt Enum? + +UrsprĂŒnglich war ein Enum geplant: +```rust +// Erstentwurf (verworfen) +enum Anzahl { + Zahl(u32), + Mehrere, +} +``` + +**Problem:** TOML serialisiert gemischte Enum-Varianten (eine als Zahl, eine als +String) je nach Variante unterschiedlich, was zu Serialisierungsfehlern fĂŒhren +kann – insbesondere innerhalb von `[[tools]]`-Arrays, wo alle Felder als +Inline-Tables dargestellt werden. + +**Lösung:** Eine flache Struct mit optionalem Zahlenfeld: +```rust +struct Anzahl { + zahl: Option, // None = "Mehrere" +} +``` + +TOML-Darstellung ist damit eindeutig: +```toml +# Konkrete Zahl: +[tools.anzahl] +zahl = 2 + +# "Mehrere": +[tools.anzahl] +# (kein zahl-Feld → None) +``` + +### 3.3 Warum `Beschaffung` als flache Struct statt Enum? + +UrsprĂŒnglicher Plan: +```rust +// Erstentwurf (verworfen) +enum Beschaffung { + Unbekannt, + InBesitz, + Leihen { von: String }, + Mieten { wo: String, preis: Option }, + Kaufen { wo: String, preis: Option }, +} +``` + +**Problem:** Rust-Enums mit Struct-Varianten und TOML-Serialisierung können +sich je nach verwendetem Serde-Tag-Modus (`untagged`, `tag`, extern) unterschiedlich +verhalten und sind fehleranfĂ€llig, besonders mit `Option`-Feldern in Varianten. + +**Lösung:** Flache Struct mit einem `BeschaffungsArt`-Enum fĂŒr den Typ und +separaten optionalen Feldern: +```rust +struct Beschaffung { + art: BeschaffungsArt, // "Unbekannt" | "InBesitz" | "Leihen" | "Mieten" | "Kaufen" + von: Option, // nur fĂŒr Leihen + wo: Option, // nur fĂŒr Mieten/Kaufen + preis: Option, // nur fĂŒr Mieten/Kaufen +} +``` + +**Vorteil:** Einfache, TOML-kompatible Serialisierung. Im Formular werden die +Felder `von`, `wo`, `preis` je nach gewĂ€hlter `art` dynamisch ein-/ausgeblendet. + +**Bekannter Trade-off:** Nicht alle Felddkombinationen sind sinnvoll (z.B. `von` +bei `Kaufen`). Die Validierung liegt beim Formular, nicht im Typ selbst. + +### 3.4 Warum `String` fĂŒr `id` und `erstellt_am`? + +FĂŒr `id` wĂ€re `uuid::Uuid` typsicherer. FĂŒr `erstellt_am` wĂ€re +`chrono::DateTime` korrekter. Beide Typen haben jedoch **WASM-KompatibilitĂ€tsprobleme**: + +- `uuid::Uuid` mit dem `v4`-Feature benötigt `getrandom`, das fĂŒr WASM die + `js`-Feature-Flag braucht. +- `chrono::Utc::now()` benötigt fĂŒr WASM die `wasmbind`-Feature-Flag. + +Da beide Werte **ausschließlich serverseitig generiert** werden (in Server +Functions) und der Client sie nur als opaque Strings liest, ist `String` die +pragmatische Wahl: + +```rust +// In Tool::neu() – nur SSR +#[cfg(feature = "ssr")] +impl Tool { + pub fn neu(input: NewTool) -> Self { + Tool { + id: uuid::Uuid::new_v4().to_string(), + erstellt_am: chrono::Utc::now().to_rfc3339(), + // ... + } + } +} +``` + +`uuid` und `chrono` sind daher als **optionale, SSR-only** Dependencies deklariert. + +--- + +## 4. Architektur-Entscheidungen (Leptos) + +### 4.1 SSR-Rendering-Modus + +Leptos unterstĂŒtzt verschiedene Rendering-Modi: + +| Modus | Beschreibung | Entscheidung | +|---|---|---| +| CSR | Nur WASM, kein Server-Rendering | Kein Dateisystemzugriff möglich | +| **SSR + Hydration** | Server rendert HTML, WASM ĂŒbernimmt | ✅ GewĂ€hlt | +| Islands | Nur bestimmte Komponenten hydratisieren | Komplexer, kein Vorteil hier | + +SSR ist notwendig, weil der Server die TOML-Datei lesen muss. Mit CSR mĂŒsste +ein separater API-Server betrieben werden. + +### 4.2 Server Functions statt REST-API + +Leptos `#[server]`-Funktionen generieren automatisch: +- Serverseitig: die echte Implementierung +- Clientseitig: einen HTTP-Stub, der den Server aufruft + +Das erspart eine manuelle REST-API mit separaten Routen, Serialisierung und +Client-Code. Alle Typen werden geteilt – wenn sich das Datenmodell Ă€ndert, +bricht der Compiler sowohl Server- als auch Client-Code. + +### 4.3 Suspense-Grenze fĂŒr Resource-Zugriffe + +**Problem:** In Leptos 0.8 dĂŒrfen `Resource`-Zugriffe in Hydration-Mode +ausschließlich innerhalb einer ``-Komponente erfolgen. Anderenfalls +drohen Hydration-Mismatches und eine Warnung im Log. + +**Falsch (ursprĂŒnglicher Ansatz):** +```rust +// Außerhalb Suspense → Warnung +let alle_tags = Signal::derive(move || { + tools_resource.get() // ← Resource-Zugriff außerhalb Suspense! + .and_then(|r| r.ok()) + // ... +}); +``` + +**Richtig (finaler Ansatz):** +```rust +"LĂ€dt
"

}> + {move || { + tools_resource.get().map(|result| { // ← Zugriff korrekt innerhalb + let tools = result.unwrap_or_default(); + // Tags und gefilterte Liste hier berechnen + view! { /* Tabelle */ } + }) + }} +
+``` + +**Konsequenz:** `FilterBar` und Tabelle liegen beide innerhalb der +Suspense-Grenze. Bei FilterĂ€nderungen (reaktive `filter: RwSignal`) re-rendert +der Suspense-Block, ohne den Ladeindikator zu zeigen (Resource ist bereits geladen). + +### 4.4 ReaktivitĂ€t: Wie Updates die Liste neu laden + +Nach jeder Mutation (erstellen, aktualisieren, löschen) muss die Liste neu +geladen werden. Das geschieht ĂŒber einen einfachen **Versions-ZĂ€hler**: + +```rust +let version = RwSignal::new(0u32); +let neu_laden = move || version.update(|v| *v += 1); + +// Resource hĂ€ngt von version ab +let tools_resource = Resource::new( + move || version.get(), // ← neu_laden() erhöht version → Resource re-fetcht + |_| tools_laden(), +); +``` + +Das ist bewusst einfach gehalten. Eine Alternative wĂ€re `ServerAction` mit +automatischem `resource.refetch()`, was aber mehr Boilerplate erfordert. + +--- + +## 5. Komponentenstruktur + +``` +ToolList (Hauptkomponente – hĂ€lt den gesamten Zustand) +│ +├── ToolForm (Formular – erscheint wenn bearbeitungs_modus != None) +│ └── Callback on_fertig / on_abbrechen → zurĂŒck zur Liste +│ +└── + └── FilterBar (bekommt filter: RwSignal + alle_tags: Vec) + └── (iteriert ĂŒber gefilterte Tools) +``` + +**Zustand-Management in `ToolList`:** + +| Signal | Typ | Zweck | +|---|---|---| +| `version` | `RwSignal` | Löst Resource-Reload aus | +| `bearbeitungs_modus` | `RwSignal>` | Steuert Formular-Sichtbarkeit | +| `filter` | `RwSignal` | Aktueller Filterzustand | +| `csv_fehler/erfolg` | `RwSignal>` | Import-Feedback | + +**Warum kein globales State-Management (z.B. Context)?** +FĂŒr eine Single-Page-App mit einer einzigen Hauptansicht ist lokaler +Komponentenzustand ausreichend. Ein Context wĂŒrde nur Sinn ergeben, wenn +mehrere unabhĂ€ngige Seiten/Routen den gleichen Zustand teilen mĂŒssten. + +--- + +## 6. CSV-Import Design + +### Format + +Bewusst minimal gehalten – nur `name` und `anzahl`: + +```csv +name,anzahl +Schubkarren,2 +Nadelrolle,mehrere +Besen, +``` + +**BegrĂŒndung:** Die bestehende Notizliste (`toollist_notes.txt`) ist nicht +gut strukturiert genug fĂŒr einen vollstĂ€ndigen Import. Ein minimales Format +erlaubt das schnelle Anlegen vieler EintrĂ€ge, die dann manuell nachbearbeitet +werden können. + +### Fehlertoleranz + +Der Import ist absichtlich tolerant: +- Fehlende `anzahl` → `Anzahl::zahl(1)` +- `"mehrere"` (Groß-/Kleinschreibung egal) → `Anzahl::mehrere()` +- Nicht-numerische Anzahl → `Anzahl::mehrere()` +- Leerzeilen und Kommentarzeilen (`#`) → ĂŒbersprungen +- Kopfzeile (`name,...`) → automatisch erkannt und ĂŒbersprungen + +Alle importierten EintrĂ€ge erhalten Standardwerte: +`Status = Offen`, `PrioritĂ€t = Mittel`, `Beschaffung = Unbekannt`. + +--- + +## 7. Bekannte EinschrĂ€nkungen & offene Punkte + +| Thema | Aktueller Stand | Mögliche Lösung | +|---|---|---| +| Concurrent Writes | Kein Locking auf TOML-Datei | `tokio::sync::Mutex` in Axum-State | +| Verwendungsliste | Textarea (zeilenweise) | Dynamische Liste mit +/- Buttons | +| Bauabschnitt | Nur via Tags | Eigenes Pflichtfeld fĂŒr Bauphase | +| Detailansicht | Alles in Tabellenzeile gequetscht | Aufklappbare Zeile oder Modal | +| Sortierung | Keine | Klickbare Spaltenköpfe | +| Export | Nicht vorhanden | Druckansicht, CSV-Export | +| Deployment | Nur Dev-Server (`cargo leptos watch`) | Release-Build + Systemd-Service | diff --git a/end2end/.gitignore b/end2end/.gitignore new file mode 100644 index 0000000..169a2af --- /dev/null +++ b/end2end/.gitignore @@ -0,0 +1,3 @@ +node_modules +playwright-report +test-results diff --git a/end2end/package.json b/end2end/package.json new file mode 100644 index 0000000..a80ac59 --- /dev/null +++ b/end2end/package.json @@ -0,0 +1,15 @@ +{ + "name": "end2end", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": {}, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.44.1", + "@types/node": "^20.12.12", + "typescript": "^5.4.5" + } +} diff --git a/end2end/playwright.config.ts b/end2end/playwright.config.ts new file mode 100644 index 0000000..aee2d46 --- /dev/null +++ b/end2end/playwright.config.ts @@ -0,0 +1,105 @@ +import type { PlaywrightTestConfig } from "@playwright/test"; +import { devices, defineConfig } from "@playwright/test"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./tests", + /* Maximum time one test can run for. */ + timeout: 30 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + }, + }, + + { + name: "firefox", + use: { + ...devices["Desktop Firefox"], + }, + }, + + { + name: "webkit", + use: { + ...devices["Desktop Safari"], + }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { + // ...devices['Pixel 5'], + // }, + // }, + // { + // name: 'Mobile Safari', + // use: { + // ...devices['iPhone 12'], + // }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { + // channel: 'msedge', + // }, + // }, + // { + // name: 'Google Chrome', + // use: { + // channel: 'chrome', + // }, + // }, + ], + + /* Folder for test artifacts such as screenshots, videos, traces, etc. */ + // outputDir: 'test-results/', + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // port: 3000, + // }, +}); diff --git a/end2end/tests/example.spec.ts b/end2end/tests/example.spec.ts new file mode 100644 index 0000000..0139fc3 --- /dev/null +++ b/end2end/tests/example.spec.ts @@ -0,0 +1,9 @@ +import { test, expect } from "@playwright/test"; + +test("homepage has title and heading text", async ({ page }) => { + await page.goto("http://localhost:3000/"); + + await expect(page).toHaveTitle("Welcome to Leptos"); + + await expect(page.locator("h1")).toHaveText("Welcome to Leptos!"); +}); diff --git a/end2end/tsconfig.json b/end2end/tsconfig.json new file mode 100644 index 0000000..e075f97 --- /dev/null +++ b/end2end/tsconfig.json @@ -0,0 +1,109 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..2ba8527cb12f5f28f331b8d361eef560492d4c77 GIT binary patch literal 15406 zcmeHOd3aPs5`TblWD*3D%tXPJ#q(n!z$P=3gCjvf#a)E}a;Uf>h{pmVih!a-5LVO` zB?JrzEFicD0wRLo0iPfO372xnkvkzFlRHB)lcTnNZ}KK@US{UKN#b8?e_zkLy1RZ= zT~*y(-6IICgf>E_P6A)M3(wvl2qr-gx_5Ux-_uzT*6_Q&ee1v9B?vzS3&K5IhO2N5 z$9ukLN<`G>$$|GLnga~y%>f}*j%+w@(ixVUb^1_Gjoc;(?TrD3m2)RduFblVN)uy; zQAEd^T{5>-YYH%|Kv{V^cxHMBr1Ik7Frht$imC`rqx@5*| z+OqN!xAjqmaU=qR$uGDMa7p!W9oZ+64($4xDk^FyFQ<_9Z`(;DLnB<;LLJD1<&vnZ zo0(>zIkQTse}qNMb6+i`th54(3pKm8;UAJ<_BULR*Z=m5FU7jiW(&#l+}WkHZ|e@1 z`pm;Q^pCuLUQUrnQ(hPM10pSSHQS=Bf8DqG1&!-B!oQQ|FuzLruL1w(+g<8&znyI? zzX-}?SwUvNjEuT?7uUOy{Fb@xKklpj+jdYM^IK9}NxvLRZd{l9FHEQJ4IO~q%4I0O zAN|*8x^nIU4Giw?f*tmNx=7H)2-Zn?J^B6SgpcW3ZXV_57Sn%Mtfr_=w|sYpAhdJT zcKo6Z*oIOU(az~3$LOEWm9Q)dYWMA}T7L23MVGqrcA%4H)+^`+=j+Hh8CTCnnG2Rh zgcXVW%F8$R9)6}f=NQiLPt8qt3xNUQI>Q*)H1lzk<&n?XR-f}tc&9V0H0lhGqHJ^N zN%h(9-Of2_)!Xk{qdIkU>1%mk%I_Id1!MU*yq&&>)Q+!L^t&-2mW9Xq7g9C@* zl&PKJ&su2L+iku?Te?Pf?k3tUK){Bj_gb&aPo8Ago^XI~mRTd(5{&^tf1)!-lSMha z@$~ae!r(~`=p&|mMxy2EiZQ6FvXb(1avS*`Pj%$)*?vwceGKHmHnl`v&fEQ_Wh+G) zEPQ^3&oV%}%;zF`AM|S%d>pM@1}33PN5*4SewROk_K$n^i8QjaYiRzwG8#OvVIF|{x85wH+?*P*%)woI zR538k@=(E`V;p1UwA|fqSh`$n_t;Sz4T)`_s~pRR4lbmWWSdxa-FqLZ%fLT)Bh?iye?COx~mO1wkn5)HNMg7`8~ z25VJhz&3Z7`M>6luJrEw$Jikft+6SxyIh?)PU1?DfrKMGC z=3T;;omE4H`PWqF8?0*dOA3o9y@~WK`S}{?tIHquEw?v`M^D%Lobpdrp%3}1=-&qk zqAtb1px-1Fy6}E8IUg4s%8B0~P<P5C;de%@n~XnDKF@fr$a+^@$^P|>vlw($aSK2lRtLt~8tRb`I0 znfI!G?K|<5ry*gk>y56rZy0NkK6)))6Mg1=K?7yS9p+#1Ij=W*%5Rt-mlc;#MOnE9 zoi`-+6oj@)`gq2Af!B+9%J#K9V=ji2dj2<_qaLSXOCeqQ&<0zMSb$5mAi;HU=v`v<>NYk}MbD!ewYVB+N-ctzn=l&bTwv)*7 zmY<+Y@SBbtl9PPk$HTR?ln@(T92XjTRj0Mx|Mzl;lW>Su_y^~fh?8(L?oz8h!cCpb zZG-OY=NJ3{>r*`U<(J%#zjFT-a9>u6+23H{=d(utkgqt7@^)C;pkb)fQ|Q=*8*SyT z;otKe+f8fEp)ZacKZDn3TNzs>_Kx+g*c_mr8LBhr8GnoEmAQk#%sR52`bdbW8Ms$!0u2bdt=T-lK3JbDW`F(Urt%Ob2seiN>7U`YN}aOdIiCC;eeufJC#m3S z9#|l2c?G@t*hH5y^76jkv)rs4H+;oiTuY5FQwRMN_7NUqeiD|b&RyxPXQz|3qC(_> zZJMwjC4F!1m2INXqzisQ4X^w=>&(+Ecdu&~IWEMn7f*YcYI&eWI(6hI#f114%aymM zyhlG6{q>XN7(LyGiMAS&qijR%d2rV|>AUT_sE&EKUSTCM26>aKzNxk0?K|utOcxl# zxIOwM#O!!H+QzbX*&p=QuKe4y;bS>&StQOE5AEGg_ubk8{;1yOVAJfE_Js-lL7rr9 z)CEuFIlkApj~uV^zJK7KocjT=4B zJP(}0x}|A7C$$5gIp>KBPZ|A#2Ew;$#g9Fk)r;Q~?G$>x<+JM)J3u>j zi68K=I;ld`JJ?Nq+^_B?C+Q%+x#m{9JF$tbaDeNIep%=^#>KHGtg=L)>m z_J&vaZTs2{qP!4Gdw5u5Kcf}5R4(q}Lebx%(J$7l*Q`Il#pCTM%!`y5y*-~zIVs}D z9;t+(xmV~R65^ZQXe+<5{$QW0O8MT~a{kdFLR)nfRMA9L(YU>x*DTltN#m-2km zC;T`cfb{c`mcx(z7o_a8bYJn8_^dz4Cq!DZ37{P6uF{@#519UWK1{>(9sZB1I^6MmNc39MJ-_|)!S8vO+O3&$MulU3Gc z_W{N*B(yneyl-oN_MKaJ{CZ6dv-~^8uPbLSh&0jfV@EfA{2Dc!_rOyfx`R0T@LonA z<*%O?-aa_Wm-z$s@K(ex7UhM0-?9C=PkYdk&d2n((E4>&(f4D`fOQY%CURMMyJyU` zVeJBAId&StHjw76tnwSqZs3e0683`L{a3k9JYdg#(ZVw4J`&CkV-2LFaDE1Z?CehVy%vZx$tM3tTax8E@2;N^QTrPcI?Ob8uK!DM0_sfE6ks2M?iw zPS4{(k-PF*-oY>S!d9;L+|xdTtLen9B2LvpL4k;#ScB< z$NP_7j~7)5eXuoYEk*dK_rSz9yT_C4B{r~^#^o}-VQI=Y?01|$aa!a7=UEm$|DsQQ zfLK1qmho2@)nwA?$1%T6jwO2HZ({6&;`s|OQOxI4S8*Hw=Qp!b(gNJR%SAj&wGa>^&2@x)Vj zhd^WfzJ^b0O{E^q82Pw({uT`E`MT2WnZ02{E%t*yRPN>?W>0vU^4@Vyh4;mLj918c z*s*papo?<}cQM{5lcgZScx}?usg{mS!KkH9U%@|^_33?{FI{1ss+8kXyFY&5M-e~f zM$){FF;_+z3sNJ)Er~{Beux$fEl{R4|7WKcpEsGtK57f+H0DJ$hI;U;JtF>+lG@sV zQI_;bQ^7XIJ>Bs?C32b1v;am;P4GUqAJ#zOHv}4SmV|xXX6~O9&e_~YCCpbT>s$`! k<4FtN!5 impl IntoView { + view! { + + + + + + + + + + + + + + } +} + +#[component] +pub fn App() -> impl IntoView { + provide_meta_context(); + + view! { + + + + <Router> + <main> + <Routes fallback=|| "Seite nicht gefunden.".into_view()> + <Route path=StaticSegment("") view=ToolList/> + </Routes> + </main> + </Router> + } +} diff --git a/src/components/filter_bar.rs b/src/components/filter_bar.rs new file mode 100644 index 0000000..8b7a65b --- /dev/null +++ b/src/components/filter_bar.rs @@ -0,0 +1,168 @@ +use crate::models::tool::{BeschaffungsArt, Prioritaet, Status}; +use leptos::prelude::*; + +#[derive(Clone, Debug, PartialEq, Default)] +pub struct FilterZustand { + pub suche: String, + pub status: Option<Status>, + pub prioritaet: Option<Prioritaet>, + pub beschaffung: Option<BeschaffungsArt>, + /// Nur Tools anzeigen, die mindestens einen dieser Tags haben + pub tags_einschliessen: Vec<String>, + /// Tools ausblenden, die irgendeinen dieser Tags haben + pub tags_ausschliessen: Vec<String>, +} + +/// Tags werden als einfacher Vec ĂŒbergeben – der FilterBar-Wrapper liegt +/// bereits innerhalb des <Suspense>, daher brauchen wir kein Signal. +#[component] +pub fn FilterBar(filter: RwSignal<FilterZustand>, alle_tags: Vec<String>) -> impl IntoView { + view! { + <div class="filter-bar"> + // Freitextsuche + <input + type="text" + placeholder="Suche..." + class="filter-input" + prop:value=move || filter.read().suche.clone() + on:input=move |ev| { + filter.update(|f| f.suche = event_target_value(&ev)) + } + /> + + // Status-Filter + <select + class="filter-select" + on:change=move |ev| { + let wert = event_target_value(&ev); + filter.update(|f| { + f.status = match wert.as_str() { + "Offen" => Some(Status::Offen), + "Organisiert" => Some(Status::Organisiert), + "VorOrt" => Some(Status::VorOrt), + _ => None, + } + }); + } + > + <option value="">"Alle Status"</option> + <option value="Offen">"Offen"</option> + <option value="Organisiert">"Organisiert"</option> + <option value="VorOrt">"Vor Ort"</option> + </select> + + // PrioritĂ€t-Filter + <select + class="filter-select" + on:change=move |ev| { + let wert = event_target_value(&ev); + filter.update(|f| { + f.prioritaet = match wert.as_str() { + "Hoch" => Some(Prioritaet::Hoch), + "Mittel" => Some(Prioritaet::Mittel), + "Niedrig" => Some(Prioritaet::Niedrig), + _ => None, + } + }); + } + > + <option value="">"Alle PrioritĂ€ten"</option> + <option value="Hoch">"Hoch"</option> + <option value="Mittel">"Mittel"</option> + <option value="Niedrig">"Niedrig"</option> + </select> + + // Beschaffung-Filter + <select + class="filter-select" + on:change=move |ev| { + let wert = event_target_value(&ev); + filter.update(|f| { + f.beschaffung = match wert.as_str() { + "Unbekannt" => Some(BeschaffungsArt::Unbekannt), + "InBesitz" => Some(BeschaffungsArt::InBesitz), + "Leihen" => Some(BeschaffungsArt::Leihen), + "Mieten" => Some(BeschaffungsArt::Mieten), + "Kaufen" => Some(BeschaffungsArt::Kaufen), + _ => None, + } + }); + } + > + <option value="">"Alle Beschaffungen"</option> + <option value="Unbekannt">"Unbekannt"</option> + <option value="InBesitz">"In Besitz"</option> + <option value="Leihen">"Leihen"</option> + <option value="Mieten">"Mieten"</option> + <option value="Kaufen">"Kaufen"</option> + </select> + + // Reset + <button + class="btn btn-secondary" + on:click=move |_| filter.set(FilterZustand::default()) + > + "✕ ZurĂŒcksetzen" + </button> + + // Tag-Chips (nur wenn Tags vorhanden) + {if !alle_tags.is_empty() { + view! { + <div class="tag-filter-bereich"> + <span class="tag-filter-label">"Tags:"</span> + {alle_tags.into_iter().map(|tag| { + let tag1 = tag.clone(); // fĂŒr class + let tag2 = tag.clone(); // fĂŒr on:click + let tag3 = tag.clone(); // fĂŒr Textinhalt + view! { + <span + class=move || { + let f = filter.read(); + if f.tags_einschliessen.contains(&tag1) { + "tag-chip tag-chip-ein" + } else if f.tags_ausschliessen.contains(&tag1) { + "tag-chip tag-chip-aus" + } else { + "tag-chip" + } + } + style="cursor: pointer;" + on:click=move |_| { + let t = tag2.clone(); + filter.update(|f| { + if f.tags_einschliessen.contains(&t) { + // einschließen → ausschließen + f.tags_einschliessen.retain(|x| x != &t); + f.tags_ausschliessen.push(t); + } else if f.tags_ausschliessen.contains(&t) { + // ausschließen → neutral + f.tags_ausschliessen.retain(|x| x != &t); + } else { + // neutral → einschließen + f.tags_einschliessen.push(t); + } + }); + } + > + {move || { + let f = filter.read(); + if f.tags_einschliessen.contains(&tag3) { + format!("✓ {tag3}") + } else if f.tags_ausschliessen.contains(&tag3) { + format!("✗ {tag3}") + } else { + tag3.clone() + } + }} + </span> + } + }).collect::<Vec<_>>()} + <span class="tag-filter-legende">"(1× einschließen · 2× ausschließen · 3× zurĂŒcksetzen)"</span> + </div> + }.into_any() + } else { + view! { <span></span> }.into_any() + }} + </div> + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs new file mode 100644 index 0000000..6bad501 --- /dev/null +++ b/src/components/mod.rs @@ -0,0 +1,3 @@ +pub mod filter_bar; +pub mod tool_form; +pub mod tool_list; diff --git a/src/components/tool_form.rs b/src/components/tool_form.rs new file mode 100644 index 0000000..19ca63a --- /dev/null +++ b/src/components/tool_form.rs @@ -0,0 +1,366 @@ +use crate::models::tool::{ + Anzahl, Beschaffung, BeschaffungsArt, NewTool, Prioritaet, Status, Tool, +}; +use crate::server::api::{tool_aktualisieren, tool_erstellen}; +use leptos::prelude::*; + +/// Wird als Aktion-Typ fĂŒr den Parent verwendet +#[derive(Clone, Debug)] +pub enum FormModus { + Neu, + Bearbeiten(Tool), +} + +#[component] +pub fn ToolForm( + modus: FormModus, + /// Wird aufgerufen wenn das Formular erfolgreich abgeschickt wurde + on_fertig: Callback<()>, + /// Wird aufgerufen wenn der Nutzer abbricht + on_abbrechen: Callback<()>, +) -> impl IntoView { + // Vorbelegte Werte je nach Modus + let (original_id, initial) = match &modus { + FormModus::Neu => (None, NewTool::default()), + FormModus::Bearbeiten(t) => (Some(t.id.clone()), NewTool::from(t)), + }; + + // --- Signale fĂŒr jedes Feld --- + let name = RwSignal::new(initial.name.clone()); + let anzahl_mehrere = RwSignal::new(initial.anzahl.ist_mehrere()); + let anzahl_zahl = RwSignal::new(initial.anzahl.zahl.unwrap_or(1).to_string()); + + let beschaffung_art = RwSignal::new(format!("{}", initial.beschaffung.art)); + let beschaffung_von = RwSignal::new(initial.beschaffung.von.clone().unwrap_or_default()); + let beschaffung_wo = RwSignal::new(initial.beschaffung.wo.clone().unwrap_or_default()); + let beschaffung_preis = RwSignal::new( + initial + .beschaffung + .preis + .map(|p| p.to_string()) + .unwrap_or_default(), + ); + + let verwendung_text = RwSignal::new(initial.verwendung.join("\n")); + let tags_text = RwSignal::new(initial.tags.join(", ")); + + let status = RwSignal::new(format!("{}", initial.status)); + let prioritaet = RwSignal::new(format!("{}", initial.prioritaet)); + let notizen = RwSignal::new(initial.notizen.clone().unwrap_or_default()); + let verantwortlich = RwSignal::new(initial.verantwortlich.clone().unwrap_or_default()); + + let fehler = RwSignal::new(Option::<String>::None); + + // --- Aktionen --- + let erstellen_aktion = Action::new(|input: &NewTool| { + let i = input.clone(); + async move { tool_erstellen(i).await } + }); + + let aktualisieren_aktion = Action::new(|(id, input): &(String, NewTool)| { + let (id, i) = (id.clone(), input.clone()); + async move { tool_aktualisieren(id, i).await } + }); + + // Reaktion auf Aktion-Ergebnis + let on_fertig_clone = on_fertig.clone(); + Effect::new(move |_| { + if let Some(ergebnis) = erstellen_aktion.value().get() { + match ergebnis { + Ok(_) => on_fertig_clone.run(()), + Err(e) => fehler.set(Some(e.to_string())), + } + } + }); + + let on_fertig_clone2 = on_fertig.clone(); + Effect::new(move |_| { + if let Some(ergebnis) = aktualisieren_aktion.value().get() { + match ergebnis { + Ok(_) => on_fertig_clone2.run(()), + Err(e) => fehler.set(Some(e.to_string())), + } + } + }); + + let abschicken = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + + // Validierung + let n = name.get(); + if n.trim().is_empty() { + fehler.set(Some("Name darf nicht leer sein.".into())); + return; + } + + let anzahl = if anzahl_mehrere.get() { + Anzahl::mehrere() + } else { + match anzahl_zahl.get().trim().parse::<u32>() { + Ok(z) => Anzahl::zahl(z), + Err(_) => { + fehler.set(Some("Anzahl muss eine ganze Zahl sein.".into())); + return; + } + } + }; + + let art = match beschaffung_art.get().as_str() { + "In Besitz" => BeschaffungsArt::InBesitz, + "Leihen" => BeschaffungsArt::Leihen, + "Mieten" => BeschaffungsArt::Mieten, + "Kaufen" => BeschaffungsArt::Kaufen, + _ => BeschaffungsArt::Unbekannt, + }; + + let beschaffung = Beschaffung { + art, + von: Some(beschaffung_von.get()).filter(|s| !s.is_empty()), + wo: Some(beschaffung_wo.get()).filter(|s| !s.is_empty()), + preis: beschaffung_preis.get().trim().parse::<f64>().ok(), + }; + + let verwendung: Vec<String> = verwendung_text + .get() + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + + let tags: Vec<String> = tags_text + .get() + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + + let status_wert = match status.get().as_str() { + "Organisiert" => Status::Organisiert, + "Vor Ort" | "VorOrt" => Status::VorOrt, + _ => Status::Offen, + }; + + let prio_wert = match prioritaet.get().as_str() { + "Hoch" => Prioritaet::Hoch, + "Niedrig" => Prioritaet::Niedrig, + _ => Prioritaet::Mittel, + }; + + let input = NewTool { + name: n.trim().to_string(), + anzahl, + beschaffung, + verwendung, + tags, + status: status_wert, + prioritaet: prio_wert, + notizen: Some(notizen.get()).filter(|s| !s.is_empty()), + verantwortlich: Some(verantwortlich.get()).filter(|s| !s.is_empty()), + }; + + fehler.set(None); + + if let Some(id) = &original_id { + aktualisieren_aktion.dispatch((id.clone(), input)); + } else { + erstellen_aktion.dispatch(input); + } + }; + + let titel = match &modus { + FormModus::Neu => "Neues Werkzeug", + FormModus::Bearbeiten(_) => "Werkzeug bearbeiten", + }; + + view! { + <div class="form-container"> + <h2>{titel}</h2> + + {move || fehler.get().map(|e| view! { + <div class="fehler-banner">{e}</div> + })} + + <form on:submit=abschicken> + + // --- Name --- + <div class="form-gruppe"> + <label>"Name"</label> + <input + type="text" + required + prop:value=move || name.get() + on:input=move |ev| name.set(event_target_value(&ev)) + /> + </div> + + // --- Anzahl --- + <div class="form-gruppe"> + <label>"Anzahl"</label> + <div class="anzahl-zeile"> + <label class="checkbox-label"> + <input + type="checkbox" + prop:checked=move || anzahl_mehrere.get() + on:change=move |ev| { + use wasm_bindgen::JsCast; + let checked = ev.target().unwrap() + .dyn_into::<web_sys::HtmlInputElement>().unwrap() + .checked(); + anzahl_mehrere.set(checked); + } + /> + " Mehrere" + </label> + <input + type="number" + min="1" + class="anzahl-input" + prop:value=move || anzahl_zahl.get() + prop:disabled=move || anzahl_mehrere.get() + on:input=move |ev| anzahl_zahl.set(event_target_value(&ev)) + /> + </div> + </div> + + // --- Beschaffung --- + <div class="form-gruppe"> + <label>"Beschaffung"</label> + <select + on:change=move |ev| beschaffung_art.set(event_target_value(&ev)) + > + <option value="Unbekannt" selected=move || beschaffung_art.get() == "Unbekannt">"Unbekannt"</option> + <option value="In Besitz" selected=move || beschaffung_art.get() == "In Besitz">"In Besitz"</option> + <option value="Leihen" selected=move || beschaffung_art.get() == "Leihen">"Leihen"</option> + <option value="Mieten" selected=move || beschaffung_art.get() == "Mieten">"Mieten"</option> + <option value="Kaufen" selected=move || beschaffung_art.get() == "Kaufen">"Kaufen"</option> + </select> + </div> + + // Dynamische Zusatzfelder je nach Beschaffungsart + {move || { + let art = beschaffung_art.get(); + match art.as_str() { + "Leihen" => view! { + <div class="form-gruppe"> + <label>"Von wem leihen"</label> + <input + type="text" + placeholder="Name der Person" + prop:value=move || beschaffung_von.get() + on:input=move |ev| beschaffung_von.set(event_target_value(&ev)) + /> + </div> + }.into_any(), + "Mieten" | "Kaufen" => view! { + <div> + <div class="form-gruppe"> + <label>{if art == "Mieten" { "Wo mieten" } else { "Wo kaufen" }}</label> + <input + type="text" + placeholder="Ort / Shop" + prop:value=move || beschaffung_wo.get() + on:input=move |ev| beschaffung_wo.set(event_target_value(&ev)) + /> + </div> + <div class="form-gruppe"> + <label>"Preis (€)"</label> + <input + type="number" + step="0.01" + min="0" + placeholder="optional" + prop:value=move || beschaffung_preis.get() + on:input=move |ev| beschaffung_preis.set(event_target_value(&ev)) + /> + </div> + </div> + }.into_any(), + _ => view! { <div></div> }.into_any(), + } + }} + + // --- Verwendung --- + <div class="form-gruppe"> + <label>"Verwendung (je Zeile ein Punkt)"</label> + <textarea + rows="3" + placeholder="z.B. Schutt abtransportieren" + prop:value=move || verwendung_text.get() + on:input=move |ev| verwendung_text.set(event_target_value(&ev)) + ></textarea> + </div> + + // --- Tags --- + <div class="form-gruppe"> + <label>"Tags (kommagetrennt, optional)"</label> + <input + type="text" + placeholder="z.B. garten, transport, schwer" + prop:value=move || tags_text.get() + on:input=move |ev| tags_text.set(event_target_value(&ev)) + /> + </div> + + // --- Status --- + <div class="form-gruppe"> + <label>"Status"</label> + <select on:change=move |ev| status.set(event_target_value(&ev))> + <option value="Offen" selected=move || status.get() == "Offen">"Offen"</option> + <option value="Organisiert" selected=move || status.get() == "Organisiert">"Organisiert"</option> + <option value="VorOrt" selected=move || status.get() == "VorOrt" || status.get() == "Vor Ort">"Vor Ort"</option> + </select> + </div> + + // --- PrioritĂ€t --- + <div class="form-gruppe"> + <label>"PrioritĂ€t"</label> + <select on:change=move |ev| prioritaet.set(event_target_value(&ev))> + <option value="Hoch" selected=move || prioritaet.get() == "Hoch">"Hoch"</option> + <option value="Mittel" selected=move || prioritaet.get() == "Mittel">"Mittel"</option> + <option value="Niedrig" selected=move || prioritaet.get() == "Niedrig">"Niedrig"</option> + </select> + </div> + + // --- Notizen --- + <div class="form-gruppe"> + <label>"Notizen"</label> + <textarea + rows="2" + placeholder="optional" + prop:value=move || notizen.get() + on:input=move |ev| notizen.set(event_target_value(&ev)) + ></textarea> + </div> + + // --- Verantwortlich --- + <div class="form-gruppe"> + <label>"Verantwortlich"</label> + <input + type="text" + placeholder="optional" + prop:value=move || verantwortlich.get() + on:input=move |ev| verantwortlich.set(event_target_value(&ev)) + /> + </div> + + // --- Buttons --- + <div class="form-aktionen"> + <button type="submit" class="btn btn-primary"> + {match &modus { + FormModus::Neu => "Erstellen", + FormModus::Bearbeiten(_) => "Speichern", + }} + </button> + <button + type="button" + class="btn btn-secondary" + on:click=move |_| on_abbrechen.run(()) + > + "Abbrechen" + </button> + </div> + + </form> + </div> + } +} diff --git a/src/components/tool_list.rs b/src/components/tool_list.rs new file mode 100644 index 0000000..ecd5dda --- /dev/null +++ b/src/components/tool_list.rs @@ -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> + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b232038 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,12 @@ +pub mod app; +pub mod components; +pub mod models; +pub mod server; + +#[cfg(feature = "hydrate")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn hydrate() { + use crate::app::*; + console_error_panic_hook::set_once(); + leptos::mount::hydrate_body(App); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c7176d0 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,46 @@ +#[cfg(feature = "ssr")] +#[tokio::main] +async fn main() { + use axum::Router; + use leptos::logging::log; + use leptos::prelude::*; + use leptos_axum::{generate_route_list, LeptosRoutes}; + use toollist::app::*; + + let conf = get_configuration(None).unwrap(); + let addr = conf.leptos_options.site_addr; + let leptos_options = conf.leptos_options; + // Generate the list of routes in your Leptos App + let routes = generate_route_list(App); + + let app = Router::new() + .route( + "/api/{*fn_name}", + axum::routing::post(leptos_axum::handle_server_fns), + ) + .route( + "/export/tools.csv", + axum::routing::get(toollist::server::export::handler::tools_csv), + ) + .leptos_routes(&leptos_options, routes, { + let leptos_options = leptos_options.clone(); + move || shell(leptos_options.clone()) + }) + .fallback(leptos_axum::file_and_error_handler(shell)) + .with_state(leptos_options); + + // run our app with hyper + // `axum::Server` is a re-export of `hyper::Server` + log!("listening on http://{}", &addr); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); +} + +#[cfg(not(feature = "ssr"))] +pub fn main() { + // no client-side main function + // unless we want this to work with e.g., Trunk for pure client-side testing + // see lib.rs for hydration function instead +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..cd0567e --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1 @@ +pub mod tool; diff --git a/src/models/tool.rs b/src/models/tool.rs new file mode 100644 index 0000000..8ba65cf --- /dev/null +++ b/src/models/tool.rs @@ -0,0 +1,235 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +// --------------------------------------------------------------------------- +// Tool – Hauptstruktur +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Tool { + pub id: String, + pub name: String, + pub anzahl: Anzahl, + pub beschaffung: Beschaffung, + pub verwendung: Vec<String>, + pub tags: Vec<String>, + pub status: Status, + pub prioritaet: Prioritaet, + pub notizen: Option<String>, + pub verantwortlich: Option<String>, + pub erstellt_am: String, // ISO-8601, wird nur serverseitig gesetzt +} + +#[cfg(feature = "ssr")] +impl Tool { + pub fn neu(input: NewTool) -> Self { + Tool { + id: uuid::Uuid::new_v4().to_string(), + erstellt_am: chrono::Utc::now().to_rfc3339(), + name: input.name, + anzahl: input.anzahl, + beschaffung: input.beschaffung, + verwendung: input.verwendung, + tags: input.tags, + status: input.status, + prioritaet: input.prioritaet, + notizen: input.notizen, + verantwortlich: input.verantwortlich, + } + } +} + +// --------------------------------------------------------------------------- +// Anzahl +// --------------------------------------------------------------------------- + +/// Entweder eine konkrete Zahl oder "Mehrere" (unbestimmt). +/// Flache Struct-Darstellung fĂŒr problemlose TOML-Serialisierung. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Anzahl { + /// None bedeutet "Mehrere" + pub zahl: Option<u32>, +} + +impl Anzahl { + pub fn zahl(n: u32) -> Self { + Self { zahl: Some(n) } + } + pub fn mehrere() -> Self { + Self { zahl: None } + } + pub fn ist_mehrere(&self) -> bool { + self.zahl.is_none() + } +} + +impl Default for Anzahl { + fn default() -> Self { + Self::zahl(1) + } +} + +impl fmt::Display for Anzahl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.zahl { + Some(n) => write!(f, "{}", n), + None => write!(f, "Mehrere"), + } + } +} + +// --------------------------------------------------------------------------- +// Beschaffung +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub enum BeschaffungsArt { + #[default] + Unbekannt, + InBesitz, + Leihen, + Mieten, + Kaufen, +} + +impl fmt::Display for BeschaffungsArt { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BeschaffungsArt::Unbekannt => write!(f, "Unbekannt"), + BeschaffungsArt::InBesitz => write!(f, "In Besitz"), + BeschaffungsArt::Leihen => write!(f, "Leihen"), + BeschaffungsArt::Mieten => write!(f, "Mieten"), + BeschaffungsArt::Kaufen => write!(f, "Kaufen"), + } + } +} + +/// Flache Struct-Darstellung damit TOML-Serialisierung einfach bleibt. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct Beschaffung { + pub art: BeschaffungsArt, + /// FĂŒr Leihen: von wem + pub von: Option<String>, + /// FĂŒr Mieten/Kaufen: wo + pub wo: Option<String>, + /// FĂŒr Mieten/Kaufen: Preis in € + pub preis: Option<f64>, +} + +impl fmt::Display for Beschaffung { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.art { + BeschaffungsArt::Unbekannt => write!(f, "Unbekannt"), + BeschaffungsArt::InBesitz => write!(f, "In Besitz"), + BeschaffungsArt::Leihen => { + if let Some(von) = &self.von { + write!(f, "Leihen (von {})", von) + } else { + write!(f, "Leihen") + } + } + BeschaffungsArt::Mieten => { + let wo = self.wo.as_deref().unwrap_or("?"); + if let Some(p) = self.preis { + write!(f, "Mieten bei {} ({:.2} €)", wo, p) + } else { + write!(f, "Mieten bei {}", wo) + } + } + BeschaffungsArt::Kaufen => { + let wo = self.wo.as_deref().unwrap_or("?"); + if let Some(p) = self.preis { + write!(f, "Kaufen bei {} ({:.2} €)", wo, p) + } else { + write!(f, "Kaufen bei {}", wo) + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Status +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub enum Status { + #[default] + Offen, + Organisiert, + VorOrt, +} + +impl fmt::Display for Status { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Status::Offen => write!(f, "Offen"), + Status::Organisiert => write!(f, "Organisiert"), + Status::VorOrt => write!(f, "Vor Ort"), + } + } +} + +// --------------------------------------------------------------------------- +// Prioritaet +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub enum Prioritaet { + Hoch, + #[default] + Mittel, + Niedrig, +} + +impl fmt::Display for Prioritaet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Prioritaet::Hoch => write!(f, "Hoch"), + Prioritaet::Mittel => write!(f, "Mittel"), + Prioritaet::Niedrig => write!(f, "Niedrig"), + } + } +} + +// --------------------------------------------------------------------------- +// NewTool – Eingabe-Struct fĂŒr Erstellen und Bearbeiten +// --------------------------------------------------------------------------- + +// Wird als serde-Default fĂŒr NewTool::anzahl verwendet, damit ein leeres +// Anzahl-Objekt (Mehrere = zahl: None, keine URL-Keys) korrekt deserialisiert. +fn default_anzahl_mehrere() -> Anzahl { + Anzahl::mehrere() +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NewTool { + pub name: String, + #[serde(default = "default_anzahl_mehrere")] + pub anzahl: Anzahl, + pub beschaffung: Beschaffung, + #[serde(default)] + pub verwendung: Vec<String>, + #[serde(default)] + pub tags: Vec<String>, + pub status: Status, + pub prioritaet: Prioritaet, + pub notizen: Option<String>, + pub verantwortlich: Option<String>, +} + +impl From<&Tool> for NewTool { + fn from(t: &Tool) -> Self { + NewTool { + name: t.name.clone(), + anzahl: t.anzahl.clone(), + beschaffung: t.beschaffung.clone(), + verwendung: t.verwendung.clone(), + tags: t.tags.clone(), + status: t.status.clone(), + prioritaet: t.prioritaet.clone(), + notizen: t.notizen.clone(), + verantwortlich: t.verantwortlich.clone(), + } + } +} diff --git a/src/server/api.rs b/src/server/api.rs new file mode 100644 index 0000000..99c8d07 --- /dev/null +++ b/src/server/api.rs @@ -0,0 +1,123 @@ +use crate::models::tool::{NewTool, Tool}; +use leptos::prelude::*; + +// --------------------------------------------------------------------------- +// Lesen +// --------------------------------------------------------------------------- + +#[server] +pub async fn tools_laden() -> Result<Vec<Tool>, ServerFnError> { + use crate::server::store; + store::lade_tools().map_err(ServerFnError::new) +} + +// --------------------------------------------------------------------------- +// Erstellen +// --------------------------------------------------------------------------- + +#[server] +pub async fn tool_erstellen(input: NewTool) -> Result<Tool, ServerFnError> { + use crate::server::store; + let tool = Tool::neu(input); + store::erstelle_tool(tool).map_err(ServerFnError::new) +} + +// --------------------------------------------------------------------------- +// Aktualisieren +// --------------------------------------------------------------------------- + +#[server] +pub async fn tool_aktualisieren(id: String, input: NewTool) -> Result<Tool, ServerFnError> { + use crate::server::store; + // bestehenden Eintrag laden um erstellt_am zu behalten + let tools = store::lade_tools().map_err(ServerFnError::new)?; + let original = tools + .iter() + .find(|t| t.id == id) + .ok_or_else(|| ServerFnError::new(format!("Tool '{id}' nicht gefunden")))?; + + let aktualisiert = Tool { + id: id.clone(), + erstellt_am: original.erstellt_am.clone(), + name: input.name, + anzahl: input.anzahl, + beschaffung: input.beschaffung, + verwendung: input.verwendung, + tags: input.tags, + status: input.status, + prioritaet: input.prioritaet, + notizen: input.notizen, + verantwortlich: input.verantwortlich, + }; + + store::aktualisiere_tool(&id, aktualisiert).map_err(ServerFnError::new) +} + +// --------------------------------------------------------------------------- +// Löschen +// --------------------------------------------------------------------------- + +#[server] +pub async fn tool_loeschen(id: String) -> Result<(), ServerFnError> { + use crate::server::store; + store::loesche_tool(&id).map_err(ServerFnError::new) +} + +// --------------------------------------------------------------------------- +// CSV-Import (nur Name + Anzahl) +// --------------------------------------------------------------------------- + +#[server] +pub async fn csv_importieren(inhalt: String) -> Result<usize, ServerFnError> { + use crate::models::tool::{Anzahl, Beschaffung, Prioritaet, Status}; + use crate::server::store; + + let mut tools = store::lade_tools().map_err(ServerFnError::new)?; + let mut importiert = 0usize; + + for zeile in inhalt.lines() { + let zeile = zeile.trim(); + // Kommentare und Kopfzeile ĂŒberspringen + if zeile.is_empty() || zeile.starts_with('#') || zeile.to_lowercase().starts_with("name") { + continue; + } + + let teile: Vec<&str> = zeile.splitn(2, ',').collect(); + let name = teile[0].trim().to_string(); + if name.is_empty() { + continue; + } + + let anzahl = if teile.len() > 1 { + let roh = teile[1].trim(); + if roh.to_lowercase() == "mehrere" || roh.is_empty() { + Anzahl::mehrere() + } else { + match roh.parse::<u32>() { + Ok(n) => Anzahl::zahl(n), + Err(_) => Anzahl::mehrere(), + } + } + } else { + Anzahl::zahl(1) + }; + + let tool = Tool::neu(NewTool { + name, + anzahl, + beschaffung: Beschaffung::default(), + verwendung: vec![], + tags: vec![], + status: Status::Offen, + prioritaet: Prioritaet::Mittel, + notizen: None, + verantwortlich: None, + }); + + tools.push(tool); + importiert += 1; + } + + store::speichere_tools(&tools).map_err(ServerFnError::new)?; + Ok(importiert) +} diff --git a/src/server/export.rs b/src/server/export.rs new file mode 100644 index 0000000..1c4a369 --- /dev/null +++ b/src/server/export.rs @@ -0,0 +1,72 @@ +#[cfg(feature = "ssr")] +pub mod handler { + use axum::http::{header, StatusCode}; + use axum::response::Response; + + use crate::server::store; + + /// Escaped ein einzelnes CSV-Feld nach RFC 4180. + fn feld(s: &str) -> String { + if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } + } + + pub async fn tools_csv() -> Response { + let tools = match store::lade_tools() { + Ok(t) => t, + Err(e) => { + return Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(format!("Fehler: {e}").into()) + .unwrap(); + } + }; + + let mut csv = String::from( + "id,name,anzahl,beschaffung_art,beschaffung_von,beschaffung_wo,\ + beschaffung_preis,verwendung,tags,status,prioritaet,notizen,\ + verantwortlich,erstellt_am\n", + ); + + for t in &tools { + let zeile = [ + feld(&t.id), + feld(&t.name), + feld(&t.anzahl.to_string()), + feld(&t.beschaffung.art.to_string()), + feld(t.beschaffung.von.as_deref().unwrap_or("")), + feld(t.beschaffung.wo.as_deref().unwrap_or("")), + feld( + &t.beschaffung + .preis + .map(|p| format!("{p:.2}")) + .unwrap_or_default(), + ), + feld(&t.verwendung.join("; ")), + feld(&t.tags.join(", ")), + feld(&t.status.to_string()), + feld(&t.prioritaet.to_string()), + feld(t.notizen.as_deref().unwrap_or("")), + feld(t.verantwortlich.as_deref().unwrap_or("")), + feld(&t.erstellt_am), + ] + .join(","); + + csv.push_str(&zeile); + csv.push('\n'); + } + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/csv; charset=utf-8") + .header( + header::CONTENT_DISPOSITION, + "attachment; filename=\"werkzeuge.csv\"", + ) + .body(csv.into()) + .unwrap() + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..e9e83a3 --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,5 @@ +#[cfg(feature = "ssr")] +pub mod store; + +pub mod api; +pub mod export; diff --git a/src/server/store.rs b/src/server/store.rs new file mode 100644 index 0000000..3c41c8e --- /dev/null +++ b/src/server/store.rs @@ -0,0 +1,67 @@ +use crate::models::tool::Tool; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +const STORE_PATH: &str = "data/tools.toml"; + +// TOML-Wrapper: die Datei enthĂ€lt ein [[tools]]-Array +#[derive(Serialize, Deserialize, Default)] +struct ToolStore { + tools: Vec<Tool>, +} + +pub fn lade_tools() -> Result<Vec<Tool>, String> { + let path = Path::new(STORE_PATH); + if !path.exists() { + return Ok(vec![]); + } + let inhalt = std::fs::read_to_string(path).map_err(|e| format!("Lesefehler: {e}"))?; + let store: ToolStore = toml::from_str(&inhalt).map_err(|e| format!("TOML-Fehler: {e}"))?; + Ok(store.tools) +} + +pub fn speichere_tools(tools: &[Tool]) -> Result<(), String> { + let store = ToolStore { + tools: tools.to_vec(), + }; + let inhalt = + toml::to_string_pretty(&store).map_err(|e| format!("Serialisierungsfehler: {e}"))?; + + // Verzeichnis anlegen falls nicht vorhanden + if let Some(eltern) = Path::new(STORE_PATH).parent() { + std::fs::create_dir_all(eltern).map_err(|e| format!("Verzeichnisfehler: {e}"))?; + } + + std::fs::write(STORE_PATH, inhalt).map_err(|e| format!("Schreibfehler: {e}"))?; + Ok(()) +} + +// --- Hilfsfunktionen -------------------------------------------------------- + +pub fn erstelle_tool(tool: Tool) -> Result<Tool, String> { + let mut tools = lade_tools()?; + tools.push(tool.clone()); + speichere_tools(&tools)?; + Ok(tool) +} + +pub fn aktualisiere_tool(id: &str, neu: Tool) -> Result<Tool, String> { + let mut tools = lade_tools()?; + let pos = tools + .iter() + .position(|t| t.id == id) + .ok_or_else(|| format!("Tool mit id '{id}' nicht gefunden"))?; + tools[pos] = neu.clone(); + speichere_tools(&tools)?; + Ok(neu) +} + +pub fn loesche_tool(id: &str) -> Result<(), String> { + let mut tools = lade_tools()?; + let vorher = tools.len(); + tools.retain(|t| t.id != id); + if tools.len() == vorher { + return Err(format!("Tool mit id '{id}' nicht gefunden")); + } + speichere_tools(&tools) +} diff --git a/style/main.scss b/style/main.scss new file mode 100644 index 0000000..8045122 --- /dev/null +++ b/style/main.scss @@ -0,0 +1,270 @@ +/* ---- Basis ---- */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: system-ui, sans-serif; + background: #f4f6f8; + color: #1a1a2e; + font-size: 15px; +} + +/* ---- Seite ---- */ +.seite { max-width: 1200px; margin: 0 auto; padding: 1.5rem; } + +/* ---- Kopfzeile ---- */ +.kopfzeile { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 1.25rem; + h1 { font-size: 1.6rem; } +} +.kopf-aktionen { display: flex; gap: .6rem; } + +/* ---- Buttons ---- */ +.btn { + display: inline-flex; align-items: center; gap: .3rem; + padding: .45rem .9rem; border: none; border-radius: 6px; + cursor: pointer; font-size: .9rem; font-weight: 500; + transition: filter .15s; + &:hover { filter: brightness(.92); } +} +.btn-primary { background: #3b82f6; color: #fff; } +.btn-secondary { background: #e2e8f0; color: #334155; } +.btn-klein { padding: .25rem .55rem; font-size: .85rem; } +.btn-sekundaer { background: #e2e8f0; color: #334155; } +.btn-gefahr { background: #fee2e2; color: #b91c1c; } + +/* CSV-Upload: verstecktes Input hinter einem Button-Label */ +.csv-label { cursor: pointer; } +.csv-input-versteckt { display: none; } + +/* ---- Meldungen ---- */ +.meldung { + padding: .6rem 1rem; border-radius: 6px; margin-bottom: .75rem; font-size: .9rem; +} +.meldung-erfolg { background: #dcfce7; color: #166534; } +.meldung-fehler { background: #fee2e2; color: #991b1b; } + +/* ---- Filter ---- */ +.filter-bar { + display: flex; flex-wrap: wrap; gap: .5rem; + background: #fff; padding: .75rem; border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,.08); + margin-bottom: 1rem; +} +.filter-input, .filter-select { + padding: .4rem .65rem; border: 1px solid #cbd5e1; + border-radius: 6px; font-size: .875rem; + background: #fff; +} +.filter-input { min-width: 180px; } + +/* ---- Formular ---- */ +.form-container { + background: #fff; border-radius: 10px; + padding: 1.5rem; margin-bottom: 1.25rem; + box-shadow: 0 2px 8px rgba(0,0,0,.1); + h2 { margin-bottom: 1rem; font-size: 1.2rem; } +} +.form-gruppe { + display: flex; flex-direction: column; gap: .3rem; + margin-bottom: .85rem; + label { font-size: .85rem; font-weight: 600; color: #475569; } + input[type="text"], input[type="number"], select, textarea { + padding: .45rem .65rem; border: 1px solid #cbd5e1; + border-radius: 6px; font-size: .9rem; + &:focus { outline: 2px solid #3b82f6; border-color: #3b82f6; } + } + textarea { resize: vertical; min-height: 70px; } +} +.anzahl-zeile { display: flex; align-items: center; gap: .75rem; } +.anzahl-input { width: 90px; } +.checkbox-label { display: flex; align-items: center; gap: .35rem; cursor: pointer; font-size: .9rem; } +.form-aktionen { display: flex; gap: .6rem; margin-top: 1rem; } +.fehler-banner { + background: #fee2e2; color: #991b1b; + padding: .5rem .8rem; border-radius: 6px; margin-bottom: .75rem; font-size: .875rem; +} + +/* ---- Tabelle ---- */ +.tabellen-wrapper { overflow-x: auto; } +.tool-tabelle { + width: 100%; border-collapse: collapse; + background: #fff; border-radius: 10px; overflow: hidden; + box-shadow: 0 1px 4px rgba(0,0,0,.08); + th { + background: #f8fafc; text-align: left; + padding: .65rem .9rem; font-size: .8rem; + text-transform: uppercase; letter-spacing: .04em; color: #64748b; + border-bottom: 2px solid #e2e8f0; + } + td { padding: .6rem .9rem; border-bottom: 1px solid #f1f5f9; vertical-align: top; } + tr:last-child td { border-bottom: none; } + tr:hover td { background: #f8fafc; } +} +.tool-name { min-width: 160px; } +.tool-notiz { font-size: .8rem; color: #64748b; margin-top: .2rem; } +.tag-zelle { vertical-align: middle; } +.aktionen-zelle { white-space: nowrap; } + +/* ---- Badges ---- */ +.status-badge, .prio-badge { + display: inline-block; padding: .2rem .55rem; + border-radius: 999px; font-size: .78rem; font-weight: 600; +} +.status-offen { background: #fef3c7; color: #92400e; } +.status-organisiert { background: #dbeafe; color: #1e40af; } +.status-vor-ort { background: #dcfce7; color: #166534; } +.prio-hoch { background: #fee2e2; color: #991b1b; } +.prio-mittel { background: #fef9c3; color: #854d0e; } +.prio-niedrig { background: #f0fdf4; color: #15803d; } + +.tag-chip { + display: inline-block; + background: #e0e7ff; color: #3730a3; + padding: .15rem .5rem; border-radius: 999px; font-size: .75rem; + margin: .1rem .2rem .1rem 0; + transition: background .12s, color .12s; +} +.tag-chip-ein { + background: #bfdbfe; color: #1e40af; + outline: 2px solid #3b82f6; +} +.tag-chip-aus { + background: #fecaca; color: #991b1b; + outline: 2px solid #ef4444; +} + +/* ---- Tag-Filter-Zeile ---- */ +.tag-filter-bereich { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .35rem; + width: 100%; + padding-top: .5rem; + margin-top: .1rem; + border-top: 1px solid #e2e8f0; +} +.tag-filter-label { + font-size: .8rem; + font-weight: 700; + color: #64748b; + white-space: nowrap; +} +.tag-filter-legende { + font-size: .72rem; + color: #94a3b8; + margin-left: .25rem; + white-space: nowrap; +} + +/* ---- Sortierbare Spalten ---- */ +.th-sortierbar { + cursor: pointer; + user-select: none; + white-space: nowrap; + &:hover { background: #eef2f7; color: #1e40af; } +} + +/* ---- Aufklappbare Zeilen ---- */ +.toggle-th { width: 36px; } +.toggle-zelle { width: 36px; text-align: center; } + +.btn-toggle { + background: none; + border: none; + cursor: pointer; + font-size: .85rem; + color: #94a3b8; + padding: .15rem .35rem; + border-radius: 4px; + line-height: 1; + transition: color .12s, background .12s; + &:hover { background: #e2e8f0; color: #334155; } +} + +.tool-zeile > td { transition: background .1s; } + +.detail-zeile > td { + background: #f1f5f9; + padding: 0; + border-bottom: 2px solid #e2e8f0; +} +.detail-zeile:hover > td { background: #f1f5f9; } // kein hover-highlight + +.detail-inhalt { + padding: .85rem 1rem .85rem 2.75rem !important; +} + +.detail-grid { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; +} + +.detail-block { + display: flex; + flex-direction: column; + gap: .3rem; + min-width: 140px; +} + +.detail-label { + font-size: .72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + color: #94a3b8; +} + +.detail-text { + font-size: .875rem; + color: #334155; + white-space: pre-wrap; + margin: 0; +} + +.detail-verwendung { + margin: 0 0 0 1rem; + font-size: .875rem; + color: #334155; + li { margin-bottom: .15rem; } +} + +.detail-datum { + font-size: .8rem; + color: #94a3b8; +} + +/* ---- Modal-Dialog ---- */ +.tool-dialog { + // ZuverlĂ€ssige Zentrierung unabhĂ€ngig vom Browser-UA-Stylesheet + position: fixed; + inset: 0; + margin: auto; + + border: none; + border-radius: 12px; + padding: 0; + width: min(780px, 94vw); + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 24px 64px rgba(0, 0, 0, .3); + + .form-container { + box-shadow: none; + border-radius: 12px; + } + + &::backdrop { + background: rgba(15, 23, 42, .5); + // backdrop-filter wirkt auf den Inhalt hinter dem Overlay + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); + } +} + +/* ---- Hilfstexte ---- */ +.lade-text, .leer-text { + text-align: center; padding: 2rem; color: #94a3b8; font-style: italic; +} diff --git a/toollist_data.md b/toollist_data.md new file mode 100644 index 0000000..21c0322 --- /dev/null +++ b/toollist_data.md @@ -0,0 +1,8 @@ +Werkzeug: +Namen: Text +Anzahl: Nummer oder "Mehere" +Wie ich es herbekomme: Unbekannt, In Besitz, Leihen (Dann zusaezlich von wem), Mieten (Dann zusaezlich von Wo und Preis), Kaufen (Dann zusaezlich wo und preis) + +Wie es verwendet werden soll: Liste mit Text + +Tags: Stichworte zur Verwendung oder zur art von Werkzeug oder bauabschnitt