This commit is contained in:
Oliver Walter
2026-06-06 15:37:42 +02:00
commit 5f43c19878
26 changed files with 2881 additions and 0 deletions
+14
View File
@@ -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
+119
View File
@@ -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 <site-root>/<site-pkg>/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"
+24
View File
@@ -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 <https://unlicense.org>
+174
View File
@@ -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://<server-ip>: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<String>` | Liste von Verwendungszwecken |
| `tags` | `Vec<String>` | Stichwörter (z.B. `garten`, `schwer`) |
| `status` | `Status` | `Offen` · `Organisiert` · `VorOrt` |
| `prioritaet` | `Prioritaet` | `Hoch` · `Mittel` · `Niedrig` |
| `notizen` | `Option<String>` | Freitext |
| `verantwortlich` | `Option<String>` | Wer kümmert sich darum? |
| `erstellt_am` | `String` (ISO-8601) | Erstellungszeitpunkt |
### `Beschaffung`
| Art | Zusatzfelder |
|---|---|
| `Unbekannt` | |
| `InBesitz` | |
| `Leihen` | `von: Option<String>` |
| `Mieten` | `wo: Option<String>`, `preis: Option<f64>` |
| `Kaufen` | `wo: Option<String>`, `preis: Option<f64>` |
### 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")
+357
View File
@@ -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<String>` | Mehrere Verwendungszwecke möglich |
| `tags` | `Vec<String>` | 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<String>` | Freitext für alles was sonst nirgends passt |
| `verantwortlich` | `Option<String>` | 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<u32>, // 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<f64> },
Kaufen { wo: String, preis: Option<f64> },
}
```
**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<String>, // nur für Leihen
wo: Option<String>, // nur für Mieten/Kaufen
preis: Option<f64>, // 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<Utc>` 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 `<Suspense>`-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
<Suspense fallback=|| view! { <p>"Lädt…"</p> }>
{move || {
tools_resource.get().map(|result| { // ← Zugriff korrekt innerhalb
let tools = result.unwrap_or_default();
// Tags und gefilterte Liste hier berechnen
view! { <FilterBar ... /> /* Tabelle */ }
})
}}
</Suspense>
```
**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
└── <Suspense>
└── FilterBar (bekommt filter: RwSignal + alle_tags: Vec<String>)
└── <table> (iteriert über gefilterte Tools)
```
**Zustand-Management in `ToolList`:**
| Signal | Typ | Zweck |
|---|---|---|
| `version` | `RwSignal<u32>` | Löst Resource-Reload aus |
| `bearbeitungs_modus` | `RwSignal<Option<FormModus>>` | Steuert Formular-Sichtbarkeit |
| `filter` | `RwSignal<FilterZustand>` | Aktueller Filterzustand |
| `csv_fehler/erfolg` | `RwSignal<Option<String>>` | 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 |
+3
View File
@@ -0,0 +1,3 @@
node_modules
playwright-report
test-results
+15
View File
@@ -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"
}
}
+105
View File
@@ -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,
// },
});
+9
View File
@@ -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!");
});
+109
View File
@@ -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 '<reference>'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. */
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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