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:
+396
@@ -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 30000–31999
|
||||||
|
- `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` (5–10 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.
|
||||||
@@ -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.
|
||||||
@@ -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)
|
# 1. Versionen anheben (package.json, tauri.conf.json, Cargo.toml)
|
||||||
# 2. Changelog in src/App.jsx ergänzen
|
# 2. Changelog in src/App.jsx ergänzen
|
||||||
# 3. Commit + Tag
|
# 3. Commit + Tag
|
||||||
git tag -a v0.7.0 -m "Rapport 0.7"
|
git tag -a 0.7.0 -m "Rapport 0.7" # ohne v-Prefix — latest.json verlinkt /releases/download/<VERSION>/
|
||||||
git push origin main v0.7.0
|
git push origin main 0.7.0
|
||||||
|
|
||||||
# 4. Bundle bauen
|
# 4. Bundle bauen
|
||||||
npx tauri build
|
npx tauri build
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
# 3) schreibt latest.json im Repo-Root mit URLs auf Gitea-Release-Assets
|
# 3) schreibt latest.json im Repo-Root mit URLs auf Gitea-Release-Assets
|
||||||
#
|
#
|
||||||
# Danach manuell:
|
# 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
|
# - die .app.tar.gz und (optional) die .dmg als Assets hochladen
|
||||||
# - latest.json committen + auf main pushen
|
# - latest.json committen + auf main pushen
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 31–33" /></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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user