Doku & Aufräumen: CLAUDE.md/ARCHITECTURE.md, Tag-Schema, Legacy-Views weg

CLAUDE.md (Kurzform: was zu tun/lassen ist) und ARCHITECTURE.md
(vollständige Repo-Karte mit Verzeichnis, Datenfluss, View-Inventar,
Updater-Pipeline, Schwachstellen) als neue Onboarding-Dokumente.

Tag-Schema in Doku und Skript-Kommentar an die tatsächliche Konvention
angeglichen: Gitea-Tag ohne v-Prefix (latest.json-URL nutzt
/releases/download/<VERSION>/). Betrifft scripts/release.sh, README.md
und ARCHITECTURE.md §9+§10.

Legacy-Views Contacts.jsx und Clients.jsx entfernt — durch Persons.jsx
ersetzt, in NAV_ITEMS nicht mehr verlinkt, kein Import mehr im Code.
ARCHITECTURE.md §5/§12/§14 entsprechend aktualisiert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 03:27:39 +02:00
parent 0fc4dd0e08
commit c71feddf63
6 changed files with 496 additions and 911 deletions
+396
View File
@@ -0,0 +1,396 @@
# RAPPORT — Architektur-Übersicht
> Studio-Management für Architekturbüros. Tauri 2.x Desktop-App, React 19 Frontend, Rust-Backend (minimal), localStorage-only Persistierung. Solo-Dev: Karim.
> Dieses Dokument ist die **Karte** der Codebase. Es ersetzt nicht das Lesen einzelner Dateien, soll aber verhindern, dass jede Session bei Null anfangen muss.
---
## 1. Mentales Modell in einem Absatz
RAPPORT ist eine **monolithische SPA**: ein React-Root in [App.jsx](src/App.jsx) hält den **gesamten** App-State in einem `useState({...})`, persistiert ihn synchron in `localStorage` unter dem Key `studio_data_v1`, und übergibt ihn als Props an lazy-geladene Views. Es gibt **kein Routing-Framework** (View-Wechsel via String-State), **kein State-Library** (kein Redux/Context/Zustand), **kein TypeScript**, **kein CSS-Framework** (alles inline `style={{}}` oder ein 700-Zeilen `<style>`-Block in App.jsx). Der **Rust-Teil** ist 109 Zeilen und macht nur drei Dinge: System-Tray, Window-Hide-on-Close, Plugin-Registrierung (Updater, Process, Log). Es gibt **keine `#[tauri::command]`** — Frontend ↔ Backend kommuniziert nur über das Event `rapport:navigate` (Tray → Frontend). Alle Daten sind im WebView-`localStorage`, nichts wird in Rust gespeichert.
**Konsequenz:** Wenn etwas mit Daten passiert, ist es JS. Wenn etwas mit dem Fenster/Tray/Update passiert, ist es Rust. Es gibt keine dritte Stelle.
---
## 2. Verzeichnis-Karte
```
APP/
├── src/ React-Frontend (kein TS, nur .jsx)
│ ├── main.jsx Entry: createRoot().render(<App />)
│ ├── App.jsx Root: State, Navigation, Auth, Migrationen, Sidebar, Modals [823 Z.]
│ ├── constants.js STORAGE_KEY, NAV_ITEMS, defaultData, SIA-Phasen, Statusfarben [252 Z.]
│ ├── utils.js Business-Logik: Kalkulation, Format, QR-Bill, Hash, CSV, Lohn [678 Z.]
│ ├── views/ 31 Top-Level-Screens, lazy-geladen
│ ├── components/ UI.jsx (StatusBadge, Modal, FormField, …), UpdateNotifier, UpdatesSupport
│ ├── print/ PrintComponents.jsx — alle Druckansichten (Rechnung, QR, Brief, …)
│ ├── utils/updater.js Wrapper um @tauri-apps/plugin-updater + plugin-process
│ ├── assets/ hero.png
│ ├── App.css, index.css Globale CSS-Reset + Variablen
│ └── index.jsx
├── src-tauri/ Rust-Backend (Tauri 2.10.3)
│ ├── src/main.rs 6 Zeilen — delegiert zu app_lib::run()
│ ├── src/lib.rs 103 Zeilen — Tray, Window-Events, Plugins
│ ├── Cargo.toml tauri, plugin-updater, plugin-process, plugin-log, serde
│ ├── tauri.conf.json Updater-URL, Public-Key, CSP, Bundle-Targets
│ ├── capabilities/default.json core:default, core:webview:allow-print, updater:default, process:allow-restart
│ ├── icons/ .icns, .ico, mehrere PNGs (icon.png = Quelle)
│ ├── gen/ AUTO-GENERIERT — nie editieren
│ ├── target/ Cargo-Build-Output (~2 GB) — nie editieren
│ └── build.rs Ruft tauri_build::build()
├── scripts/release.sh Build + Sign + latest.json
├── latest.json Updater-Manifest (im Repo, weil über main.HTTP abgerufen)
├── package.json version, scripts (dev, build, lint), Deps
├── eslint.config.js Flat-Config, sehr minimal
├── vite.config.js Nur @vitejs/plugin-react
├── index.html Vite-Entry
├── public/ favicon.svg, icons.svg
├── README.md Setup-Doku (Release-Sektion veraltet, siehe §6)
└── .claude/ Lokale Claude-Code-Einstellungen (gitignored)
```
---
## 3. Datenfluss — Wie Updates wirklich passieren
```
User-Interaktion in View
▼ onChange / onClick
View ruft eine der zwei Props auf:
├── update(key, value) → save({ ...data, [key]: value }) // Top-Level-Field
└── saveAll(newData) → save(newData) // Atomar
setData(newData)
localStorage.setItem("studio_data_v1", JSON.stringify(newData))
React re-rendert die Hierarchie
```
**Wichtig:**
- `save()` schreibt **synchron** bei jedem Update — kein Debouncing. Bei großen `data`-Objekten kann das spürbar werden.
- Es gibt **kein Backup, kein Conflict-Resolution**. Wenn der User zwei App-Fenster hat (passiert kaum, weil Single-Window), überschreibt das letzte gewinnt.
- Kein Schema-Validator beim Laden — wenn `localStorage` korrupt ist, crasht es im Render.
**`data` Top-Level-Struktur** (definiert in [constants.js](src/constants.js)):
```
{
settings, // Studio-Daten: Name, IBAN, MWST, Stundensätze, Rollen, …
persons[], // Kunden + Partner (vereint seit v0.5)
projects[], // Projekte mit SIA-Phasen / Budget / Billing-Type
timeEntries[], // Stundeneinträge
invoices[], quotes[],
expenses[], internalExpenses[],
employees[], absences[], ferienEntries[], lohnEntries[],
protocols[], deliveryNotes[], letterTemplates[],
dashboardTemplates[], appRoles[], users[]
}
```
---
## 4. Navigation & State-Verwaltung
**Navigation** ist eine String-State-Machine in [App.jsx](src/App.jsx):
- `const [view, setView] = useState("dashboard")`
- Jede View ist ein String-Identifier (`"dashboard"`, `"projects"`, `"time"`, …)
- `navigate(newView)` schiebt auf einen Ref-basierten History-Stack (Browser-ähnliches Vor/Zurück)
- Drill-down via `selectedProjectId` etc. (zweiter State neben `view`)
**Modals:** Inkonsistent — manche Views nutzen `modal: { type, id }`, andere haben mehrere separate `useState` (siehe [Invoices.jsx](src/views/Invoices.jsx): `setModal`, `setNewInvModal`, `setAkontoModal`). Es gibt keinen zentralen Modal-Manager.
**Sessions/Auth:**
- `sessionStorage.rapport_user` — angemeldeter User (gestrippt von Credentials)
- Login per PBKDF2-SHA-256 (100k Iter, 16-Byte Salt) — siehe `hashPassword`, `verifyPassword` in [utils.js](src/utils.js)
- Lockout: 5 Fehlversuche → 60s Sperre ([Login.jsx](src/views/Login.jsx))
- Auto-Upgrade: legacy plaintext-Passwörter werden beim ersten erfolgreichen Login zu PBKDF2 migriert ([App.jsx:143-161](src/App.jsx#L143-L161))
**Weitere `localStorage`-Keys (alle `rapport_*`-prefixed):**
| Key | Zweck |
|---|---|
| `studio_data_v1` | **Die Hauptdaten** (alles außer Settings unten) |
| `rapport_dark` | Dark Mode (`"1"`/`"0"`) |
| `rapport_sidebar_collapsed` | Sidebar collapsed (`"1"`/`"0"`) |
| `rapport_zoom` | UI-Zoom-Faktor |
| `rapport_changelog_seen` | Zuletzt gesehene Changelog-Version |
| `rapport_v0_5_migrated` | Marker für abgeschlossene Migration |
| `rapport_update_skipped_version` | Vom User übersprungene Update-Version |
| `rapport_update_last_check` | ISO-Timestamp letzter Update-Check |
| `sessionStorage.rapport_user` | Aktive Session |
---
## 5. Views — Inventar
Alle Views liegen in [src/views/](src/views/), werden in [App.jsx](src/App.jsx) per `React.lazy()` geladen und über Props `{ data, update, saveAll, modal, setModal, currentUser, … }` versorgt.
| View | Zeilen | Zweck | Komplexität |
|---|---:|---|---|
| [Projects.jsx](src/views/Projects.jsx) | 1781 | List + Detail, SIA-Phasen, Quoten-Zuordnung, Nachträge | **Sehr hoch** |
| [Time.jsx](src/views/Time.jsx) | 1538 | Wochen-Grid mit Drag&Drop, Tag/Woche/Monat, Absenzen | **Sehr hoch** |
| [Invoices.jsx](src/views/Invoices.jsx) | 1467 | Rechnungen, Akonto, QR-Bill, Mahnungen, Planer | **Sehr hoch** |
| [Employees.jsx](src/views/Employees.jsx) | 1298 | Multi-Tab: Stammdaten, Absenzen, Ferien, Lohnabschluss | **Sehr hoch** |
| [Quotes.jsx](src/views/Quotes.jsx) | 980 | Offerten: SIA / manuell / frei, Übernahme als Projekt | Hoch |
| [Protocols.jsx](src/views/Protocols.jsx) | 978 | Sitzungsprotokolle (Beschluss/Info/Aufgabe), Mahnung-Modul | Hoch |
| [Expenses.jsx](src/views/Expenses.jsx) | 914 | Mitarbeiter-Spesen + interne Ausgaben, Bild-Upload | Hoch |
| [Settings.jsx](src/views/Settings.jsx) | 869 | 7 Tabs: Studio, Dokumente, Team, Kalender, System, Support, Profil | **Sehr hoch** |
| [StudioBudget.jsx](src/views/StudioBudget.jsx) | 847 | Revenue-Sparklines, Aggregation Rechnungen/Quoten | Hoch |
| [Dashboard.jsx](src/views/Dashboard.jsx) | 762 | Drag&Drop Widget-Layout, Template-System | Hoch |
| [Persons.jsx](src/views/Persons.jsx) | 682 | Kunden + Partner (seit v0.5 vereint) | Mittel |
| [Setup.jsx](src/views/Setup.jsx) | 423 | 7-Step-Wizard für Neuinstallation | Mittel |
| [Pinboard.jsx](src/views/Pinboard.jsx) | 417 | Blog-artige Notizen, kategorisiert | Mittel |
| [Accounting.jsx](src/views/Accounting.jsx) | 374 | CSV-Export, Jahreszahlen, MwSt-Berechnung | Mittel |
| [Payroll.jsx](src/views/Payroll.jsx) | 344 | Monats-Lohnzettel, BVG-Sätze, Abzüge | Mittel |
| [DeliveryNotes.jsx](src/views/DeliveryNotes.jsx) | 294 | Lieferscheine | Niedrig |
| [Login.jsx](src/views/Login.jsx) | 197 | Login + Brute-Force-Lockout | Niedrig |
| [Documents.jsx](src/views/Documents.jsx) | 194 | Router zu Protokolle/Lieferscheine/Briefe | Niedrig |
| [MigrationScreen.jsx](src/views/MigrationScreen.jsx) | 141 | v0.5-Migration-Wizard (wenn alte Daten erkannt) | Niedrig |
| [Letters.jsx](src/views/Letters.jsx) | 114 | Brieftemplates mit Placeholdern (`{{client}}`, …) | Niedrig |
---
## 6. utils.js — Business-Logik-Bibliothek
[utils.js](src/utils.js) ist isoliert und gut testbar. Die wichtigsten Gruppen:
**Kalkulation:**
- `calcSIAHours(baukosten, schwierigkeit, phasen)` — SIA-102-Formel `p = Z1 + Z2/∛B`
- `calcManualHours(phases, roles)` — Stundenansatz × Stunden pro Rolle
- `deriveQuoteBudget(linkedQuotes, allQuotes, roles)` — Aggregiert Offerten zu Projekt-Budget
- `calcLohn(emp, monat, spesen, bonus)` — AHV/ALV/BVG/NBU/KTG/Quellensteuer
**Crypto / Auth:**
- `hashPassword(password, saltHex)` — PBKDF2-SHA-256, 100k Iter, via Web Crypto
- `verifyPassword(password, user)` — Constant-Time
- `withHashedPassword(user, password)` — Upgrade legacy → hashed
- `stripCredentials(user)` — Entfernt `password`, `passwordHash`, `passwordSalt`
**Sicherheit:**
- `sanitizeHtml(html)` — Allowlist (`<p>`, `<br>`, `<b>`, `<a>`, …), blockiert `<script>`, `javascript:`, `on*`-Handler
**Formatierung (de-CH):**
- `formatCHF(amount)``"CHF 1'234.50"`
- `formatDate(iso)``"14. Mai 2025"`
- `roundCHF(amount)` → 5-Rappen-Rundung
- `formatHours(minutes)``"2h 30m"`
**Schweizer QR-Rechnung:**
- `isQRIban(iban)` — IID-Range 3000031999
- `formatIban(iban)` → 4er-Blöcke
- `generateQRReference(invoiceNumber)` → 27-stellige Referenz mit Mod10-Prüfziffer
- `mod10(input)` — Schweizer Modulo-10-Algorithmus
**Templates / Nummerngenerierung:**
- `applyProjectNumberFormat`, `applyProtoNumberFormat` — Template-Syntax wie `{YYYY}/{NN}`
- `parseSeqFromNumber`, `nextProtoSeq`
- `buildReminderLetter(inv, nr, …)` — Mahnungstexte (1./2./3. Mahnung)
- `buildPdfName(format, content, settings)` — Sanitierter Dateiname
**Sonstiges:**
- `exportBuchhaltungCSV(data, year)` — Voller Jahresexport
- `migrateDashboardLayout(val)` — Alte Widget-IDs → Row-basiertes Layout
- `getFeiertageForYear`, `getWorkdaysInMonth`, `getSollStunden`
---
## 7. Print-Modul
[src/print/PrintComponents.jsx](src/print/PrintComponents.jsx) (~1200 Zeilen) exportiert `<PrintView>`, das via `setPrintContent(...)` aus App.jsx getriggert wird.
**Content-Typen:** `invoice`, `invoice+qr`, `qrbill`, `quote`, `letter`, `lieferschein`, `protokoll`, `lohn`, `studioBudget`, `buchhaltung`, `projectDetail`, `projectsOverview`, `mitarbeiterOverview`, `timeReport`.
**Druck-Trigger:** `getCurrentWebviewWindow().print()` (Tauri WebView) oder Fallback `window.print()` (Browser).
**Schweizer QR-Rechnung:** Lib `swissqrbill` (lokal installiert), erzeugt SVG für 100% akkuraten Druck. Format: 105mm × 210mm, separate `@page`-Regel.
**Styles:** Inline + `@page`, `print-color-adjust: exact`. Margins konfigurierbar über `settings.pageMargin{Top,Bottom,Left,Right}`.
---
## 8. Rust-Backend ([src-tauri/src/lib.rs](src-tauri/src/lib.rs))
**Alles in 103 Zeilen.** Keine `#[tauri::command]`. Kein Filesystem, kein HTTP, keine DB.
**Was er macht:**
1. **System-Tray** mit 5 Nav-Items (`nav:dashboard`, `nav:time`, `nav:projects`, `nav:buchhaltung`, `nav:settings`) + `show` + `quit` ([lib.rs:47-60](src-tauri/src/lib.rs#L47-L60))
2. **Tray-Click** → Fenster anzeigen + fokussieren ([lib.rs:81-90](src-tauri/src/lib.rs#L81-L90))
3. **Tray-Nav-Click**`emit("rapport:navigate", "<view>")` ans Frontend ([lib.rs:77](src-tauri/src/lib.rs#L77))
4. **Window-Close (X)** → Hide statt Quit, gesteuert durch `Arc<AtomicBool> is_quitting` ([lib.rs:25-35](src-tauri/src/lib.rs#L25-L35))
5. **Plugins registrieren:** `updater`, `process` (für Relaunch nach Update), `log` (nur Debug)
**Frontend lauscht** in [App.jsx:191](src/App.jsx#L191):
```js
listen("rapport:navigate", (event) => setView(event.payload))
```
**Capabilities** ([src-tauri/capabilities/default.json](src-tauri/capabilities/default.json)) — bewusst minimal:
- `core:default`
- `core:webview:allow-print` (für `window.print()`)
- `updater:default`
- `process:allow-restart` (für Relaunch nach Update)
- **Nichts** für `fs:*`, `shell:*`, `http:*`, `dialog:*`, `clipboard:*`
**Tauri-Plugins (Cargo.toml):**
- `tauri-plugin-updater` v2
- `tauri-plugin-process` v2
- `tauri-plugin-log` v2
- `serde` 1.0, `serde_json` 1.0
**Bekannte Fragilitäten:**
- `app.default_window_icon().unwrap()` — panicked, wenn Icon fehlt ([lib.rs:64](src-tauri/src/lib.rs#L64))
- Hardcoded `"main"`-Label für Window, Hardcoded `"nav:"`-Prefix — wenn Frontend Konventionen ändert, bricht Tray
- Keine Tests in Rust
---
## 9. Updater-Pipeline End-to-End
```
release.sh (lokal, manuell aufgerufen)
├─ liest VERSION aus tauri.conf.json + package.json (Mismatch → Exit)
├─ ⚠️ Cargo.toml wird NICHT geprüft
├─ Lädt Private Key aus ~/.tauri/rapport_updater.key (kein Passwort)
├─ npx tauri build (mit TAURI_SIGNING_PRIVATE_KEY env)
├─ findet .app.tar.gz + .sig in src-tauri/target/release/bundle/macos/
└─ schreibt latest.json (Repo-Root) mit version, signature, url, pub_date
└─ url zeigt auf Gitea Release Asset (manuell hochzuladen)
User (manuell):
├─ Gitea-Webinterface: Release mit Tag <VERSION> (ohne v-Prefix) erstellen
├─ .app.tar.gz (+ optional .dmg) hochladen
└─ git add latest.json && git commit && git push origin main
App-Start (in jeder installierten Version):
├─ UpdateNotifier.jsx: setTimeout 1.5s → checkForAppUpdate({ silent: true })
├─ Tauri-Plugin GET https://git.kgva.ch/karim/RAPPORT/raw/branch/main/latest.json
├─ Verifiziert Signature gegen pubkey aus tauri.conf.json (Minisign)
├─ Vergleicht latest.json.version mit getVersion()
└─ Wenn neuer → Modal mit "Installieren / Später / Diese Version überspringen"
Installation:
├─ update.downloadAndInstall(onProgress) // lädt von url in latest.json
└─ relaunch() (via plugin-process)
```
**Updater-Komponenten im Frontend:**
- [src/utils/updater.js](src/utils/updater.js) (49 Z.) — Wrapper, kapselt Skip-Logik in `localStorage`
- [src/components/UpdateNotifier.jsx](src/components/UpdateNotifier.jsx) (163 Z.) — Auto-Check beim Start, Modal mit Progress-Bar
- [src/components/UpdatesSupport.jsx](src/components/UpdatesSupport.jsx) (197 Z.) — Settings-Tab "Updates & Support", manueller Check, ignoriert Skip
- Custom DOM-Event: `window.dispatchEvent(new CustomEvent("rapport:check-update"))` — UpdatesSupport triggert manuell
**Aktueller `latest.json`:** nur `darwin-aarch64` (Apple Silicon). **Kein Intel-Build, kein Windows-Build, kein Linux-Build.** Wer auf x86_64-Mac oder anderem OS installiert, bekommt keine Updates.
**Signatur-Setup (Minisign):**
- Private Key: `~/.tauri/rapport_updater.key` (User-Home, **niemals** im Repo, gitignored via `*.key`)
- Public Key: base64 in `tauri.conf.json``plugins.updater.pubkey`
- Kein Passwort (`TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""`)
---
## 10. Build & Release-Workflow
**Versions-Bump betrifft drei Dateien — alle drei müssen synchron sein:**
1. [package.json](package.json) → `"version"`
2. [src-tauri/tauri.conf.json](src-tauri/tauri.conf.json) → `"version"`
3. [src-tauri/Cargo.toml](src-tauri/Cargo.toml) → `[package] version`
**Zusätzlich für jeden Release:**
4. [src/App.jsx](src/App.jsx) → Changelog-Entry in `CHANGELOGS`-Array (hardcoded in JSX)
5. [src/App.jsx](src/App.jsx) → `rapport_changelog_seen`-Vergleichswert (im Changelog-Modal-Close-Handler)
> ⚠️ `release.sh` prüft nur 1+2. **Cargo.toml-Mismatch bleibt unbemerkt.**
**Dev-Workflow:**
```bash
npm run dev # Vite-Server auf http://localhost:3000
npx tauri dev # Native Window + HMR
npm run lint # ESLint (manuell — kein Pre-Commit-Hook)
```
**Release-Workflow:**
```bash
# 1. Versionen in package.json, tauri.conf.json, Cargo.toml + Changelog-Entry hochziehen
# 2. Commit
# 3. Release-Script:
./scripts/release.sh
# 4. In Gitea-UI: Release <VERSION> erstellen (Tag OHNE v-Prefix — latest.json-URL nutzt /<VERSION>/), .app.tar.gz hochladen
# 5. git add latest.json && git commit -m "Release X.Y.Z" && git push origin main
# 6. git tag -a <VERSION> -m "..." && git push origin <VERSION>
```
> Die [README.md](README.md)-Release-Sektion erwähnt `scripts/release.sh` nicht und ist veraltet.
---
## 11. Konventionen
**Sprache:**
- **UI-Strings: Deutsch** ("Zeiterfassung", "Buchhaltung", "Beenden")
- **Code-Identifier: Englisch** (`isQuitting`, `setView`, `currentUser`)
- **Wenig Inline-Kommentare** — wenn vorhanden, meist Deutsch
**Naming:**
- Komponenten/Views: PascalCase, eine Datei = ein Default-Export (ggf. mit Named-Exports für Sub-Views)
- Utils: camelCase
- Dateien: PascalCase für Components/Views, lowercase für constants/utils
**Styling:**
- Inline-Styles dominieren (über 200 in [Invoices.jsx](src/views/Invoices.jsx) allein)
- Globale Klassen: `.btn`, `.card`, `.pill`, `.filter-bar`, `.modal` — definiert im `<style>`-Block in [App.jsx](src/App.jsx)
- CSS-Variablen für Theming: `--bg`, `--text`, `--border`, … (Dark Mode via `data-theme`-Attribut)
- **Kein** Tailwind, **kein** CSS-Module, **kein** styled-components
**ESLint** ([eslint.config.js](eslint.config.js)): Flat-Config mit `js.configs.recommended`, `reactHooks.configs.flat.recommended`, `reactRefresh.configs.vite`. Kein Prettier, kein Husky, kein lint-staged.
**Imports:** Stdlib oben (React), dann Constants/Utils, dann lokale Components. Keine Pfad-Aliase (`~/`, `@/` werden **nicht** verwendet — relative Pfade `../foo`).
---
## 12. Wo es weh tut — Realistische Schwachstellen
1. **Vier "God Components"** über 1200 Zeilen ([Projects](src/views/Projects.jsx), [Time](src/views/Time.jsx), [Invoices](src/views/Invoices.jsx), [Employees](src/views/Employees.jsx)) — Refactoring riskant ohne Tests, Sub-Komponenten sind intern definiert statt extrahiert.
2. **App.jsx ist 823 Zeilen** und macht: Auth, State, Migration, Sidebar, Modals, Changelog, About, Print-Routing, Hotkeys, Navigation-History, Theme. Jede Änderung an App.jsx ist hochriskant — sie betrifft alles.
3. **Inline-Styles ohne Konvention** — Spacing/Farben sind über das Projekt verstreut, kein Design-Token-System.
4. **Modal-State chaotisch** — manche Views haben `{type,id}`, andere mehrere `useState`. Kein zentraler Manager.
5. **Keine Tests.** Nichts. Kein Vitest, kein Cypress, kein Rust-Test. Kalkulationen in `utils.js` wären leicht testbar.
6. **Kein TypeScript.** Bei 18k Zeilen JSX ohne Types ist jedes Schema-Refactor Risiko.
7. **Kein Error-Boundary** — wenn eine lazy-geladene View crasht, weißer Screen.
8. **`localStorage` ohne Schema-Validierung** — korrupte Daten crashen im Render.
9. **Keine CI**, keine Pre-Commit-Hooks. Linting muss man sich selbst merken.
10. **Updater nur für Apple Silicon** — wenn User x86_64-Mac/Windows/Linux hat, kein Update.
11. **README-Release-Sektion veraltet** — erwähnt `scripts/release.sh` nicht.
12. **`release.sh` prüft Cargo.toml-Version nicht** — Inkonsistenz bleibt unbemerkt.
13. **`.unwrap()` im Tray-Icon-Load** in [lib.rs:64](src-tauri/src/lib.rs#L64) — Startup-Panic möglich, wenn Icon fehlt.
---
## 13. Wenn-du-anfasst-Hinweise
| Bereich | Risiko | Notiz |
|---|---|---|
| `App.jsx` State/Auth/Migration | **Sehr hoch** | Touch nur mit klarem Auftrag, betrifft alles |
| `constants.js` `defaultData` Shape | **Hoch** | Schema-Änderung erfordert Migration (siehe Beispiele in App.jsx:56-122) |
| `utils.js` Kalkulationen | **Hoch** | Ohne Tests — Änderung an `calcSIAHours`, `calcLohn`, `generateQRReference`, `mod10` → manuell durchrechnen |
| `print/PrintComponents.jsx` | **Hoch** | SwissQR-Bill ist Pixel-genau — Layout-Bugs sichtbar erst im Druck |
| Views (Invoices/Projects/Time) | **Hoch** | Lange Files mit Edge-Cases (Mahnung, Akonto, Drag&Drop) |
| `Settings.jsx` Permissions | **Hoch** | Tangiert Rollen/Berechtigungen, Dashboard-Templates |
| `Login.jsx` Hash-Logik | **Hoch** | PBKDF2 + Migration, sicherheitsrelevant |
| `lib.rs` Tray/Window | **Mittel** | Wenn Nav-IDs geändert werden, müssen Frontend + Rust synchron bleiben |
| `tauri.conf.json` Updater | **Sehr hoch** | Public Key ändern bricht alle bestehenden Installationen |
| `release.sh` | **Sehr hoch** | Falsche Änderung → defekte Updates beim User |
| Neue Util / neue View | Niedrig | Isoliert, safe — kopiere bestehende, entferne was du nicht brauchst |
---
## 14. Offene Fragen / Nicht-Validiertes
- Wo werden Bild-Uploads (Receipts in Expenses, Logo in Settings) gespeichert? Vermutlich Base64 in `data` → wächst `localStorage` unkontrolliert.
- Wie groß darf `data` werden, bevor `localStorage` (510 MB Limit) bricht? Aktuell ohne Monitoring.
- PDF-Export: aktuell nur `window.print()` → User-PDF-Dialog. Kein direkter File-Save.
- Multi-User-Workflow: `users[]` in `data`, aber nur ein Browser-localStorage → keine echte Mehrfachnutzung.
+97
View File
@@ -0,0 +1,97 @@
# Anweisungen für Claude
Dieses Dokument wird in **jede** Claude-Session automatisch geladen. Es ist die kürzeste Form: Was du wissen musst und was du tun/lassen sollst. Details stehen in [ARCHITECTURE.md](ARCHITECTURE.md) — **lies sie, bevor du nicht-triviale Änderungen machst.**
---
## Was das Projekt ist
**RAPPORT** — Tauri 2.x Desktop-App für Architekturbüros. React 19 (kein TypeScript), minimaler Rust-Backend (System-Tray + Updater, keine Tauri-Commands). Solo-Dev: Karim. Daten leben in `localStorage` unter Key `studio_data_v1`. macOS Apple Silicon ist die primäre Plattform.
Architektur in einem Satz: **App.jsx hält den gesamten State, übergibt ihn an lazy-geladene Views als Props, persistiert synchron in localStorage. Rust macht nur Tray + Update.**
→ Vollständige Karte: [ARCHITECTURE.md](ARCHITECTURE.md)
---
## Befehle
```bash
npm run dev # Vite-Server auf http://localhost:3000
npx tauri dev # Native App-Window mit HMR (für UI-Verifikation)
npm run lint # ESLint
npm run build # Frontend-Build (dist/)
npx tauri build # Vollständiges App-Bundle
./scripts/release.sh # Release-Build mit Signatur — NUR auf explizite Anweisung
```
Es gibt **keine Tests**, keine CI, keinen Pre-Commit-Hook. Korrektheit ist Augenmaß.
---
## Sprache
- **Antworte auf Deutsch.** Karim arbeitet auf Deutsch.
- **UI-Strings im Code: Deutsch** ("Zeiterfassung", "Buchhaltung", "Beenden").
- **Code-Identifier: Englisch** (`setView`, `currentUser`, `isQuitting`).
---
## Vor jeder Änderung — die drei Reflexe
1. **Wenn es um Daten/State geht** → schau in [App.jsx](src/App.jsx) (Top-Level-State, Migrations, `save()`, `update()`). Kein Zustand existiert ausserhalb davon.
2. **Wenn es um Logik geht** (Kalkulation, Format, Hash, QR-Bill) → schau in [utils.js](src/utils.js). Dort sind isolierte Funktionen — wahrscheinlich gibt es schon eine.
3. **Wenn es um das Datenmodell geht** → schau in [constants.js](src/constants.js) (`defaultData` ist die Shape-Referenz).
---
## Tu — Konventionen die hier gelten
- **Inline-Styles** (`style={{}}`) sind etabliert. Keine Tailwind-Vorschläge, keine CSS-Module einführen, ohne dass Karim das initiiert.
- **Globale Klassen** (`.btn`, `.card`, `.pill`, `.modal`, `.filter-bar`) sind in [App.jsx](src/App.jsx)'s `<style>`-Block definiert — nutze sie statt eigene zu erfinden.
- **CSS-Variablen** für Theming (`--bg`, `--text`, `--border`, …). Dark Mode ist `data-theme="dark"` am Root. Niemals harte Farben einbauen, ohne CSS-Variable zu prüfen.
- **Lazy-Loaded Views** — neue Top-Level-Screens in [App.jsx](src/App.jsx) mit `React.lazy()` einbinden, nicht direkt importieren.
- **Props-Pattern:** Views bekommen `{ data, update, saveAll, modal, setModal, currentUser, … }`. Kein Context, kein Redux. Wenn du State teilst, mach es über `data` oder hebe ihn in App.jsx.
- **`update(key, value)`** für Top-Level-Field-Updates, **`saveAll(newData)`** für atomare Multi-Field-Updates.
- **Relative Imports** (`../utils.js`), keine Pfad-Aliase.
- **Deutsch in UI-Strings**, auch in neuen Features.
---
## Lass — Stolperfallen
- **Kein TypeScript einführen** ohne expliziten Auftrag — die Migration wäre eigenständig zu diskutieren.
- **Keine Test-Suite "nebenbei" aufsetzen** — sinnvoll, aber separate Entscheidung.
- **Keine Context-/Redux-/Zustand-Provider hinzufügen** — das Single-Root-State-Pattern ist bewusst.
- **Keine Tauri-Commands (`#[tauri::command]`) erfinden**, ohne mit Karim zu klären. Die Architektur ist absichtlich Frontend-zentriert.
- **Keine neuen `localStorage`-Keys** erfinden, ohne `rapport_`-Prefix und Eintragung in [ARCHITECTURE.md §4](ARCHITECTURE.md).
- **Niemals Tests/Lint mit `--no-verify` umgehen** — wenn ein Pre-Commit-Hook fehlt, fehlt er aus Absicht; wenn einer da ist, ist das Karims Entscheidung.
- **`localStorage` ist die Wahrheit** — keine Versuche, parallele Persistierung (IndexedDB, Tauri AppData) hinzuzufügen.
---
## Releases — heißes Eisen
**Mach niemals einen Release auf eigene Faust.** Auch nicht "halb" (kein Versions-Bump, kein `release.sh`, kein Tag, kein `latest.json`-Edit), es sei denn Karim sagt es explizit.
Wenn ein Release gewünscht ist, denke an:
- Version in **drei** Dateien synchron: [package.json](package.json), [src-tauri/tauri.conf.json](src-tauri/tauri.conf.json), [src-tauri/Cargo.toml](src-tauri/Cargo.toml). `release.sh` prüft Cargo.toml **nicht** — du musst manuell.
- Changelog-Entry in [App.jsx](src/App.jsx)'s `CHANGELOGS`-Array + den `rapport_changelog_seen`-Vergleichswert.
- `release.sh` braucht `~/.tauri/rapport_updater.key` — wenn fehlt, abbrechen, nicht generieren.
- Asset-Upload auf Gitea und `latest.json`-Commit sind **manuelle** Schritte.
Details: [ARCHITECTURE.md §9 + §10](ARCHITECTURE.md).
---
## UI-Verifikation
Wenn du Frontend-Änderungen machst, die optisch wirken, **starte `npx tauri dev`** und schau, ob es funktioniert. ESLint und Type-Checking gibt es hier nicht — also ist Augenschein die Verifikation. Wenn du es nicht selbst öffnen kannst, sag das explizit, anstatt "fertig" zu melden.
---
## Wenn du etwas Neues findest
Wenn du beim Arbeiten merkst, dass [ARCHITECTURE.md](ARCHITECTURE.md) falsch oder veraltet ist (z.B. neue Views, neue Konventionen, Refactor): **update sie im selben Commit**. Sie ist die Karte — wenn sie verrottet, war die Mühe umsonst.
Wenn du eine wiederkehrende Anweisung von Karim bekommst, die hier fehlt: schlag vor, sie in CLAUDE.md zu ergänzen.
+2 -2
View File
@@ -121,8 +121,8 @@ Zusätzlich im UI-Changelog: [`src/App.jsx`](src/App.jsx) — Konstante `CHANGEL
# 1. Versionen anheben (package.json, tauri.conf.json, Cargo.toml)
# 2. Changelog in src/App.jsx ergänzen
# 3. Commit + Tag
git tag -a v0.7.0 -m "Rapport 0.7"
git push origin main v0.7.0
git tag -a 0.7.0 -m "Rapport 0.7" # ohne v-Prefix — latest.json verlinkt /releases/download/<VERSION>/
git push origin main 0.7.0
# 4. Bundle bauen
npx tauri build
+1 -1
View File
@@ -11,7 +11,7 @@
# 3) schreibt latest.json im Repo-Root mit URLs auf Gitea-Release-Assets
#
# Danach manuell:
# - auf Gitea einen Release mit Tag v<VERSION> erstellen
# - auf Gitea einen Release mit Tag <VERSION> erstellen (OHNE v-Prefix — latest.json verlinkt /releases/download/<VERSION>/)
# - die .app.tar.gz und (optional) die .dmg als Assets hochladen
# - latest.json committen + auf main pushen
-452
View File
@@ -1,452 +0,0 @@
import React, { useState } from "react";
import { generateId } from "../utils.js";
import { Header, Modal, FormField, useConfirm } from "../components/UI.jsx";
export default
function Clients({ data, update, modal, setModal, setView }) {
const clients = data.clients || [];
const { askConfirm, ConfirmModalEl } = useConfirm();
const [selectedId, setSelectedId] = useState(() => {
const id = window.__navToClient || null;
window.__navToClient = null;
return id;
});
const [search, setSearch] = useState("");
const [groupBy, setGroupBy] = useState("alpha");
const [contactModal, setContactModal] = useState(null);
const [contactForm, setContactForm] = useState({ name: "", position: "", email: "", phone: "" });
const [showHauptPicker, setShowHauptPicker] = useState(false);
const emptyForm = {
name: "", street: "", zip: "", city: "", country: "CH",
email: "", phone: "", website: "",
contacts: [],
_contactName: "", _contactPosition: "",
};
const [form, setForm] = useState(emptyForm);
const selectedClient = clients.find(c => c.id === selectedId) || null;
// ── Client speichern ──
const save = () => {
if (!form.name.trim()) return;
const { _contactName, _contactPosition, ...clientData } = form;
let contacts = clientData.contacts || [];
if (_contactName.trim() && !modal?.id) {
contacts = [{ id: generateId(), name: _contactName.trim(), position: _contactPosition.trim(), email: "", phone: "" }];
}
const client = { ...clientData, contacts, id: modal?.id || generateId() };
update("clients", modal?.id ? clients.map(c => c.id === modal.id ? client : c) : [...clients, client]);
setModal(null);
};
const openNew = () => { setForm(emptyForm); setModal({ type: "client" }); };
const openEdit = (c) => {
setForm({ ...emptyForm, ...c, _contactName: "", _contactPosition: "" });
setModal({ type: "client", id: c.id });
};
const del = async (id) => {
if (await askConfirm("Kunde löschen? Alle zugehörigen Projekte verlieren die Kundenzuordnung.")) {
update("clients", clients.filter(c => c.id !== id));
if (selectedId === id) setSelectedId(null);
}
};
// ── Kontakt speichern ──
const saveContact = () => {
if (!contactForm.name.trim()) return;
const client = clients.find(c => c.id === contactModal.clientId);
if (!client) return;
const contacts = client.contacts || [];
const updated = contactModal.contactId
? contacts.map(ct => ct.id === contactModal.contactId ? { ...ct, ...contactForm } : ct)
: [...contacts, { ...contactForm, id: generateId() }];
update("clients", clients.map(c => c.id === client.id ? { ...c, contacts: updated } : c));
setContactModal(null);
};
const delContact = async (clientId, contactId) => {
if (await askConfirm("Kontaktperson löschen?")) {
const client = clients.find(c => c.id === clientId);
update("clients", clients.map(c => c.id === clientId ? { ...c, contacts: (c.contacts || []).filter(ct => ct.id !== contactId) } : c));
}
};
// ── Detail-Ansicht ──
if (selectedId && selectedClient) {
const projs = (data.projects || []).filter(p => p.clientId === selectedId).sort((a, b) => (b.startDate || "").localeCompare(a.startDate || ""));
const invoices = (data.invoices || []).filter(i => i.clientId === selectedId).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
const quotes = (data.quotes || []).filter(q => q.clientId === selectedId).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
const contacts = selectedClient.contacts || [];
const hauptkontakt = contacts[0] || null;
const addressLine = [selectedClient.street, [selectedClient.zip, selectedClient.city].filter(Boolean).join(" ")].filter(Boolean).join(", ");
const navTo = (view) => { window.__navClientId = selectedId; setView(view); };
const formatCHF = (v) => v != null ? `CHF ${Number(v).toLocaleString("de-CH", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—";
const fmtDate = (s) => s ? new Date(s).toLocaleDateString("de-CH") : "—";
return (
<div>
{ConfirmModalEl}
<button className="btn btn-ghost" onClick={() => setSelectedId(null)} style={{ marginBottom: 18, padding: "6px 14px", fontSize: 12 }}> Alle Kunden</button>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 20 }}>
<div>
<h2 style={{ margin: 0, fontFamily: "'Playfair Display', serif", fontSize: 26 }}>{selectedClient.name}</h2>
{addressLine && <div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>{addressLine}</div>}
</div>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => openEdit(selectedClient)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, alignItems: "start", marginBottom: 20 }}>
{/* Firmeninfo */}
<div className="card">
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888", marginBottom: 14 }}>FIRMENINFO</div>
{[
{ label: "E-Mail", value: selectedClient.email, href: `mailto:${selectedClient.email}` },
{ label: "Telefon", value: selectedClient.phone },
{ label: "Website", value: selectedClient.website, href: selectedClient.website?.startsWith("http") ? selectedClient.website : selectedClient.website ? `https://${selectedClient.website}` : null },
{ label: "Adresse", value: addressLine || null },
].filter(r => r.value).map(({ label, value, href }) => (
<div key={label} style={{ display: "flex", gap: 12, padding: "6px 0", borderBottom: "1px solid #f5f2ec" }}>
<span style={{ fontSize: 11, color: "#aaa", minWidth: 70 }}>{label}</span>
{href ? <a href={href} style={{ fontSize: 13, color: "#1a4e8a", textDecoration: "none" }}>{value}</a> : <span style={{ fontSize: 13 }}>{value}</span>}
</div>
))}
{contacts.length > 0 && (
<div style={{ marginTop: 14, paddingTop: 12, borderTop: "1px solid #ece8e2", position: "relative" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<div style={{ fontSize: 11, color: "#888" }}>HAUPTKONTAKT</div>
{contacts.length > 1 && (
<button className="btn btn-ghost" style={{ fontSize: 10, padding: "2px 8px" }} onClick={() => setShowHauptPicker(v => !v)}>
ändern
</button>
)}
</div>
{showHauptPicker ? (
<div style={{ border: "1px solid #ece8e2", borderRadius: 6, overflow: "hidden" }}>
{contacts.map((ct, i) => (
<button key={ct.id} onClick={() => {
const reordered = [ct, ...contacts.filter(x => x.id !== ct.id)];
update("clients", clients.map(c => c.id === selectedId ? { ...c, contacts: reordered } : c));
setShowHauptPicker(false);
}} style={{
display: "block", width: "100%", textAlign: "left", padding: "9px 12px",
background: i === 0 ? "#f5f2ec" : "white", border: "none", borderBottom: i < contacts.length - 1 ? "1px solid #f0ede8" : "none",
cursor: "pointer", fontFamily: "inherit",
}}>
<div style={{ fontWeight: i === 0 ? 600 : 400, fontSize: 13 }}>{ct.name}</div>
{ct.position && <div style={{ fontSize: 11, color: "#888" }}>{ct.position}</div>}
</button>
))}
</div>
) : hauptkontakt ? (
<>
<div style={{ fontWeight: 600, fontSize: 13 }}>{hauptkontakt.name}</div>
{hauptkontakt.position && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{hauptkontakt.position}</div>}
<div style={{ display: "flex", gap: 14, marginTop: 6 }}>
{hauptkontakt.email && <a href={`mailto:${hauptkontakt.email}`} style={{ fontSize: 12, color: "#1a4e8a", textDecoration: "none" }}>{hauptkontakt.email}</a>}
{hauptkontakt.phone && <span style={{ fontSize: 12, color: "#555" }}>{hauptkontakt.phone}</span>}
</div>
</>
) : null}
</div>
)}
</div>
{/* Ansprechpartner */}
<div className="card" style={{ padding: 0 }}>
<div style={{ padding: "14px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: contacts.length > 0 ? "1px solid #ece8e2" : "none" }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>ANSPRECHPARTNER ({contacts.length})</div>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => { setContactForm({ name: "", position: "", email: "", phone: "" }); setContactModal({ clientId: selectedId }); }}>+ Hinzufügen</button>
</div>
{contacts.length === 0 ? (
<div style={{ padding: "20px", fontSize: 12, color: "#aaa", textAlign: "center" }}>Noch keine Ansprechpartner erfasst.</div>
) : (
contacts.map((ct, i) => (
<div key={ct.id} style={{ padding: "12px 20px", borderBottom: i < contacts.length - 1 ? "1px solid #f5f2ec" : "none" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>{ct.name}</span>
{i === 0 && <span style={{ fontSize: 9, background: "#ece8e2", color: "#888", padding: "1px 6px", borderRadius: 3, letterSpacing: "0.08em" }}>HAUPT</span>}
</div>
{ct.position && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{ct.position}</div>}
<div style={{ display: "flex", gap: 14, marginTop: 4 }}>
{ct.email && <a href={`mailto:${ct.email}`} style={{ fontSize: 12, color: "#1a4e8a", textDecoration: "none" }}>{ct.email}</a>}
{ct.phone && <span style={{ fontSize: 12, color: "#555" }}>{ct.phone}</span>}
</div>
</div>
<div style={{ display: "flex", gap: 4 }}>
<button className="btn btn-ghost" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => { setContactForm({ name: ct.name, position: ct.position || "", email: ct.email || "", phone: ct.phone || "" }); setContactModal({ clientId: selectedId, contactId: ct.id }); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => delContact(selectedId, ct.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
</div>
))
)}
</div>
</div>
{/* Projekte */}
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "12px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: projs.length > 0 ? "1px solid #ece8e2" : "none" }}>
<span style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>PROJEKTE ({projs.length})</span>
{projs.length > 0 && <button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => navTo("projects")}>Alle anzeigen </button>}
</div>
{projs.length === 0
? <div style={{ padding: "16px 20px", fontSize: 12, color: "#aaa" }}>Noch keine Projekte.</div>
: <>
<table style={{ width: "100%" }}>
<thead><tr><th>Projekt</th><th>Kategorie</th><th>Status</th><th style={{ textAlign: "right" }}>Budget</th></tr></thead>
<tbody>
{projs.slice(0, 5).map(p => (
<tr key={p.id}>
<td><strong>{p.name}</strong>{p.number && <span style={{ fontSize: 11, color: "#aaa", marginLeft: 6 }}>{p.number}</span>}</td>
<td style={{ fontSize: 12, color: "#888" }}>{p.category || "—"}</td>
<td><span style={{ fontSize: 11, color: p.status === "aktiv" ? "#2d6a4f" : "#888" }}>{p.status}</span></td>
<td style={{ textAlign: "right", fontSize: 12 }}>{p.budget > 0 ? formatCHF(p.budget) : "—"}</td>
</tr>
))}
</tbody>
</table>
{projs.length > 5 && <div style={{ padding: "8px 20px", fontSize: 11, color: "#aaa", borderTop: "1px solid #f5f2ec" }}>+{projs.length - 5} weitere <button className="btn btn-ghost" style={{ fontSize: 11, padding: "2px 8px" }} onClick={() => navTo("projects")}>Alle anzeigen</button></div>}
</>
}
</div>
{/* Rechnungen */}
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "12px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: invoices.length > 0 ? "1px solid #ece8e2" : "none" }}>
<span style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>RECHNUNGEN ({invoices.length})</span>
{invoices.length > 0 && <button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => navTo("invoices")}>Alle anzeigen </button>}
</div>
{invoices.length === 0
? <div style={{ padding: "16px 20px", fontSize: 12, color: "#aaa" }}>Noch keine Rechnungen.</div>
: <>
<table style={{ width: "100%" }}>
<thead><tr><th>Nr.</th><th>Datum</th><th>Projekt</th><th>Status</th><th style={{ textAlign: "right" }}>Betrag</th></tr></thead>
<tbody>
{invoices.slice(0, 5).map(inv => {
const proj = inv.projectId ? (data.projects || []).find(p => p.id === inv.projectId) : null;
return (
<tr key={inv.id}>
<td><strong>{inv.number}</strong></td>
<td style={{ fontSize: 12, color: "#888" }}>{fmtDate(inv.date)}</td>
<td style={{ fontSize: 12, color: "#555" }}>{proj?.name || "—"}</td>
<td><span style={{ fontSize: 11, color: inv.status === "bezahlt" ? "#2d6a4f" : inv.status === "überfällig" ? "#8a1a1a" : "#888" }}>{inv.status}</span></td>
<td style={{ textAlign: "right", fontSize: 12, fontWeight: 500 }}>{formatCHF(inv.total)}</td>
</tr>
);
})}
</tbody>
</table>
{invoices.length > 5 && <div style={{ padding: "8px 20px", fontSize: 11, color: "#aaa", borderTop: "1px solid #f5f2ec" }}>+{invoices.length - 5} weitere <button className="btn btn-ghost" style={{ fontSize: 11, padding: "2px 8px" }} onClick={() => navTo("invoices")}>Alle anzeigen</button></div>}
</>
}
</div>
{/* Offerten */}
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "12px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: quotes.length > 0 ? "1px solid #ece8e2" : "none" }}>
<span style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>OFFERTEN ({quotes.length})</span>
{quotes.length > 0 && <button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => navTo("quotes")}>Alle anzeigen </button>}
</div>
{quotes.length === 0
? <div style={{ padding: "16px 20px", fontSize: 12, color: "#aaa" }}>Noch keine Offerten.</div>
: <>
<table style={{ width: "100%" }}>
<thead><tr><th>Nr.</th><th>Datum</th><th>Modus</th><th>Status</th><th style={{ textAlign: "right" }}>Honorar</th></tr></thead>
<tbody>
{quotes.slice(0, 5).map(q => (
<tr key={q.id}>
<td><strong>{q.number}</strong></td>
<td style={{ fontSize: 12, color: "#888" }}>{fmtDate(q.date)}</td>
<td style={{ fontSize: 11, color: "#888" }}>{q.mode === "sia" ? "SIA 102" : q.mode === "manual" ? "Aufwand" : "Frei"}</td>
<td><span style={{ fontSize: 11, color: q.status === "genehmigt" ? "#2d6a4f" : "#888" }}>{q.status || "—"}</span></td>
<td style={{ textAlign: "right", fontSize: 12, fontWeight: 500 }}>{formatCHF(q.total)}</td>
</tr>
))}
</tbody>
</table>
{quotes.length > 5 && <div style={{ padding: "8px 20px", fontSize: 11, color: "#aaa", borderTop: "1px solid #f5f2ec" }}>+{quotes.length - 5} weitere <button className="btn btn-ghost" style={{ fontSize: 11, padding: "2px 8px" }} onClick={() => navTo("quotes")}>Alle anzeigen</button></div>}
</>
}
</div>
{/* Kontakt-Modal */}
{contactModal && (
<Modal title={contactModal.contactId ? "Kontakt bearbeiten" : "Neuer Ansprechpartner"} onClose={() => setContactModal(null)} onSave={saveContact}>
<div className="form-row">
<FormField label="Name *"><input value={contactForm.name} onChange={e => setContactForm({ ...contactForm, name: e.target.value })} autoFocus /></FormField>
<FormField label="Funktion / Position"><input value={contactForm.position} onChange={e => setContactForm({ ...contactForm, position: e.target.value })} placeholder="z.B. Geschäftsführer, Bauleiter…" /></FormField>
</div>
<div className="form-row">
<FormField label="E-Mail"><input type="email" value={contactForm.email} onChange={e => setContactForm({ ...contactForm, email: e.target.value })} /></FormField>
<FormField label="Telefon"><input value={contactForm.phone} onChange={e => setContactForm({ ...contactForm, phone: e.target.value })} /></FormField>
</div>
</Modal>
)}
{/* Client-Edit-Modal */}
{modal?.type === "client" && modal.id && (
<Modal title="Kunde bearbeiten" onClose={() => setModal(null)} onSave={save} wide>
{clientFormFields(form, setForm)}
</Modal>
)}
</div>
);
}
// ── Listen-Ansicht ──
const filteredClients = clients.filter(c => {
if (!search) return true;
const q = search.toLowerCase();
return [c.name, c.city, c.email, c.street, ...(c.contacts || []).map(ct => ct.name)].some(v => v?.toLowerCase().includes(q));
});
const clientGroups = (() => {
if (groupBy === "none") return [{ key: "_all", label: null, items: filteredClients }];
if (groupBy === "alpha") {
const g = {};
[...filteredClients].sort((a, b) => a.name.localeCompare(b.name, "de"))
.forEach(c => { const k = c.name[0]?.toUpperCase() || "#"; (g[k] = g[k] || []).push(c); });
return Object.entries(g).sort((a, b) => a[0].localeCompare(b[0])).map(([k, items]) => ({ key: k, label: k, items }));
}
if (groupBy === "city") {
const g = {};
[...filteredClients].sort((a, b) => a.name.localeCompare(b.name, "de"))
.forEach(c => { const k = c.city || "Ohne Ort"; (g[k] = g[k] || []).push(c); });
return Object.entries(g).sort((a, b) => a[0].localeCompare(b[0])).map(([k, items]) => ({ key: k, label: k, items }));
}
})();
const ClientTable = ({ items }) => (
<div className="card" style={{ padding: 0 }}>
<table style={{ width: "100%" }}>
<thead>
<tr>
<th>Firmenname</th>
<th>Adresse</th>
<th>Hauptkontakt</th>
<th style={{ textAlign: "center", width: 80 }}>Kontakte</th>
<th style={{ textAlign: "center", width: 80 }}>Projekte</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{items.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>Keine Treffer</td></tr>}
{items.map(c => {
const projs = (data.projects || []).filter(p => p.clientId === c.id).length;
const cts = c.contacts || [];
const hauptkontakt = cts[0];
const city = [c.zip, c.city].filter(Boolean).join(" ");
return (
<tr key={c.id} style={{ cursor: "pointer" }} onClick={() => setSelectedId(c.id)}>
<td>
<strong>{c.name}</strong>
{c.email && <div style={{ fontSize: 11, color: "#888" }}>{c.email}</div>}
</td>
<td style={{ fontSize: 12, color: "#666" }}>
{c.street && <div>{c.street}</div>}
{city && <div>{city}</div>}
</td>
<td style={{ fontSize: 12 }}>
{hauptkontakt ? (
<>
<div style={{ fontWeight: 500 }}>{hauptkontakt.name}</div>
{hauptkontakt.position && <div style={{ fontSize: 11, color: "#888" }}>{hauptkontakt.position}</div>}
</>
) : <span style={{ color: "#ccc" }}></span>}
</td>
<td style={{ textAlign: "center", fontSize: 12, color: "#888" }}>{cts.length || "—"}</td>
<td style={{ textAlign: "center", color: projs ? "#2d6a4f" : "#ccc", fontSize: 12, fontWeight: projs ? 600 : 400 }}>{projs || "—"}</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }} onClick={e => e.stopPropagation()}>
<button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12 }} onClick={() => openEdit(c)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => del(c.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
return (
<div>
{ConfirmModalEl}
<Header title="Kunden" action={<button className="btn btn-primary" onClick={openNew}>+ Neuer Kunde</button>} />
<div style={{ display: "flex", gap: 8, marginBottom: 16, alignItems: "center" }}>
<input placeholder="Suchen…" value={search} onChange={e => setSearch(e.target.value)}
style={{ flex: "1 1 200px", maxWidth: 300, fontSize: 12 }} />
<select value={groupBy} onChange={e => setGroupBy(e.target.value)} style={{ fontSize: 12, width: 170 }}>
<option value="alpha">Alphabetisch</option>
<option value="city">Nach Ort</option>
<option value="none">Keine Gruppierung</option>
</select>
</div>
{clients.length === 0 ? (
<div className="card" style={{ padding: 40, textAlign: "center", color: "#aaa" }}>Noch keine Kunden erfasst.</div>
) : filteredClients.length === 0 ? (
<div className="card" style={{ padding: 40, textAlign: "center", color: "#aaa" }}>Keine Treffer</div>
) : clientGroups.map(group => (
<div key={group.key} style={{ marginBottom: 20 }}>
{group.label && (
<div style={{ fontSize: 10, letterSpacing: "0.14em", color: "#aaa", fontWeight: 600, marginBottom: 8, paddingLeft: 2 }}>
{group.label.toUpperCase()} <span style={{ opacity: 0.55 }}>{group.items.length}</span>
</div>
)}
<ClientTable items={group.items} />
</div>
))}
{modal?.type === "client" && (
<Modal title={modal.id ? "Kunde bearbeiten" : "Neuer Kunde"} onClose={() => setModal(null)} onSave={save} wide>
{clientFormFields(form, setForm, !modal.id)}
</Modal>
)}
</div>
);
}
function clientFormFields(form, setForm, isNew = false) {
return (
<>
<FormField label="Firmenname *">
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} autoFocus placeholder="z.B. Müller Immobilien AG" />
</FormField>
<div className="form-row">
<FormField label="Strasse + Nr."><input value={form.street || ""} onChange={e => setForm({ ...form, street: e.target.value })} placeholder="Bahnhofstrasse 1" /></FormField>
<FormField label="PLZ"><input value={form.zip || ""} onChange={e => setForm({ ...form, zip: e.target.value })} style={{ maxWidth: 100 }} /></FormField>
<FormField label="Ort"><input value={form.city || ""} onChange={e => setForm({ ...form, city: e.target.value })} /></FormField>
<FormField label="Land"><input value={form.country || "CH"} onChange={e => setForm({ ...form, country: e.target.value.toUpperCase() })} maxLength={2} style={{ maxWidth: 70 }} /></FormField>
</div>
<div className="form-row">
<FormField label="E-Mail Firma"><input type="email" value={form.email || ""} onChange={e => setForm({ ...form, email: e.target.value })} /></FormField>
<FormField label="Telefon Firma"><input value={form.phone || ""} onChange={e => setForm({ ...form, phone: e.target.value })} /></FormField>
<FormField label="Website"><input value={form.website || ""} onChange={e => setForm({ ...form, website: e.target.value })} placeholder="www.beispiel.ch" /></FormField>
</div>
{isNew && (
<>
<div style={{ marginTop: 16, paddingTop: 14, borderTop: "1px solid #ece8e2", fontSize: 11, letterSpacing: "0.08em", color: "#888", marginBottom: 10 }}>
HAUPTKONTAKT (optional)
</div>
<div className="form-row">
<FormField label="Name Referenzperson">
<input value={form._contactName || ""} onChange={e => setForm({ ...form, _contactName: e.target.value })} placeholder="z.B. Hans Müller" />
</FormField>
<FormField label="Funktion / Position">
<input value={form._contactPosition || ""} onChange={e => setForm({ ...form, _contactPosition: e.target.value })} placeholder="z.B. Geschäftsführer" />
</FormField>
</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: -6 }}>Weitere Ansprechpartner können in der Kundendetailseite hinzugefügt werden.</div>
</>
)}
</>
);
}
-456
View File
@@ -1,456 +0,0 @@
import React, { useState } from "react";
import { generateId } from "../utils.js";
import { Header, Modal, FormField, useConfirm , DateInput } from "../components/UI.jsx";
const CONTACT_TYPES = [
"Elektroplaner", "HLKSE-Planer", "Statiker", "Tragwerksplaner",
"Kostenplaner", "Landschaftsarchitekt", "Bauphysiker",
"Vermessungsingenieur", "Brandschutzspezialist", "Geologe",
"Generalunternehmer", "Fachplaner", "Sonstiges",
];
const fmtCHF = (v) => v != null ? `CHF ${Number(v).toLocaleString("de-CH", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : "—";
const fmtDate = (s) => s ? new Date(s).toLocaleDateString("de-CH") : "—";
export default
function Contacts({ data, update }) {
const contacts = data.contacts || [];
const { askConfirm, ConfirmModalEl } = useConfirm();
const [selectedId, setSelectedId] = useState(() => {
const id = window.__navToContact || null;
window.__navToContact = null;
return id;
});
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [groupBy, setGroupBy] = useState("alpha");
const emptyFirm = {
name: "", type: "", street: "", zip: "", city: "", email: "", phone: "", website: "", note: "",
contacts: [], honorarOffers: [],
_personName: "", _personPosition: "",
};
const [firmModal, setFirmModal] = useState(null);
const [firmForm, setFirmForm] = useState(emptyFirm);
const [personModal, setPersonModal] = useState(null);
const [personForm, setPersonForm] = useState({ name: "", position: "", email: "", phone: "" });
const [honorarModal, setHonorarModal] = useState(null);
const [honorarForm, setHonorarForm] = useState({ date: "", amount: "", phase: "", description: "", note: "" });
const selectedContact = contacts.find(c => c.id === selectedId) || null;
// ── Firm CRUD ──
const saveFirm = () => {
if (!firmForm.name.trim()) return;
const { _personName, _personPosition, ...firmData } = firmForm;
let persons = firmData.contacts || [];
if (_personName.trim() && !firmModal?.id) {
persons = [{ id: generateId(), name: _personName.trim(), position: _personPosition.trim(), email: "", phone: "" }];
}
const firm = { ...firmData, contacts: persons, id: firmModal?.id || generateId() };
update("contacts", firmModal?.id ? contacts.map(c => c.id === firmModal.id ? firm : c) : [...contacts, firm]);
setFirmModal(null);
};
const openNew = () => { setFirmForm(emptyFirm); setFirmModal({}); };
const openEdit = (c) => { setFirmForm({ ...emptyFirm, ...c, _personName: "", _personPosition: "" }); setFirmModal({ id: c.id }); };
const delFirm = async (id) => {
if (await askConfirm("Kontakt löschen?")) {
update("contacts", contacts.filter(c => c.id !== id));
if (selectedId === id) setSelectedId(null);
}
};
// ── Person CRUD ──
const savePerson = () => {
if (!personForm.name.trim()) return;
const firm = contacts.find(c => c.id === personModal.contactId);
if (!firm) return;
const persons = firm.contacts || [];
const updated = personModal.personId
? persons.map(p => p.id === personModal.personId ? { ...p, ...personForm } : p)
: [...persons, { ...personForm, id: generateId() }];
update("contacts", contacts.map(c => c.id === firm.id ? { ...c, contacts: updated } : c));
setPersonModal(null);
};
const delPerson = async (contactId, personId) => {
if (await askConfirm("Person löschen?")) {
update("contacts", contacts.map(c => c.id === contactId
? { ...c, contacts: (c.contacts || []).filter(p => p.id !== personId) } : c));
}
};
// ── Honorar CRUD ──
const saveHonorar = () => {
const firm = contacts.find(c => c.id === honorarModal.contactId);
if (!firm) return;
const offers = firm.honorarOffers || [];
const offer = { id: honorarModal.offerId || generateId(), date: honorarForm.date, amount: parseFloat(honorarForm.amount) || 0, phase: honorarForm.phase, description: honorarForm.description, note: honorarForm.note };
const updated = honorarModal.offerId ? offers.map(o => o.id === honorarModal.offerId ? offer : o) : [...offers, offer];
update("contacts", contacts.map(c => c.id === firm.id ? { ...c, honorarOffers: updated } : c));
setHonorarModal(null);
};
const delHonorar = async (contactId, offerId) => {
if (await askConfirm("Honorarangebot löschen?")) {
update("contacts", contacts.map(c => c.id === contactId
? { ...c, honorarOffers: (c.honorarOffers || []).filter(o => o.id !== offerId) } : c));
}
};
// ── Form fields (shared new/edit) ──
const firmFormFields = (isNew) => (
<>
<div className="form-row">
<FormField label="Firmenname *">
<input value={firmForm.name} onChange={e => setFirmForm(f => ({ ...f, name: e.target.value }))} autoFocus placeholder="z.B. Elektroplaner AG" />
</FormField>
<FormField label="Typ">
<select value={firmForm.type} onChange={e => setFirmForm(f => ({ ...f, type: e.target.value }))}>
<option value=""> wählen </option>
{CONTACT_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</FormField>
</div>
<div className="form-row">
<FormField label="Strasse + Nr."><input value={firmForm.street || ""} onChange={e => setFirmForm(f => ({ ...f, street: e.target.value }))} /></FormField>
<FormField label="PLZ"><input value={firmForm.zip || ""} onChange={e => setFirmForm(f => ({ ...f, zip: e.target.value }))} style={{ maxWidth: 90 }} /></FormField>
<FormField label="Ort"><input value={firmForm.city || ""} onChange={e => setFirmForm(f => ({ ...f, city: e.target.value }))} /></FormField>
</div>
<div className="form-row">
<FormField label="E-Mail Firma"><input type="email" value={firmForm.email || ""} onChange={e => setFirmForm(f => ({ ...f, email: e.target.value }))} /></FormField>
<FormField label="Telefon Firma"><input value={firmForm.phone || ""} onChange={e => setFirmForm(f => ({ ...f, phone: e.target.value }))} /></FormField>
<FormField label="Website"><input value={firmForm.website || ""} onChange={e => setFirmForm(f => ({ ...f, website: e.target.value }))} placeholder="www.beispiel.ch" /></FormField>
</div>
<FormField label="Bemerkung"><input value={firmForm.note || ""} onChange={e => setFirmForm(f => ({ ...f, note: e.target.value }))} /></FormField>
{isNew && (
<>
<div className="section-divider" style={{ marginTop: 16, marginBottom: 10 }}>
HAUPTKONTAKT (optional)
</div>
<div className="form-row">
<FormField label="Name Ansprechpartner">
<input value={firmForm._personName || ""} onChange={e => setFirmForm(f => ({ ...f, _personName: e.target.value }))} placeholder="z.B. Max Muster" />
</FormField>
<FormField label="Funktion / Position">
<input value={firmForm._personPosition || ""} onChange={e => setFirmForm(f => ({ ...f, _personPosition: e.target.value }))} placeholder="z.B. Projektleiter" />
</FormField>
</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: -6 }}>Weitere Personen können in der Detailansicht hinzugefügt werden.</div>
</>
)}
</>
);
// ── Detail view ──
if (selectedId && selectedContact) {
const persons = selectedContact.contacts || [];
const offers = selectedContact.honorarOffers || [];
const hauptperson = persons[0] || null;
const linkedProjects = (data.projects || []).filter(p => (p.projectContacts || []).some(pc => pc.contactId === selectedId));
const addressLine = [selectedContact.street, [selectedContact.zip, selectedContact.city].filter(Boolean).join(" ")].filter(Boolean).join(", ");
return (
<div>
{ConfirmModalEl}
<button className="btn btn-ghost" onClick={() => setSelectedId(null)} style={{ marginBottom: 18, padding: "6px 14px", fontSize: 12 }}> Alle Kontakte</button>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 20 }}>
<div>
{selectedContact.type && <div style={{ fontSize: 11, color: "#888", marginBottom: 4, letterSpacing: "0.08em" }}>{selectedContact.type.toUpperCase()}</div>}
<h2 style={{ margin: 0, fontFamily: "'Playfair Display', serif", fontSize: 26 }}>{selectedContact.name}</h2>
{addressLine && <div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>{addressLine}</div>}
</div>
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => openEdit(selectedContact)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ fontSize: 12 }} onClick={() => delFirm(selectedContact.id)}>Löschen</button>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, alignItems: "start", marginBottom: 20 }}>
{/* Firmeninfo */}
<div className="card">
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888", marginBottom: 14 }}>FIRMENINFO</div>
{[
{ label: "E-Mail", value: selectedContact.email, href: selectedContact.email ? `mailto:${selectedContact.email}` : null },
{ label: "Telefon", value: selectedContact.phone },
{ label: "Website", value: selectedContact.website, href: selectedContact.website ? (selectedContact.website.startsWith("http") ? selectedContact.website : `https://${selectedContact.website}`) : null },
{ label: "Adresse", value: addressLine || null },
].filter(r => r.value).map(({ label, value, href }) => (
<div key={label} style={{ display: "flex", gap: 12, padding: "6px 0", borderBottom: "1px solid #f5f2ec" }}>
<span style={{ fontSize: 11, color: "#aaa", minWidth: 70 }}>{label}</span>
{href ? <a href={href} style={{ fontSize: 13, color: "#1a4e8a", textDecoration: "none" }}>{value}</a> : <span style={{ fontSize: 13 }}>{value}</span>}
</div>
))}
{selectedContact.note && <div style={{ marginTop: 12, fontSize: 12, color: "#555", lineHeight: 1.5 }}>{selectedContact.note}</div>}
{persons.length > 0 && hauptperson && (
<div style={{ marginTop: 14, paddingTop: 12, borderTop: "1px solid #ece8e2" }}>
<div style={{ fontSize: 11, color: "#888", marginBottom: 6 }}>HAUPTKONTAKT</div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{hauptperson.name}</div>
{hauptperson.position && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{hauptperson.position}</div>}
<div style={{ display: "flex", gap: 14, marginTop: 4 }}>
{hauptperson.email && <a href={`mailto:${hauptperson.email}`} style={{ fontSize: 12, color: "#1a4e8a", textDecoration: "none" }}>{hauptperson.email}</a>}
{hauptperson.phone && <span style={{ fontSize: 12, color: "#555" }}>{hauptperson.phone}</span>}
</div>
</div>
)}
</div>
{/* Ansprechpartner */}
<div className="card" style={{ padding: 0 }}>
<div style={{ padding: "14px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: persons.length > 0 ? "1px solid #ece8e2" : "none" }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>ANSPRECHPARTNER ({persons.length})</div>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => { setPersonForm({ name: "", position: "", email: "", phone: "" }); setPersonModal({ contactId: selectedId }); }}>+ Hinzufügen</button>
</div>
{persons.length === 0
? <div style={{ padding: "20px", fontSize: 12, color: "#aaa", textAlign: "center" }}>Noch keine Ansprechpartner erfasst.</div>
: persons.map((p, i) => (
<div key={p.id} style={{ padding: "12px 20px", borderBottom: i < persons.length - 1 ? "1px solid #f5f2ec" : "none" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>{p.name}</span>
{i === 0 && <span style={{ fontSize: 9, background: "#ece8e2", color: "#888", padding: "1px 6px", borderRadius: 3, letterSpacing: "0.08em" }}>HAUPT</span>}
</div>
{p.position && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{p.position}</div>}
<div style={{ display: "flex", gap: 14, marginTop: 4 }}>
{p.email && <a href={`mailto:${p.email}`} style={{ fontSize: 12, color: "#1a4e8a", textDecoration: "none" }}>{p.email}</a>}
{p.phone && <span style={{ fontSize: 12, color: "#555" }}>{p.phone}</span>}
</div>
</div>
<div style={{ display: "flex", gap: 4 }}>
<button className="btn btn-ghost" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => { setPersonForm({ name: p.name, position: p.position || "", email: p.email || "", phone: p.phone || "" }); setPersonModal({ contactId: selectedId, personId: p.id }); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => delPerson(selectedId, p.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
</div>
))
}
</div>
</div>
{/* Honorar-Angebote */}
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "14px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: offers.length > 0 ? "1px solid #ece8e2" : "none" }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>HONORAR-ANGEBOTE ({offers.length})</div>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => { setHonorarForm({ date: new Date().toISOString().slice(0, 10), amount: "", phase: "", description: "", note: "" }); setHonorarModal({ contactId: selectedId }); }}>+ Hinzufügen</button>
</div>
{offers.length === 0
? <div style={{ padding: "16px 20px", fontSize: 12, color: "#aaa" }}>Noch keine Honorar-Angebote erfasst.</div>
: (
<table>
<thead><tr><th style={{ width: 110 }}>Datum</th><th>Beschrieb</th><th style={{ width: 120 }}>Phase</th><th style={{ width: 140, textAlign: "right" }}>Betrag</th><th style={{ width: 70 }}></th></tr></thead>
<tbody>
{[...offers].sort((a, b) => (b.date || "").localeCompare(a.date || "")).map(o => (
<tr key={o.id}>
<td style={{ fontSize: 12, color: "#888" }}>{fmtDate(o.date)}</td>
<td>
<div style={{ fontSize: 13 }}>{o.description || <span style={{ color: "#aaa" }}></span>}</div>
{o.note && <div style={{ fontSize: 11, color: "#888" }}>{o.note}</div>}
</td>
<td style={{ fontSize: 12, color: "#888" }}>{o.phase || "—"}</td>
<td style={{ textAlign: "right", fontWeight: 600 }}>{fmtCHF(o.amount)}</td>
<td style={{ textAlign: "right" }}>
<button className="btn btn-ghost" style={{ padding: "3px 8px", fontSize: 11, marginRight: 4 }} onClick={() => { setHonorarForm({ date: o.date || "", amount: o.amount?.toString() || "", phase: o.phase || "", description: o.description || "", note: o.note || "" }); setHonorarModal({ contactId: selectedId, offerId: o.id }); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => delHonorar(selectedId, o.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
))}
</tbody>
{offers.length > 1 && (
<tfoot>
<tr>
<td colSpan={3} style={{ textAlign: "right", fontSize: 11, color: "#888", paddingRight: 8 }}>Total</td>
<td style={{ textAlign: "right", fontWeight: 700 }}>{fmtCHF(offers.reduce((s, o) => s + (parseFloat(o.amount) || 0), 0))}</td>
<td />
</tr>
</tfoot>
)}
</table>
)
}
</div>
{/* Beteiligt an */}
{linkedProjects.length > 0 && (
<div className="card" style={{ padding: 0 }}>
<div style={{ padding: "14px 20px", fontSize: 11, letterSpacing: "0.1em", color: "#888", borderBottom: "1px solid #ece8e2" }}>BETEILIGT AN ({linkedProjects.length})</div>
<table>
<thead><tr><th>Projekt</th><th style={{ width: 160 }}>Kunde</th><th style={{ width: 110 }}>Status</th></tr></thead>
<tbody>
{linkedProjects.map(proj => {
const client = (data.clients || []).find(c => c.id === proj.clientId);
return (
<tr key={proj.id}>
<td><strong>{proj.number ? <span style={{ color: "#b07848", marginRight: 8 }}>{proj.number}</span> : null}{proj.name}</strong></td>
<td style={{ fontSize: 12, color: "#888" }}>{client?.name || "—"}</td>
<td><span style={{ fontSize: 11, padding: "2px 8px", borderRadius: 3, background: "#f5f2ec", color: "#555" }}>{proj.status}</span></td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Person modal */}
{personModal && (
<Modal title={personModal.personId ? "Person bearbeiten" : "Person hinzufügen"} onClose={() => setPersonModal(null)} onSave={savePerson}>
<div className="form-row">
<FormField label="Name *"><input value={personForm.name} onChange={e => setPersonForm(f => ({ ...f, name: e.target.value }))} autoFocus /></FormField>
<FormField label="Funktion / Rolle"><input value={personForm.position} onChange={e => setPersonForm(f => ({ ...f, position: e.target.value }))} placeholder="z.B. Projektleiter" /></FormField>
</div>
<div className="form-row">
<FormField label="E-Mail"><input type="email" value={personForm.email} onChange={e => setPersonForm(f => ({ ...f, email: e.target.value }))} /></FormField>
<FormField label="Telefon"><input value={personForm.phone} onChange={e => setPersonForm(f => ({ ...f, phone: e.target.value }))} /></FormField>
</div>
</Modal>
)}
{/* Honorar modal */}
{honorarModal && (
<Modal title={honorarModal.offerId ? "Angebot bearbeiten" : "Honorar-Angebot erfassen"} onClose={() => setHonorarModal(null)} onSave={saveHonorar}>
<div className="form-row">
<FormField label="Datum"><DateInput value={honorarForm.date} onChange={e => setHonorarForm(f => ({ ...f, date: e.target.value }))} /></FormField>
<FormField label="Betrag (CHF)"><input type="number" min="0" step="100" value={honorarForm.amount} onChange={e => setHonorarForm(f => ({ ...f, amount: e.target.value }))} placeholder="0" /></FormField>
</div>
<FormField label="Beschrieb"><input value={honorarForm.description} onChange={e => setHonorarForm(f => ({ ...f, description: e.target.value }))} placeholder="z.B. Elektroplanung Rohbau" /></FormField>
<FormField label="Phase"><input value={honorarForm.phase} onChange={e => setHonorarForm(f => ({ ...f, phase: e.target.value }))} placeholder="z.B. Phase 3133" /></FormField>
<FormField label="Bemerkung"><input value={honorarForm.note} onChange={e => setHonorarForm(f => ({ ...f, note: e.target.value }))} /></FormField>
</Modal>
)}
{/* Edit modal */}
{firmModal && (
<Modal title="Kontakt bearbeiten" onClose={() => setFirmModal(null)} onSave={saveFirm} wide>
{firmFormFields(false)}
</Modal>
)}
</div>
);
}
// ── List view ──
const allTypes = [...new Set(contacts.map(c => c.type).filter(Boolean))].sort();
const filtered = contacts
.filter(c =>
(!typeFilter || c.type === typeFilter) &&
(!search || c.name.toLowerCase().includes(search.toLowerCase()) ||
(c.type || "").toLowerCase().includes(search.toLowerCase()) ||
(c.contacts || []).some(p => p.name.toLowerCase().includes(search.toLowerCase())))
)
.sort((a, b) => a.name.localeCompare(b.name, "de"));
const contactGroups = (() => {
if (groupBy === "none") return [{ key: "_all", label: null, items: filtered }];
if (groupBy === "alpha") {
const g = {};
filtered.forEach(c => { const k = c.name[0]?.toUpperCase() || "#"; (g[k] = g[k] || []).push(c); });
return Object.entries(g).sort((a, b) => a[0].localeCompare(b[0])).map(([k, items]) => ({ key: k, label: k, items }));
}
if (groupBy === "type") {
const g = {};
filtered.forEach(c => { const k = c.type || "Ohne Typ"; (g[k] = g[k] || []).push(c); });
return Object.entries(g).sort((a, b) => a[0].localeCompare(b[0])).map(([k, items]) => ({ key: k, label: k, items }));
}
})();
const ContactTable = ({ items }) => (
<div className="card" style={{ padding: 0 }}>
<table style={{ width: "100%" }}>
<thead>
<tr>
<th>Firma</th>
<th style={{ width: 140 }}>Typ</th>
<th style={{ width: 160 }}>Adresse</th>
<th>Hauptkontakt</th>
<th style={{ width: 80, textAlign: "center" }}>Personen</th>
<th style={{ width: 80, textAlign: "center" }}>Projekte</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{items.length === 0 && <tr><td colSpan={7} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>Keine Treffer</td></tr>}
{items.map(c => {
const persons = c.contacts || [];
const haupt = persons[0];
const city = [c.zip, c.city].filter(Boolean).join(" ");
const projCount = (data.projects || []).filter(p => (p.projectContacts || []).some(pc => pc.contactId === c.id)).length;
return (
<tr key={c.id} style={{ cursor: "pointer" }} onClick={() => setSelectedId(c.id)}>
<td>
<strong>{c.name}</strong>
{c.email && <div style={{ fontSize: 11, color: "#888" }}>{c.email}</div>}
</td>
<td style={{ fontSize: 12, color: "#666" }}>{c.type || <span style={{ color: "#ccc" }}></span>}</td>
<td style={{ fontSize: 12, color: "#666" }}>
{c.street && <div>{c.street}</div>}
{city && <div>{city}</div>}
</td>
<td style={{ fontSize: 12 }}>
{haupt ? (
<>
<div style={{ fontWeight: 500 }}>{haupt.name}</div>
{haupt.position && <div style={{ fontSize: 11, color: "#888" }}>{haupt.position}</div>}
</>
) : <span style={{ color: "#ccc" }}></span>}
</td>
<td style={{ textAlign: "center", fontSize: 12, color: "#888" }}>{persons.length || "—"}</td>
<td style={{ textAlign: "center", fontSize: 12, color: projCount > 0 ? "#1a4e8a" : "#ccc", fontWeight: projCount > 0 ? 600 : 400 }}>{projCount || "—"}</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }} onClick={e => e.stopPropagation()}>
<button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12 }} onClick={() => openEdit(c)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => delFirm(c.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
return (
<div>
{ConfirmModalEl}
<Header title="Kontakte" action={<button className="btn btn-primary" onClick={openNew}>+ Neuer Kontakt</button>} />
<div style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap", alignItems: "center" }}>
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Suchen…"
style={{ flex: "1 1 200px", maxWidth: 300, fontSize: 12 }} />
{allTypes.length > 0 && (
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)} style={{ fontSize: 12, minWidth: 160 }}>
<option value="">Alle Typen</option>
{allTypes.map(t => <option key={t} value={t}>{t}</option>)}
</select>
)}
<select value={groupBy} onChange={e => setGroupBy(e.target.value)} style={{ fontSize: 12, width: 160 }}>
<option value="alpha">Alphabetisch</option>
<option value="type">Nach Typ</option>
<option value="none">Keine Gruppierung</option>
</select>
</div>
{contacts.length === 0 ? (
<div className="card" style={{ padding: 40, textAlign: "center", color: "#aaa" }}>Noch keine Kontakte erfasst.</div>
) : filtered.length === 0 ? (
<div className="card" style={{ padding: 40, textAlign: "center", color: "#aaa" }}>Keine Treffer</div>
) : contactGroups.map(group => (
<div key={group.key} style={{ marginBottom: 20 }}>
{group.label && (
<div style={{ fontSize: 10, letterSpacing: "0.14em", color: "#aaa", fontWeight: 600, marginBottom: 8, paddingLeft: 2 }}>
{group.label.toUpperCase()} <span style={{ opacity: 0.55 }}>{group.items.length}</span>
</div>
)}
<ContactTable items={group.items} />
</div>
))}
{firmModal && (
<Modal title={firmModal.id ? "Kontakt bearbeiten" : "Neuer Kontakt"} onClose={() => setFirmModal(null)} onSave={saveFirm} wide>
{firmFormFields(!firmModal.id)}
</Modal>
)}
</div>
);
}