Initial commit — RAPPORT App (Stand Mai 2026)

This commit is contained in:
karim gabriele varano
2026-05-09 01:53:15 +02:00
commit 2cf748fa36
70 changed files with 28775 additions and 0 deletions
Executable
+27
View File
@@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Claude Code
.claude/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
Executable
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+21
View File
@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])
Executable
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="de-CH">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>rapportv01</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Generated Executable
+2703
View File
File diff suppressed because it is too large Load Diff
Executable
+29
View File
@@ -0,0 +1,29 @@
{
"name": "rapportv01",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/cli": "^2.10.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"vite": "^8.0.10"
}
}
+179
View File
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Erste Schritte — Rapport</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,400;0,500;1,400&family=Playfair+Display:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #ebe7e1;
--surface: #fdfcfa;
--text: #1a1a18;
--text2: #4a4844;
--text4: #8c8880;
--border: #ddd8d0;
--accent: #b07848;
}
html { background: var(--bg); color: var(--text); font-family: 'DM Mono', monospace; font-size: 14px; line-height: 1.6; }
body { max-width: 720px; margin: 0 auto; padding: 64px 32px 120px; }
/* Header */
.site-header { margin-bottom: 64px; padding-bottom: 28px; border-bottom: 1px solid var(--border); display: flex; align-items: baseline; justify-content: space-between; }
.logo { font-size: 22px; letter-spacing: -0.02em; color: var(--text); text-decoration: none; }
.logo span { font-family: 'Playfair Display', serif; font-style: italic; font-weight: 400; font-size: 13px; color: var(--text4); margin-left: 12px; }
/* Page title */
.page-title { font-family: 'Playfair Display', serif; font-size: 42px; font-weight: 400; letter-spacing: -0.02em; line-height: 1.1; margin-bottom: 12px; }
.page-subtitle { font-size: 13px; color: var(--text4); margin-bottom: 56px; line-height: 1.7; max-width: 500px; }
/* Steps */
.steps { display: flex; flex-direction: column; gap: 0; }
.step { display: flex; gap: 32px; padding: 32px 0; border-bottom: 1px solid var(--border); }
.step:first-child { border-top: 1px solid var(--border); }
.step-number {
font-family: 'Playfair Display', serif;
font-size: 32px;
font-weight: 400;
color: var(--border);
line-height: 1;
flex-shrink: 0;
width: 40px;
padding-top: 2px;
}
.step-content { flex: 1; }
.step-title { font-size: 15px; font-weight: 500; margin-bottom: 8px; color: var(--text); letter-spacing: 0.01em; }
.step-desc { font-size: 12px; color: var(--text2); line-height: 1.8; }
.step-fields { margin-top: 14px; display: flex; flex-direction: column; gap: 6px; }
.step-field { display: flex; align-items: baseline; gap: 12px; font-size: 11px; }
.step-field .label { color: var(--text4); letter-spacing: 0.08em; text-transform: uppercase; flex-shrink: 0; width: 160px; }
.step-field .val { color: var(--text2); }
/* Info box */
.info-box { margin-top: 56px; padding: 24px 28px; background: var(--surface); border-radius: 10px; border: 1px solid var(--border); box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.05); }
.info-box-title { font-size: 10px; letter-spacing: 0.12em; color: var(--text4); text-transform: uppercase; margin-bottom: 10px; }
.info-box p { font-size: 12px; color: var(--text2); line-height: 1.8; }
/* Concept tags */
.concepts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 48px; }
.concept { padding: 6px 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 20px; font-size: 11px; color: var(--text2); }
/* Footer */
.site-footer { margin-top: 80px; padding-top: 24px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text4); display: flex; justify-content: space-between; align-items: center; }
.site-footer a { color: var(--text4); text-decoration: none; border-bottom: 1px solid var(--border); }
.site-footer a:hover { color: var(--text2); }
@media (max-width: 600px) {
body { padding: 40px 20px 80px; }
.page-title { font-size: 32px; }
.step { gap: 20px; }
.step-number { font-size: 24px; width: 28px; }
.site-header { flex-direction: column; gap: 8px; }
}
</style>
</head>
<body>
<header class="site-header">
<a href="/" class="logo">RAPPORT <span>Studio Administration</span></a>
<span style="font-size: 10px; letter-spacing: 0.1em; color: var(--text4);">ALPHA 0.3</span>
</header>
<h1 class="page-title">Erste Schritte</h1>
<p class="page-subtitle">Rapport ist eine lokale Studio-Administrationssoftware für Architekturbüros. Alle Daten bleiben auf deinem Gerät — kein Server, kein Login.</p>
<div class="steps">
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<div class="step-title">Einstellungen konfigurieren</div>
<div class="step-desc">Bevor du beginnst, trage die Stammdaten deines Studios ein. Diese erscheinen auf allen Dokumenten und Rechnungen.</div>
<div class="step-fields">
<div class="step-field"><span class="label">Studio-Name</span><span class="val">Erscheint in der Sidebar und auf Druckdokumenten</span></div>
<div class="step-field"><span class="label">IBAN</span><span class="val">Für den QR-Einzahlungsschein auf Rechnungen (QR-IBAN empfohlen)</span></div>
<div class="step-field"><span class="label">MwSt-Satz</span><span class="val">Standardmässig 8.1 %</span></div>
<div class="step-field"><span class="label">Stundensätze</span><span class="val">Pro Rolle, werden für Honorarberechnungen verwendet</span></div>
</div>
</div>
</div>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<div class="step-title">Ersten Kunden anlegen</div>
<div class="step-desc">Unter <strong>Personen</strong> kannst du Kunden und Kontakte verwalten. Kunden werden mit Projekten und Rechnungen verknüpft. Name und Adresse sind für Dokumente erforderlich.</div>
</div>
</div>
<div class="step">
<div class="step-number">3</div>
<div class="step-content">
<div class="step-title">Mitarbeiter erfassen</div>
<div class="step-desc">Unter <strong>Mitarbeiter</strong> legst du dein Team an. Wichtig sind Pensum, Wochenstunden und Eintrittsdatum — diese fliessen in die Zeiterfassung und Lohnabrechnung ein.</div>
</div>
</div>
<div class="step">
<div class="step-number">4</div>
<div class="step-content">
<div class="step-title">Erstes Projekt erstellen</div>
<div class="step-desc">Unter <strong>Projekte</strong> erfasst du dein Bauvorhaben. Vergib eine Projektnummer, wähle den Auftraggeber und setze den Status. Optional kannst du eine Offerte verknüpfen, um das Stundenbudget abzuleiten.</div>
<div class="step-fields">
<div class="step-field"><span class="label">SIA-Phasen</span><span class="val">Aktiviere nur die für das Projekt relevanten Phasen</span></div>
<div class="step-field"><span class="label">Abrechnungsart</span><span class="val">Stundenbasis oder Pauschal</span></div>
</div>
</div>
</div>
<div class="step">
<div class="step-number">5</div>
<div class="step-content">
<div class="step-title">Zeit erfassen</div>
<div class="step-desc">Unter <strong>Zeiterfassung</strong> buchst du Stunden auf Projekte und Phasen. Die Wochenansicht erlaubt das visuelle Planen per Drag & Drop. Der Monatssaldo zeigt jederzeit Stand bis heute.</div>
</div>
</div>
<div class="step">
<div class="step-number">6</div>
<div class="step-content">
<div class="step-title">Erste Rechnung stellen</div>
<div class="step-desc">Unter <strong>Rechnungen</strong> erstellst du Rechnungen mit Positionen, MwSt und QR-Einzahlungsschein. Rechnungen lassen sich als PDF drucken und mit einem Status versehen (Entwurf → Gesendet → Bezahlt).</div>
</div>
</div>
</div>
<div class="info-box">
<div class="info-box-title">Datenspeicherung</div>
<p>Alle Daten werden ausschliesslich lokal im Browser gespeichert (<code>localStorage</code>). Es gibt keinen Server, keine Cloud, keine Synchronisation. Erstelle regelmässig ein Backup über <strong>Einstellungen → Datenbank exportieren</strong>.</p>
</div>
<div class="concepts">
<span class="concept">Lokal gespeichert</span>
<span class="concept">Kein Login erforderlich</span>
<span class="concept">SIA 102 Honorare</span>
<span class="concept">QR-Rechnung</span>
<span class="concept">Lohnabrechnung</span>
<span class="concept">PDF-Export</span>
<span class="concept">Zeiterfassung</span>
<span class="concept">AGPL-3.0</span>
</div>
<footer class="site-footer">
<span>Rapport Alpha 0.3 — <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank">AGPL-3.0</a></span>
<a href="https://rapport.gabrielevarano.ch/">rapport.gabrielevarano.ch</a>
</footer>
</body>
</html>
+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
Generated Executable
+5295
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "app"
version = "0.2.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.6", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.10.3", features = [] }
tauri-plugin-log = "2"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default",
"core:webview:allow-print"
]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+16
View File
@@ -0,0 +1,16 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "RAPPORT PRE-RELEASE",
"version": "0.5.0",
"identifier": "com.karimgabrielevarano.rapport",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:3000",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "RAPPORT PRE-RELEASE",
"width": 1400,
"height": 900,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
Executable
+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
Executable
+684
View File
@@ -0,0 +1,684 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import { STORAGE_KEY, NAV_ITEMS, defaultData } from "./constants.js";
import { migrateDashboardLayout } from "./utils.js";
import Dashboard from "./views/Dashboard.jsx";
import { Projects, ProjectDetail } from "./views/Projects.jsx";
import Time from "./views/Time.jsx";
import { Spesen, InternalExpenses } from "./views/Spesen.jsx";
import Protokolle from "./views/Protokolle.jsx";
import Lieferscheine from "./views/Lieferscheine.jsx";
import Buchhaltung from "./views/Buchhaltung.jsx";
import Invoices from "./views/Invoices.jsx";
import Quotes from "./views/Quotes.jsx";
import Personen from "./views/Personen.jsx";
import Letters from "./views/Letters.jsx";
import Settings from "./views/Settings.jsx";
import StudioBudget from "./views/StudioBudget.jsx";
import Loehne from "./views/Loehne.jsx";
import Mitarbeiter from "./views/Mitarbeiter.jsx";
import Pinnwand from "./views/Pinnwand.jsx";
import Dokumente from "./views/Dokumente.jsx";
import { PrintView } from "./print/PrintComponents.jsx";
import Login from "./views/Login.jsx";
import Setup from "./views/Setup.jsx";
import MigrationScreen from "./views/MigrationScreen.jsx";
export default function App() {
const [data, setData] = useState(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
let merged = { ...defaultData, ...parsed, settings: { ...defaultData.settings, ...parsed.settings } };
// Migrate: clients[] + contacts[] → persons[]
if (!merged.persons && (merged.clients?.length || merged.contacts?.length)) {
const idMap = {};
const persons = [];
const usedContactIds = new Set();
for (const c of merged.clients || []) {
const linked = (merged.contacts || []).find(ct => ct.id === c.linkedContactId);
persons.push({
...c,
isAuftraggeber: true,
isPartner: !!linked,
type: c.type || linked?.type || "",
note: c.note || linked?.note || "",
honorarOffers: c.honorarOffers || linked?.honorarOffers || [],
contacts: c.contacts?.length ? c.contacts : (linked?.contacts || []),
linkedContactId: undefined,
linkedClientId: undefined,
});
if (linked) { usedContactIds.add(linked.id); idMap[linked.id] = c.id; }
}
for (const ct of merged.contacts || []) {
if (usedContactIds.has(ct.id)) continue;
persons.push({ ...ct, isAuftraggeber: false, isPartner: true, linkedClientId: undefined });
}
const remapProjects = (merged.projects || []).map(p => ({
...p,
projectContacts: (p.projectContacts || []).map(pc => ({ ...pc, contactId: idMap[pc.contactId] || pc.contactId })),
}));
const remapProtocols = (merged.protocols || []).map(p => ({
...p,
entries: (p.entries || []).map(e => ({ ...e, assignee: e.assignee ? (idMap[e.assignee] || e.assignee) : e.assignee })),
}));
merged = { ...merged, persons, projects: remapProjects, protocols: remapProtocols, clients: undefined, contacts: undefined };
}
// Migrate: projects linked to SIA/manual quotes should be pauschal (not stundensatz)
const allQuotes = merged.quotes || [];
const projects = (merged.projects || []).map(p => {
if ((p.billingType || p.type || "stundensatz") === "stundensatz" && (p.linkedQuotes || []).length > 0) {
const linkedQs = (p.linkedQuotes || []).map(lq => allQuotes.find(q => q.id === lq.quoteId)).filter(Boolean);
if (linkedQs.some(q => q.mode === "sia" || q.mode === "manual")) {
return { ...p, billingType: "pauschal", budget: p.budget || p.budgetAmount || 0 };
}
}
return p;
});
// Migrate: add r-projektleiter if missing, seed dashboardTemplateId from defaultData
const roleDefMap = (defaultData.appRoles || []).reduce((acc, r) => { acc[r.id] = r; return acc; }, {});
const roles = (merged.appRoles || defaultData.appRoles).map(r => ({
...r,
dashboardTemplateId: r.dashboardTemplateId || roleDefMap[r.id]?.dashboardTemplateId || null,
permissions: (() => {
let perms = r.permissions;
if (perms && r.id === "r-projektleiter" && !perms.includes("mitarbeiter")) perms = [...perms, "mitarbeiter"];
if (perms && !perms.includes("settings")) perms = [...perms, "settings"];
return perms;
})(),
}));
if (!roles.find(r => r.id === "r-projektleiter") && roleDefMap["r-projektleiter"]) {
const adminIdx = roles.findIndex(r => r.id === "r-admin");
roles.splice(adminIdx + 1, 0, roleDefMap["r-projektleiter"]);
}
// Migrate user-level dashboardWidgets to Row[] format
const users = (merged.users || []).map(u => ({
...u,
dashboardWidgets: u.dashboardWidgets ? migrateDashboardLayout(u.dashboardWidgets) : undefined,
}));
// Ensure dashboardTemplates exist (old data won't have them)
const dashboardTemplates = merged.dashboardTemplates?.length ? merged.dashboardTemplates : defaultData.dashboardTemplates;
return { ...merged, projects, appRoles: roles, users, dashboardTemplates };
}
} catch {}
return defaultData;
});
const [isNewInstall] = useState(() => !localStorage.getItem(STORAGE_KEY));
const [currentUser, setCurrentUser] = useState(() => {
try { return JSON.parse(sessionStorage.getItem("rapport_user")) || null; } catch { return null; }
});
const handleLogin = (user) => {
sessionStorage.setItem("rapport_user", JSON.stringify(user));
setCurrentUser(user);
};
const handleLogout = () => {
sessionStorage.removeItem("rapport_user");
setCurrentUser(null);
};
const handleSetupComplete = (newData) => {
localStorage.setItem("rapport_v0_5_migrated", "1");
save(newData);
const adminUser = (newData.users || []).find(u => u.role === "admin");
if (adminUser) handleLogin(adminUser);
};
const userPermissions = (() => {
if (!currentUser || currentUser.role === "admin") return null;
const role = (data.appRoles || []).find(r => r.id === currentUser.appRoleId);
if (role) return role.permissions; // null = alle
return currentUser.permissions || null; // Fallback für alte Einträge ohne Rolle
})();
const currentUserRecord = (data.users || []).find(u => u.id === currentUser?.id);
const userInitials = (() => {
const parts = ((currentUser?.displayName || currentUser?.username) || "").trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return (parts[0] || "?")[0].toUpperCase();
})();
const visibleNavItems = userPermissions === null ? NAV_ITEMS : NAV_ITEMS.map(item => {
if (item.children) {
const ch = item.children.filter(c => userPermissions.includes(c.id));
return ch.length > 0 ? { ...item, children: ch } : null;
}
return userPermissions.includes(item.id) ? item : null;
}).filter(Boolean);
const allAccessibleViews = visibleNavItems.flatMap(item => item.children ? item.children.map(c => c.id) : [item.id]);
const [view, setView] = useState(() => {
if (!userPermissions) return "dashboard";
return userPermissions.includes("dashboard") ? "dashboard" : (userPermissions[0] || "dashboard");
});
const navHistRef = useRef([view]);
const navPosRef = useRef(0);
const [navCanBack, setNavCanBack] = useState(false);
const [navCanForward, setNavCanForward] = useState(false);
const navigate = (newView) => {
const pos = navPosRef.current;
const hist = navHistRef.current;
if (hist[pos] === newView) return;
const trimmed = [...hist.slice(0, pos + 1), newView];
navHistRef.current = trimmed;
navPosRef.current = trimmed.length - 1;
setView(newView);
setNavCanBack(true);
setNavCanForward(false);
};
const goBack = () => {
const pos = navPosRef.current;
if (pos <= 0) return;
navPosRef.current = pos - 1;
setView(navHistRef.current[pos - 1]);
setNavCanBack(pos - 1 > 0);
setNavCanForward(true);
};
const goForward = () => {
const pos = navPosRef.current;
const hist = navHistRef.current;
if (pos >= hist.length - 1) return;
navPosRef.current = pos + 1;
setView(hist[pos + 1]);
setNavCanBack(true);
setNavCanForward(pos + 1 < hist.length - 1);
};
const [selectedProjectId, setSelectedProjectId] = useState(null);
const [modal, setModal] = useState(null);
const [printContent, setPrintContent] = useState(null);
const [darkMode, setDarkMode] = useState(() => localStorage.getItem("rapport_dark") === "1");
const [showChangelog, setShowChangelog] = useState(() => localStorage.getItem("rapport_changelog_seen") !== "0.5");
const [changelogVersion, setChangelogVersion] = useState("0.5");
const [navOpen, setNavOpen] = useState(false);
const [expandedNav, setExpandedNav] = useState(new Set(["buchhaltung"]));
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("rapport_sidebar_collapsed") === "1");
const [isMobile, setIsMobile] = useState(() => window.matchMedia("(max-width: 768px)").matches);
useEffect(() => {
const mq = window.matchMedia("(max-width: 768px)");
const handler = (e) => setIsMobile(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const collapsed = sidebarCollapsed && !isMobile;
const [uiZoom, setUiZoom] = useState(() => parseFloat(localStorage.getItem("rapport_zoom") || "1"));
// Persist dark mode
useEffect(() => { localStorage.setItem("rapport_dark", darkMode ? "1" : "0"); }, [darkMode]);
useEffect(() => { localStorage.setItem("rapport_sidebar_collapsed", sidebarCollapsed ? "1" : "0"); }, [sidebarCollapsed]);
// UI-Zoom: nur main-content, Sidebar bleibt unberührt
useEffect(() => {
localStorage.setItem("rapport_zoom", String(uiZoom));
// Tauri: native WebView-Zoom entfernen falls gesetzt (Sidebar-Problem)
if (window.__TAURI_INTERNALS__) {
import("@tauri-apps/api/webviewWindow")
.then(({ getCurrentWebviewWindow }) => getCurrentWebviewWindow().setZoom(1))
.catch(() => {});
}
}, [uiZoom]);
const zoomStep = 0.05;
const zoomIn = () => setUiZoom(z => Math.min(1.5, Math.round((z + zoomStep) * 100) / 100));
const zoomOut = () => setUiZoom(z => Math.max(0.5, Math.round((z - zoomStep) * 100) / 100));
// Navigation zu Protokoll von Projekt aus
useEffect(() => {
const handler = (e) => {
navigate("protokolle");
window.__openProtokoll = e.detail?.id || null;
};
window.addEventListener("openProtokoll", handler);
return () => window.removeEventListener("openProtokoll", handler);
}, []);
// Auto-expand parent when navigating to a child
useEffect(() => {
NAV_ITEMS.forEach(item => {
if (item.children?.some(c => c.id === view)) {
setExpandedNav(prev => { const next = new Set(prev); next.add(item.id); return next; });
}
});
}, [view]);
const save = useCallback((newData) => {
setData(newData);
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(newData)); } catch {}
}, []);
const update = useCallback((key, value) => {
save({ ...data, [key]: value });
}, [data, save]);
// Auto-überfällig
useEffect(() => {
const today = new Date().toISOString().slice(0, 10);
const updated = data.invoices.map(inv =>
inv.status === "gesendet" && inv.dueDate && inv.dueDate < today
? { ...inv, status: "überfällig" } : inv
);
if (updated.some((inv, i) => inv.status !== data.invoices[i].status))
save({ ...data, invoices: updated });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (isNewInstall && !data.settings.setupCompleted) {
return <Setup onComplete={handleSetupComplete} />;
}
if (!localStorage.getItem("rapport_v0_5_migrated")) {
return <MigrationScreen data={data} onComplete={handleSetupComplete} />;
}
if (!currentUser) {
return <Login users={data.users || []} settings={data.settings} onLogin={handleLogin} version="0.5" />;
}
if (printContent) {
return <PrintView content={printContent} onClose={() => setPrintContent(null)} settings={data.settings} />;
}
return (
<div className="app-wrapper" data-theme={darkMode ? "dark" : "light"} style={{ display: "flex", height: "100%", overflow: "hidden", background: "var(--bg)", fontFamily: "'DM Mono', 'Courier New', monospace", color: "var(--text)" }}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&family=DM+Mono:wght@300;400;500&family=Playfair+Display:wght@400;700&family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap');
.msr { font-family: 'Material Symbols Rounded'; font-weight: 300; font-style: normal; font-size: 20px; line-height: 1; letter-spacing: normal; text-transform: none; display: inline-block; white-space: nowrap; direction: ltr; font-feature-settings: 'liga'; -webkit-font-smoothing: antialiased; user-select: none; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24; }
:root, [data-theme=light] {
--bg: #ebe7e1;
--bg2: #e3dfd9;
--surface: #fdfcfa;
--surface2: #f7f4f0;
--surface3: #f0ece4;
--border: #ddd8d0;
--border2: #e6e1da;
--border3: #d8d2ca;
--text: #1a1a18;
--text2: #4a4844;
--text3: #6a6660;
--text4: #8c8880;
--text5: #b0aca4;
--input-bg: #fdfcfa;
--input-border: #c4bbb0;
--scrollbar-track: #e3dfd9;
--scrollbar-thumb: #b4aca0;
--accent: #b07848;
color-scheme: light;
}
[data-theme=dark] {
--bg: #161614;
--bg2: #1e1e1a;
--surface: #222220;
--surface2: #292924;
--surface3: #2e2e28;
--border: #38382e;
--border2: #2e2e28;
--border3: #333328;
--text: #e8e5df;
--text2: #b0aca4;
--text3: #9a968e;
--text4: #7a7670;
--text5: #565450;
--input-bg: #1e1e1a;
--input-border: #3a3a30;
--scrollbar-track: #1e1e1a;
--scrollbar-thumb: #3a3a30;
--accent: #e8e5df;
color-scheme: dark;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { width: 100%; height: 100%; margin: 0; padding: 0; background: #1a1a18; }
body, #root { width: 100%; height: 100%; margin: 0; padding: 0; background: var(--bg); color: var(--text); }
#root h1, #root h2, #root h3 { color: var(--text); -webkit-text-fill-color: var(--text); }
#root > div { width: 100% !important; max-width: 100% !important; }
#root, #root div, #root h1, #root h2, #root h3, #root p, #root label, #root span, #root nav, #root section, #root article, #root main, #root aside, #root header, #root footer { text-align: left; }
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: var(--scrollbar-track); }
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
input, select, textarea, button { font-family: inherit; font-size: 13px; line-height: 1.4; margin: 0; -webkit-appearance: none; -moz-appearance: none; appearance: none; box-sizing: border-box; }
input, select, textarea { background: var(--input-bg); border: 1.5px solid var(--input-border); border-radius: 20px; padding: 8px 14px; color: var(--text); outline: none; transition: border-color 0.2s, box-shadow 0.2s; width: 100%; height: 36px; }
textarea { height: auto; min-height: 38px; line-height: 1.5; border-radius: 16px; padding: 10px 14px; }
select { padding-right: 28px; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M1 1l4 4 4-4' stroke='%23999' stroke-width='1.5' fill='none' stroke-linecap='round'/></svg>"); background-repeat: no-repeat; background-position: right 10px center; cursor: pointer; }
input[type="checkbox"], input[type="radio"] { -webkit-appearance: auto; -moz-appearance: auto; appearance: auto; width: auto; height: auto; }
input[type="date"] { min-height: 36px; }
input:focus, select:focus, textarea:focus { border-color: #9a7858; box-shadow: 0 0 0 3px rgba(154,120,88,0.14); }
button { cursor: pointer; border: none; line-height: 1.4; }
.btn { padding: 0 20px; height: 36px; border-radius: 20px; font-weight: 600; font-size: 12px; letter-spacing: 0.02em; transition: all 0.18s; display: inline-flex; align-items: center; justify-content: center; white-space: nowrap; gap: 6px; }
.btn-primary { background: #252520; color: #f0ede8; box-shadow: 0 1px 3px rgba(0,0,0,0.18), 0 1px 2px rgba(0,0,0,0.12); }
.btn-primary:hover { background: #363630; box-shadow: 0 2px 8px rgba(0,0,0,0.28); }
.btn-danger { background: #8a1a1a; color: #fff; border-radius: 20px; }
.btn-danger:hover { background: #a02020; box-shadow: 0 2px 8px rgba(138,26,26,0.25); }
.btn-ghost { background: var(--surface); color: var(--text2); border: 1.5px solid var(--border3); border-radius: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06); }
.btn-ghost:hover { border-color: var(--text2); color: var(--text2); background: var(--surface2); box-shadow: 0 2px 6px rgba(0,0,0,0.12); }
.filter-bar { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; align-items: center; }
.filter-label { font-size: 11px; color: var(--text4); letter-spacing: 0.06em; white-space: nowrap; font-weight: 500; }
.pill { border-radius: 20px !important; border: 1.5px solid var(--border3); font-size: 12px; height: 32px; white-space: nowrap; transition: all 0.15s; box-sizing: border-box; }
button.pill { background: var(--surface); color: var(--text3); cursor: pointer; font-family: inherit; padding: 0 14px; display: inline-flex; align-items: center; box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06); }
button.pill:hover { border-color: var(--text2); color: var(--text2); background: var(--surface2); box-shadow: 0 2px 6px rgba(0,0,0,0.12); }
button.pill.active { background: var(--text); color: var(--bg); border-color: var(--text); }
input.pill, select.pill { width: auto; min-width: 100px; padding: 0 14px; }
select.pill { padding-right: 28px; }
.btn-sm { height: 28px !important; padding: 0 10px !important; font-size: 12px !important; }
.section-label { font-size: 11px; letter-spacing: 0.1em; color: var(--text4); font-weight: 500; text-transform: uppercase; margin-bottom: 12px; display: block; }
.panel-label { padding: 12px 16px; border-bottom: 1px solid var(--border2); font-size: 11px; letter-spacing: 0.1em; color: var(--text4); font-weight: 500; text-transform: uppercase; }
.section-divider { margin: 14px 0 10px; padding-top: 12px; border-top: 1px solid var(--border2); font-size: 11px; letter-spacing: 0.08em; color: var(--text4); text-transform: uppercase; display: block; }
.empty-state { text-align: center; color: var(--text4); padding: 32px !important; font-size: 13px; }
.card { background: var(--surface); border-radius: 16px; border: 1px solid var(--border2); padding: 22px 24px; overflow-x: auto; box-shadow: 0 1px 2px rgba(0,0,0,0.07), 0 4px 20px rgba(0,0,0,0.06); }
.tag { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 10px; font-weight: 600; color: #fff; letter-spacing: 0.06em; text-transform: uppercase; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font-size: 10px; letter-spacing: 0.1em; color: var(--text4); font-weight: 500; padding: 10px 16px; border-bottom: 1px solid var(--border); text-transform: uppercase; }
td { padding: 12px 16px; font-size: 13px; border-bottom: 1px solid var(--border2); color: var(--text); }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--surface2); transition: background 0.12s; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 20px; backdrop-filter: blur(4px); }
.modal { background: var(--surface); border-radius: 24px; padding: 32px 36px; width: 100%; max-width: 560px; max-height: 90vh; overflow-y: auto; border: 1px solid var(--border2); box-shadow: 0 8px 48px rgba(0,0,0,0.16), 0 2px 8px rgba(0,0,0,0.08); }
.form-row { display: flex; gap: 14px; margin-bottom: 16px; }
.form-group { display: flex; flex-direction: column; gap: 6px; flex: 1; }
label { font-size: 10px; letter-spacing: 0.09em; color: var(--text4); font-weight: 500; text-transform: uppercase; }
@media print { body { background: white !important; } .no-print { display: none !important; } }
.mobile-header { display: none; background: #1a1a18; padding: 14px 18px; position: sticky; top: 0; z-index: 150; align-items: center; gap: 12px; }
.sidebar-logo-btn { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.sidebar-logo-btn:hover .sidebar-logo-text { opacity: 0.6; }
.sidebar-logo-text { transition: opacity 0.15s; }
.nav-btn { transition: all 0.15s; }
.nav-btn:hover { background: rgba(240,237,232,0.06) !important; }
.nav-pill { margin: 2px 8px; border-radius: 28px !important; width: calc(100% - 16px) !important; }
.nav-pill-active { background: #2e2e28 !important; }
.nav-pill:hover { background: rgba(240,237,232,0.08) !important; }
.mobile-col-toggle { display: none; }
@media (max-width: 768px) {
.sidebar { display: none !important; }
.sidebar.open { display: flex !important; position: fixed; inset: 0; z-index: 200; width: 100% !important; height: 100vh; overflow-y: auto; }
.sidebar-logo-btn { pointer-events: none !important; }
.main-content { padding: 16px !important; }
.mobile-header { display: flex !important; }
.mobile-close { display: block !important; }
.mobile-col-toggle { display: inline-flex !important; }
.app-wrapper { flex-direction: column !important; }
.form-row { flex-direction: column !important; }
.card { padding: 14px !important; }
.modal { padding: 18px !important; margin: 10px; }
.responsive-grid-2 { grid-template-columns: 1fr !important; }
.responsive-grid-4 { grid-template-columns: 1fr 1fr !important; }
.dashboard-grid > * { grid-column: span 12 !important; }
h1 { font-size: 26px !important; }
table { min-width: 500px; }
.hide-mobile { display: none !important; }
.hide-compact { display: none !important; }
}
`}</style>
{/* Mobile Header */}
<div className="mobile-header">
<button onClick={() => setNavOpen(o => !o)} style={{ background: "none", border: "none", color: "#aaa", fontSize: 22, padding: 0, lineHeight: 1 }}></button>
<div style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 24, color: "#f0ede8", letterSpacing: "-0.02em", lineHeight: 1 }}>RAPPORT</div>
</div>
{/* Sidebar */}
<div className={`sidebar${navOpen ? " open" : ""}`} style={{ width: collapsed ? 56 : 210, background: "#1a1a18", display: "flex", flexDirection: "column", padding: "33px 0", alignSelf: "stretch", margin: "15px 15px 15px 0", flexShrink: 0, overflowY: "auto", borderRadius: "0 16px 16px 0", border: "1px solid #3a3a34", boxShadow: "6px 0 20px rgba(0,0,0,0.18), 0 6px 16px rgba(0,0,0,0.12), 0 -6px 16px rgba(0,0,0,0.12)", clipPath: "inset(-14px -100px -14px 0)", transition: "width 0.2s ease", overflow: "hidden" }}>
<div style={{ padding: collapsed ? "0 0 28px" : "0 22px 28px", borderBottom: "1px solid #2d2d28", display: "flex", alignItems: "center", justifyContent: collapsed ? "center" : "space-between", transition: "padding 0.2s" }}>
{collapsed ? (
<button className="sidebar-logo-btn" onClick={() => setSidebarCollapsed(false)} title="Sidebar ausklappen">
<div className="sidebar-logo-text" style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 22, color: "#f0ede8", lineHeight: 1, letterSpacing: "-0.02em" }}>R</div>
</button>
) : (
<>
<button className="sidebar-logo-btn" onClick={() => setSidebarCollapsed(true)} title="Sidebar einklappen">
<div className="sidebar-logo-text">
<div style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 28, color: "#f0ede8", lineHeight: 0.95, letterSpacing: "-0.02em" }}>RAPPORT</div>
<div style={{ fontSize: 9, color: "#4a4840", marginTop: 8, letterSpacing: "0.15em", fontWeight: 500 }}>{(data.settings.name || "STUDIO ADMINISTRATION").toUpperCase()}</div>
</div>
</button>
<button onClick={() => setNavOpen(false)} style={{ background: "none", border: "none", color: "#555", fontSize: 20, padding: 0, lineHeight: 1, display: "none" }} className="mobile-close"><span className="material-icons" style={{fontSize:16,verticalAlign:"middle"}}>close</span></button>
</>
)}
</div>
<nav style={{ flex: 1, padding: "18px 0" }}>
{visibleNavItems.map(item => {
const isParentActive = view === item.id || (item.children || []).some(c => c.id === view);
const isExpanded = expandedNav.has(item.id);
const toggleExpand = (e) => {
e.stopPropagation();
setExpandedNav(prev => {
const next = new Set(prev);
next.has(item.id) ? next.delete(item.id) : next.add(item.id);
return next;
});
};
if (item.children) {
if (collapsed) {
return (
<button key={item.id} title={item.label} onClick={() => { navigate(item.id); setNavOpen(false); }} style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: "100%", padding: "11px 0",
background: isParentActive ? "#252520" : "transparent",
border: "none", cursor: "pointer",
color: isParentActive ? "#e8e5df" : "#555", transition: "all 0.15s",
}}><span className="msr" style={{ fontSize: 22 }}>{item.icon}</span></button>
);
}
return (
<div key={item.id} style={{ padding: "2px 8px" }}>
<div style={{ display: "flex", alignItems: "center", borderRadius: 28, background: isParentActive ? "#2e2e28" : "transparent", transition: "background 0.15s" }}>
<button className="nav-btn" onClick={() => { navigate(item.id); setNavOpen(false); setExpandedNav(prev => { const next = new Set(prev); next.add(item.id); return next; }); }} style={{
flex: 1, padding: "10px 0 10px 14px",
background: "transparent", border: "none",
color: isParentActive ? "#e8e5df" : "#aaa",
display: "flex", alignItems: "center", gap: 10,
fontSize: 11, textAlign: "left", fontFamily: "inherit", cursor: "pointer",
letterSpacing: "0.08em", textTransform: "uppercase", fontWeight: 500, borderRadius: "28px 0 0 28px",
}}>
<span className="msr" style={{ fontSize: 18, flexShrink: 0, opacity: isParentActive ? 1 : 0.6 }}>{item.icon}</span>
{item.label}
</button>
<button className="nav-btn" onClick={toggleExpand} style={{
flexShrink: 0, width: 36, alignSelf: "stretch",
display: "flex", alignItems: "center", justifyContent: "center",
background: "transparent", border: "none", cursor: "pointer",
color: isExpanded ? "#bbb" : "#555", fontSize: 8,
fontFamily: "inherit", padding: 0, borderRadius: "0 28px 28px 0",
}}>
<span style={{ display: "inline-block", transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}></span>
</button>
</div>
{isExpanded && (
<div style={{ paddingTop: 2, paddingBottom: 4 }}>
{item.children.map(child => (
<button key={child.id} className="nav-btn" onClick={() => { navigate(child.id); setNavOpen(false); }} style={{
display: "block", width: "100%", padding: "7px 14px 7px 42px",
background: view === child.id ? "#222220" : "transparent",
color: view === child.id ? "#d8d5cf" : "#777",
fontSize: 10, textAlign: "left", fontFamily: "inherit", cursor: "pointer",
border: "none", letterSpacing: "0.07em", textTransform: "uppercase", fontWeight: 500,
borderRadius: 20,
}}>{child.label}</button>
))}
</div>
)}
</div>
);
}
if (collapsed) {
return (
<button key={item.id} title={item.label} className="nav-btn" onClick={() => { navigate(item.id); setSelectedProjectId(null); setNavOpen(false); }} style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: "100%", padding: "11px 0",
background: view === item.id ? "#2e2e28" : "transparent",
border: "none", cursor: "pointer",
color: view === item.id ? "#e8e5df" : "#555",
}}><span className="msr" style={{ fontSize: 22 }}>{item.icon}</span></button>
);
}
return (
<button key={item.id} className="nav-btn nav-pill" onClick={() => { navigate(item.id); setSelectedProjectId(null); setNavOpen(false); }} style={{
display: "flex", alignItems: "center", gap: 10,
padding: "10px 14px",
background: view === item.id ? "#2e2e28" : "transparent",
color: view === item.id ? "#e8e5df" : "#aaa",
border: "none", fontFamily: "inherit", cursor: "pointer",
letterSpacing: "0.08em", textTransform: "uppercase", fontWeight: 500, fontSize: 11,
}}>
<span className="msr" style={{ fontSize: 18, flexShrink: 0, opacity: view === item.id ? 1 : 0.6 }}>{item.icon}</span>
{item.label}
</button>
);
})}
</nav>
{/* ── Vor / Zurück ── */}
<div style={{ borderTop: "1px solid #2d2d28", display: "flex", gap: 2, padding: collapsed ? "8px 0" : "6px 10px", justifyContent: collapsed ? "center" : "flex-start" }}>
{[["goBack", "", goBack, navCanBack, "Zurück"], ["goForward", "", goForward, navCanForward, "Vorwärts"]].map(([key, ch, fn, enabled, title]) => (
<button key={key} onClick={enabled ? fn : undefined} title={title} style={{ background: "none", border: "none", color: enabled ? "#888" : "#333", cursor: enabled ? "pointer" : "default", fontSize: 20, lineHeight: 1, padding: "4px 9px", fontFamily: "inherit", borderRadius: 6, transition: "color 0.15s" }}
onMouseEnter={e => { if (enabled) e.currentTarget.style.color = "#ccc"; }}
onMouseLeave={e => { e.currentTarget.style.color = enabled ? "#888" : "#333"; }}>
{ch}
</button>
))}
</div>
{!collapsed && <div style={{ padding: "8px 16px", borderTop: "1px solid #2d2d28", fontSize: 10, color: "#aaa", letterSpacing: "0.08em" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<button onClick={() => { setChangelogVersion("0.5"); setShowChangelog(true); }} style={{ background: "none", border: "none", padding: 0, color: "#aaa", fontSize: 10, letterSpacing: "0.08em", cursor: "pointer", fontFamily: "inherit", textAlign: "left" }}>ALPHA 0.5 </button>
<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noreferrer" style={{ color: "#555", fontSize: 10, letterSpacing: "0.08em", textDecoration: "none" }}>AGPL-3.0 </a>
<a href="https://rapport.gabrielevarano.ch/" target="_blank" rel="noreferrer" style={{ color: "#555", fontSize: 10, letterSpacing: "0.08em", textDecoration: "none" }}>WEBSITE </a>
</div>
</div>}
{/* Benutzer + Logout + Theme */}
<div style={{ padding: collapsed ? "10px 0" : "10px 16px", borderTop: "1px solid #2d2d28", display: "flex", alignItems: "center", justifyContent: collapsed ? "center" : "space-between", gap: 8 }}>
{!collapsed && (
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<div style={{ width: 28, height: 28, borderRadius: "50%", background: "#2e2e28", border: "1.5px solid #3a3a30", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 600, color: "#9a9690", flexShrink: 0, overflow: "hidden", letterSpacing: "0.02em" }}>
{currentUserRecord?.avatar
? <img src={currentUserRecord.avatar} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: userInitials}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 10, color: "#aaa", fontWeight: 500, letterSpacing: "0.04em", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{currentUser.displayName || currentUser.username}
</div>
{currentUser.role === "admin" && <div style={{ fontSize: 8, color: "#555", letterSpacing: "0.1em" }}>ADMIN</div>}
</div>
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: 2, flexShrink: 0 }}>
<button onClick={() => setDarkMode(d => !d)} title={darkMode ? "Light Mode" : "Dark Mode"} style={{ background: "none", border: "none", color: "#555", cursor: "pointer", padding: 4, display: "flex", alignItems: "center", justifyContent: "center", borderRadius: 6, transition: "color 0.15s" }}
onMouseEnter={e => e.currentTarget.style.color = "#ccc"}
onMouseLeave={e => e.currentTarget.style.color = "#555"}>
<span className="msr" style={{ fontSize: 18 }}>{darkMode ? "light_mode" : "dark_mode"}</span>
</button>
<button onClick={handleLogout} title="Abmelden" style={{ background: "none", border: "none", color: "#555", cursor: "pointer", padding: 4, display: "flex", alignItems: "center", justifyContent: "center", borderRadius: 6, transition: "color 0.15s", flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.color = "#b5621e"}
onMouseLeave={e => e.currentTarget.style.color = "#555"}>
<span className="msr" style={{ fontSize: 18 }}>logout</span>
</button>
</div>
</div>
</div>
{/* Main */}
<div className="main-content" style={{ flex: 1, padding: "28px 24px", overflowY: "auto", minWidth: 0, background: "var(--bg)", zoom: uiZoom !== 1 ? uiZoom : undefined }}>
{view === "dashboard" && <Dashboard data={data} setView={navigate} currentUser={currentUser} saveAll={save} />}
{view === "pinnwand" && <Pinnwand data={data} update={update} currentUser={currentUser} />}
{view === "projects" && !selectedProjectId && <Projects data={data} update={update} saveAll={save} modal={modal} setModal={setModal} onSelect={setSelectedProjectId} setPrintContent={setPrintContent} currentUser={currentUser} />}
{view === "projects" && selectedProjectId && <ProjectDetail data={data} update={update} saveAll={save} projectId={selectedProjectId} onBack={() => setSelectedProjectId(null)} setPrintContent={setPrintContent} modal={modal} setModal={setModal} currentUser={currentUser} />}
{view === "time" && <Time data={data} update={update} currentUser={currentUser} setPrintContent={setPrintContent} />}
{view === "quotes" && <Quotes data={data} update={update} setData={setData} saveAll={save} modal={modal} setModal={setModal} setPrintContent={setPrintContent} setView={navigate} onSelectProject={setSelectedProjectId} />}
{view === "dokumente" && <Dokumente data={data} setView={navigate} />}
{view === "lieferscheine" && <Lieferscheine data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />}
{view === "protokolle" && <Protokolle data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />}
{view === "buchhaltung" && <Buchhaltung data={data} update={update} setView={navigate} setPrintContent={setPrintContent} />}
{view === "invoices" && <Invoices data={data} update={update} saveAll={save} modal={modal} setModal={setModal} setPrintContent={setPrintContent} setView={navigate} />}
{view === "loehne" && <Loehne data={data} update={update} saveAll={save} setPrintContent={setPrintContent} setView={navigate} />}
{view === "expenses" && <Spesen data={data} update={update} saveAll={save} modal={modal} setModal={setModal} standalone setView={navigate} />}
{view === "internal-expenses" && <InternalExpenses data={data} update={update} setView={navigate} />}
{view === "personen" && <Personen data={data} update={update} saveAll={save} setView={navigate} />}
{view === "mitarbeiter" && <Mitarbeiter data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />}
{view === "letters" && <Letters data={data} update={update} setPrintContent={setPrintContent} />}
{view === "settings" && <Settings data={data} update={update} currentUser={currentUser} uiZoom={uiZoom} setUiZoom={setUiZoom} />}
{view === "studio-budget" && <StudioBudget data={data} update={update} setView={navigate} setPrintContent={setPrintContent} />}
</div>
{showChangelog && (() => {
const CHANGELOGS = {
"0.5": {
items: [
["Anmeldesystem", "Benutzerverwaltung mit Rollen und Passwörtern. Jeder Mitarbeiter erhält einen eigenen Login. Berechtigungen steuern, welche Module sichtbar sind."],
["Migration bestehender Daten", "Beim ersten Start mit einer bestehenden Datenbank erscheint ein Migrations-Assistent: Daten sichern, Admin-Konto einrichten — alle bisherigen Inhalte bleiben erhalten."],
["Rechnungstypen: Akonto / Teilrechnung / Schluss", "Klare Trennung der Rechnungsarten mit unterschiedlicher steuerlicher Behandlung. Akonto ist erst bei der Schlussrechnung steuerrelevant; Teilrechnungen sind sofort wirksam."],
["Neuer Rechnungsdialog", "Zweistufige Auswahl: zuerst Art der Rechnung (Akonto / Teilrechnung / Schlussrechnung), dann Berechnungsmethode (Stunden / % vom Budget / Fixer Betrag / SIA-Phase)."],
["Akonto & Teilrechnung nach SIA-Phase", "Für Pauschal-Projekte können einzelne SIA-Phasen direkt verrechnet werden — bereits verrechnete Phasen werden als solche markiert."],
["Aufwandsrechnungen erweitert", "Stundenprojekte unterstützen jetzt Akonto (Stunden bis heute, %, Fixbetrag) und Teilrechnung (Stunden auswählen, %, Fixbetrag, SIA-Phase)."],
["Buchhaltung: Akonto-MwSt getrennt", "Akonto-Rechnungen werden in der Buchhaltung separat ausgewiesen — die MwSt wird erst bei der Schlussrechnung als steuerrelevant gezählt."],
["Mitarbeiter: Intern & Absenzen", "Umbenennung zu «Intern / Absenzen». Neue Jahresübersicht mit Monatsvergleich und Vorjahr, Absenzkategorien-Matrix, sowie Auswertung interner Stunden ohne Projektbezug."],
],
},
"0.4": {
items: [
["Material Design 3", "Sidebar mit einklappbarem Icon-Modus und Material Symbols Rounded Icons. Buttons als Pill, Inputs und Cards mit mehr Radius, Tags als Chips, Modals mit Backdrop-Blur."],
["Interne Projektbeteiligung", "Mitarbeitende können direkt im Projekt zugewiesen werden. In Protokollen erscheinen unter «Intern» nur noch die zugewiesenen Personen."],
["Offerten im Budget", "Neben Rechnungen können jetzt auch Offerten in die Einnahmen-Planung des Bürobudgets einbezogen werden."],
["Kategorien direkt auf der Seite", "Spesenarten und Ausgaben-Kategorien werden neu direkt auf der jeweiligen Seite verwaltet, nicht mehr in den Einstellungen."],
],
},
"0.3": {
items: [
["Visuelles Redesign", "Sidebar schwebt als eigenständiges Panel mit Radius und Schatten. Cards haben mehr Tiefe. Header kompakter, Abstände überarbeitet, Menüpunkte in Kapitälchen. Studio-Name in der Sidebar."],
["Zoom", "Der UI-Zoom betrifft nur den Hauptinhalt — die Sidebar bleibt immer gleich gross und unverzerrt."],
],
},
"0.2": {
items: [
["Neue Module", "Lohnabrechnung, Bürobudget, Protokolle, Lieferscheine und Spesen (Mitarbeiter & intern) hinzugefügt."],
["Zeiterfassung Wochenansicht", "Visuelles Zeitraster mit Drag & Drop — Einträge verschieben und skalieren, Wechsel zwischen Tag-, Wochen- und Monatsansicht."],
["Ferienplanung", "Ferienanträge stellen, genehmigen und zurückziehen. Absenzverwaltung ausgebaut."],
["Teilzeit", "Lohn wird proportional zum Pensum berechnet, pro Monat überschreibbar."],
],
},
"0.1": {
items: [
["Erster Release", "Projekte, Kunden, Mitarbeiterverwaltung, Zeiterfassung."],
["Rechnungen & Offerten", "Rechnungen mit QR-Einzahlungsschein, Offerten nach SIA 102, manuell oder frei — konvertierbar zur Rechnung."],
["Einstellungen", "Studio-Stammdaten, Stundensätze, Rollen, MwSt und Projektnummern-Format."],
],
},
};
const versions = Object.keys(CHANGELOGS);
const current = CHANGELOGS[changelogVersion] || CHANGELOGS["0.5"];
return (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
<div style={{ background: "#fff", borderRadius: 10, width: "100%", maxWidth: 480, boxShadow: "0 8px 40px rgba(0,0,0,0.18)", overflow: "hidden" }}>
<div style={{ background: "#1a1a18", padding: "28px 32px 20px" }}>
<div style={{ fontSize: 10, letterSpacing: "0.18em", color: "#b07848", marginBottom: 8, fontWeight: 600 }}>CHANGELOG</div>
<div style={{ display: "flex", alignItems: "baseline", gap: 16 }}>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 28, color: "#f0ede8", fontWeight: 400, lineHeight: 1.1 }}>Alpha {changelogVersion}</div>
</div>
<div style={{ display: "flex", gap: 6, marginTop: 14 }}>
{versions.map(v => (
<button key={v} onClick={() => setChangelogVersion(v)} style={{ fontSize: 10, padding: "3px 10px", borderRadius: 20, border: "1px solid", borderColor: v === changelogVersion ? "#b07848" : "#3a3a30", background: v === changelogVersion ? "#b07848" : "transparent", color: v === changelogVersion ? "#1a1a18" : "#888", cursor: "pointer", fontFamily: "inherit", letterSpacing: "0.06em" }}>
Alpha {v}
</button>
))}
</div>
</div>
<div style={{ padding: "20px 32px 8px", maxHeight: 340, overflowY: "auto" }}>
{current.items.map(([title, desc]) => (
<div key={title} style={{ display: "flex", gap: 14, marginBottom: 14 }}>
<div style={{ width: 4, flexShrink: 0, background: "#b07848", borderRadius: 2, marginTop: 2 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#1a1a18", marginBottom: 2 }}>{title}</div>
<div style={{ fontSize: 12, color: "#888", lineHeight: 1.5 }}>{desc}</div>
</div>
</div>
))}
</div>
<div style={{ padding: "12px 32px 24px" }}>
<button className="btn btn-primary" style={{ width: "100%", fontSize: 13 }} onClick={() => { setShowChangelog(false); localStorage.setItem("rapport_changelog_seen", "0.5"); }}>
Schliessen
</button>
</div>
</div>
</div>
);
})()}
</div>
);
}
+451
View File
@@ -0,0 +1,451 @@
import React, { useState, useEffect, useCallback } from "react";
import { STORAGE_KEY, NAV_ITEMS, defaultData } from "./constants.js";
import Dashboard from "./views/Dashboard.jsx";
import { Projects, ProjectDetail } from "./views/Projects.jsx";
import Time from "./views/Time.jsx";
import { Spesen, InternalExpenses } from "./views/Spesen.jsx";
import Protokolle from "./views/Protokolle.jsx";
import Lieferscheine from "./views/Lieferscheine.jsx";
import Buchhaltung from "./views/Buchhaltung.jsx";
import Invoices from "./views/Invoices.jsx";
import Quotes, { ConvertQuoteModal } from "./views/Quotes.jsx";
import Personen from "./views/Personen.jsx";
import Letters from "./views/Letters.jsx";
import Settings from "./views/Settings.jsx";
import StudioBudget from "./views/StudioBudget.jsx";
import Loehne from "./views/Loehne.jsx";
import Mitarbeiter from "./views/Mitarbeiter.jsx";
import Dokumente from "./views/Dokumente.jsx";
import { PrintView } from "./print/PrintComponents.jsx";
export default function App() {
const [data, setData] = useState(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
let merged = { ...defaultData, ...parsed, settings: { ...defaultData.settings, ...parsed.settings } };
// Migrate: clients[] + contacts[] → persons[]
if (!merged.persons && (merged.clients?.length || merged.contacts?.length)) {
const idMap = {};
const persons = [];
const usedContactIds = new Set();
for (const c of merged.clients || []) {
const linked = (merged.contacts || []).find(ct => ct.id === c.linkedContactId);
persons.push({
...c,
isAuftraggeber: true,
isPartner: !!linked,
type: c.type || linked?.type || "",
note: c.note || linked?.note || "",
honorarOffers: c.honorarOffers || linked?.honorarOffers || [],
contacts: c.contacts?.length ? c.contacts : (linked?.contacts || []),
linkedContactId: undefined,
linkedClientId: undefined,
});
if (linked) { usedContactIds.add(linked.id); idMap[linked.id] = c.id; }
}
for (const ct of merged.contacts || []) {
if (usedContactIds.has(ct.id)) continue;
persons.push({ ...ct, isAuftraggeber: false, isPartner: true, linkedClientId: undefined });
}
const remapProjects = (merged.projects || []).map(p => ({
...p,
projectContacts: (p.projectContacts || []).map(pc => ({ ...pc, contactId: idMap[pc.contactId] || pc.contactId })),
}));
const remapProtocols = (merged.protocols || []).map(p => ({
...p,
entries: (p.entries || []).map(e => ({ ...e, assignee: e.assignee ? (idMap[e.assignee] || e.assignee) : e.assignee })),
}));
merged = { ...merged, persons, projects: remapProjects, protocols: remapProtocols, clients: undefined, contacts: undefined };
}
// Migrate: projects linked to SIA/manual quotes should be pauschal (not stundensatz)
const allQuotes = merged.quotes || [];
const projects = (merged.projects || []).map(p => {
if ((p.billingType || p.type || "stundensatz") === "stundensatz" && (p.linkedQuotes || []).length > 0) {
const linkedQs = (p.linkedQuotes || []).map(lq => allQuotes.find(q => q.id === lq.quoteId)).filter(Boolean);
if (linkedQs.some(q => q.mode === "sia" || q.mode === "manual")) {
return { ...p, billingType: "pauschal", budget: p.budget || p.budgetAmount || 0 };
}
}
return p;
});
return { ...merged, projects };
}
} catch {}
return defaultData;
});
const [view, setView] = useState("dashboard");
const [selectedProjectId, setSelectedProjectId] = useState(null);
const [modal, setModal] = useState(null);
const [printContent, setPrintContent] = useState(null);
const [darkMode, setDarkMode] = useState(() => localStorage.getItem("rapport_dark") === "1");
const [whatsNewDismissed, setWhatsNewDismissed] = useState(() => localStorage.getItem("rapport_whats_new_v0_2_0") === "1");
const [navOpen, setNavOpen] = useState(false);
const [expandedNav, setExpandedNav] = useState(new Set(["buchhaltung"]));
const [uiZoom, setUiZoom] = useState(() => parseFloat(localStorage.getItem("rapport_zoom") || "1"));
// Persist dark mode
useEffect(() => { localStorage.setItem("rapport_dark", darkMode ? "1" : "0"); }, [darkMode]);
// UI-Zoom via Tauri native API (kein CSS-Balken-Problem), Fallback auf document.body.style.zoom
useEffect(() => {
localStorage.setItem("rapport_zoom", String(uiZoom));
const apply = async () => {
try {
if (window.__TAURI_INTERNALS__) {
const { getCurrentWebviewWindow } = await import("@tauri-apps/api/webviewWindow");
await getCurrentWebviewWindow().setZoom(uiZoom);
} else {
document.body.style.zoom = uiZoom === 1 ? "" : String(uiZoom);
}
} catch {
document.body.style.zoom = uiZoom === 1 ? "" : String(uiZoom);
}
};
apply();
}, [uiZoom]);
const zoomStep = 0.05;
const zoomIn = () => setUiZoom(z => Math.min(1.5, Math.round((z + zoomStep) * 100) / 100));
const zoomOut = () => setUiZoom(z => Math.max(0.5, Math.round((z - zoomStep) * 100) / 100));
// Navigation zu Protokoll von Projekt aus
useEffect(() => {
const handler = (e) => {
setView("protokolle");
window.__openProtokoll = e.detail?.id || null;
};
window.addEventListener("openProtokoll", handler);
return () => window.removeEventListener("openProtokoll", handler);
}, []);
// Auto-expand parent when navigating to a child
useEffect(() => {
NAV_ITEMS.forEach(item => {
if (item.children?.some(c => c.id === view)) {
setExpandedNav(prev => { const next = new Set(prev); next.add(item.id); return next; });
}
});
}, [view]);
const save = useCallback((newData) => {
setData(newData);
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(newData)); } catch {}
}, []);
const update = useCallback((key, value) => {
save({ ...data, [key]: value });
}, [data, save]);
// Auto-überfällig
useEffect(() => {
const today = new Date().toISOString().slice(0, 10);
const updated = data.invoices.map(inv =>
inv.status === "gesendet" && inv.dueDate && inv.dueDate < today
? { ...inv, status: "überfällig" } : inv
);
if (updated.some((inv, i) => inv.status !== data.invoices[i].status))
save({ ...data, invoices: updated });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (printContent) {
return <PrintView content={printContent} onClose={() => setPrintContent(null)} settings={data.settings} />;
}
return (
<div className="app-wrapper" data-theme={darkMode ? "dark" : "light"} style={{ display: "flex", height: "100%", overflow: "hidden", background: "var(--bg)", fontFamily: "'DM Mono', 'Courier New', monospace", color: "var(--text)" }}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&family=DM+Mono:wght@300;400;500&family=Playfair+Display:wght@400;700&display=swap');
:root, [data-theme=light] {
--bg: #f0ede8;
--bg2: #e8e4de;
--surface: #fff;
--surface2: #faf8f5;
--surface3: #f5f0e8;
--border: #e0dbd4;
--border2: #ece8e2;
--border3: #ddd8d0;
--text: #1a1a18;
--text2: #555;
--text3: #666;
--text4: #888;
--text5: #aaa;
--input-bg: #fff;
--input-border: #c8c0b8;
--scrollbar-track: #e8e4de;
--scrollbar-thumb: #b8b0a4;
color-scheme: light;
}
[data-theme=dark] {
--bg: #161614;
--bg2: #1e1e1a;
--surface: #222220;
--surface2: #292924;
--surface3: #2e2e28;
--border: #38382e;
--border2: #2e2e28;
--border3: #333328;
--text: #e8e5df;
--text2: #b0aca4;
--text3: #9a968e;
--text4: #7a7670;
--text5: #565450;
--input-bg: #1e1e1a;
--input-border: #3a3a30;
--scrollbar-track: #1e1e1a;
--scrollbar-thumb: #3a3a30;
color-scheme: dark;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { width: 100%; height: 100%; margin: 0; padding: 0; background: #1a1a18; }
body, #root { width: 100%; height: 100%; margin: 0; padding: 0; background: var(--bg); color: var(--text); }
#root h1, #root h2, #root h3 { color: var(--text); -webkit-text-fill-color: var(--text); }
#root > div { width: 100% !important; max-width: 100% !important; }
#root, #root div, #root h1, #root h2, #root h3, #root p, #root label, #root span, #root nav, #root section, #root article, #root main, #root aside, #root header, #root footer { text-align: left; }
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: var(--scrollbar-track); }
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
input, select, textarea, button { font-family: inherit; font-size: 13px; line-height: 1.4; margin: 0; -webkit-appearance: none; -moz-appearance: none; appearance: none; box-sizing: border-box; }
input, select, textarea { background: var(--input-bg); border: 1.5px solid var(--input-border); border-radius: 4px; padding: 8px 10px; color: var(--text); outline: none; transition: border-color 0.15s; width: 100%; height: 36px; }
textarea { height: auto; min-height: 36px; line-height: 1.5; }
select { padding-right: 28px; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/></svg>"); background-repeat: no-repeat; background-position: right 10px center; cursor: pointer; }
input[type="checkbox"], input[type="radio"] { -webkit-appearance: auto; -moz-appearance: auto; appearance: auto; width: auto; height: auto; }
input[type="date"] { min-height: 36px; }
input:focus, select:focus, textarea:focus { border-color: #8a6a3a; }
button { cursor: pointer; border: none; line-height: 1.4; }
.btn { padding: 0 18px; height: 36px; border-radius: 4px; font-weight: 500; transition: all 0.15s; display: inline-flex; align-items: center; justify-content: center; white-space: nowrap; }
.btn-primary { background: #2a2a22; color: #f0ede8; }
.btn-primary:hover { background: #8a6a3a; }
.btn-danger { background: #8a1a1a; color: #fff; }
.btn-danger:hover { background: #a02020; }
.btn-ghost { background: transparent; color: var(--text2); border: 1.5px solid var(--input-border); }
.btn-ghost:hover { border-color: #8a6a3a; color: #8a6a3a; }
.card { background: var(--surface); border-radius: 8px; border: 1px solid var(--border3); padding: 20px; overflow-x: auto; }
.tag { display: inline-block; padding: 3px 9px; border-radius: 3px; font-size: 11px; font-weight: 500; color: #fff; letter-spacing: 0.05em; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font-size: 11px; letter-spacing: 0.08em; color: var(--text4); font-weight: 500; padding: 10px 12px; border-bottom: 1.5px solid var(--border); text-transform: uppercase; }
td { padding: 11px 12px; font-size: 13px; border-bottom: 1px solid var(--border2); color: var(--text); }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--surface2); }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 20px; }
.modal { background: var(--surface); border-radius: 10px; padding: 28px; width: 100%; max-width: 560px; max-height: 90vh; overflow-y: auto; border: 1px solid var(--border); }
.form-row { display: flex; gap: 12px; margin-bottom: 14px; }
.form-group { display: flex; flex-direction: column; gap: 5px; flex: 1; }
label { font-size: 11px; letter-spacing: 0.06em; color: var(--text3); font-weight: 500; text-transform: uppercase; }
@media print { body { background: white !important; } .no-print { display: none !important; } }
.mobile-header { display: none; background: #1a1a18; padding: 14px 18px; position: sticky; top: 0; z-index: 150; align-items: center; gap: 12px; }
.mobile-col-toggle { display: none; }
@media (max-width: 768px) {
.sidebar { display: none !important; }
.sidebar.open { display: flex !important; position: fixed; inset: 0; z-index: 200; width: 100% !important; height: 100vh; overflow-y: auto; }
.main-content { padding: 16px !important; }
.mobile-header { display: flex !important; }
.mobile-close { display: block !important; }
.mobile-col-toggle { display: inline-flex !important; }
.app-wrapper { flex-direction: column !important; }
.form-row { flex-direction: column !important; }
.card { padding: 14px !important; }
.modal { padding: 18px !important; margin: 10px; }
.responsive-grid-2 { grid-template-columns: 1fr !important; }
.responsive-grid-4 { grid-template-columns: 1fr 1fr !important; }
h1 { font-size: 26px !important; }
table { min-width: 500px; }
.hide-mobile { display: none !important; }
.hide-compact { display: none !important; }
}
`}</style>
{/* Mobile Header */}
<div className="mobile-header">
<button onClick={() => setNavOpen(o => !o)} style={{ background: "none", border: "none", color: "#d4a85a", fontSize: 22, padding: 0, lineHeight: 1 }}>☰</button>
<div style={{ fontFamily: "'Archivo Black', sans-serif", fontSize: 20, color: "#f0ede8", letterSpacing: "-0.02em" }}>RAPPORT</div>
<div style={{ marginLeft: "auto", fontSize: 11, color: "#555", letterSpacing: "0.08em" }}>{NAV_ITEMS.find(n => n.id === view || (n.children||[]).some(c=>c.id===view))?.label || ""}</div>
</div>
{/* Sidebar */}
<div className={`sidebar${navOpen ? " open" : ""}`} style={{ width: 210, background: "#1a1a18", display: "flex", flexDirection: "column", padding: "28px 0", height: "100%", flexShrink: 0, overflowY: "auto" }}>
<div style={{ padding: "0 22px 28px", borderBottom: "1px solid #2d2d28", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<div style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 28, color: "#f0ede8", lineHeight: 0.95, letterSpacing: "-0.02em" }}>RAPPORT</div>
<div style={{ fontSize: 9, color: "#d4a85a", marginTop: 8, letterSpacing: "0.15em", fontWeight: 500 }}>STUDIO ADMINISTRATION</div>
</div>
<button onClick={() => setNavOpen(false)} style={{ background: "none", border: "none", color: "#555", fontSize: 20, padding: 0, lineHeight: 1, display: "none" }} className="mobile-close">✕</button>
</div>
<nav style={{ flex: 1, padding: "18px 0" }}>
{NAV_ITEMS.map(item => {
const isParentActive = view === item.id || (item.children || []).some(c => c.id === view);
const isExpanded = expandedNav.has(item.id);
const toggleExpand = (e) => {
e.stopPropagation();
setExpandedNav(prev => {
const next = new Set(prev);
next.has(item.id) ? next.delete(item.id) : next.add(item.id);
return next;
});
};
if (item.children) {
return (
<div key={item.id}>
<div style={{ display: "flex", alignItems: "center",
background: isParentActive ? "#2a2a22" : "transparent",
borderLeft: isParentActive ? "2px solid #d4a85a" : "2px solid transparent",
transition: "all 0.15s",
}}>
<button onClick={toggleExpand} style={{
flexShrink: 0, width: 22, alignSelf: "stretch",
display: "flex", alignItems: "center", justifyContent: "center",
background: "transparent", border: "none", cursor: "pointer",
color: isExpanded ? "#d4a85a" : "#444", fontSize: 8,
fontFamily: "inherit", padding: 0,
}}>
<span style={{ display: "inline-block", transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>▶</span>
</button>
<button onClick={() => { setView(item.id); setNavOpen(false); setExpandedNav(prev => { const next = new Set(prev); next.add(item.id); return next; }); }} style={{
flex: 1, padding: "11px 22px 11px 0",
background: "transparent", border: "none",
color: isParentActive ? "#d4a85a" : "#999",
fontSize: 13, textAlign: "left", fontFamily: "inherit", cursor: "pointer",
letterSpacing: "0.02em"
}}>{item.label}</button>
</div>
{isExpanded && (
<div style={{ background: "#111110" }}>
{item.children.map(child => (
<button key={child.id} onClick={() => { setView(child.id); setNavOpen(false); }} style={{
display: "block", width: "100%", padding: "8px 22px 8px 36px",
background: view === child.id ? "#1e1e18" : "transparent",
color: view === child.id ? "#d4a85a" : "#666",
fontSize: 12, textAlign: "left", fontFamily: "inherit", cursor: "pointer",
borderLeft: view === child.id ? "2px solid #d4a85a" : "2px solid transparent",
transition: "all 0.15s", letterSpacing: "0.02em"
}}>{child.label}</button>
))}
</div>
)}
</div>
);
}
return (
<button key={item.id} onClick={() => { setView(item.id); setSelectedProjectId(null); setNavOpen(false); }} style={{
display: "block", width: "100%", padding: "11px 22px",
background: view === item.id ? "#2a2a22" : "transparent",
color: view === item.id ? "#d4a85a" : "#999",
fontSize: 13, textAlign: "left", fontFamily: "inherit", cursor: "pointer",
borderLeft: view === item.id ? "2px solid #d4a85a" : "2px solid transparent",
transition: "all 0.15s", letterSpacing: "0.02em"
}}>{item.label}</button>
);
})}
</nav>
{/* Zoom-Steuerung */}
<div style={{ padding: "8px 22px", borderTop: "1px solid #2d2d28", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 4 }}>
<button onClick={zoomOut} style={{ background: "none", border: "none", color: "#444", cursor: "pointer", width: 20, height: 20, fontSize: 12, lineHeight: 1, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "inherit", flexShrink: 0, opacity: 0.6 }}></button>
<button onClick={() => setUiZoom(1)} title="Auf 100% zurücksetzen" style={{ background: "none", border: "none", color: uiZoom === 1 ? "#3a3a30" : "#666", cursor: uiZoom === 1 ? "default" : "pointer", fontSize: 9, fontFamily: "inherit", letterSpacing: "0.06em", padding: "0 2px", opacity: 0.6 }}>{Math.round(uiZoom * 100)}%</button>
<button onClick={zoomIn} style={{ background: "none", border: "none", color: "#444", cursor: "pointer", width: 20, height: 20, fontSize: 12, lineHeight: 1, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "inherit", flexShrink: 0, opacity: 0.6 }}>+</button>
</div>
<div style={{ padding: "14px 22px", borderTop: "1px solid #2d2d28", fontSize: 10, color: "#555", letterSpacing: "0.08em" }}>
<div style={{ color: "#3d3d38" }}>ALPHA 0.2.1</div>
<div style={{ marginTop: 10, paddingTop: 10, borderTop: "1px solid #2d2d28", color: "#3d3d38", lineHeight: 1.5 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<div>AGPL-3.0</div>
</div>
<button onClick={() => setDarkMode(d => !d)} title={darkMode ? "Light Mode" : "Dark Mode"} style={{
background: darkMode ? "#2a2a22" : "#333328",
border: "1px solid #3a3a30",
borderRadius: 20,
width: 36, height: 20,
cursor: "pointer",
position: "relative",
transition: "background 0.2s",
padding: 0,
flexShrink: 0,
}}>
<span style={{
position: "absolute",
top: 2, left: darkMode ? 18 : 2,
width: 14, height: 14,
borderRadius: "50%",
background: darkMode ? "#d4a85a" : "#555",
transition: "left 0.2s, background 0.2s",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 7, lineHeight: 1,
}}>{darkMode ? "☽" : "☀"}</span>
</button>
</div>
</div>
</div>
</div>
{/* Main */}
<div className="main-content" style={{ flex: 1, padding: "36px 40px", overflowY: "auto", minWidth: 0, background: "var(--bg)" }}>
{view === "dashboard" && <Dashboard data={data} setView={setView} />}
{view === "projects" && !selectedProjectId && <Projects data={data} update={update} saveAll={save} modal={modal} setModal={setModal} onSelect={setSelectedProjectId} setPrintContent={setPrintContent} />}
{view === "projects" && selectedProjectId && <ProjectDetail data={data} update={update} saveAll={save} projectId={selectedProjectId} onBack={() => setSelectedProjectId(null)} setPrintContent={setPrintContent} modal={modal} setModal={setModal} />}
{view === "time" && <Time data={data} update={update} />}
{view === "quotes" && <Quotes data={data} update={update} setData={setData} saveAll={save} modal={modal} setModal={setModal} setPrintContent={setPrintContent} setView={setView} onSelectProject={setSelectedProjectId} />}
{view === "dokumente" && <Dokumente data={data} setView={setView} />}
{view === "lieferscheine" && <Lieferscheine data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />}
{view === "protokolle" && <Protokolle data={data} update={update} saveAll={save} setPrintContent={setPrintContent} />}
{view === "buchhaltung" && <Buchhaltung data={data} update={update} setView={setView} setPrintContent={setPrintContent} />}
{view === "invoices" && <Invoices data={data} update={update} saveAll={save} modal={modal} setModal={setModal} setPrintContent={setPrintContent} setView={setView} />}
{view === "loehne" && <Loehne data={data} update={update} saveAll={save} setPrintContent={setPrintContent} setView={setView} />}
{view === "expenses" && <Spesen data={data} update={update} saveAll={save} modal={modal} setModal={setModal} standalone setView={setView} />}
{view === "internal-expenses" && <InternalExpenses data={data} update={update} setView={setView} />}
{view === "personen" && <Personen data={data} update={update} saveAll={save} setView={setView} />}
{view === "mitarbeiter" && <Mitarbeiter data={data} update={update} />}
{view === "letters" && <Letters data={data} update={update} setPrintContent={setPrintContent} />}
{view === "settings" && <Settings data={data} update={update} />}
{view === "studio-budget" && <StudioBudget data={data} update={update} setView={setView} setPrintContent={setPrintContent} />}
</div>
{!whatsNewDismissed && (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.55)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
<div style={{ background: "#fff", borderRadius: 10, width: "100%", maxWidth: 480, boxShadow: "0 8px 40px rgba(0,0,0,0.18)", overflow: "hidden" }}>
<div style={{ background: "#1a1a18", padding: "28px 32px 24px" }}>
<div style={{ fontSize: 10, letterSpacing: "0.18em", color: "#d4a85a", marginBottom: 8, fontWeight: 600 }}>NEUERUNGEN</div>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 28, color: "#f0ede8", fontWeight: 400, lineHeight: 1.1 }}>Version 0.2.0</div>
</div>
<div style={{ padding: "24px 32px 8px" }}>
{[
["Wochenansicht", "Zeiterfassung mit Kalender-Ansicht, Einträge per Drag & Drop verschieben und skalieren"],
["Tagessaldo", "Wochenkopf zeigt Soll/Ist-Differenz pro Tag — wie im Monatskalender"],
["Eintrag kopieren", "Rechtsklick auf einen Eintrag → auf morgen duplizieren"],
["Teilzeit & Pensum", "Lohn wird automatisch gemäss Pensum berechnet, pro Monat überschreibbar"],
["Ferien zurückziehen", "Beantragte und genehmigte Ferien können jederzeit zurückgezogen werden"],
].map(([title, desc]) => (
<div key={title} style={{ display: "flex", gap: 14, marginBottom: 16 }}>
<div style={{ width: 4, flexShrink: 0, background: "#d4a85a", borderRadius: 2, marginTop: 2 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#1a1a18", marginBottom: 2 }}>{title}</div>
<div style={{ fontSize: 12, color: "#888", lineHeight: 1.5 }}>{desc}</div>
</div>
</div>
))}
<div style={{ display: "flex", gap: 14, marginTop: 8, padding: "14px 16px", background: "#fdf0e8", border: "1px solid #e8c99a", borderRadius: 6 }}>
<div style={{ width: 4, flexShrink: 0, background: "#b5621e", borderRadius: 2, marginTop: 2 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: "#8a3a0a", marginBottom: 3 }}>Datenbank-Neustart empfohlen</div>
<div style={{ fontSize: 12, color: "#a0500a", lineHeight: 1.5 }}>Aufgrund umfangreicher Neuerungen und fehlerhafter Teile im letzten Build wird empfohlen, unter <strong>Einstellungen → Datenbank löschen</strong> neu zu starten.</div>
</div>
</div>
</div>
<div style={{ padding: "0 32px 24px" }}>
<button className="btn btn-primary" style={{ width: "100%", fontSize: 13 }}
onClick={() => { setWhatsNewDismissed(true); localStorage.setItem("rapport_whats_new_v0_2_0", "1"); }}>
Los geht's
</button>
</div>
</div>
</div>
)}
</div>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+469
View File
@@ -0,0 +1,469 @@
import React, { useState, useEffect, useRef } from "react";
import { STATUS_COLORS, STATUS_BG } from "../constants.js";
export function StatusBadge({ status }) {
const color = STATUS_COLORS[status] || "#888";
const bg = STATUS_BG[status] || "#f5f5f5";
return (
<span style={{
display: "inline-block", padding: "2px 10px", borderRadius: 20,
fontSize: 11, fontWeight: 600, letterSpacing: "0.04em",
color, background: bg, border: `1.5px solid ${color}40`,
}}>{status}</span>
);
}
export function StatusSelect({ value, options, onChange }) {
const color = STATUS_COLORS[value] || "#888";
const bg = STATUS_BG[value] || "#f5f5f5";
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
onClick={e => e.stopPropagation()}
style={{
fontSize: 11, height: 26, padding: "0 20px 0 8px",
background: bg, color, border: `1.5px solid ${color}40`,
fontWeight: 600, borderRadius: 20, cursor: "pointer",
fontFamily: "inherit", appearance: "auto",
}}
>
{options.map(s => <option key={s} value={s}>{s}</option>)}
</select>
);
}
export function Header({ title, action }) {
return (
<div style={{ marginBottom: 10, paddingBottom: 7, borderBottom: "1px solid var(--border)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h1 style={{ fontFamily: "'Playfair Display', serif", fontSize: 30, fontWeight: 400, letterSpacing: "-0.02em", lineHeight: 1.1 }}>{title}</h1>
{action}
</div>
</div>
);
}
export function FormField({ label, children }) {
return (
<div className="form-group" style={{ marginBottom: 14 }}>
<label>{label}</label>
{children}
</div>
);
}
export function Modal({ title, onClose, onSave, saveLabel, hideSave, children, wide, overflow }) {
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ ...(wide ? { maxWidth: 740 } : {}), ...(overflow ? { overflow: "visible" } : {}) }}>
<h2 style={{ fontFamily: "'Playfair Display', serif", fontWeight: 400, marginBottom: 26, fontSize: 24, letterSpacing: "-0.01em" }}>{title}</h2>
{children}
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 24, paddingTop: 20, borderTop: "1px solid var(--border2)" }}>
<button className="btn btn-ghost" onClick={onClose}>Abbrechen</button>
{!hideSave && <button className="btn btn-primary" onClick={onSave}>{saveLabel || "Speichern"}</button>}
</div>
</div>
</div>
);
}
export function StudioLogo({ settings, size = 24 }) {
if (settings.logo) {
const h = settings.logoSize || 60;
return <img src={settings.logo} alt={settings.name} style={{ maxHeight: h, maxWidth: h * 5, display: "block" }} />;
}
return <div style={{ fontFamily: "'Playfair Display', serif", fontSize: size, fontWeight: 400, fontStyle: "italic", letterSpacing: "-0.01em" }}>{settings.name}</div>;
}
export function useConfirm() {
const [pending, setPending] = useState(null);
const askConfirm = (msg, confirmLabel = "Löschen") =>
new Promise(resolve => setPending({ msg, confirmLabel, resolve }));
const ConfirmModalEl = pending ? (
<div style={{ position: "fixed", inset: 0, background: "rgba(26,26,24,0.5)", zIndex: 2000, display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ background: "#f0ede8", borderRadius: 8, padding: "24px 28px", maxWidth: 400, width: "90%", fontFamily: "inherit", boxShadow: "0 8px 32px rgba(0,0,0,0.2)" }}>
<div style={{ fontSize: 13, lineHeight: 1.6, color: "#1a1a18", marginBottom: 22 }}>{pending.msg}</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button className="btn btn-ghost" onClick={() => { pending.resolve(false); setPending(null); }}>Abbrechen</button>
<button className="btn btn-danger" onClick={() => { pending.resolve(true); setPending(null); }}>{pending.confirmLabel}</button>
</div>
</div>
</div>
) : null;
return { askConfirm, ConfirmModalEl };
}
export function DateInput({ value, onChange, style, ...props }) {
const toDE = (iso) => {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
return `${iso.slice(8, 10)}.${iso.slice(5, 7)}.${iso.slice(0, 4)}`;
};
const toISO = (de) => {
const m = de.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (!m) return null;
const iso = `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`;
return isNaN(new Date(iso).getTime()) ? null : iso;
};
const [text, setText] = React.useState(() => toDE(value));
React.useEffect(() => { const de = toDE(value); if (de !== text) setText(de); }, [value]);
const handleChange = (e) => {
const digits = e.target.value.replace(/\D/g, "").slice(0, 8);
let fmt = "";
if (digits.length <= 2) fmt = digits;
else if (digits.length <= 4) fmt = `${digits.slice(0, 2)}.${digits.slice(2)}`;
else fmt = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
setText(fmt);
const iso = toISO(fmt);
if (iso) onChange({ target: { value: iso } });
};
return (
<input
type="text"
value={text}
onChange={handleChange}
onBlur={() => { if (!toISO(text) && value) setText(toDE(value)); }}
placeholder="TT.MM.JJJJ"
style={style}
{...props}
/>
);
}
export function RichEditor({ value, onChange, minHeight = 420, compact = false }) {
const editorRef = React.useRef(null);
const isInternalChange = React.useRef(false);
const lastValue = React.useRef(value);
const savedRange = React.useRef(null);
const colorInputRef = React.useRef(null);
const [colorValue, setColorValue] = React.useState("#000000");
// Only push content into DOM when value changes from outside (template load)
useEffect(() => {
if (!editorRef.current) return;
if (value !== lastValue.current && !isInternalChange.current) {
editorRef.current.innerHTML = value;
lastValue.current = value;
}
isInternalChange.current = false;
}, [value]);
const saveSelection = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0 && editorRef.current?.contains(sel.anchorNode)) {
savedRange.current = sel.getRangeAt(0).cloneRange();
}
};
const restoreSelection = () => {
editorRef.current?.focus();
if (!savedRange.current) {
// Place cursor at end if no saved range
const range = document.createRange();
range.selectNodeContents(editorRef.current);
range.collapse(false);
const sel = window.getSelection();
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
return;
}
const sel = window.getSelection();
if (sel) { sel.removeAllRanges(); sel.addRange(savedRange.current); }
};
const exec = (cmd, val = null) => {
restoreSelection();
document.execCommand(cmd, false, val);
saveSelection();
// Sync content after command
isInternalChange.current = true;
const html = editorRef.current?.innerHTML || "";
lastValue.current = html;
onChange(html);
};
const TB = ({ cmd, val, title, style: s, children }) => (
<button
onMouseDown={e => { e.preventDefault(); exec(cmd, val); }}
title={title}
style={{
padding: "0 9px", height: 30, borderRadius: 4,
border: "none", cursor: "pointer", fontFamily: "inherit", fontSize: 12,
background: "transparent", color: "var(--text3)",
display: "flex", alignItems: "center", justifyContent: "center",
transition: "background 0.1s",
...s,
}}
onMouseEnter={e => e.currentTarget.style.background = "var(--border2)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>{children}</button>
);
const Sep = () => <div style={{ width: 1, height: 16, background: "var(--border)", margin: "0 3px", flexShrink: 0 }} />;
return (
<div style={{ border: "1.5px solid var(--input-border)", borderRadius: 8, overflow: "hidden" }}>
<div style={{ display: "flex", alignItems: "center", gap: 1, padding: "5px 8px", borderBottom: "1px solid var(--border2)", background: "var(--surface2)" }}>
<TB cmd="bold" title="Fett (Ctrl+B)" s={{ fontWeight: 700, minWidth: 28 }}>B</TB>
<TB cmd="italic" title="Kursiv (Ctrl+I)" s={{ fontStyle: "italic", minWidth: 28 }}>I</TB>
<TB cmd="underline" title="Unterstrichen (Ctrl+U)" s={{ textDecoration: "underline", minWidth: 28 }}>U</TB>
<Sep />
<TB cmd="justifyLeft" title="Linksbündig" s={{ padding: "0 7px" }}>
<svg width="14" height="12" viewBox="0 0 14 12" fill="currentColor">
<rect x="0" y="0" width="14" height="1.5" rx="0.75"/>
<rect x="0" y="3.5" width="9" height="1.5" rx="0.75"/>
<rect x="0" y="7" width="14" height="1.5" rx="0.75"/>
<rect x="0" y="10.5" width="11" height="1.5" rx="0.75"/>
</svg>
</TB>
<TB cmd="justifyCenter" title="Zentriert" s={{ padding: "0 7px" }}>
<svg width="14" height="12" viewBox="0 0 14 12" fill="currentColor">
<rect x="0" y="0" width="14" height="1.5" rx="0.75"/>
<rect x="2.5" y="3.5" width="9" height="1.5" rx="0.75"/>
<rect x="0" y="7" width="14" height="1.5" rx="0.75"/>
<rect x="1.5" y="10.5" width="11" height="1.5" rx="0.75"/>
</svg>
</TB>
<TB cmd="justifyRight" title="Rechtsbündig" s={{ padding: "0 7px" }}>
<svg width="14" height="12" viewBox="0 0 14 12" fill="currentColor">
<rect x="0" y="0" width="14" height="1.5" rx="0.75"/>
<rect x="5" y="3.5" width="9" height="1.5" rx="0.75"/>
<rect x="0" y="7" width="14" height="1.5" rx="0.75"/>
<rect x="3" y="10.5" width="11" height="1.5" rx="0.75"/>
</svg>
</TB>
<Sep />
{/* Font size */}
<select
onMouseDown={saveSelection}
onChange={e => { exec("fontSize", e.target.value); e.target.value = ""; }}
title="Schriftgrösse"
style={{
width: 80, height: 26, border: "1px solid var(--border2)", borderRadius: 4,
background: "transparent", color: "var(--text3)", fontSize: 11,
padding: "0 4px", cursor: "pointer", outline: "none",
}}
>
<option value="">Grösse</option>
<option value="1">Klein</option>
<option value="3">Normal</option>
<option value="5">Gross</option>
<option value="7">Titel</option>
</select>
<Sep />
{/* Color */}
<button
onMouseDown={e => { e.preventDefault(); saveSelection(); colorInputRef.current?.click(); }}
title="Textfarbe"
style={{
padding: "0 8px", height: 30, borderRadius: 4, border: "none",
cursor: "pointer", background: "transparent",
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: 2, transition: "background 0.1s",
}}
onMouseEnter={e => e.currentTarget.style.background = "var(--border2)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--text3)", lineHeight: 1 }}>A</span>
<div style={{ width: 14, height: 3, borderRadius: 1, background: colorValue }} />
</button>
<input
ref={colorInputRef}
type="color"
value={colorValue}
onChange={e => { setColorValue(e.target.value); exec("foreColor", e.target.value); }}
style={{ position: "absolute", width: 0, height: 0, opacity: 0, pointerEvents: "none" }}
/>
</div>
{/* Editor */}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={() => {
isInternalChange.current = true;
const html = editorRef.current?.innerHTML || "";
lastValue.current = html;
onChange(html);
}}
onKeyUp={saveSelection}
onMouseUp={saveSelection}
onBlur={saveSelection}
onPaste={e => {
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
restoreSelection();
document.execCommand("insertText", false, text);
saveSelection();
}}
style={{
minHeight, padding: compact ? "8px 10px" : "20px 24px", outline: "none",
fontSize: compact ? 12 : 13, lineHeight: 1.8, color: "var(--text)",
background: "var(--surface)",
fontFamily: "'DM Mono', 'Courier New', monospace",
}}
/>
</div>
);
}
export function CalendarPopup({ value, onChange, onClose, align = "left", showClear = true }) {
const [viewYM, setViewYM] = useState(() => {
const d = value ? new Date(value + "T00:00:00") : new Date();
return { year: d.getFullYear(), month: d.getMonth() };
});
const todayStr = new Date().toISOString().slice(0, 10);
const { year, month } = viewYM;
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startWeekday = (firstDay.getDay() + 6) % 7;
const cells = [];
for (let i = 0; i < startWeekday; i++) cells.push(null);
for (let d = 1; d <= lastDay.getDate(); d++) {
cells.push(`${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`);
}
const prevMonth = () => { const d = new Date(year, month - 1, 1); setViewYM({ year: d.getFullYear(), month: d.getMonth() }); };
const nextMonth = () => { const d = new Date(year, month + 1, 1); setViewYM({ year: d.getFullYear(), month: d.getMonth() }); };
const select = (ds) => { onChange(ds); onClose(); };
return (
<div
style={{
position: "absolute",
top: "calc(100% + 4px)",
[align === "right" ? "right" : "left"]: 0,
zIndex: 2000,
background: "#f0ede8",
border: "1px solid #e0dbd4",
borderRadius: 8,
padding: 12,
boxShadow: "0 8px 28px rgba(0,0,0,0.16)",
width: 232,
}}
onMouseDown={e => e.stopPropagation()}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
<button onMouseDown={e => { e.preventDefault(); prevMonth(); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px 8px", fontSize: 14, color: "#888", lineHeight: 1 }}></button>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 13, color: "#1a1a18" }}>
{firstDay.toLocaleDateString("de-CH", { month: "long", year: "numeric" })}
</div>
<button onMouseDown={e => { e.preventDefault(); nextMonth(); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px 8px", fontSize: 14, color: "#888", lineHeight: 1 }}></button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7,1fr)", gap: 2, marginBottom: 3 }}>
{["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"].map(d => (
<div key={d} style={{ textAlign: "center", fontSize: 9, color: "#aaa", padding: "2px 0" }}>{d}</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7,1fr)", gap: 2 }}>
{cells.map((ds, i) => {
if (!ds) return <div key={i} />;
const dow = new Date(ds + "T00:00:00").getDay();
const isWeekend = dow === 0 || dow === 6;
const isSelected = ds === value;
const isToday = ds === todayStr;
return (
<button key={ds}
onMouseDown={e => { e.preventDefault(); select(ds); }}
style={{
aspectRatio: "1", fontSize: 11, cursor: "pointer",
fontFamily: "inherit", borderRadius: 4,
border: isToday && !isSelected ? "1.5px solid #b07848" : "1.5px solid transparent",
background: isSelected ? "#1a1a18" : "transparent",
color: isSelected ? "#b07848" : isWeekend ? "#bbb" : "#1a1a18",
fontWeight: isToday && !isSelected ? 600 : 400,
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = "#e8e3dc"; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = "transparent"; }}
>
{new Date(ds + "T00:00:00").getDate()}
</button>
);
})}
</div>
{showClear && value && (
<div style={{ marginTop: 8, paddingTop: 8, borderTop: "1px solid #e0dbd4", textAlign: "center" }}>
<button onMouseDown={e => { e.preventDefault(); onChange(""); onClose(); }}
style={{ fontSize: 10, color: "#aaa", background: "none", border: "none", cursor: "pointer", fontFamily: "inherit" }}>
Datum löschen
</button>
</div>
)}
</div>
);
}
export function DatePicker({ value, onChange, style, placeholder, align = "left", ...props }) {
const [open, setOpen] = useState(false);
const wrapRef = useRef(null);
const toDE = (iso) => {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
return `${iso.slice(8, 10)}.${iso.slice(5, 7)}.${iso.slice(0, 4)}`;
};
useEffect(() => {
if (!open) return;
const close = (e) => { if (!wrapRef.current?.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
return (
<div ref={wrapRef} style={{ position: "relative", display: "inline-block", width: "100%" }}>
<input
type="text"
value={toDE(value)}
readOnly
onClick={() => setOpen(o => !o)}
placeholder={placeholder || "TT.MM.JJJJ"}
style={{ cursor: "pointer", ...style }}
{...props}
/>
{open && (
<CalendarPopup
value={value}
onChange={ds => onChange({ target: { value: ds } })}
onClose={() => setOpen(false)}
align={align}
/>
)}
</div>
);
}
export function NavArrows({ onPrev, onNext, disabledNext = false }) {
const btn = (onClick, disabled, children) => (
<button
onClick={disabled ? undefined : onClick}
disabled={disabled}
style={{
background: "none", border: "none", padding: "0 11px", height: 30,
cursor: disabled ? "default" : "pointer",
color: disabled ? "var(--border2)" : "var(--text3)",
fontSize: 17, lineHeight: 1, display: "flex", alignItems: "center",
fontFamily: "inherit", flexShrink: 0,
}}
>{children}</button>
);
return (
<div style={{ display: "flex", border: "1px solid var(--border2)", borderRadius: 20, overflow: "hidden" }}>
{btn(onPrev, false, "")}
<div style={{ width: 1, background: "var(--border2)", alignSelf: "stretch" }} />
{btn(onNext, disabledNext, "")}
</div>
);
}
export function useCalendarNav() {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const close = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
return { open, setOpen, ref };
}
+444
View File
@@ -0,0 +1,444 @@
import React, { useState, useEffect, useRef } from "react";
import { STATUS_COLORS, STATUS_BG } from "../constants.js";
export function StatusBadge({ status }) {
const color = STATUS_COLORS[status] || "#888";
const bg = STATUS_BG[status] || "#f5f5f5";
return (
<span style={{
display: "inline-block", padding: "2px 9px", borderRadius: 4,
fontSize: 11, fontWeight: 600, letterSpacing: "0.03em",
color, background: bg, border: `1.5px solid ${color}40`,
}}>{status}</span>
);
}
export function StatusSelect({ value, options, onChange }) {
const color = STATUS_COLORS[value] || "#888";
const bg = STATUS_BG[value] || "#f5f5f5";
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
onClick={e => e.stopPropagation()}
style={{
fontSize: 11, height: 26, padding: "0 20px 0 8px",
background: bg, color, border: `1.5px solid ${color}40`,
fontWeight: 600, borderRadius: 4, cursor: "pointer",
fontFamily: "inherit", appearance: "auto",
}}
>
{options.map(s => <option key={s} value={s}>{s}</option>)}
</select>
);
}
export function Header({ title, action }) {
return (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 26 }}>
<h1 style={{ fontFamily: "'Playfair Display', serif", fontSize: 36, fontWeight: 400, letterSpacing: "-0.01em" }}>{title}</h1>
{action}
</div>
);
}
export function FormField({ label, children }) {
return (
<div className="form-group" style={{ marginBottom: 14 }}>
<label>{label}</label>
{children}
</div>
);
}
export function Modal({ title, onClose, onSave, saveLabel, hideSave, children, wide, overflow }) {
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ ...(wide ? { maxWidth: 720 } : {}), ...(overflow ? { overflow: "visible" } : {}) }}>
<h2 style={{ fontFamily: "'Playfair Display', serif", fontWeight: 400, marginBottom: 22, fontSize: 22 }}>{title}</h2>
{children}
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 20, paddingTop: 18, borderTop: "1px solid #eee" }}>
<button className="btn btn-ghost" onClick={onClose}>Abbrechen</button>
{!hideSave && <button className="btn btn-primary" onClick={onSave}>{saveLabel || "Speichern"}</button>}
</div>
</div>
</div>
);
}
export function StudioLogo({ settings, size = 24 }) {
if (settings.logo) {
const h = settings.logoSize || 60;
return <img src={settings.logo} alt={settings.name} style={{ maxHeight: h, maxWidth: h * 5, display: "block" }} />;
}
return <div style={{ fontFamily: "'Playfair Display', serif", fontSize: size, fontWeight: 400, fontStyle: "italic", letterSpacing: "-0.01em" }}>{settings.name}</div>;
}
export function useConfirm() {
const [pending, setPending] = useState(null);
const askConfirm = (msg, confirmLabel = "Löschen") =>
new Promise(resolve => setPending({ msg, confirmLabel, resolve }));
const ConfirmModalEl = pending ? (
<div style={{ position: "fixed", inset: 0, background: "rgba(26,26,24,0.5)", zIndex: 2000, display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ background: "#f0ede8", borderRadius: 8, padding: "24px 28px", maxWidth: 400, width: "90%", fontFamily: "inherit", boxShadow: "0 8px 32px rgba(0,0,0,0.2)" }}>
<div style={{ fontSize: 13, lineHeight: 1.6, color: "#1a1a18", marginBottom: 22 }}>{pending.msg}</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button className="btn btn-ghost" onClick={() => { pending.resolve(false); setPending(null); }}>Abbrechen</button>
<button className="btn btn-danger" onClick={() => { pending.resolve(true); setPending(null); }}>{pending.confirmLabel}</button>
</div>
</div>
</div>
) : null;
return { askConfirm, ConfirmModalEl };
}
export function DateInput({ value, onChange, style, ...props }) {
const toDE = (iso) => {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
return `${iso.slice(8, 10)}.${iso.slice(5, 7)}.${iso.slice(0, 4)}`;
};
const toISO = (de) => {
const m = de.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (!m) return null;
const iso = `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`;
return isNaN(new Date(iso).getTime()) ? null : iso;
};
const [text, setText] = React.useState(() => toDE(value));
React.useEffect(() => { const de = toDE(value); if (de !== text) setText(de); }, [value]);
const handleChange = (e) => {
const digits = e.target.value.replace(/\D/g, "").slice(0, 8);
let fmt = "";
if (digits.length <= 2) fmt = digits;
else if (digits.length <= 4) fmt = `${digits.slice(0, 2)}.${digits.slice(2)}`;
else fmt = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
setText(fmt);
const iso = toISO(fmt);
if (iso) onChange({ target: { value: iso } });
};
return (
<input
type="text"
value={text}
onChange={handleChange}
onBlur={() => { if (!toISO(text) && value) setText(toDE(value)); }}
placeholder="TT.MM.JJJJ"
style={style}
{...props}
/>
);
}
export function RichEditor({ value, onChange, minHeight = 420, compact = false }) {
const editorRef = React.useRef(null);
const isInternalChange = React.useRef(false);
const lastValue = React.useRef(value);
const savedRange = React.useRef(null);
const colorInputRef = React.useRef(null);
const [colorValue, setColorValue] = React.useState("#000000");
// Only push content into DOM when value changes from outside (template load)
useEffect(() => {
if (!editorRef.current) return;
if (value !== lastValue.current && !isInternalChange.current) {
editorRef.current.innerHTML = value;
lastValue.current = value;
}
isInternalChange.current = false;
}, [value]);
const saveSelection = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0 && editorRef.current?.contains(sel.anchorNode)) {
savedRange.current = sel.getRangeAt(0).cloneRange();
}
};
const restoreSelection = () => {
editorRef.current?.focus();
if (!savedRange.current) {
// Place cursor at end if no saved range
const range = document.createRange();
range.selectNodeContents(editorRef.current);
range.collapse(false);
const sel = window.getSelection();
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
return;
}
const sel = window.getSelection();
if (sel) { sel.removeAllRanges(); sel.addRange(savedRange.current); }
};
const exec = (cmd, val = null) => {
restoreSelection();
document.execCommand(cmd, false, val);
saveSelection();
// Sync content after command
isInternalChange.current = true;
const html = editorRef.current?.innerHTML || "";
lastValue.current = html;
onChange(html);
};
const TB = ({ cmd, val, title, style: s, children }) => (
<button
onMouseDown={e => { e.preventDefault(); exec(cmd, val); }}
title={title}
style={{
padding: "0 9px", height: 30, borderRadius: 4,
border: "none", cursor: "pointer", fontFamily: "inherit", fontSize: 12,
background: "transparent", color: "var(--text3)",
display: "flex", alignItems: "center", justifyContent: "center",
transition: "background 0.1s",
...s,
}}
onMouseEnter={e => e.currentTarget.style.background = "var(--border2)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>{children}</button>
);
const Sep = () => <div style={{ width: 1, height: 16, background: "var(--border)", margin: "0 3px", flexShrink: 0 }} />;
return (
<div style={{ border: "1.5px solid var(--input-border)", borderRadius: 8, overflow: "hidden" }}>
<div style={{ display: "flex", alignItems: "center", gap: 1, padding: "5px 8px", borderBottom: "1px solid var(--border2)", background: "var(--surface2)" }}>
<TB cmd="bold" title="Fett (Ctrl+B)" s={{ fontWeight: 700, minWidth: 28 }}>B</TB>
<TB cmd="italic" title="Kursiv (Ctrl+I)" s={{ fontStyle: "italic", minWidth: 28 }}>I</TB>
<TB cmd="underline" title="Unterstrichen (Ctrl+U)" s={{ textDecoration: "underline", minWidth: 28 }}>U</TB>
<Sep />
<TB cmd="justifyLeft" title="Linksbündig" s={{ padding: "0 7px" }}>
<svg width="14" height="12" viewBox="0 0 14 12" fill="currentColor">
<rect x="0" y="0" width="14" height="1.5" rx="0.75"/>
<rect x="0" y="3.5" width="9" height="1.5" rx="0.75"/>
<rect x="0" y="7" width="14" height="1.5" rx="0.75"/>
<rect x="0" y="10.5" width="11" height="1.5" rx="0.75"/>
</svg>
</TB>
<TB cmd="justifyCenter" title="Zentriert" s={{ padding: "0 7px" }}>
<svg width="14" height="12" viewBox="0 0 14 12" fill="currentColor">
<rect x="0" y="0" width="14" height="1.5" rx="0.75"/>
<rect x="2.5" y="3.5" width="9" height="1.5" rx="0.75"/>
<rect x="0" y="7" width="14" height="1.5" rx="0.75"/>
<rect x="1.5" y="10.5" width="11" height="1.5" rx="0.75"/>
</svg>
</TB>
<TB cmd="justifyRight" title="Rechtsbündig" s={{ padding: "0 7px" }}>
<svg width="14" height="12" viewBox="0 0 14 12" fill="currentColor">
<rect x="0" y="0" width="14" height="1.5" rx="0.75"/>
<rect x="5" y="3.5" width="9" height="1.5" rx="0.75"/>
<rect x="0" y="7" width="14" height="1.5" rx="0.75"/>
<rect x="3" y="10.5" width="11" height="1.5" rx="0.75"/>
</svg>
</TB>
<Sep />
{/* Font size */}
<select
onMouseDown={saveSelection}
onChange={e => { exec("fontSize", e.target.value); e.target.value = ""; }}
title="Schriftgrösse"
style={{
width: 80, height: 26, border: "1px solid var(--border2)", borderRadius: 4,
background: "transparent", color: "var(--text3)", fontSize: 11,
padding: "0 4px", cursor: "pointer", outline: "none",
}}
>
<option value="">Grösse</option>
<option value="1">Klein</option>
<option value="3">Normal</option>
<option value="5">Gross</option>
<option value="7">Titel</option>
</select>
<Sep />
{/* Color */}
<button
onMouseDown={e => { e.preventDefault(); saveSelection(); colorInputRef.current?.click(); }}
title="Textfarbe"
style={{
padding: "0 8px", height: 30, borderRadius: 4, border: "none",
cursor: "pointer", background: "transparent",
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: 2, transition: "background 0.1s",
}}
onMouseEnter={e => e.currentTarget.style.background = "var(--border2)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--text3)", lineHeight: 1 }}>A</span>
<div style={{ width: 14, height: 3, borderRadius: 1, background: colorValue }} />
</button>
<input
ref={colorInputRef}
type="color"
value={colorValue}
onChange={e => { setColorValue(e.target.value); exec("foreColor", e.target.value); }}
style={{ position: "absolute", width: 0, height: 0, opacity: 0, pointerEvents: "none" }}
/>
</div>
{/* Editor */}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={() => {
isInternalChange.current = true;
const html = editorRef.current?.innerHTML || "";
lastValue.current = html;
onChange(html);
}}
onKeyUp={saveSelection}
onMouseUp={saveSelection}
onBlur={saveSelection}
onPaste={e => {
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
restoreSelection();
document.execCommand("insertText", false, text);
saveSelection();
}}
style={{
minHeight, padding: compact ? "8px 10px" : "20px 24px", outline: "none",
fontSize: compact ? 12 : 13, lineHeight: 1.8, color: "var(--text)",
background: "var(--surface)",
fontFamily: "'DM Mono', 'Courier New', monospace",
}}
/>
</div>
);
}
export function CalendarPopup({ value, onChange, onClose, align = "left", showClear = true }) {
const [viewYM, setViewYM] = useState(() => {
const d = value ? new Date(value + "T00:00:00") : new Date();
return { year: d.getFullYear(), month: d.getMonth() };
});
const todayStr = new Date().toISOString().slice(0, 10);
const { year, month } = viewYM;
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startWeekday = (firstDay.getDay() + 6) % 7;
const cells = [];
for (let i = 0; i < startWeekday; i++) cells.push(null);
for (let d = 1; d <= lastDay.getDate(); d++) {
cells.push(`${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`);
}
const prevMonth = () => { const d = new Date(year, month - 1, 1); setViewYM({ year: d.getFullYear(), month: d.getMonth() }); };
const nextMonth = () => { const d = new Date(year, month + 1, 1); setViewYM({ year: d.getFullYear(), month: d.getMonth() }); };
const select = (ds) => { onChange(ds); onClose(); };
return (
<div
style={{
position: "absolute",
top: "calc(100% + 4px)",
[align === "right" ? "right" : "left"]: 0,
zIndex: 2000,
background: "#f0ede8",
border: "1px solid #e0dbd4",
borderRadius: 8,
padding: 12,
boxShadow: "0 8px 28px rgba(0,0,0,0.16)",
width: 232,
}}
onMouseDown={e => e.stopPropagation()}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
<button onMouseDown={e => { e.preventDefault(); prevMonth(); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px 8px", fontSize: 14, color: "#888", lineHeight: 1 }}>←</button>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 13, color: "#1a1a18" }}>
{firstDay.toLocaleDateString("de-CH", { month: "long", year: "numeric" })}
</div>
<button onMouseDown={e => { e.preventDefault(); nextMonth(); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px 8px", fontSize: 14, color: "#888", lineHeight: 1 }}>→</button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7,1fr)", gap: 2, marginBottom: 3 }}>
{["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"].map(d => (
<div key={d} style={{ textAlign: "center", fontSize: 9, color: "#aaa", padding: "2px 0" }}>{d}</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7,1fr)", gap: 2 }}>
{cells.map((ds, i) => {
if (!ds) return <div key={i} />;
const dow = new Date(ds + "T00:00:00").getDay();
const isWeekend = dow === 0 || dow === 6;
const isSelected = ds === value;
const isToday = ds === todayStr;
return (
<button key={ds}
onMouseDown={e => { e.preventDefault(); select(ds); }}
style={{
aspectRatio: "1", fontSize: 11, cursor: "pointer",
fontFamily: "inherit", borderRadius: 4,
border: isToday && !isSelected ? "1.5px solid #d4a85a" : "1.5px solid transparent",
background: isSelected ? "#1a1a18" : "transparent",
color: isSelected ? "#d4a85a" : isWeekend ? "#bbb" : "#1a1a18",
fontWeight: isToday && !isSelected ? 600 : 400,
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = "#e8e3dc"; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = "transparent"; }}
>
{new Date(ds + "T00:00:00").getDate()}
</button>
);
})}
</div>
{showClear && value && (
<div style={{ marginTop: 8, paddingTop: 8, borderTop: "1px solid #e0dbd4", textAlign: "center" }}>
<button onMouseDown={e => { e.preventDefault(); onChange(""); onClose(); }}
style={{ fontSize: 10, color: "#aaa", background: "none", border: "none", cursor: "pointer", fontFamily: "inherit" }}>
Datum löschen
</button>
</div>
)}
</div>
);
}
export function DatePicker({ value, onChange, style, placeholder, align = "left", ...props }) {
const [open, setOpen] = useState(false);
const wrapRef = useRef(null);
const toDE = (iso) => {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
return `${iso.slice(8, 10)}.${iso.slice(5, 7)}.${iso.slice(0, 4)}`;
};
useEffect(() => {
if (!open) return;
const close = (e) => { if (!wrapRef.current?.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
return (
<div ref={wrapRef} style={{ position: "relative", display: "inline-block", width: "100%" }}>
<input
type="text"
value={toDE(value)}
readOnly
onClick={() => setOpen(o => !o)}
placeholder={placeholder || "TT.MM.JJJJ"}
style={{ cursor: "pointer", ...style }}
{...props}
/>
{open && (
<CalendarPopup
value={value}
onChange={ds => onChange({ target: { value: ds } })}
onClose={() => setOpen(false)}
align={align}
/>
)}
</div>
);
}
export function useCalendarNav() {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const close = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
return { open, setOpen, ref };
}
+252
View File
@@ -0,0 +1,252 @@
// ─── CONSTANTS ──────────────────────────────────────────────────
export const STORAGE_KEY = "studio_data_v1";
// SIA-Phasen nach SIA 102/103 (Architektur)
export const SIA_PHASES = [
{ id: "11", label: "11 Strategische Planung" },
{ id: "21", label: "21 Vorstudien" },
{ id: "22", label: "22 Machbarkeitsstudie" },
{ id: "31", label: "31 Vorprojekt" },
{ id: "32", label: "32 Bauprojekt" },
{ id: "33", label: "33 Bewilligungsverfahren" },
{ id: "41", label: "41 Ausschreibung" },
{ id: "51", label: "51 Ausführungsprojekt" },
{ id: "52", label: "52 Ausführung" },
{ id: "53", label: "53 Inbetriebnahme / Abschluss" },
];
// SIA 102 Teilleistungen mit Standard-Prozentanteilen
export const SIA_PHASE_WEIGHTS = [
{ id: "31", label: "Vorprojekt", items: [
{ label: "Lösungsstudien / Grobschätzung", pct: 3 },
{ label: "Vorprojekt / Kostenschätzung", pct: 6 },
]},
{ id: "32", label: "Bauprojekt", items: [
{ label: "Bauprojekt", pct: 13 },
{ label: "Detailstudien", pct: 4 },
{ label: "Kostenvoranschlag", pct: 4 },
]},
{ id: "33", label: "Bewilligungsverfahren", items: [
{ label: "Bewilligungsverfahren", pct: 2.5 },
]},
{ id: "41", label: "Ausschreibung", items: [
{ label: "Ausschreibungspläne", pct: 10 },
{ label: "Ausschreibung / Offertvergleich", pct: 8 },
]},
{ id: "51", label: "Ausführungsplanung", items: [
{ label: "Ausführungspläne", pct: 15 },
{ label: "Werkverträge", pct: 1 },
]},
{ id: "52", label: "Ausführung", items: [
{ label: "Gestalterische Leitung", pct: 6 },
{ label: "Bauleitung / Kostenkontrolle", pct: 23 },
]},
{ id: "53", label: "Inbetriebnahme / Abschluss", items: [
{ label: "Inbetriebnahme", pct: 1 },
{ label: "Dokumentation", pct: 1 },
{ label: "Garantiearbeiten", pct: 1.5 },
{ label: "Schlussabrechnung", pct: 1 },
]},
];
// Projekt-Typen
export const PROJECT_TYPES = [
"Wettbewerb",
"Studienauftrag",
"Direktauftrag",
"Machbarkeitsstudie",
"Gutachten",
"Grafik",
"Sonstiges",
];
export const EXPENSE_CATEGORIES = [
"Reise / Fahrt", "Drucken / Reprografie", "Modellbau / Material",
"Büromaterial", "Weiterbildung",
"Unterauftrag / Freelancer", "Verpflegung / Geschäftsessen",
"Sonstiges",
];
export const INTERNAL_EXPENSE_CATEGORIES = [
"Miete / Raumkosten", "Software / Lizenzen", "Hardware / IT",
"Telefon / Internet", "Versicherung", "Steuern / Abgaben",
"Büromaterial", "Marketing / Werbung", "Weiterbildung",
"Unterauftrag / Freelancer", "Bankgebühren", "Sonstiges",
];
export const NAV_ITEMS = [
{ id: "dashboard", label: "Übersicht", icon: "grid_view" },
{ id: "pinnwand", label: "Pinnwand", icon: "campaign" },
{ id: "projects", label: "Projekte", icon: "work" },
{ id: "time", label: "Zeiterfassung", icon: "schedule" },
{ id: "quotes", label: "Offerten", icon: "request_quote" },
{ id: "buchhaltung", label: "Buchhaltung", icon: "account_balance", children: [
{ id: "invoices", label: "Rechnungen" },
{ id: "internal-expenses", label: "Ausgaben" },
{ id: "expenses", label: "Spesen" },
{ id: "loehne", label: "Löhne" },
{ id: "studio-budget", label: "Budget" },
]},
{ id: "dokumente", label: "Dokumente", icon: "folder", children: [
{ id: "protokolle", label: "Protokolle" },
{ id: "lieferscheine", label: "Lieferscheine" },
{ id: "letters", label: "Briefe" },
]},
{ id: "personen", label: "Personen", icon: "group" },
{ id: "mitarbeiter", label: "Mitarbeiter", icon: "badge" },
{ id: "settings", label: "Einstellungen", icon: "settings" },
];
export const STATUS_COLORS = {
aktiv: "#2d6a4f", abgeschlossen: "#555", pausiert: "#b5621e",
entwurf: "#7a6a00", gesendet: "#1a4e8a", bezahlt: "#2d6a4f", überfällig: "#8a1a1a",
offen: "#7a6a00", angenommen: "#2d6a4f", abgelehnt: "#8a1a1a", abgelaufen: "#888",
genehmigt: "#1a4e8a", "auf nächsten Lohn": "#b5621e", ausbezahlt: "#2d6a4f",
};
export const STATUS_BG = {
aktiv: "#e8f5ee", abgeschlossen: "#f0f0ee", pausiert: "#fdf0e8",
entwurf: "#fffbe6", gesendet: "#e8f0fa", bezahlt: "#e8f5ee", überfällig: "#fdf2f2",
offen: "#fffbe6", angenommen: "#e8f5ee", abgelehnt: "#fdf2f2", abgelaufen: "#f2f2f2",
genehmigt: "#e8f0fa", "auf nächsten Lohn": "#fdf0e8", ausbezahlt: "#e8f5ee",
};
export const defaultData = {
settings: {
setupCompleted: false,
name: "Mein Studio",
address: "Musterstrasse 1\n8001 Zürich",
street: "",
zip: "",
city: "",
country: "CH",
email: "mail@studio.ch",
phone: "+41 79 000 00 00",
iban: "CH00 0000 0000 0000 0000 0",
ibanType: "qr", // "qr" | "normal"
mwst: "CHE-000.000.000 MWST",
mwstRate: 8.1,
defaultHourlyRate: 120,
autoPrint: false,
logoSize: 60,
expenseCategories: [...EXPENSE_CATEGORIES],
internalExpenseCategories: [...INTERNAL_EXPENSE_CATEGORIES],
projectNumberFormat: "YYYY/NN",
invoiceNumberFormat: "YYYY-NNN",
protokollNumberFormat: "YYYY-TT-NN",
protokollTypeAbbreviations: {
"Bausitzung": "BS",
"Planungssitzung": "PS",
"Baubesprechung": "BB",
"Jour fixe": "JF",
"Interne Sitzung": "IS",
"Kundensitzung": "KS",
"Abnahme": "AB",
"Sonstiges": "SO",
},
pdfNameFormat: "{studio}_{typ}_{nummer}",
qrNewPage: true,
pageMarginTop: 20,
pageMarginBottom: 20,
pageMarginLeft: 20,
pageMarginRight: 20,
defaultWochenstunden: 35,
defaultFerienWochen: 5,
closedMonths: [],
blockMaiTag: true,
roles: [
{ id: "PL", label: "Projektleiter/in", rate: 140 },
{ id: "TS", label: "Technischer Support", rate: 120 },
{ id: "BL", label: "Bauleiter/in", rate: 135 },
{ id: "AS", label: "Administrativer Support", rate: 120 },
],
},
persons: [],
projects: [],
timeEntries: [],
invoices: [],
quotes: [],
expenses: [],
internalExpenses: [],
deliveryNotes: [],
protocols: [],
employees: [],
feiertage: [],
absences: [],
ferienEntries: [],
absenzTypes: [],
lohnEntries: [],
uberstundenAbschluss: [],
dashboardTemplates: [
{ id: "tpl-admin", name: "Administrator", isPublic: true, layout: [
{ id: "dw-a1", cols: 4, minH: 0, widgets: ["kpi-projekte","kpi-stunden","kpi-ausstehend","kpi-umsatz"] },
{ id: "dw-a2", cols: 1, minH: 0, widgets: ["warnungen"] },
{ id: "dw-a3", cols: 2, minH: 0, widgets: ["aktive-projekte","unverrechnete-stunden"] },
{ id: "dw-a4", cols: 2, minH: 0, widgets: ["umsatz-sparkline","offene-offerten"] },
{ id: "dw-a5", cols: 1, minH: 0, widgets: ["letzte-zeiteintraege"] },
]},
{ id: "tpl-projektleiter", name: "Projektleiter", isPublic: true, layout: [
{ id: "dw-p1", cols: 2, minH: 0, widgets: ["kpi-projekte","kpi-stunden"] },
{ id: "dw-p2", cols: 1, minH: 0, widgets: ["warnungen"] },
{ id: "dw-p3", cols: 3, minH: 0, widgets: ["meine-projekte","team-auslastung","offene-offerten"] },
{ id: "dw-p4", cols: 1, minH: 0, widgets: ["letzte-zeiteintraege"] },
]},
{ id: "tpl-mitarbeiter", name: "Mitarbeiter", isPublic: true, layout: [
{ id: "dw-m1", cols: 3, minH: 0, widgets: ["kpi-stunden","ueberstunden","meine-ferien"] },
{ id: "dw-m2", cols: 2, minH: 0, widgets: ["meine-projekte","stunden-woche"] },
{ id: "dw-m3", cols: 1, minH: 0, widgets: ["meine-zeiteintraege"] },
]},
],
appRoles: [
{ id: "r-admin", name: "Administrator", permissions: null, dashboardTemplateId: "tpl-admin" },
{ id: "r-projektleiter", name: "Projektleiter", permissions: ["dashboard","projects","time","quotes","personen","mitarbeiter","settings"], dashboardTemplateId: "tpl-projektleiter" },
{ id: "r-mitarbeiter", name: "Mitarbeiter", permissions: ["dashboard","projects","time","personen","settings"], dashboardTemplateId: "tpl-mitarbeiter" },
],
users: [
{ id: "admin", username: "admin", password: "admin", role: "admin", displayName: "Administrator", appRoleId: "r-admin" },
],
blogPosts: [],
letterTemplates: [
{ id: "offer", name: "Offerte", body: "Sehr geehrte/r {{client}}\n\nGerne unterbreiten wir Ihnen die Offerte für das Projekt «{{project}}».\n\n[Leistungsumfang]\n\nHonorar: CHF [Betrag]\n\nWir freuen uns auf die Zusammenarbeit.\n\nFreundliche Grüsse" },
{ id: "reminder", name: "Zahlungserinnerung", body: "Sehr geehrte/r {{client}}\n\nBei einer Überprüfung unserer Buchhaltung stellen wir fest, dass die Rechnung [Nr.] vom [Datum] über CHF [Betrag] noch nicht beglichen ist.\n\nWir bitten Sie höflich, den offenen Betrag innert 10 Tagen zu überweisen.\n\nFreundliche Grüsse" },
],
};
export const PROTOKOLL_TYPES = ["Bausitzung", "Planungssitzung", "Baubesprechung", "Jour fixe", "Interne Sitzung", "Kundensitzung", "Abnahme", "Sonstiges"];
export const PROTOKOLL_ENTRY_TYPES = [
{ id: "beschluss", label: "Beschluss", color: "#1a4e8a", bg: "#e8f0fa", icon: "⬡" },
{ id: "info", label: "Info", color: "#2d6a4f", bg: "#e8f5ee", icon: "" },
{ id: "aufgabe", label: "Aufgabe", color: "#b5621e", bg: "#fdf0e8", icon: "◎" },
];
export const DASHBOARD_WIDGETS = [
{ id: "kpi-projekte", label: "Aktive Projekte (KPI)", span: 3 },
{ id: "kpi-stunden", label: "Stunden diesen Monat (KPI)", span: 3 },
{ id: "kpi-ausstehend", label: "Ausstehend (KPI)", span: 3 },
{ id: "kpi-umsatz", label: "Jahresumsatz (KPI)", span: 3 },
{ id: "warnungen", label: "Warnungen", span: 12 },
{ id: "aktive-projekte", label: "Projektliste mit Budget", span: 4 },
{ id: "unverrechnete-stunden", label: "Unverrechnete Stunden", span: 4 },
{ id: "umsatz-sparkline", label: "Umsatz Sparkline", span: 4 },
{ id: "offene-offerten", label: "Offene Offerten", span: 4 },
{ id: "letzte-zeiteintraege", label: "Letzte Zeiteinträge", span: 12 },
{ id: "meine-zeiteintraege", label: "Meine Zeiteinträge", span: 12 },
{ id: "meine-projekte", label: "Meine Projekte", span: 6 },
{ id: "meine-ferien", label: "Ferienstand", span: 4 },
{ id: "ueberstunden", label: "Stundensaldo", span: 4 },
{ id: "stunden-woche", label: "Stunden pro Woche", span: 6 },
{ id: "team-auslastung", label: "Team-Auslastung", span: 6 },
{ id: "interner-blog", label: "Pinnwand", span: 6 },
];
export const DEFAULT_ABSENZ_TYPES = [
{ id: "krankheit", label: "Krankheit", color: "#8a1a1a" },
{ id: "unfall", label: "Unfall", color: "#b5621e" },
{ id: "intern", label: "Intern", color: "#1a4e8a" },
{ id: "informatik", label: "Informatik", color: "#555" },
{ id: "rechnungswesen", label: "Rechnungswesen", color: "#7a6a00" },
{ id: "weiterbildung", label: "Weiterbildung", color: "#2d6a4f" },
{ id: "militaer", label: "Militär / Zivildienst", color: "#3d3d38" },
];
Executable
+3
View File
@@ -0,0 +1,3 @@
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
#root { height: 100%; }
Executable
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+1686
View File
File diff suppressed because it is too large Load Diff
Executable
+540
View File
@@ -0,0 +1,540 @@
// ─── UTILS ──────────────────────────────────────────────────
import { DEFAULT_ABSENZ_TYPES } from "./constants.js";
// SIA-Honorar-Formel: p = Z1 + Z2 / ∛B
export function calcSIAHours(baukosten, schwierigkeit, phasen) {
if (!baukosten || baukosten <= 0) return { p: 0, total: 0, phases: [] };
const cbrtB = Math.cbrt(baukosten);
const p = 0.062 + 10.58 / cbrtB;
const results = phasen.map(ph => {
const items = ph.items.map(it => {
if (it.enabled === false) return { ...it, hours: 0 };
const r = it.r ?? 1;
const hours = baukosten * (p / 100) * schwierigkeit * (it.pct / 100) * r;
return { ...it, hours: Math.round(hours * 10) / 10 };
});
const phaseHours = items.reduce((s, it) => s + it.hours, 0);
return { ...ph, items, hours: phaseHours };
});
const total = results.reduce((s, ph) => s + ph.hours, 0);
return { p: Math.round(p * 1000) / 1000, cbrtB, total, phases: results };
}
// Manuelle Aufwandschätzung: Stunden pro Rolle × Stundensatz
export function calcManualHours(phases, roles) {
const results = phases.filter(ph => ph.enabled).map(ph => {
const roleDetails = (roles || []).map(r => ({
...r, hours: ph.hoursByRole?.[r.id] || 0,
}));
const totalHours = roleDetails.reduce((s, r) => s + r.hours, 0);
const totalAmount = roleDetails.reduce((s, r) => s + r.hours * r.rate, 0);
return { ...ph, roleDetails, totalHours, totalAmount };
});
return {
phases: results,
totalHours: results.reduce((s, p) => s + p.totalHours, 0),
totalAmount: results.reduce((s, p) => s + p.totalAmount, 0),
};
}
// Berechnet Budget aus linkedQuotes-Array (Migration von sourceQuoteId)
export function migrateLinkedQuotes(project) {
if (project.linkedQuotes) return project.linkedQuotes;
if (project.sourceQuoteId) return [{ quoteId: project.sourceQuoteId, role: "Hauptofferte" }];
return [];
}
export function deriveQuoteBudget(linkedQuotes, allQuotes, settingsRoles) {
const quotes = (linkedQuotes || []).map(lq => allQuotes.find(q => q.id === lq.quoteId)).filter(Boolean);
let totalHours = 0;
let totalAmount = 0;
const phaseMap = {}; // phaseId -> hours (summed)
const enabledSet = new Set(); // phases enabled in quotes even with 0 hours
quotes.forEach(q => {
const roles = q.quoteRoles || settingsRoles || [];
if (q.mode === "sia") {
const calc = calcSIAHours(q.sia?.baukosten, q.sia?.schwierigkeit, q.sia?.phases || []);
totalHours += calc.total || 0;
totalAmount += (calc.total || 0) * (q.sia?.stundenansatz || 0);
(calc.phases || []).forEach(ph => {
if (ph.hours > 0) phaseMap[ph.id] = (phaseMap[ph.id] || 0) + ph.hours;
});
} else if (q.mode === "manual") {
const calc = calcManualHours(q.manualPhases || [], roles);
totalHours += calc.totalHours || 0;
totalAmount += calc.totalAmount || 0;
(q.manualPhases || []).filter(ph => ph.enabled).forEach(ph => {
enabledSet.add(ph.id);
const h = roles.reduce((s, r) => s + (ph.hoursByRole?.[r.id] || 0), 0);
if (h > 0) phaseMap[ph.id] = (phaseMap[ph.id] || 0) + h;
});
} else if (q.mode === "free") {
totalAmount += (q.freeItems || []).reduce((s, it) => s + (it.qty * it.price), 0);
}
});
const phasesBudget = Object.entries(phaseMap).map(([id, hours]) => ({ id, hours: Math.round(hours * 10) / 10 }));
const enabledPhases = [...new Set([...phasesBudget.map(p => p.id), ...enabledSet])];
return {
budgetHours: Math.round(totalHours * 10) / 10,
budgetAmount: Math.round(totalAmount),
phasesBudget,
enabledPhases,
hasFreeQuotes: quotes.some(q => q.mode === "free"),
hasHourQuotes: quotes.some(q => q.mode === "sia" || q.mode === "manual"),
};
}
export function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}
export function formatCHF(amount) {
return new Intl.NumberFormat("de-CH", { style: "currency", currency: "CHF" }).format(amount || 0);
}
export function formatDate(dateStr) {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleDateString("de-CH");
}
// Schweizer 5-Rappen-Rundung
export function roundCHF(amount) {
return Math.round((amount || 0) * 20) / 20;
}
// Absender-Adresse aus strukturierten Feldern bauen; Fallback auf altes Freitext-Feld
export function formatSenderAddress(settings) {
const parts = [];
if (settings.street) parts.push(settings.street);
const line2 = [settings.zip, settings.city].filter(Boolean).join(" ");
if (line2) parts.push(line2);
if (parts.length > 0) return parts.join("\n");
return settings.address || "";
}
// ─── QR-BILL HELPERS ──────────────────────────────────────────
// Prüft ob IBAN eine QR-IBAN ist (IID im Bereich 30000-31999)
export function isQRIban(iban) {
const clean = (iban || "").replace(/\s/g, "").toUpperCase();
if (!clean.startsWith("CH") && !clean.startsWith("LI")) return false;
const iid = parseInt(clean.slice(4, 9));
return iid >= 30000 && iid <= 31999;
}
// IBAN-Formatierung mit Leerzeichen alle 4 Stellen
export function formatIban(iban) {
const clean = (iban || "").replace(/\s/g, "").toUpperCase();
return clean.match(/.{1,4}/g)?.join(" ") || "";
}
// Modulo-10-Rekursiv Prüfziffer (für QR-Referenz)
export function mod10(input) {
const table = [[0,9,4,6,8,2,7,1,3,5],[9,4,6,8,2,7,1,3,5,0],[4,6,8,2,7,1,3,5,0,9],[6,8,2,7,1,3,5,0,9,4],[8,2,7,1,3,5,0,9,4,6],[2,7,1,3,5,0,9,4,6,8],[7,1,3,5,0,9,4,6,8,2],[1,3,5,0,9,4,6,8,2,7],[3,5,0,9,4,6,8,2,7,1],[5,0,9,4,6,8,2,7,1,3]];
let carry = 0;
for (const ch of input) {
const digit = parseInt(ch);
if (isNaN(digit)) continue;
carry = table[carry][digit];
}
return ((10 - carry) % 10).toString();
}
// Generiert 27-stellige QR-Referenz aus Rechnungsnummer
export function generateQRReference(invoiceNumber) {
// Nur Ziffern extrahieren, links mit Nullen auf 26 Stellen auffüllen, dann Prüfziffer
const digits = (invoiceNumber || "").replace(/\D/g, "").padStart(26, "0").slice(-26);
return digits + mod10(digits);
}
// Formatiert Referenz in 5er-Blöcken von rechts
export function formatReference(ref) {
const clean = (ref || "").replace(/\s/g, "");
const reversed = clean.split("").reverse().join("");
const blocks = reversed.match(/.{1,5}/g) || [];
return blocks.map(b => b.split("").reverse().join("")).reverse().join(" ");
}
export function formatHours(minutes) {
const h = Math.floor((minutes || 0) / 60);
const m = (minutes || 0) % 60;
return `${h}h${m > 0 ? " " + m + "m" : ""}`;
}
// Formatiert Projektnummer nach konfigurierbarem Format
// Platzhalter: YYYY=2025, YY=25, NN=01..99
export function applyProjectNumberFormat(fmt, seq) {
const now = new Date();
const yyyy = String(now.getFullYear());
const yy = yyyy.slice(2);
const nn = String(seq).padStart(2, "0");
return (fmt || "YYYY/NN").replace(/YYYY/g, yyyy).replace(/YY/g, yy).replace(/NN/g, nn);
}
// Parst die laufende Nummer aus einer gespeicherten Projektnummer anhand des Formats
export function parseSeqFromNumber(num, fmt) {
if (!num || !fmt) return null;
const now = new Date();
const yyyy = String(now.getFullYear());
const yy = yyyy.slice(2);
// Escape regex special chars, then replace placeholders with capture groups
const pattern = (fmt || "YYYY/NN")
.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")
.replace(/YYYY/g, yyyy)
.replace(/YY/g, yy)
.replace(/NN/g, "(\\d+)");
const match = num.match(new RegExp("^" + pattern + "$"));
return match ? parseInt(match[1]) : null;
}
export function exportBuchhaltungCSV(data, year = "") {
const sep = ";";
const q = (s) => `"${String(s || "").replace(/"/g, '""')}"`;
const mwstRate = data.settings.mwstRate || 8.1;
const rows = [];
// Einnahmen
rows.push(["EINNAHMEN", "", "", "", "", "", "", ""].map(q).join(sep));
rows.push(["Datum", "Rechnungs-Nr.", "Kunde", "Beschreibung", "Netto CHF", "MWST-Satz %", "MWST CHF", "Brutto CHF"].map(q).join(sep));
const paidInvoices = [...data.invoices].filter(i => !year || (i.date || "").startsWith(year)).sort((a, b) => (a.date || "").localeCompare(b.date || ""));
paidInvoices.forEach(inv => {
const client = (data.persons||[]).find(c => c.id === inv.clientId);
const desc = (inv.items || []).map(it => it.desc).filter(Boolean).join(", ");
const taxRate = inv.mwst ? mwstRate : 0;
rows.push([inv.date, inv.number, client?.name || "", desc, (inv.sub || 0).toFixed(2), taxRate, (inv.tax || 0).toFixed(2), (inv.total || 0).toFixed(2)].map(q).join(sep));
});
const totalNet = paidInvoices.reduce((s, i) => s + (i.sub || 0), 0);
const totalTax = paidInvoices.reduce((s, i) => s + (i.tax || 0), 0);
const totalGross = paidInvoices.reduce((s, i) => s + (i.total || 0), 0);
rows.push(["", "", "", "TOTAL", totalNet.toFixed(2), "", totalTax.toFixed(2), totalGross.toFixed(2)].map(q).join(sep));
rows.push([""].join(sep));
// Ausgaben
rows.push(["SPESEN (MITARBEITERBEZOGEN)", "", "", "", "", "", "", ""].map(q).join(sep));
rows.push(["Datum", "Kategorie", "Projekt", "Beschreibung", "Netto CHF", "MWST-Satz %", "MWST CHF", "Brutto CHF"].map(q).join(sep));
const sortedExp = [...(data.expenses || [])].filter(e => !year || (e.date || "").startsWith(year)).sort((a, b) => (a.date || "").localeCompare(b.date || ""));
sortedExp.forEach(exp => {
const proj = data.projects.find(p => p.id === exp.projectId);
const net = exp.inclMwst ? (exp.amount / (1 + (exp.mwstRate || 0) / 100)) : exp.amount;
const taxAmt = exp.amount - net;
rows.push([exp.date, exp.category, proj?.name || "", exp.description, net.toFixed(2), exp.mwstRate || 0, taxAmt.toFixed(2), exp.amount.toFixed(2)].map(q).join(sep));
});
const expTotal = sortedExp.reduce((s, e) => s + (e.amount || 0), 0);
const expTax = sortedExp.reduce((s, e) => { const net = e.inclMwst ? (e.amount / (1 + (e.mwstRate || 0) / 100)) : e.amount; return s + (e.amount - net); }, 0);
rows.push(["", "", "", "TOTAL", (expTotal - expTax).toFixed(2), "", expTax.toFixed(2), expTotal.toFixed(2)].map(q).join(sep));
rows.push([""].join(sep));
// Interne Ausgaben
rows.push(["INTERNE AUSGABEN", "", "", "", "", "", "", ""].map(q).join(sep));
rows.push(["Datum", "Kategorie", "Beschreibung", "Wiederkehrend", "Netto CHF", "MWST-Satz %", "MWST CHF", "Brutto CHF"].map(q).join(sep));
const sortedIntExp = [...(data.internalExpenses || [])].filter(e => !year || (e.date || "").startsWith(year)).sort((a, b) => (a.date || "").localeCompare(b.date || ""));
sortedIntExp.forEach(exp => {
const net = exp.inclMwst ? (exp.amount / (1 + (exp.mwstRate || 0) / 100)) : exp.amount;
const taxAmt = exp.amount - net;
rows.push([exp.date, exp.category, exp.description, exp.recurring ? (exp.recurringInterval || "monatlich") : "", net.toFixed(2), exp.mwstRate || 0, taxAmt.toFixed(2), exp.amount.toFixed(2)].map(q).join(sep));
});
const intExpTotal = sortedIntExp.reduce((s, e) => s + (e.amount || 0), 0);
const intExpTax = sortedIntExp.reduce((s, e) => { const net = e.inclMwst ? (e.amount / (1 + (e.mwstRate || 0) / 100)) : e.amount; return s + (e.amount - net); }, 0);
rows.push(["", "", "", "TOTAL", (intExpTotal - intExpTax).toFixed(2), "", intExpTax.toFixed(2), intExpTotal.toFixed(2)].map(q).join(sep));
rows.push([""].join(sep));
// Personalaufwand (Löhne)
const sortedLoehne = [...(data.lohnEntries || [])].filter(l => !year || l.monat.startsWith(year)).sort((a, b) => a.monat.localeCompare(b.monat));
if (sortedLoehne.length > 0) {
rows.push(["PERSONALAUFWAND / LÖHNE", "", "", "", "", "", "", "", ""].map(q).join(sep));
rows.push(["Monat", "Mitarbeiter", "Brutto", "Abzüge AN", "Netto", "Spesen", "Auszahlung AN", "PK AG-Anteil", "Gesamtlohnkosten"].map(q).join(sep));
sortedLoehne.forEach(l => {
const name = l.empSnapshot?.name || "";
const bvgAG = l.bvgAG || 0;
const gesamt = (l.auszahlung || 0) + bvgAG;
rows.push([l.monat, name, (l.bruttoTotal || 0).toFixed(2), (l.totalAbzuege || 0).toFixed(2), (l.netto || 0).toFixed(2), (l.spesenTotal || 0).toFixed(2), (l.auszahlung || 0).toFixed(2), bvgAG.toFixed(2), gesamt.toFixed(2)].map(q).join(sep));
});
const lohnTotal = sortedLoehne.reduce((s, l) => s + (l.auszahlung || 0) + (l.bvgAG || 0), 0);
rows.push(["", "", "", "", "", "", "", "TOTAL", lohnTotal.toFixed(2)].map(q).join(sep));
rows.push([""].join(sep));
}
// Zusammenfassung
const lohnTotalAll = sortedLoehne.reduce((s, l) => s + (l.auszahlung || 0) + (l.bvgAG || 0), 0);
rows.push(["ZUSAMMENFASSUNG", "", "", "", "", "", "", ""].map(q).join(sep));
rows.push(["", "", "", "Einnahmen (Netto)", totalNet.toFixed(2), "", "", ""].map(q).join(sep));
rows.push(["", "", "", "Spesen (Netto)", (expTotal - expTax).toFixed(2), "", "", ""].map(q).join(sep));
rows.push(["", "", "", "Interne Ausgaben (Netto)", (intExpTotal - intExpTax).toFixed(2), "", "", ""].map(q).join(sep));
rows.push(["", "", "", "Personalaufwand (Löhne)", lohnTotalAll.toFixed(2), "", "", ""].map(q).join(sep));
rows.push(["", "", "", "Ergebnis (Netto)", (totalNet - (expTotal - expTax) - (intExpTotal - intExpTax) - lohnTotalAll).toFixed(2), "", "", ""].map(q).join(sep));
rows.push(["", "", "", "MWST auf Einnahmen", "", "", totalTax.toFixed(2), ""].map(q).join(sep));
rows.push(["", "", "", "Vorsteuer (Spesen + Ausgaben)", "", "", (expTax + intExpTax).toFixed(2), ""].map(q).join(sep));
rows.push(["", "", "", "MWST-Schuld", "", "", (totalTax - expTax - intExpTax).toFixed(2), ""].map(q).join(sep));
const bom = "";
const blob = new Blob([bom + rows.join("\n")], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `buchhaltung-${year || "gesamt"}.csv`;
a.click();
URL.revokeObjectURL(url);
}
export function buildReminderLetter(inv, nr, sentDate, clients, settings) {
const client = clients.find(c => c.id === inv.clientId);
const existingReminders = inv.reminders || [];
const daysPast = Math.floor((new Date() - new Date(inv.dueDate)) / 86400000);
const intro = nr === 1
? `Bei einer Überprüfung unserer Buchhaltung stellen wir fest, dass die Rechnung Nr. ${inv.number} vom ${formatDate(inv.date)} über ${formatCHF(inv.total)} seit ${daysPast} Tagen (Fälligkeit: ${formatDate(inv.dueDate)}) noch nicht beglichen ist.\n\nWir bitten Sie höflich, den offenen Betrag innert 10 Tagen zu überweisen.`
: nr === 2
? `Leider mussten wir feststellen, dass unsere Zahlungserinnerung vom ${formatDate(existingReminders[0]?.sentDate || existingReminders[0]?.date)} bezüglich Rechnung Nr. ${inv.number} über ${formatCHF(inv.total)} bisher ohne Reaktion geblieben ist.\n\nWir fordern Sie hiermit auf, den ausstehenden Betrag innert 7 Tagen zu begleichen.`
: `Trotz unserer Zahlungserinnerungen vom ${existingReminders.map(r => formatDate(r.sentDate || r.date)).join(" und ")} ist die Rechnung Nr. ${inv.number} über ${formatCHF(inv.total)} nach wie vor unbeglichen.\n\nWir sehen uns gezwungen, bei weiterem Ausbleiben der Zahlung innerhalb von 5 Tagen rechtliche Schritte einzuleiten.`;
const body = `Sehr geehrte/r ${client?.name || "[Kunde]"}\n\n${intro}\n\nIBAN: ${settings.iban}\nReferenz: ${inv.number}\n\nFreundliche Grüsse\n${settings.name}`;
const subject = nr === 1 ? `Zahlungserinnerung Rechnung Nr. ${inv.number}` : `${nr}. Mahnung Rechnung Nr. ${inv.number}`;
return { client, subject, body };
}
export function getKW(dateStr) {
if (!dateStr) return "—";
const d = new Date(dateStr);
const jan4 = new Date(d.getFullYear(), 0, 4);
const startOfWeek = new Date(jan4);
startOfWeek.setDate(jan4.getDate() - ((jan4.getDay() + 6) % 7));
const kw = Math.ceil(((d - startOfWeek) / 86400000 + 1) / 7);
return `KW ${kw}`;
}
export function linkedClientForNote(n, data) {
if (n.clientId) return (data.persons||[]).find(c => c.id === n.clientId) || null;
return n.clientManual ? { name: n.clientManual, address: n.deliveryAddress || "" } : null;
}
export function getWeekNumber(date) {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
const week1 = new Date(d.getFullYear(), 0, 4);
return Math.round(((d - week1) / 86400000 + ((week1.getDay() + 6) % 7) - 2) / 7) + 1;
}
export function formatKW(dateStr) {
if (!dateStr) return "—";
return `KW ${getWeekNumber(dateStr)} / ${new Date(dateStr).getFullYear()}`;
}
// Generiert nächste Protokollnummer (legacy, nicht mehr direkt genutzt)
export function nextProtoNumber(protocols, projectNumber) {
const prefix = projectNumber ? `${projectNumber}-P` : "P";
const existing = (protocols || []).map(p => {
const m = (p.nummer || p.number || "").match(/(\d+)$/);
return m ? parseInt(m[1]) : 0;
});
const max = existing.length ? Math.max(...existing) : 0;
return `${prefix}${String(max + 1).padStart(2, "0")}`;
}
// Nächste Sequenznummer aus bestehenden Protokollnummern ableiten
// Ignoriert Jahr-artige Zahlen (4-stellig, 20002099)
export function nextProtoSeq(protocols) {
let max = 0;
(protocols || []).forEach(p => {
const groups = (p.nummer || "").match(/\d+/g) || [];
groups.forEach(n => {
const num = parseInt(n);
if (n.length <= 3 || !(num >= 2000 && num <= 2099)) {
if (num > max) max = num;
}
});
});
return max + 1;
}
// Wendet konfigurierbares Protokollnummer-Format an
// Platzhalter: YYYY YY MM DD PP(Projektnr.) TT(Typkürzel) NN/NNN(Sequenz)
export function applyProtoNumberFormat(fmt, { date, projectNumber, seq, typKuerzel }) {
const d = date ? new Date(date + "T12:00:00") : new Date();
const yyyy = String(d.getFullYear());
const yy = yyyy.slice(2);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
// Normalize separators in project number, then strip a leading year segment
// so PP can be combined with YYYY/YY without producing a double year.
const ppNorm = (projectNumber || "")
.replace(/[\/\s.]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
const pp4 = ppNorm.replace(/^\d{4}-/, "");
const pp2 = ppNorm.replace(/^\d{2}-/, "");
const ppStripped = pp4 !== ppNorm ? pp4 : pp2 !== ppNorm ? pp2 : ppNorm;
const pp = ppStripped || ppNorm || "P";
const tt = typKuerzel || "SO";
const tokens = {
YYYY: yyyy, YY: yy, MM: mm, DD: dd,
PPP: ppNorm || "P", PP: pp, TT: tt,
NNN: String(seq).padStart(3, "0"),
NN: String(seq).padStart(2, "0"),
};
return (fmt || "PP-TT-NN").replace(/YYYY|YY|NNN|NN|MM|DD|PPP|PP|TT/g, m => tokens[m] ?? m);
}
// Convert plain text (legacy templates) to HTML
export function textToHtml(text) {
if (!text) return "";
if (text.trim().startsWith("<")) return text; // already HTML
return text
.split("\n\n")
.map(para => `<p>${para.replace(/\n/g, "<br>")}</p>`)
.join("");
}
// Convert HTML back to plain text for print (fallback)
export function htmlToText(html) {
const div = document.createElement("div");
div.innerHTML = html;
return div.innerText || div.textContent || "";
}
export function getFeiertageForYear(feiertage, year) {
return (feiertage || []).filter(f => {
if (f.repeatsYearly) return true;
return f.date.startsWith(String(year));
}).map(f => {
if (f.repeatsYearly) return { ...f, date: `${year}-${f.date.slice(5, 10)}` };
return f;
});
}
export function getAbsenzTypes(data) {
const custom = data.absenzTypes || [];
const overrideMap = new Map(custom.map(t => [t.id, t]));
const defaultIds = new Set(DEFAULT_ABSENZ_TYPES.map(t => t.id));
const defaults = DEFAULT_ABSENZ_TYPES
.map(t => overrideMap.get(t.id) || t)
.filter(t => !overrideMap.get(t.id)?.deleted);
const newCustom = custom.filter(t => !defaultIds.has(t.id) && !t.deleted);
return [...defaults, ...newCustom];
}
export function getWorkdaysInMonth(year, month, feiertage) {
// month: 0-based; use ISO string to avoid local-midnight timezone offset
const days = [];
const monthStr = `${year}-${String(month + 1).padStart(2, "0")}`;
const d = new Date(`${monthStr}-01`);
while (d.toISOString().slice(0, 7) === monthStr) {
const ds = d.toISOString().slice(0, 10);
const dow = d.getDay();
if (dow !== 0 && dow !== 6) {
const ft = (feiertage || []).find(f => f.date === ds);
days.push({ date: ds, feiertag: ft || null });
}
d.setDate(d.getDate() + 1);
}
return days;
}
export function getSollStunden(employee, date, feiertage) {
// Returns Soll-Stunden for a given workday
const pensum = (employee.pensum || 100) / 100;
const wochenstunden = (employee.wochenstunden || 35);
const tagessoll = (wochenstunden * pensum) / 5;
const ft = (feiertage || []).find(f => f.date === date);
if (ft) return tagessoll + (ft.stundenDelta || 0); // e.g. -1 for Halbfeiertag, 0 for full
return tagessoll;
}
// ─── LOHNBERECHNUNG HELPER ──────────────────────────────────────
export function calcLohn(emp, monat, spesen, bonus) {
const pensumFactor = (emp.pensum || 100) / 100;
const bruttoBase = emp.monatslohn || 0;
const brutto = Math.round(bruttoBase * pensumFactor * 100) / 100;
// 13. Monatslohn: 1/12 pro Monat
const dreizehnter = emp.dreizehnterLohn ? Math.round(brutto / 12 * 100) / 100 : 0;
const bonusBetrag = Math.round((bonus || 0) * 100) / 100;
const bruttoTotal = brutto + dreizehnter + bonusBetrag; // Bonus ist AHV-pflichtig
const ahv = Math.round(bruttoTotal * ((emp.ahvSatz ?? 5.3) / 100) * 100) / 100;
const alv = Math.round(bruttoTotal * ((emp.alvSatz ?? 1.1) / 100) * 100) / 100;
const bvg = Math.round(bruttoTotal * ((emp.bvgSatz ?? 8.0) / 100) * 100) / 100;
const nbu = Math.round(bruttoTotal * ((emp.nbuSatz ?? 1.5) / 100) * 100) / 100;
const ktg = Math.round(bruttoTotal * ((emp.ktgSatz ?? 0.5) / 100) * 100) / 100;
const qst = emp.quellensteuerPflichtig
? Math.round(bruttoTotal * ((emp.quellensteuerSatz ?? 10) / 100) * 100) / 100 : 0;
const bvgAG = Math.round(bruttoTotal * ((emp.pkAGSatz ?? 8.0) / 100) * 100) / 100;
const totalAbzuege = ahv + alv + bvg + nbu + ktg + qst;
const netto = Math.round((bruttoTotal - totalAbzuege) * 100) / 100;
const spesenTotal = Math.round((spesen || []).reduce((s, e) => s + (e.amount || 0), 0) * 100) / 100;
const auszahlung = Math.round((netto + spesenTotal) * 100) / 100;
return { brutto, bruttoBase, dreizehnter, bonusBetrag, bruttoTotal, ahv, alv, bvg, nbu, ktg, qst, totalAbzuege, netto, spesenTotal, auszahlung, bvgAG };
}
// ─── Dashboard layout helpers ──────────────────────────────────────────────────
const KPI_IDS = ["kpi-projekte","kpi-stunden","kpi-ausstehend","kpi-umsatz"];
const MID_IDS = ["aktive-projekte","unverrechnete-stunden","umsatz-sparkline","offene-offerten"];
const FULL_IDS = ["warnungen","letzte-zeiteintraege","meine-zeiteintraege"];
export function widgetsToRows(widgetIds) {
const uid = () => Math.random().toString(36).slice(2, 8);
const kpi = widgetIds.filter(w => KPI_IDS.includes(w));
const mid = widgetIds.filter(w => MID_IDS.includes(w));
const full = widgetIds.filter(w => FULL_IDS.includes(w));
const rows = [];
if (kpi.length) rows.push({ id: uid(), cols: Math.min(4, Math.max(2, kpi.length)), minH: 0, widgets: kpi });
for (let i = 0; i < mid.length; i += 3) {
const chunk = mid.slice(i, i + 3);
rows.push({ id: uid(), cols: Math.max(2, chunk.length), minH: 0, widgets: chunk });
}
full.forEach(w => rows.push({ id: uid(), cols: 1, minH: 0, widgets: [w] }));
return rows;
}
export function migrateDashboardLayout(val) {
if (!val || !Array.isArray(val) || val.length === 0) return null;
if (typeof val[0] === "object" && "widgets" in val[0]) return val;
return widgetsToRows(val);
}
// PDF-Dateiname aus Format und Content ableiten
export function buildPdfName(format, content, settings) {
const studio = (settings?.name || "RAPPORT").replace(/\s+/g, "-");
const typeLabels = {
"invoice": "Rechnung", "invoice+qr": "Rechnung", "qrbill": "QR-Rechnung",
"quote": "Offerte", "lieferschein": "Lieferschein", "protokoll": "Protokoll",
"lohn": "Lohnabrechnung", "letter": "Brief", "projectDetail": "Projekt",
"projectsOverview": "Projekte", "buchhaltung": "Buchhaltung", "studioBudget": "Budget",
};
const typ = typeLabels[content?.type] || content?.type || "Dokument";
let nummer = "";
let kunde = "";
let datum = new Date().toISOString().slice(0, 10);
if (content?.inv) {
nummer = content.inv.number || "";
datum = content.inv.date || datum;
kunde = content.client?.company || content.client?.name || "";
} else if (content?.quote) {
nummer = content.quote.number || "";
datum = content.quote.date || datum;
kunde = content.client?.company || content.client?.name || "";
} else if (content?.note) {
nummer = content.note.number || "";
kunde = content.client?.company || content.client?.name || "";
} else if (content?.protokoll) {
datum = content.protokoll.date || datum;
nummer = content.protokoll.number || "";
} else if (content?.emp) {
kunde = content.emp.name || "";
nummer = content.monatLabel || "";
} else if (content?.filterYear) {
nummer = String(content.filterYear);
}
const sanitize = (s) => (s || "").replace(/[^a-zA-Z0-9äöüÄÖÜ_\-\.]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
const fmt = format || "{studio}_{typ}_{nummer}";
const result = fmt
.replace("{studio}", sanitize(studio))
.replace("{typ}", sanitize(typ))
.replace("{nummer}", sanitize(nummer))
.replace("{kunde}", sanitize(kunde))
.replace("{datum}", sanitize(datum));
return result.replace(/-+/g, "-").replace(/^-|-$/g, "") || "RAPPORT";
}
+374
View File
@@ -0,0 +1,374 @@
import React, { useState } from "react";
import { formatCHF, formatDate, exportBuchhaltungCSV } from "../utils.js";
import { Header, StatusBadge } from "../components/UI.jsx";
import { MahnModal } from "./Protokolle.jsx";
import { ReceiptViewer } from "./Spesen.jsx";
export default
function Buchhaltung({ data, update, setView, setPrintContent }) {
const mwstRate = data.settings.mwstRate || 8.1;
const currentYear = new Date().getFullYear().toString();
const [filterYear, setFilterYear] = useState(currentYear);
const availableYears = Array.from(new Set([
...data.invoices.map(i => (i.date || "").slice(0, 4)),
...(data.expenses || []).map(e => (e.date || "").slice(0, 4)),
].filter(Boolean))).sort().reverse();
if (!availableYears.includes(currentYear)) availableYears.unshift(currentYear);
// Gefilterte Daten
const invoices = data.invoices.filter(i => !filterYear || (i.date || "").startsWith(filterYear));
const expenses = (data.expenses || []).filter(e => !filterYear || (e.date || "").startsWith(filterYear));
const lohnEntries = (data.lohnEntries || []).filter(l => !filterYear || l.monat.startsWith(filterYear));
// Einnahmen
const totalInvoiced = invoices.reduce((s, i) => s + (i.sub || 0), 0);
const totalTax = invoices.reduce((s, i) => s + (i.tax || 0), 0);
// Akonto-MwSt ist erst bei Schlussrechnung steuerrelevant — separat ausweisen
const akontoInvoices = invoices.filter(i => i.invoiceKind === "akonto");
const akontoTax = akontoInvoices.reduce((s, i) => s + (i.tax || 0), 0);
const akontoSub = akontoInvoices.reduce((s, i) => s + (i.sub || 0), 0);
const taxWithoutAkonto = totalTax - akontoTax;
const totalPaid = invoices.filter(i => i.status === "bezahlt").reduce((s, i) => s + (i.total || 0), 0);
const totalOpen = invoices.filter(i => i.status === "gesendet" || i.status === "entwurf").reduce((s, i) => s + (i.total || 0), 0);
const totalOverdue = invoices.filter(i => i.status === "überfällig").reduce((s, i) => s + (i.total || 0), 0);
const totalDraftSub = invoices.filter(i => i.status === "entwurf").reduce((s, i) => s + (i.sub || 0), 0);
const totalBilledSub = totalInvoiced - totalDraftSub;
// Ausgaben Spesen
const totalExpBrutto = expenses.reduce((s, e) => s + (e.amount || 0), 0);
const totalExpNet = expenses.reduce((s, e) => { const net = e.inclMwst ? e.amount / (1 + (e.mwstRate || 0) / 100) : e.amount; return s + net; }, 0);
const totalExpTax = totalExpBrutto - totalExpNet;
// Interne Ausgaben
const internalExpenses = (data.internalExpenses || []).filter(e => !filterYear || (e.date || "").startsWith(filterYear));
const totalIntExpBrutto = internalExpenses.reduce((s, e) => s + (e.amount || 0), 0);
const totalIntExpNet = internalExpenses.reduce((s, e) => { const net = e.inclMwst ? e.amount / (1 + (e.mwstRate || 0) / 100) : e.amount; return s + net; }, 0);
const totalIntExpTax = totalIntExpBrutto - totalIntExpNet;
// Personalaufwand (abgeschlossene Lohnabrechnungen — Auszahlung an MA + AG-Abgaben)
const totalLoehne = lohnEntries.reduce((s, l) => s + (l.auszahlung || 0), 0);
const totalLoehneAGAbgaben = lohnEntries.reduce((s, l) => s + (l.bvgAG || 0), 0);
const totalLoehneGesamt = totalLoehne + totalLoehneAGAbgaben;
const totalLoehneAnzahl = lohnEntries.length;
const totalAusgaben = totalExpNet + totalIntExpNet + totalLoehneGesamt;
const result = totalInvoiced - totalAusgaben;
// Überfällige Rechnungen für Mahnwesen
const today = new Date().toISOString().slice(0, 10);
const [mahnModal, setMahnModal] = useState(null);
const [mahnMode, setMahnMode] = useState("new");
const [mahnSentDate, setMahnSentDate] = useState(new Date().toISOString().slice(0, 10));
const [receiptView, setReceiptView] = useState(null);
const overdueInvoices = data.invoices.filter(i =>
(i.status === "gesendet" || i.status === "überfällig") && i.dueDate && i.dueDate < today
).sort((a, b) => (a.dueDate || "").localeCompare(b.dueDate || ""));
const sendReminder = (inv) => {
const reminders = inv.reminders || [];
setMahnMode(reminders.length === 0 ? "new" : "reprint");
setMahnSentDate(new Date().toISOString().slice(0, 10));
setMahnModal({ inv });
};
return (
<div>
<Header title="Buchhaltung" action={
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<select className="pill" value={filterYear} onChange={e => setFilterYear(e.target.value)}>
<option value="">Alle Jahre</option>
{availableYears.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<button className="btn btn-ghost" onClick={() => exportBuchhaltungCSV(data, filterYear)}> CSV</button>
<button className="btn btn-ghost" onClick={() => setPrintContent({ type: "buchhaltung", data, filterYear, settings: data.settings })}> PDF</button>
</div>
} />
{/* KPI-Karten */}
<div className="responsive-grid-4" style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 28 }}>
{[
{ label: "UMSATZ NETTO", value: formatCHF(totalInvoiced), sub: totalDraftSub > 0 ? `${invoices.length} Rechnungen — davon ${formatCHF(totalDraftSub)} erwartet` : `${invoices.length} Rechnungen`, color: "#2d6a4f" },
{ label: "DAVON BEZAHLT", value: formatCHF(totalPaid), sub: "eingegangen", color: "#2d6a4f" },
{ label: "OFFEN / ÜBERFÄLLIG", value: formatCHF(totalOpen + totalOverdue), sub: `${overdueInvoices.length} überfällig`, color: totalOverdue > 0 ? "#8a1a1a" : "#7a6a00" },
{ label: "AUSGABEN TOTAL", value: formatCHF(totalAusgaben), sub: `Spesen + ${totalLoehneAnzahl} Lohnabrechnungen (inkl. AG-Abgaben)`, color: "#555" },
].map(c => (
<div key={c.label} className="card" style={{ borderTop: `3px solid ${c.color}` }}>
<div style={{ fontSize: 10, color: "#888", letterSpacing: "0.12em", marginBottom: 8 }}>{c.label}</div>
<div style={{ fontSize: 22, fontFamily: "'Playfair Display', serif", fontWeight: 700, color: c.color }}>{c.value}</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 4 }}>{c.sub}</div>
</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, marginBottom: 28 }}>
{/* Ergebnis-Übersicht */}
<div className="card">
<div className="section-label" style={{ marginBottom: 16 }}>JAHRESERGEBNIS {filterYear || "GESAMT"}</div>
{[
{ label: "Einnahmen (Netto)", value: totalInvoiced, bold: false },
totalDraftSub > 0 && { label: "→ Davon fakturiert", value: totalBilledSub, note: true },
totalDraftSub > 0 && { label: "→ Davon erwartet (Entwürfe)", value: totalDraftSub, note: true, expected: true },
{ label: "Spesen (Netto)", value: -totalExpNet, bold: false },
{ label: "Interne Ausgaben (Netto)", value: -totalIntExpNet, bold: false },
{ label: "Personalaufwand (Löhne)", value: -totalLoehne, bold: false },
totalLoehneAGAbgaben > 0 && { label: "→ AG-Sozialabgaben (PK/BVG)", value: -totalLoehneAGAbgaben, note: true },
{ label: "Ergebnis vor MWST", value: result, bold: true, sep: true },
{ label: `MWST auf Einnahmen (${mwstRate}%, excl. Akonto)`, value: taxWithoutAkonto, bold: false, small: true },
akontoTax > 0 && { label: `↳ Akonto-MWST ausstehend (bei Schlussrechn.)`, value: akontoTax, bold: false, small: true, pending: true },
{ label: "Vorsteuer (Spesen + Ausgaben)", value: -(totalExpTax + totalIntExpTax), bold: false, small: true },
{ label: "MWST-Schuld (excl. Akonto)", value: taxWithoutAkonto - totalExpTax - totalIntExpTax, bold: true, small: true },
].filter(Boolean).map((row, i) => (
<div key={i} style={{
display: "flex", justifyContent: "space-between",
padding: row.sep ? "10px 0 4px" : row.note ? "2px 0 2px 12px" : "4px 0",
borderTop: row.sep ? "1.5px solid #1a1a18" : "none",
marginTop: row.sep ? 8 : 0,
}}>
<span style={{ fontSize: row.small ? 11 : row.note ? 11 : 13, color: row.expected ? "#b5621e" : row.pending ? "#b07848" : row.small || row.note ? "#888" : "#555", fontStyle: row.note ? "italic" : "normal" }}>
{row.label}
{row.expected && <span style={{ fontSize: 10, marginLeft: 4, background: "#fdf0e8", color: "#b5621e", padding: "1px 8px", borderRadius: 20, fontStyle: "normal", fontWeight: 600 }}>noch nicht fakturiert</span>}
{row.pending && <span style={{ fontSize: 10, marginLeft: 4, background: "#fff8ed", color: "#b07848", padding: "1px 8px", borderRadius: 20, fontStyle: "normal", fontWeight: 600 }}>ausstehend</span>}
</span>
<span style={{ fontSize: row.small || row.note ? 11 : 13, fontWeight: row.bold ? 700 : 400, color: row.expected ? "#b5621e" : row.pending ? "#b07848" : row.value < 0 ? "#8a1a1a" : row.bold ? "#1a1a18" : "#888", fontFamily: row.bold ? "'Playfair Display', serif" : "inherit" }}>
{formatCHF(row.value)}
</span>
</div>
))}
</div>
{/* Monatsumsatz */}
<div className="card">
<div className="section-label" style={{ marginBottom: 4 }}>MONATSUMSATZ {filterYear || new Date().getFullYear()}</div>
{(() => {
const year = filterYear || String(new Date().getFullYear());
const months = ["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"];
const monthData = months.map((label, i) => {
const key = `${year}-${String(i + 1).padStart(2, "0")}`;
const invs = data.invoices.filter(inv => (inv.date || "").startsWith(key));
const paid = invs.filter(inv => inv.status === "bezahlt").reduce((s, inv) => s + (inv.sub || 0), 0);
const open = invs.filter(inv => inv.status === "gesendet" || inv.status === "überfällig").reduce((s, inv) => s + (inv.sub || 0), 0);
const draft = invs.filter(inv => inv.status === "entwurf").reduce((s, inv) => s + (inv.sub || 0), 0);
return { label, key, paid, open, draft, total: paid + open + draft };
});
const maxVal = Math.max(...monthData.map(m => m.total), 1);
const yearTotal = monthData.reduce((s, m) => s + m.total, 0);
const currentMonth = new Date().toISOString().slice(0, 7);
return (
<>
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 16 }}>
Total {formatCHF(yearTotal)} · {data.invoices.filter(i => (i.date||"").startsWith(year)).length} Rechnungen
</div>
<div style={{ display: "flex", alignItems: "flex-end", gap: 4, height: 80, marginBottom: 8 }}>
{monthData.map(m => (
<div key={m.key} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 0 }}>
<div style={{ width: "100%", display: "flex", flexDirection: "column", justifyContent: "flex-end", height: 72 }}>
{m.total === 0 ? (
<div style={{ height: 2, background: "#ece8e2", borderRadius: 1 }} />
) : (
<>
{m.draft > 0 && <div style={{ height: `${(m.draft / maxVal) * 70}px`, background: "#ccc", borderRadius: "2px 2px 0 0", minHeight: 2 }} title={`Entwurf: ${formatCHF(m.draft)}`} />}
{m.open > 0 && <div style={{ height: `${(m.open / maxVal) * 70}px`, background: "#b07848", minHeight: 2 }} title={`Ausstehend: ${formatCHF(m.open)}`} />}
{m.paid > 0 && <div style={{ height: `${(m.paid / maxVal) * 70}px`, background: "#2d6a4f", borderRadius: m.draft === 0 && m.open === 0 ? "2px 2px 0 0" : 0, minHeight: 2 }} title={`Bezahlt: ${formatCHF(m.paid)}`} />}
</>
)}
</div>
</div>
))}
</div>
<div style={{ display: "flex", gap: 4 }}>
{monthData.map(m => (
<div key={m.key} style={{
flex: 1, textAlign: "center", fontSize: 9, color: m.key === currentMonth ? "#1a1a18" : "#aaa",
fontWeight: m.key === currentMonth ? 700 : 400,
}}>{m.label}</div>
))}
</div>
<div style={{ display: "flex", gap: 14, marginTop: 14, paddingTop: 12, borderTop: "1px solid #ece8e2" }}>
{[
{ color: "#2d6a4f", label: "Bezahlt" },
{ color: "#b07848", label: "Ausstehend" },
{ color: "#ccc", label: "Entwurf" },
].map(l => (
<div key={l.label} style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 10, color: "#888" }}>
<div style={{ width: 10, height: 10, background: l.color, borderRadius: 2 }} />
{l.label}
</div>
))}
</div>
</>
);
})()}
</div>
</div>
{/* Mahnwesen */}
<div className="card" style={{ marginBottom: 28 }}>
<div className="section-label" style={{ marginBottom: overdueInvoices.length ? 16 : 0 }}>
MAHNWESEN ÜBERFÄLLIGE RECHNUNGEN
{overdueInvoices.length === 0 && <span style={{ marginLeft: 12, color: "#2d6a4f", fontWeight: 400 }}> Keine überfälligen Rechnungen</span>}
</div>
{overdueInvoices.length > 0 && (
<table>
<thead><tr>
<th>Nr.</th><th>Kunde</th><th>Fällig seit</th><th>Mahnungen</th><th style={{ textAlign: "right" }}>Betrag</th><th></th>
</tr></thead>
<tbody>
{overdueInvoices.map(inv => {
const client = ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === inv.clientId);
const daysPast = Math.floor((new Date() - new Date(inv.dueDate)) / 86400000);
const reminders = inv.reminders || [];
const nextNr = reminders.length + 1;
const mahnLabel = nextNr === 1 ? "Zahlungserinnerung" : `${nextNr}. Mahnung`;
const mahnColor = nextNr >= 3 ? "#8a1a1a" : nextNr === 2 ? "#b5621e" : "#7a6a00";
return (
<tr key={inv.id}>
<td><strong>{inv.number}</strong></td>
<td>{client?.name || "—"}</td>
<td>
<span style={{ color: daysPast > 30 ? "#8a1a1a" : "#b5621e", fontWeight: 500 }}>
{formatDate(inv.dueDate)} ({daysPast} Tage)
</span>
</td>
<td>
{reminders.length === 0 ? (
<span style={{ fontSize: 11, color: "#aaa" }}>Keine</span>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
{reminders.map((r, i) => (
<span key={i} style={{ fontSize: 11, color: i === reminders.length - 1 ? "#b5621e" : "#aaa" }}>
{i === 0 ? "Erinnerung" : `${i + 1}. Mahnung`} · {formatDate(r.date)}
</span>
))}
</div>
)}
</td>
<td style={{ textAlign: "right" }}><strong>{formatCHF(inv.total)}</strong></td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn btn-ghost" style={{ fontSize: 12, padding: "5px 12px", borderColor: mahnColor, color: mahnColor }}
onClick={() => sendReminder(inv)}>
{mahnLabel}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* Letzte Rechnungen */}
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<div className="section-label" style={{ marginBottom: 0 }}>LETZTE RECHNUNGEN</div>
<button className="btn btn-ghost" style={{ fontSize: 12, padding: "4px 12px" }} onClick={() => setView("invoices")}>Alle anzeigen </button>
</div>
<table>
<thead><tr><th>Nr.</th><th>Kunde</th><th>Datum</th><th style={{ textAlign: "right" }}>Betrag</th><th>Status</th></tr></thead>
<tbody>
{[...data.invoices].sort((a, b) => (b.date || "").localeCompare(a.date || "")).slice(0, 6).map(inv => {
const client = ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === inv.clientId);
return (
<tr key={inv.id}>
<td><strong>{inv.number}</strong></td>
<td>{client?.name || "—"}</td>
<td>{formatDate(inv.date)}</td>
<td style={{ textAlign: "right" }}><strong>{formatCHF(inv.total)}</strong></td>
<td><StatusBadge status={inv.status} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Spesen & Belege */}
{expenses.length > 0 && (
<div className="card" style={{ marginTop: 28 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<div className="section-label" style={{ marginBottom: 0 }}>
SPESEN {filterYear || ""}
<span style={{ marginLeft: 10, fontWeight: 400, color: "#888", fontSize: 11 }}>
{expenses.filter(e => e.receiptData).length} von {expenses.length} mit Beleg
</span>
</div>
<button className="btn btn-ghost" style={{ fontSize: 12, padding: "4px 12px" }} onClick={() => setView && setView("expenses")}>
Alle anzeigen
</button>
</div>
<table>
<thead>
<tr>
<th style={{ width: 100 }}>Datum</th>
<th style={{ width: 160 }}>Kategorie</th>
<th>Beschreibung</th>
<th style={{ width: 120 }}>Mitarbeiter</th>
<th style={{ textAlign: "right", width: 120 }}>Brutto</th>
<th style={{ width: 80, textAlign: "center" }}>Beleg</th>
<th style={{ width: 110 }}>Status</th>
</tr>
</thead>
<tbody>
{[...expenses].sort((a, b) => (b.date || "").localeCompare(a.date || "")).map(e => {
const emp = (data.employees || []).find(em => em.id === e.employeeId);
const expStatus = e.status || "offen";
const statusColors = { offen: "#b07848", genehmigt: "#2d6a4f", "auf nächsten Lohn": "#1a4e8a", ausbezahlt: "#2d6a4f" };
return (
<tr key={e.id}>
<td>{formatDate(e.date)}</td>
<td style={{ fontSize: 12 }}>{e.category}</td>
<td style={{ color: "#555" }}>{e.description || "—"}</td>
<td style={{ color: "#888", fontSize: 12 }}>{emp?.name || "—"}</td>
<td style={{ textAlign: "right", fontWeight: 600 }}>{formatCHF(e.amount)}</td>
<td style={{ textAlign: "center" }}>
{e.receiptData ? (
<button
className="btn btn-ghost btn-sm"
title={e.receiptName || "Beleg anzeigen"}
onClick={() => setReceiptView(e)}
style={{ lineHeight: 1, padding: "3px 6px" }}
><span className="material-icons" style={{ fontSize: 16, verticalAlign: "middle" }}>receipt_long</span></button>
) : (
<span style={{ color: "#ddd", fontSize: 12 }}></span>
)}
</td>
<td>
<span style={{ fontSize: 11, fontWeight: 600, color: statusColors[expStatus] || "#888" }}>
{expStatus.charAt(0).toUpperCase() + expStatus.slice(1)}
</span>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr style={{ borderTop: "2px solid #1a1a18" }}>
<td colSpan={4} style={{ color: "#888", fontSize: 12 }}>{expenses.length} Einträge</td>
<td style={{ textAlign: "right", fontWeight: 700 }}>{formatCHF(totalExpBrutto)}</td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
</div>
)}
{/* Mahnung-Dialog */}
{mahnModal && (
<MahnModal
inv={mahnModal.inv}
data={data}
update={update}
setPrintContent={setPrintContent}
onClose={() => setMahnModal(null)}
mahnMode={mahnMode}
setMahnMode={setMahnMode}
mahnSentDate={mahnSentDate}
setMahnSentDate={setMahnSentDate}
/>
)}
<ReceiptViewer expense={receiptView} onClose={() => setReceiptView(null)} />
</div>
);
}
+452
View File
@@ -0,0 +1,452 @@
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
@@ -0,0 +1,456 @@
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>
);
}
+762
View File
@@ -0,0 +1,762 @@
import React, { useState, useRef, useEffect } from "react";
import { SIA_PHASES, DASHBOARD_WIDGETS } from "../constants.js";
import { formatCHF, formatDate, formatHours, migrateDashboardLayout, widgetsToRows, generateId } from "../utils.js";
const HEIGHT_OPTS = [
{ v: 0, l: "Auto" },
{ v: 160, l: "S" },
{ v: 280, l: "M" },
{ v: 420, l: "L" },
];
function deepCloneLayout(layout) {
return (layout || []).map(r => ({ ...r, widgets: [...r.widgets] }));
}
export default function Dashboard({ data, setView, currentUser, saveAll }) {
const today = new Date().toISOString().slice(0, 10);
const thisMonth = today.slice(0, 7);
const thisYear = today.slice(0, 4);
const lastMonth = (() => { const d = new Date(); d.setMonth(d.getMonth() - 1); return d.toISOString().slice(0, 7); })();
// ─── Layout resolution ─────────────────────────────────────────────
const myUser = (data.users || []).find(u => u.id === currentUser?.id);
const myRole = (data.appRoles || []).find(r => r.id === (currentUser?.appRoleId || myUser?.appRoleId));
const myTpl = (data.dashboardTemplates || []).find(t => t.id === myRole?.dashboardTemplateId);
const roleLayout = myTpl
? migrateDashboardLayout(myTpl.layout)
: migrateDashboardLayout(myRole?.dashboardWidgets) // fallback for old data
|| widgetsToRows(DASHBOARD_WIDGETS.map(w => w.id));
const savedLayout = myUser?.dashboardWidgets ? migrateDashboardLayout(myUser.dashboardWidgets) : null;
const [editMode, setEditMode] = useState(false);
const [layout, setLayout] = useState([]);
const [dragOver, setDragOver] = useState(null);
const [addPopoverRowId, setAddPopoverRowId] = useState(null);
const [saveTemplateOpen, setSaveTemplateOpen] = useState(false);
const [newPublicName, setNewPublicName] = useState("");
const [newPrivateName, setNewPrivateName] = useState("");
const dragRef = useRef(null);
const activeLayout = editMode ? layout : (savedLayout || roleLayout);
// ─── Data ──────────────────────────────────────────────────────────
const activeProjects = data.projects.filter(p => p.status === "aktiv");
const projMins = id => data.timeEntries.filter(e => e.projectId === id).reduce((s, e) => s + (e.minutes || 0), 0);
const monthMins = data.timeEntries.filter(e => (e.date||"").startsWith(thisMonth)).reduce((s,e)=>s+(e.minutes||0),0);
const lastMoMins = data.timeEntries.filter(e => (e.date||"").startsWith(lastMonth)).reduce((s,e)=>s+(e.minutes||0),0);
const myEmpId = currentUser?.employeeId;
const myMonthMins = data.timeEntries.filter(e => (e.date||"").startsWith(thisMonth) && e.employeeId===myEmpId).reduce((s,e)=>s+(e.minutes||0),0);
const recentTime = [...data.timeEntries].sort((a,b)=>(b.date||"").localeCompare(a.date||"")).slice(0,6);
const myRecentTime = [...data.timeEntries].filter(e=>e.employeeId===myEmpId).sort((a,b)=>(b.date||"").localeCompare(a.date||"")).slice(0,6);
const openInvoices = data.invoices.filter(i => i.status==="gesendet"||i.status==="überfällig");
const overdueInvoices = data.invoices.filter(i => i.status==="überfällig");
const openAmount = openInvoices.reduce((s,i)=>s+(i.total||0),0);
const paidThisYear = data.invoices.filter(i=>i.status==="bezahlt"&&(i.date||"").startsWith(thisYear)).reduce((s,i)=>s+(i.sub||0),0);
const pendingQuotes = (data.quotes||[]).filter(q=>q.status==="gesendet");
const expiredQuotes = (data.quotes||[]).filter(q=>q.status==="gesendet"&&q.validUntil&&q.validUntil<today);
const unbilledProjects = activeProjects.map(p=>{
const mins = data.timeEntries.filter(e=>e.projectId===p.id&&!e.invoiceId).reduce((s,e)=>s+(e.minutes||0),0);
return {...p, unbilledMins:mins, unbilledAmt:(mins/60)*(p.hourlyRate||0)};
}).filter(p=>p.unbilledMins>0&&(p.billingType||p.type)==="stundensatz").sort((a,b)=>b.unbilledMins-a.unbilledMins);
const totalUnbilled = unbilledProjects.reduce((s,p)=>s+p.unbilledMins,0);
const last6Months = Array.from({length:6},(_,i)=>{const d=new Date();d.setMonth(d.getMonth()-(5-i));return d.toISOString().slice(0,7);});
const monthlyRevenue = last6Months.map(m=>({m,paid:data.invoices.filter(i=>i.status==="bezahlt"&&(i.date||"").startsWith(m)).reduce((s,i)=>s+(i.sub||0),0)}));
const maxRev = Math.max(...monthlyRevenue.map(m=>m.paid),1);
// ─── Permission ────────────────────────────────────────────────────
const canSaveTemplate = !myRole || myRole.permissions === null || (myRole.permissions||[]).includes("dashboard-vorlage");
// ─── Edit mode ─────────────────────────────────────────────────────
const enterEdit = () => { setLayout(deepCloneLayout(savedLayout || roleLayout)); setEditMode(true); };
const cancelEdit = () => { setEditMode(false); setAddPopoverRowId(null); setSaveTemplateOpen(false); setNewPublicName(""); setNewPrivateName(""); };
const saveEdit = () => {
if (currentUser && saveAll) {
const users = (data.users||[]).map(u => u.id===currentUser.id ? {...u, dashboardWidgets: layout} : u);
saveAll({ ...data, users });
}
setEditMode(false); setAddPopoverRowId(null); setSaveTemplateOpen(false); setNewPublicName(""); setNewPrivateName("");
};
const loadTemplate = tplId => {
const tpl = (data.dashboardTemplates||[]).find(t=>t.id===tplId);
if (tpl?.layout) setLayout(deepCloneLayout(migrateDashboardLayout(tpl.layout)));
};
const saveAsTemplate = (tplId) => {
if (!saveAll) return;
const dashboardTemplates = (data.dashboardTemplates||[]).map(t => t.id===tplId ? {...t, layout} : t);
saveAll({ ...data, dashboardTemplates });
setSaveTemplateOpen(false);
};
const createTemplate = (name, isPublic) => {
if (!name.trim() || !saveAll) return;
const tpl = { id: generateId(), name: name.trim(), isPublic, layout, ...(!isPublic ? { createdBy: currentUser?.id } : {}) };
saveAll({ ...data, dashboardTemplates: [...(data.dashboardTemplates||[]), tpl] });
setSaveTemplateOpen(false);
if (isPublic) setNewPublicName(""); else setNewPrivateName("");
};
// Add widget to row, moving it out of any other row it's already in
const addWidgetExclusive = (rowId, wid) => {
setLayout(l => l.map(r => {
if (r.id === rowId) return { ...r, widgets: r.widgets.includes(wid) ? r.widgets : [...r.widgets, wid] };
return { ...r, widgets: r.widgets.filter(w => w !== wid) };
}));
setAddPopoverRowId(null);
};
// Close popovers on outside click
useEffect(() => {
if (!addPopoverRowId && !saveTemplateOpen) return;
const close = e => { if (!e.target.closest("[data-popover]")) { setAddPopoverRowId(null); setSaveTemplateOpen(false); } };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [addPopoverRowId, saveTemplateOpen]);
// ─── Row operations ────────────────────────────────────────────────
const updateRow = (rowId, patch) => setLayout(l => l.map(r => r.id===rowId ? {...r,...patch} : r));
const addRow = (afterId) => {
const row = { id: generateId(), cols: 2, minH: 0, widgets: [] };
setLayout(l => {
const i = l.findIndex(r=>r.id===afterId);
const next = [...l];
next.splice(i+1, 0, row);
return next;
});
};
const deleteRow = rowId => setLayout(l => l.filter(r=>r.id!==rowId));
const moveRow = (rowId, dir) => setLayout(l => {
const i = l.findIndex(r=>r.id===rowId);
const j = i + dir;
if (j<0||j>=l.length) return l;
const next=[...l];
[next[i],next[j]]=[next[j],next[i]];
return next;
});
const addWidgetToRow = (rowId, wid) =>
setLayout(l => l.map(r => r.id===rowId ? {...r, widgets:[...r.widgets,wid]} : r));
const removeWidgetFromRow = (rowId, wid) =>
setLayout(l => l.map(r => r.id===rowId ? {...r, widgets:r.widgets.filter(w=>w!==wid)} : r));
// ─── Drag & Drop ───────────────────────────────────────────────────
const handleDragStart = (fromRowId, widgetId) => {
dragRef.current = { fromRowId, widgetId };
};
const handleDragEnd = () => {
dragRef.current = null;
setDragOver(null);
};
const handleDrop = (toRowId, beforeWidgetId) => {
const src = dragRef.current;
if (!src) return;
const { fromRowId, widgetId } = src;
setLayout(l => {
const next = deepCloneLayout(l);
const fromRow = next.find(r=>r.id===fromRowId);
const toRow = next.find(r=>r.id===toRowId);
if (!fromRow||!toRow) return l;
fromRow.widgets = fromRow.widgets.filter(w=>w!==widgetId);
if (beforeWidgetId) {
const idx = toRow.widgets.indexOf(beforeWidgetId);
toRow.widgets.splice(idx<0 ? toRow.widgets.length : idx, 0, widgetId);
} else {
if (!toRow.widgets.includes(widgetId)) toRow.widgets.push(widgetId);
}
return next;
});
dragRef.current = null;
setDragOver(null);
};
// Styles
const ctrlBtn = "pill";
const activeCtrlBtn = "pill active";
// Widget content renderers
const wContent = {
"kpi-projekte": () => <KpiCard label="AKTIVE PROJEKTE" value={activeProjects.length} color="#b07848" go="projects" setView={!editMode?setView:undefined} sub={`${data.projects.filter(p=>p.status==="abgeschlossen").length} abgeschlossen`} />,
"kpi-stunden": () => <KpiCard label={`STUNDEN ${new Date().toLocaleString("de-CH",{month:"long"}).toUpperCase()}`} value={formatHours(myEmpId?myMonthMins:monthMins)} color="#2d6a4f" go="time" setView={!editMode?setView:undefined} sub={!myEmpId&&lastMoMins>0?`Vormonat: ${formatHours(lastMoMins)}`:undefined} />,
"kpi-ausstehend": () => <KpiCard label="AUSSTEHEND" value={formatCHF(openAmount)} color={overdueInvoices.length>0?"#8a1a1a":"#7a6a00"} go="invoices" setView={!editMode?setView:undefined} sub={overdueInvoices.length>0?`${overdueInvoices.length} überfällig`:`${openInvoices.length} gesendet`} />,
"kpi-umsatz": () => <KpiCard label="UMSATZ DIESES JAHR" value={formatCHF(paidThisYear)} color="#2d6a4f" go="invoices" setView={!editMode?setView:undefined} sub="bezahlt (netto)" />,
"warnungen": () => {
const has = overdueInvoices.length>0||expiredQuotes.length>0;
if (!editMode&&!has) return null;
return has ? (
<div style={{display:"flex",gap:12,flexWrap:"wrap",height:"100%",alignItems:"stretch"}}>
{overdueInvoices.length>0&&<div onClick={!editMode?()=>setView("invoices"):undefined} style={{flex:1,minWidth:180,padding:"12px 16px",background:"#fff3f3",border:"1.5px solid #e0b0b0",borderRadius:8,cursor:editMode?"default":"pointer",display:"flex",alignItems:"center",gap:12}}>
<div style={{fontSize:18}}></div>
<div><div style={{fontSize:12,fontWeight:600,color:"#8a1a1a"}}>{overdueInvoices.length} überfällige Rechnung{overdueInvoices.length>1?"en":""}</div><div style={{fontSize:11,color:"#b5621e",marginTop:2}}>{formatCHF(overdueInvoices.reduce((s,i)=>s+i.total,0))} ausstehend</div></div>
</div>}
{expiredQuotes.length>0&&<div onClick={!editMode?()=>setView("quotes"):undefined} style={{flex:1,minWidth:180,padding:"12px 16px",background:"#fffbe8",border:"1.5px solid #e0d090",borderRadius:8,cursor:editMode?"default":"pointer",display:"flex",alignItems:"center",gap:12}}>
<div style={{fontSize:18}}></div>
<div><div style={{fontSize:12,fontWeight:600,color:"#7a6a00"}}>{expiredQuotes.length} Offerte{expiredQuotes.length>1?"n":""} abgelaufen</div><div style={{fontSize:11,color:"#888",marginTop:2}}>Gültigkeit überschritten</div></div>
</div>}
</div>
) : <div className="card" style={{fontSize:12,color:"var(--text5)",fontStyle:"italic"}}>Warnungen (keine aktuellen)</div>;
},
"aktive-projekte": () => (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)"}}>AKTIVE PROJEKTE</div>
<button onClick={()=>setView("projects")} style={{background:"none",border:"none",fontSize:11,color:"var(--text4)",cursor:"pointer",fontFamily:"inherit"}}>Alle </button>
</div>
{activeProjects.length===0?<div style={{fontSize:13,color:"var(--text5)",textAlign:"center",padding:"20px 0"}}>Keine aktiven Projekte</div>:activeProjects.slice(0,6).map(p=>{
const used=projMins(p.id),budget=p.budgetHours||0,pct=budget>0?Math.min((used/60)/budget,1):0,over=budget>0&&(used/60)>budget;
const cl=(data.persons||[]).filter(x=>x.isAuftraggeber).find(c=>c.id===p.clientId);
return (<div key={p.id} style={{marginBottom:14,paddingBottom:14,borderBottom:"1px solid var(--border2)"}}>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
<div><div style={{fontSize:12,fontWeight:500,color:"var(--text)"}}>{p.name}</div>{cl&&<div style={{fontSize:10,color:"var(--text4)",marginTop:1}}>{cl.name}</div>}</div>
<div style={{textAlign:"right",fontSize:11,color:over?"#8a1a1a":"var(--text4)",whiteSpace:"nowrap"}}>{formatHours(used)}{budget>0?` / ${budget}h`:""}</div>
</div>
{budget>0&&<div style={{height:3,background:"var(--border2)",borderRadius:2}}><div style={{width:`${pct*100}%`,height:"100%",background:over?"#8a1a1a":pct>0.8?"#b5621e":"#2d6a4f",borderRadius:2,transition:"width 0.3s"}}/></div>}
</div>);
})}
</div>
),
"unverrechnete-stunden": () => (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)"}}>UNVERRECHNETE STUNDEN</div>
<button onClick={()=>setView("invoices")} style={{background:"none",border:"none",fontSize:11,color:"var(--text4)",cursor:"pointer",fontFamily:"inherit"}}></button>
</div>
{totalUnbilled===0?<div style={{fontSize:13,color:"#2d6a4f",textAlign:"center",padding:"20px 0"}}> Alles verrechnet</div>:<>
<div style={{fontSize:22,fontFamily:"'Playfair Display',serif",fontWeight:700,color:"#b5621e",marginBottom:2}}>{formatHours(totalUnbilled)}</div>
<div style={{fontSize:11,color:"var(--text5)",marginBottom:16}}> {formatCHF(unbilledProjects.reduce((s,p)=>s+p.unbilledAmt,0))}</div>
{unbilledProjects.slice(0,5).map(p=><div key={p.id} style={{display:"flex",justifyContent:"space-between",padding:"5px 0",borderBottom:"1px solid var(--border2)",fontSize:12}}><span style={{color:"var(--text2)",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",maxWidth:"60%"}}>{p.name}</span><span style={{color:"#b5621e",flexShrink:0}}>{formatHours(p.unbilledMins)}</span></div>)}
</>}
</div>
),
"umsatz-sparkline": () => (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)",marginBottom:14}}>UMSATZ LETZTE 6 MONATE</div>
<div style={{display:"flex",alignItems:"flex-end",gap:6,height:60}}>
{monthlyRevenue.map(({m,paid})=>(
<div key={m} style={{flex:1,display:"flex",flexDirection:"column",alignItems:"center",gap:4}}>
<div style={{width:"100%",height:paid>0?`${Math.max((paid/maxRev)*54,4)}px`:"2px",background:m===thisMonth?"#b07848":paid>0?"#2d6a4f":"var(--border2)",borderRadius:"2px 2px 0 0",transition:"height 0.3s"}} title={formatCHF(paid)}/>
<div style={{fontSize:8,color:m===thisMonth?"#b07848":"var(--text5)"}}>{new Date(m+"-01").toLocaleString("de-CH",{month:"short"})}</div>
</div>
))}
</div>
</div>
),
"offene-offerten": () => (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)"}}>OFFENE OFFERTEN</div>
<button onClick={()=>setView("quotes")} style={{background:"none",border:"none",fontSize:11,color:"var(--text4)",cursor:"pointer",fontFamily:"inherit"}}>Alle </button>
</div>
{pendingQuotes.length===0?<div style={{fontSize:12,color:"var(--text5)"}}>Keine pendenten Offerten</div>:pendingQuotes.slice(0,5).map(q=>{
const cl=(data.persons||[]).filter(p=>p.isAuftraggeber).find(c=>c.id===q.clientId);
const expired=q.validUntil&&q.validUntil<today;
return (<div key={q.id} style={{display:"flex",justifyContent:"space-between",padding:"5px 0",borderBottom:"1px solid var(--border2)",fontSize:12}}>
<div><div style={{color:expired?"#b5621e":"var(--text2)"}}>{q.number}</div>{cl&&<div style={{fontSize:10,color:"var(--text5)"}}>{cl.name}</div>}</div>
<div style={{textAlign:"right",flexShrink:0}}><div style={{color:"var(--text2)",fontWeight:500}}>{formatCHF(q.total)}</div>{expired&&<div style={{fontSize:9,color:"#b5621e"}}>abgelaufen</div>}</div>
</div>);
})}
</div>
),
"letzte-zeiteintraege": () => (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)"}}>LETZTE ZEITEINTRÄGE</div>
<button onClick={()=>setView("time")} style={{background:"none",border:"none",fontSize:11,color:"var(--text4)",cursor:"pointer",fontFamily:"inherit"}}>Zeiterfassung </button>
</div>
<TimeTable entries={recentTime} data={data}/>
</div>
),
"meine-zeiteintraege": () => (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)"}}>MEINE ZEITEINTRÄGE</div>
<button onClick={()=>setView("time")} style={{background:"none",border:"none",fontSize:11,color:"var(--text4)",cursor:"pointer",fontFamily:"inherit"}}>Zeiterfassung </button>
</div>
<TimeTable entries={myRecentTime} data={data}/>
</div>
),
"meine-projekte": () => {
const myProjects = (data.projects||[]).filter(p=>p.status==="aktiv"&&(p.internalMembers||[]).includes(myEmpId));
return (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)"}}>MEINE PROJEKTE</div>
<button onClick={()=>!editMode&&setView("projects")} style={{background:"none",border:"none",fontSize:11,color:"var(--text4)",cursor:"pointer",fontFamily:"inherit"}}>Alle </button>
</div>
{myProjects.length===0
? <div style={{fontSize:13,color:"var(--text5)",textAlign:"center",padding:"20px 0"}}>Keinen Projekten zugewiesen</div>
: myProjects.map(p=>{
const myMins=data.timeEntries.filter(e=>e.projectId===p.id&&e.employeeId===myEmpId).reduce((s,e)=>s+(e.minutes||0),0);
const cl=(data.persons||[]).find(c=>c.id===p.clientId);
return (
<div key={p.id} style={{marginBottom:12,paddingBottom:12,borderBottom:"1px solid var(--border2)"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"baseline"}}>
<div>
<div style={{fontSize:12,fontWeight:500,color:"var(--text)"}}>{p.name}</div>
{cl&&<div style={{fontSize:10,color:"var(--text4)",marginTop:1}}>{cl.name}</div>}
</div>
<div style={{fontSize:11,color:"var(--text3)",flexShrink:0,marginLeft:8}}>{formatHours(myMins)}</div>
</div>
</div>
);
})
}
</div>
);
},
"meine-ferien": () => {
const myEmp=(data.employees||[]).find(e=>e.id===myEmpId);
if(!myEmpId||!myEmp) return <div className="card" style={{height:"100%",boxSizing:"border-box",display:"flex",alignItems:"center",justifyContent:"center",fontSize:12,color:"var(--text5)"}}>Kein Mitarbeiterprofil verknüpft</div>;
const anspruchTage=(myEmp.ferienWochen||5)*5;
const approvedEntries=(data.ferienEntries||[]).filter(f=>f.employeeId===myEmpId&&(f.status==="approved"||!f.status)&&(f.dateFrom||"").startsWith(thisYear));
const countWorkdays=(from,to)=>{
let n=0;const d=new Date(from);const end=new Date(to);
while(d<=end){const dow=d.getDay();if(dow!==0&&dow!==6)n++;d.setDate(d.getDate()+1);}
return n;
};
const bezogenTage=approvedEntries.reduce((s,f)=>s+countWorkdays(f.dateFrom,f.dateTo),0);
const restTage=anspruchTage-bezogenTage;
const pct=Math.min(bezogenTage/anspruchTage,1);
const upcoming=(data.ferienEntries||[]).filter(f=>f.employeeId===myEmpId&&(f.status==="approved"||!f.status)&&f.dateFrom>today).slice(0,2);
return (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)",marginBottom:14}}>FERIENSTAND {thisYear}</div>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:6}}>
<div style={{textAlign:"center"}}>
<div style={{fontSize:22,fontFamily:"'Playfair Display',serif",fontWeight:700,color:"#2d6a4f"}}>{restTage}</div>
<div style={{fontSize:10,color:"var(--text4)"}}>Verbleibend</div>
</div>
<div style={{textAlign:"center"}}>
<div style={{fontSize:22,fontFamily:"'Playfair Display',serif",fontWeight:700,color:"var(--text)"}}>{bezogenTage}</div>
<div style={{fontSize:10,color:"var(--text4)"}}>Bezogen</div>
</div>
<div style={{textAlign:"center"}}>
<div style={{fontSize:22,fontFamily:"'Playfair Display',serif",fontWeight:700,color:"var(--text3)"}}>{anspruchTage}</div>
<div style={{fontSize:10,color:"var(--text4)"}}>Anspruch</div>
</div>
</div>
<div style={{height:4,background:"var(--border2)",borderRadius:2,margin:"12px 0 14px"}}>
<div style={{width:`${pct*100}%`,height:"100%",background:restTage<5?"#8a1a1a":"#2d6a4f",borderRadius:2,transition:"width 0.4s"}}/>
</div>
{upcoming.length>0&&<>
<div style={{fontSize:9,letterSpacing:"0.1em",color:"var(--text4)",marginBottom:6}}>NÄCHSTE FERIEN</div>
{upcoming.map(f=><div key={f.id} style={{fontSize:11,color:"var(--text2)",marginBottom:3}}>{formatDate(f.dateFrom)} {formatDate(f.dateTo)}</div>)}
</>}
</div>
);
},
"ueberstunden": () => {
const myEmp=(data.employees||[]).find(e=>e.id===myEmpId);
if(!myEmpId||!myEmp) return <div className="card" style={{height:"100%",boxSizing:"border-box",display:"flex",alignItems:"center",justifyContent:"center",fontSize:12,color:"var(--text5)"}}>Kein Mitarbeiterprofil verknüpft</div>;
const pensum=(myEmp.pensum||100)/100;
const tagessollH=((myEmp.wochenstunden||35)*pensum)/5;
const startOfYear=`${thisYear}-01-01`;
const fts=data.feiertage||[];
// Respect eintrittsdatum: only count from when the employee actually started
const effectiveYearStart=myEmp.eintrittsdatum&&myEmp.eintrittsdatum>startOfYear?myEmp.eintrittsdatum:startOfYear;
const todayD=new Date(today);
let workdays=0;const d=new Date(effectiveYearStart);
while(d<=todayD){const dow=d.getDay();const ds=d.toISOString().slice(0,10);const ft=fts.find(f=>f.date===ds);const isFt=ft&&(ft.stundenDelta===0||ft.stundenDelta===null||ft.stundenDelta===undefined);if(dow!==0&&dow!==6&&!isFt)workdays++;d.setDate(d.getDate()+1);}
const sollMin=workdays*tagessollH*60;
const istMin=data.timeEntries.filter(e=>e.employeeId===myEmpId&&(e.date||"")>=effectiveYearStart&&(e.date||"")<=today).reduce((s,e)=>s+(e.minutes||0),0);
const deltaMin=istMin-sollMin;
const isPos=deltaMin>=0;
// This month — also respect eintrittsdatum
const effectiveMonthStart=myEmp.eintrittsdatum&&myEmp.eintrittsdatum>`${thisMonth}-01`?myEmp.eintrittsdatum:`${thisMonth}-01`;
const sollMonthMin=(() => {
let wd=0;const me=new Date(new Date(`${thisMonth}-01`).getFullYear(),new Date(`${thisMonth}-01`).getMonth()+1,0);
const end=me<todayD?me:todayD;const dd=new Date(effectiveMonthStart);
while(dd<=end){const dow=dd.getDay();const ds=dd.toISOString().slice(0,10);const ft=fts.find(f=>f.date===ds);const isFt=ft&&(ft.stundenDelta===0||ft.stundenDelta===null||ft.stundenDelta===undefined);if(dow!==0&&dow!==6&&!isFt)wd++;dd.setDate(dd.getDate()+1);}
return wd*tagessollH*60;
})();
const istMonthMin=data.timeEntries.filter(e=>e.employeeId===myEmpId&&(e.date||"").startsWith(thisMonth)).reduce((s,e)=>s+(e.minutes||0),0);
const deltaMonth=istMonthMin-sollMonthMin;
return (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)",marginBottom:14}}>STUNDENSALDO</div>
<div style={{marginBottom:14}}>
<div style={{fontSize:10,color:"var(--text4)",marginBottom:4}}>JAHRESSALDO {thisYear}</div>
<div style={{fontSize:26,fontFamily:"'Playfair Display',serif",fontWeight:700,color:isPos?"#2d6a4f":"#8a1a1a"}}>
{isPos?"+":""}{formatHours(Math.abs(deltaMin))}
<span style={{fontSize:13,fontWeight:400,marginLeft:4}}>{isPos?"Überstunden":"Minusstunden"}</span>
</div>
<div style={{fontSize:11,color:"var(--text4)",marginTop:2}}>{formatHours(istMin)} von {formatHours(sollMin)} Soll</div>
</div>
<div style={{paddingTop:12,borderTop:"1px solid var(--border2)"}}>
<div style={{fontSize:10,color:"var(--text4)",marginBottom:4}}>DIESER MONAT</div>
<div style={{fontSize:14,fontWeight:600,color:deltaMonth>=0?"#2d6a4f":"#8a1a1a"}}>
{deltaMonth>=0?"+":""}{formatHours(Math.abs(deltaMonth))}
</div>
<div style={{fontSize:11,color:"var(--text4)"}}>{formatHours(istMonthMin)} von {formatHours(sollMonthMin)} Soll</div>
</div>
</div>
);
},
"stunden-woche": () => {
const myEmp=(data.employees||[]).find(e=>e.id===myEmpId);
const pensum=(myEmp?.pensum||100)/100;
const sollH=(myEmp?.wochenstunden||35)*pensum;
const getMonday=(dateStr)=>{const d=new Date(dateStr);const day=d.getDay();d.setDate(d.getDate()-(day===0?6:day-1));return d;};
const monday=getMonday(today);
const weeks=Array.from({length:5},(_,i)=>{
const mon=new Date(monday);mon.setDate(mon.getDate()-(4-i)*7);
const sun=new Date(mon);sun.setDate(sun.getDate()+6);
const monStr=mon.toISOString().slice(0,10);
const sunStr=sun.toISOString().slice(0,10);
const isCurrent=i===4;
const mins=(myEmpId?data.timeEntries.filter(e=>e.employeeId===myEmpId&&e.date>=monStr&&e.date<=sunStr):data.timeEntries.filter(e=>e.date>=monStr&&e.date<=sunStr)).reduce((s,e)=>s+(e.minutes||0),0);
const kw=(() => { const d2=new Date(mon);d2.setHours(0,0,0,0);d2.setDate(d2.getDate()+3-(d2.getDay()||7)+1); const w1=new Date(d2.getFullYear(),0,4);return 1+Math.round(((d2-w1)/86400000+((w1.getDay()||7)-1))/7); })();
return {kw,mins,isCurrent};
});
const maxMins=Math.max(...weeks.map(w=>w.mins),sollH*60,1);
const sollPct=(sollH*60)/maxMins;
return (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)",marginBottom:14}}>STUNDEN PRO WOCHE</div>
<div style={{display:"flex",alignItems:"flex-end",gap:8,height:80,position:"relative"}}>
<div style={{position:"absolute",bottom:`${sollPct*100}%`,left:0,right:0,borderTop:"1px dashed var(--border3)",pointerEvents:"none"}} title={`Soll: ${sollH}h`}/>
{weeks.map(({kw,mins,isCurrent})=>{
const pct=mins/maxMins;
const over=mins>sollH*60;
return (
<div key={kw} style={{flex:1,display:"flex",flexDirection:"column",alignItems:"center",gap:3}}>
<div style={{fontSize:9,color:over?"#2d6a4f":"var(--text4)"}}>{mins>0?formatHours(mins):""}</div>
<div style={{width:"100%",height:`${Math.max(pct*72,mins>0?4:1)}px`,background:isCurrent?(over?"#2d6a4f":"#b07848"):over?"#2d6a4f33":"var(--border2)",borderRadius:"3px 3px 0 0",transition:"height 0.3s"}}/>
<div style={{fontSize:9,color:isCurrent?"var(--text2)":"var(--text5)",fontWeight:isCurrent?600:400}}>KW{kw}</div>
</div>
);
})}
</div>
<div style={{marginTop:10,fontSize:10,color:"var(--text4)"}}>
Soll: {sollH}h / Woche
</div>
</div>
);
},
"interner-blog": () => {
const posts = (data.blogPosts || []);
const recent = [...posts].sort((a,b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
return b.createdAt.localeCompare(a.createdAt);
}).slice(0, 3);
const getMonday = d => { const dd=new Date(d); dd.setDate(dd.getDate()-(dd.getDay()===0?6:dd.getDay()-1)); return dd; };
const weekStart = getMonday(new Date(today));
const weekEnd = new Date(weekStart); weekEnd.setDate(weekEnd.getDate()+6);
const feiertageWeek = (data.feiertage||[]).filter(f=>f.date>=weekStart.toISOString().slice(0,10)&&f.date<=weekEnd.toISOString().slice(0,10));
const TYPE_COLOR = { beitrag:"#1a4e8a", ankuendigung:"#b5621e", event:"#2d6a4f" };
const TYPE_LABEL = { beitrag:"Beitrag", ankuendigung:"Ankündigung", event:"Event" };
return (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)"}}>PINNWAND</div>
<button onClick={()=>!editMode&&setView("pinnwand")} style={{background:"none",border:"none",fontSize:11,color:"var(--text4)",cursor:"pointer",fontFamily:"inherit"}}>Alle </button>
</div>
{feiertageWeek.length>0&&(
<div style={{padding:"8px 12px",background:"#e8f5ee",border:"1px solid #a8d8b8",borderRadius:8,marginBottom:12,fontSize:11,color:"#2d6a4f",display:"flex",gap:8,alignItems:"center"}}>
<span>🎉</span>
<span><b>Feiertag diese Woche:</b> {feiertageWeek.map(f=>f.name).join(", ")}</span>
</div>
)}
{recent.length===0
? <div style={{fontSize:13,color:"var(--text5)",textAlign:"center",padding:"16px 0"}}>Keine Beiträge</div>
: recent.map(p=>(
<div key={p.id} style={{marginBottom:12,paddingBottom:12,borderBottom:"1px solid var(--border2)"}}>
<div style={{display:"flex",alignItems:"center",gap:6,marginBottom:4}}>
<span style={{fontSize:9,fontWeight:600,color:TYPE_COLOR[p.type]||"#1a4e8a",letterSpacing:"0.06em"}}>{(TYPE_LABEL[p.type]||"").toUpperCase()}</span>
{p.pinned&&<span style={{fontSize:9,color:"#b07848"}}>📌</span>}
</div>
{p.title&&<div style={{fontSize:13,fontWeight:500,color:"var(--text)",marginBottom:2}}>{p.title}</div>}
<div style={{fontSize:11,color:"var(--text3)",lineHeight:1.5,display:"-webkit-box",WebkitLineClamp:2,WebkitBoxOrient:"vertical",overflow:"hidden"}}>{p.body}</div>
<div style={{fontSize:10,color:"var(--text4)",marginTop:4}}>{p.authorName} · {new Date(p.createdAt).toLocaleDateString("de-CH")}</div>
</div>
))
}
</div>
);
},
"team-auslastung": () => {
const activeEmps=(data.employees||[]).filter(e=>e.aktiv!==false);
const pensum=e=>(e.pensum||100)/100;
const sollH=e=>((e.wochenstunden||35)*pensum(e))/5*(() => {
let wd=0;const d=new Date(`${thisMonth}-01`);const end=new Date(d.getFullYear(),d.getMonth()+1,0);const todayD=new Date(today);
const cap=end<todayD?end:todayD;const dd=new Date(d);
while(dd<=cap){const dow=dd.getDay();if(dow!==0&&dow!==6)wd++;dd.setDate(dd.getDate()+1);}
return wd;
})();
const empData=activeEmps.map(e=>{
const istMin=data.timeEntries.filter(t=>t.employeeId===e.id&&(t.date||"").startsWith(thisMonth)).reduce((s,t)=>s+(t.minutes||0),0);
const sollMin=sollH(e)*60;
const pct=sollMin>0?Math.min(istMin/sollMin,1.5):0;
return{...e,istMin,sollMin,pct};
}).sort((a,b)=>b.istMin-a.istMin);
return (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)",marginBottom:14}}>TEAM-AUSLASTUNG {new Date().toLocaleString("de-CH",{month:"long"}).toUpperCase()}</div>
{empData.length===0
? <div style={{fontSize:13,color:"var(--text5)"}}>Keine Mitarbeitenden</div>
: empData.map(e=>{
const over=e.pct>1;
return (
<div key={e.id} style={{marginBottom:10}}>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:3}}>
<div style={{fontSize:12,color:"var(--text)"}}>{e.name}</div>
<div style={{fontSize:11,color:over?"#2d6a4f":"var(--text4)",fontWeight:over?600:400}}>{formatHours(e.istMin)}{e.sollMin>0?` / ${formatHours(e.sollMin)}`:""}</div>
</div>
<div style={{height:3,background:"var(--border2)",borderRadius:2}}>
<div style={{width:`${Math.min(e.pct,1)*100}%`,height:"100%",background:over?"#2d6a4f":e.pct>0.8?"#b07848":"var(--border3)",borderRadius:2,transition:"width 0.3s"}}/>
</div>
</div>
);
})
}
</div>
);
},
};
// ─── Row renderer ──────────────────────────────────────────────────
const renderRow = (row, rowIdx) => {
const isDragOverRow = dragOver?.rowId === row.id && dragOver?.before === null;
const content = wContent[row.id] ? null : null; // widget lookup happens below
return (
<div key={row.id} style={{marginBottom:16}}>
{/* Row toolbar */}
{editMode && (
<div style={{display:"flex",alignItems:"center",gap:6,marginBottom:6,flexWrap:"wrap"}}>
<span style={{fontSize:10,color:"var(--text4)",letterSpacing:"0.08em"}}>SPALTEN</span>
{[1,2,3,4].map(n=>(
<button key={n} onClick={()=>updateRow(row.id,{cols:n})} className={row.cols===n?activeCtrlBtn:ctrlBtn}>{n}</button>
))}
<span style={{fontSize:10,color:"var(--text4)",letterSpacing:"0.08em",marginLeft:6}}>HÖHE</span>
{HEIGHT_OPTS.map(opt=>(
<button key={opt.v} onClick={()=>updateRow(row.id,{minH:opt.v})} className={row.minH===opt.v?activeCtrlBtn:ctrlBtn}>{opt.l}</button>
))}
<div data-popover style={{position:"relative",marginLeft:6}}>
<button onClick={()=>setAddPopoverRowId(addPopoverRowId===row.id?null:row.id)} className={addPopoverRowId===row.id?activeCtrlBtn:ctrlBtn}>+ Widget</button>
{addPopoverRowId===row.id&&(
<div style={{position:"absolute",top:"calc(100% + 4px)",left:0,zIndex:200,background:"var(--surface)",border:"1px solid var(--border)",borderRadius:8,padding:"6px 0",minWidth:230,boxShadow:"0 4px 20px rgba(0,0,0,0.15)"}}>
{DASHBOARD_WIDGETS.map(d=>{
const inThis=row.widgets.includes(d.id);
const inOther=!inThis&&layout.some(r=>r.id!==row.id&&r.widgets.includes(d.id));
return (
<button key={d.id} onClick={()=>!inThis&&addWidgetExclusive(row.id,d.id)}
style={{display:"flex",alignItems:"center",gap:8,width:"100%",padding:"7px 14px",background:"none",border:"none",color:inThis?"var(--text4)":"var(--text)",cursor:inThis?"default":"pointer",fontFamily:"inherit",fontSize:12,textAlign:"left"}}>
<span style={{width:14,textAlign:"center",fontSize:10,opacity:0.5,flexShrink:0}}>{inThis?"✓":"+"}</span>
<span style={{flex:1}}>{d.label}</span>
{inOther&&<span style={{fontSize:9,color:"var(--text5)"}}>verschieben</span>}
</button>
);
})}
</div>
)}
</div>
<div style={{marginLeft:"auto",display:"flex",gap:4}}>
{rowIdx>0&&<button onClick={()=>moveRow(row.id,-1)} className={ctrlBtn}></button>}
{rowIdx<layout.length-1&&<button onClick={()=>moveRow(row.id,1)} className={ctrlBtn}></button>}
<button onClick={()=>addRow(row.id)} className={ctrlBtn}>+ Zeile</button>
<button onClick={()=>deleteRow(row.id)} className={ctrlBtn} style={{color:"#8a1a1a"}}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
)}
{/* Row grid */}
<div
style={{
display:"grid",
gridTemplateColumns:`repeat(${row.cols},1fr)`,
gap:16,
minHeight:row.minH||undefined,
alignItems:"stretch",
outline: isDragOverRow&&editMode ? "2px dashed var(--text)" : "none",
borderRadius:10,
transition:"outline 0.1s",
}}
onDragOver={editMode?e=>{e.preventDefault();setDragOver({rowId:row.id,before:null});}:undefined}
onDrop={editMode?e=>{e.preventDefault();handleDrop(row.id,null);}:undefined}
>
{row.widgets.map(wid=>{
const content = wContent[wid];
if (!content) return null;
const rendered = content();
if (rendered===null&&!editMode) return null;
const isBefore = dragOver?.rowId===row.id&&dragOver?.before===wid;
const isDragging = dragRef.current?.widgetId===wid;
return (
<div
key={wid}
draggable={editMode}
onDragStart={editMode?()=>handleDragStart(row.id,wid):undefined}
onDragEnd={editMode?handleDragEnd:undefined}
onDragOver={editMode?e=>{e.preventDefault();e.stopPropagation();setDragOver({rowId:row.id,before:wid});}:undefined}
onDrop={editMode?e=>{e.preventDefault();e.stopPropagation();handleDrop(row.id,wid);}:undefined}
style={{
position:"relative",
height:"100%",
opacity:isDragging?0.35:1,
boxShadow:isBefore?"inset 3px 0 0 var(--text)":undefined,
borderRadius:10,
cursor:editMode?"grab":"default",
transition:"opacity 0.15s",
}}
>
{rendered===null?<div className="card" style={{fontSize:12,color:"var(--text5)",fontStyle:"italic",height:"100%",boxSizing:"border-box",display:"flex",alignItems:"center",justifyContent:"center"}}>{DASHBOARD_WIDGETS.find(d=>d.id===wid)?.label}</div>:rendered}
{editMode&&(
<button
onClick={()=>removeWidgetFromRow(row.id,wid)}
className={ctrlBtn} style={{position:"absolute",top:8,right:8,zIndex:10,color:"#8a1a1a",lineHeight:1,padding:"2px 6px"}}
onMouseDown={e=>e.stopPropagation()}
>×</button>
)}
</div>
);
})}
{/* Empty slot drop target */}
{editMode&&(
<div
onDragOver={e=>{e.preventDefault();setDragOver({rowId:row.id,before:null});}}
onDrop={e=>{e.preventDefault();handleDrop(row.id,null);}}
style={{minHeight:80,border:"1.5px dashed var(--border3)",borderRadius:10,display:"flex",alignItems:"center",justifyContent:"center",color:"var(--text5)",fontSize:13}}
>
ablegen
</div>
)}
</div>
</div>
);
};
// ─── Render ────────────────────────────────────────────────────────
return (
<div>
{/* Header */}
<div style={{marginBottom:28,display:"flex",justifyContent:"space-between",alignItems:"flex-start"}}>
<div>
<h1 style={{fontFamily:"'Playfair Display',serif",fontSize:30,fontWeight:400,letterSpacing:"-0.02em",lineHeight:1.1,color:"var(--text)"}}>
{data.settings.name||"Studio"}
</h1>
<div style={{fontSize:11,color:"var(--text4)",letterSpacing:"0.1em",marginTop:6}}>
{new Date().toLocaleDateString("de-CH",{weekday:"long",day:"numeric",month:"long",year:"numeric"}).toUpperCase()}
</div>
</div>
<div style={{display:"flex",gap:8,alignItems:"center",marginTop:6,flexShrink:0}}>
{editMode?(
<>
<select defaultValue="" onChange={e=>{if(e.target.value){loadTemplate(e.target.value);e.target.value="";}}} className={ctrlBtn}>
<option value="">Vorlage laden</option>
{(data.dashboardTemplates||[]).filter(t=>t.isPublic).map(t=>(
<option key={t.id} value={t.id}>{t.name}</option>
))}
{(data.dashboardTemplates||[]).filter(t=>!t.isPublic&&t.createdBy===currentUser?.id).map(t=>(
<option key={t.id} value={t.id}>{t.name} (persönlich)</option>
))}
</select>
<div data-popover style={{position:"relative"}}>
<button onClick={()=>setSaveTemplateOpen(v=>!v)} className={saveTemplateOpen?activeCtrlBtn:ctrlBtn}>Vorlage speichern</button>
{saveTemplateOpen&&(
<div style={{position:"absolute",top:"calc(100% + 4px)",right:0,zIndex:200,background:"var(--surface)",border:"1px solid var(--border)",borderRadius:8,minWidth:240,boxShadow:"0 4px 20px rgba(0,0,0,0.15)",overflow:"hidden"}}>
{canSaveTemplate&&(<>
<div style={{padding:"8px 14px 4px",fontSize:9,color:"var(--text4)",letterSpacing:"0.1em",fontWeight:600}}>ÖFFENTLICHE VORLAGE</div>
{(data.dashboardTemplates||[]).filter(t=>t.isPublic).map(t=>(
<button key={t.id} onClick={()=>saveAsTemplate(t.id)} style={{display:"flex",justifyContent:"space-between",alignItems:"center",width:"100%",padding:"6px 14px",background:"none",border:"none",color:"var(--text)",cursor:"pointer",fontFamily:"inherit",fontSize:12,textAlign:"left"}}>
<span>{t.name}</span><span style={{fontSize:9,color:"var(--text5)"}}>überschreiben</span>
</button>
))}
<div style={{padding:"6px 14px 10px"}}>
<div style={{display:"flex",gap:5}}>
<input value={newPublicName} onChange={e=>setNewPublicName(e.target.value)} placeholder="Neue öffentliche Vorlage…"
onKeyDown={e=>e.key==="Enter"&&createTemplate(newPublicName,true)}
style={{flex:1,height:26,border:"1px solid var(--border)",borderRadius:4,padding:"0 8px",fontSize:11,background:"var(--surface)",color:"var(--text)",fontFamily:"inherit",outline:"none"}} />
<button onClick={()=>createTemplate(newPublicName,true)} className={ctrlBtn}>+</button>
</div>
</div>
<div style={{height:1,background:"var(--border)"}}/>
</>)}
<div style={{padding:"8px 14px 4px",fontSize:9,color:"var(--text4)",letterSpacing:"0.1em",fontWeight:600}}>PERSÖNLICHE VORLAGE</div>
{(data.dashboardTemplates||[]).filter(t=>!t.isPublic&&t.createdBy===currentUser?.id).map(t=>(
<button key={t.id} onClick={()=>saveAsTemplate(t.id)} style={{display:"flex",justifyContent:"space-between",alignItems:"center",width:"100%",padding:"6px 14px",background:"none",border:"none",color:"var(--text)",cursor:"pointer",fontFamily:"inherit",fontSize:12,textAlign:"left"}}>
<span>{t.name}</span><span style={{fontSize:9,color:"var(--text5)"}}>überschreiben</span>
</button>
))}
<div style={{padding:"6px 14px 10px"}}>
<div style={{display:"flex",gap:5}}>
<input value={newPrivateName} onChange={e=>setNewPrivateName(e.target.value)} placeholder="Neue persönliche Vorlage…"
onKeyDown={e=>e.key==="Enter"&&createTemplate(newPrivateName,false)}
style={{flex:1,height:26,border:"1px solid var(--border)",borderRadius:4,padding:"0 8px",fontSize:11,background:"var(--surface)",color:"var(--text)",fontFamily:"inherit",outline:"none"}} />
<button onClick={()=>createTemplate(newPrivateName,false)} className={ctrlBtn}>+</button>
</div>
</div>
</div>
)}
</div>
<button onClick={cancelEdit} className={ctrlBtn}>Abbrechen</button>
<button onClick={saveEdit} className="btn btn-primary" style={{fontSize:11}}>Speichern</button>
</>
):(
<button onClick={enterEdit} className={ctrlBtn} style={{display:"flex",alignItems:"center",gap:5}}>
<span className="material-symbols-outlined" style={{fontSize:13,verticalAlign:"middle"}}>edit</span>
Anpassen
</button>
)}
</div>
</div>
{/* Edit banner */}
{editMode&&(
<div style={{padding:"10px 16px",background:"var(--bg2)",border:"1px dashed var(--border3)",borderRadius:8,marginBottom:20,fontSize:12,color:"var(--text4)"}}>
Dashboard anpassen Spaltenanzahl und Höhe pro Zeile wählen. Widgets per Drag &amp; Drop verschieben oder mit × entfernen.
</div>
)}
{/* Rows */}
{activeLayout.map((row,idx) => renderRow(row,idx))}
{/* Edit mode: add first row / add row at bottom */}
{editMode&&(
<div style={{marginTop:8}}>
<button onClick={()=>addRow(layout[layout.length-1]?.id||"")} className={ctrlBtn} style={{width:"100%",padding:"10px",textAlign:"center",borderStyle:"dashed",color:"var(--text4)"}}>
+ Neue Zeile hinzufügen
</button>
</div>
)}
</div>
);
}
function KpiCard({label,value,sub,color,go,setView}) {
return (
<div className="card"
onClick={go&&setView?()=>setView(go):undefined}
style={{borderTop:`3px solid ${color||"var(--border)"}`,cursor:go&&setView?"pointer":"default",transition:"transform 0.15s",height:"100%",boxSizing:"border-box"}}
onMouseEnter={go&&setView?e=>{e.currentTarget.style.transform="translateY(-2px)";}:undefined}
onMouseLeave={go&&setView?e=>{e.currentTarget.style.transform="";}:undefined}
>
<div style={{fontSize:10,color:"var(--text4)",letterSpacing:"0.12em",marginBottom:8}}>{label}</div>
<div style={{fontSize:24,fontFamily:"'Playfair Display',serif",fontWeight:700,color:color||"var(--text)",marginBottom:3}}>{value}</div>
{sub&&<div style={{fontSize:11,color:"var(--text5)"}}>{sub}</div>}
</div>
);
}
function TimeTable({entries,data}) {
if (!entries.length) return <div style={{fontSize:13,color:"var(--text5)",padding:"8px 0"}}>Noch keine Zeiteinträge</div>;
return (
<table>
<thead><tr><th>Datum</th><th>Projekt</th><th>Beschreibung</th><th style={{textAlign:"right"}}>Dauer</th></tr></thead>
<tbody>
{entries.map(e=>{
const proj=data.projects.find(p=>p.id===e.projectId);
const phase=SIA_PHASES.find(ph=>ph.id===e.phaseId);
return (<tr key={e.id}>
<td>{formatDate(e.date)}</td>
<td><div style={{color:"var(--text)"}}>{proj?.name||"—"}</div>{phase&&<div style={{fontSize:10,color:"var(--text5)"}}>Phase {phase.id}</div>}</td>
<td style={{color:"var(--text3)"}}>{e.description||"—"}</td>
<td style={{textAlign:"right",fontWeight:500}}>{formatHours(e.minutes)}</td>
</tr>);
})}
</tbody>
</table>
);
}
+252
View File
@@ -0,0 +1,252 @@
import React from "react";
import { SIA_PHASES } from "../constants.js";
import { formatCHF, formatDate, formatHours } from "../utils.js";
export default function Dashboard({ data, setView }) {
const today = new Date().toISOString().slice(0, 10);
const thisMonth = today.slice(0, 7);
const thisYear = today.slice(0, 4);
const lastMonth = (() => { const d = new Date(); d.setMonth(d.getMonth() - 1); return d.toISOString().slice(0, 7); })();
// Projekte
const activeProjects = data.projects.filter(p => p.status === "aktiv");
const projectMinutes = (id) => data.timeEntries.filter(e => e.projectId === id).reduce((s, e) => s + (e.minutes || 0), 0);
// Zeit
const monthMinutes = data.timeEntries.filter(e => (e.date || "").startsWith(thisMonth)).reduce((s, e) => s + (e.minutes || 0), 0);
const lastMonthMinutes = data.timeEntries.filter(e => (e.date || "").startsWith(lastMonth)).reduce((s, e) => s + (e.minutes || 0), 0);
const recentTime = [...data.timeEntries].sort((a, b) => (b.date || "").localeCompare(a.date || "")).slice(0, 6);
// Rechnungen
const openInvoices = data.invoices.filter(i => i.status === "gesendet" || i.status === "überfällig");
const overdueInvoices = data.invoices.filter(i => i.status === "überfällig");
const openAmount = openInvoices.reduce((s, i) => s + (i.total || 0), 0);
const paidThisYear = data.invoices.filter(i => i.status === "bezahlt" && (i.date || "").startsWith(thisYear)).reduce((s, i) => s + (i.sub || 0), 0);
// Offerten
const pendingQuotes = (data.quotes || []).filter(q => q.status === "gesendet");
const expiredQuotes = (data.quotes || []).filter(q => q.status === "gesendet" && q.validUntil && q.validUntil < today);
// Unverrechnete Stunden
const unbilledProjects = activeProjects
.map(p => {
const mins = data.timeEntries.filter(e => e.projectId === p.id && !e.invoiceId).reduce((s, e) => s + (e.minutes || 0), 0);
return { ...p, unbilledMins: mins, unbilledAmount: (mins / 60) * (p.hourlyRate || 0) };
})
.filter(p => p.unbilledMins > 0 && (p.billingType || p.type) === "stundensatz")
.sort((a, b) => b.unbilledMins - a.unbilledMins);
const totalUnbilledMins = unbilledProjects.reduce((s, p) => s + p.unbilledMins, 0);
// Monats-Umsatz für Sparkline (letzten 6 Monate)
const last6Months = Array.from({ length: 6 }, (_, i) => {
const d = new Date(); d.setMonth(d.getMonth() - (5 - i));
return d.toISOString().slice(0, 7);
});
const monthlyRevenue = last6Months.map(m => ({
m, paid: data.invoices.filter(i => i.status === "bezahlt" && (i.date || "").startsWith(m)).reduce((s, i) => s + (i.sub || 0), 0),
}));
const maxRevMonth = Math.max(...monthlyRevenue.map(m => m.paid), 1);
const Card = ({ label, value, sub, color, go, children }) => (
<div className="card" onClick={go ? () => setView(go) : undefined}
style={{ borderTop: `3px solid ${color || "var(--border)"}`, cursor: go ? "pointer" : "default", transition: "transform 0.15s" }}
onMouseEnter={go ? e => { e.currentTarget.style.transform = "translateY(-2px)"; } : undefined}
onMouseLeave={go ? e => { e.currentTarget.style.transform = ""; } : undefined}>
<div style={{ fontSize: 10, color: "var(--text4)", letterSpacing: "0.12em", marginBottom: 8 }}>{label}</div>
{value !== undefined && <div style={{ fontSize: 24, fontFamily: "'Playfair Display', serif", fontWeight: 700, color: color || "var(--text)", marginBottom: 3 }}>{value}</div>}
{sub && <div style={{ fontSize: 11, color: "var(--text5)" }}>{sub}</div>}
{children}
</div>
);
return (
<div>
{/* Header */}
<div style={{ marginBottom: 28 }}>
<h1 style={{ fontFamily: "'Playfair Display', serif", fontSize: 34, fontWeight: 400, letterSpacing: "-0.01em", color: "var(--text)" }}>
{data.settings.name || "Studio"}
</h1>
<div style={{ fontSize: 11, color: "var(--text4)", letterSpacing: "0.1em", marginTop: 4 }}>
{new Date().toLocaleDateString("de-CH", { weekday: "long", day: "numeric", month: "long", year: "numeric" }).toUpperCase()}
</div>
</div>
{/* KPI Cards */}
<div className="responsive-grid-4" style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 24 }}>
<Card label="AKTIVE PROJEKTE" value={activeProjects.length} color="#d4a85a" go="projects"
sub={`${data.projects.filter(p => p.status === "abgeschlossen").length} abgeschlossen`} />
<Card label={`STUNDEN ${new Date().toLocaleString("de-CH", { month: "long" }).toUpperCase()}`}
value={formatHours(monthMinutes)} color="#2d6a4f" go="time"
sub={lastMonthMinutes > 0 ? `Vormonat: ${formatHours(lastMonthMinutes)}` : "Noch keine Vormonatsdaten"} />
<Card label="AUSSTEHEND" value={formatCHF(openAmount)}
color={overdueInvoices.length > 0 ? "#8a1a1a" : "#7a6a00"} go="invoices"
sub={overdueInvoices.length > 0 ? `${overdueInvoices.length} überfällig` : `${openInvoices.length} gesendet`} />
<Card label="UMSATZ DIESES JAHR" value={formatCHF(paidThisYear)} color="#2d6a4f" go="invoices"
sub="bezahlt (netto)" />
</div>
{/* Warnungen */}
{(overdueInvoices.length > 0 || expiredQuotes.length > 0) && (
<div style={{ display: "flex", gap: 12, marginBottom: 24, flexWrap: "wrap" }}>
{overdueInvoices.length > 0 && (
<div onClick={() => setView("invoices")} style={{ flex: 1, minWidth: 200, padding: "12px 16px", background: "#fff3f3", border: "1.5px solid #e0b0b0", borderRadius: 8, cursor: "pointer", display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ fontSize: 18 }}>⚠</div>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: "#8a1a1a" }}>{overdueInvoices.length} überfällige Rechnung{overdueInvoices.length > 1 ? "en" : ""}</div>
<div style={{ fontSize: 11, color: "#b5621e", marginTop: 2 }}>{formatCHF(overdueInvoices.reduce((s, i) => s + i.total, 0))} ausstehend</div>
</div>
</div>
)}
{expiredQuotes.length > 0 && (
<div onClick={() => setView("quotes")} style={{ flex: 1, minWidth: 200, padding: "12px 16px", background: "#fffbe8", border: "1.5px solid #e0d090", borderRadius: 8, cursor: "pointer", display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ fontSize: 18 }}>⏱</div>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: "#7a6a00" }}>{expiredQuotes.length} Offerte{expiredQuotes.length > 1 ? "n" : ""} abgelaufen</div>
<div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>Gültigkeit überschritten</div>
</div>
</div>
)}
</div>
)}
{/* Hauptbereich: 3 Spalten */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 20, marginBottom: 20 }} className="responsive-grid-2">
{/* Aktive Projekte mit Budget */}
<div className="card" style={{ gridColumn: "span 1" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", color: "var(--text4)" }}>AKTIVE PROJEKTE</div>
<button onClick={() => setView("projects")} style={{ background: "none", border: "none", fontSize: 11, color: "var(--text4)", cursor: "pointer", fontFamily: "inherit" }}>Alle →</button>
</div>
{activeProjects.length === 0 ? (
<div style={{ fontSize: 13, color: "var(--text5)", textAlign: "center", padding: "20px 0" }}>Keine aktiven Projekte</div>
) : activeProjects.slice(0, 6).map(p => {
const used = projectMinutes(p.id);
const budget = p.budgetHours || 0;
const pct = budget > 0 ? Math.min((used / 60) / budget, 1) : 0;
const overBudget = budget > 0 && (used / 60) > budget;
const client = ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === p.clientId);
return (
<div key={p.id} style={{ marginBottom: 14, paddingBottom: 14, borderBottom: "1px solid var(--border2)" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 500, color: "var(--text)" }}>{p.name}</div>
{client && <div style={{ fontSize: 10, color: "var(--text4)", marginTop: 1 }}>{client.name}</div>}
</div>
<div style={{ textAlign: "right", fontSize: 11, color: overBudget ? "#8a1a1a" : "var(--text4)", whiteSpace: "nowrap" }}>
{formatHours(used)}{budget > 0 ? ` / ${budget}h` : ""}
</div>
</div>
{budget > 0 && (
<div style={{ height: 3, background: "var(--border2)", borderRadius: 2 }}>
<div style={{ width: `${pct * 100}%`, height: "100%", background: overBudget ? "#8a1a1a" : pct > 0.8 ? "#b5621e" : "#2d6a4f", borderRadius: 2, transition: "width 0.3s" }} />
</div>
)}
</div>
);
})}
</div>
{/* Unverrechnete Stunden */}
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", color: "var(--text4)" }}>UNVERRECHNETE STUNDEN</div>
<button onClick={() => setView("invoices")} style={{ background: "none", border: "none", fontSize: 11, color: "var(--text4)", cursor: "pointer", fontFamily: "inherit" }}>→</button>
</div>
{totalUnbilledMins === 0 ? (
<div style={{ fontSize: 13, color: "#2d6a4f", textAlign: "center", padding: "20px 0" }}>✓ Alles verrechnet</div>
) : (
<>
<div style={{ fontSize: 22, fontFamily: "'Playfair Display', serif", fontWeight: 700, color: "#b5621e", marginBottom: 2 }}>{formatHours(totalUnbilledMins)}</div>
<div style={{ fontSize: 11, color: "var(--text5)", marginBottom: 16 }}>≈ {formatCHF(unbilledProjects.reduce((s, p) => s + p.unbilledAmount, 0))}</div>
{unbilledProjects.slice(0, 5).map(p => (
<div key={p.id} style={{ display: "flex", justifyContent: "space-between", padding: "5px 0", borderBottom: "1px solid var(--border2)", fontSize: 12 }}>
<span style={{ color: "var(--text2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: "60%" }}>{p.name}</span>
<span style={{ color: "#b5621e", flexShrink: 0 }}>{formatHours(p.unbilledMins)}</span>
</div>
))}
</>
)}
</div>
{/* Monatsumsatz Sparkline + Offerte */}
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div className="card">
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", color: "var(--text4)", marginBottom: 14 }}>UMSATZ LETZTE 6 MONATE</div>
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, height: 60 }}>
{monthlyRevenue.map(({ m, paid }) => (
<div key={m} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}>
<div style={{ width: "100%", height: paid > 0 ? `${Math.max((paid / maxRevMonth) * 54, 4)}px` : "2px", background: m === thisMonth ? "#d4a85a" : paid > 0 ? "#2d6a4f" : "var(--border2)", borderRadius: "2px 2px 0 0", transition: "height 0.3s" }} title={formatCHF(paid)} />
<div style={{ fontSize: 8, color: m === thisMonth ? "#d4a85a" : "var(--text5)" }}>
{new Date(m + "-01").toLocaleString("de-CH", { month: "short" })}
</div>
</div>
))}
</div>
</div>
<div className="card" style={{ flex: 1 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", color: "var(--text4)" }}>OFFENE OFFERTEN</div>
<button onClick={() => setView("quotes")} style={{ background: "none", border: "none", fontSize: 11, color: "var(--text4)", cursor: "pointer", fontFamily: "inherit" }}>Alle →</button>
</div>
{pendingQuotes.length === 0 ? (
<div style={{ fontSize: 12, color: "var(--text5)" }}>Keine pendenten Offerten</div>
) : pendingQuotes.slice(0, 4).map(q => {
const cl = ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === q.clientId);
const expired = q.validUntil && q.validUntil < today;
return (
<div key={q.id} style={{ display: "flex", justifyContent: "space-between", padding: "5px 0", borderBottom: "1px solid var(--border2)", fontSize: 12 }}>
<div>
<div style={{ color: expired ? "#b5621e" : "var(--text2)" }}>{q.number}</div>
{cl && <div style={{ fontSize: 10, color: "var(--text5)" }}>{cl.name}</div>}
</div>
<div style={{ textAlign: "right", flexShrink: 0 }}>
<div style={{ color: "var(--text2)", fontWeight: 500 }}>{formatCHF(q.total)}</div>
{expired && <div style={{ fontSize: 9, color: "#b5621e" }}>abgelaufen</div>}
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Untere Zeile: Letzte Zeiteinträge */}
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", color: "var(--text4)" }}>LETZTE ZEITEINTRÄGE</div>
<button onClick={() => setView("time")} style={{ background: "none", border: "none", fontSize: 11, color: "var(--text4)", cursor: "pointer", fontFamily: "inherit" }}>Zeiterfassung →</button>
</div>
{recentTime.length === 0 ? (
<div style={{ fontSize: 13, color: "var(--text5)", padding: "8px 0" }}>Noch keine Zeiteinträge</div>
) : (
<table>
<thead>
<tr>
<th>Datum</th><th>Projekt</th><th>Beschreibung</th><th style={{ textAlign: "right" }}>Dauer</th>
</tr>
</thead>
<tbody>
{recentTime.map(e => {
const proj = data.projects.find(p => p.id === e.projectId);
const phase = SIA_PHASES.find(ph => ph.id === e.phaseId);
return (
<tr key={e.id}>
<td>{formatDate(e.date)}</td>
<td>
<div style={{ color: "var(--text)" }}>{proj?.name || "—"}</div>
{phase && <div style={{ fontSize: 10, color: "var(--text5)" }}>Phase {phase.id}</div>}
</td>
<td style={{ color: "var(--text3)" }}>{e.description || "—"}</td>
<td style={{ textAlign: "right", fontWeight: 500 }}>{formatHours(e.minutes)}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
import React from "react";
import { PROTOKOLL_TYPES } from "../constants.js";
import { formatDate } from "../utils.js";
import { Header } from "../components/UI.jsx";
export default function Dokumente({ data, setView }) {
const protocols = (data.protocols || []).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
const deliveryNotes = (data.deliveryNotes || []).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
const letterTemplates = data.letterTemplates || [];
const recentProtos = protocols.slice(0, 6);
const recentNotes = deliveryNotes.slice(0, 5);
const protoByType = PROTOKOLL_TYPES.map(t => ({
type: t,
count: protocols.filter(p => p.type === t).length,
})).filter(r => r.count > 0).sort((a, b) => b.count - a.count);
const maxTypeCount = protoByType[0]?.count || 1;
const getClient = (n) => {
if (n.clientId) return (data.persons||[]).filter(p=>p.isAuftraggeber).find(c => c.id === n.clientId)?.name || "—";
if (n.projectId) {
const proj = data.projects?.find(p => p.id === n.projectId);
if (proj?.clientId) return (data.persons||[]).filter(p=>p.isAuftraggeber).find(c => c.id === proj.clientId)?.name || "—";
}
return "—";
};
const getProject = (n) => {
if (!n.projectId) return null;
return data.projects?.find(p => p.id === n.projectId)?.name || null;
};
const SectionCard = ({ title, count, action, onAction, children, accent }) => (
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "14px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: "1px solid #ece8e2" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>{title}</span>
{count > 0 && (
<span style={{ fontSize: 10, fontWeight: 700, background: accent || "#ece8e2", color: "#1a1a18", padding: "2px 7px", borderRadius: 10 }}>{count}</span>
)}
</div>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={onAction}>
{action}
</button>
</div>
{children}
</div>
);
return (
<div>
<Header title="Dokumente" />
{/* KPI-Zeile */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16, marginBottom: 24 }}>
{[
{ label: "Protokolle", count: protocols.length, sub: `${recentProtos.length > 0 ? formatDate(recentProtos[0]?.date) : "—"} zuletzt`, view: "protokolle", color: "#2d6a4f", bg: "#e8f5ee" },
{ label: "Lieferscheine", count: deliveryNotes.length, sub: `${recentNotes.length > 0 ? formatDate(recentNotes[0]?.date) : "—"} zuletzt`, view: "lieferscheine", color: "#1a4e8a", bg: "#e8f0fa" },
{ label: "Briefvorlagen", count: letterTemplates.length, sub: "Vorlagen", view: "letters", color: "#7a3e8a", bg: "#f3eafa" },
].map(({ label, count, sub, view: v, color, bg }) => (
<div key={label} className="card" style={{ cursor: "pointer", transition: "box-shadow 0.15s" }}
onClick={() => setView(v)}>
<div style={{ fontSize: 10, letterSpacing: "0.1em", color: "#888", marginBottom: 8 }}>{label.toUpperCase()}</div>
<div style={{ fontSize: 36, fontFamily: "'Playfair Display', serif", fontWeight: 700, color, lineHeight: 1, marginBottom: 4 }}>{count}</div>
<div style={{ fontSize: 11, color: "#aaa" }}>{sub}</div>
<div style={{ marginTop: 12 }}>
<span style={{ fontSize: 10, fontWeight: 600, color, background: bg, padding: "3px 10px", borderRadius: 20 }}> Öffnen</span>
</div>
</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "3fr 2fr", gap: 20, alignItems: "start" }}>
{/* Linke Spalte */}
<div>
{/* Letzte Protokolle */}
<SectionCard title="LETZTE PROTOKOLLE" count={protocols.length} action="Alle Protokolle →" onAction={() => setView("protokolle")} accent="#e8f5ee">
{recentProtos.length === 0 ? (
<div style={{ padding: "20px", fontSize: 12, color: "#aaa" }}>Noch keine Protokolle erfasst.</div>
) : (
<table style={{ tableLayout: "fixed" }}>
<thead>
<tr>
<th style={{ width: "11%" }}>Datum</th>
<th style={{ width: "18%" }}>Typ</th>
<th>Titel</th>
<th>Projekt</th>
<th style={{ width: "12%" }}>Einträge</th>
</tr>
</thead>
<tbody>
{recentProtos.map(p => {
const proj = getProject(p);
return (
<tr key={p.id}>
<td style={{ fontSize: 11, color: "#888" }}>{formatDate(p.date)}</td>
<td>
<span style={{ fontSize: 10, background: "#f5f2ec", color: "#555", padding: "2px 10px", borderRadius: 20 }}>{p.type || "—"}</span>
</td>
<td style={{ fontWeight: 500 }}>{p.title || <span style={{ color: "#ccc" }}></span>}</td>
<td style={{ fontSize: 11, color: "#888" }}>{proj || <span style={{ color: "#ddd" }}></span>}</td>
<td style={{ fontSize: 11, color: "#888", textAlign: "center" }}>{(p.entries || []).length}</td>
</tr>
);
})}
</tbody>
</table>
)}
</SectionCard>
{/* Letzte Lieferscheine */}
<SectionCard title="LETZTE LIEFERSCHEINE" count={deliveryNotes.length} action="Alle Lieferscheine →" onAction={() => setView("lieferscheine")} accent="#e8f0fa">
{recentNotes.length === 0 ? (
<div style={{ padding: "20px", fontSize: 12, color: "#aaa" }}>Noch keine Lieferscheine erfasst.</div>
) : (
<table style={{ tableLayout: "fixed" }}>
<thead>
<tr>
<th style={{ width: "14%" }}>Nr.</th>
<th style={{ width: "11%" }}>Datum</th>
<th>Kunde</th>
<th>Projekt</th>
<th style={{ width: "12%" }}>Positionen</th>
</tr>
</thead>
<tbody>
{recentNotes.map(n => (
<tr key={n.id}>
<td style={{ fontWeight: 600, fontSize: 12 }}>{n.number || "—"}</td>
<td style={{ fontSize: 11, color: "#888" }}>{formatDate(n.date)}</td>
<td style={{ fontSize: 12 }}>{getClient(n)}</td>
<td style={{ fontSize: 11, color: "#888" }}>{getProject(n) || <span style={{ color: "#ddd" }}></span>}</td>
<td style={{ fontSize: 11, color: "#888", textAlign: "center" }}>{(n.items || []).length}</td>
</tr>
))}
</tbody>
</table>
)}
</SectionCard>
</div>
{/* Rechte Spalte */}
<div>
{/* Protokoll-Typen */}
{protoByType.length > 0 && (
<div className="card" style={{ marginBottom: 20 }}>
<div className="section-label">PROTOKOLLE NACH TYP</div>
{protoByType.map(({ type, count }) => {
const pct = (count / maxTypeCount) * 100;
return (
<div key={type} style={{ marginBottom: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, marginBottom: 3 }}>
<span style={{ color: "#555" }}>{type}</span>
<span style={{ fontWeight: 600 }}>{count}</span>
</div>
<div style={{ height: 5, background: "#ece8e2", borderRadius: 3, overflow: "hidden" }}>
<div style={{ width: `${pct}%`, height: "100%", background: "#2d6a4f", borderRadius: 3 }} />
</div>
</div>
);
})}
</div>
)}
{/* Briefvorlagen */}
<div className="card" style={{ padding: 0 }}>
<div style={{ padding: "14px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: letterTemplates.length > 0 ? "1px solid #ece8e2" : "none" }}>
<span className="section-label" style={{ marginBottom: 0 }}>BRIEFVORLAGEN</span>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => setView("letters")}>
Verwalten
</button>
</div>
{letterTemplates.length === 0 ? (
<div style={{ padding: "20px", fontSize: 12, color: "#aaa" }}>Noch keine Briefvorlagen erstellt.</div>
) : (
<div style={{ padding: "8px 0" }}>
{letterTemplates.map(t => (
<div key={t.id} style={{ padding: "8px 20px", borderBottom: "1px solid #f5f2ec", display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 13, color: "#555", flex: 1 }}>{t.name || "Unbenannt"}</span>
<span style={{ fontSize: 10, color: "#aaa" }}>
{t.body ? `${t.body.replace(/<[^>]+>/g, "").slice(0, 40)}` : "—"}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+1467
View File
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
import React, { useState, useEffect } from "react";
import { generateId, textToHtml, htmlToText } from "../utils.js";
import { Header, FormField, RichEditor, useConfirm } from "../components/UI.jsx";
export default
function Letters({ data, update, setPrintContent }) {
const [selectedTemplate, setSelectedTemplate] = useState(data.letterTemplates[0]?.id || "");
const [clientId, setClientId] = useState("");
const [projectId, setProjectId] = useState("");
const [body, setBody] = useState(() => textToHtml(data.letterTemplates[0]?.body || ""));
const { askConfirm, ConfirmModalEl } = useConfirm();
const [subject, setSubject] = useState(data.letterTemplates[0]?.name || "");
const prevTemplate = React.useRef(selectedTemplate);
useEffect(() => {
const tpl = data.letterTemplates.find(t => t.id === selectedTemplate);
if (tpl) {
const client = ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === clientId);
const proj = data.projects.find(p => p.id === projectId);
let text = tpl.body || "";
text = text.replace(/\{\{client\}\}/g, client?.name || "[Kunde]");
text = text.replace(/\{\{project\}\}/g, proj?.name || "[Projekt]");
setBody(textToHtml(text));
setSubject(tpl.name);
}
}, [selectedTemplate, clientId, projectId]);
const client = ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === clientId);
const saveAsTemplate = () => {
const name = prompt("Name der neuen Vorlage?");
if (!name) return;
const tpl = { id: generateId(), name, body };
update("letterTemplates", [...data.letterTemplates, tpl]);
setSelectedTemplate(tpl.id);
};
const updateTemplate = () => {
update("letterTemplates", data.letterTemplates.map(t => t.id === selectedTemplate ? { ...t, body } : t));
};
const deleteTemplate = async () => {
if (!(await askConfirm("Vorlage löschen?"))) return;
const remaining = data.letterTemplates.filter(t => t.id !== selectedTemplate);
update("letterTemplates", remaining);
setSelectedTemplate(remaining[0]?.id || "");
};
return (
<div>
{ConfirmModalEl}
<Header title="Briefe" action={
<button className="btn btn-primary" onClick={() => setPrintContent({ type: "letter", client, subject, body, isHtml: true, settings: data.settings })}>
Drucken / PDF
</button>
} />
<div style={{ display: "grid", gridTemplateColumns: "280px 1fr", gap: 20, alignItems: "start" }}>
{/* Linke Spalte */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div className="card">
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "var(--text4)", fontWeight: 600, marginBottom: 12 }}>VORLAGE</div>
<select value={selectedTemplate} onChange={e => setSelectedTemplate(e.target.value)} style={{ marginBottom: 10 }}>
{data.letterTemplates.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-ghost" style={{ flex: 1, fontSize: 11, padding: "5px 8px" }} onClick={updateTemplate}>Überschreiben</button>
<button className="btn btn-ghost" style={{ flex: 1, fontSize: 11, padding: "5px 8px" }} onClick={saveAsTemplate}>Neu speichern</button>
<button className="btn btn-ghost" style={{ padding: "5px 8px", fontSize: 11 }} onClick={deleteTemplate}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
<div className="card">
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "var(--text4)", fontWeight: 600, marginBottom: 12 }}>EMPFÄNGER</div>
<FormField label="Kunde">
<select value={clientId} onChange={e => setClientId(e.target.value)}>
<option value=""> wählen </option>
{((data.persons||[]).filter(p=>p.isAuftraggeber)).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</FormField>
<FormField label="Projekt (optional)">
<select value={projectId} onChange={e => setProjectId(e.target.value)}>
<option value=""> keines </option>
{data.projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</FormField>
</div>
<div className="card" style={{ fontSize: 11, color: "var(--text5)", lineHeight: 1.7 }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "var(--text4)", fontWeight: 600, marginBottom: 8 }}>PLATZHALTER</div>
<code style={{ fontSize: 10, background: "var(--surface2)", padding: "1px 5px", borderRadius: 3 }}>{"{{client}}"}</code> Kundenname<br/>
<code style={{ fontSize: 10, background: "var(--surface2)", padding: "1px 5px", borderRadius: 3, marginTop: 4, display: "inline-block" }}>{"{{project}}"}</code> Projektname
</div>
</div>
{/* Editor */}
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border2)" }}>
<input
value={subject}
onChange={e => setSubject(e.target.value)}
placeholder="Betreff / Titel"
style={{ fontSize: 15, fontWeight: 500, border: "none", background: "transparent", color: "var(--text)", width: "100%", outline: "none", height: "auto" }}
/>
</div>
<div style={{ padding: "0" }}>
<RichEditor value={body} onChange={setBody} />
</div>
</div>
</div>
</div>
);
}
+294
View File
@@ -0,0 +1,294 @@
import React, { useState } from "react";
import { generateId, formatCHF, formatDate, linkedClientForNote } from "../utils.js";
import { Header, Modal, FormField, StatusBadge, useConfirm , DateInput } from "../components/UI.jsx";
export default
function Lieferscheine({ data, update, saveAll, setPrintContent }) {
const today = new Date().toISOString().slice(0, 10);
const emptyForm = {
date: today,
number: "",
projectId: "",
clientId: "",
clientManual: "",
projectManual: "",
deliveryAddress: "",
notes: "",
items: [{ id: generateId(), desc: "", qty: 1, unit: "Stk.", note: "" }],
};
const [modal, setModal] = useState(null); // null | "new" | id
const [form, setForm] = useState(emptyForm);
const [filter, setFilter] = useState({ search: "", projectId: "", clientId: "" });
const [sort, setSort] = useState({ col: "date", dir: -1 });
const { askConfirm, ConfirmModalEl } = useConfirm();
const notes = data.deliveryNotes || [];
// Nummernvergabe
const nextNumber = () => {
const year = new Date().getFullYear();
const existing = notes.map(n => {
const m = (n.number || "").match(/LS[-]?(\d+)/i);
return m ? parseInt(m[1]) : 0;
});
const max = existing.length ? Math.max(...existing) : 0;
return `LS-${year}-${String(max + 1).padStart(3, "0")}`;
};
const openNew = () => {
setForm({ ...emptyForm, number: nextNumber() });
setModal("new");
};
const openEdit = (n) => { setForm({ ...n }); setModal(n.id); };
const closeModal = () => { setModal(null); setForm(emptyForm); };
const save = () => {
const isNew = modal === "new";
const entry = { ...form, id: isNew ? generateId() : modal };
const updated = isNew
? [...notes, { ...entry, createdAt: new Date().toISOString() }]
: notes.map(n => n.id === modal ? entry : n);
saveAll({ ...data, deliveryNotes: updated });
closeModal();
};
const del = async (id) => {
if (await askConfirm("Lieferschein löschen?"))
saveAll({ ...data, deliveryNotes: notes.filter(n => n.id !== id) });
};
const addItem = () => setForm(f => ({ ...f, items: [...f.items, { id: generateId(), desc: "", qty: 1, unit: "Stk.", note: "" }] }));
const updateItem = (id, changes) => setForm(f => ({ ...f, items: f.items.map(it => it.id === id ? { ...it, ...changes } : it) }));
const removeItem = (id) => setForm(f => ({ ...f, items: f.items.filter(it => it.id !== id) }));
// Ableitungen für Anzeige
const getClient = (n) => {
if (n.clientId) return ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === n.clientId)?.name || "—";
return n.clientManual || "—";
};
const getProject = (n) => {
if (n.projectId) return data.projects.find(p => p.id === n.projectId)?.name || "—";
return n.projectManual || "—";
};
// Filter
const filtered = notes.filter(n => {
if (filter.projectId && n.projectId !== filter.projectId) return false;
if (filter.clientId && n.clientId !== filter.clientId) return false;
if (filter.search) {
const q = filter.search.toLowerCase();
const text = [n.number, getClient(n), getProject(n), n.notes, ...(n.items || []).map(it => it.desc)].join(" ").toLowerCase();
if (!text.includes(q)) return false;
}
return true;
});
const toggleSort = (col) => setSort(s => ({ col, dir: s.col === col ? -s.dir : -1 }));
const SortTh = ({ col, children, style }) => (
<th onClick={() => toggleSort(col)} style={{ cursor: "pointer", userSelect: "none", ...style }}>
{children} <span style={{ color: sort.col === col ? "#b07848" : "#ccc", fontSize: 10 }}>{sort.col === col ? (sort.dir === 1 ? "▲" : "▼") : "⇅"}</span>
</th>
);
const sorted = [...filtered].sort((a, b) => {
const va = sort.col === "date" ? (a.date || "") : sort.col === "number" ? (a.number || "") : sort.col === "client" ? getClient(a) : getProject(a);
const vb = sort.col === "date" ? (b.date || "") : sort.col === "number" ? (b.number || "") : sort.col === "client" ? getClient(b) : getProject(b);
return va.localeCompare(vb) * sort.dir;
});
const UNITS = ["Stk.", "m", "m²", "m³", "kg", "l", "Set", "Blatt", "Rolle", "Palette", "Pkg."];
// Client-Felder im Modal: verknüpft oder manuell
const linkedClient = form.clientId ? ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === form.clientId) : null;
const linkedProject = form.projectId ? data.projects.find(p => p.id === form.projectId) : null;
return (
<div>
{ConfirmModalEl}
<Header title="Lieferscheine" action={
<button className="btn btn-primary" onClick={openNew}>+ Neuer Lieferschein</button>
} />
<div className="filter-bar">
<input className="pill" placeholder="Suche (Nr., Kunde, Projekt, Position…)" value={filter.search} onChange={e => setFilter({ ...filter, search: e.target.value })} style={{ minWidth: 220 }} />
<select className="pill" value={filter.clientId} onChange={e => setFilter({ ...filter, clientId: e.target.value })}>
<option value="">Alle Kunden</option>
{((data.persons||[]).filter(p=>p.isAuftraggeber)).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<select className="pill" value={filter.projectId} onChange={e => setFilter({ ...filter, projectId: e.target.value })}>
<option value="">Alle Projekte</option>
{data.projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
{(filter.search || filter.clientId || filter.projectId) && (
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => setFilter({ search: "", projectId: "", clientId: "" })}>Zurücksetzen</button>
)}
<div style={{ marginLeft: "auto", fontSize: 12, color: "#888" }}>
<strong style={{ color: "#1a1a18" }}>{filtered.length}</strong> {filtered.length === 1 ? "Lieferschein" : "Lieferscheine"}
</div>
</div>
{/* Tabelle */}
{sorted.length === 0 ? (
<div className="card" style={{ padding: 0 }}>
<table>
<thead><tr><th style={{ width: 120 }}>Nr.</th><th style={{ width: 100 }}>Datum</th><th style={{ width: 180 }}>Empfänger</th><th>Projekt</th><th style={{ width: 60 }}>Pos.</th><th style={{ width: 120 }}></th></tr></thead>
<tbody><tr><td colSpan={6} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>{notes.length === 0 ? "Noch keine Lieferscheine" : "Keine Treffer"}</td></tr></tbody>
</table>
</div>
) : (
<div className="card" style={{ padding: 0 }}>
<table>
<thead>
<tr>
<SortTh col="number" style={{ width: 120 }}>Nr.</SortTh>
<SortTh col="date" style={{ width: 100 }}>Datum</SortTh>
<SortTh col="client" style={{ width: 180 }}>Empfänger</SortTh>
<SortTh col="project">Projekt</SortTh>
<th style={{ width: 60, textAlign: "center" }}>Pos.</th>
<th style={{ width: 120 }}></th>
</tr>
</thead>
<tbody>
{sorted.map(n => (
<tr key={n.id}>
<td><strong style={{ color: "#b07848" }}>{n.number || "—"}</strong></td>
<td>{formatDate(n.date)}</td>
<td>{getClient(n)}</td>
<td style={{ color: "#888" }}>{getProject(n)}</td>
<td style={{ textAlign: "center", color: "#888", fontSize: 12 }}>{(n.items || []).length}</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn btn-ghost" style={{ padding: "5px 10px", fontSize: 12, marginRight: 4 }}
onClick={() => { const client = linkedClientForNote(n, data); setPrintContent({ type: "lieferschein", note: n, client, settings: data.settings, data }); }}>
PDF
</button>
<button className="btn btn-ghost" style={{ padding: "5px 10px", fontSize: 12, marginRight: 4 }} onClick={() => openEdit(n)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => del(n.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Modal */}
{modal && (
<Modal title={modal === "new" ? "Neuer Lieferschein" : "Lieferschein bearbeiten"} onClose={closeModal} onSave={save} wide>
{/* Kopfdaten */}
<div style={{ fontSize: 11, letterSpacing: "0.08em", color: "#888", marginBottom: 10 }}>ALLGEMEIN</div>
<div className="form-row">
<FormField label="Nummer">
<input value={form.number} onChange={e => setForm({ ...form, number: e.target.value })} placeholder="LS-2025-001" />
</FormField>
<FormField label="Datum">
<DateInput value={form.date} onChange={e => setForm({ ...form, date: e.target.value })} />
</FormField>
</div>
{/* Empfänger */}
<div style={{ fontSize: 11, letterSpacing: "0.08em", color: "#888", margin: "14px 0 10px", paddingTop: 12, borderTop: "1px solid var(--border2)" }}>EMPFÄNGER</div>
<div className="form-row">
<FormField label="Kunde (verknüpft)">
<select value={form.clientId} onChange={e => {
const c = ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(x => x.id === e.target.value);
setForm({ ...form, clientId: e.target.value, clientManual: "", deliveryAddress: "" });
}}>
<option value=""> manuell eingeben </option>
{((data.persons||[]).filter(p=>p.isAuftraggeber)).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</FormField>
{!form.clientId && (
<FormField label="Empfänger (manuell)">
<input value={form.clientManual} onChange={e => setForm({ ...form, clientManual: e.target.value })} placeholder="Firmen- oder Personenname" />
</FormField>
)}
</div>
<FormField label="Lieferadresse">
{form.clientId ? (
<div style={{ padding: "8px 10px", background: "var(--surface2)", border: "1.5px solid var(--border)", borderRadius: 4, fontSize: 12, color: "var(--text2)", lineHeight: 1.6, minHeight: 60, whiteSpace: "pre-line" }}>
{linkedClient?.address || <span style={{ color: "var(--text4)", fontStyle: "italic" }}>Keine Adresse beim Kunden hinterlegt</span>}
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 6, borderTop: "1px solid var(--border2)", paddingTop: 4 }}>
Adresse aus Kundenstamm · unter «Kunden» bearbeitbar
</div>
</div>
) : (
<textarea
value={form.deliveryAddress}
onChange={e => setForm({ ...form, deliveryAddress: e.target.value })}
placeholder={"Strasse\nPLZ Ort"}
style={{ minHeight: 60, resize: "vertical" }}
/>
)}
</FormField>
{/* Projekt */}
<div style={{ fontSize: 11, letterSpacing: "0.08em", color: "#888", margin: "14px 0 10px", paddingTop: 12, borderTop: "1px solid var(--border2)" }}>PROJEKT</div>
<div className="form-row">
<FormField label="Projekt (verknüpft)">
<select value={form.projectId} onChange={e => setForm({ ...form, projectId: e.target.value, projectManual: "" })}>
<option value=""> manuell eingeben </option>
{data.projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</FormField>
{!form.projectId && (
<FormField label="Projekt (manuell)">
<input value={form.projectManual} onChange={e => setForm({ ...form, projectManual: e.target.value })} placeholder="Projektbezeichnung" />
</FormField>
)}
</div>
{/* Positionen */}
<div style={{ fontSize: 11, letterSpacing: "0.08em", color: "#888", margin: "14px 0 10px", paddingTop: 12, borderTop: "1px solid var(--border2)" }}>POSITIONEN</div>
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 10 }}>
<thead>
<tr>
<th style={{ fontSize: 10, color: "#888", fontWeight: 500, textAlign: "left", padding: "4px 6px 6px 0", width: "40%" }}>Beschreibung</th>
<th style={{ fontSize: 10, color: "#888", fontWeight: 500, textAlign: "right", padding: "4px 6px", width: 70 }}>Menge</th>
<th style={{ fontSize: 10, color: "#888", fontWeight: 500, textAlign: "left", padding: "4px 6px", width: 80 }}>Einheit</th>
<th style={{ fontSize: 10, color: "#888", fontWeight: 500, textAlign: "left", padding: "4px 6px" }}>Bemerkung</th>
<th style={{ width: 32 }}></th>
</tr>
</thead>
<tbody>
{form.items.map((it, i) => (
<tr key={it.id}>
<td style={{ padding: "4px 6px 4px 0" }}>
<input value={it.desc} onChange={e => updateItem(it.id, { desc: e.target.value })} placeholder={`Position ${i + 1}`} style={{ height: 32, fontSize: 12 }} autoFocus={i === form.items.length - 1 && form.items.length > 1} />
</td>
<td style={{ padding: "4px 6px" }}>
<input type="number" min={0} step="0.01" value={it.qty} onChange={e => updateItem(it.id, { qty: +e.target.value })} style={{ height: 32, fontSize: 12, textAlign: "right" }} />
</td>
<td style={{ padding: "4px 6px" }}>
<select value={it.unit} onChange={e => updateItem(it.id, { unit: e.target.value })} style={{ height: 32, fontSize: 12 }}>
{UNITS.map(u => <option key={u} value={u}>{u}</option>)}
</select>
</td>
<td style={{ padding: "4px 6px" }}>
<input value={it.note || ""} onChange={e => updateItem(it.id, { note: e.target.value })} placeholder="optional" style={{ height: 32, fontSize: 12 }} />
</td>
<td style={{ padding: "4px 0 4px 4px" }}>
{form.items.length > 1 && (
<button onClick={() => removeItem(it.id)} style={{ background: "none", border: "none", color: "#aaa", cursor: "pointer", fontSize: 16, padding: 0, lineHeight: 1 }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
)}
</td>
</tr>
))}
</tbody>
</table>
<button className="btn btn-ghost" style={{ fontSize: 12, marginBottom: 14 }} onClick={addItem}>+ Position hinzufügen</button>
{/* Notizen */}
<div style={{ fontSize: 11, letterSpacing: "0.08em", color: "#888", margin: "14px 0 10px", paddingTop: 12, borderTop: "1px solid var(--border2)" }}>NOTIZEN / BEMERKUNGEN</div>
<FormField label="">
<textarea value={form.notes || ""} onChange={e => setForm({ ...form, notes: e.target.value })} placeholder="Allgemeine Bemerkungen zum Lieferschein…" style={{ minHeight: 72, resize: "vertical" }} />
</FormField>
</Modal>
)}
</div>
);
}
// Hilfsfunktion für PDF-Knopf in Tabelle
+344
View File
@@ -0,0 +1,344 @@
import React, { useState } from "react";
import { generateId, formatCHF, formatDate, formatIban, calcLohn } from "../utils.js";
import { Header, useConfirm, NavArrows } from "../components/UI.jsx";
export default
function Loehne({ data, update, saveAll, setPrintContent, setView }) {
const now = new Date();
const [selYear, setSelYear] = useState(now.getFullYear());
const [selMonth, setSelMonth] = useState(now.getMonth());
const [selEmpId, setSelEmpId] = useState("");
const [bruttoOverride, setBruttoOverride] = useState({});
const [pensumOverride, setPensumOverride] = useState({});
const [bonusOverride, setBonusOverride] = useState({}); // empId -> bonus CHF
const { askConfirm, ConfirmModalEl } = useConfirm();
const employees = data.employees || [];
const lohnEntries = data.lohnEntries || [];
const months = ["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"];
const monatStr = `${selYear}-${String(selMonth + 1).padStart(2, "0")}`;
const todayYear = now.getFullYear();
const todayMonth = now.getMonth();
const isAtFuture = selYear > todayYear || (selYear === todayYear && selMonth >= todayMonth);
const goNext = () => {
if (isAtFuture) return;
if (selMonth === 11) { setSelMonth(0); setSelYear(y => y + 1); } else setSelMonth(m => m + 1);
};
const goPrev = () => {
if (selMonth === 0) { setSelMonth(11); setSelYear(y => y - 1); } else setSelMonth(m => m - 1);
};
const isMonatAvailable = (emp) => {
// Nicht in die Zukunft (aktueller Monat ist max)
if (selYear > todayYear || (selYear === todayYear && selMonth > todayMonth)) return false;
// Nicht vor Eintrittsdatum
if (emp.eintrittsdatum) {
const parts = emp.eintrittsdatum.split("-");
const ey = parseInt(parts[0]);
const em = parseInt(parts[1]) - 1; // 0-basiert
if (selYear < ey || (selYear === ey && selMonth < em)) return false;
}
return true;
};
const getEffBrutto = (emp) => bruttoOverride[emp.id] != null ? bruttoOverride[emp.id] : (emp.monatslohn || 0);
const getEffPensum = (emp) => pensumOverride[emp.id] != null ? pensumOverride[emp.id] : (emp.pensum || 100);
const offeneSpesen = (empId) => (data.expenses || []).filter(e =>
e.employeeId === empId && (e.date || "").startsWith(monatStr) && !e.lohnEntryId
);
const findEntry = (empId) => lohnEntries.find(l => l.monat === monatStr && l.employeeId === empId);
const abschliessen = (emp) => {
if (findEntry(emp.id)) return;
const spesen = offeneSpesen(emp.id);
const effBrutto = getEffBrutto(emp);
const effPensum = getEffPensum(emp);
const bonus = bonusOverride[emp.id] || 0;
const calc = calcLohn({ ...emp, monatslohn: effBrutto, pensum: effPensum }, monatStr, spesen, bonus);
// Alle Sätze statisch festhalten — unabhängig von späteren Änderungen
const saetzeSnapshot = {
ahvSatz: emp.ahvSatz ?? 5.3,
alvSatz: emp.alvSatz ?? 1.1,
bvgSatz: emp.bvgSatz ?? 8.0,
nbuSatz: emp.nbuSatz ?? 1.5,
ktgSatz: emp.ktgSatz ?? 0.5,
quellensteuerPflichtig: emp.quellensteuerPflichtig || false,
quellensteuerSatz: emp.quellensteuerSatz ?? 10,
dreizehnterLohn: emp.dreizehnterLohn || false,
pensum: effPensum,
eintrittsdatum: emp.eintrittsdatum || "",
pkAGSatz: emp.pkAGSatz ?? 8.0,
};
const entry = {
id: generateId(), monat: monatStr, employeeId: emp.id,
empSnapshot: { name: emp.name, role: emp.role, adresse: emp.adresse, ort: emp.ort, ahvNr: emp.ahvNr, personalNr: emp.personalNr, lohnIban: emp.lohnIban },
saetzeSnapshot,
bruttoWasOverridden: bruttoOverride[emp.id] != null,
bonusBeschrieb: bonusOverride[`${emp.id}_beschrieb`] || "",
...calc, spesenIds: spesen.map(s => s.id),
createdAt: new Date().toISOString(),
};
const updatedExp = (data.expenses || []).map(e =>
spesen.some(s => s.id === e.id) ? { ...e, lohnEntryId: entry.id, status: "ausbezahlt" } : e
);
saveAll({ ...data, lohnEntries: [...lohnEntries, entry], expenses: updatedExp });
};
const stornieren = async (id) => {
if (!(await askConfirm("Lohnabrechnung stornieren? Spesen werden wieder freigegeben.", "Stornieren"))) return;
const updatedExp = (data.expenses || []).map(e => e.lohnEntryId === id ? { ...e, lohnEntryId: null, status: "auf nächsten Lohn" } : e);
saveAll({ ...data, lohnEntries: lohnEntries.filter(l => l.id !== id), expenses: updatedExp });
};
const selectedEmp = employees.find(e => e.id === selEmpId);
const previewSpesen = selEmpId ? offeneSpesen(selEmpId) : [];
const previewCalc = selectedEmp ? calcLohn({ ...selectedEmp, monatslohn: getEffBrutto(selectedEmp), pensum: getEffPensum(selectedEmp) }, monatStr, previewSpesen, bonusOverride[selectedEmp?.id] || 0) : null;
const existingEntry = selEmpId ? findEntry(selEmpId) : null;
const monatEntries = lohnEntries.filter(l => l.monat === monatStr);
const cardPensum = existingEntry ? (existingEntry.saetzeSnapshot?.pensum || 100) : (selectedEmp ? getEffPensum(selectedEmp) : 100);
const LohnRow = ({ label, value, bold, color, indent }) => (
<div style={{ display: "flex", justifyContent: "space-between", padding: "3px 0", fontSize: indent ? 11 : 12, color: color || (indent ? "#888" : "#555"), paddingLeft: indent ? 12 : 0 }}>
<span>{label}</span><span style={{ fontWeight: bold ? 700 : 400 }}>{formatCHF(value)}</span>
</div>
);
const renderCalc = (calc, emp, isPreview, displayPensum) => (
<div>
{isPreview && (
<div style={{ marginBottom: 12, padding: "8px 10px", background: "#faf8f5", border: "1px solid #e0dbd4", borderRadius: 4 }}>
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "#888", marginBottom: 6 }}>MONATSLOHN (100% BASIS) für diesen Lohnlauf anpassbar</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input type="number" step={100} min={0} value={getEffBrutto(emp)}
onChange={e => setBruttoOverride(o => ({ ...o, [emp.id]: +e.target.value }))}
style={{ flex: 1, height: 32, fontSize: 13, textAlign: "right", border: bruttoOverride[emp.id] != null && bruttoOverride[emp.id] !== emp.monatslohn ? "1.5px solid #b5621e" : "1px solid #c8c0b8" }} />
<span style={{ fontSize: 11, color: "#888" }}>CHF</span>
{bruttoOverride[emp.id] != null && bruttoOverride[emp.id] !== emp.monatslohn && (
<button onClick={() => setBruttoOverride(o => { const n={...o}; delete n[emp.id]; return n; })}
title="Stammdaten-Wert wiederherstellen"
style={{ fontSize: 11, color: "#888", background: "none", border: "1px solid #c8c0b8", borderRadius: 4, cursor: "pointer", padding: "2px 8px" }}> Reset</button>
)}
</div>
{bruttoOverride[emp.id] != null && bruttoOverride[emp.id] !== emp.monatslohn && (
<div style={{ fontSize: 10, color: "#b5621e", marginTop: 4 }}>Abweichend von Stammdaten ({formatCHF(emp.monatslohn || 0)})</div>
)}
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "#888", margin: "10px 0 6px", paddingTop: 8, borderTop: "1px solid #e0dbd4" }}>PENSUM DIESEN MONAT</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
<input type="number" min={10} max={100} step={5}
value={getEffPensum(emp)}
onChange={e => setPensumOverride(o => ({ ...o, [emp.id]: +e.target.value }))}
style={{ width: 72, height: 32, fontSize: 13, textAlign: "right", border: pensumOverride[emp.id] != null && pensumOverride[emp.id] !== (emp.pensum || 100) ? "1.5px solid #b5621e" : "1px solid #c8c0b8" }} />
<span style={{ fontSize: 11, color: "#888" }}>%</span>
{pensumOverride[emp.id] != null && pensumOverride[emp.id] !== (emp.pensum || 100) && (
<button onClick={() => setPensumOverride(o => { const n={...o}; delete n[emp.id]; return n; })}
title="Stammdaten-Wert wiederherstellen"
style={{ fontSize: 11, color: "#888", background: "none", border: "1px solid #c8c0b8", borderRadius: 4, cursor: "pointer", padding: "2px 8px" }}> Reset</button>
)}
</div>
{pensumOverride[emp.id] != null && pensumOverride[emp.id] !== (emp.pensum || 100) && (
<div style={{ fontSize: 10, color: "#b5621e", marginBottom: 4 }}>Abweichend von Stammdaten ({emp.pensum || 100}%)</div>
)}
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "#888", margin: "10px 0 6px", paddingTop: 8, borderTop: "1px solid #e0dbd4" }}>EINMALZAHLUNG / BONUS (AHV-pflichtig)</div>
<div style={{ display: "flex", gap: 8 }}>
<input type="number" step={100} min={0} value={bonusOverride[emp.id] || ""}
onChange={e => setBonusOverride(o => ({ ...o, [emp.id]: +e.target.value || 0 }))}
placeholder="0"
style={{ width: 110, height: 32, fontSize: 13, textAlign: "right", border: bonusOverride[emp.id] ? "1.5px solid #2d6a4f" : "1px solid #c8c0b8" }} />
<span style={{ fontSize: 11, color: "#888", lineHeight: "32px" }}>CHF</span>
<input value={bonusOverride[`${emp.id}_beschrieb`] || ""}
onChange={e => setBonusOverride(o => ({ ...o, [`${emp.id}_beschrieb`]: e.target.value }))}
placeholder="Beschrieb (z.B. Jahresbonus)"
style={{ flex: 1, height: 32, fontSize: 12, border: "1px solid #c8c0b8" }} />
</div>
</div>
)}
<div style={{ display: "flex", justifyContent: "space-between", padding: "3px 0", fontSize: 12, color: "#555" }}>
<span>Monatslohn {months[selMonth]} {selYear}{displayPensum < 100 ? ` (${displayPensum}%)` : ""}</span>
<span>{formatCHF(calc.brutto)}</span>
</div>
{displayPensum < 100 && calc.bruttoBase != null && calc.bruttoBase !== calc.brutto && (
<div style={{ display: "flex", justifyContent: "space-between", padding: "1px 0 3px 12px", fontSize: 10, color: "#888" }}>
<span>Basis 100%: {formatCHF(calc.bruttoBase)} × {displayPensum}%</span>
</div>
)}
{calc.dreizehnter > 0 && <LohnRow label="13. Monatslohn (1/12)" value={calc.dreizehnter} indent />}
{calc.bonusBetrag > 0 && <LohnRow label={emp.saetzeSnapshot ? (emp.bonusBeschrieb || "Einmalzahlung / Bonus") : (bonusOverride[`${emp.id}_beschrieb`] || "Einmalzahlung / Bonus")} value={calc.bonusBetrag} indent />}
<LohnRow label="Bruttolohn Total" value={calc.bruttoTotal} bold />
<div style={{ marginTop: 10, marginBottom: 4, fontSize: 10, letterSpacing: "0.08em", color: "#888" }}>ABZÜGE ARBEITNEHMER</div>
{(() => {
const s = emp.saetzeSnapshot || emp; // abgeschlossen: snapshot, preview: live emp
return <>
<LohnRow label={`AHV/IV/EO (${s.ahvSatz ?? 5.3}%)`} value={-calc.ahv} indent color="#8a1a1a" />
<LohnRow label={`ALV (${s.alvSatz ?? 1.1}%)`} value={-calc.alv} indent color="#8a1a1a" />
<LohnRow label={`BVG/PK (${s.bvgSatz ?? 8.0}%)`} value={-calc.bvg} indent color="#8a1a1a" />
<LohnRow label={`NBU (${s.nbuSatz ?? 1.5}%)`} value={-calc.nbu} indent color="#8a1a1a" />
<LohnRow label={`KTG (${s.ktgSatz ?? 0.5}%)`} value={-calc.ktg} indent color="#8a1a1a" />
{calc.qst > 0 && <LohnRow label={`Quellensteuer (${s.quellensteuerSatz ?? 10}%)`} value={-calc.qst} indent color="#8a1a1a" />}
</>;
})()}
<div style={{ borderTop: "1.5px solid #1a1a18", marginTop: 8, paddingTop: 8 }}>
<LohnRow label="Nettolohn" value={calc.netto} bold />
</div>
{calc.spesenTotal > 0 && <>
<div style={{ marginTop: 10, marginBottom: 4, fontSize: 10, letterSpacing: "0.08em", color: "#888" }}>SPESENERSTATTUNG</div>
<LohnRow label="Spesen" value={calc.spesenTotal} indent />
</>}
<div style={{ borderTop: "1.5px solid #1a1a18", marginTop: 8, paddingTop: 8 }}>
<LohnRow label="Auszahlung Total" value={calc.auszahlung} bold color="#2d6a4f" />
</div>
{calc.bvgAG > 0 && (() => {
const s = emp.saetzeSnapshot || emp;
return (
<div style={{ marginTop: 14, paddingTop: 10, borderTop: "1px dashed #c8c0b8" }}>
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "#888", marginBottom: 4 }}>LOHNKOSTEN ARBEITGEBER</div>
<LohnRow label={`PK / BVG AG-Anteil (${s.pkAGSatz ?? 8.0}%)`} value={calc.bvgAG} indent color="#b5621e" />
<LohnRow label="Gesamtlohnkosten (inkl. PK AG)" value={calc.auszahlung + calc.bvgAG} bold />
</div>
);
})()}
</div>
);
return (
<div>
{ConfirmModalEl}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
</div>
<Header title="Löhne" />
{employees.length === 0 ? (
<div className="card" style={{ padding: 0 }}>
<table>
<thead><tr><th>Name</th><th style={{ textAlign: "right" }}>Brutto</th><th style={{ textAlign: "right" }}>Netto</th><th style={{ textAlign: "right" }}>Spesen</th><th style={{ textAlign: "right" }}>Auszahlung</th><th>Status</th><th></th></tr></thead>
<tbody><tr><td colSpan={7} className="empty-state">Noch keine Mitarbeiter erfasst</td></tr></tbody>
</table>
</div>
) : (
<div className="responsive-grid-2" style={{ display: "grid", gridTemplateColumns: "1fr 340px", gap: 20, alignItems: "start" }}>
<div>
<div className="card" style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", marginBottom: 16 }}>
<NavArrows onPrev={goPrev} onNext={goNext} disabledNext={isAtFuture} />
<div style={{ flex: 1, textAlign: "center", fontFamily: "'Playfair Display', serif", fontSize: 18 }}>{months[selMonth]} {selYear}</div>
</div>
<div className="card" style={{ padding: 0 }}>
<div className="panel-label">MITARBEITER</div>
<table>
<thead><tr>
<th>Name</th>
<th style={{ textAlign: "right" }}>Brutto</th>
<th style={{ textAlign: "right" }}>Netto</th>
<th style={{ textAlign: "right" }}>Spesen</th>
<th style={{ textAlign: "right" }}>Auszahlung</th>
<th>Status</th>
<th></th>
</tr></thead>
<tbody>
{employees.filter(e => e.monatslohn > 0).length === 0 && (
<tr><td colSpan={7} className="empty-state">Noch kein Monatslohn hinterlegt.</td></tr>
)}
{employees.filter(e => e.monatslohn > 0).map(emp => {
const available = isMonatAvailable(emp);
const entry = findEntry(emp.id);
const spesen = entry ? (data.expenses || []).filter(e => e.lohnEntryId === entry.id) : offeneSpesen(emp.id);
const effBrutto = getEffBrutto(emp);
const calc = entry ? entry : calcLohn({ ...emp, monatslohn: effBrutto, pensum: getEffPensum(emp) }, monatStr, spesen);
return (
<tr key={emp.id} onClick={() => available && setSelEmpId(emp.id)}
style={{ cursor: available ? "pointer" : "default", background: selEmpId === emp.id ? "#faf8f5" : "", opacity: available ? 1 : 0.35 }}>
<td>
<strong>{emp.name}</strong>
{emp.role && <div style={{ fontSize: 11, color: "#888" }}>{emp.role}</div>}
{!available && <div style={{ fontSize: 10, color: "#aaa" }}>vor Eintritt</div>}
</td>
<td style={{ textAlign: "right" }}>{available ? formatCHF(calc.bruttoTotal) : "—"}</td>
<td style={{ textAlign: "right" }}>{available ? formatCHF(calc.netto) : "—"}</td>
<td style={{ textAlign: "right", color: calc.spesenTotal > 0 ? "#b5621e" : "#aaa" }}>{available && calc.spesenTotal > 0 ? formatCHF(calc.spesenTotal) : "—"}</td>
<td style={{ textAlign: "right", fontWeight: 600 }}>{available ? formatCHF(calc.auszahlung) : "—"}</td>
<td>{!available ? null : entry
? <span style={{ fontSize: 10, color: "#2d6a4f", background: "#e8f5ee", border: "1px solid #b0d8c0", borderRadius: 20, padding: "2px 10px", fontWeight: 600 }}>Abgeschlossen</span>
: <span style={{ fontSize: 10, color: "#b5621e", background: "#fff8f0", border: "1px solid #f0d0a0", borderRadius: 20, padding: "2px 10px", fontWeight: 600 }}>Offen</span>}
</td>
<td style={{ textAlign: "right" }}>
{available && (entry
? <button className="btn btn-ghost" style={{ fontSize: 11, padding: "2px 8px" }} onClick={e => { e.stopPropagation(); stornieren(entry.id); }}>Stornieren</button>
: <button className="btn btn-primary" style={{ fontSize: 11, padding: "2px 10px" }} onClick={e => { e.stopPropagation(); abschliessen(emp); }}>Abschliessen</button>
)}
</td>
</tr>
);
})}
</tbody>
{monatEntries.length > 0 && (() => {
const t = monatEntries.reduce((s,l) => ({ b:s.b+l.bruttoTotal, n:s.n+l.netto, sp:s.sp+l.spesenTotal, a:s.a+l.auszahlung }), {b:0,n:0,sp:0,a:0});
return (
<tfoot>
<tr style={{ borderTop: "1.5px solid #1a1a18", fontWeight: 600 }}>
<td>Total</td>
<td style={{ textAlign: "right" }}>{formatCHF(t.b)}</td>
<td style={{ textAlign: "right" }}>{formatCHF(t.n)}</td>
<td style={{ textAlign: "right" }}>{formatCHF(t.sp)}</td>
<td style={{ textAlign: "right" }}>{formatCHF(t.a)}</td>
<td colSpan={2}></td>
</tr>
</tfoot>
);
})()}
</table>
</div>
</div>
<div>
{selectedEmp && isMonatAvailable(selectedEmp) ? (
<div className="card">
<div className="section-label" style={{ marginBottom: 4 }}>LOHNABRECHNUNG</div>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 18, marginBottom: 2 }}>{selectedEmp.name}</div>
<div style={{ fontSize: 11, color: "#888", marginBottom: 16 }}>{[selectedEmp.role, `${months[selMonth]} ${selYear}`, cardPensum < 100 ? `${cardPensum}%` : null].filter(Boolean).join(" · ")}</div>
{existingEntry
? renderCalc(existingEntry, { ...selectedEmp, ...existingEntry.empSnapshot, saetzeSnapshot: existingEntry.saetzeSnapshot }, false, existingEntry.saetzeSnapshot?.pensum || 100)
: renderCalc(previewCalc, selectedEmp, true, getEffPensum(selectedEmp))
}
{(() => {
const sp = existingEntry ? (data.expenses||[]).filter(e => e.lohnEntryId === existingEntry.id) : previewSpesen;
return sp.length > 0 ? (
<div style={{ marginTop: 14, paddingTop: 12, borderTop: "1px solid #ece8e2" }}>
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "#888", marginBottom: 8 }}>SPESEN DETAIL</div>
{sp.map(s => (
<div key={s.id} style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "#888", padding: "2px 0" }}>
<span>{s.category}{s.description ? `${s.description}` : ""}</span>
<span>{formatCHF(s.amount)}</span>
</div>
))}
</div>
) : null;
})()}
<div style={{ marginTop: 16, display: "flex", gap: 8 }}>
{existingEntry ? (
<>
<button className="btn btn-ghost" style={{ flex: 1, fontSize: 11 }} onClick={() => setPrintContent({ type: "lohn", entry: existingEntry, emp: selectedEmp, data, monatLabel: `${months[selMonth]} ${selYear}` })}>PDF</button>
<button className="btn btn-ghost" style={{ fontSize: 11, color: "#8a1a1a", borderColor: "#8a1a1a" }} onClick={() => stornieren(existingEntry.id)}>Stornieren</button>
</>
) : (
<button className="btn btn-primary" style={{ flex: 1, fontSize: 11 }}
onClick={() => abschliessen(selectedEmp)}
disabled={!selectedEmp.monatslohn && bruttoOverride[selectedEmp.id] == null}>
Lohnabrechnung abschliessen
</button>
)}
</div>
{!existingEntry && !selectedEmp.monatslohn && bruttoOverride[selectedEmp.id] == null && (
<div style={{ marginTop: 10, fontSize: 11, color: "#b5621e" }}>Kein Monatslohn hinterlegt.</div>
)}
</div>
) : (
<div className="card" style={{ color: "#aaa", textAlign: "center", padding: 40, fontSize: 13 }}>
Mitarbeiter auswählen für Detailansicht
</div>
)}
</div>
</div>
)}
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import React, { useState } from "react";
export default function Login({ users, settings, onLogin, version }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState(false);
const [shake, setShake] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
const user = (users || []).find(
u => u.username === username && u.password === password
);
if (user) {
onLogin(user);
} else {
setError(true);
setShake(true);
setTimeout(() => setShake(false), 500);
}
};
const studioName = settings?.name || "Studio";
return (
<div style={{
minHeight: "100vh", minWidth: "100vw",
background: "#ebe7e1",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "'DM Mono', 'Courier New', monospace",
position: "fixed", inset: 0, zIndex: 9999,
}}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&family=DM+Mono:wght@300;400;500&family=Playfair+Display:ital,wght@0,400;1,400&display=swap');
@keyframes login-shake {
0%,100% { transform: translateX(0); }
20%,60% { transform: translateX(-8px); }
40%,80% { transform: translateX(8px); }
}
@keyframes login-fade-in {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.login-card {
animation: login-fade-in 0.5s cubic-bezier(0.22,1,0.36,1) both;
}
.login-card.shake {
animation: login-shake 0.45s ease;
}
.login-input {
width: 100%; box-sizing: border-box;
background: #f7f4f0; border: 1.5px solid #ddd8d0;
border-radius: 10px; padding: 11px 14px;
font-family: 'DM Mono', monospace; font-size: 13px;
color: #1a1a18; outline: none;
transition: border-color 0.18s, box-shadow 0.18s;
}
.login-input:focus {
border-color: #9a7858;
box-shadow: 0 0 0 3px rgba(154,120,88,0.14);
}
.login-input.error {
border-color: #b5621e;
box-shadow: 0 0 0 3px rgba(181,98,30,0.12);
}
.login-btn {
width: 100%; padding: 13px;
background: #1a1a18; color: #f0ede8;
border: none; border-radius: 10px;
font-family: 'DM Mono', monospace; font-size: 13px;
font-weight: 500; letter-spacing: 0.04em;
cursor: pointer; transition: background 0.18s, box-shadow 0.18s;
}
.login-btn:hover {
background: #2e2e28;
box-shadow: 0 4px 16px rgba(0,0,0,0.28);
}
.login-btn:active { background: #0e0e0c; }
`}</style>
<div className={`login-card${shake ? " shake" : ""}`} style={{
background: "#fdfcfa",
borderRadius: 20,
padding: "48px 44px 40px",
width: "100%", maxWidth: 380,
boxShadow: "0 8px 40px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.07)",
border: "1px solid #ddd8d0",
margin: "0 20px",
}}>
{/* Logo */}
<div style={{ textAlign: "center", marginBottom: 36 }}>
<div style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 36, color: "#1a1a18", letterSpacing: "-0.02em", lineHeight: 1 }}>
RAPPORT
</div>
<div style={{ fontSize: 9, color: "#b0aca4", letterSpacing: "0.2em", marginTop: 8, fontWeight: 500 }}>
{studioName.toUpperCase()}
</div>
<div style={{ width: 32, height: 1.5, background: "#ddd8d0", margin: "16px auto 0" }} />
</div>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: 14 }}>
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 7, fontWeight: 500 }}>
BENUTZER
</label>
<input
className={`login-input${error ? " error" : ""}`}
type="text"
autoComplete="username"
autoFocus
value={username}
onChange={e => { setUsername(e.target.value); setError(false); }}
placeholder="admin"
/>
</div>
<div style={{ marginBottom: 28 }}>
<label style={{ display: "block", fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 7, fontWeight: 500 }}>
PASSWORT
</label>
<input
className={`login-input${error ? " error" : ""}`}
type="password"
autoComplete="current-password"
value={password}
onChange={e => { setPassword(e.target.value); setError(false); }}
placeholder="••••••"
/>
</div>
{error && (
<div style={{ marginBottom: 18, padding: "9px 14px", background: "#fff5f0", borderRadius: 8, border: "1px solid #f5c9b0", fontSize: 11, color: "#b5621e", textAlign: "center" }}>
Falscher Benutzername oder Passwort
</div>
)}
<button className="login-btn" type="submit">
Anmelden
</button>
</form>
<div style={{ marginTop: 24, textAlign: "center", fontSize: 9, color: "#c8c4be", letterSpacing: "0.08em" }}>
{version ? `V${version}` : ""}
</div>
</div>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
import React, { useState } from "react";
import Setup from "./Setup.jsx";
export default function MigrationScreen({ data, onComplete }) {
const [backed, setBacked] = useState(false);
const [goSetup, setGoSetup] = useState(false);
const studioName = data.settings?.name || "Studio";
if (goSetup) {
return <Setup onComplete={onComplete} />;
}
const handleBackup = () => {
try {
const stored = localStorage.getItem("studio_data_v1") || JSON.stringify(data);
const blob = new Blob([stored], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `rapport-backup-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {}
setBacked(true);
};
return (
<div style={{
minHeight: "100vh", minWidth: "100vw",
background: "#ebe7e1",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "'DM Mono', 'Courier New', monospace",
position: "fixed", inset: 0, zIndex: 9999,
overflowY: "auto", padding: "24px 0",
}}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&family=DM+Mono:wght@300;400;500&display=swap');
@keyframes mig-fade-in {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.mig-card { animation: mig-fade-in 0.5s cubic-bezier(0.22,1,0.36,1) both; }
.mig-btn-primary {
width: 100%; padding: 13px;
background: #1a1a18; color: #f0ede8;
border: none; border-radius: 10px;
font-family: 'DM Mono', monospace; font-size: 13px;
font-weight: 500; letter-spacing: 0.04em;
cursor: pointer; transition: background 0.18s;
}
.mig-btn-primary:hover { background: #2e2e28; }
.mig-btn-backup {
width: 100%; padding: 13px;
background: transparent; color: #1a1a18;
border: 2px solid #1a1a18; border-radius: 10px;
font-family: 'DM Mono', monospace; font-size: 13px;
font-weight: 500; letter-spacing: 0.04em;
cursor: pointer; transition: background 0.18s;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.mig-btn-backup:hover { background: #f0ece6; }
.mig-btn-backup.done { border-color: #2d6a4f; color: #2d6a4f; background: #eaf5ee; cursor: default; }
.mig-skip { background: none; border: none; font-family: inherit; font-size: 11px; color: #aaa; cursor: pointer; text-decoration: underline; padding: 0; }
.mig-skip:hover { color: #888; }
`}</style>
<div className="mig-card" style={{
background: "#fdfcfa",
borderRadius: 20,
padding: "44px 40px 36px",
width: "100%", maxWidth: 440,
boxShadow: "0 8px 40px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.07)",
border: "1px solid #ddd8d0",
margin: "0 20px",
}}>
{/* Header */}
<div style={{ textAlign: "center", marginBottom: 28 }}>
<div style={{ fontFamily: "Krungthep, 'Archivo Black', sans-serif", fontSize: 30, color: "#1a1a18", letterSpacing: "-0.02em", lineHeight: 1 }}>
RAPPORT
</div>
<div style={{ fontSize: 9, color: "#b0aca4", letterSpacing: "0.2em", marginTop: 6, fontWeight: 500 }}>
{studioName.toUpperCase()}
</div>
<div style={{ width: 32, height: 1.5, background: "#ddd8d0", margin: "14px auto 0" }} />
</div>
{/* Info banner */}
<div style={{ padding: "14px 16px", background: "#fff8ed", borderRadius: 10, border: "1px solid #f0e4c4", marginBottom: 10 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: "#b07848", letterSpacing: "0.06em", marginBottom: 6 }}>
VERSION 0.5 NEUE INITIALISIERUNG
</div>
<div style={{ fontSize: 12, color: "#7a5a30", lineHeight: 1.65 }}>
Diese Version enthält umfangreiche Datenbankänderungen und ein neues Anmeldesystem. Die App muss neu eingerichtet werden.
</div>
</div>
{/* Recommendation */}
<div style={{ padding: "12px 16px", background: "#f5f0e8", borderRadius: 10, border: "1px solid #e0d8cc", marginBottom: 24, fontSize: 12, color: "#5a5040", lineHeight: 1.65 }}>
<strong style={{ display: "block", marginBottom: 4, fontSize: 11, letterSpacing: "0.06em", color: "#1a1a18" }}>EMPFEHLUNG</strong>
Sichern Sie zuerst die bestehenden Daten. Alte Datenbankstrukturen sind zwar importierbar, das Einspielen von Backups zum Testen wird jedoch ausdrücklich abgeraten umbenannte Felder können zu Fehlern führen.
</div>
{/* Step 1: Backup */}
<div style={{ marginBottom: 6 }}>
<div style={{ fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 8, fontWeight: 500 }}>
SCHRITT 1 DATEN SICHERN
</div>
<button
className={`mig-btn-backup${backed ? " done" : ""}`}
onClick={backed ? undefined : handleBackup}
>
{backed
? <><span></span> Backup heruntergeladen</>
: <><span style={{ fontSize: 16 }}></span> Datenbank herunterladen</>
}
</button>
{!backed && (
<div style={{ textAlign: "right", marginTop: 6 }}>
<button className="mig-skip" onClick={() => setBacked(true)}>
Kein Backup nötig überspringen
</button>
</div>
)}
</div>
{/* Step 2: Setup */}
<div style={{ marginTop: 20, opacity: backed ? 1 : 0.3, transition: "opacity 0.3s", pointerEvents: backed ? "auto" : "none" }}>
<div style={{ fontSize: 9, letterSpacing: "0.15em", color: "#8c8880", marginBottom: 8, fontWeight: 500 }}>
SCHRITT 2 NEU EINRICHTEN
</div>
<button className="mig-btn-primary" onClick={() => setGoSetup(true)}>
Weiter zur Einrichtung
</button>
</div>
</div>
</div>
);
}
+1284
View File
File diff suppressed because it is too large Load Diff
+682
View File
@@ -0,0 +1,682 @@
import React, { useState } from "react";
import { generateId, formatCHF, formatDate } from "../utils.js";
import { Header, Modal, FormField, StatusBadge, useConfirm } from "../components/UI.jsx";
const CONTACT_TYPES = [
"Elektroplaner", "HLKSE-Planer", "Statiker", "Tragwerksplaner",
"Kostenplaner", "Landschaftsarchitekt", "Bauphysiker",
"Vermessungsingenieur", "Brandschutzspezialist", "Geologe",
"Generalunternehmer", "Fachplaner", "Sonstiges",
];
const emptyFirm = {
name: "", type: "", street: "", zip: "", city: "", country: "CH",
email: "", phone: "", website: "", note: "", contacts: [],
honorarOffers: [], isAuftraggeber: false, isPartner: false,
};
const RoleBadge = ({ label, color, bg }) => (
<span style={{ fontSize: 10, fontWeight: 600, color, background: bg, padding: "2px 10px", borderRadius: 20, letterSpacing: "0.04em", whiteSpace: "nowrap" }}>{label}</span>
);
const SectionHead = ({ label }) => (
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: "0.1em", color: "#aaa", marginTop: 20, marginBottom: 12, paddingTop: 14, borderTop: "1px solid var(--border)" }}>{label}</div>
);
// ── Detail View ────────────────────────────────────────────────────────────────
function FirmaDetail({ firma, data, onBack, onEdit, onAddPerson, onEditPerson, onDeletePerson, onDeleteFirm }) {
const invoices = (data.invoices || []).filter(i => i.clientId === firma.id);
const quotes = (data.quotes || []).filter(q => q.clientId === firma.id);
const asClient = (data.projects || []).filter(p => p.clientId === firma.id);
const asPartner = (data.projects || []).filter(p => (p.projectContacts || []).some(pc => pc.contactId === firma.id));
const allProjects = [...new Map([...asClient, ...asPartner].map(p => [p.id, p])).values()]
.sort((a, b) => (b.startDate || "").localeCompare(a.startDate || ""));
const protocols = (data.protocols || []).filter(proto =>
asPartner.some(p => p.id === proto.projectId)
).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
const addr = [firma.street, [firma.zip, firma.city].filter(Boolean).join(" ")].filter(Boolean).join(", ");
const InfoRow = ({ label, value }) => value ? (
<div style={{ display: "flex", gap: 12, marginBottom: 4 }}>
<span style={{ fontSize: 11, color: "var(--text4)", width: 100, flexShrink: 0 }}>{label}</span>
<span style={{ fontSize: 13 }}>{value}</span>
</div>
) : null;
return (
<div>
<button className="btn btn-ghost" onClick={onBack} style={{ marginBottom: 18, padding: "6px 14px", fontSize: 12 }}> Alle Personen</button>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 24 }}>
<div>
<div style={{ display: "flex", gap: 6, marginBottom: 8 }}>
{firma.isAuftraggeber && <RoleBadge label="Auftraggeber" color="#1a4e8a" bg="#e8f0fa" />}
{firma.isPartner && <RoleBadge label="Partner" color="#2d6a4f" bg="#e8f5ee" />}
{firma.type && <span style={{ fontSize: 11, color: "var(--text4)" }}>{firma.type}</span>}
</div>
<h1 style={{ fontFamily: "'Playfair Display', serif", fontSize: 34, fontWeight: 400, letterSpacing: "-0.01em", marginBottom: 6 }}>{firma.name}</h1>
{addr && <div style={{ fontSize: 13, color: "var(--text3)" }}>{addr}</div>}
</div>
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-ghost" onClick={onEdit}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
</div>
</div>
{/* KPIs */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 14, marginBottom: 28 }} className="responsive-grid-4">
{[
{ label: "PROJEKTE", value: allProjects.length },
...(firma.isAuftraggeber ? [
{ label: "RECHNUNGEN", value: invoices.length },
{ label: "OFFERTEN", value: quotes.length },
{ label: "OFFEN", value: formatCHF(invoices.filter(i => i.status === "gesendet" || i.status === "überfällig").reduce((s, i) => s + (i.total || 0), 0)), color: "#b5621e" },
] : [
{ label: "BETEILIGUNGEN", value: asPartner.length },
{ label: "PROTOKOLLE", value: protocols.length },
{ label: "ANSPRECHP.", value: (firma.contacts || []).length },
]),
].map((k, i) => (
<div key={i} className="card">
<div style={{ fontSize: 10, color: "var(--text4)", letterSpacing: "0.12em", marginBottom: 8 }}>{k.label}</div>
<div style={{ fontSize: 24, fontFamily: "'Playfair Display', serif", fontWeight: 700, color: k.color || "var(--text2)" }}>{k.value}</div>
</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 20 }} className="responsive-grid-2">
<div>
{/* Projekte */}
{allProjects.length > 0 && (
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "14px 16px 10px", borderBottom: "1px solid var(--border2)", fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", color: "var(--text4)" }}>PROJEKTE</div>
<table style={{ width: "100%" }}>
<tbody>
{allProjects.map(p => (
<tr key={p.id}>
<td style={{ fontSize: 11, color: "var(--text4)", width: 80 }}>{p.number || "—"}</td>
<td><strong style={{ fontSize: 13 }}>{p.name}</strong></td>
<td style={{ fontSize: 11, color: "var(--text4)" }}>
{asClient.find(x => x.id === p.id) && <span style={{ marginRight: 6, color: "#1a4e8a" }}>Auftraggeber</span>}
{asPartner.find(x => x.id === p.id) && <span style={{ color: "#2d6a4f" }}>Beteiligt</span>}
</td>
<td style={{ textAlign: "right" }}><StatusBadge status={p.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Rechnungen — nur bei Auftraggeber */}
{firma.isAuftraggeber && (
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "14px 16px 10px", borderBottom: "1px solid var(--border2)", fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", color: "var(--text4)" }}>RECHNUNGEN</div>
{invoices.length === 0 ? (
<div style={{ padding: "24px 16px", fontSize: 12, color: "var(--text5)" }}>Keine Rechnungen.</div>
) : (
<table style={{ width: "100%" }}>
<tbody>
{[...invoices].sort((a, b) => (b.date || "").localeCompare(a.date || "")).map(inv => (
<tr key={inv.id}>
<td style={{ fontSize: 11, color: "var(--text4)", width: 90 }}>{inv.number || "—"}</td>
<td style={{ fontSize: 12 }}>{formatDate(inv.date)}</td>
<td style={{ textAlign: "right", fontWeight: 600, fontSize: 13 }}>{formatCHF(inv.total || 0)}</td>
<td style={{ textAlign: "right", width: 90 }}><StatusBadge status={inv.status} /></td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* Offerten — nur bei Auftraggeber */}
{firma.isAuftraggeber && (
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "14px 16px 10px", borderBottom: "1px solid var(--border2)", fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", color: "var(--text4)" }}>OFFERTEN</div>
{quotes.length === 0 ? (
<div style={{ padding: "24px 16px", fontSize: 12, color: "var(--text5)" }}>Keine Offerten.</div>
) : (
<table style={{ width: "100%" }}>
<tbody>
{[...quotes].sort((a, b) => (b.date || "").localeCompare(a.date || "")).map(q => (
<tr key={q.id}>
<td style={{ fontSize: 11, color: "var(--text4)", width: 90 }}>{q.number || "—"}</td>
<td style={{ fontSize: 12 }}>{formatDate(q.date)}</td>
<td style={{ fontSize: 11, color: "var(--text4)" }}>{q.mode === "sia" ? "SIA 102" : q.mode === "manual" ? "Manuell" : "Frei"}</td>
<td style={{ textAlign: "right", fontWeight: 600, fontSize: 13 }}>{formatCHF(q.sub || 0)}</td>
<td style={{ textAlign: "right", width: 90 }}><StatusBadge status={q.status} /></td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* Protokolle — nur bei Partner */}
{firma.isPartner && protocols.length > 0 && (
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "14px 16px 10px", borderBottom: "1px solid var(--border2)", fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", color: "var(--text4)" }}>PROTOKOLLE</div>
<table style={{ width: "100%" }}>
<tbody>
{protocols.slice(0, 10).map(proto => {
const proj = (data.projects || []).find(p => p.id === proto.projectId);
return (
<tr key={proto.id}>
<td style={{ fontSize: 11, color: "var(--text4)", width: 80 }}>{formatDate(proto.date)}</td>
<td style={{ fontSize: 12 }}>{proto.type || "Protokoll"}{proto.number ? ` #${proto.number}` : ""}</td>
<td style={{ fontSize: 11, color: "var(--text4)" }}>{proj?.name || ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Right column: Stammdaten + Ansprechpartner */}
<div>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", color: "var(--text4)", marginBottom: 12 }}>STAMMDATEN</div>
<InfoRow label="E-Mail" value={firma.email} />
<InfoRow label="Telefon" value={firma.phone} />
<InfoRow label="Website" value={firma.website} />
<InfoRow label="Adresse" value={addr} />
{firma.note && <div style={{ marginTop: 10, fontSize: 12, color: "var(--text3)", borderTop: "1px solid var(--border2)", paddingTop: 10 }}>{firma.note}</div>}
</div>
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", color: "var(--text4)" }}>ANSPRECHPARTNER</div>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "2px 8px" }} onClick={onAddPerson}>+ Person</button>
</div>
{(firma.contacts || []).length === 0 ? (
<div style={{ fontSize: 12, color: "var(--text5)" }}>Keine Ansprechpartner erfasst.</div>
) : (firma.contacts || []).map(c => (
<div key={c.id} style={{ borderBottom: "1px solid var(--border2)", paddingBottom: 10, marginBottom: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{c.name}</div>
{c.position && <div style={{ fontSize: 11, color: "var(--text4)" }}>{c.position}</div>}
{c.email && <div style={{ fontSize: 11, color: "var(--text4)" }}>{c.email}</div>}
{c.phone && <div style={{ fontSize: 11, color: "var(--text4)" }}>{c.phone}</div>}
</div>
<div style={{ display: "flex", gap: 4 }}>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "2px 7px" }} onClick={() => onEditPerson(firma.id, c.id)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ fontSize: 11, padding: "2px 6px" }} onClick={() => onDeletePerson(firma.id, c.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
// ── Main View ──────────────────────────────────────────────────────────────────
export default function Personen({ data, update, saveAll, setView }) {
const persons = data.persons || [];
const projects = data.projects || [];
const { askConfirm, ConfirmModalEl } = useConfirm();
const [selectedId, setSelectedId] = useState(null);
const [search, setSearch] = useState("");
const [ansicht, setAnsicht] = useState("firmen");
const [groupBy, setGroupBy] = useState("alpha");
const [firmModal, setFirmModal] = useState(null);
const [firmForm, setFirmForm] = useState({});
const [personModal, setPersonModal] = useState(null);
const [personForm, setPersonForm] = useState({});
// ── Counts ─────────────────────────────────────────────────────────────────
const allPersonsFlat = persons.flatMap(firma =>
(firma.contacts || []).map(c => ({ ...c, _firmaId: firma.id, _firmaName: firma.name, _firmaType: firma.type }))
);
// ── Filtering ──────────────────────────────────────────────────────────────
const q = search.toLowerCase();
const filteredFirmen = persons.filter(p => {
if (ansicht === "auftraggeber" && !p.isAuftraggeber) return false;
if (ansicht === "partner" && !p.isPartner) return false;
if (q) {
const addr = [p.street, p.zip, p.city].filter(Boolean).join(" ");
const contactNames = (p.contacts || []).map(c => c.name).join(" ");
if (![p.name, p.type, p.email, addr, contactNames].some(v => v?.toLowerCase().includes(q))) return false;
}
return true;
}).sort((a, b) => a.name.localeCompare(b.name, "de"));
const filteredPersons = allPersonsFlat.filter(c => {
if (!q) return true;
return [c.name, c.position, c.email, c.phone, c._firmaName].some(v => v?.toLowerCase().includes(q));
}).sort((a, b) => a.name.localeCompare(b.name, "de"));
const showPersonsView = ansicht === "personen";
// ── Grouping ───────────────────────────────────────────────────────────────
const groupedFirmen = (() => {
if (groupBy === "none") return [{ key: "_all", label: null, items: filteredFirmen }];
if (groupBy === "alpha") {
const g = {};
filteredFirmen.forEach(p => { const k = p.name[0]?.toUpperCase() || "#"; (g[k] = g[k] || []).push(p); });
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 = {};
filteredFirmen.forEach(p => {
const k = p.isAuftraggeber && p.isPartner ? "Auftraggeber & Partner"
: p.isAuftraggeber ? "Auftraggeber" : (p.type || "Ohne Typ");
(g[k] = g[k] || []).push(p);
});
const order = ["Auftraggeber & Partner", "Auftraggeber"];
return Object.entries(g).sort((a, b) => {
const oa = order.indexOf(a[0]), ob = order.indexOf(b[0]);
if (oa !== -1 && ob !== -1) return oa - ob;
if (oa !== -1) return -1; if (ob !== -1) return 1;
return a[0].localeCompare(b[0]);
}).map(([k, items]) => ({ key: k, label: k, items }));
}
})();
const groupedPersons = (() => {
if (groupBy === "none") return [{ key: "_all", label: null, items: filteredPersons }];
if (groupBy === "alpha") {
const g = {};
filteredPersons.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 === "firma") {
const g = {};
filteredPersons.forEach(c => { (g[c._firmaName] = g[c._firmaName] || []).push(c); });
return Object.entries(g).sort((a, b) => a[0].localeCompare(b[0])).map(([k, items]) => ({ key: k, label: k, items }));
}
})();
const firmenGroupOpts = [{ id: "alpha", label: "Alphabetisch" }, { id: "type", label: "Nach Typ" }, { id: "none", label: "Keine" }];
const personGroupOpts = [{ id: "alpha", label: "Alphabetisch" }, { id: "firma", label: "Nach Firma" }, { id: "none", label: "Keine" }];
const activeGroupOpts = showPersonsView ? personGroupOpts : firmenGroupOpts;
const hasFilter = search || ansicht !== "firmen";
// ── Firma CRUD ─────────────────────────────────────────────────────────────
const openNewFirm = (preset = {}) => {
setFirmForm({ ...emptyFirm, ...preset, _hauptName: "", _hauptPosition: "", _hauptPhone: "", _hauptEmail: "" });
setFirmModal({ isNew: true });
};
const openEditFirm = (id) => {
const p = persons.find(x => x.id === id);
if (!p) return;
setFirmForm({ ...emptyFirm, ...p });
setFirmModal({ id });
};
const saveFirm = () => {
if (!firmForm.name?.trim() || (!firmForm.isAuftraggeber && !firmForm.isPartner)) return;
const { _hauptName, _hauptPosition, _hauptPhone, _hauptEmail, ...firmData } = firmForm;
if (firmModal.id) {
update("persons", persons.map(p => p.id === firmModal.id ? { ...p, ...firmData } : p));
} else {
const hauptkontakt = _hauptName?.trim()
? [{ id: generateId(), name: _hauptName.trim(), position: _hauptPosition || "", phone: _hauptPhone || "", email: _hauptEmail || "" }]
: [];
update("persons", [...persons, { ...firmData, contacts: [...hauptkontakt, ...(firmData.contacts || [])], id: generateId() }]);
}
setFirmModal(null);
};
const deleteFirm = async (id) => {
if (!await askConfirm("Firma löschen? Referenzen in Projekten und Rechnungen bleiben erhalten.")) return;
update("persons", persons.filter(p => p.id !== id));
if (selectedId === id) setSelectedId(null);
};
const deleteContactFromFirm = async (firmaId, personId) => {
if (!await askConfirm("Ansprechpartner entfernen?")) return;
update("persons", persons.map(p => p.id === firmaId
? { ...p, contacts: (p.contacts || []).filter(c => c.id !== personId) } : p));
};
// ── Ansprechpartner CRUD ───────────────────────────────────────────────────
const openNewPerson = (firmaId = "") => {
setPersonForm({ firmaId, name: "", position: "", phone: "", email: "" });
setPersonModal({ isNew: true });
};
const openEditPerson = (firmaId, personId) => {
const firma = persons.find(p => p.id === firmaId);
const person = (firma?.contacts || []).find(c => c.id === personId);
if (!person) return;
setPersonForm({ firmaId, name: person.name || "", position: person.position || "", phone: person.phone || "", email: person.email || "" });
setPersonModal({ firmaId, personId });
};
const savePerson = () => {
if (!personForm.name?.trim() || !personForm.firmaId) return;
if (personModal.personId) {
update("persons", persons.map(p => p.id === personModal.firmaId
? { ...p, contacts: (p.contacts || []).map(c => c.id === personModal.personId ? { ...c, name: personForm.name, position: personForm.position, phone: personForm.phone, email: personForm.email } : c) } : p));
} else {
const newContact = { id: generateId(), name: personForm.name.trim(), position: personForm.position || "", phone: personForm.phone || "", email: personForm.email || "" };
update("persons", persons.map(p => p.id === personForm.firmaId
? { ...p, contacts: [...(p.contacts || []), newContact] } : p));
}
setPersonModal(null);
};
// ── Detail view ────────────────────────────────────────────────────────────
const selectedFirma = selectedId ? persons.find(p => p.id === selectedId) : null;
if (selectedFirma) {
return (
<div>
{ConfirmModalEl}
<FirmaDetail
firma={selectedFirma}
data={data}
onBack={() => setSelectedId(null)}
onEdit={() => openEditFirm(selectedFirma.id)}
onAddPerson={() => openNewPerson(selectedFirma.id)}
onEditPerson={openEditPerson}
onDeletePerson={deleteContactFromFirm}
onDeleteFirm={deleteFirm}
/>
{firmModal && renderFirmModal()}
{personModal && renderPersonModal()}
</div>
);
}
// ── List view helpers ──────────────────────────────────────────────────────
function renderFirmModal() {
return (
<Modal
title={firmModal.id ? "Firma bearbeiten" : firmForm.isAuftraggeber && !firmForm.isPartner ? "Neuer Auftraggeber" : !firmForm.isAuftraggeber && firmForm.isPartner ? "Neuer Partner" : "Neue Firma"}
onClose={() => setFirmModal(null)}
onSave={saveFirm}
>
<FormField label="Firmenname *">
<input autoFocus value={firmForm.name || ""} onChange={e => setFirmForm({ ...firmForm, name: e.target.value })}
style={!firmForm.name?.trim() ? { borderColor: "#b5621e" } : {}} />
</FormField>
<FormField label="Rolle">
<div style={{ display: "flex", gap: 20, marginTop: 2 }}>
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13, fontWeight: 400, textTransform: "none", letterSpacing: 0, cursor: "pointer" }}>
<input type="checkbox" checked={!!firmForm.isAuftraggeber} onChange={e => setFirmForm({ ...firmForm, isAuftraggeber: e.target.checked })} style={{ width: "auto" }} />
Auftraggeber <span style={{ fontSize: 10, color: "#888" }}>(Rechnungen)</span>
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13, fontWeight: 400, textTransform: "none", letterSpacing: 0, cursor: "pointer" }}>
<input type="checkbox" checked={!!firmForm.isPartner} onChange={e => setFirmForm({ ...firmForm, isPartner: e.target.checked })} style={{ width: "auto" }} />
Partner <span style={{ fontSize: 10, color: "#888" }}>(Projektbeteiligte)</span>
</label>
</div>
{!firmForm.isAuftraggeber && !firmForm.isPartner && (
<div style={{ fontSize: 11, color: "#b5621e", marginTop: 4 }}>Mindestens eine Rolle auswählen.</div>
)}
</FormField>
{(firmForm.isPartner || firmForm.type) && (
<FormField label="Typ / Branche">
<select value={firmForm.type || ""} onChange={e => setFirmForm({ ...firmForm, type: e.target.value })}>
<option value=""> auswählen </option>
{CONTACT_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</FormField>
)}
<div className="form-row">
<FormField label="Strasse"><input value={firmForm.street || ""} onChange={e => setFirmForm({ ...firmForm, street: e.target.value })} /></FormField>
<FormField label="PLZ"><input value={firmForm.zip || ""} onChange={e => setFirmForm({ ...firmForm, zip: e.target.value })} style={{ maxWidth: 90 }} /></FormField>
<FormField label="Ort"><input value={firmForm.city || ""} onChange={e => setFirmForm({ ...firmForm, city: e.target.value })} /></FormField>
</div>
<div className="form-row">
<FormField label="E-Mail (Firma)"><input type="email" value={firmForm.email || ""} onChange={e => setFirmForm({ ...firmForm, email: e.target.value })} /></FormField>
<FormField label="Telefon (Firma)"><input value={firmForm.phone || ""} onChange={e => setFirmForm({ ...firmForm, phone: e.target.value })} /></FormField>
<FormField label="Website"><input value={firmForm.website || ""} onChange={e => setFirmForm({ ...firmForm, website: e.target.value })} /></FormField>
</div>
<FormField label="Notiz">
<textarea value={firmForm.note || ""} onChange={e => setFirmForm({ ...firmForm, note: e.target.value })} rows={2} style={{ height: "auto" }} />
</FormField>
{!firmModal.id && (
<>
<SectionHead label="HAUPTKONTAKT (optional)" />
<FormField label="Name">
<input value={firmForm._hauptName || ""} onChange={e => setFirmForm({ ...firmForm, _hauptName: e.target.value })} placeholder="Vorname Nachname" />
</FormField>
<FormField label="Position / Funktion">
<input value={firmForm._hauptPosition || ""} onChange={e => setFirmForm({ ...firmForm, _hauptPosition: e.target.value })} placeholder="z.B. Projektleiter" />
</FormField>
<div className="form-row">
<FormField label="Telefon">
<input value={firmForm._hauptPhone || ""} onChange={e => setFirmForm({ ...firmForm, _hauptPhone: e.target.value })} />
</FormField>
<FormField label="E-Mail">
<input type="email" value={firmForm._hauptEmail || ""} onChange={e => setFirmForm({ ...firmForm, _hauptEmail: e.target.value })} />
</FormField>
</div>
</>
)}
{firmModal.id && (() => {
const contacts = firmForm.contacts || [];
return (
<>
<SectionHead label="ANSPRECHPARTNER" />
{contacts.length === 0 && <div style={{ fontSize: 12, color: "#aaa", marginBottom: 8 }}>Noch keine Ansprechpartner.</div>}
{contacts.map(c => (
<div key={c.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: "1px solid var(--border2)" }}>
<div>
<span style={{ fontWeight: 500, fontSize: 13 }}>{c.name}</span>
{c.position && <span style={{ fontSize: 12, color: "#888", marginLeft: 8 }}>{c.position}</span>}
{c.email && <span style={{ fontSize: 11, color: "#aaa", marginLeft: 8 }}>{c.email}</span>}
</div>
<div style={{ display: "flex", gap: 4 }}>
<button type="button" className="btn btn-ghost" style={{ fontSize: 11, padding: "2px 8px" }}
onClick={() => { setFirmModal(null); openEditPerson(firmModal.id, c.id); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button type="button" className="btn btn-danger" style={{ fontSize: 11, padding: "2px 6px" }}
onClick={() => setFirmForm(f => ({ ...f, contacts: (f.contacts || []).filter(x => x.id !== c.id) }))}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
))}
<button type="button" className="btn btn-ghost" style={{ fontSize: 12, marginTop: 10 }}
onClick={() => { setFirmModal(null); openNewPerson(firmModal.id); }}>+ Person hinzufügen</button>
</>
);
})()}
</Modal>
);
}
function renderPersonModal() {
return (
<Modal title={personModal.personId ? "Person bearbeiten" : "Neue Person"} onClose={() => setPersonModal(null)} onSave={savePerson}>
<FormField label="Firma *">
<select value={personForm.firmaId || ""} onChange={e => setPersonForm({ ...personForm, firmaId: e.target.value })}
style={!personForm.firmaId ? { borderColor: "#b5621e" } : {}} disabled={!!personModal.personId}>
<option value=""> Firma auswählen </option>
{persons.sort((a, b) => a.name.localeCompare(b.name, "de")).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</FormField>
<FormField label="Name *">
<input autoFocus value={personForm.name || ""} onChange={e => setPersonForm({ ...personForm, name: e.target.value })}
placeholder="Vorname Nachname" style={!personForm.name?.trim() ? { borderColor: "#b5621e" } : {}} />
</FormField>
<FormField label="Position / Funktion">
<input value={personForm.position || ""} onChange={e => setPersonForm({ ...personForm, position: e.target.value })} placeholder="z.B. Bauleiter" />
</FormField>
<div className="form-row">
<FormField label="Telefon"><input value={personForm.phone || ""} onChange={e => setPersonForm({ ...personForm, phone: e.target.value })} /></FormField>
<FormField label="E-Mail"><input type="email" value={personForm.email || ""} onChange={e => setPersonForm({ ...personForm, email: e.target.value })} /></FormField>
</div>
</Modal>
);
}
// ── List render ────────────────────────────────────────────────────────────
return (
<div>
{ConfirmModalEl}
<Header title="Personen" action={
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-ghost" onClick={() => openNewPerson()} disabled={persons.length === 0} title={persons.length === 0 ? "Zuerst eine Firma anlegen" : ""}>+ Neue Person</button>
<button className="btn btn-ghost" onClick={() => openNewFirm({ isPartner: true })}>+ Neuer Partner</button>
<button className="btn btn-primary" onClick={() => openNewFirm({ isAuftraggeber: true })}>+ Neuer Auftraggeber</button>
</div>
} />
<div className="filter-bar">
<input className="pill" placeholder="Suche (Firma, Person, E-Mail…)" value={search} onChange={e => setSearch(e.target.value)} style={{ minWidth: 220 }} />
<select className="pill" value={ansicht} onChange={e => { setAnsicht(e.target.value); setGroupBy("alpha"); }}>
<option value="firmen">Alle Firmen</option>
<option value="auftraggeber">Auftraggeber</option>
<option value="partner">Partner</option>
<option value="personen">Einzelpersonen</option>
</select>
{hasFilter && <button className="btn btn-ghost" onClick={() => { setSearch(""); setAnsicht("firmen"); }} style={{ fontSize: 12 }}>Zurücksetzen</button>}
</div>
<div className="filter-bar">
<span className="filter-label">GRUPPIEREN:</span>
{activeGroupOpts.map(g => <button key={g.id} className={`pill${groupBy === g.id ? " active" : ""}`} onClick={() => setGroupBy(g.id)}>{g.label}</button>)}
<div style={{ marginLeft: "auto", fontSize: 12, color: "var(--text4)" }}>
{showPersonsView
? <><strong style={{ color: "var(--text)" }}>{filteredPersons.length}</strong> {filteredPersons.length === 1 ? "Person" : "Personen"}</>
: <><strong style={{ color: "var(--text)" }}>{filteredFirmen.length}</strong> {filteredFirmen.length === 1 ? "Firma" : "Firmen"} · {allPersonsFlat.length} Ansprechpartner</>
}
</div>
</div>
{/* Einzelpersonen-Ansicht */}
{showPersonsView ? (
filteredPersons.length === 0 ? (
<div className="card" style={{ padding: 48, textAlign: "center", color: "#aaa" }}>
{allPersonsFlat.length === 0 ? "Noch keine Ansprechpartner erfasst." : "Keine Treffer"}
</div>
) : groupedPersons.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>}
<div className="card" style={{ padding: 0 }}>
<table style={{ width: "100%" }}>
<thead><tr>
<th>Name</th>
<th style={{ width: 150 }}>Position</th>
<th style={{ width: 180 }}>Firma</th>
<th>E-Mail</th>
<th style={{ width: 130 }}>Telefon</th>
<th style={{ width: 60 }}></th>
</tr></thead>
<tbody>
{group.items.map(c => (
<tr key={`${c._firmaId}-${c.id}`}>
<td><strong>{c.name}</strong></td>
<td style={{ fontSize: 12, color: "var(--text3)" }}>{c.position || <span style={{ color: "#ddd" }}></span>}</td>
<td>
<span onClick={() => setSelectedId(c._firmaId)} style={{ fontSize: 12, fontWeight: 500, color: "var(--text2)", cursor: "pointer", textDecoration: "underline", textDecorationColor: "transparent" }}
onMouseEnter={e => e.target.style.textDecorationColor = "currentColor"}
onMouseLeave={e => e.target.style.textDecorationColor = "transparent"}>
{c._firmaName}
</span>
{c._firmaType && <span style={{ fontSize: 10, color: "#aaa", marginLeft: 6 }}>{c._firmaType}</span>}
</td>
<td style={{ fontSize: 12, color: "var(--text3)" }}>{c.email || <span style={{ color: "#ddd" }}></span>}</td>
<td style={{ fontSize: 12, color: "var(--text3)" }}>{c.phone || <span style={{ color: "#ddd" }}></span>}</td>
<td style={{ textAlign: "right" }}>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "3px 8px" }} onClick={() => openEditPerson(c._firmaId, c.id)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))
) : (
/* Firmen-Ansicht */
filteredFirmen.length === 0 ? (
<div className="card" style={{ padding: 48, textAlign: "center", color: "#aaa" }}>
{persons.length === 0 ? "Noch keine Firmen erfasst." : "Keine Treffer"}
</div>
) : groupedFirmen.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>}
<div className="card" style={{ padding: 0 }}>
<table style={{ width: "100%" }}>
<thead><tr>
<th>Firma</th>
<th style={{ width: 160 }}>Rolle</th>
<th style={{ width: 130 }}>Branche / Typ</th>
<th>Adresse</th>
<th>Hauptkontakt</th>
<th style={{ textAlign: "center", width: 72 }}>Projekte</th>
<th style={{ width: 110 }}></th>
</tr></thead>
<tbody>
{group.items.map(p => {
const addr = [p.street, [p.zip, p.city].filter(Boolean).join(" ")].filter(Boolean).join(", ");
const projCount = projects.filter(pr => pr.clientId === p.id || (pr.projectContacts || []).some(pc => pc.contactId === p.id)).length;
const contacts = p.contacts || [];
const hauptkontakt = contacts[0] || null;
return (
<React.Fragment key={p.id}>
<tr style={{ cursor: "pointer" }} onClick={() => setSelectedId(p.id)}>
<td>
<strong style={{ color: "var(--text)" }}>{p.name}</strong>
{p.email && <div style={{ fontSize: 11, color: "#888" }}>{p.email}</div>}
</td>
<td>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{p.isAuftraggeber && <RoleBadge label="Auftraggeber" color="#1a4e8a" bg="#e8f0fa" />}
{p.isPartner && <RoleBadge label="Partner" color="#2d6a4f" bg="#e8f5ee" />}
</div>
</td>
<td style={{ fontSize: 12, color: "#888" }}>{p.type || <span style={{ color: "#ddd" }}></span>}</td>
<td style={{ fontSize: 12, color: "#777" }}>{addr || <span style={{ color: "#ddd" }}></span>}</td>
<td style={{ fontSize: 12 }}>
{hauptkontakt ? (
<div>
<span style={{ fontWeight: 500 }}>{hauptkontakt.name}</span>
{hauptkontakt.position && <span style={{ color: "#aaa" }}>, {hauptkontakt.position}</span>}
{contacts.length > 1 && <span style={{ fontSize: 10, color: "#aaa", marginLeft: 6 }}>+{contacts.length - 1}</span>}
</div>
) : <span style={{ color: "#ddd" }}></span>}
</td>
<td style={{ textAlign: "center", fontSize: 12, color: projCount ? "#2d6a4f" : "#ccc", fontWeight: projCount ? 600 : 400 }}>{projCount || "—"}</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }} onClick={e => e.stopPropagation()}>
<div style={{ display: "flex", gap: 4, justifyContent: "flex-end" }}>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "3px 8px" }} title="Person hinzufügen" onClick={() => openNewPerson(p.id)}>+ P</button>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "3px 8px" }} title="Bearbeiten" onClick={() => openEditFirm(p.id)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ fontSize: 11, padding: "3px 7px" }} onClick={() => deleteFirm(p.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</td>
</tr>
{contacts.map(c => (
<tr key={c.id} style={{ background: "var(--surface2)", cursor: "default" }} onClick={e => e.stopPropagation()}>
<td colSpan={4} style={{ paddingLeft: 28, fontSize: 12, color: "var(--text3)" }}>
<span style={{ color: "var(--text5)", marginRight: 8 }}></span>
<span style={{ fontWeight: 500 }}>{c.name}</span>
{c.position && <span style={{ color: "var(--text4)" }}>, {c.position}</span>}
{c.email && <span style={{ color: "var(--text4)", marginLeft: 10 }}>{c.email}</span>}
{c.phone && <span style={{ color: "var(--text4)", marginLeft: 10 }}>{c.phone}</span>}
</td>
<td colSpan={3} style={{ textAlign: "right", paddingRight: 12 }}>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "2px 7px" }} onClick={() => openEditPerson(p.id, c.id)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ fontSize: 11, padding: "2px 6px", marginLeft: 4 }} onClick={() => deleteContactFromFirm(p.id, c.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
))}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</div>
))
)}
{firmModal && renderFirmModal()}
{personModal && renderPersonModal()}
</div>
);
}
+417
View File
@@ -0,0 +1,417 @@
import React, { useState, useRef } from "react";
import { generateId } from "../utils.js";
const TYPE_META = {
beitrag: { label: "Beitrag", color: "#1a4e8a", bg: "#e8f0fa" },
ankuendigung: { label: "Ankündigung", color: "#b5621e", bg: "#fdf0e8" },
event: { label: "Event", color: "#2d6a4f", bg: "#e8f5ee" },
};
function canWriteCheck(currentUser, data) {
const myUser = (data.users || []).find(u => u.id === currentUser?.id);
const myRole = (data.appRoles|| []).find(r => r.id === (currentUser?.appRoleId || myUser?.appRoleId));
return currentUser?.role === "admin" || myRole?.permissions === null || (myRole?.permissions || []).includes("pinnwand-schreiben");
}
// ─── Poll bar ─────────────────────────────────────────────────────────────────
function PollBlock({ poll, postId, currentUserId, onVote }) {
if (!poll) return null;
const total = Object.keys(poll.votes || {}).length;
const myVote = poll.votes?.[currentUserId];
const hasVoted = !!myVote;
return (
<div style={{ marginTop: 14, padding: "12px 14px", background: "var(--surface2)", borderRadius: 10, border: "1px solid var(--border2)" }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text)", marginBottom: 10 }}>{poll.question}</div>
{poll.options.map(opt => {
const count = Object.values(poll.votes || {}).filter(v => v === opt.id).length;
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
const isMyVote = myVote === opt.id;
return (
<div key={opt.id} style={{ marginBottom: 8 }}>
{!hasVoted ? (
<button onClick={() => onVote(postId, opt.id)} style={{
width: "100%", padding: "8px 14px", textAlign: "left",
background: "var(--surface)", border: "1.5px solid var(--border)",
borderRadius: 8, cursor: "pointer", fontSize: 13, fontFamily: "inherit",
color: "var(--text)", transition: "border-color 0.15s",
}}
onMouseEnter={e => e.currentTarget.style.borderColor = "#9a7858"}
onMouseLeave={e => e.currentTarget.style.borderColor = "var(--border)"}
>{opt.label}</button>
) : (
<div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4, fontSize: 12 }}>
<span style={{ color: isMyVote ? "var(--text)" : "var(--text3)", fontWeight: isMyVote ? 600 : 400 }}>
{isMyVote ? "✓ " : ""}{opt.label}
</span>
<span style={{ color: "var(--text4)" }}>{count} ({pct}%)</span>
</div>
<div style={{ height: 4, background: "var(--border2)", borderRadius: 2 }}>
<div style={{ width: `${pct}%`, height: "100%", background: isMyVote ? "#1a4e8a" : "var(--border3)", borderRadius: 2, transition: "width 0.4s" }} />
</div>
</div>
)}
</div>
);
})}
{hasVoted && <div style={{ fontSize: 11, color: "var(--text4)", marginTop: 6 }}>{total} Stimme{total !== 1 ? "n" : ""} abgegeben</div>}
</div>
);
}
// ─── Post card ────────────────────────────────────────────────────────────────
function PostCard({ post, currentUser, canWrite, onEdit, onDelete, onPin, onVote }) {
const meta = TYPE_META[post.type] || TYPE_META.beitrag;
const isOwn = post.authorId === currentUser?.id;
const isAdmin = currentUser?.role === "admin";
const [expanded, setExpanded] = useState(true);
const [confirmDel, setConfirmDel] = useState(false);
const bodyLines = (post.body || "").split("\n");
const isLong = bodyLines.length > 6 || post.body?.length > 400;
const isDraft = post.status === "entwurf";
return (
<div style={{ background: "var(--surface)", border: isDraft ? "1.5px dashed var(--border3)" : "1px solid var(--border2)", borderRadius: 14, marginBottom: 14, boxShadow: "0 1px 4px rgba(0,0,0,0.05)", overflow: "hidden", opacity: isDraft ? 0.85 : 1 }}>
{post.image && (
<img src={post.image} alt="" style={{ width: "100%", maxHeight: 300, objectFit: "cover", display: "block" }} />
)}
<div style={{ padding: "18px 22px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span style={{ fontSize: 10, fontWeight: 600, padding: "2px 10px", borderRadius: 20, color: meta.color, background: meta.bg, letterSpacing: "0.06em" }}>
{meta.label.toUpperCase()}
</span>
{isDraft && <span style={{ fontSize: 10, color: "#7a6a00", background: "#fffbe6", fontWeight: 600, padding: "2px 10px", borderRadius: 20, letterSpacing: "0.06em" }}>ENTWURF</span>}
{post.pinned && <span style={{ fontSize: 10, color: "#b07848", fontWeight: 600, letterSpacing: "0.06em", display: "flex", alignItems: "center", gap: 3 }}><span className="material-icons" style={{ fontSize: 12 }}>push_pin</span> ANGEPINNT</span>}
{post.eventDate && (
<span style={{ fontSize: 11, color: "#2d6a4f", background: "#e8f5ee", padding: "2px 10px", borderRadius: 20 }}>
📅 {new Date(post.eventDate).toLocaleDateString("de-CH", { weekday: "short", day: "numeric", month: "long" })}
</span>
)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, flexShrink: 0, marginLeft: 8 }}>
{isAdmin && !isDraft && (
<button onClick={() => onPin(post.id)} title={post.pinned ? "Loslösen" : "Anpinnen"}
style={{ background: "none", border: "none", cursor: "pointer", opacity: post.pinned ? 1 : 0.4, padding: 2, display: "flex", alignItems: "center" }}><span className="material-icons" style={{ fontSize: 18 }}>push_pin</span></button>
)}
{(isOwn || isAdmin) && canWrite && (
<>
<button onClick={() => onEdit(post)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text4)", padding: "2px 6px", display: "flex", alignItems: "center" }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
{confirmDel ? (
<>
<span style={{ fontSize: 11, color: "#8a1a1a", fontFamily: "inherit" }}>Löschen?</span>
<button onClick={() => onDelete(post.id)} style={{ background: "#8a1a1a", color: "#fff", border: "none", borderRadius: 5, cursor: "pointer", fontSize: 11, padding: "2px 8px", fontFamily: "inherit" }}>Ja</button>
<button onClick={() => setConfirmDel(false)} style={{ background: "none", border: "1px solid var(--border3)", borderRadius: 5, cursor: "pointer", fontSize: 11, padding: "2px 8px", fontFamily: "inherit", color: "var(--text3)" }}>Nein</button>
</>
) : (
<button onClick={() => setConfirmDel(true)} style={{ background: "none", border: "none", cursor: "pointer", color: "#8a1a1a", padding: "2px 6px", display: "flex", alignItems: "center" }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
)}
</>
)}
</div>
</div>
{post.title && <div style={{ fontSize: 17, fontFamily: "'Playfair Display', serif", color: "var(--text)", marginBottom: 8, lineHeight: 1.3 }}>{post.title}</div>}
<div style={{ fontSize: 13, color: "var(--text2)", lineHeight: 1.7, whiteSpace: "pre-wrap", overflow: "hidden", maxHeight: expanded ? "none" : "6.8em" }}>
{post.body}
</div>
{isLong && (
<button onClick={() => setExpanded(e => !e)} style={{ background: "none", border: "none", color: "#9a7858", fontSize: 12, cursor: "pointer", fontFamily: "inherit", padding: "4px 0", marginTop: 2 }}>
{expanded ? "Weniger anzeigen ↑" : "Mehr anzeigen ↓"}
</button>
)}
<PollBlock poll={post.poll} postId={post.id} currentUserId={currentUser?.id} onVote={onVote} />
<div style={{ marginTop: 12, fontSize: 11, color: "var(--text4)", display: "flex", gap: 16 }}>
<span>{post.authorName}</span>
<span>{new Date(post.createdAt).toLocaleDateString("de-CH", { day: "numeric", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
</div>
</div>
</div>
);
}
// ─── Post modal ───────────────────────────────────────────────────────────────
function PostModal({ initial, onSave, onClose }) {
const [form, setForm] = useState(initial || { title: "", body: "", type: "beitrag", eventDate: "", pinned: false, poll: null, image: "" });
const [pollEnabled, setPollEnabled] = useState(!!initial?.poll);
const [poll, setPoll] = useState(initial?.poll || { question: "", options: [{ id: generateId(), label: "" }, { id: generateId(), label: "" }], votes: {} });
const fileInputRef = useRef(null);
const setF = patch => setForm(f => ({ ...f, ...patch }));
const setOpt = (id, label) => setPoll(p => ({ ...p, options: p.options.map(o => o.id === id ? { ...o, label } : o) }));
const addOpt = () => { if (poll.options.length >= 5) return; setPoll(p => ({ ...p, options: [...p.options, { id: generateId(), label: "" }] })); };
const delOpt = id => { if (poll.options.length <= 2) return; setPoll(p => ({ ...p, options: p.options.filter(o => o.id !== id) })); };
const handleImage = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.onload = () => {
const maxW = 1200;
const scale = img.width > maxW ? maxW / img.width : 1;
const canvas = document.createElement("canvas");
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
setF({ image: canvas.toDataURL("image/jpeg", 0.8) });
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
e.target.value = "";
};
const handleSave = (draft = false) => {
if (!form.body.trim()) return;
const finalPoll = pollEnabled && poll.question.trim() && poll.options.filter(o => o.label.trim()).length >= 2
? { ...poll, options: poll.options.filter(o => o.label.trim()), votes: initial?.poll?.votes || {} }
: null;
onSave({ ...form, poll: finalPoll }, draft);
};
const isDraft = initial?.status === "entwurf";
const isNew = !initial;
const primaryLabel = (isNew || isDraft) ? "Veröffentlichen" : "Speichern";
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" style={{ maxWidth: 580 }} onClick={e => e.stopPropagation()}>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 20, marginBottom: 20, color: "var(--text)" }}>
{initial ? "Beitrag bearbeiten" : "Neuer Beitrag"}
</div>
<div className="form-row" style={{ marginBottom: 14 }}>
<div className="form-group" style={{ flex: "0 0 160px" }}>
<label>TYP</label>
<select value={form.type} onChange={e => setF({ type: e.target.value })}>
{Object.entries(TYPE_META).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
{form.type === "event" && (
<div className="form-group">
<label>DATUM</label>
<input type="date" value={form.eventDate} onChange={e => setF({ eventDate: e.target.value })} />
</div>
)}
</div>
<div className="form-group" style={{ marginBottom: 14 }}>
<label>TITEL (optional)</label>
<input value={form.title} onChange={e => setF({ title: e.target.value })} placeholder="Titel des Beitrags…" />
</div>
<div className="form-group" style={{ marginBottom: 14 }}>
<label>INHALT *</label>
<textarea value={form.body} onChange={e => setF({ body: e.target.value })} rows={5} placeholder="Beitragstext…" style={{ minHeight: 120, resize: "vertical" }} />
</div>
{/* Image upload */}
<div style={{ marginBottom: 14 }}>
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: "none" }} onChange={handleImage} />
{form.image ? (
<div style={{ position: "relative" }}>
<img src={form.image} alt="" style={{ width: "100%", maxHeight: 220, objectFit: "cover", borderRadius: 8, display: "block" }} />
<button onClick={() => setF({ image: "" })} style={{
position: "absolute", top: 6, right: 6, background: "rgba(0,0,0,0.55)", border: "none",
borderRadius: 6, color: "#fff", fontSize: 11, padding: "4px 10px", cursor: "pointer", fontFamily: "inherit",
}}>Bild entfernen</button>
</div>
) : (
<button onClick={() => fileInputRef.current?.click()} style={{
width: "100%", padding: "10px 0", border: "1.5px dashed var(--border3)", borderRadius: 8,
background: "transparent", color: "var(--text4)", fontSize: 12, cursor: "pointer", fontFamily: "inherit",
}}>+ Beitragsbild hochladen</button>
)}
</div>
{/* Poll */}
<div style={{ marginBottom: 18, padding: "12px 14px", background: "var(--surface2)", borderRadius: 10, border: "1px solid var(--border2)" }}>
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 12, marginBottom: pollEnabled ? 12 : 0 }}>
<input type="checkbox" checked={pollEnabled} onChange={e => setPollEnabled(e.target.checked)} style={{ width: "auto" }} />
Umfrage hinzufügen
</label>
{pollEnabled && (
<>
<div className="form-group" style={{ marginBottom: 10 }}>
<label>FRAGE</label>
<input value={poll.question} onChange={e => setPoll(p => ({ ...p, question: e.target.value }))} placeholder="z.B. Bist du dabei?" />
</div>
<label style={{ fontSize: 9, letterSpacing: "0.1em", color: "var(--text4)", fontWeight: 500, display: "block", marginBottom: 6 }}>ANTWORTMÖGLICHKEITEN</label>
{poll.options.map((opt, i) => (
<div key={opt.id} style={{ display: "flex", gap: 6, marginBottom: 6 }}>
<input value={opt.label} onChange={e => setOpt(opt.id, e.target.value)} placeholder={`Option ${i + 1}`} style={{ flex: 1 }} />
{poll.options.length > 2 && <button onClick={() => delOpt(opt.id)} style={{ background: "none", border: "none", color: "#8a1a1a", cursor: "pointer", padding: "0 4px", display: "flex", alignItems: "center" }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>}
</div>
))}
{poll.options.length < 5 && (
<button onClick={addOpt} style={{ background: "none", border: "1px dashed var(--border3)", borderRadius: 6, padding: "5px 12px", fontSize: 11, color: "var(--text4)", cursor: "pointer", fontFamily: "inherit", marginTop: 2 }}>+ Option</button>
)}
</>
)}
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button className="btn btn-ghost" onClick={onClose}>Abbrechen</button>
<button className="btn btn-ghost" onClick={() => handleSave(true)} disabled={!form.body.trim()}>
Als Entwurf speichern
</button>
<button className="btn btn-primary" onClick={() => handleSave(false)} disabled={!form.body.trim()}>
{primaryLabel}
</button>
</div>
</div>
</div>
);
}
// ─── Main view ────────────────────────────────────────────────────────────────
export default function Pinnwand({ data, update, currentUser }) {
const [modal, setModal] = useState(null); // null | { mode: "create" } | { mode: "edit", post }
const [filter, setFilter] = useState("alle");
const canWrite = canWriteCheck(currentUser, data);
const posts = data.blogPosts || [];
const myId = currentUser?.id;
const isAdminUser = currentUser?.role === "admin";
const sorted = [...posts]
.filter(p => {
if (p.status === "entwurf" && p.authorId !== myId && !isAdminUser) return false;
if (filter === "entwuerfe") return p.status === "entwurf";
if (filter !== "alle") return p.type === filter;
return true;
})
.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
return b.createdAt.localeCompare(a.createdAt);
});
const savePost = (form, draft = false) => {
const status = draft ? "entwurf" : "published";
if (modal?.mode === "edit") {
update("blogPosts", posts.map(p => p.id === form.id ? { ...p, ...form, status } : p));
} else {
const newPost = {
...form,
id: generateId(),
authorId: myId || "admin",
authorName: currentUser?.displayName || currentUser?.username || "—",
createdAt: new Date().toISOString(),
pinned: false,
status,
};
update("blogPosts", [newPost, ...posts]);
}
setModal(null);
};
const deletePost = id => update("blogPosts", posts.filter(p => p.id !== id));
const togglePin = id => update("blogPosts", posts.map(p => p.id === id ? { ...p, pinned: !p.pinned } : p));
const vote = (postId, optionId) => {
update("blogPosts", posts.map(p => {
if (p.id !== postId || !p.poll) return p;
const votes = { ...p.poll.votes, [currentUser?.id]: optionId };
return { ...p, poll: { ...p.poll, votes } };
}));
};
// Holiday notice this week
const today = new Date();
const getMonday = d => { const dd = new Date(d); dd.setDate(dd.getDate() - (dd.getDay() === 0 ? 6 : dd.getDay() - 1)); return dd; };
const weekStart = getMonday(today);
const weekEnd = new Date(weekStart); weekEnd.setDate(weekEnd.getDate() + 6);
const toISO = d => d.toISOString().slice(0, 10);
const feiertageThisWeek = (data.feiertage || []).filter(f => f.date >= toISO(weekStart) && f.date <= toISO(weekEnd));
const FILTER_OPTS = [
{ id: "alle", label: "Alle" },
{ id: "beitrag", label: "Beiträge" },
{ id: "ankuendigung", label: "Ankündigungen" },
{ id: "event", label: "Events" },
...(canWrite ? [{ id: "entwuerfe", label: "Entwürfe" }] : []),
];
const btnStyle = active => ({
padding: "5px 14px", borderRadius: 20, border: "1.5px solid", fontSize: 11, cursor: "pointer", fontFamily: "inherit",
background: active ? "var(--text)" : "var(--surface)",
color: active ? "var(--bg)" : "var(--text3)",
borderColor: active ? "var(--text)" : "var(--border3)",
transition: "all 0.15s",
});
const leftPosts = sorted.filter((_, i) => i % 2 === 0);
const rightPosts = sorted.filter((_, i) => i % 2 === 1);
const renderCard = post => (
<PostCard
key={post.id}
post={post}
currentUser={currentUser}
canWrite={canWrite}
onEdit={p => setModal({ mode: "edit", post: p })}
onDelete={deletePost}
onPin={togglePin}
onVote={vote}
/>
);
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 24 }}>
<div>
<h1 style={{ fontFamily: "'Playfair Display', serif", fontSize: 30, fontWeight: 400, letterSpacing: "-0.02em", color: "var(--text)", marginBottom: 6 }}>Pinnwand</h1>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{FILTER_OPTS.map(opt => (
<button key={opt.id} onClick={() => setFilter(opt.id)} style={btnStyle(filter === opt.id)}>{opt.label}</button>
))}
</div>
</div>
{canWrite && (
<button className="btn btn-primary" onClick={() => setModal({ mode: "create" })} style={{ flexShrink: 0 }}>
+ Beitrag
</button>
)}
</div>
{/* Holiday notice */}
{feiertageThisWeek.length > 0 && (
<div style={{ padding: "12px 18px", background: "#e8f5ee", border: "1.5px solid #a8d8b8", borderRadius: 12, marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
<span style={{ fontSize: 20 }}>🎉</span>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: "#2d6a4f", marginBottom: 2 }}>Feiertag diese Woche</div>
<div style={{ fontSize: 12, color: "#3a7a5a" }}>
{feiertageThisWeek.map(f => `${new Date(f.date).toLocaleDateString("de-CH", { weekday: "long", day: "numeric", month: "long" })}: ${f.name}`).join(" · ")}
</div>
</div>
</div>
)}
{sorted.length === 0 && (
<div style={{ textAlign: "center", padding: "60px 0", color: "var(--text4)" }}>
<div style={{ fontSize: 32, marginBottom: 12 }}>📋</div>
<div style={{ fontSize: 14 }}>Noch keine Beiträge</div>
{canWrite && <div style={{ fontSize: 12, marginTop: 6 }}>Erstelle den ersten Beitrag mit dem Button oben rechts.</div>}
</div>
)}
{sorted.length > 0 && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, alignItems: "start" }}>
<div>{leftPosts.map(renderCard)}</div>
<div>{rightPosts.map(renderCard)}</div>
</div>
)}
{modal && (
<PostModal
initial={modal.mode === "edit" ? modal.post : null}
onSave={savePost}
onClose={() => setModal(null)}
/>
)}
</div>
);
}
+1781
View File
File diff suppressed because it is too large Load Diff
+978
View File
@@ -0,0 +1,978 @@
import React, { useState, useEffect, useRef } from "react";
import { PROTOKOLL_TYPES, PROTOKOLL_ENTRY_TYPES } from "../constants.js";
import { generateId, formatCHF, formatDate, formatHours, buildReminderLetter, getKW, getWeekNumber, formatKW, nextProtoNumber, nextProtoSeq, applyProtoNumberFormat } from "../utils.js";
import { Header, Modal, FormField, StatusBadge, useConfirm, RichEditor , DateInput } from "../components/UI.jsx";
export
function MahnModal({ inv, data, update, setPrintContent, onClose, mahnMode, setMahnMode, mahnSentDate, setMahnSentDate }) {
const reminders = inv.reminders || [];
const nextNr = reminders.length + 1;
const lastReminder = reminders.at(-1);
const nextLabel = nextNr === 1 ? "Zahlungserinnerung" : `${nextNr}. Mahnung`;
const nextColor = nextNr >= 3 ? "#8a1a1a" : nextNr === 2 ? "#b5621e" : "#7a6a00";
const confirm = () => {
if (mahnMode.startsWith("reprint-")) {
const idx = parseInt(mahnMode.split("-")[1]);
const r = reminders[idx];
const { client, subject, body } = buildReminderLetter(inv, r.nr, r.sentDate || r.date, (data.persons||[]).filter(p=>p.isAuftraggeber), data.settings);
setPrintContent({ type: "letter", client, subject, body, settings: data.settings });
} else {
const { client, subject, body } = buildReminderLetter(inv, nextNr, mahnSentDate, (data.persons||[]).filter(p=>p.isAuftraggeber), data.settings);
setPrintContent({ type: "letter", client, subject, body, settings: data.settings });
const newReminder = { nr: nextNr, date: new Date().toISOString().slice(0, 10), sentDate: mahnSentDate, daysPast: Math.floor((new Date() - new Date(inv.dueDate)) / 86400000) };
update("invoices", data.invoices.map(i => i.id === inv.id
? { ...i, status: "überfällig", reminders: [...reminders, newReminder] }
: i
));
}
onClose();
};
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 480 }}>
<h2 style={{ fontFamily: "'Playfair Display', serif", fontWeight: 400, marginBottom: 6, fontSize: 22 }}>Mahnung</h2>
<div style={{ fontSize: 12, color: "var(--text4)", marginBottom: 20 }}>Rechnung {inv.number} · {formatCHF(inv.total)}</div>
{reminders.length > 0 && (
<div style={{ marginBottom: 20, padding: "10px 14px", background: "var(--surface2)", borderRadius: 6, border: "1px solid var(--border2)" }}>
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "var(--text4)", marginBottom: 8 }}>BISHERIGE MAHNUNGEN</div>
{reminders.map((r, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", fontSize: 12, padding: "3px 0", borderBottom: i < reminders.length - 1 ? "1px solid var(--border2)" : "none" }}>
<span style={{ color: "var(--text2)" }}>{i === 0 ? "Zahlungserinnerung" : `${r.nr}. Mahnung`}</span>
<span style={{ color: "var(--text4)" }}>gesendet {formatDate(r.sentDate || r.date)}</span>
</div>
))}
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 20 }}>
{reminders.map((r, i) => {
const rLabel = i === 0 ? "Zahlungserinnerung" : `${r.nr}. Mahnung`;
const rMode = `reprint-${i}`;
return (
<label key={i} style={{ display: "flex", gap: 12, padding: "11px 14px", borderRadius: 6, border: `1.5px solid ${mahnMode === rMode ? "var(--text)" : "var(--border)"}`, cursor: "pointer", textTransform: "none", fontSize: 13, color: "var(--text)" }}>
<input type="radio" checked={mahnMode === rMode} onChange={() => setMahnMode(rMode)} style={{ width: "auto", marginTop: 2 }} />
<div>
<div style={{ fontWeight: 500 }}>{rLabel} nochmals drucken</div>
<div style={{ fontSize: 11, color: "var(--text4)", marginTop: 2 }}>Gesendet am {formatDate(r.sentDate || r.date)} · kein neuer Eintrag</div>
</div>
</label>
);
})}
<label style={{ display: "flex", gap: 12, padding: "11px 14px", borderRadius: 6, border: `1.5px solid ${mahnMode === "new" ? "var(--text)" : "var(--border)"}`, cursor: "pointer", textTransform: "none", fontSize: 13, color: "var(--text)" }}>
<input type="radio" checked={mahnMode === "new"} onChange={() => setMahnMode("new")} style={{ width: "auto", marginTop: 2 }} />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, color: nextColor }}>{nextLabel} auslösen</div>
<div style={{ fontSize: 11, color: "var(--text4)", marginTop: 2 }}>Wird als neuer Eintrag gespeichert</div>
{mahnMode === "new" && (
<div style={{ marginTop: 10, display: "flex", alignItems: "center", gap: 8 }}>
<label style={{ fontSize: 11, color: "var(--text4)", textTransform: "uppercase", letterSpacing: "0.06em", whiteSpace: "nowrap" }}>Sendedatum</label>
<DateInput value={mahnSentDate} onChange={e => setMahnSentDate(e.target.value)} style={{ flex: 1, height: 32, fontSize: 12 }} />
</div>
)}
</div>
</label>
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button className="btn btn-ghost" onClick={onClose}>Abbrechen</button>
<button className="btn btn-primary" onClick={confirm} style={{ background: mahnMode === "new" ? nextColor : "#2a2a22" }}>
Drucken{mahnMode === "new" ? " & speichern" : ""}
</button>
</div>
</div>
</div>
);
}
export default
function Protokolle({ data, update, saveAll, setPrintContent }) {
const today = new Date().toISOString().slice(0, 10);
const protokolle = data.protocols || [];
const [detailId, setDetailId] = useState(() => {
const id = window.__openProtokoll || null;
window.__openProtokoll = null;
return id;
});
const [filter, setFilter] = useState({ search: "", type: "", projectId: "" });
const [sort, setSort] = useState({ col: "date", dir: -1 });
const detail = detailId ? protokolle.find(p => p.id === detailId) : null;
// hooks must be called before any early return
const { askConfirm, ConfirmModalEl } = useConfirm();
const deleteProtokoll = async (id) => {
if (await askConfirm("Protokoll löschen?")) {
saveAll({ ...data, protocols: protokolle.filter(x => x.id !== id) });
}
};
// ── Detail-Ansicht ────────────────────────────────────────────
if (detail) {
return <ProtokollDetail
protokoll={detail}
data={data}
onBack={() => setDetailId(null)}
onSave={p => { saveAll({ ...data, protocols: protokolle.map(x => x.id === p.id ? p : x) }); }}
onDelete={id => { saveAll({ ...data, protocols: protokolle.filter(x => x.id !== id) }); setDetailId(null); }}
setPrintContent={setPrintContent}
saveAll={saveAll}
/>;
}
// ── Listenansicht ─────────────────────────────────────────────
const newProtokoll = () => {
const defaultType = PROTOKOLL_TYPES[0];
const abbr = data.settings.protokollTypeAbbreviations || {};
const typKuerzel = abbr[defaultType] || "SO";
const seq = nextProtoSeq(protokolle);
const nummer = applyProtoNumberFormat(data.settings.protokollNumberFormat || "YYYY-TT-NN", {
date: today, projectNumber: "", seq, typKuerzel,
});
const p = {
id: generateId(),
title: "",
type: defaultType,
date: today,
time: "10:00",
endTime: "",
location: "",
projectId: "",
projectManual: "",
nummer,
participants: [],
traktanden: [{ id: generateId(), nr: "1", title: "", items: [] }],
nextDate: "",
verteiler: "",
createdAt: new Date().toISOString(),
};
saveAll({ ...data, protocols: [...protokolle, p] });
setDetailId(p.id);
};
const filtered = protokolle.filter(p => {
if (filter.type && p.type !== filter.type) return false;
if (filter.projectId && p.projectId !== filter.projectId) return false;
if (filter.search) {
const q = filter.search.toLowerCase();
const proj = data.projects.find(x => x.id === p.projectId);
if (![p.title, p.nummer, p.type, proj?.name, p.location].filter(Boolean).join(" ").toLowerCase().includes(q)) return false;
}
return true;
});
const toggleSort = col => setSort(s => ({ col, dir: s.col === col ? -s.dir : -1 }));
const SortTh = ({ col, children, style }) => (
<th onClick={() => toggleSort(col)} style={{ cursor: "pointer", userSelect: "none", ...style }}>
{children} <span style={{ color: sort.col === col ? "#b07848" : "#ccc", fontSize: 10 }}>{sort.col === col ? (sort.dir === 1 ? "▲" : "▼") : "⇅"}</span>
</th>
);
const sorted = [...filtered].sort((a, b) => {
const va = sort.col === "date" ? (a.date || "") : sort.col === "type" ? (a.type || "") : sort.col === "nummer" ? (a.nummer || "") : (a.title || "");
const vb = sort.col === "date" ? (b.date || "") : sort.col === "type" ? (b.type || "") : sort.col === "nummer" ? (b.nummer || "") : (b.title || "");
return va.localeCompare(vb) * sort.dir;
});
// Offene Aufgaben über alle Protokolle
const alleTasks = protokolle.flatMap(p =>
(p.traktanden || []).flatMap(t =>
(t.items || []).filter(it => it.type === "aufgabe" && it.status !== "erledigt")
.map(it => ({ ...it, protokollId: p.id, protokollNr: p.nummer, protokollTitle: p.title, protokollDate: p.date }))
)
);
return (
<div>
{ConfirmModalEl}
<Header title="Protokolle" action={
<button className="btn btn-primary" onClick={newProtokoll}>+ Neues Protokoll</button>
} />
{/* Offene Aufgaben-Banner */}
{alleTasks.length > 0 && (
<div className="card" style={{ marginBottom: 20, borderLeft: "4px solid #b5621e", padding: "12px 20px" }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#b5621e", marginBottom: 10, fontWeight: 600 }}>
OFFENE AUFGABEN ({alleTasks.length})
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{alleTasks.slice(0, 6).map(t => {
const due = t.dueDateType === "kw" ? `KW ${t.dueKW}/${t.dueYear || new Date().getFullYear()}` : t.dueDate ? formatDate(t.dueDate) : "—";
const isLate = t.dueDate && t.dueDateType === "datum" && t.dueDate < today;
return (
<div key={t.id} onClick={() => setDetailId(t.protokollId)}
style={{ fontSize: 12, padding: "5px 10px", background: isLate ? "#fdf2f2" : "var(--surface2)", border: `1px solid ${isLate ? "#e8b0b0" : "var(--border)"}`, borderRadius: 4, cursor: "pointer" }}>
<span style={{ color: "var(--text4)", fontSize: 10, marginRight: 6 }}>{t.protokollNr}</span>
<strong>{t.text}</strong>
{t.responsible && <span style={{ color: "var(--text4)", marginLeft: 6 }}> {t.responsible}</span>}
<span style={{ color: isLate ? "#8a1a1a" : "var(--text4)", marginLeft: 6, fontSize: 10 }}>{due}</span>
</div>
);
})}
{alleTasks.length > 6 && <div style={{ fontSize: 12, color: "var(--text4)", padding: "5px 10px" }}>+{alleTasks.length - 6} weitere</div>}
</div>
</div>
)}
<div className="filter-bar">
<input className="pill" placeholder="Suche (Titel, Nr., Ort…)" value={filter.search} onChange={e => setFilter({ ...filter, search: e.target.value })} style={{ minWidth: 200 }} />
<select className="pill" value={filter.type} onChange={e => setFilter({ ...filter, type: e.target.value })}>
<option value="">Alle Typen</option>
{PROTOKOLL_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<select className="pill" value={filter.projectId} onChange={e => setFilter({ ...filter, projectId: e.target.value })}>
<option value="">Alle Projekte</option>
{data.projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
{(filter.search || filter.type || filter.projectId) && (
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => setFilter({ search: "", type: "", projectId: "" })}>Zurücksetzen</button>
)}
<div style={{ marginLeft: "auto", fontSize: 12, color: "#888" }}>
<strong style={{ color: "#1a1a18" }}>{filtered.length}</strong> Protokolle
</div>
</div>
{sorted.length === 0 ? (
<div className="card" style={{ padding: 0 }}>
<table>
<thead><tr><th style={{ width: 110 }}>Nr.</th><th style={{ width: 100 }}>Datum</th><th style={{ width: 160 }}>Typ</th><th>Titel</th><th style={{ width: 160 }}>Projekt</th><th style={{ width: 50 }}>TN</th><th style={{ width: 50 }}>📌</th><th style={{ width: 130 }}></th></tr></thead>
<tbody><tr><td colSpan={8} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>{protokolle.length === 0 ? "Noch keine Protokolle" : "Keine Treffer"}</td></tr></tbody>
</table>
</div>
) : (
<div className="card" style={{ padding: 0 }}>
<table>
<thead>
<tr>
<SortTh col="nummer" style={{ width: 110 }}>Nr.</SortTh>
<SortTh col="date" style={{ width: 100 }}>Datum</SortTh>
<SortTh col="type" style={{ width: 160 }}>Typ</SortTh>
<SortTh col="title">Titel</SortTh>
<th style={{ width: 160 }}>Projekt</th>
<th style={{ width: 50, textAlign: "center" }}>TN</th>
<th style={{ width: 50, textAlign: "center" }}>📌</th>
<th style={{ width: 130 }}></th>
</tr>
</thead>
<tbody>
{sorted.map(p => {
const proj = data.projects.find(x => x.id === p.projectId);
const anwesend = (p.participants || []).filter(x => x.status === "anwesend").length;
const total = (p.participants || []).length;
const offeneTasks = (p.traktanden || []).flatMap(t => (t.items || []).filter(it => it.type === "aufgabe" && it.status !== "erledigt")).length;
return (
<tr key={p.id} onClick={() => setDetailId(p.id)} style={{ cursor: "pointer" }}>
<td><strong style={{ color: "#b07848" }}>{p.nummer}</strong></td>
<td>{formatDate(p.date)}</td>
<td><span style={{ fontSize: 11, color: "#555" }}>{p.type}</span></td>
<td><strong>{p.title || <span style={{ color: "#aaa", fontWeight: 400 }}>Kein Titel</span>}</strong></td>
<td style={{ color: "#888", fontSize: 12 }}>{proj?.name || p.projectManual || "—"}</td>
<td style={{ textAlign: "center", fontSize: 12, color: "#888" }}>{total > 0 ? `${anwesend}/${total}` : "—"}</td>
<td style={{ textAlign: "center" }}>
{offeneTasks > 0 && <span style={{ fontSize: 11, color: "#b5621e", fontWeight: 600 }}>{offeneTasks}</span>}
</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn btn-ghost" style={{ fontSize: 12, padding: "5px 10px", marginRight: 4 }}
onClick={e => { e.stopPropagation(); setPrintContent({ type: "protokoll", protokoll: p, data, settings: data.settings }); }}>PDF</button>
<button className="btn btn-ghost" style={{ fontSize: 12, padding: "5px 10px", marginRight: 4 }}
onClick={e => { e.stopPropagation(); setDetailId(p.id); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ fontSize: 12, padding: "5px 10px" }}
onClick={e => { e.stopPropagation(); deleteProtokoll(p.id); }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
export
function ItemEditor({ tId, item, today, onUpdate, onRemove }) {
const typeConfig = {
info: { icon: "", color: "#1a4e8a", label: "Information" },
beschluss:{ icon: "✓", color: "#2d6a4f", label: "Beschluss" },
aufgabe: { icon: "→", color: "#b5621e", label: "Aufgabe" },
};
const tc = typeConfig[item.type] || typeConfig.info;
return (
<div style={{ display: "flex", gap: 10, alignItems: "flex-start", flex: 1 }}>
<div style={{ flexShrink: 0, width: 24, height: 24, borderRadius: 4, background: tc.color, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 12, marginTop: 6 }}>{tc.icon}</div>
<div style={{ flex: 1 }}>
<RichEditor value={item.text || ""} onChange={html => onUpdate({ text: html })} minHeight={60} compact />
{item.type === "beschluss" && (
<div style={{ display: "flex", gap: 8, marginTop: 6, alignItems: "center" }}>
<label style={{ fontSize: 11, color: "var(--text4)", textTransform: "uppercase", letterSpacing: "0.06em" }}>Beschlussdatum</label>
<DateInput value={item.date || today} onChange={e => onUpdate({ date: e.target.value })}
style={{ height: 28, fontSize: 11, width: 140 }} />
</div>
)}
{item.type === "aufgabe" && (
<div style={{ display: "flex", gap: 8, marginTop: 6, flexWrap: "wrap", alignItems: "center" }}>
<input value={item.responsible || ""} onChange={e => onUpdate({ responsible: e.target.value })}
placeholder="Verantwortliche/r" style={{ height: 28, fontSize: 11, flex: "1 1 140px", maxWidth: 200 }} />
<select value={item.dueDateType || "kw"} onChange={e => onUpdate({ dueDateType: e.target.value })}
style={{ height: 28, fontSize: 11, width: 70 }}>
<option value="kw">KW</option>
<option value="datum">Datum</option>
</select>
{(item.dueDateType || "kw") === "kw" ? (
<>
<input type="number" min={1} max={53} value={item.dueKW || ""} onChange={e => onUpdate({ dueKW: e.target.value })}
placeholder="KW" style={{ height: 28, fontSize: 11, width: 60 }} />
<input type="number" min={2024} max={2035} value={item.dueYear || new Date().getFullYear()} onChange={e => onUpdate({ dueYear: +e.target.value })}
style={{ height: 28, fontSize: 11, width: 72 }} />
</>
) : (
<DateInput value={item.dueDate || ""} onChange={e => onUpdate({ dueDate: e.target.value })}
style={{ height: 28, fontSize: 11, width: 140 }} />
)}
<select value={item.status || "offen"} onChange={e => onUpdate({ status: e.target.value })}
style={{ height: 28, fontSize: 11, width: 100,
background: item.status === "erledigt" ? "#e8f5ee" : item.status === "in Arbeit" ? "#fffbe6" : "var(--input-bg)",
color: item.status === "erledigt" ? "#2d6a4f" : item.status === "in Arbeit" ? "#7a6a00" : "#b5621e",
fontWeight: 600 }}>
<option value="offen">Offen</option>
<option value="in Arbeit">In Arbeit</option>
<option value="erledigt">Erledigt</option>
</select>
</div>
)}
</div>
<button onClick={onRemove}
style={{ background: "none", border: "none", color: "var(--text4)", cursor: "pointer", fontSize: 16, padding: 0, marginTop: 6, flexShrink: 0 }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
);
}
export
function ProtokollDetail({ protokoll, data, onBack, onSave, onDelete, setPrintContent, saveAll }) {
const [p, setP] = useState(() => JSON.parse(JSON.stringify(protokoll)));
const isDirty = JSON.stringify(p) !== JSON.stringify(protokoll);
const today = new Date().toISOString().slice(0, 10);
const [showFolge, setShowFolge] = useState(false);
const { askConfirm, ConfirmModalEl } = useConfirm();
const [folgeSelection, setFolgeSelection] = useState(null); // built on open
const save = () => onSave(p);
const setField = (k, v) => setP(prev => {
const updated = { ...prev, [k]: v };
if (k === "projectId" || k === "date" || k === "type") {
const abbr = data.settings.protokollTypeAbbreviations || {};
const proj = data.projects.find(x => x.id === updated.projectId);
const typKuerzel = abbr[updated.type] || "SO";
const groups = (prev.nummer || "").match(/\d+/g) || [];
const seq = (() => {
for (let i = groups.length - 1; i >= 0; i--) {
const n = parseInt(groups[i]);
if (!(groups[i].length === 4 && n >= 2000 && n <= 2099)) return n;
}
return 1;
})();
updated.nummer = applyProtoNumberFormat(data.settings.protokollNumberFormat || "YYYY-TT-NN", {
date: updated.date, projectNumber: proj?.number || "", seq, typKuerzel,
});
}
return updated;
});
// ── Teilnehmer ────────────────────────────────────────────────
const proj = data.projects.find(x => x.id === p.projectId);
const projectContactPersons = proj ? (proj.projectContacts || []).flatMap(pc => {
const firm = (data.persons || []).find(c => c.id === pc.contactId);
if (!firm) return [];
const firmPersons = firm.contacts || [];
if (pc.personIds && pc.personIds.length > 0) {
return firmPersons.filter(fp => pc.personIds.includes(fp.id)).map(fp => ({
id: `pc-${fp.id}`,
name: fp.name,
role: [fp.position, firm.name].filter(Boolean).join(" · "),
source: "extern",
}));
}
// No specific persons selected — show firm entry (if it has no contacts) or all persons
if (firmPersons.length === 0) {
return [{ id: `pcf-${firm.id}`, name: firm.name, role: firm.type || "Beteiligter", source: "extern" }];
}
return firmPersons.map(fp => ({
id: `pc-${fp.id}`,
name: fp.name,
role: [fp.position, firm.name].filter(Boolean).join(" · "),
source: "extern",
}));
}) : [];
const projectMembers = proj?.internalMembers?.length
? (data.employees || []).filter(e => proj.internalMembers.includes(e.id))
: (data.employees || []);
const allPersons = [
...projectMembers.map(e => ({ id: `emp-${e.id}`, name: e.name, role: e.role || "Mitarbeiter", source: "intern" })),
...projectContactPersons,
...(data.persons || []).filter(p => p.isAuftraggeber).flatMap(c => {
if (c.contacts && c.contacts.length > 0) {
return c.contacts.map(ct => ({
id: `cnt-${ct.id}`,
name: ct.name,
role: [ct.position, c.name].filter(Boolean).join(" · "),
source: "extern",
}));
}
return [{ id: `cli-${c.id}`, name: c.name, role: "Auftraggeber", source: "extern" }];
}),
];
const addParticipant = (personId) => {
if (!personId || p.participants.some(x => x.id === personId)) return;
const person = allPersons.find(x => x.id === personId);
if (!person) return;
setP(prev => ({ ...prev, participants: [...prev.participants, { ...person, status: "anwesend" }] }));
};
const addManualParticipant = () => {
const name = prompt("Name der Person:");
if (!name?.trim()) return;
const role = prompt("Funktion / Firma (optional):") || "";
setP(prev => ({ ...prev, participants: [...prev.participants, { id: generateId(), name: name.trim(), role, source: "manuell", status: "anwesend" }] }));
};
const setParticipantStatus = (id, status) =>
setP(prev => ({ ...prev, participants: prev.participants.map(x => x.id === id ? { ...x, status } : x) }));
const removeParticipant = (id) =>
setP(prev => ({ ...prev, participants: prev.participants.filter(x => x.id !== id) }));
// ── Traktanden ────────────────────────────────────────────────
const addTraktandum = () => {
const maxNr = Math.max(0, ...(p.traktanden || []).map(t => parseInt(t.nr) || 0));
setP(prev => ({ ...prev, traktanden: [...(prev.traktanden || []), { id: generateId(), nr: String(maxNr + 1), title: "", items: [] }] }));
};
const setTraktandum = (id, changes) =>
setP(prev => ({ ...prev, traktanden: (prev.traktanden || []).map(t => t.id === id ? { ...t, ...changes } : t) }));
const removeTraktandum = (id) =>
setP(prev => ({ ...prev, traktanden: (prev.traktanden || []).filter(t => t.id !== id) }));
const addItem = (tId, type) => {
const item = type === "aufgabe"
? { id: generateId(), type, text: "", responsible: "", dueDateType: "kw", dueKW: "", dueYear: new Date().getFullYear(), dueDate: "", status: "offen" }
: type === "beschluss"
? { id: generateId(), type, text: "", date: today }
: { id: generateId(), type: "info", text: "" };
setP(prev => ({ ...prev, traktanden: (prev.traktanden || []).map(t => t.id === tId ? { ...t, items: [...(t.items || []), item] } : t) }));
};
const setItem = (tId, iId, changes) =>
setP(prev => ({ ...prev, traktanden: (prev.traktanden || []).map(t => t.id === tId ? { ...t, items: (t.items || []).map(it => it.id === iId ? { ...it, ...changes } : it) } : t) }));
const removeItem = (tId, iId) =>
setP(prev => ({ ...prev, traktanden: (prev.traktanden || []).map(t => t.id === tId ? { ...t, items: (t.items || []).filter(it => it.id !== iId) } : t) }));
const statusConfig = {
anwesend: { label: "Anwesend", color: "#2d6a4f", bg: "#e8f5ee" },
entschuldigt: { label: "Entschuldigt", color: "#b5621e", bg: "#fdf0e8" },
abwesend: { label: "Abwesend", color: "#8a1a1a", bg: "#fdf2f2" },
eingeladen: { label: "Eingeladen", color: "#1a4e8a", bg: "#e8f0fa" },
};
const unaddedPersons = allPersons.filter(x => !p.participants.some(pt => pt.id === x.id));
// ── Drag & Drop ───────────────────────────────────────────────
// Pointer-based drag (reliable in Tauri/WKWebView — HTML5 drag API is not)
const dragItem = React.useRef(null); // { kind: "traktandum"|"item", idx, tId? }
const dragOver = React.useRef(null); // { idx, tId? }
const [dragOverTraktandum, setDragOverTraktandum] = React.useState(null);
const [dragOverItem, setDragOverItem] = React.useState(null); // { tId, idx }
const [draggingTraktandum, setDraggingTraktandum] = React.useState(null);
const [draggingItem, setDraggingItem] = React.useState(null); // { tId, idx }
const commitDrag = () => {
const { kind, idx: from, tId } = dragItem.current || {};
const to = dragOver.current?.idx;
dragItem.current = null;
dragOver.current = null;
setDragOverTraktandum(null);
setDragOverItem(null);
setDraggingTraktandum(null);
setDraggingItem(null);
if (from == null || to == null || from === to) return;
if (kind === "traktandum") {
setP(prev => {
const arr = [...(prev.traktanden || [])];
const [moved] = arr.splice(from, 1);
arr.splice(to, 0, moved);
return { ...prev, traktanden: arr };
});
} else if (kind === "item") {
setP(prev => ({
...prev,
traktanden: (prev.traktanden || []).map(t => {
if (t.id !== tId) return t;
const arr = [...(t.items || [])];
const [moved] = arr.splice(from, 1);
arr.splice(to, 0, moved);
return { ...t, items: arr };
}),
}));
}
};
React.useEffect(() => {
const up = () => { if (dragItem.current) commitDrag(); };
window.addEventListener("mouseup", up);
return () => window.removeEventListener("mouseup", up);
});
const startDragTraktandum = (idx) => {
dragItem.current = { kind: "traktandum", idx };
dragOver.current = { idx };
setDraggingTraktandum(idx);
};
const enterTraktandum = (idx) => {
if (dragItem.current?.kind !== "traktandum") return;
dragOver.current = { idx };
setDragOverTraktandum(idx);
};
const startDragItem = (tId, idx) => {
dragItem.current = { kind: "item", idx, tId };
dragOver.current = { idx };
setDraggingItem({ tId, idx });
};
const enterItem = (tId, idx) => {
if (dragItem.current?.kind !== "item" || dragItem.current?.tId !== tId) return;
dragOver.current = { idx };
setDragOverItem({ tId, idx });
};
return (
<div>
{ConfirmModalEl}
{/* Header */}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
<button onClick={onBack} style={{ background: "none", border: "none", fontSize: 12, color: "#888", cursor: "pointer", padding: 0, fontFamily: "inherit" }}> Protokolle</button>
{isDirty && <span style={{ fontSize: 11, color: "#b5621e" }}> Ungespeichert</span>}
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 24 }}>
<div style={{ flex: 1, marginRight: 20 }}>
<input
value={p.title}
onChange={e => setField("title", e.target.value)}
placeholder="Protokolltitel…"
style={{ fontSize: 28, fontFamily: "'Playfair Display', serif", fontWeight: 400, background: "none", border: "none", borderBottom: "2px solid var(--border)", borderRadius: 0, padding: "4px 0", width: "100%", outline: "none", color: "var(--text)" }}
/>
<div style={{ fontSize: 12, color: "var(--text4)", marginTop: 6 }}>
{p.nummer}
{p.type && <span> · {p.type}</span>}
{(() => { const proj = data.projects.find(x => x.id === p.projectId); return proj?.number ? <span style={{ color: "#b07848", marginLeft: 8, fontWeight: 600 }}>{proj.number}</span> : null; })()}
</div>
</div>
<div style={{ display: "flex", gap: 8, flexShrink: 0 }}>
<button className="btn btn-ghost" onClick={async () => { if (await askConfirm("Protokoll löschen?")) onDelete(p.id); }}>Löschen</button>
<button className="btn btn-ghost" onClick={() => setShowFolge(true)} title="Neue Sitzung auf Basis dieses Protokolls erstellen"> Folgesitzung</button>
<button className="btn btn-ghost" onClick={() => setPrintContent({ type: "protokoll", protokoll: p, data, settings: data.settings })}>PDF</button>
<button className="btn btn-primary" onClick={save} style={isDirty ? { background: "#2d6a4f" } : {}}>
{isDirty ? "Speichern ●" : "Gespeichert"}
</button>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 340px", gap: 20, alignItems: "start" }}>
{/* ── LINKE SPALTE: Metadaten + Traktanden ── */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Kopfdaten */}
<div className="card">
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "var(--text4)", marginBottom: 14 }}>SITZUNGSDETAILS</div>
<div className="form-row">
<FormField label="Typ">
<select value={p.type} onChange={e => setField("type", e.target.value)}>
{PROTOKOLL_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</FormField>
<FormField label="Datum">
<DateInput value={p.date} onChange={e => setField("date", e.target.value)} />
</FormField>
<FormField label="Von">
<input type="time" value={p.time || ""} onChange={e => setField("time", e.target.value)} />
</FormField>
<FormField label="Bis">
<input type="time" value={p.endTime || ""} onChange={e => setField("endTime", e.target.value)} />
</FormField>
</div>
<div className="form-row">
<FormField label="Ort / Raum">
<input value={p.location || ""} onChange={e => setField("location", e.target.value)} placeholder="z.B. Büro Studio, Baustelle…" />
</FormField>
<FormField label="Projekt">
<select value={p.projectId || ""} onChange={e => setField("projectId", e.target.value)}>
<option value=""> kein Projekt </option>
{data.projects.map(x => <option key={x.id} value={x.id}>{x.name}</option>)}
</select>
</FormField>
</div>
{!p.projectId && (
<FormField label="Projekt (manuell)">
<input value={p.projectManual || ""} onChange={e => setField("projectManual", e.target.value)} placeholder="Projektbezeichnung" />
</FormField>
)}
<div className="form-row">
<FormField label="Nächste Sitzung">
<DateInput value={p.nextDate || ""} onChange={e => setField("nextDate", e.target.value)} />
</FormField>
<FormField label="Verteiler">
<input value={p.verteiler || ""} onChange={e => setField("verteiler", e.target.value)} placeholder="z.B. alle TN, Archiv…" />
</FormField>
</div>
</div>
{/* Traktanden draggable */}
{(p.traktanden || []).map((t, ti) => {
const isDragTarget = dragOverTraktandum === ti;
return (
<div
key={t.id}
onMouseEnter={() => enterTraktandum(ti)}
className="card"
style={{
borderLeft: "4px solid #b07848",
outline: isDragTarget ? "2px dashed #b07848" : "none",
outlineOffset: 2,
opacity: draggingTraktandum === ti ? 0.5 : 1,
transition: "outline 0.1s",
}}
>
{/* Traktandum-Header */}
<div style={{ display: "flex", gap: 10, alignItems: "center", marginBottom: 12 }}>
{/* Drag handle */}
<div
title="Verschieben"
style={{ cursor: "grab", color: "var(--text4)", fontSize: 14, flexShrink: 0, userSelect: "none", paddingTop: 2 }}
onMouseDown={e => { e.preventDefault(); startDragTraktandum(ti); }}
></div>
<input value={t.nr} onChange={e => setTraktandum(t.id, { nr: e.target.value })}
style={{ width: 48, height: 32, fontSize: 13, fontWeight: 700, textAlign: "center", background: "#b07848", color: "#1a1a18", border: "none", borderRadius: 4 }} />
<input value={t.title} onChange={e => setTraktandum(t.id, { title: e.target.value })}
placeholder="Traktandentitel…"
style={{ flex: 1, height: 32, fontSize: 14, fontWeight: 500, background: "none", border: "none", borderBottom: "1.5px solid var(--border)", borderRadius: 0, outline: "none", color: "var(--text)" }} />
{(p.traktanden || []).length > 1 && (
<button onClick={() => removeTraktandum(t.id)}
style={{ background: "none", border: "none", color: "var(--text4)", cursor: "pointer", fontSize: 16, padding: 0 }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
)}
</div>
{/* Items */}
{(t.items || []).map((item, ii) => {
const isItemTarget = dragOverItem?.tId === t.id && dragOverItem?.idx === ii;
return (
<div
key={item.id}
onMouseEnter={() => enterItem(t.id, ii)}
style={{
outline: isItemTarget ? "2px dashed #b07848" : "none",
outlineOffset: 1,
borderRadius: 4,
opacity: draggingItem?.tId === t.id && draggingItem?.idx === ii ? 0.4 : 1,
transition: "outline 0.1s",
}}
>
<div style={{ display: "flex", gap: 10, padding: "10px 0", borderBottom: "1px solid var(--border2)", alignItems: "flex-start" }}>
{/* Item drag handle */}
<div
title="Verschieben"
style={{ cursor: "grab", color: "var(--text4)", fontSize: 12, flexShrink: 0, marginTop: 8, userSelect: "none" }}
onMouseDown={e => { e.preventDefault(); startDragItem(t.id, ii); }}
></div>
<ItemEditor
tId={t.id}
item={item}
today={today}
onUpdate={changes => setItem(t.id, item.id, changes)}
onRemove={() => removeItem(t.id, item.id)}
/>
</div>
</div>
);
})}
{/* Item-Buttons */}
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
{[
{ type: "info", icon: "", label: "Info", color: "#1a4e8a" },
{ type: "beschluss", icon: "✓", label: "Beschluss", color: "#2d6a4f" },
{ type: "aufgabe", icon: "→", label: "Aufgabe", color: "#b5621e" },
].map(btn => (
<button key={btn.type} onClick={() => addItem(t.id, btn.type)} style={{
fontSize: 11, padding: "5px 12px", borderRadius: 4,
border: `1.5px solid ${btn.color}20`,
background: `${btn.color}10`,
color: btn.color, cursor: "pointer", fontFamily: "inherit", display: "flex", alignItems: "center", gap: 5,
}}>
{btn.icon} {btn.label}
</button>
))}
</div>
</div>
);
})}
<button className="btn btn-ghost" onClick={addTraktandum} style={{ alignSelf: "flex-start" }}>
+ Traktandum hinzufügen
</button>
</div>
{/* ── RECHTE SPALTE: Teilnehmer + Aufgaben-Zusammenfassung ── */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Teilnehmer */}
<div className="card">
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "var(--text4)", marginBottom: 14 }}>TEILNEHMER ({p.participants.length})</div>
{/* Hinzufügen */}
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
<select defaultValue="" onChange={e => { addParticipant(e.target.value); e.target.value = ""; }}
style={{ flex: 1, height: 32, fontSize: 12 }}>
<option value="">+ Person hinzufügen</option>
{unaddedPersons.length > 0 && <optgroup label="Intern">
{unaddedPersons.filter(x => x.source === "intern").map(x => <option key={x.id} value={x.id}>{x.name}</option>)}
</optgroup>}
{unaddedPersons.filter(x => x.source === "extern").length > 0 && <optgroup label="Kunden">
{unaddedPersons.filter(x => x.source === "extern").map(x => <option key={x.id} value={x.id}>{x.name}</option>)}
</optgroup>}
</select>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "0 10px", whiteSpace: "nowrap", height: 32 }} onClick={addManualParticipant}>+ Manuell</button>
</div>
{/* Teilnehmerliste */}
{p.participants.length === 0 ? (
<div style={{ fontSize: 12, color: "var(--text4)", padding: "8px 0" }}>Noch keine Teilnehmer</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{p.participants.map(tn => {
const sc = statusConfig[tn.status] || statusConfig.anwesend;
return (
<div key={tn.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", borderRadius: 6, background: sc.bg, border: `1px solid ${sc.color}30` }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{tn.name}</div>
{tn.role && <div style={{ fontSize: 10, color: sc.color, opacity: 0.8 }}>{tn.role}</div>}
</div>
<select value={tn.status} onChange={e => setParticipantStatus(tn.id, e.target.value)}
style={{ height: 26, fontSize: 10, fontWeight: 600, color: sc.color, background: "transparent", border: `1px solid ${sc.color}40`, borderRadius: 3, padding: "0 4px", maxWidth: 110 }}>
{Object.entries(statusConfig).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<button onClick={() => removeParticipant(tn.id)}
style={{ background: "none", border: "none", color: "var(--text4)", cursor: "pointer", fontSize: 14, padding: 0, flexShrink: 0 }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
);
})}
</div>
)}
{/* Anwesenheits-Statistik */}
{p.participants.length > 0 && (
<div style={{ marginTop: 12, paddingTop: 10, borderTop: "1px solid var(--border2)", display: "flex", gap: 12, fontSize: 11 }}>
{Object.entries(statusConfig).map(([k, v]) => {
const count = p.participants.filter(x => x.status === k).length;
return count > 0 ? (
<div key={k} style={{ color: v.color, fontWeight: 600 }}>{count} {v.label}</div>
) : null;
})}
</div>
)}
</div>
{/* Aufgaben-Überblick */}
{(() => {
const tasks = (p.traktanden || []).flatMap(t => (t.items || []).filter(it => it.type === "aufgabe"));
if (tasks.length === 0) return null;
const offen = tasks.filter(t => t.status !== "erledigt");
const erledigt = tasks.filter(t => t.status === "erledigt");
return (
<div className="card">
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "var(--text4)", marginBottom: 14 }}>
AUFGABEN ({tasks.length})
</div>
<div style={{ display: "flex", gap: 12, marginBottom: 12, fontSize: 12 }}>
<div style={{ color: "#b5621e", fontWeight: 600 }}>{offen.length} offen</div>
<div style={{ color: "#2d6a4f", fontWeight: 600 }}>{erledigt.length} erledigt</div>
</div>
<div style={{ height: 6, background: "var(--border)", borderRadius: 3, overflow: "hidden", marginBottom: 14 }}>
<div style={{ width: `${tasks.length > 0 ? (erledigt.length / tasks.length) * 100 : 0}%`, height: "100%", background: "#2d6a4f", borderRadius: 3 }} />
</div>
{offen.map(t => {
const due = t.dueDateType === "kw" ? (t.dueKW ? `KW ${t.dueKW}/${t.dueYear || new Date().getFullYear()}` : "—") : t.dueDate ? formatDate(t.dueDate) : "—";
const isLate = t.dueDate && t.dueDateType === "datum" && t.dueDate < today;
return (
<div key={t.id} style={{ padding: "6px 0", borderBottom: "1px solid var(--border2)", fontSize: 12 }}>
<div style={{ fontWeight: 500, color: isLate ? "#8a1a1a" : "var(--text)" }}>{t.text || "—"}</div>
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 2, display: "flex", gap: 8 }}>
{t.responsible && <span> {t.responsible}</span>}
<span style={{ color: isLate ? "#8a1a1a" : "var(--text4)" }}>{due}</span>
</div>
</div>
);
})}
</div>
);
})()}
{/* Beschlüsse-Überblick */}
{(() => {
const beschluesse = (p.traktanden || []).flatMap(t => (t.items || []).filter(it => it.type === "beschluss"));
if (beschluesse.length === 0) return null;
return (
<div className="card">
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "var(--text4)", marginBottom: 14 }}>BESCHLÜSSE ({beschluesse.length})</div>
{beschluesse.map(b => (
<div key={b.id} style={{ padding: "6px 0", borderBottom: "1px solid var(--border2)", fontSize: 12 }}>
<div style={{ fontWeight: 500 }}>{b.text || "—"}</div>
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 2 }}>{b.date ? formatDate(b.date) : "—"}</div>
</div>
))}
</div>
);
})()}
</div>
</div>
{/* ── Folgesitzung-Dialog ── */}
{showFolge && (() => {
// Initialisiere Auswahl beim ersten Öffnen
if (!folgeSelection) {
const sel = (p.traktanden || []).map(t => ({
tId: t.id,
tTitle: t.title,
tNr: t.nr,
include: true,
items: (t.items || []).map(it => ({
id: it.id,
type: it.type,
text: it.text,
// Aufgaben: offen/in Arbeit → automatisch übernommen; erledigt → nicht
include: it.type !== "aufgabe" ? false : (it.status || "offen") !== "erledigt",
isErledigt: it.type === "aufgabe" && (it.status || "offen") === "erledigt",
original: it,
})),
}));
setFolgeSelection(sel);
return null;
}
const toggleTraktandum = (tId) => setFolgeSelection(prev => prev.map(t => t.tId === tId ? { ...t, include: !t.include } : t));
const toggleItem = (tId, iId) => setFolgeSelection(prev => prev.map(t => t.tId === tId ? { ...t, items: t.items.map(it => it.id === iId ? { ...it, include: !it.include } : it) } : t));
const createFolge = () => {
const allProts = data.protocols || [];
const abbr = data.settings.protokollTypeAbbreviations || {};
const typKuerzel = abbr[p.type] || "SO";
const folgeDate = p.nextDate || new Date().toISOString().slice(0, 10);
const proj = data.projects.find(x => x.id === p.projectId);
const seq = nextProtoSeq(allProts);
const newNummer = applyProtoNumberFormat(data.settings.protokollNumberFormat || "YYYY-TT-NN", {
date: folgeDate, projectNumber: proj?.number || "", seq, typKuerzel,
});
const newTraktanden = folgeSelection
.filter(t => t.include)
.map(t => ({
id: generateId(),
nr: t.tNr,
title: t.tTitle,
items: t.items
.filter(it => it.include)
.map(it => ({ ...it.original, id: generateId(), status: it.original.type === "aufgabe" ? (it.original.status === "erledigt" ? "erledigt" : it.original.status) : it.original.status })),
}));
if (newTraktanden.length === 0) newTraktanden.push({ id: generateId(), nr: "1", title: "", items: [] });
const folge = {
id: generateId(),
title: "",
type: p.type,
date: p.nextDate || new Date().toISOString().slice(0, 10),
time: p.time || "10:00",
endTime: p.endTime || "",
location: p.location || "",
projectId: p.projectId,
projectManual: p.projectManual || "",
nummer: newNummer,
participants: (p.participants || []).map(pt => ({ ...pt, status: "eingeladen" })),
traktanden: newTraktanden,
vorgaenger: p.id,
nextDate: "",
verteiler: p.verteiler || "",
createdAt: new Date().toISOString(),
};
const updated = { ...data, protocols: [...allProts, folge] };
// Save and navigate to new protokoll
if (typeof saveAll === "function") saveAll(updated);
setShowFolge(false);
setFolgeSelection(null);
onSave(p); // save current first
setTimeout(() => {
window.__openProtokoll = folge.id;
window.dispatchEvent(new CustomEvent("openProtokoll", { detail: { id: folge.id } }));
}, 100);
};
const typeIcons = { info: "", beschluss: "✅", aufgabe: "📌" };
const typeColors = { info: "#1a4e8a", beschluss: "#2d6a4f", aufgabe: "#b5621e" };
return (
<Modal title="Folgesitzung erstellen" onClose={() => { setShowFolge(false); setFolgeSelection(null); }} onSave={createFolge} saveLabel="Folgesitzung erstellen" wide>
<div style={{ fontSize: 13, color: "var(--text3)", marginBottom: 16 }}>
Wähle welche Traktanden und Punkte übernommen werden sollen.
<span style={{ color: "#2d6a4f", marginLeft: 8, fontSize: 12 }}> Offene Aufgaben sind vorausgewählt</span>
</div>
{folgeSelection.map(t => (
<div key={t.tId} style={{ marginBottom: 12, border: "1px solid var(--border)", borderRadius: 6, overflow: "hidden", opacity: t.include ? 1 : 0.5 }}>
{/* Traktandum-Header */}
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", background: t.include ? "#fffbe6" : "var(--surface2)", borderBottom: t.items.length > 0 ? "1px solid var(--border2)" : "none", cursor: "pointer" }}
onClick={() => toggleTraktandum(t.tId)}>
<input type="checkbox" checked={t.include} onChange={() => toggleTraktandum(t.tId)} onClick={e => e.stopPropagation()} style={{ width: "auto", flexShrink: 0 }} />
<span style={{ fontSize: 12, fontWeight: 700, color: "#b5861e", marginRight: 4 }}>{t.tNr}</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{t.tTitle || <span style={{ color: "var(--text4)", fontStyle: "italic" }}>Kein Titel</span>}</span>
<span style={{ fontSize: 11, color: "var(--text4)", marginLeft: "auto" }}>{t.items.length} Punkte</span>
</div>
{/* Items */}
{t.items.map(it => (
<div key={it.id} style={{
display: "flex", alignItems: "flex-start", gap: 10, padding: "8px 14px 8px 28px",
borderBottom: "1px solid var(--border2)",
background: it.isErledigt ? (it.include ? "#e8f5ee" : "var(--surface2)") : "transparent",
opacity: (!t.include || !it.include) ? 0.45 : 1,
}}>
<input type="checkbox" checked={it.include && t.include} disabled={!t.include}
onChange={() => toggleItem(t.tId, it.id)} style={{ width: "auto", flexShrink: 0, marginTop: 3 }} />
<span style={{ fontSize: 12, marginTop: 1, flexShrink: 0 }}>{typeIcons[it.type]}</span>
<div style={{ flex: 1 }}>
<span style={{ fontSize: 12 }}>{it.text || <span style={{ color: "var(--text4)", fontStyle: "italic" }}>Leer</span>}</span>
{it.isErledigt && <span style={{ fontSize: 10, color: "#2d6a4f", marginLeft: 8, fontWeight: 600 }}> erledigt</span>}
{it.original?.responsible && <span style={{ fontSize: 10, color: "var(--text4)", marginLeft: 8 }}> {it.original.responsible}</span>}
</div>
</div>
))}
</div>
))}
<div style={{ marginTop: 12, padding: "10px 14px", background: "var(--surface2)", borderRadius: 6, fontSize: 12, color: "var(--text4)" }}>
Alle Teilnehmer werden übernommen mit Status «Eingeladen». Datum wird auf «Nächste Sitzung» ({p.nextDate ? formatDate(p.nextDate) : "nicht gesetzt"}) gesetzt.
</div>
</Modal>
);
})()}
</div>
);
}
// ─── LIEFERSCHEINE ──────────────────────────────────────────────────
+980
View File
@@ -0,0 +1,980 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { SIA_PHASES, SIA_PHASE_WEIGHTS, STORAGE_KEY } from "../constants.js";
import { calcSIAHours, calcManualHours, generateId, formatCHF, formatDate, formatHours, roundCHF, applyProjectNumberFormat, migrateLinkedQuotes, deriveQuoteBudget } from "../utils.js";
import { Header, Modal, FormField, StatusBadge, StatusSelect, useConfirm , DateInput } from "../components/UI.jsx";
export default
function Quotes({ data, update, setData, saveAll, modal, setModal, setPrintContent, setView, onSelectProject }) {
const clients = (data.persons || []).filter(p => p.isAuftraggeber);
const roles = data.settings.roles || [];
const defaultRolesForPhase = () => {
const obj = {};
roles.forEach(r => { obj[r.id] = 0; });
return obj;
};
const emptyManualPhases = () => SIA_PHASES.map(p => ({
id: p.id, label: p.label, enabled: ["31","32","33","41","51","52","53"].includes(p.id),
hoursByRole: defaultRolesForPhase(),
}));
const emptySIAPhases = () => SIA_PHASE_WEIGHTS.map(ph => ({
id: ph.id, label: ph.label,
items: ph.items.map(it => ({ ...it, enabled: true, r: 1 })),
}));
const defaultQuoteRoles = () => (data.settings.roles || []).map(r => ({ ...r }));
const emptyForm = {
number: "", clientId: "", projectId: "", projectName: "", date: new Date().toISOString().slice(0,10),
validUntil: "", mode: "sia", notes: "", status: "entwurf", mwst: true,
manualPhases: emptyManualPhases(),
quoteRoles: defaultQuoteRoles(),
sia: { baukosten: 0, schwierigkeit: 1, stundenansatz: data.settings.defaultHourlyRate || 120, phases: emptySIAPhases() },
freeItems: [{ id: generateId(), desc: "", qty: 1, price: 0 }],
};
const [form, setForm] = useState(emptyForm);
const [filter, setFilter] = useState(() => { const cid = window.__navClientId || ""; window.__navClientId = null; return { status: "", search: "", clientId: cid, year: "" }; });
const [groupBy, setGroupBy] = useState("date");
const [sort, setSort] = useState({ col: "date", dir: -1 });
const [compact, setCompact] = useState(true);
const { askConfirm, ConfirmModalEl } = useConfirm();
const toggleSort = (col) => setSort(s => ({ col, dir: s.col === col ? -s.dir : -1 }));
const SortTh = ({ col, children, style }) => (
<th onClick={() => toggleSort(col)} style={{ cursor: "pointer", userSelect: "none", whiteSpace: "nowrap", ...style }}>
{children} <span style={{ color: sort.col === col ? "#b07848" : "#ccc", fontSize: 10 }}>{sort.col === col ? (sort.dir === 1 ? "▲" : "▼") : "⇅"}</span>
</th>
);
const nextNum = () => {
const y = new Date().getFullYear();
const nums = (data.quotes||[]).filter(q => q.number?.startsWith("O"+y+"-")).map(q => parseInt(q.number.split("-")[1]||"0")).filter(Boolean);
return "O"+y+"-"+String((nums.length ? Math.max(...nums)+1 : 1)).padStart(3,"0");
};
const openNew = () => {
const vd = new Date(); vd.setDate(vd.getDate()+60);
setForm({ ...emptyForm, number: nextNum(), validUntil: vd.toISOString().slice(0,10), manualPhases: emptyManualPhases(), quoteRoles: defaultQuoteRoles(), sia: { ...emptyForm.sia, phases: emptySIAPhases() } });
setModal({ type: "quote" });
};
const openEdit = (q) => {
setForm({ ...emptyForm, ...q, manualPhases: q.manualPhases || emptyManualPhases(), quoteRoles: q.quoteRoles || defaultQuoteRoles(), sia: q.sia || emptyForm.sia, freeItems: q.freeItems || [{ id: generateId(), desc: "", qty: 1, price: 0 }] });
setModal({ type: "quote", id: q.id });
};
const del = async (id) => { if (await askConfirm("Offerte löschen?")) update("quotes", (data.quotes||[]).filter(q => q.id !== id)); };
const setStatus = (id, st) => update("quotes", (data.quotes||[]).map(q => q.id === id ? { ...q, status: st } : q));
const createProjectFromQuote = (q) => {
const qRoles = q.quoteRoles || data.settings.roles || [];
const siaH = q.mode === "sia" ? calcSIAHours(q.sia?.baukosten, q.sia?.schwierigkeit, q.sia?.phases || []) : null;
const manH = q.mode === "manual" ? calcManualHours(q.manualPhases || [], qRoles) : null;
const budgetHours = q.mode === "sia" ? Math.round((siaH?.total || 0) * 10) / 10
: q.mode === "manual" ? Math.round((manH?.totalHours || 0) * 10) / 10 : 0;
const enabledPhases = q.mode === "sia"
? (siaH?.phases || []).filter(ph => ph.hours > 0).map(ph => ph.id)
: q.mode === "manual"
? (q.manualPhases || []).filter(ph => ph.enabled).map(ph => ph.id)
: [];
const phasesBudget = q.mode === "sia"
? (siaH?.phases || []).filter(ph => ph.hours > 0).map(ph => ({ id: ph.id, hours: Math.round(ph.hours * 10) / 10 }))
: q.mode === "manual"
? (q.manualPhases || []).filter(ph => ph.enabled).map(ph => ({
id: ph.id, hours: Math.round(qRoles.reduce((s, r) => s + (ph.hoursByRole?.[r.id] || 0), 0) * 10) / 10
}))
: [];
// Projektnummer generieren
const fmt = data.settings.projectNumberFormat || "YYYY/NN";
const currentYear = new Date().getFullYear();
const lastSeq = data.settings.lastProjectYear === currentYear ? (data.settings.lastProjectSeq || 0) : 0;
const nextSeq = lastSeq >= 99 ? 1 : lastSeq + 1;
const projNumber = applyProjectNumberFormat(fmt, nextSeq);
const existingProj = data.projects.find(p => p.id === q.projectId);
const projName = q.projectName || existingProj?.name || ("Projekt " + q.number);
const stundenansatz = q.mode === "sia" ? (q.sia?.stundenansatz || data.settings.defaultHourlyRate)
: q.mode === "manual" ? (qRoles[0]?.rate || data.settings.defaultHourlyRate)
: data.settings.defaultHourlyRate;
const billingType = q.mode === "manual" ? "stundensatz" : "pauschal";
const budgetFromQuote = q.mode === "free"
? (q.freeItems || []).reduce((s, it) => s + it.qty * it.price, 0)
: q.sub || 0;
const newProj = {
id: generateId(),
number: projNumber,
name: projName,
clientId: q.clientId || "",
category: existingProj?.category || "Direktauftrag",
billingType,
hourlyRate: stundenansatz,
budget: billingType === "pauschal" ? budgetFromQuote : 0,
status: "aktiv",
description: "",
startDate: new Date().toISOString().slice(0, 10),
enabledPhases,
budgetHours,
budgetAmount: budgetFromQuote,
phasesBudget,
linkedQuotes: [{ quoteId: q.id, role: "Hauptofferte" }],
createdAt: new Date().toISOString(),
};
const newSettings = { ...data.settings, lastProjectSeq: nextSeq, lastProjectYear: currentYear };
// Offerte mit neuem Projekt verknüpfen
const updatedQuotes = (data.quotes || []).map(x => x.id === q.id ? { ...x, projectId: newProj.id } : x);
saveAll({ ...data, projects: [...data.projects, newProj], quotes: updatedQuotes, settings: newSettings });
if (setView && onSelectProject) { setView("projects"); onSelectProject(newProj.id); }
};
// Berechnungen
const activeRoles = form.quoteRoles || roles;
const siaCalc = form.mode === "sia" ? calcSIAHours(form.sia.baukosten, form.sia.schwierigkeit, form.sia.phases) : null;
const manCalc = form.mode === "manual" ? calcManualHours(form.manualPhases, activeRoles) : null;
const freeSubTotal = form.mode === "free" ? (form.freeItems || []).reduce((s, it) => s + (it.qty * it.price), 0) : 0;
const subTotal = form.mode === "sia" ? (siaCalc?.total || 0) * (form.sia.stundenansatz || 0) : form.mode === "manual" ? (manCalc?.totalAmount || 0) : freeSubTotal;
const totalHours = form.mode === "sia" ? (siaCalc?.total || 0) : form.mode === "manual" ? (manCalc?.totalHours || 0) : 0;
const taxRate = data.settings.mwstRate || 8.1;
const tax = form.mwst ? subTotal * (taxRate / 100) : 0;
const total = roundCHF(subTotal + tax);
const save = () => {
if (!form.clientId) { alert("Bitte einen Kunden auswählen."); return; }
// Projektname oder verknüpftes Projekt
if (!form.projectId && !form.projectName?.trim()) {
alert("Bitte einen Projektnamen eingeben oder ein Projekt verknüpfen."); return;
}
// Mindestens eine Position
if (form.mode === "free") {
const hasItem = (form.freeItems || []).some(it => it.desc?.trim() || it.price > 0);
if (!hasItem) { alert("Bitte mindestens eine Position mit Beschreibung oder Betrag erfassen."); return; }
} else if (form.mode === "manual") {
const hasPhase = (form.manualPhases || []).some(ph => ph.enabled && Object.values(ph.hoursByRole || {}).some(h => h > 0));
if (!hasPhase) { alert("Bitte mindestens eine Phase mit Stunden erfassen."); return; }
} else if (form.mode === "sia") {
if (!form.sia?.baukosten || form.sia.baukosten <= 0) {
alert("Bitte Baukosten eingeben (SIA-Modus)."); return;
}
}
const q = { ...form, sub: subTotal, totalHours, tax, total, id: modal?.id || generateId(), createdAt: modal?.id ? form.createdAt : new Date().toISOString() };
const quotes = modal?.id ? (data.quotes||[]).map(x => x.id === modal.id ? q : x) : [...(data.quotes||[]), q];
update("quotes", quotes);
setModal(null);
};
const [projectModal, setProjectModal] = useState(null); // quote object when open
const [pmMode, setPmMode] = useState("new");
const [pmName, setPmName] = useState("");
const [pmAttachId, setPmAttachId] = useState("");
// Hilfsfunktion: erstellt die eigentliche Rechnung (UNUSED - kept for reference)
const createInvoice_UNUSED = (q, mode, value) => {
const existing = data.invoices.filter(i => i.quoteId === q.id);
const totalInvoiced = existing.reduce((s, i) => s + (i.sub || 0), 0);
const totalQuoteSub = q.sub || 0;
const remaining = Math.max(0, totalQuoteSub - totalInvoiced);
// Rechnungsnummer (format-aware)
const _fmt = data.settings.invoiceNumberFormat || "YYYY-NNN";
const _now = new Date();
const _yyyy = String(_now.getFullYear()); const _yy = _yyyy.slice(2);
const _pat = _fmt.replace(/[-/\^$*+?.()|[\]{}]/g,"\\$&").replace(/YYYY/g,_yyyy).replace(/YY/g,_yy).replace(/N+/,"(\\d+)");
const _rx = new RegExp("^"+_pat+"$");
const _nums = data.invoices.map(i => { const m=(i.number||"").match(_rx); return m?parseInt(m[1]):null; }).filter(n=>n!==null);
const _seq = _nums.length ? Math.max(..._nums)+1 : 1;
const _pad = (_fmt.match(/N+/)||["NNN"])[0].length;
const invNum = _fmt.replace(/YYYY/g,_yyyy).replace(/YY/g,_yy).replace(/N+/,String(_seq).padStart(_pad,"0"));
let items = [];
let invoiceKind = "voll";
if (mode === "voll") {
if (existing.length > 0) {
// Restbetrag als Schlussrechnung
invoiceKind = "schluss";
items = [{
id: generateId(),
desc: `Schlussrechnung gemäss Offerte ${q.number}`,
qty: 1, price: Math.round(remaining * 100) / 100, discount: 0,
}];
} else {
invoiceKind = "voll";
if (q.mode === "sia" && q.sia) {
const c = calcSIAHours(q.sia.baukosten, q.sia.schwierigkeit, q.sia.phases);
items = c.phases.filter(ph => ph.hours > 0).map(ph => ({
id: generateId(), desc: "Phase "+ph.id+" "+ph.label,
qty: Math.round(ph.hours*100)/100, price: q.sia.stundenansatz, discount: 0,
}));
} else if (q.mode === "manual") {
const c = calcManualHours(q.manualPhases, q.quoteRoles || roles);
items = c.phases.filter(ph => ph.totalHours > 0).map(ph => ({
id: generateId(), desc: ph.label,
qty: Math.round(ph.totalHours*100)/100,
price: ph.totalHours > 0 ? Math.round(ph.totalAmount/ph.totalHours*100)/100 : 0, discount: 0,
}));
} else if (q.mode === "free") {
items = (q.freeItems || []).filter(it => it.desc || it.price).map(it => ({
id: generateId(), desc: it.desc, qty: it.qty, price: it.price, discount: 0,
}));
}
}
} else if (mode === "akonto-percent") {
invoiceKind = "akonto";
const akontoBetrag = totalQuoteSub * (value / 100);
items = [{
id: generateId(),
desc: `Akontorechnung gemäss Offerte ${q.number} (${value.toFixed(1)}% des Gesamthonorars)`,
qty: 1, price: Math.round(akontoBetrag * 100) / 100, discount: 0,
}];
} else if (mode === "akonto-amount") {
invoiceKind = "akonto";
const pct = totalQuoteSub > 0 ? (value / totalQuoteSub) * 100 : 0;
items = [{
id: generateId(),
desc: `Akontorechnung gemäss Offerte ${q.number}${pct > 0 ? ` (${pct.toFixed(1)}% des Gesamthonorars)` : ""}`,
qty: 1, price: value, discount: 0,
}];
}
if (items.length === 0) { alert("Keine Positionen — Offerte ist leer oder fehlerhaft."); return; }
const due = new Date(); due.setDate(due.getDate()+30);
const sub = items.reduce((s,it) => s + it.qty*it.price, 0);
const t = q.mwst ? sub*(taxRate/100) : 0;
let notes = `Gemäss Offerte ${q.number}. Zahlbar innert 30 Tagen netto.`;
if (invoiceKind === "schluss" && existing.length > 0) {
const akontoListe = existing.map(i => ` ${i.number}: CHF ${(i.sub||0).toFixed(2)}`).join("\n");
notes = `Schlussrechnung gemäss Offerte ${q.number}.\nBisherige Akontorechnungen:\n${akontoListe}\n\nZahlbar innert 30 Tagen netto.`;
}
const newInv = {
id: generateId(), number: invNum, clientId: q.clientId, projectId: q.projectId, quoteId: q.id,
invoiceKind,
date: new Date().toISOString().slice(0,10), dueDate: due.toISOString().slice(0,10),
items, mwst: q.mwst, notes,
status: "entwurf", discountType: "none", discountValue: 0, discountLabel: "Rabatt",
sub, subAfterDisc: sub, globalDisc: 0, tax: t, total: roundCHF(sub+t), createdAt: new Date().toISOString(),
};
// Beide Updates atomar
setData(prev => {
const updatedInvoices = [...prev.invoices, newInv];
const updatedQuotes = (prev.quotes || []).map(x => x.id === q.id ? {
...x, status: mode === "schluss" ? "angenommen" : x.status,
} : x);
const next = { ...prev, invoices: updatedInvoices, quotes: updatedQuotes };
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
return next;
});
};
const convertToInvoice = (q) => {
setView("invoices");
setModal({ type: "newInvoice", quoteId: q.id });
};
// SIA-Editor Helpers
const toggleSIAItem = (phId, idx) => {
setForm(f => ({ ...f, sia: { ...f.sia, phases: f.sia.phases.map(p => p.id !== phId ? p : { ...p, items: p.items.map((it,i) => i === idx ? { ...it, enabled: !it.enabled } : it) }) } }));
};
const updateSIAItem = (phId, idx, changes) => {
setForm(f => ({ ...f, sia: { ...f.sia, phases: f.sia.phases.map(p => p.id !== phId ? p : { ...p, items: p.items.map((it,i) => i === idx ? { ...it, ...changes } : it) }) } }));
};
const updateManualHours = (phId, roleId, val) => {
setForm(f => ({ ...f, manualPhases: f.manualPhases.map(p => p.id !== phId ? p : { ...p, hoursByRole: { ...p.hoursByRole, [roleId]: Math.max(0,+val||0) } }) }));
};
const toggleManualPhase = (phId, enabled) => {
setForm(f => ({ ...f, manualPhases: f.manualPhases.map(p => p.id !== phId ? p : { ...p, enabled }) }));
};
const filtered = [...(data.quotes||[])].filter(q => {
if (filter.status && q.status !== filter.status) return false;
if (filter.clientId && q.clientId !== filter.clientId) return false;
if (filter.year && !(q.date||"").startsWith(filter.year)) return false;
if (filter.search) {
const s = filter.search.toLowerCase();
const cl = clients.find(c => c.id === q.clientId);
if (![q.number,cl?.name,cl?.company,q.notes,q.projectName].filter(Boolean).join(" ").toLowerCase().includes(s)) return false;
}
return true;
}).sort((a, b) => {
let va, vb;
if (sort.col === "number") { va = a.number || ""; vb = b.number || ""; }
else if (sort.col === "date") { va = a.date || ""; vb = b.date || ""; }
else if (sort.col === "validUntil") { va = a.validUntil || ""; vb = b.validUntil || ""; }
else if (sort.col === "client") { va = clients.find(c => c.id === a.clientId)?.name || ""; vb = clients.find(c => c.id === b.clientId)?.name || ""; }
else if (sort.col === "total") { va = a.total || 0; vb = b.total || 0; }
else if (sort.col === "status") { va = a.status || ""; vb = b.status || ""; }
else { va = a.date || ""; vb = b.date || ""; }
return typeof va === "number" ? (va - vb) * sort.dir : va.localeCompare(vb) * sort.dir;
});
const availableQuoteYears = Array.from(new Set((data.quotes||[]).map(q => (q.date||"").slice(0,4)).filter(Boolean))).sort().reverse();
// Gruppieren
const groupedQuotes = (() => {
if (groupBy === "date") {
const months = {};
filtered.forEach(q => {
const key = (q.date || "").slice(0, 7);
if (!months[key]) months[key] = [];
months[key].push(q);
});
return Object.entries(months).sort((a, b) => b[0].localeCompare(a[0])).map(([key, items]) => ({
key, label: key ? new Date(key + "-01").toLocaleDateString("de-CH", { month: "long", year: "numeric" }) : "Ohne Datum", items,
}));
}
if (groupBy === "client") {
const clients = {};
filtered.forEach(q => {
const cl = clients.find(c => c.id === q.clientId);
const key = q.clientId || "__none__";
const label = cl?.name || "Kein Kunde";
if (!clients[key]) clients[key] = { label, items: [] };
clients[key].items.push(q);
});
return Object.entries(clients).sort((a, b) => a[1].label.localeCompare(b[1].label)).map(([key, val]) => ({ key, label: val.label, items: val.items }));
}
if (groupBy === "status") {
const statuses = {};
filtered.forEach(q => {
const key = q.status || "unbekannt";
if (!statuses[key]) statuses[key] = [];
statuses[key].push(q);
});
return Object.entries(statuses).sort((a, b) => a[0].localeCompare(b[0])).map(([key, items]) => ({ key, label: key.charAt(0).toUpperCase() + key.slice(1), items }));
}
return [{ key: "all", label: "", items: filtered }];
})();
return (
<div>
{ConfirmModalEl}
<Header title="Offerten" action={<button className="btn btn-primary" onClick={openNew}>+ Neue Offerte</button>} />
<div className="filter-bar">
<input className="pill" placeholder="Suche…" value={filter.search} onChange={e => setFilter({...filter, search: e.target.value})} style={{ minWidth: 180 }} />
<select className="pill" value={filter.status} onChange={e => setFilter({...filter, status: e.target.value})}>
<option value="">Alle Status</option>
{["entwurf","gesendet","angenommen","abgelehnt","abgelaufen"].map(s => <option key={s}>{s}</option>)}
</select>
<select className="pill" value={filter.clientId || ""} onChange={e => setFilter({...filter, clientId: e.target.value})}>
<option value="">Alle Kunden</option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<select className="pill" value={filter.year || ""} onChange={e => setFilter({...filter, year: e.target.value})}>
<option value="">Alle Jahre</option>
{availableQuoteYears.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<div style={{ marginLeft: "auto", fontSize: 12, color: "var(--text4)" }}>{filtered.length} Offerte{filtered.length !== 1 ? "n" : ""} · {formatCHF(filtered.reduce((s,q)=>s+(q.total||0),0))}</div>
</div>
<div className="filter-bar">
<span className="filter-label">GRUPPIEREN:</span>
{[{ id: "none", label: "Keine" }, { id: "date", label: "Monat" }, { id: "client", label: "Kunde" }, { id: "status", label: "Status" }].map(g => (
<button key={g.id} className={`pill${groupBy === g.id ? " active" : ""}`} onClick={() => setGroupBy(g.id)}>{g.label}</button>
))}
</div>
<div className="card" style={{ padding: 0 }}>
<table>
<thead><tr><SortTh col="number" style={{ width: 100 }}>Nr.</SortTh><SortTh col="client">Kunde</SortTh><th className={compact ? "hide-compact" : ""}>Modus</th><SortTh col="date" className={compact ? "hide-compact" : ""}>Datum</SortTh><SortTh col="validUntil" className={compact ? "hide-compact" : ""}>Gültig bis</SortTh><SortTh col="total">Honorar</SortTh><SortTh col="status">Status</SortTh><th></th></tr></thead>
<tbody>
{filtered.length === 0 && <tr><td colSpan={8} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>{(data.quotes||[]).length === 0 ? "Noch keine Offerten" : "Keine Treffer"}</td></tr>}
{groupedQuotes.map(group => (
<React.Fragment key={group.key}>
{groupBy !== "none" && group.label && (
<tr>
<td colSpan={8} style={{ background: "#f5f0e8", padding: "6px 12px", fontSize: 10, letterSpacing: "0.1em", color: "#888", fontWeight: 600, textTransform: "uppercase", borderTop: "1px solid #e0dbd4" }}>
{group.label}
<span style={{ float: "right", fontWeight: 400, letterSpacing: 0, textTransform: "none", fontSize: 11, color: "#aaa" }}>
{group.items.length} · {formatCHF(group.items.reduce((s, q) => s + (q.total || 0), 0))}
</span>
</td>
</tr>
)}
{group.items.map(q => {
const cl = clients.find(c => c.id === q.clientId);
const qInvoiced = q.status === "angenommen"
? (data.invoices || []).filter(i => i.quoteId === q.id).reduce((s, i) => s + (i.sub || 0), 0)
: 0;
return (
<tr key={q.id}>
<td><strong>{q.number}</strong>{q.projectName && <div style={{ fontSize: 11, color: "#888", marginTop: 1 }}>{q.projectName}</div>}</td>
<td>{cl?.name || "—"}</td>
<td style={{ fontSize: 11, color: "#888" }}>{q.mode === "sia" ? "SIA 102" : q.mode === "free" ? "Freie Positionen" : "Aufwand"}</td>
<td>{formatDate(q.date)}</td>
<td>{formatDate(q.validUntil)}</td>
<td>
<strong>{formatCHF(q.total)}</strong>
{qInvoiced > 0 && <div style={{ fontSize: 10, color: "#2d6a4f", marginTop: 1 }}>verrechnet {formatCHF(qInvoiced)}</div>}
</td>
<td>
<StatusSelect value={q.status} options={["entwurf","gesendet","angenommen","abgelehnt","abgelaufen"]} onChange={v => setStatus(q.id, v)} />
</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12 }} onClick={() => setPrintContent({ type: "quote", quote: q, client: cl, settings: data.settings })}>PDF</button>
{!q.convertedToInvoiceId && <button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12, borderColor: "#2d6a4f", color: "#2d6a4f" }} onClick={() => convertToInvoice(q)}> Rechnung</button>}
{(q.mode === "sia" || q.mode === "manual" || q.mode === "free") && (() => {
const linkedProj = data.projects.find(p => migrateLinkedQuotes(p).some(lq => lq.quoteId === q.id));
return linkedProj
? <button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12, borderColor: "#1a4e8a", color: "#1a4e8a" }} onClick={() => { const suggested = q.projectName || linkedProj.name || ("Projekt " + q.number); setPmMode("new"); setPmName(suggested); setPmAttachId(linkedProj.id); setProjectModal(q); }}> {linkedProj.number || "Projekt"}</button>
: <button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12, borderColor: "#7a6a00", color: "#7a6a00" }} onClick={() => { const suggested = q.projectName || (data.projects.find(p => p.id === q.projectId)?.name) || ("Projekt " + q.number); setPmMode("new"); setPmName(suggested); setPmAttachId(q.projectId || ""); setProjectModal(q); }}> Projekt</button>;
})()}
<button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12 }} onClick={() => openEdit(q)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => del(q.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
</React.Fragment>
))}
</tbody>
</table>
</div>
{projectModal && (() => {
const q = projectModal;
const qRoles = q.quoteRoles || data.settings.roles || [];
const siaH = q.mode === "sia" ? calcSIAHours(q.sia?.baukosten, q.sia?.schwierigkeit, q.sia?.phases || []) : null;
const manH = q.mode === "manual" ? calcManualHours(q.manualPhases || [], qRoles) : null;
const budgetH = q.mode === "sia" ? Math.round((siaH?.total||0)*10)/10 : q.mode === "manual" ? Math.round((manH?.totalHours||0)*10)/10 : 0;
const stundenansatz = q.mode === "sia" ? (q.sia?.stundenansatz || data.settings.defaultHourlyRate) : data.settings.defaultHourlyRate;
const alreadyLinkedProject = data.projects.find(p => migrateLinkedQuotes(p).some(lq => lq.quoteId === q.id));
// Offerte entkoppeln
const doUnlink = () => {
const updatedProjects = data.projects.map(p => {
const linked = migrateLinkedQuotes(p).filter(lq => lq.quoteId !== q.id);
if (linked.length === migrateLinkedQuotes(p).length) return p;
const derived = deriveQuoteBudget(linked, data.quotes || [], data.settings.roles || []);
return { ...p, linkedQuotes: linked, budgetHours: derived.budgetHours, budgetAmount: derived.budgetAmount, phasesBudget: derived.phasesBudget };
});
const updatedQuotes = (data.quotes || []).map(x => x.id === q.id ? { ...x, projectId: null } : x);
saveAll({ ...data, projects: updatedProjects, quotes: updatedQuotes });
setProjectModal(null);
};
const doCreate = () => {
if (pmMode === "new") {
createProjectFromQuote({ ...q, projectName: pmName });
} else {
if (!pmAttachId) return;
const proj = data.projects.find(p => p.id === pmAttachId);
if (!proj) return;
const existingLinked = migrateLinkedQuotes(proj);
if (existingLinked.some(lq => lq.quoteId === q.id)) { setProjectModal(null); return; }
const newLinked = [...existingLinked, { quoteId: q.id, role: existingLinked.length === 0 ? "Hauptofferte" : "Nachtrag" }];
const derived = deriveQuoteBudget(newLinked, data.quotes || [], data.settings.roles || []);
const updatedProjects = data.projects.map(p => p.id === pmAttachId ? {
...p, linkedQuotes: newLinked, budgetHours: derived.budgetHours,
budgetAmount: derived.budgetAmount, phasesBudget: derived.phasesBudget,
enabledPhases: [...new Set([...(p.enabledPhases || []), ...derived.enabledPhases])],
hourlyRate: p.hourlyRate || stundenansatz,
} : p);
const updatedQuotes = (data.quotes || []).map(x => x.id === q.id ? { ...x, projectId: pmAttachId } : x);
saveAll({ ...data, projects: updatedProjects, quotes: updatedQuotes });
if (setView && onSelectProject) { setView("projects"); onSelectProject(pmAttachId); }
}
setProjectModal(null);
};
// Wenn Offerte bereits verknüpft: Entkoppeln-Dialog zeigen
if (alreadyLinkedProject) {
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && setProjectModal(null)}>
<div className="modal">
<h2 style={{ fontFamily: "'Playfair Display', serif", fontWeight: 400, marginBottom: 16, fontSize: 22 }}>Offerte verknüpft</h2>
<div style={{ marginBottom: 20, padding: 12, background: "#faf8f5", borderRadius: 6, border: "1px solid #ece8e2", fontSize: 12 }}>
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "#888", marginBottom: 6 }}>OFFERTE</div>
<div><strong>{q.number}</strong>{q.projectName ? " · " + q.projectName : ""}</div>
<div style={{ marginTop: 8, fontSize: 10, letterSpacing: "0.08em", color: "#888", marginBottom: 4 }}>VERKNÜPFTES PROJEKT</div>
<div><strong>{alreadyLinkedProject.number ? alreadyLinkedProject.number + " · " : ""}{alreadyLinkedProject.name}</strong></div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 20 }}>
<button className="btn btn-primary" onClick={() => { setView("projects"); onSelectProject(alreadyLinkedProject.id); setProjectModal(null); }}
style={{ padding: "12px 18px", textAlign: "left", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span>Zum Projekt navigieren</span>
<span style={{ opacity: 0.7 }}></span>
</button>
<button className="btn btn-ghost" onClick={doUnlink}
style={{ padding: "12px 18px", textAlign: "left", display: "flex", justifyContent: "space-between", alignItems: "center", borderColor: "#c0a0a0", color: "#8a1a1a" }}>
<span>Offerte entkoppeln</span>
<span style={{ opacity: 0.7, fontSize: 11 }}>Verknüpfung aufheben</span>
</button>
</div>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button className="btn btn-ghost" onClick={() => setProjectModal(null)}>Abbrechen</button>
</div>
</div>
</div>
);
}
return (
<Modal title="Projekt aus Offerte" onClose={() => setProjectModal(null)} onSave={doCreate} saveLabel={pmMode === "new" ? "Neues Projekt erstellen" : "Zu Projekt hinzufügen"}>
<div style={{ marginBottom: 16, padding: 12, background: "#faf8f5", borderRadius: 6, border: "1px solid #ece8e2", fontSize: 12 }}>
<div style={{ display: "flex", gap: 16, marginBottom: 4 }}>
<span><strong>{q.number}</strong> · {q.mode === "sia" ? "SIA 102" : q.mode === "manual" ? "Aufwand" : "Pauschal"}</span>
{budgetH > 0 && <span style={{ color: "#888" }}>{budgetH}h Soll-Budget</span>}
<span style={{ color: "#888" }}>CHF {stundenansatz}/h</span>
<span style={{ fontWeight: 600 }}>{formatCHF(q.total)}</span>
</div>
{q.projectName && <div style={{ color: "#555" }}>Auftragsbezeichnung: <strong>{q.projectName}</strong></div>}
</div>
<div style={{ display: "flex", gap: 0, marginBottom: 16, borderBottom: "1.5px solid #e0dbd4" }}>
{[{ id: "new", label: "Neues Projekt erstellen" }, { id: "attach", label: "Zu bestehendem Projekt" }].map(tab => (
<button key={tab.id} onClick={() => setPmMode(tab.id)} style={{
padding: "9px 18px", background: "transparent", border: "none", fontFamily: "inherit",
borderBottom: pmMode === tab.id ? "2px solid #1a1a18" : "2px solid transparent",
marginBottom: -1.5, color: pmMode === tab.id ? "#1a1a18" : "#888",
fontSize: 12, fontWeight: 500, cursor: "pointer",
}}>{tab.label}</button>
))}
</div>
{pmMode === "new" ? (
<div>
<FormField label="Projektname">
<input value={pmName} onChange={e => setPmName(e.target.value)} autoFocus placeholder="z.B. Umbau EFH Muster" />
</FormField>
<div style={{ fontSize: 11, color: "#888", marginTop: 4 }}>
Neues Projekt wird erstellt mit: Stundenbudget {budgetH}h, Stundensatz CHF {stundenansatz}/h, SIA-Phasen aus der Offerte.
</div>
</div>
) : (
<div>
<FormField label="Projekt auswählen">
<select value={pmAttachId} onChange={e => setPmAttachId(e.target.value)} autoFocus>
<option value=""> Projekt wählen </option>
{data.projects.map(p => (
<option key={p.id} value={p.id}>{p.number ? `${p.number} · ` : ""}{p.name}</option>
))}
</select>
</FormField>
{pmAttachId && (() => {
const proj = data.projects.find(p => p.id === pmAttachId);
const existing = migrateLinkedQuotes(proj);
const alreadyLinked = existing.some(lq => lq.quoteId === q.id);
return (
<div style={{ fontSize: 11, color: alreadyLinked ? "#b5621e" : "#888", marginTop: 4 }}>
{alreadyLinked
? "⚠ Diese Offerte ist bereits mit diesem Projekt verknüpft."
: `Offerte wird als ${existing.length === 0 ? "Hauptofferte" : "Nachtrag"} hinzugefügt. Stundenbudget: ${proj.budgetHours || 0}h + ${budgetH}h = ${(proj.budgetHours || 0) + budgetH}h`}
</div>
);
})()}
</div>
)}
</Modal>
);
})()}
{modal?.type === "quote" && (
<Modal title={modal.id ? "Offerte bearbeiten" : "Neue Offerte"} onClose={() => setModal(null)} onSave={save} wide>
<div className="form-row">
<FormField label="Nr."><input value={form.number} onChange={e => setForm({...form, number: e.target.value})} /></FormField>
<FormField label="Datum"><DateInput value={form.date} onChange={e => setForm({...form, date: e.target.value})} /></FormField>
<FormField label="Gültig bis"><DateInput value={form.validUntil} onChange={e => setForm({...form, validUntil: e.target.value})} /></FormField>
</div>
<div className="form-row">
<FormField label="Kunde *">
<select value={form.clientId} onChange={e => setForm({...form, clientId: e.target.value})} style={!form.clientId ? { borderColor: "#b5621e" } : {}}>
<option value=""> Kunde wählen (Pflichtfeld) </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</FormField>
<FormField label="Auftragsbezeichnung / Projektname">
<input value={form.projectName || ""} onChange={e => setForm({...form, projectName: e.target.value})} placeholder="z.B. Umbau EFH Muster, Neubau MFH…" />
</FormField>
<FormField label="Projekt verknüpfen (optional)">
<select value={form.projectId} onChange={e => setForm({...form, projectId: e.target.value, projectName: e.target.value ? (data.projects.find(p => p.id === e.target.value)?.name || form.projectName) : form.projectName})}>
<option value=""> kein Projekt </option>
{data.projects.map(p => <option key={p.id} value={p.id}>{p.number ? `${p.number} · ` : ""}{p.name}</option>)}
</select>
</FormField>
</div>
{/* Tab-Auswahl */}
<div style={{ display: "flex", gap: 0, marginBottom: 16, borderBottom: "1.5px solid #e0dbd4" }}>
{[{ id: "sia", label: "SIA 102 (Baukosten)" }, { id: "manual", label: "Aufwandschätzung (Stunden)" }, { id: "free", label: "Freie Positionen" }].map(tab => (
<button key={tab.id} onClick={() => setForm({...form, mode: tab.id})} style={{
padding: "10px 20px", background: "transparent", border: "none", fontFamily: "inherit",
borderBottom: form.mode === tab.id ? "2px solid #1a1a18" : "2px solid transparent",
marginBottom: -1.5, color: form.mode === tab.id ? "#1a1a18" : "#888",
fontSize: 12, fontWeight: 500, cursor: "pointer",
}}>{tab.label}</button>
))}
</div>
{/* Freier Modus */}
{form.mode === "free" && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<div style={{ fontSize: 11, color: "#888" }}>Positionen frei definieren</div>
<button className="btn btn-ghost" style={{ padding: "4px 12px", fontSize: 11 }} onClick={() => setForm({...form, freeItems: [...(form.freeItems||[]), { id: generateId(), desc: "", qty: 1, price: 0 }]})}>+ Position</button>
</div>
<div style={{ borderRadius: 6, border: "1px solid #ece8e2", overflow: "hidden" }}>
<table style={{ fontSize: 12 }}>
<thead>
<tr style={{ background: "#f5f0e8" }}>
<th style={{ padding: "8px 10px" }}>Beschreibung</th>
<th style={{ padding: "8px 6px", textAlign: "right", width: 75 }}>Menge</th>
<th style={{ padding: "8px 6px", textAlign: "right", width: 100 }}>Preis CHF</th>
<th style={{ padding: "8px 6px", textAlign: "right", width: 100 }}>Total</th>
<th style={{ width: 36 }}></th>
</tr>
</thead>
<tbody>
{(form.freeItems || []).map((it, idx) => (
<tr key={it.id} style={{ borderTop: "1px solid #ece8e2" }}>
<td style={{ padding: "4px 6px" }}>
<input value={it.desc} onChange={e => setForm({...form, freeItems: form.freeItems.map((x,i) => i===idx ? {...x, desc: e.target.value} : x)})} placeholder="Leistungsbeschreibung" style={{ border: "1px solid #e0dbd4", height: 28, fontSize: 12 }} />
</td>
<td style={{ padding: "4px 4px" }}>
<input type="number" step="0.25" min={0} value={it.qty} onChange={e => setForm({...form, freeItems: form.freeItems.map((x,i) => i===idx ? {...x, qty: +e.target.value} : x)})} style={{ border: "1px solid #e0dbd4", height: 28, fontSize: 12, textAlign: "right" }} />
</td>
<td style={{ padding: "4px 4px" }}>
<input type="number" step="0.05" min={0} value={it.price} onChange={e => setForm({...form, freeItems: form.freeItems.map((x,i) => i===idx ? {...x, price: +e.target.value} : x)})} style={{ border: "1px solid #e0dbd4", height: 28, fontSize: 12, textAlign: "right" }} />
</td>
<td style={{ padding: "4px 6px", textAlign: "right", color: "#888" }}>{formatCHF(it.qty * it.price)}</td>
<td style={{ padding: "4px 4px", textAlign: "center" }}>
<button className="btn btn-danger" style={{ padding: "0 7px", height: 26, fontSize: 11 }} onClick={() => setForm({...form, freeItems: form.freeItems.filter((_,i) => i!==idx)})}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* SIA-Modus */}
{form.mode === "sia" && siaCalc && (
<div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10, marginBottom: 14, padding: 14, background: "#fff8ed", borderRadius: 6, border: "1px solid #f0e4c4" }}>
<FormField label="Baukosten (CHF)">
<input type="number" value={form.sia.baukosten} onChange={e => setForm({...form, sia: {...form.sia, baukosten: +e.target.value}})} style={{ background: "#fff" }} />
</FormField>
<FormField label="Schwierigkeitsgrad n">
<input type="number" step="0.1" value={form.sia.schwierigkeit} onChange={e => setForm({...form, sia: {...form.sia, schwierigkeit: +e.target.value}})} style={{ background: "#fff" }} />
</FormField>
<FormField label="Stundenansatz CHF/h">
<input type="number" value={form.sia.stundenansatz} onChange={e => setForm({...form, sia: {...form.sia, stundenansatz: +e.target.value}})} style={{ background: "#fff" }} />
</FormField>
</div>
<div style={{ display: "flex", gap: 16, marginBottom: 12, padding: "6px 14px", background: "#faf8f5", borderRadius: 6, fontSize: 11, color: "#666" }}>
<div>B = <strong style={{ color: "#1a1a18" }}>{siaCalc.cbrtB?.toFixed(1)}</strong></div>
<div>p = <strong style={{ color: "#1a1a18" }}>{siaCalc.p}</strong></div>
<div style={{ marginLeft: "auto" }}>Total <strong style={{ color: "#1a1a18" }}>{formatHours(Math.round(siaCalc.total * 60))}</strong> · <strong style={{ color: "#1a1a18" }}>{formatCHF(siaCalc.total * (form.sia.stundenansatz||0))}</strong></div>
</div>
<div style={{ marginBottom: 16, borderRadius: 6, border: "1px solid #ece8e2", overflow: "hidden" }}>
<table style={{ fontSize: 12 }}>
<thead>
<tr style={{ background: "#f5f0e8" }}>
<th style={{ padding: "8px", width: 30 }}></th>
<th style={{ padding: "8px" }}>Teilleistung</th>
<th style={{ padding: "8px", textAlign: "right", width: 55 }}>q %</th>
<th style={{ padding: "8px", textAlign: "right", width: 55 }}>r</th>
<th style={{ padding: "8px", textAlign: "right", width: 75 }}>Stunden</th>
<th style={{ padding: "8px", textAlign: "right", width: 95 }}>Honorar</th>
</tr>
</thead>
<tbody>
{siaCalc.phases.map(ph => (
<React.Fragment key={ph.id}>
<tr style={{ background: "#faf8f5" }}>
<td colSpan={4} style={{ padding: "6px 8px", fontSize: 11, fontWeight: 600, color: "#555" }}>PHASE {ph.id} · {ph.label}</td>
<td style={{ padding: "6px 8px", textAlign: "right", fontWeight: 600, fontSize: 11 }}>{formatHours(Math.round(ph.hours*60))}</td>
<td style={{ padding: "6px 8px", textAlign: "right", fontWeight: 600, fontSize: 11 }}>{formatCHF(ph.hours*(form.sia.stundenansatz||0))}</td>
</tr>
{ph.items.map((it, idx) => (
<tr key={idx} style={{ opacity: it.enabled !== false ? 1 : 0.35 }}>
<td style={{ padding: "4px 8px" }}>
<input type="checkbox" checked={it.enabled !== false} onChange={() => toggleSIAItem(ph.id, idx)} style={{ width: "auto" }} />
</td>
<td style={{ padding: "4px 8px" }}>{it.label}</td>
<td style={{ padding: "4px 8px" }}>
<input type="number" step="0.5" value={it.pct} onChange={e => updateSIAItem(ph.id, idx, { pct: +e.target.value })} disabled={it.enabled === false} style={{ width: 48, height: 26, fontSize: 11, textAlign: "right" }} />
</td>
<td style={{ padding: "4px 8px" }}>
<input type="number" step="0.05" value={it.r ?? 1} onChange={e => updateSIAItem(ph.id, idx, { r: +e.target.value })} disabled={it.enabled === false} style={{ width: 48, height: 26, fontSize: 11, textAlign: "right" }} />
</td>
<td style={{ padding: "4px 8px", textAlign: "right", color: "#888" }}>{formatHours(Math.round(it.hours*60))}</td>
<td style={{ padding: "4px 8px", textAlign: "right", color: "#888" }}>{formatCHF(it.hours*(form.sia.stundenansatz||0))}</td>
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Manueller Modus */}
{form.mode === "manual" && manCalc && (
<div>
<div style={{ marginBottom: 14, padding: 12, background: "#fff8ed", borderRadius: 6, border: "1px solid #f0e4c4" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", color: "#888" }}>ROLLEN &amp; STUNDENSÄTZE FÜR DIESE OFFERTE</div>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "3px 10px" }}
title="Rollen aus Einstellungen zurücksetzen"
onClick={() => setForm(f => ({ ...f, quoteRoles: defaultQuoteRoles() }))}>
Reset
</button>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "3px 10px" }}
onClick={() => {
const newId = "R" + (form.quoteRoles.length + 1);
setForm(f => ({ ...f, quoteRoles: [...f.quoteRoles, { id: newId, label: "Neue Rolle", rate: 120 }] }));
}}>
+ Rolle
</button>
</div>
</div>
<table style={{ width: "100%", fontSize: 12, borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "1px solid #e0dbd4" }}>
<th style={{ textAlign: "left", padding: "4px 6px", fontSize: 10, color: "#888", fontWeight: 500, width: 70 }}>KÜRZEL</th>
<th style={{ textAlign: "left", padding: "4px 6px", fontSize: 10, color: "#888", fontWeight: 500 }}>BEZEICHNUNG</th>
<th style={{ textAlign: "right", padding: "4px 6px", fontSize: 10, color: "#888", fontWeight: 500, width: 110 }}>CHF/h</th>
<th style={{ width: 32 }}></th>
</tr>
</thead>
<tbody>
{(form.quoteRoles || []).map((r, idx) => (
<tr key={idx} style={{ borderBottom: "1px solid #f0e8d8" }}>
<td style={{ padding: "3px 6px" }}>
<input
value={r.id}
onChange={e => setForm(f => ({ ...f, quoteRoles: f.quoteRoles.map((x, i) => i === idx ? { ...x, id: e.target.value.toUpperCase().slice(0,4) } : x) }))}
style={{ width: 52, height: 26, fontSize: 12, fontWeight: 600, textAlign: "center", border: "1px solid #e0dbd4", background: "#fff" }}
maxLength={4}
/>
</td>
<td style={{ padding: "3px 6px" }}>
<input
value={r.label}
onChange={e => setForm(f => ({ ...f, quoteRoles: f.quoteRoles.map((x, i) => i === idx ? { ...x, label: e.target.value } : x) }))}
style={{ width: "100%", height: 26, fontSize: 12, border: "1px solid #e0dbd4", background: "#fff" }}
/>
</td>
<td style={{ padding: "3px 6px", textAlign: "right" }}>
<input
type="number"
min={0}
step={5}
value={r.rate}
onChange={e => setForm(f => ({ ...f, quoteRoles: f.quoteRoles.map((x, i) => i === idx ? { ...x, rate: +e.target.value } : x) }))}
style={{ width: 80, height: 26, fontSize: 12, textAlign: "right", border: "1px solid #e0dbd4", background: "#fff" }}
/>
</td>
<td style={{ padding: "3px 4px", textAlign: "center" }}>
<button className="btn btn-danger" style={{ padding: "0 7px", height: 26, fontSize: 11 }}
onClick={() => setForm(f => ({ ...f, quoteRoles: f.quoteRoles.filter((_, i) => i !== idx), manualPhases: f.manualPhases.map(p => { const h = { ...p.hoursByRole }; delete h[r.id]; return { ...p, hoursByRole: h }; }) }))}>
<span className="material-icons" style={{ fontSize: 16 }}>close</span>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ marginBottom: 16, borderRadius: 6, border: "1px solid #ece8e2", overflow: "hidden" }}>
<table style={{ fontSize: 12 }}>
<thead>
<tr style={{ background: "#f5f0e8" }}>
<th style={{ padding: "8px", width: 30 }}></th>
<th style={{ padding: "8px" }}>Phase</th>
{activeRoles.map(r => <th key={r.id} style={{ padding: "8px", textAlign: "right", width: 65 }} title={r.label+" · CHF "+r.rate+"/h"}>{r.id}</th>)}
<th style={{ padding: "8px", textAlign: "right", width: 65 }}>Std</th>
<th style={{ padding: "8px", textAlign: "right", width: 95 }}>Honorar</th>
</tr>
</thead>
<tbody>
{form.manualPhases.map(rawPh => {
const calcPh = manCalc.phases.find(p => p.id === rawPh.id);
return (
<tr key={rawPh.id} style={{ opacity: rawPh.enabled ? 1 : 0.35 }}>
<td style={{ padding: "4px 8px" }}>
<input type="checkbox" checked={rawPh.enabled} onChange={e => toggleManualPhase(rawPh.id, e.target.checked)} style={{ width: "auto" }} />
</td>
<td style={{ padding: "4px 8px", fontSize: 11 }}>{rawPh.label}</td>
{activeRoles.map(r => (
<td key={r.id} style={{ padding: "4px 4px" }}>
<input type="number" min={0} step="0.5" value={rawPh.hoursByRole?.[r.id] || 0}
onChange={e => updateManualHours(rawPh.id, r.id, e.target.value)}
disabled={!rawPh.enabled}
style={{ width: 56, height: 26, fontSize: 11, textAlign: "right", background: rawPh.enabled ? "#fff8ed" : undefined }} />
</td>
))}
<td style={{ padding: "4px 8px", textAlign: "right", color: "#888" }}>{calcPh ? formatHours(Math.round(calcPh.totalHours*60)) : "—"}</td>
<td style={{ padding: "4px 8px", textAlign: "right", color: "#888" }}>{calcPh ? formatCHF(calcPh.totalAmount) : "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div style={{ display: "flex", justifyContent: "flex-end", fontSize: 11, color: "#666", marginBottom: 10 }}>
Total: <strong style={{ marginLeft: 8, color: "#1a1a18" }}>{formatHours(Math.round(manCalc.totalHours*60))} · {formatCHF(manCalc.totalAmount)}</strong>
</div>
</div>
)}
{/* Total */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14, padding: "12px 14px", background: "#faf8f5", borderRadius: 6, border: "1px solid #ece8e2" }}>
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", textTransform: "none", fontSize: 13, color: "#1a1a18" }}>
<input type="checkbox" checked={form.mwst} onChange={e => setForm({...form, mwst: e.target.checked})} style={{ width: "auto" }} />
MWST {taxRate}% ausweisen
</label>
<div style={{ textAlign: "right", fontSize: 12 }}>
<div style={{ color: "#888" }}>Honorar netto {formatCHF(subTotal)}</div>
{form.mwst && <div style={{ color: "#888" }}>MWST {formatCHF(tax)}</div>}
<div style={{ fontSize: 16, fontWeight: 700, marginTop: 3 }}>Total {formatCHF(total)}</div>
</div>
</div>
<FormField label="Bemerkungen">
<textarea rows={3} value={form.notes} onChange={e => setForm({...form, notes: e.target.value})} style={{ resize: "vertical" }} />
</FormField>
</Modal>
)}
</div>
);
}
export
function ConvertQuoteModal({ quote, existingInvoices, taxRate, onClose, onConfirm }) {
const totalSub = quote.sub || 0;
const totalInvoiced = existingInvoices.reduce((s, i) => s + (i.sub || 0), 0);
const remaining = Math.max(0, totalSub - totalInvoiced);
const hasExisting = existingInvoices.length > 0;
const progressPct = totalSub > 0 ? (totalInvoiced / totalSub) * 100 : 0;
const [mode, setMode] = useState("voll");
const [percentValue, setPercentValue] = useState(30);
const [amountValue, setAmountValue] = useState(Math.round(remaining * 100) / 100);
// Vollrechnung = remaining when akonto exists, otherwise full total
const vollAmount = hasExisting ? remaining : totalSub;
let previewAmount = 0;
if (mode === "voll") previewAmount = vollAmount;
else if (mode === "akonto-percent") previewAmount = totalSub * (percentValue / 100);
else if (mode === "akonto-amount") previewAmount = amountValue;
const previewTax = quote.mwst ? previewAmount * (taxRate / 100) : 0;
const previewTotal = roundCHF(previewAmount + previewTax);
const canSubmit = previewAmount > 0;
const handleConfirm = () => {
let value = 0;
if (mode === "akonto-percent") value = percentValue;
else if (mode === "akonto-amount") value = amountValue;
onConfirm(quote, mode, value);
};
const Option = ({ id, title, desc, disabled }) => (
<label
onClick={() => !disabled && setMode(id)}
style={{
display: "block", padding: "12px 14px", marginBottom: 8,
borderRadius: 6, border: mode === id ? "2px solid #1a1a18" : "1.5px solid #ddd8d0",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.4 : 1, background: mode === id ? "#faf8f5" : "#fff",
textTransform: "none",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 16, height: 16, borderRadius: "50%",
border: mode === id ? "5px solid #1a1a18" : "2px solid #aaa",
flexShrink: 0,
}} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: "#1a1a18" }}>{title}</div>
{desc && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{desc}</div>}
</div>
</div>
</label>
);
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 540 }}>
<h2 style={{ fontFamily: "'Playfair Display', serif", fontWeight: 400, marginBottom: 8, fontSize: 22 }}>
Rechnung aus Offerte
</h2>
<div style={{ fontSize: 12, color: "#888", marginBottom: 18 }}>
Offerte {quote.number} · Honorar netto {formatCHF(totalSub)}
</div>
{/* Fortschritts-Anzeige */}
{hasExisting && (
<div style={{ marginBottom: 18, padding: 14, background: "#faf8f5", borderRadius: 6, border: "1px solid #ece8e2" }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "#666", marginBottom: 6 }}>
<span>Bereits verrechnet</span>
<span><strong style={{ color: "#1a1a18" }}>{formatCHF(totalInvoiced)}</strong> · {progressPct.toFixed(0)}%</span>
</div>
<div style={{ height: 6, background: "#ece8e2", borderRadius: 3, overflow: "hidden", marginBottom: 8 }}>
<div style={{ width: `${progressPct}%`, height: "100%", background: "#2d6a4f" }}></div>
</div>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "#666" }}>
<span>Restbetrag</span>
<span><strong style={{ color: "#b5621e" }}>{formatCHF(remaining)}</strong></span>
</div>
<div style={{ marginTop: 10, paddingTop: 10, borderTop: "1px solid #ece8e2", fontSize: 10, color: "#888" }}>
{existingInvoices.map(i => (
<div key={i.id} style={{ display: "flex", justifyContent: "space-between", padding: "2px 0" }}>
<span>{i.number} · {i.invoiceKind === "akonto" ? "Akonto" : i.invoiceKind} · {formatDate(i.date)}</span>
<span>{formatCHF(i.sub)}</span>
</div>
))}
</div>
</div>
)}
<div style={{ marginBottom: 14 }}>
<Option
id="voll"
title={hasExisting ? "Schlussrechnung / Restbetrag" : "Vollrechnung"}
desc={hasExisting
? `Noch offenes Honorar verrechnen: ${formatCHF(remaining)}`
: "Gesamtes Offerthonorar in einer Rechnung verrechnen"}
disabled={hasExisting && remaining <= 0}
/>
<Option
id="akonto-percent"
title="Akontorechnung (Prozent)"
desc={`Teilbetrag als % vom Gesamthonorar (${formatCHF(totalSub)})`}
/>
{mode === "akonto-percent" && (
<div style={{ marginLeft: 26, marginTop: -4, marginBottom: 12, padding: "10px 14px", background: "#fff8ed", borderRadius: 6, border: "1px solid #f0e4c4", display: "flex", alignItems: "center", gap: 10 }}>
<input type="number" min={1} max={100} step="1" value={percentValue} onChange={e => setPercentValue(Math.max(0, Math.min(100, +e.target.value || 0)))} style={{ width: 80, textAlign: "right", background: "#fff" }} />
<span style={{ fontSize: 13, color: "#666" }}>% des Gesamthonorars</span>
</div>
)}
<Option
id="akonto-amount"
title="Akontorechnung (Betrag)"
desc={`Spezifischer CHF-Betrag`}
/>
{mode === "akonto-amount" && (
<div style={{ marginLeft: 26, marginTop: -4, marginBottom: 12, padding: "10px 14px", background: "#fff8ed", borderRadius: 6, border: "1px solid #f0e4c4", display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 13, color: "#666" }}>CHF</span>
<input type="number" min={0} step="50" value={amountValue} onChange={e => setAmountValue(Math.max(0, +e.target.value || 0))} style={{ width: 140, textAlign: "right", background: "#fff" }} />
</div>
)}
</div>
{/* Vorschau */}
<div style={{ padding: "12px 14px", background: "#f5f0e8", borderRadius: 6, marginBottom: 18 }}>
<div style={{ fontSize: 10, letterSpacing: "0.1em", color: "#888", marginBottom: 8 }}>VORSCHAU NEUE RECHNUNG</div>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, color: "#666", padding: "2px 0" }}>
<span>Netto</span><span>{formatCHF(previewAmount)}</span>
</div>
{quote.mwst && (
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, color: "#666", padding: "2px 0" }}>
<span>MWST {taxRate}%</span><span>{formatCHF(previewTax)}</span>
</div>
)}
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, paddingTop: 6, borderTop: "1px solid #d8d0c4", fontSize: 14, fontWeight: 700 }}>
<span>Total</span><span>{formatCHF(previewTotal)}</span>
</div>
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button className="btn btn-ghost" onClick={onClose}>Abbrechen</button>
<button className="btn btn-primary" onClick={handleConfirm} disabled={!canSubmit} style={{ opacity: canSubmit ? 1 : 0.4 }}>Rechnung erstellen</button>
</div>
</div>
</div>
);
}
// ─── CLIENTS ──────────────────────────────────────────────────
+840
View File
@@ -0,0 +1,840 @@
import React, { useState } from "react";
import { STORAGE_KEY, DEFAULT_ABSENZ_TYPES } from "../constants.js";
import { formatIban, isQRIban, applyProjectNumberFormat, applyProtoNumberFormat, generateId, getFeiertageForYear, getAbsenzTypes } from "../utils.js";
import { Header, FormField, Modal, DateInput, useConfirm } from "../components/UI.jsx";
const PERMISSION_GROUPS = [
{ label: "Grundmodule", items: [
{ id: "dashboard", label: "Übersicht" },
{ id: "projects", label: "Projekte" },
{ id: "time", label: "Zeiterfassung" },
{ id: "personen", label: "Kunden & Partner" },
]},
{ label: "Buchhaltung", items: [
{ id: "invoices", label: "Rechnungen" },
{ id: "expenses", label: "Spesen" },
{ id: "internal-expenses", label: "Ausgaben" },
{ id: "loehne", label: "Löhne" },
{ id: "studio-budget", label: "Budget" },
]},
{ label: "Dokumente", items: [
{ id: "quotes", label: "Offerten" },
{ id: "protokolle", label: "Protokolle" },
{ id: "lieferscheine", label: "Lieferscheine" },
{ id: "letters", label: "Briefe" },
]},
{ label: "Administration", items: [
{ id: "mitarbeiter", label: "Mitarbeiter (inkl. Zeiterfassung aller MA)" },
{ id: "settings", label: "Einstellungen / Mein Profil" },
{ id: "dashboard-vorlage", label: "Dashboard-Vorlagen speichern" },
{ id: "pinnwand-schreiben", label: "Pinnwand — Beiträge verfassen" },
]},
];
const TABS = [
{ id: "studio", label: "Studio" },
{ id: "dokumente", label: "Dokumente & Formate" },
{ id: "team", label: "Team & Rollen" },
{ id: "kalender", label: "Feiertage & Absenzen" },
{ id: "system", label: "System" },
{ id: "profil", label: "Mein Profil" },
];
const SETTINGS_TABS = new Set(["studio", "dokumente", "team", "system"]);
function Section({ title, children }) {
return (
<div style={{ marginBottom: 28 }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#aaa", fontWeight: 600, marginBottom: 14, paddingBottom: 8, borderBottom: "1px solid #ece8e2" }}>{title}</div>
{children}
</div>
);
}
function FmtRow({ label, value, onChange, placeholder, preview }) {
return (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, color: "#555", marginBottom: 5 }}>{label}</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input value={value} onChange={e => onChange(e.target.value)} style={{ maxWidth: 160, fontFamily: "monospace" }} placeholder={placeholder} />
{preview && <span style={{ fontSize: 12, color: "#2d6a4f", fontWeight: 500 }}> {preview}</span>}
</div>
</div>
);
}
function PersonalSettings({ data, update, currentUser, uiZoom, setUiZoom, nav = null }) {
const emp = (data.employees || []).find(e => e.id === currentUser.employeeId || e.name === currentUser.displayName);
const userRec = (data.users || []).find(u => u.id === currentUser.id);
const [empForm, setEmpForm] = useState({
street: emp?.street || "",
zip: emp?.zip || "",
city: emp?.city || "",
lohnIban: emp?.lohnIban || "",
});
const [saved, setSaved] = useState(false);
const zoomStep = 0.05;
const saveProfile = () => {
if (emp) {
update("employees", (data.employees || []).map(e => e.id === emp.id ? { ...e, ...empForm } : e));
}
setSaved(true);
setTimeout(() => setSaved(false), 2500);
};
const uploadAvatar = (e) => {
const file = e.target.files?.[0]; if (!file) return;
const reader = new FileReader();
reader.onload = ev => update("users", (data.users || []).map(u => u.id === currentUser.id ? { ...u, avatar: ev.target.result } : u));
reader.readAsDataURL(file);
};
return (
<div>
<Header title="Mein Profil" action={
<button className="btn btn-primary" style={{ background: saved ? "#2d6a4f" : "#2a2a22", transition: "background 0.3s" }} onClick={saveProfile}>
{saved ? "✓ Gespeichert" : "Speichern"}
</button>
} />
{nav}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div className="card">
<Section title="PROFIL">
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 6 }}>
<div style={{ width: 64, height: 64, borderRadius: "50%", background: "#f0ede8", border: "2px solid #ece8e2", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 24, color: "#888", overflow: "hidden", flexShrink: 0 }}>
{userRec?.avatar
? <img src={userRec.avatar} alt="Profil" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: (currentUser.displayName || currentUser.username).charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 500, color: "#1a1a18", marginBottom: 6 }}>{currentUser.displayName || currentUser.username}</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<label className="btn btn-ghost" style={{ padding: "5px 12px", fontSize: 11, cursor: "pointer" }}>
{userRec?.avatar ? "Ersetzen" : "Foto hochladen"}
<input type="file" accept="image/*" style={{ display: "none" }} onChange={uploadAvatar} />
</label>
{userRec?.avatar && (
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 11 }}
onClick={() => update("users", (data.users || []).map(u => u.id === currentUser.id ? { ...u, avatar: null } : u))}>
<span className="material-icons" style={{ fontSize: 16 }}>close</span>
</button>
)}
</div>
</div>
</div>
</Section>
<Section title="MEINE ADRESSE">
<FormField label="Strasse + Nr.">
<input value={empForm.street} onChange={e => setEmpForm(f => ({ ...f, street: e.target.value }))} placeholder="z.B. Musterstrasse 1" />
</FormField>
<div className="form-row">
<FormField label="PLZ"><input value={empForm.zip} onChange={e => setEmpForm(f => ({ ...f, zip: e.target.value }))} style={{ maxWidth: 120 }} /></FormField>
<FormField label="Ort"><input value={empForm.city} onChange={e => setEmpForm(f => ({ ...f, city: e.target.value }))} /></FormField>
</div>
{!emp && (
<div style={{ fontSize: 11, color: "#b5621e", marginTop: 6, padding: "8px 12px", background: "#fdf0e8", borderRadius: 8 }}>
Kein verknüpfter Mitarbeitereintrag gefunden. Wende dich an den Administrator.
</div>
)}
</Section>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div className="card">
<Section title="LOHNKONTO">
<FormField label="IBAN">
<input value={empForm.lohnIban} onChange={e => setEmpForm(f => ({ ...f, lohnIban: formatIban(e.target.value) }))} placeholder="CH00 0000 0000 0000 0000 0" />
</FormField>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 6, lineHeight: 1.6 }}>
Diese Angabe wird für die Lohnüberweisung verwendet und ist nur für den Administrator sichtbar.
</div>
{!emp && (
<div style={{ fontSize: 11, color: "#b5621e", marginTop: 6, padding: "8px 12px", background: "#fdf0e8", borderRadius: 8 }}>
Kein verknüpfter Mitarbeitereintrag gefunden.
</div>
)}
</Section>
</div>
<div className="card">
<Section title="DARSTELLUNG">
<div style={{ fontSize: 12, color: "#555", marginBottom: 12 }}>Skalierung / Zoom</div>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<button onClick={() => setUiZoom(z => Math.max(0.5, Math.round((z - zoomStep) * 100) / 100))} style={{ width: 36, height: 36, border: "1.5px solid #ddd8d0", borderRadius: "50%", background: "none", fontSize: 20, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "inherit" }}></button>
<div style={{ flex: 1, textAlign: "center" }}>
<div style={{ fontSize: 22, fontWeight: 500, fontFamily: "monospace", color: "#1a1a18" }}>{Math.round((uiZoom || 1) * 100)}%</div>
</div>
<button onClick={() => setUiZoom(z => Math.min(1.5, Math.round((z + zoomStep) * 100) / 100))} style={{ width: 36, height: 36, border: "1.5px solid #ddd8d0", borderRadius: "50%", background: "none", fontSize: 20, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "inherit" }}>+</button>
</div>
{(uiZoom || 1) !== 1 && (
<button className="btn btn-ghost" style={{ marginTop: 12, fontSize: 11, width: "100%" }} onClick={() => setUiZoom(1)}>
Auf 100% zurücksetzen
</button>
)}
</Section>
</div>
</div>
</div>
</div>
);
}
export default function Settings({ data, update, currentUser, uiZoom, setUiZoom }) {
const [s, setS] = useState(data.settings);
const [saved, setSaved] = useState(false);
const [initModal, setInitModal] = useState(false);
const [tab, setTab] = useState("studio");
const [editingRoleId, setEditingRoleId] = useState(null);
const [roleForm, setRoleForm] = useState(null);
const [ftForm, setFtForm] = useState({ date: "", label: "", stundenDelta: 0, repeatsYearly: true });
const [absenzTypeForm, setAbsenzTypeForm] = useState({ label: "", color: "#555" });
const [kalModal, setKalModal] = useState(null);
const { askConfirm, ConfirmModalEl } = useConfirm();
const isDirty = JSON.stringify(s) !== JSON.stringify(data.settings);
const isAdmin = !currentUser || currentUser.role === "admin";
const setField = (changes) => { setS(prev => ({ ...prev, ...changes })); setSaved(false); };
const save = () => {
update("settings", s);
setSaved(true);
setTimeout(() => setSaved(false), 2500);
};
const exportData = () => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `studio-backup-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
const importData = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async evt => {
try {
const imported = JSON.parse(evt.target.result);
if (await askConfirm("Aktuelle Daten wirklich überschreiben?", "Überschreiben")) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(imported));
window.location.reload();
}
} catch {
alert("Datei konnte nicht gelesen werden.");
}
};
reader.readAsText(file);
};
// Number format previews
const invFmt = s.invoiceNumberFormat || "YYYY-NNN";
const _now = new Date(); const _yyyy = String(_now.getFullYear()); const _yy = _yyyy.slice(2);
const _invPadLen = (invFmt.match(/N+/) || ["NNN"])[0].length;
const invFmtPreview = invFmt.replace(/YYYY/g, _yyyy).replace(/YY/g, _yy).replace(/N+/, String(1).padStart(_invPadLen, "0"));
const protoFmt = s.protokollNumberFormat || "PP-TT-NN";
const protoAbbr = s.protokollTypeAbbreviations || {};
const protoFmtPreview = applyProtoNumberFormat(protoFmt, {
date: new Date().toISOString().slice(0, 10),
projectNumber: "2026-01",
seq: 1,
typKuerzel: Object.values(protoAbbr)[0] || "BS",
});
// App-Rollen
const appRoles = data.appRoles || [];
const openEditRole = (role) => { setEditingRoleId(role.id); setRoleForm({ ...role, permissions: role.permissions ? [...role.permissions] : null }); };
const saveRole = () => { update("appRoles", appRoles.map(r => r.id === roleForm.id ? { ...roleForm } : r)); setEditingRoleId(null); };
const addRole = () => { const nr = { id: generateId(), name: "Neue Rolle", permissions: ["dashboard", "projects", "time"] }; update("appRoles", [...appRoles, nr]); openEditRole(nr); };
const deleteRole = async (id) => { if (!await askConfirm("Rolle löschen? Benutzer mit dieser Rolle verlieren ihren Zugang.")) return; update("appRoles", appRoles.filter(r => r.id !== id)); };
const togglePerm = (permId) => { if (!roleForm || roleForm.permissions === null) return; const cur = roleForm.permissions || []; setRoleForm({ ...roleForm, permissions: cur.includes(permId) ? cur.filter(p => p !== permId) : [...cur, permId] }); };
// Feiertage & Absenztypen
const feiertage = data.feiertage || [];
const absenzTypes = getAbsenzTypes(data);
const defaultAbsenzIds = new Set(DEFAULT_ABSENZ_TYPES.map(t => t.id));
const saveFt = () => {
const isNew = !ftForm.id;
const ft = { ...ftForm, id: ftForm.id || generateId() };
update("feiertage", isNew ? [...feiertage, ft] : feiertage.map(f => f.id === ft.id ? ft : f));
setKalModal(null);
};
const delFt = (id) => update("feiertage", feiertage.filter(f => f.id !== id));
const saveAbsenzType = () => {
const t = { ...absenzTypeForm, id: absenzTypeForm.id || generateId() };
const custom = data.absenzTypes || [];
const exists = custom.some(x => x.id === t.id);
update("absenzTypes", exists ? custom.map(x => x.id === t.id ? t : x) : [...custom, t]);
setKalModal(null);
};
const delAbsenzType = (id) => {
const inUse = (data.absences || []).some(a => a.type === id);
if (inUse) { alert("Dieser Absenztyp ist bereits vergeben und kann nicht gelöscht werden."); return; }
if (defaultAbsenzIds.has(id)) {
const custom = data.absenzTypes || [];
const exists = custom.some(x => x.id === id);
update("absenzTypes", exists ? custom.map(x => x.id === id ? { ...x, deleted: true } : x) : [...custom, { id, deleted: true }]);
} else {
update("absenzTypes", (data.absenzTypes || []).filter(t => t.id !== id));
}
};
if (!isAdmin) {
return <PersonalSettings data={data} update={update} currentUser={currentUser} uiZoom={uiZoom || 1} setUiZoom={setUiZoom || (() => {})} />;
}
return (
<div>
{ConfirmModalEl}
{tab !== "profil" && (
<div className="filter-bar" style={{ marginBottom: 20 }}>
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)} className={`pill${tab === t.id ? " active" : ""}`}>{t.label}</button>
))}
</div>
)}
{/* ── Profil-Tab ── */}
{tab === "profil" && <PersonalSettings data={data} update={update} currentUser={currentUser} uiZoom={uiZoom || 1} setUiZoom={setUiZoom || (() => {})} nav={
<div className="filter-bar" style={{ marginBottom: 20 }}>
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)} className={`pill${tab === t.id ? " active" : ""}`}>{t.label}</button>
))}
</div>
} />}
{/* ── Settings-Tabs (studio / dokumente / team / system / kalender) ── */}
{tab !== "profil" && <>
{initModal && (
<div className="modal-overlay" onClick={() => setInitModal(false)}>
<div className="modal" style={{ maxWidth: 440 }} onClick={e => e.stopPropagation()}>
<div style={{ fontFamily: "'Playfair Display',serif", fontSize: 20, marginBottom: 6 }}>Neu initialisieren</div>
<p style={{ fontSize: 13, color: "#666", lineHeight: 1.7, marginBottom: 16 }}>
Die App wird auf den Ausgangszustand zurückgesetzt. Alle Projekte, Rechnungen, Mitarbeitende und Einstellungen gehen unwiderruflich verloren.
</p>
<div style={{ padding: "10px 14px", background: "#fdf2f2", border: "1px solid #e0b0b0", borderRadius: 8, fontSize: 12, color: "#8a1a1a", marginBottom: 20, lineHeight: 1.6 }}>
Backup vorher erstellen empfohlen.
</div>
<button className="btn btn-primary" style={{ width: "100%", marginBottom: 10 }} onClick={exportData}> Backup erstellen</button>
<button className="btn btn-danger" style={{ width: "100%", marginBottom: 10 }} onClick={async () => {
setInitModal(false);
if (!await askConfirm("Wirklich initialisieren? Alle Daten gehen verloren.", "Initialisieren")) return;
localStorage.removeItem(STORAGE_KEY);
window.location.reload();
}}>Trotzdem initialisieren</button>
<button className="btn btn-ghost" style={{ width: "100%" }} onClick={() => setInitModal(false)}>Abbrechen</button>
</div>
</div>
)}
{/* Feiertage / Absenztypen Modals */}
{kalModal === "ft" && (
<Modal title={ftForm.id ? "Feiertag bearbeiten" : "Neuer Feiertag"} onClose={() => setKalModal(null)} onSave={saveFt}>
<div className="form-row">
<FormField label="Datum"><DateInput value={ftForm.date || ""} onChange={e => setFtForm({ ...ftForm, date: e.target.value })} autoFocus /></FormField>
<FormField label="Bezeichnung"><input value={ftForm.label || ""} onChange={e => setFtForm({ ...ftForm, label: e.target.value })} placeholder="z.B. Nationalfeiertag" /></FormField>
</div>
<div className="form-row">
<FormField label="Stunden-Delta (0 = ganzer Tag frei)">
<input type="number" step={0.5} value={ftForm.stundenDelta ?? 0} onChange={e => setFtForm({ ...ftForm, stundenDelta: +e.target.value })} />
<div style={{ fontSize: 11, color: "#888", marginTop: 4 }}>z.B. 3.5 für Halbtag, 1 für 1h früher</div>
</FormField>
<FormField label=" ">
<label style={{ display: "flex", alignItems: "center", gap: 8, height: 36, cursor: "pointer", textTransform: "none", fontSize: 13 }}>
<input type="checkbox" checked={!!ftForm.repeatsYearly} onChange={e => setFtForm({ ...ftForm, repeatsYearly: e.target.checked })} style={{ width: "auto" }} />
Jährlich wiederkehrend
</label>
</FormField>
</div>
</Modal>
)}
{kalModal === "absenztype" && (
<Modal title={absenzTypeForm.id ? "Absenztyp bearbeiten" : "Neuer Absenztyp"} onClose={() => setKalModal(null)} onSave={saveAbsenzType}>
<div className="form-row">
<FormField label="Bezeichnung"><input value={absenzTypeForm.label || ""} onChange={e => setAbsenzTypeForm({ ...absenzTypeForm, label: e.target.value })} autoFocus /></FormField>
<FormField label="Farbe">
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input type="color" value={absenzTypeForm.color || "#555"} onChange={e => setAbsenzTypeForm({ ...absenzTypeForm, color: e.target.value })} style={{ width: 44, height: 36, padding: 2, border: "1px solid #e0dbd4", borderRadius: 4 }} />
<input value={absenzTypeForm.color || ""} onChange={e => setAbsenzTypeForm({ ...absenzTypeForm, color: e.target.value })} style={{ flex: 1 }} placeholder="#555" />
</div>
</FormField>
</div>
</Modal>
)}
{/* Stable header — Speichern only for settings tabs */}
<Header title="Einstellungen" action={
SETTINGS_TABS.has(tab)
? <button className="btn btn-primary" style={{ background: saved ? "#2d6a4f" : isDirty ? "#8a6a3a" : "#2a2a22", transition: "background 0.3s" }} onClick={save}>
{saved ? "✓ Gespeichert" : isDirty ? "Speichern *" : "Gespeichert"}
</button>
: null
} />
{/* ── Tab: Studio ── */}
{tab === "studio" && (
<>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
<div className="card">
<Section title="STUDIO & ERSCHEINUNGSBILD">
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: "#555", marginBottom: 6 }}>Logo (SVG / PNG)</div>
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
{s.logo && <img src={s.logo} alt="Logo" style={{ maxHeight: 40, maxWidth: 120, border: "1px solid #ece8e2", borderRadius: 4, padding: 4, background: "#fff" }} />}
<label className="btn btn-ghost" style={{ padding: "6px 12px", fontSize: 11, cursor: "pointer" }}>
{s.logo ? "Ersetzen" : "Hochladen"}
<input type="file" accept="image/svg+xml,image/png,image/jpeg" style={{ display: "none" }} onChange={e => {
const file = e.target.files?.[0]; if (!file) return;
const reader = new FileReader();
reader.onload = ev => setField({ logo: ev.target.result });
reader.readAsDataURL(file);
}} />
</label>
{s.logo && <button className="btn btn-danger" style={{ padding: "6px 10px", fontSize: 11 }} onClick={() => setField({ logo: null })}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 14 }}>
<span style={{ fontSize: 12, color: "#555" }}>Logohöhe auf PDFs</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input type="number" min={20} max={200} step={5} value={s.logoSize || 60} onChange={e => setField({ logoSize: +e.target.value })} style={{ width: 70 }} />
<span style={{ fontSize: 11, color: "#888" }}>px</span>
{(s.logoSize || 60) !== 60 && <button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 10px" }} onClick={() => setField({ logoSize: 60 })}> Standard</button>}
</div>
</div>
<FormField label="Name / Firma"><input value={s.name} onChange={e => setField({ name: e.target.value })} /></FormField>
</Section>
<Section title="ADRESSE">
<FormField label="Strasse + Nr."><input value={s.street || ""} onChange={e => setField({ street: e.target.value })} placeholder="z.B. Bahnhofstrasse 1" /></FormField>
<div className="form-row">
<FormField label="PLZ"><input value={s.zip || ""} onChange={e => setField({ zip: e.target.value })} style={{ maxWidth: 120 }} /></FormField>
<FormField label="Ort"><input value={s.city || ""} onChange={e => setField({ city: e.target.value })} /></FormField>
<FormField label="Land"><input value={s.country || "CH"} onChange={e => setField({ country: e.target.value.toUpperCase() })} maxLength={2} style={{ maxWidth: 80 }} /></FormField>
</div>
<div className="form-row">
<FormField label="E-Mail"><input type="email" value={s.email} onChange={e => setField({ email: e.target.value })} /></FormField>
<FormField label="Telefon"><input value={s.phone} onChange={e => setField({ phone: e.target.value })} /></FormField>
</div>
</Section>
</div>
<div className="card">
<Section title="FINANZEN">
<FormField label="IBAN">
<input value={s.iban} onChange={e => { const f = formatIban(e.target.value); setField({ iban: f, ibanType: isQRIban(f) ? "qr" : "normal" }); }} placeholder="CH00 0000 0000 0000 0000 0" />
<div style={{ fontSize: 11, color: isQRIban(s.iban) ? "#2d6a4f" : "#b5621e", marginTop: 4 }}>
{isQRIban(s.iban) ? "✓ QR-IBAN — strukturierte Referenz verfügbar" : "Normale IBAN — ohne strukturierte Referenz"}
</div>
</FormField>
<FormField label="MWST-Nr."><input value={s.mwst} onChange={e => setField({ mwst: e.target.value })} /></FormField>
<div className="form-row">
<FormField label="MWST-Satz (%)">
<input type="number" step="0.1" value={s.mwstRate} onChange={e => setField({ mwstRate: +e.target.value })} />
</FormField>
<FormField label="Standard-Stundensatz (CHF)">
<input type="number" value={s.defaultHourlyRate} onChange={e => setField({ defaultHourlyRate: +e.target.value })} />
</FormField>
</div>
</Section>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, marginTop: 20 }} className="responsive-grid-2">
<div className="card">
<Section title="SPESENKATEGORIEN">
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 12 }}>Kategorien für Mitarbeiterspesen.</div>
{(s.expenseCategories || []).map((cat, i) => (
<div key={i} style={{ display: "flex", gap: 6, marginBottom: 6, alignItems: "center" }}>
<input value={cat} onChange={e => setField({ expenseCategories: (s.expenseCategories || []).map((c, j) => j === i ? e.target.value : c) })} style={{ flex: 1, fontSize: 12 }} />
<button className="btn btn-danger" style={{ padding: "0 7px", height: 30, fontSize: 11 }} onClick={() => setField({ expenseCategories: (s.expenseCategories || []).filter((_, j) => j !== i) })}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
))}
<button className="btn btn-ghost" style={{ marginTop: 4, fontSize: 11 }} onClick={() => setField({ expenseCategories: [...(s.expenseCategories || []), ""] })}>+ Kategorie</button>
</Section>
</div>
<div className="card">
<Section title="INTERNE AUSGABEN-KATEGORIEN">
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 12 }}>Kategorien für interne Büroausgaben.</div>
{(s.internalExpenseCategories || []).map((cat, i) => (
<div key={i} style={{ display: "flex", gap: 6, marginBottom: 6, alignItems: "center" }}>
<input value={cat} onChange={e => setField({ internalExpenseCategories: (s.internalExpenseCategories || []).map((c, j) => j === i ? e.target.value : c) })} style={{ flex: 1, fontSize: 12 }} />
<button className="btn btn-danger" style={{ padding: "0 7px", height: 30, fontSize: 11 }} onClick={() => setField({ internalExpenseCategories: (s.internalExpenseCategories || []).filter((_, j) => j !== i) })}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
))}
<button className="btn btn-ghost" style={{ marginTop: 4, fontSize: 11 }} onClick={() => setField({ internalExpenseCategories: [...(s.internalExpenseCategories || []), ""] })}>+ Kategorie</button>
</Section>
</div>
</div>
</>
)}
{/* ── Tab: Dokumente & Formate ── */}
{tab === "dokumente" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
<div className="card">
<Section title="NUMMERNFORMATE">
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 14 }}>
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>YYYY</code> = {_yyyy} &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>YY</code> = {_yy} &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>MM</code> = Monat &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>NN</code> = laufende Nr.
</div>
<FmtRow label="Projektnummer" value={s.projectNumberFormat || "YYYY/NN"} onChange={v => setField({ projectNumberFormat: v })} placeholder="YYYY/NN" preview={applyProjectNumberFormat(s.projectNumberFormat || "YYYY/NN", 1)} />
<FmtRow label="Rechnungsnummer" value={invFmt} onChange={v => setField({ invoiceNumberFormat: v })} placeholder="YYYY-NNN" preview={invFmtPreview} />
<FmtRow label="Protokollnummer" value={protoFmt} onChange={v => setField({ protokollNumberFormat: v })} placeholder="PP-TT-NN" preview={protoFmtPreview} />
<div style={{ fontSize: 11, color: "#aaa", marginTop: 8, lineHeight: 1.8 }}>
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>PP</code> = Projektnr. ohne Jahreszahl &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>PPP</code> = Projektnr. komplett &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>TT</code> = Sitzungstyp
</div>
</Section>
<Section title="PDF-DATEINAME">
<div style={{ marginBottom: 8 }}>
<input value={s.pdfNameFormat || "{studio}_{typ}_{nummer}"} onChange={e => setField({ pdfNameFormat: e.target.value })} style={{ width: "100%", fontFamily: "monospace" }} placeholder="{studio}_{typ}_{nummer}" />
</div>
<div style={{ fontSize: 11, color: "#aaa", lineHeight: 1.8 }}>
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{studio}"}</code> &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{typ}"}</code> &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{nummer}"}</code> &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{kunde}"}</code> &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{datum}"}</code>
</div>
</Section>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div className="card">
<Section title="SEITENRÄNDER (MM)">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px 16px" }}>
{[["pageMarginTop", "Oben"], ["pageMarginBottom", "Unten"], ["pageMarginLeft", "Links"], ["pageMarginRight", "Rechts"]].map(([key, label]) => (
<div key={key}>
<div style={{ fontSize: 11, color: "#888", marginBottom: 4 }}>{label}</div>
<input type="number" min={0} max={60} value={s[key] ?? 20} onChange={e => setField({ [key]: +e.target.value })} style={{ width: "100%", fontFamily: "monospace" }} />
</div>
))}
</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 10 }}>Gilt für alle Dokumente ausser QR-Rechnung.</div>
</Section>
</div>
<div className="card">
<Section title="DRUCKOPTIONEN">
<label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer", fontSize: 13, color: "#1a1a18", marginBottom: 10 }}>
<input type="checkbox" checked={!!s.autoPrint} onChange={e => setField({ autoPrint: e.target.checked })} style={{ width: "auto" }} />
Druckdialog automatisch öffnen
</label>
<label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer", fontSize: 13, color: "#1a1a18" }}>
<input type="checkbox" checked={s.qrNewPage !== false} onChange={e => setField({ qrNewPage: e.target.checked })} style={{ width: "auto" }} />
QR-Rechnung auf separater Seite
</label>
</Section>
</div>
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14, paddingBottom: 8, borderBottom: "1px solid #ece8e2" }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#aaa", fontWeight: 600 }}>PROTOKOLL-TYPKÜRZEL</div>
<button className="btn btn-ghost" style={{ padding: "3px 10px", fontSize: 11 }} onClick={() => setField({ protokollTypeAbbreviations: { ...protoAbbr, "Neuer Typ": "NT" } })}>+ Typ</button>
</div>
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 12 }}>
Kürzel für Platzhalter <code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>TT</code> in der Protokollnummer.
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{Object.entries(protoAbbr).map(([typName, kuerzel]) => (
<div key={typName} style={{ display: "flex", gap: 6, alignItems: "center" }}>
<input defaultValue={typName} onBlur={e => {
const newName = e.target.value.trim();
if (!newName || newName === typName) { e.target.value = typName; return; }
const newAbbr = Object.fromEntries(Object.entries(protoAbbr).map(([k, v]) => [k === typName ? newName : k, v]));
setField({ protokollTypeAbbreviations: newAbbr });
}} placeholder="Sitzungstyp" style={{ flex: 1, fontSize: 12 }} />
<input value={kuerzel} onChange={e => setField({ protokollTypeAbbreviations: { ...protoAbbr, [typName]: e.target.value.toUpperCase().slice(0, 4) } })}
placeholder="BS" style={{ width: 52, textAlign: "center", fontWeight: 600, fontSize: 12, fontFamily: "monospace" }} maxLength={4} />
<button className="btn btn-danger" style={{ padding: "0 7px", height: 30, fontSize: 11 }} onClick={() => setField({ protokollTypeAbbreviations: Object.fromEntries(Object.entries(protoAbbr).filter(([k]) => k !== typName)) })}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* ── Tab: Team & Rollen ── */}
{tab === "team" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div className="card">
<Section title="MITARBEITER-STANDARDS">
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 14 }}>Vorausgefüllt bei neuen Mitarbeitern.</div>
<FormField label="Wochenstunden (100%)">
<input type="number" min={1} max={50} step={0.5} value={s.defaultWochenstunden || 35} onChange={e => setField({ defaultWochenstunden: +e.target.value })} />
</FormField>
<FormField label="Ferienwochen / Jahr">
<input type="number" min={4} max={10} step={0.5} value={s.defaultFerienWochen || 5} onChange={e => setField({ defaultFerienWochen: +e.target.value })} />
<div style={{ fontSize: 11, color: "#aaa", marginTop: 3 }}>Minimum gesetzlich: 4 Wochen</div>
</FormField>
<FormField label="PK / BVG AG-Anteil %">
<input type="number" min={0} step={0.1} value={s.defaultPkAGSatz ?? 8.0} onChange={e => setField({ defaultPkAGSatz: +e.target.value })} />
</FormField>
</Section>
</div>
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14, paddingBottom: 8, borderBottom: "1px solid #ece8e2" }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#aaa", fontWeight: 600 }}>ROLLEN FÜR OFFERTEN</div>
<button className="btn btn-ghost" style={{ padding: "3px 10px", fontSize: 11 }} onClick={() => setField({ roles: [...(s.roles || []), { id: "", label: "", rate: s.defaultHourlyRate || 120 }] })}>+ Rolle</button>
</div>
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 12 }}>Kürzel, Bezeichnung und Stundensatz für Aufwandschätzungen in Offerten.</div>
{(s.roles || []).map((r, idx) => (
<div key={idx} style={{ display: "flex", gap: 6, marginBottom: 8, alignItems: "center" }}>
<input value={r.id} onChange={e => setField({ roles: s.roles.map((x, i) => i === idx ? { ...x, id: e.target.value.toUpperCase().slice(0, 3) } : x) })} placeholder="PL" style={{ width: 46, textAlign: "center", fontSize: 11 }} />
<input value={r.label} onChange={e => setField({ roles: s.roles.map((x, i) => i === idx ? { ...x, label: e.target.value } : x) })} placeholder="Bezeichnung" style={{ flex: 1, fontSize: 12 }} />
<input type="number" value={r.rate} onChange={e => setField({ roles: s.roles.map((x, i) => i === idx ? { ...x, rate: +e.target.value } : x) })} style={{ width: 72, textAlign: "right", fontSize: 12 }} />
<span style={{ fontSize: 11, color: "#888" }}>CHF/h</span>
<button className="btn btn-danger" style={{ padding: "0 7px", height: 30, fontSize: 11 }} onClick={() => setField({ roles: s.roles.filter((_, i) => i !== idx) })}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
))}
</div>
</div>
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8, paddingBottom: 8, borderBottom: "1px solid #ece8e2" }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#aaa", fontWeight: 600 }}>APP-ROLLEN & BERECHTIGUNGEN</div>
<button className="btn btn-ghost" style={{ padding: "3px 10px", fontSize: 11 }} onClick={addRole}>+ Rolle</button>
</div>
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 16 }}>
Rollen bestimmen, welche Bereiche Mitarbeitende sehen können.
</div>
{appRoles.map(role => (
<div key={role.id} style={{ marginBottom: 10, border: "1.5px solid #ece8e2", borderRadius: 8, overflow: "hidden" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 14px", background: "#faf8f5" }}>
<div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{role.name}</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 2 }}>
{role.permissions === null ? "Voller Zugriff" : `${(role.permissions || []).length} Bereiche`}
{" · "}{(data.users || []).filter(u => u.appRoleId === role.id).length} Benutzer
</div>
</div>
<div style={{ display: "flex", gap: 6 }}>
{editingRoleId === role.id
? <><button className="btn btn-primary" style={{ padding: "4px 12px", fontSize: 11 }} onClick={saveRole}>Speichern</button>
<button className="btn btn-ghost" style={{ padding: "4px 10px", fontSize: 11 }} onClick={() => setEditingRoleId(null)}>Abbrechen</button></>
: <button className="btn btn-ghost" style={{ padding: "4px 10px", fontSize: 11 }} onClick={() => openEditRole(role)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
}
{role.id !== "r-admin" && <button className="btn btn-danger" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => deleteRole(role.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>}
</div>
</div>
{editingRoleId === role.id && roleForm && (
<div style={{ padding: "14px 14px 10px", borderTop: "1px solid #ece8e2" }}>
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 11, color: "#888", display: "block", marginBottom: 5 }}>Rollenname</label>
<input value={roleForm.name} onChange={e => setRoleForm({ ...roleForm, name: e.target.value })} style={{ fontSize: 13, width: "100%", maxWidth: 300 }} />
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 12, marginBottom: 10 }}>
<input type="checkbox" checked={roleForm.permissions === null}
onChange={e => setRoleForm({ ...roleForm, permissions: e.target.checked ? null : ["dashboard"] })}
style={{ width: "auto" }} />
Voller Zugriff (Administrator)
</label>
{roleForm.permissions !== null && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: "10px 20px" }}>
{PERMISSION_GROUPS.map(group => (
<div key={group.label}>
<div style={{ fontSize: 10, letterSpacing: "0.1em", color: "#aaa", marginBottom: 6, fontWeight: 600 }}>{group.label.toUpperCase()}</div>
{group.items.map(perm => (
<label key={perm.id} style={{ display: "flex", alignItems: "center", gap: 7, cursor: "pointer", fontSize: 12, marginBottom: 5 }}>
<input type="checkbox" checked={(roleForm.permissions || []).includes(perm.id)} onChange={() => togglePerm(perm.id)} style={{ width: "auto" }} />
{perm.label}
</label>
))}
</div>
))}
</div>
)}
</div>
<div style={{ paddingTop: 10, borderTop: "1px solid #ece8e2" }}>
<label style={{ fontSize: 11, color: "#888", display: "block", marginBottom: 6 }}>Dashboard-Vorlage</label>
<select value={roleForm.dashboardTemplateId || ""} onChange={e => setRoleForm({ ...roleForm, dashboardTemplateId: e.target.value || null })} style={{ width: "100%", maxWidth: 300 }}>
<option value=""> keine </option>
{(data.dashboardTemplates || []).filter(t => t.isPublic).map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* ── Tab: Feiertage & Absenzen ── */}
{tab === "kalender" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14, paddingBottom: 8, borderBottom: "1px solid #ece8e2" }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#aaa", fontWeight: 600 }}>FEIERTAGE</div>
<button className="btn btn-ghost" style={{ padding: "3px 10px", fontSize: 11 }} onClick={() => { setFtForm({ date: "", label: "", stundenDelta: 0, repeatsYearly: true }); setKalModal("ft"); }}>+ Feiertag</button>
</div>
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 14, lineHeight: 1.6 }}>
Gelten für alle Mitarbeiter. Jährlich wiederkehrende werden automatisch übertragen. Mit «Stunden-Delta» lassen sich Halbfeiertage definieren (z.B. 3.5h für den 24.12.).
</div>
<div className="card" style={{ padding: 0, marginBottom: feiertage.length === 0 ? 12 : 0 }}>
<table>
<thead><tr><th>Datum</th><th>Bezeichnung</th><th style={{ textAlign: "right" }}>Delta</th><th></th></tr></thead>
<tbody>
{feiertage.length === 0 && <tr><td colSpan={4} style={{ textAlign: "center", color: "#aaa", padding: 24 }}>Noch keine Feiertage erfasst</td></tr>}
{[...feiertage].sort((a, b) => (a.date || "").slice(5).localeCompare((b.date || "").slice(5))).map(f => (
<tr key={f.id}>
<td style={{ fontSize: 12 }}>{f.repeatsYearly ? f.date.slice(5).replace("-", ".") + " ↻" : f.date ? new Date(f.date).toLocaleDateString("de-CH") : "—"}</td>
<td style={{ fontWeight: 500, fontSize: 12 }}>{f.label}</td>
<td style={{ textAlign: "right", fontSize: 12, color: f.stundenDelta < 0 ? "#b5621e" : "#aaa" }}>{f.stundenDelta === 0 || f.stundenDelta === null ? "ganztag" : `${f.stundenDelta}h`}</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn btn-ghost" style={{ padding: "3px 8px", fontSize: 11, marginRight: 4 }} onClick={() => { setFtForm({ ...f }); setKalModal("ft"); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => delFt(f.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{feiertage.length === 0 && (
<button className="btn btn-ghost" style={{ fontSize: 11, width: "100%" }} onClick={() => {
const easterSunday = (year) => {
const a = year%19, b = Math.floor(year/100), c = year%100;
const d = Math.floor(b/4), e = b%4, f = Math.floor((b+8)/25);
const g = Math.floor((b-f+1)/3), h = (19*a+b-d-g+15)%30;
const i = Math.floor(c/4), k = c%4;
const l = (32+2*e+2*i-h-k)%7;
const m = Math.floor((a+11*h+22*l)/451);
const month = Math.floor((h+l-7*m+114)/31);
const day = ((h+l-7*m+114)%31)+1;
return new Date(year, month-1, day);
};
const addDays = (date, days) => { const d = new Date(date); d.setDate(d.getDate()+days); return d; };
const fmt = (date) => `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`;
const yr = new Date().getFullYear();
const fixe = [
{ label: "Neujahr", date: "2000-01-01", stundenDelta: 0, repeatsYearly: true },
{ label: "Berchtoldstag", date: "2000-01-02", stundenDelta: 0, repeatsYearly: true },
{ label: "Tag der Arbeit", date: "2000-05-01", stundenDelta: 0, repeatsYearly: true },
{ label: "Nationalfeiertag", date: "2000-08-01", stundenDelta: 0, repeatsYearly: true },
{ label: "Weihnachten", date: "2000-12-25", stundenDelta: 0, repeatsYearly: true },
{ label: "Stephanstag", date: "2000-12-26", stundenDelta: 0, repeatsYearly: true },
{ label: "Heiligabend (Halbtag)", date: "2000-12-24", stundenDelta: -3.5, repeatsYearly: true },
{ label: "Silvester (Halbtag)", date: "2000-12-31", stundenDelta: -3.5, repeatsYearly: true },
].map(f => ({ ...f, id: generateId() }));
const beweglich = [];
for (let y = yr; y <= yr+3; y++) {
const ostern = easterSunday(y);
[
{ label: "Karfreitag", offset: -2 },
{ label: "Ostermontag", offset: 1 },
{ label: "Auffahrt", offset: 39 },
{ label: "Auffahrt Vortag (Halbtag)", offset: 38, stundenDelta: -3.5 },
{ label: "Pfingstmontag", offset: 50 },
].forEach(({ label, offset, stundenDelta = 0 }) => {
beweglich.push({ id: generateId(), label, date: fmt(addDays(ostern, offset)), stundenDelta, repeatsYearly: false });
});
}
update("feiertage", [...feiertage, ...fixe, ...beweglich]);
}}> CH-Vorlagen laden (inkl. Ostern, Auffahrt, Pfingsten)</button>
)}
</div>
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14, paddingBottom: 8, borderBottom: "1px solid #ece8e2" }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#aaa", fontWeight: 600 }}>ABSENZTYPEN</div>
<button className="btn btn-ghost" style={{ padding: "3px 10px", fontSize: 11 }} onClick={() => { setAbsenzTypeForm({ label: "", color: "#555" }); setKalModal("absenztype"); }}>+ Typ</button>
</div>
<table>
<thead><tr><th>Bezeichnung</th><th>Farbe</th><th>Typ</th><th></th></tr></thead>
<tbody>
{absenzTypes.map(t => {
const inUse = (data.absences || []).some(a => a.type === t.id);
const isDefault = defaultAbsenzIds.has(t.id);
const isOverridden = isDefault && (data.absenzTypes || []).some(x => x.id === t.id);
return (
<tr key={t.id}>
<td><span className="tag" style={{ background: t.color, fontSize: 10 }}>{t.label}</span></td>
<td style={{ color: "#888", fontSize: 12 }}>{t.color}</td>
<td style={{ fontSize: 11, color: isDefault ? (isOverridden ? "#b07848" : "#aaa") : "#2d6a4f" }}>
{isDefault ? (isOverridden ? "Angepasst" : "Standard") : "Eigener"}
</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn btn-ghost" style={{ padding: "3px 8px", fontSize: 11, marginRight: 4 }} onClick={() => { setAbsenzTypeForm(t); setKalModal("absenztype"); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "3px 8px", fontSize: 11, opacity: inUse ? 0.35 : 1 }} onClick={() => delAbsenzType(t.id)} title={inUse ? "In Verwendung" : "Löschen"}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* ── Tab: System ── */}
{tab === "system" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
<div className="card">
<Section title="DATEN & BACKUP">
<p style={{ fontSize: 13, color: "#666", lineHeight: 1.7, marginBottom: 16 }}>
Alle Daten liegen ausschliesslich im Browser (localStorage). Regelmässige Backups sind empfohlen.
</p>
<button className="btn btn-primary" style={{ width: "100%", marginBottom: 10 }} onClick={exportData}> Backup als JSON herunterladen</button>
<label className="btn btn-ghost" style={{ width: "100%", textAlign: "center", display: "block", marginBottom: 10 }}>
Backup importieren
<input type="file" accept=".json" onChange={importData} style={{ display: "none" }} />
</label>
<button className="btn btn-danger" style={{ width: "100%" }} onClick={() => setInitModal(true)}>
Neu initialisieren
</button>
</Section>
<Section title="DATENBANKÜBERSICHT">
{[
{ label: "Personen", value: (data.persons || []).length },
{ label: "Projekte", value: data.projects.length },
{ label: "Zeiteinträge", value: data.timeEntries.length },
{ label: "Rechnungen", value: data.invoices.length },
{ label: "Offerten", value: (data.quotes || []).length },
{ label: "Spesen", value: (data.expenses || []).length },
{ label: "Interne Ausgaben", value: (data.internalExpenses || []).length },
].map(r => (
<div key={r.label} style={{ display: "flex", justifyContent: "space-between", padding: "4px 0", fontSize: 12, borderBottom: "1px solid #f0ede8" }}>
<span style={{ color: "#888" }}>{r.label}</span>
<strong style={{ color: "#555" }}>{r.value}</strong>
</div>
))}
</Section>
</div>
<div className="card">
<Section title="ZEITERFASSUNG">
<label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer", fontSize: 13, color: "#1a1a18" }}>
<input type="checkbox" checked={s.blockMaiTag !== false} onChange={e => setField({ blockMaiTag: e.target.checked })} style={{ width: "auto" }} />
Tag der Arbeit (1. Mai) in Wochenansicht sperren
</label>
</Section>
</div>
</div>
)}
</>}
</div>
);
}
+416
View File
@@ -0,0 +1,416 @@
import React, { useState, useRef } from "react";
import { defaultData } from "../constants.js";
import { generateId } from "../utils.js";
// ─── Palette — matches the app's light theme exactly ─────────────────────────
const C = {
bg: "#ebe7e1",
surface: "#fdfcfa",
surface2: "#f7f4f0",
border: "#ddd8d0",
border2: "#e6e1da",
text: "#1a1a18",
text3: "#6a6660",
text4: "#8c8880",
danger: "#8a1a1a",
dangerBg: "#fdf2f2",
dangerBorder: "#e0b0b0",
};
const S = {
wrap: {
background: C.bg,
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "'DM Mono','Courier New',monospace",
color: C.text,
padding: "32px 16px",
},
card: {
width: "100%",
maxWidth: 460,
background: C.surface,
borderRadius: 14,
padding: "44px 40px",
boxShadow: "0 2px 24px rgba(60,50,40,0.10)",
border: `1px solid ${C.border}`,
},
logo: {
fontFamily: "Krungthep,'Archivo Black',sans-serif",
fontSize: 34,
color: C.text,
letterSpacing: "-0.02em",
textAlign: "center",
marginBottom: 4,
},
sub: {
textAlign: "center",
fontSize: 10,
color: C.text4,
letterSpacing: "0.14em",
marginBottom: 32,
},
progress: {
display: "flex",
gap: 6,
justifyContent: "center",
marginBottom: 32,
},
dot: (active, done) => ({
width: active ? 22 : 8,
height: 8,
borderRadius: 4,
background: done ? C.text : active ? C.text : C.border,
opacity: done ? 1 : active ? 1 : 0.4,
transition: "all 0.3s",
}),
label: {
fontSize: 9,
letterSpacing: "0.14em",
color: C.text4,
display: "block",
marginBottom: 5,
marginTop: 16,
},
input: {
width: "100%",
boxSizing: "border-box",
background: C.surface,
border: `1px solid ${C.border}`,
borderRadius: 6,
padding: "9px 12px",
color: C.text,
fontFamily: "'DM Mono',monospace",
fontSize: 13,
outline: "none",
transition: "border-color 0.15s",
},
inputErr: { border: `1px solid ${C.dangerBorder}` },
textarea: {
width: "100%",
boxSizing: "border-box",
background: C.surface,
border: `1px solid ${C.border}`,
borderRadius: 6,
padding: "9px 12px",
color: C.text,
fontFamily: "'DM Mono',monospace",
fontSize: 13,
outline: "none",
resize: "vertical",
minHeight: 64,
},
err: { fontSize: 11, color: C.danger, marginTop: 4 },
hint: { fontSize: 11, color: C.text4, marginTop: 5, lineHeight: 1.5 },
btnPrimary: {
width: "100%",
background: C.text,
border: "none",
borderRadius: 8,
padding: "12px 0",
color: "#fff",
fontFamily: "'DM Mono',monospace",
fontSize: 13,
cursor: "pointer",
marginTop: 28,
letterSpacing: "0.04em",
transition: "opacity 0.15s",
},
btnGhost: {
width: "100%",
background: "transparent",
border: `1px solid ${C.border}`,
borderRadius: 8,
padding: "10px 0",
color: C.text4,
fontFamily: "'DM Mono',monospace",
fontSize: 12,
cursor: "pointer",
marginTop: 10,
},
};
// ─── Prompt for users with existing data ─────────────────────────────────────
export function SetupPrompt({ onSetup, onSkip }) {
return (
<div style={S.wrap}>
<div style={S.card}>
<div style={S.logo}>RAPPORT</div>
<div style={S.sub}>NEUE VERSION</div>
<div style={{ fontFamily: "'Playfair Display',serif", fontSize: 20, color: C.text, marginBottom: 10 }}>
Neuer Einrichtungsassistent.
</div>
<div style={{ fontSize: 12, color: C.text3, lineHeight: 1.7, marginBottom: 24 }}>
Rapport verfügt über einen neuen Setup-Assistenten für Studio, Admin-Account und Mitarbeiterprofil.
Möchtest du ihn durchlaufen?
</div>
<div style={{ background: C.dangerBg, border: `1px solid ${C.dangerBorder}`, borderRadius: 8, padding: "10px 14px", fontSize: 11, color: C.danger, marginBottom: 4 }}>
Neu einrichten löscht alle bestehenden Daten unwiderruflich.
</div>
<button onClick={onSetup} style={{ ...S.btnPrimary, marginTop: 16 }}>Neu einrichten</button>
<button onClick={onSkip} style={S.btnGhost}>Vorhandene Daten behalten </button>
</div>
</div>
);
}
// ─── Main setup wizard ────────────────────────────────────────────────────────
export default function Setup({ onComplete }) {
const TOTAL = 3;
const [step, setStep] = useState(1);
const [studio, setStudio] = useState({ name: "", address: "", email: "", phone: "" });
const [account, setAccount] = useState({ displayName: "", username: "admin", password: "", confirm: "" });
const [errors, setErrors] = useState({});
const [showPw, setShowPw] = useState(false);
const [importErr, setImportErr] = useState("");
const importRef = useRef(null);
const handleImport = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const parsed = JSON.parse(ev.target.result);
if (!parsed.settings || !Array.isArray(parsed.projects)) {
setImportErr("Ungültiges Backup-Format.");
return;
}
onComplete({
...defaultData,
...parsed,
settings: { ...defaultData.settings, ...parsed.settings, setupCompleted: true },
appRoles: parsed.appRoles || defaultData.appRoles,
users: parsed.users || defaultData.users,
});
} catch {
setImportErr("Datei konnte nicht gelesen werden.");
}
};
reader.readAsText(file);
e.target.value = "";
};
const setS = (k, v) => setStudio(s => ({ ...s, [k]: v }));
const setA = (k, v) => setAccount(a => ({ ...a, [k]: v }));
const clearErr = k => setErrors(e => { const n = { ...e }; delete n[k]; return n; });
const validate = () => {
const errs = {};
if (step === 1 && !studio.name.trim()) errs.name = "Pflichtfeld";
if (step === 2) {
if (!account.displayName.trim()) errs.displayName = "Pflichtfeld";
if (!account.username.trim()) errs.username = "Pflichtfeld";
if (account.password.length < 4) errs.password = "Mindestens 4 Zeichen";
if (account.password !== account.confirm) errs.confirm = "Passwörter stimmen nicht überein";
}
setErrors(errs);
return Object.keys(errs).length === 0;
};
const next = () => { if (validate()) setStep(s => s + 1); };
const back = () => setStep(s => s - 1);
const finish = () => {
const empId = generateId();
onComplete({
...defaultData,
settings: {
...defaultData.settings,
setupCompleted: true,
name: studio.name.trim(),
address: studio.address.trim() || defaultData.settings.address,
email: studio.email.trim() || defaultData.settings.email,
phone: studio.phone.trim() || defaultData.settings.phone,
},
users: [{
id: "admin",
username: account.username.trim(),
password: account.password,
role: "admin",
displayName: account.displayName.trim(),
appRoleId: "r-admin",
employeeId: empId,
}],
employees: [{
id: empId,
name: account.displayName.trim(),
email: studio.email.trim() || "",
wochenstunden: defaultData.settings.defaultWochenstunden || 35,
ferienWochen: defaultData.settings.defaultFerienWochen || 5,
eintrittsdatum: new Date().toISOString().slice(0, 10),
aktiv: true,
appUserId: "admin",
}],
});
};
const Progress = () => (
<div style={S.progress}>
{Array.from({ length: TOTAL }, (_, i) => (
<div key={i} style={S.dot(i + 1 === step, i + 1 < step)} />
))}
</div>
);
const Header = ({ step: n, title, lead }) => (
<>
<div style={S.logo}>RAPPORT</div>
<div style={S.sub}>SCHRITT {n} VON {TOTAL}</div>
<Progress />
<div style={{ fontFamily: "'Playfair Display',serif", fontSize: 20, color: C.text, marginBottom: 6 }}>{title}</div>
{lead && <div style={{ fontSize: 12, color: C.text3, lineHeight: 1.65, marginBottom: 20 }}>{lead}</div>}
</>
);
// ── Step 1: Studio ──────────────────────────────────────────────────────────
if (step === 1) return (
<div style={S.wrap}>
<div style={S.card}>
<Header step={1} title="Willkommen."
lead="Lass uns Rapport für dein Studio einrichten. Das dauert weniger als zwei Minuten." />
<label style={S.label}>STUDIO / UNTERNEHMEN *</label>
<input
style={{ ...S.input, ...(errors.name ? S.inputErr : {}) }}
placeholder="Muster Architektur GmbH"
autoFocus
value={studio.name}
onChange={e => { setS("name", e.target.value); clearErr("name"); }}
onKeyDown={e => e.key === "Enter" && next()}
/>
{errors.name && <div style={S.err}>{errors.name}</div>}
<label style={S.label}>ADRESSE</label>
<textarea
style={S.textarea}
placeholder={"Musterstrasse 1\n8001 Zürich"}
value={studio.address}
onChange={e => setS("address", e.target.value)}
/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<div>
<label style={S.label}>E-MAIL</label>
<input style={S.input} type="email" placeholder="mail@studio.ch"
value={studio.email} onChange={e => setS("email", e.target.value)} />
</div>
<div>
<label style={S.label}>TELEFON</label>
<input style={S.input} placeholder="+41 44 000 00 00"
value={studio.phone} onChange={e => setS("phone", e.target.value)} />
</div>
</div>
<button style={S.btnPrimary} onClick={next}>Weiter </button>
<div style={{ display: "flex", alignItems: "center", gap: 10, margin: "18px 0 4px" }}>
<div style={{ flex: 1, height: 1, background: C.border }} />
<span style={{ fontSize: 10, color: C.text4, letterSpacing: "0.1em" }}>ODER</span>
<div style={{ flex: 1, height: 1, background: C.border }} />
</div>
<input ref={importRef} type="file" accept=".json" style={{ display: "none" }} onChange={handleImport} />
<button style={S.btnGhost} onClick={() => { setImportErr(""); importRef.current?.click(); }}>
Backup importieren
</button>
{importErr && <div style={{ ...S.err, textAlign: "center", marginTop: 8 }}>{importErr}</div>}
</div>
</div>
);
// ── Step 2: Admin-Account ───────────────────────────────────────────────────
if (step === 2) return (
<div style={S.wrap}>
<div style={S.card}>
<Header step={2} title="Dein Account."
lead="Dieser Account hat vollen Systemzugriff. Weitere Mitarbeitende können später mit eingeschränkten Berechtigungen hinzugefügt werden." />
<label style={S.label}>DEIN NAME *</label>
<input
style={{ ...S.input, ...(errors.displayName ? S.inputErr : {}) }}
placeholder="Anna Muster"
autoFocus
value={account.displayName}
onChange={e => { setA("displayName", e.target.value); clearErr("displayName"); }}
/>
{errors.displayName && <div style={S.err}>{errors.displayName}</div>}
<div style={S.hint}>Wird als Mitarbeiterprofil angelegt und im System angezeigt.</div>
<label style={S.label}>BENUTZERNAME *</label>
<input
style={{ ...S.input, ...(errors.username ? S.inputErr : {}) }}
placeholder="admin"
value={account.username}
onChange={e => { setA("username", e.target.value.toLowerCase().replace(/\s/g, "")); clearErr("username"); }}
/>
{errors.username && <div style={S.err}>{errors.username}</div>}
<label style={S.label}>PASSWORT *</label>
<div style={{ position: "relative" }}>
<input
style={{ ...S.input, ...(errors.password ? S.inputErr : {}), paddingRight: 80 }}
type={showPw ? "text" : "password"}
placeholder="Mindestens 4 Zeichen"
value={account.password}
onChange={e => { setA("password", e.target.value); clearErr("password"); }}
/>
<button onClick={() => setShowPw(v => !v)}
style={{ position: "absolute", right: 10, top: "50%", transform: "translateY(-50%)", background: "none", border: "none", color: C.text4, cursor: "pointer", fontSize: 11, fontFamily: "inherit" }}>
{showPw ? "verbergen" : "anzeigen"}
</button>
</div>
{errors.password && <div style={S.err}>{errors.password}</div>}
<label style={S.label}>PASSWORT BESTÄTIGEN *</label>
<input
style={{ ...S.input, ...(errors.confirm ? S.inputErr : {}) }}
type={showPw ? "text" : "password"}
placeholder="Nochmals eingeben"
value={account.confirm}
onChange={e => { setA("confirm", e.target.value); clearErr("confirm"); }}
onKeyDown={e => e.key === "Enter" && next()}
/>
{errors.confirm && <div style={S.err}>{errors.confirm}</div>}
<button style={S.btnPrimary} onClick={next}>Weiter </button>
<button style={S.btnGhost} onClick={back}> Zurück</button>
</div>
</div>
);
// ── Step 3: Done ────────────────────────────────────────────────────────────
return (
<div style={S.wrap}>
<div style={S.card}>
<Header step={3} title="Alles bereit." />
{[
{ label: "STUDIO", value: studio.name },
{ label: "MITARBEITER", value: account.displayName },
{ label: "BENUTZERNAME", value: account.username },
{ label: "ADRESSE", value: studio.address || "—" },
].map(({ label, value }) => (
<div key={label} style={{ padding: "10px 0", borderBottom: `1px solid ${C.border2}`, display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 16 }}>
<div style={{ fontSize: 9, color: C.text4, letterSpacing: "0.12em", flexShrink: 0 }}>{label}</div>
<div style={{ fontSize: 12, color: C.text, textAlign: "right" }}>{value}</div>
</div>
))}
<div style={{ marginTop: 20, padding: "12px 14px", background: C.surface2, borderRadius: 8, border: `1px solid ${C.border}`, fontSize: 11, color: C.text3, lineHeight: 1.65 }}>
Projekte, Mitarbeitende, Einstellungen und Berechtigungen können jederzeit in der App angepasst werden.
</div>
<button style={S.btnPrimary} onClick={finish}>Rapport starten </button>
<button style={S.btnGhost} onClick={back}> Zurück</button>
</div>
</div>
);
}
+914
View File
@@ -0,0 +1,914 @@
import React, { useState, useRef } from "react";
import { EXPENSE_CATEGORIES, INTERNAL_EXPENSE_CATEGORIES } from "../constants.js";
import { generateId, formatCHF, formatDate } from "../utils.js";
import { Header, Modal, FormField, StatusSelect, useConfirm, DateInput } from "../components/UI.jsx";
export
function Spesen({ data, update, saveAll, standalone, setView }) {
const mwstRate = data.settings.mwstRate || 8.1;
const expCats = data.settings.expenseCategories || EXPENSE_CATEGORIES;
const currentYear = new Date().getFullYear().toString();
const emptyExp = { date: new Date().toISOString().slice(0, 10), category: expCats[0], projectId: "", employeeId: "", description: "", amount: 0, mwstRate: mwstRate, inclMwst: true, status: "offen" };
const [modal, setModal] = useState(null);
const [form, setForm] = useState(emptyExp);
const [filter, setFilter] = useState({ year: currentYear, category: "", projectId: "", employeeId: "", search: "", status: "" });
const [compact, setCompact] = useState(true);
const [groupBy, setGroupBy] = useState("date"); // "date" | "category" | "project"
const { askConfirm, ConfirmModalEl } = useConfirm();
const [sort, setSort] = useState({ col: "date", dir: -1 });
const [receiptView, setReceiptView] = useState(null);
const receiptInputRef = useRef(null);
const [expCatModal, setExpCatModal] = useState(false);
const [expCatEdit, setExpCatEdit] = useState("");
const [expCatEditIdx, setExpCatEditIdx] = useState(null);
const [newExpCat, setNewExpCat] = useState("");
const years = Array.from(new Set((data.expenses || []).map(e => (e.date || "").slice(0, 4)).filter(Boolean))).sort().reverse();
const handleReceiptUpload = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const isPdf = file.type === "application/pdf";
const reader = new FileReader();
reader.onload = (ev) => {
if (isPdf) {
setForm(f => ({ ...f, receiptData: ev.target.result, receiptName: file.name }));
} else {
const img = new Image();
img.onload = () => {
const maxW = 1600;
const scale = img.width > maxW ? maxW / img.width : 1;
const canvas = document.createElement("canvas");
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
setForm(f => ({ ...f, receiptData: canvas.toDataURL("image/jpeg", 0.82), receiptName: file.name }));
};
img.src = ev.target.result;
}
};
reader.readAsDataURL(file);
e.target.value = "";
};
const openNew = () => { setForm(emptyExp); setModal("new"); };
const openEdit = (e) => { setForm({ ...e }); setModal(e.id); };
const closeModal = () => { setModal(null); setForm(emptyExp); };
const saveExp = () => {
if (!form.amount) return;
const isNew = modal === "new";
const prevStatus = isNew ? null : (data.expenses || []).find(e => e.id === modal)?.status;
const newId = isNew ? generateId() : modal;
const expenses = isNew
? [...(data.expenses || []), { ...form, id: newId }]
: (data.expenses || []).map(e => e.id === modal ? { ...form, id: modal } : e);
if (form.status === "ausbezahlt" && prevStatus !== "ausbezahlt" && !form.lohnEntryId) {
const newInternal = {
id: generateId(),
date: form.date || new Date().toISOString().slice(0, 10),
category: "Sonstiges",
description: `Barauszahlung Spese: ${form.description || form.category}`,
amount: form.amount,
mwstRate: 0,
inclMwst: false,
recurring: false,
};
saveAll({ ...data, expenses, internalExpenses: [...(data.internalExpenses || []), newInternal] });
} else {
update("expenses", expenses);
}
closeModal();
};
const delExp = async (id) => { if (await askConfirm("Spese löschen?")) update("expenses", (data.expenses || []).filter(e => e.id !== id)); };
const handleStatusChange = (exp, v) => {
if (v === "ausbezahlt" && !exp.lohnEntryId) {
const newInternal = {
id: generateId(),
date: exp.date || new Date().toISOString().slice(0, 10),
category: "Sonstiges",
description: `Barauszahlung Spese: ${exp.description || exp.category}`,
amount: exp.amount,
mwstRate: 0,
inclMwst: false,
recurring: false,
};
saveAll({
...data,
expenses: (data.expenses || []).map(x => x.id === exp.id ? { ...x, status: "ausbezahlt" } : x),
internalExpenses: [...(data.internalExpenses || []), newInternal],
});
} else {
update("expenses", (data.expenses || []).map(x => x.id === exp.id ? { ...x, status: v } : x));
}
};
// Filtern
const allFiltered = [...(data.expenses || [])].filter(e => {
if (filter.year && !(e.date || "").startsWith(filter.year)) return false;
if (filter.category && e.category !== filter.category) return false;
if (filter.projectId && e.projectId !== filter.projectId) return false;
if (filter.status && (e.status || "offen") !== filter.status) return false;
if (filter.employeeId && e.employeeId !== filter.employeeId) return false;
if (filter.search) {
const q = filter.search.toLowerCase();
const proj = data.projects.find(p => p.id === e.projectId);
const emp = (data.employees || []).find(em => em.id === e.employeeId);
if (![e.description, e.category, proj?.name, emp?.name].filter(Boolean).join(" ").toLowerCase().includes(q)) return false;
}
return true;
});
// Sortieren
const sorted = [...allFiltered].sort((a, b) => {
let va, vb;
if (sort.col === "date") { va = a.date || ""; vb = b.date || ""; }
else if (sort.col === "amount") { va = a.amount || 0; vb = b.amount || 0; }
else if (sort.col === "category") { va = a.category || ""; vb = b.category || ""; }
else if (sort.col === "project") {
va = data.projects.find(p => p.id === a.projectId)?.name || "";
vb = data.projects.find(p => p.id === b.projectId)?.name || "";
} else if (sort.col === "employee") {
va = (data.employees || []).find(e => e.id === a.employeeId)?.name || "";
vb = (data.employees || []).find(e => e.id === b.employeeId)?.name || "";
}
else { va = ""; vb = ""; }
return typeof va === "number" ? (va - vb) * sort.dir : va.localeCompare(vb) * sort.dir;
});
const toggleSort = (col) => setSort(s => ({ col, dir: s.col === col ? -s.dir : -1 }));
const SortTh = ({ col, children, style }) => (
<th onClick={() => toggleSort(col)} style={{ cursor: "pointer", userSelect: "none", ...style }}>
{children} <span style={{ color: sort.col === col ? "#b07848" : "#ccc", fontSize: 10 }}>{sort.col === col ? (sort.dir === 1 ? "▲" : "▼") : "⇅"}</span>
</th>
);
// Gruppieren
const getNet = (e) => e.inclMwst ? e.amount / (1 + (e.mwstRate || 0) / 100) : e.amount;
const getTax = (e) => e.amount - getNet(e);
const groupedData = (() => {
if (groupBy === "date") {
// Nach Monat gruppieren
const months = {};
sorted.forEach(e => {
const key = (e.date || "").slice(0, 7);
if (!months[key]) months[key] = [];
months[key].push(e);
});
return Object.entries(months).sort((a, b) => sort.dir * b[0].localeCompare(a[0])).map(([key, items]) => ({
key,
label: key ? new Date(key + "-01").toLocaleDateString("de-CH", { month: "long", year: "numeric" }) : "Ohne Datum",
items,
}));
}
if (groupBy === "category") {
const cats = {};
sorted.forEach(e => {
const key = e.category || "Sonstiges";
if (!cats[key]) cats[key] = [];
cats[key].push(e);
});
return Object.entries(cats).sort((a, b) => a[0].localeCompare(b[0])).map(([key, items]) => ({ key, label: key, items }));
}
if (groupBy === "project") {
const projs = {};
sorted.forEach(e => {
const proj = data.projects.find(p => p.id === e.projectId);
const key = e.projectId || "__none__";
const label = proj?.name || "Kein Projekt";
if (!projs[key]) projs[key] = { label, items: [] };
projs[key].items.push(e);
});
return Object.entries(projs).sort((a, b) => a[1].label.localeCompare(b[1].label)).map(([key, val]) => ({ key, label: val.label, items: val.items }));
}
return [{ key: "all", label: "", items: sorted }];
})();
// Totals
const totalNet = allFiltered.reduce((s, e) => s + getNet(e), 0);
const totalTax = allFiltered.reduce((s, e) => s + getTax(e), 0);
const totalBrutto = allFiltered.reduce((s, e) => s + (e.amount || 0), 0);
// KPI nach Kategorie für Mini-Balken
const categoryTotals = expCats.map(cat => ({
cat,
amount: allFiltered.filter(e => e.category === cat).reduce((s, e) => s + (e.amount || 0), 0),
})).filter(c => c.amount > 0).sort((a, b) => b.amount - a.amount).slice(0, 4);
const hasFilters = filter.category || filter.projectId || filter.employeeId || filter.search || filter.status;
return (
<div>
{ConfirmModalEl}
<ReceiptViewer expense={receiptView} onClose={() => setReceiptView(null)} />
{standalone && (
<>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
</div>
<Header title="Spesen" action={
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-ghost" onClick={() => { setExpCatEdit(""); setExpCatEditIdx(null); setExpCatModal(true); }}>Kategorien verwalten</button>
<button className="btn btn-primary" onClick={openNew}>+ Spese erfassen</button>
</div>
} />
{expCatModal && (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", zIndex: 100, display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
<div className="modal" style={{ maxWidth: 420 }}>
<div style={{ fontSize: 18, fontFamily: "'Playfair Display', serif", fontWeight: 400, marginBottom: 20 }}>Spesenarten verwalten</div>
<div style={{ marginBottom: 16 }}>
{expCats.map((cat, idx) => {
const inUse = (data.expenses || []).some(e => e.category === cat);
return (
<div key={idx} style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
{expCatEditIdx === idx ? (
<input autoFocus value={expCatEdit} onChange={e => setExpCatEdit(e.target.value)}
onBlur={() => {
if (expCatEdit.trim()) {
const updated = expCats.map((c, i) => i === idx ? expCatEdit.trim() : c);
update("settings", { ...data.settings, expenseCategories: updated });
}
setExpCatEditIdx(null);
}}
onKeyDown={e => { if (e.key === "Enter") e.target.blur(); if (e.key === "Escape") setExpCatEditIdx(null); }}
style={{ flex: 1, height: 30, fontSize: 12 }} />
) : (
<div style={{ flex: 1, fontSize: 13, padding: "4px 0" }}>{cat}</div>
)}
<button className="btn btn-ghost" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => { setExpCatEdit(cat); setExpCatEditIdx(idx); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "3px 8px", fontSize: 11, opacity: inUse ? 0.35 : 1 }}
onClick={() => { if (!inUse) update("settings", { ...data.settings, expenseCategories: expCats.filter((_, i) => i !== idx) }); }}
title={inUse ? "In Verwendung" : "Löschen"}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
);
})}
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 20 }}>
<input value={newExpCat} placeholder="Neue Kategorie…"
onChange={e => setNewExpCat(e.target.value)}
onKeyDown={e => { if (e.key === "Enter" && newExpCat.trim()) { update("settings", { ...data.settings, expenseCategories: [...expCats, newExpCat.trim()] }); setNewExpCat(""); } }}
style={{ flex: 1, height: 32, fontSize: 12 }} />
<button className="btn btn-ghost" style={{ whiteSpace: "nowrap" }}
onClick={() => { if (newExpCat.trim()) { update("settings", { ...data.settings, expenseCategories: [...expCats, newExpCat.trim()] }); setNewExpCat(""); } }}>+ Hinzufügen</button>
</div>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button className="btn btn-primary" onClick={() => setExpCatModal(false)}>Schliessen</button>
</div>
</div>
</div>
)}
</>
)}
{/* KPI-Streifen */}
{allFiltered.length > 0 && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 20 }}>
<div className="card" style={{ borderTop: "3px solid #555" }}>
<div style={{ fontSize: 10, color: "#888", letterSpacing: "0.12em", marginBottom: 6 }}>TOTAL BRUTTO</div>
<div style={{ fontSize: 20, fontFamily: "'Playfair Display', serif", fontWeight: 700 }}>{formatCHF(totalBrutto)}</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 2 }}>{allFiltered.length} Einträge</div>
</div>
<div className="card" style={{ borderTop: "3px solid #888" }}>
<div style={{ fontSize: 10, color: "#888", letterSpacing: "0.12em", marginBottom: 6 }}>NETTO</div>
<div style={{ fontSize: 20, fontFamily: "'Playfair Display', serif", fontWeight: 700 }}>{formatCHF(totalNet)}</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 2 }}>abzgl. Vorsteuer</div>
</div>
<div className="card" style={{ borderTop: "3px solid #b5621e" }}>
<div style={{ fontSize: 10, color: "#888", letterSpacing: "0.12em", marginBottom: 6 }}>VORSTEUER</div>
<div style={{ fontSize: 20, fontFamily: "'Playfair Display', serif", fontWeight: 700 }}>{formatCHF(totalTax)}</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 2 }}>rückforderbar</div>
</div>
<div className="card" style={{ borderTop: "3px solid #2d6a4f" }}>
<div style={{ fontSize: 10, color: "#888", letterSpacing: "0.12em", marginBottom: 6 }}>TOP KATEGORIE</div>
{categoryTotals[0] ? (
<>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>{categoryTotals[0].cat}</div>
{categoryTotals.map(c => (
<div key={c.cat} style={{ marginBottom: 4 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 10, color: "#888", marginBottom: 2 }}>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 100 }}>{c.cat}</span>
<span>{formatCHF(c.amount)}</span>
</div>
<div style={{ height: 3, background: "#ece8e2", borderRadius: 2 }}>
<div style={{ width: `${(c.amount / categoryTotals[0].amount) * 100}%`, height: "100%", background: "#b07848", borderRadius: 2 }} />
</div>
</div>
))}
</>
) : <div style={{ fontSize: 12, color: "#aaa" }}></div>}
</div>
</div>
)}
<div className="filter-bar">
<input className="pill" placeholder="Suche (Beschreibung, Kategorie, Projekt…)" value={filter.search} onChange={e => setFilter({ ...filter, search: e.target.value })} style={{ minWidth: 220 }} />
<select className="pill" value={filter.year} onChange={e => setFilter({ ...filter, year: e.target.value })}>
<option value="">Alle Jahre</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<select className="pill" value={filter.category} onChange={e => setFilter({ ...filter, category: e.target.value })}>
<option value="">Alle Kategorien</option>
{expCats.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<select className="pill" value={filter.projectId} onChange={e => setFilter({ ...filter, projectId: e.target.value })}>
<option value="">Alle Projekte</option>
{data.projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<select className="pill" value={filter.employeeId} onChange={e => setFilter({ ...filter, employeeId: e.target.value })}>
<option value="">Alle Mitarbeiter</option>
{(data.employees || []).map(em => <option key={em.id} value={em.id}>{em.name}</option>)}
</select>
<select className="pill" value={filter.status} onChange={e => setFilter({ ...filter, status: e.target.value })}>
<option value="">Alle Status</option>
<option value="offen">Offen</option>
<option value="genehmigt">Genehmigt</option>
<option value="auf nächsten Lohn">Auf nächsten Lohn</option>
<option value="ausbezahlt">Ausbezahlt</option>
</select>
{hasFilters && (
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => setFilter({ year: filter.year, category: "", projectId: "", employeeId: "", search: "", status: "" })}>Zurücksetzen</button>
)}
<div style={{ marginLeft: "auto", fontSize: 12, color: "var(--text4)" }}>
<strong style={{ color: "var(--text)" }}>{allFiltered.length}</strong> {allFiltered.length === 1 ? "Eintrag" : "Einträge"} · {formatCHF(totalBrutto)}
</div>
</div>
<div className="filter-bar">
<span className="filter-label">GRUPPIEREN:</span>
{[{ id: "date", label: "Monat" }, { id: "category", label: "Kategorie" }, { id: "project", label: "Projekt" }].map(g => (
<button key={g.id} className={`pill${groupBy === g.id ? " active" : ""}`} onClick={() => setGroupBy(g.id)}>{g.label}</button>
))}
</div>
{/* Tabelle gruppiert */}
{allFiltered.length === 0 ? (
<div className="card" style={{ padding: 0 }}>
<table>
<thead><tr><th style={{ width: 100 }}>Datum</th><th style={{ width: 170 }}>Kategorie</th><th style={{ width: 140 }}>Projekt</th><th style={{ width: 130 }}>Mitarbeiter</th><th>Beschreibung</th><th style={{ textAlign: "right", width: 110 }}>Netto</th><th style={{ textAlign: "right", width: 100 }}>Vorsteuer</th><th style={{ textAlign: "right", width: 120 }}>Brutto</th><th style={{ width: 130 }}>Status</th><th style={{ width: 90 }}></th></tr></thead>
<tbody><tr><td colSpan={10} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>{(data.expenses || []).length === 0 ? "Noch keine Spesen" : "Keine Treffer"}</td></tr></tbody>
</table>
</div>
) : (
<div className="card" style={{ padding: 0 }}>
<table>
<thead>
<tr>
<SortTh col="date" style={{ width: 100 }}>Datum</SortTh>
{groupBy !== "category" && <SortTh col="category" style={{ width: 170 }}>Kategorie</SortTh>}
{groupBy !== "project" && <SortTh col="project" style={{ width: 140 }}>Projekt</SortTh>}
<SortTh col="employee" style={{ width: 130 }}>Mitarbeiter</SortTh>
<th>Beschreibung</th>
<SortTh col="amount" style={{ textAlign: "right", width: 110 }}>Netto</SortTh>
<th className={compact ? "hide-compact" : ""} style={{ textAlign: "right", width: 100 }}>Vorsteuer</th>
<SortTh col="amount" style={{ textAlign: "right", width: 120 }}>Brutto</SortTh>
<th style={{ width: 130 }}>Status</th>
<th style={{ width: 90 }}></th>
</tr>
</thead>
<tbody>
{groupedData.map(group => {
const gNet = group.items.reduce((s, e) => s + getNet(e), 0);
const gTax = group.items.reduce((s, e) => s + getTax(e), 0);
const gBrutto = group.items.reduce((s, e) => s + (e.amount || 0), 0);
return (
<React.Fragment key={group.key}>
{/* Gruppen-Header (nur wenn > 1 Gruppe) */}
{groupedData.length > 1 && (
<tr style={{ background: "#f5f0e8" }}>
<td colSpan={groupBy === "date" ? 8 : 7} style={{ padding: "6px 12px", fontSize: 11, fontWeight: 600, letterSpacing: "0.04em", color: "#555" }}>
{group.label}
<span style={{ marginLeft: 10, fontWeight: 400, color: "#888", fontSize: 10 }}>{group.items.length} Einträge</span>
</td>
<td colSpan={2} style={{ padding: "6px 12px", textAlign: "right", fontSize: 11, fontWeight: 600 }}>{formatCHF(gBrutto)}</td>
</tr>
)}
{group.items.map(e => {
const proj = data.projects.find(p => p.id === e.projectId);
const emp = (data.employees || []).find(em => em.id === e.employeeId);
const net = getNet(e);
const tax = getTax(e);
const expStatus = e.status || "offen";
return (
<tr key={e.id}>
<td>{formatDate(e.date)}</td>
{groupBy !== "category" && <td>{e.category}</td>}
{groupBy !== "project" && <td style={{ color: "#888" }}>{proj?.name || "—"}</td>}
<td style={{ color: "#888", fontSize: 12 }}>{emp?.name || "—"}</td>
<td style={{ color: "#555" }}>{e.description || "—"}</td>
<td style={{ textAlign: "right" }}>{formatCHF(net)}</td>
<td className={compact ? "hide-compact" : ""} style={{ textAlign: "right", color: "#888" }}>{e.mwstRate > 0 ? formatCHF(tax) : "—"}</td>
<td style={{ textAlign: "right" }}><strong>{formatCHF(e.amount)}</strong></td>
<td>
{e.lohnEntryId
? <span style={{ display: "inline-block", padding: "2px 9px", borderRadius: 4, fontSize: 11, fontWeight: 600, letterSpacing: "0.03em", color: "#2d6a4f", background: "#e8f5ee", border: "1.5px solid #2d6a4f40", whiteSpace: "nowrap" }}> Via Lohn</span>
: <StatusSelect value={expStatus} options={["offen", "genehmigt", "auf nächsten Lohn", "ausbezahlt"]} onChange={v => handleStatusChange(e, v)} />
}
</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
{e.receiptData && (
<button className="btn btn-ghost" title="Beleg anzeigen" style={{ padding: "5px 10px", fontSize: 12, marginRight: 4 }} onClick={() => setReceiptView(e)}>
<span className="material-icons" style={{ fontSize: 14, verticalAlign: "middle" }}>receipt_long</span>
</button>
)}
<button className="btn btn-ghost" style={{ padding: "5px 10px", fontSize: 12, marginRight: 4 }} onClick={() => openEdit(e)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => delExp(e.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
{/* Gruppen-Subtotal wenn > 1 Gruppe */}
{groupedData.length > 1 && (
<tr style={{ background: "#faf8f5", borderTop: "1px solid #e0dbd4" }}>
<td colSpan={groupBy === "date" ? 5 : 4} style={{ padding: "5px 12px", fontSize: 11, color: "#888", fontStyle: "italic" }}>Total {group.label}</td>
<td style={{ textAlign: "right", padding: "5px 12px", fontWeight: 600, fontSize: 11 }}>{formatCHF(gNet)}</td>
<td className={compact ? "hide-compact" : ""} style={{ textAlign: "right", padding: "5px 12px", fontWeight: 600, fontSize: 11, color: "#888" }}>{formatCHF(gTax)}</td>
<td colSpan={3} style={{ textAlign: "right", padding: "5px 12px", fontWeight: 700, fontSize: 11 }}>{formatCHF(gBrutto)}</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
<tfoot>
<tr style={{ borderTop: "2px solid #1a1a18" }}>
<td colSpan={groupBy === "date" ? 5 : 4} style={{ color: "#888", fontSize: 12 }}>
{allFiltered.length} {allFiltered.length === 1 ? "Eintrag" : "Einträge"} {filter.year ? filter.year : ""}
</td>
<td style={{ textAlign: "right", fontWeight: 600 }}>{formatCHF(totalNet)}</td>
<td className={compact ? "hide-compact" : ""} style={{ textAlign: "right", fontWeight: 600, color: "#888" }}>{formatCHF(totalTax)}</td>
<td colSpan={3} style={{ textAlign: "right", fontWeight: 700 }}>{formatCHF(totalBrutto)}</td>
</tr>
</tfoot>
</table>
</div>
)}
{modal && (
<Modal title={modal === "new" ? "Spese erfassen" : "Spese bearbeiten"} onClose={closeModal} onSave={saveExp}>
<div className="form-row">
<FormField label="Datum"><DateInput value={form.date} onChange={e => setForm({ ...form, date: e.target.value })} /></FormField>
<FormField label="Kategorie">
<select value={form.category} onChange={e => setForm({ ...form, category: e.target.value })}>
{expCats.map(c => <option key={c}>{c}</option>)}
</select>
</FormField>
</div>
<div className="form-row">
<FormField label="Mitarbeiter">
<select value={form.employeeId || ""} onChange={e => setForm({ ...form, employeeId: e.target.value })}>
<option value=""> kein Mitarbeiter </option>
{(data.employees || []).map(em => <option key={em.id} value={em.id}>{em.name}</option>)}
</select>
</FormField>
<FormField label="Projekt (optional)">
<select value={form.projectId} onChange={e => setForm({ ...form, projectId: e.target.value })}>
<option value=""> kein Projekt </option>
{data.projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</FormField>
</div>
<div className="form-row">
<FormField label="Status">
<select value={form.status || "offen"} onChange={e => setForm({ ...form, status: e.target.value })}>
<option value="offen">Offen</option>
<option value="genehmigt">Genehmigt</option>
<option value="auf nächsten Lohn">Auf nächsten Lohn</option>
<option value="ausbezahlt">Ausbezahlt</option>
</select>
</FormField>
</div>
<FormField label="Beschreibung"><input value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="z.B. Zug ZürichBern, Plotkosten…" autoFocus /></FormField>
<div className="form-row">
<FormField label="Betrag CHF"><input type="number" step="0.05" value={form.amount} onChange={e => setForm({ ...form, amount: +e.target.value })} /></FormField>
<FormField label="MWST-Satz %"><input type="number" step="0.1" value={form.mwstRate} onChange={e => setForm({ ...form, mwstRate: +e.target.value })} /></FormField>
<FormField label=" ">
<label style={{ display: "flex", alignItems: "center", gap: 6, height: 36, cursor: "pointer", textTransform: "none", fontSize: 13 }}>
<input type="checkbox" checked={!!form.inclMwst} onChange={e => setForm({ ...form, inclMwst: e.target.checked })} style={{ width: "auto" }} />
Betrag inkl. MWST
</label>
</FormField>
</div>
{form.amount > 0 && (
<div style={{ fontSize: 11, color: "#888", padding: "8px 12px", background: "#faf8f5", borderRadius: 4 }}>
{form.inclMwst
? `Netto: ${formatCHF(form.amount / (1 + (form.mwstRate || 0) / 100))} · MWST: ${formatCHF(form.amount - form.amount / (1 + (form.mwstRate || 0) / 100))}`
: `Brutto inkl. ${form.mwstRate}% MWST: ${formatCHF(form.amount * (1 + (form.mwstRate || 0) / 100))}`}
</div>
)}
<div style={{ borderTop: "1px solid var(--border2)", paddingTop: 14, marginTop: 14 }}>
<label style={{ fontSize: 10, color: "#888", letterSpacing: "0.08em", display: "block", marginBottom: 8 }}>BELEG / QUITTUNG</label>
{form.receiptData ? (
<div style={{ display: "flex", alignItems: "center", gap: 8, background: "#faf8f5", padding: "8px 12px", borderRadius: 4 }}>
<span style={{ fontSize: 12, color: "#555", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", display: "flex", alignItems: "center", gap: 4 }}>
<span className="material-icons" style={{ fontSize: 15 }}>receipt_long</span>
{form.receiptName || "Beleg hochgeladen"}
</span>
<button className="btn btn-ghost" type="button" style={{ fontSize: 12 }} onClick={() => setReceiptView({ ...form })}>Anzeigen</button>
<button className="btn btn-danger" type="button" style={{ fontSize: 12 }} onClick={() => setForm(f => ({ ...f, receiptData: null, receiptName: null }))}>Entfernen</button>
</div>
) : (
<label style={{ display: "inline-flex", alignItems: "center", gap: 6, cursor: "pointer", fontSize: 12, color: "#b07848", border: "1px dashed #b07848", borderRadius: 4, padding: "6px 14px" }}>
<span className="material-icons" style={{ fontSize: 15 }}>upload_file</span>
Quittung hochladen (Bild oder PDF)
<input ref={receiptInputRef} type="file" accept="image/*,application/pdf" onChange={handleReceiptUpload} style={{ display: "none" }} />
</label>
)}
</div>
</Modal>
)}
</div>
);
}
export function ReceiptViewer({ expense, onClose }) {
if (!expense?.receiptData) return null;
const isPdf = expense.receiptData.startsWith("data:application/pdf");
return (
<div
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.78)", zIndex: 300, display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}
onClick={onClose}
>
<div
style={{ background: "#fff", borderRadius: 8, maxWidth: 860, width: "100%", maxHeight: "92vh", display: "flex", flexDirection: "column", boxShadow: "0 8px 40px rgba(0,0,0,0.35)" }}
onClick={e => e.stopPropagation()}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 18px", borderBottom: "1px solid #e0dbd4", gap: 12 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 600, fontFamily: "'Playfair Display', serif", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{expense.description || expense.category || "Beleg"}
</div>
{expense.receiptName && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{expense.receiptName}</div>}
</div>
<div style={{ display: "flex", gap: 8, flexShrink: 0 }}>
<a href={expense.receiptData} download={expense.receiptName || "beleg"} className="btn btn-ghost" style={{ fontSize: 12 }}> Herunterladen</a>
<button className="btn btn-ghost" onClick={onClose}><span className="material-icons" style={{ fontSize: 16, verticalAlign: "middle" }}>close</span> Schliessen</button>
</div>
</div>
<div style={{ flex: 1, overflow: "auto", padding: 16, display: "flex", justifyContent: "center", alignItems: "flex-start" }}>
{isPdf ? (
<iframe src={expense.receiptData} style={{ width: "100%", height: "75vh", border: "none" }} title="Beleg" />
) : (
<img src={expense.receiptData} alt="Beleg" style={{ maxWidth: "100%", maxHeight: "75vh", objectFit: "contain", borderRadius: 4 }} />
)}
</div>
</div>
</div>
);
}
export
function InternalExpenses({ data, update, setView }) {
const mwstRate = data.settings.mwstRate || 8.1;
const intCats = data.settings.internalExpenseCategories || INTERNAL_EXPENSE_CATEGORIES;
const currentYear = new Date().getFullYear().toString();
const emptyItem = { date: new Date().toISOString().slice(0, 10), category: intCats[0], description: "", amount: 0, mwstRate: mwstRate, inclMwst: true, recurring: false, recurringInterval: "monatlich" };
const [modal, setModal] = useState(null);
const [form, setForm] = useState(emptyItem);
const [catModal, setCatModal] = useState(false);
const [catEdit, setCatEdit] = useState("");
const [catEditIdx, setCatEditIdx] = useState(null);
const [newCat, setNewCat] = useState("");
const [filter, setFilter] = useState({ year: currentYear, category: "", search: "" });
const [sort, setSort] = useState({ col: "date", dir: -1 });
const [groupBy, setGroupBy] = useState("date");
const { askConfirm: askConfirmInt, ConfirmModalEl: ConfirmModalElInt } = useConfirm();
const items = data.internalExpenses || [];
const years = Array.from(new Set(items.map(e => (e.date || "").slice(0, 4)).filter(Boolean))).sort().reverse();
const openNew = () => { setForm(emptyItem); setModal("new"); };
const openEdit = (e) => { setForm({ ...e }); setModal(e.id); };
const closeModal = () => { setModal(null); setForm(emptyItem); };
const save = () => {
if (!form.amount) return;
const isNew = modal === "new";
const updated = isNew
? [...items, { ...form, id: generateId() }]
: items.map(e => e.id === modal ? { ...form, id: modal } : e);
update("internalExpenses", updated);
closeModal();
};
const del = async (id) => { if (await askConfirmInt("Ausgabe löschen?")) update("internalExpenses", items.filter(e => e.id !== id)); };
const getNet = (e) => e.inclMwst ? e.amount / (1 + (e.mwstRate || 0) / 100) : e.amount;
const getTax = (e) => e.amount - getNet(e);
const allFiltered = items.filter(e => {
if (filter.year && !(e.date || "").startsWith(filter.year)) return false;
if (filter.category && e.category !== filter.category) return false;
if (filter.search) {
const q = filter.search.toLowerCase();
if (![e.description, e.category].filter(Boolean).join(" ").toLowerCase().includes(q)) return false;
}
return true;
});
const toggleSort = (col) => setSort(s => ({ col, dir: s.col === col ? -s.dir : -1 }));
const SortTh = ({ col, children, style }) => (
<th onClick={() => toggleSort(col)} style={{ cursor: "pointer", userSelect: "none", ...style }}>
{children} <span style={{ color: sort.col === col ? "#b07848" : "#ccc", fontSize: 10 }}>{sort.col === col ? (sort.dir === 1 ? "▲" : "▼") : "⇅"}</span>
</th>
);
const sorted = [...allFiltered].sort((a, b) => {
let va, vb;
if (sort.col === "date") { va = a.date || ""; vb = b.date || ""; }
else if (sort.col === "amount") { va = a.amount || 0; vb = b.amount || 0; }
else if (sort.col === "category") { va = a.category || ""; vb = b.category || ""; }
else { va = ""; vb = ""; }
return typeof va === "number" ? (va - vb) * sort.dir : va.localeCompare(vb) * sort.dir;
});
const groupedData = (() => {
if (groupBy === "date") {
const months = {};
sorted.forEach(e => {
const key = (e.date || "").slice(0, 7);
if (!months[key]) months[key] = [];
months[key].push(e);
});
return Object.entries(months).sort((a, b) => sort.dir * b[0].localeCompare(a[0])).map(([key, its]) => ({
key, label: key ? new Date(key + "-01").toLocaleDateString("de-CH", { month: "long", year: "numeric" }) : "Ohne Datum", items: its,
}));
}
if (groupBy === "category") {
const cats = {};
sorted.forEach(e => { const k = e.category || "Sonstiges"; if (!cats[k]) cats[k] = []; cats[k].push(e); });
return Object.entries(cats).sort((a, b) => a[0].localeCompare(b[0])).map(([key, its]) => ({ key, label: key, items: its }));
}
return [{ key: "all", label: "", items: sorted }];
})();
const totalNet = allFiltered.reduce((s, e) => s + getNet(e), 0);
const totalTax = allFiltered.reduce((s, e) => s + getTax(e), 0);
const totalBrutto = allFiltered.reduce((s, e) => s + (e.amount || 0), 0);
const categoryTotals = intCats.map(cat => ({
cat, amount: allFiltered.filter(e => e.category === cat).reduce((s, e) => s + (e.amount || 0), 0),
})).filter(c => c.amount > 0).sort((a, b) => b.amount - a.amount).slice(0, 4);
const hasFilters = filter.category || filter.search;
return (
<div>
{ConfirmModalElInt}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
</div>
<Header title="Ausgaben" action={
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-ghost" onClick={() => { setCatEdit(""); setCatEditIdx(null); setCatModal(true); }}>Kategorien verwalten</button>
<button className="btn btn-primary" onClick={openNew}>+ Ausgabe erfassen</button>
</div>
} />
{catModal && (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", zIndex: 100, display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
<div className="modal" style={{ maxWidth: 420 }}>
<div style={{ fontSize: 18, fontFamily: "'Playfair Display', serif", fontWeight: 400, marginBottom: 20 }}>Kategorien verwalten</div>
<div style={{ marginBottom: 16 }}>
{intCats.map((cat, idx) => {
const inUse = items.some(e => e.category === cat);
return (
<div key={idx} style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
{catEditIdx === idx ? (
<input autoFocus value={catEdit} onChange={e => setCatEdit(e.target.value)}
onBlur={() => {
if (catEdit.trim()) {
const updated = intCats.map((c, i) => i === idx ? catEdit.trim() : c);
update("settings", { ...data.settings, internalExpenseCategories: updated });
}
setCatEditIdx(null);
}}
onKeyDown={e => { if (e.key === "Enter") e.target.blur(); if (e.key === "Escape") setCatEditIdx(null); }}
style={{ flex: 1, height: 30, fontSize: 12 }} />
) : (
<div style={{ flex: 1, fontSize: 13, padding: "4px 0" }}>{cat}</div>
)}
<button className="btn btn-ghost" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => { setCatEdit(cat); setCatEditIdx(idx); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "3px 8px", fontSize: 11, opacity: inUse ? 0.35 : 1 }}
onClick={() => {
if (inUse) return;
update("settings", { ...data.settings, internalExpenseCategories: intCats.filter((_, i) => i !== idx) });
}} title={inUse ? "In Verwendung" : "Löschen"}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
);
})}
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 20 }}>
<input value={newCat} placeholder="Neue Kategorie…"
onChange={e => setNewCat(e.target.value)}
onKeyDown={e => { if (e.key === "Enter" && newCat.trim()) { update("settings", { ...data.settings, internalExpenseCategories: [...intCats, newCat.trim()] }); setNewCat(""); } }}
style={{ flex: 1, height: 32, fontSize: 12 }} />
<button className="btn btn-ghost" style={{ whiteSpace: "nowrap" }}
onClick={() => { if (newCat.trim()) { update("settings", { ...data.settings, internalExpenseCategories: [...intCats, newCat.trim()] }); setNewCat(""); } }}>+ Hinzufügen</button>
</div>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button className="btn btn-primary" onClick={() => setCatModal(false)}>Schliessen</button>
</div>
</div>
</div>
)}
{allFiltered.length > 0 && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 20 }}>
<div className="card" style={{ borderTop: "3px solid #1a4e8a" }}>
<div style={{ fontSize: 10, color: "#888", letterSpacing: "0.12em", marginBottom: 6 }}>TOTAL BRUTTO</div>
<div style={{ fontSize: 20, fontFamily: "'Playfair Display', serif", fontWeight: 700 }}>{formatCHF(totalBrutto)}</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 2 }}>{allFiltered.length} Einträge</div>
</div>
<div className="card" style={{ borderTop: "3px solid #888" }}>
<div style={{ fontSize: 10, color: "#888", letterSpacing: "0.12em", marginBottom: 6 }}>NETTO</div>
<div style={{ fontSize: 20, fontFamily: "'Playfair Display', serif", fontWeight: 700 }}>{formatCHF(totalNet)}</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 2 }}>abzgl. Vorsteuer</div>
</div>
<div className="card" style={{ borderTop: "3px solid #b5621e" }}>
<div style={{ fontSize: 10, color: "#888", letterSpacing: "0.12em", marginBottom: 6 }}>VORSTEUER</div>
<div style={{ fontSize: 20, fontFamily: "'Playfair Display', serif", fontWeight: 700 }}>{formatCHF(totalTax)}</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 2 }}>rückforderbar</div>
</div>
<div className="card" style={{ borderTop: "3px solid #2d6a4f" }}>
<div style={{ fontSize: 10, color: "#888", letterSpacing: "0.12em", marginBottom: 6 }}>TOP KATEGORIE</div>
{categoryTotals[0] ? (
<>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>{categoryTotals[0].cat}</div>
{categoryTotals.map(c => (
<div key={c.cat} style={{ marginBottom: 4 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 10, color: "#888", marginBottom: 2 }}>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 100 }}>{c.cat}</span>
<span>{formatCHF(c.amount)}</span>
</div>
<div style={{ height: 3, background: "#ece8e2", borderRadius: 2 }}>
<div style={{ width: `${(c.amount / categoryTotals[0].amount) * 100}%`, height: "100%", background: "#b07848", borderRadius: 2 }} />
</div>
</div>
))}
</>
) : <div style={{ fontSize: 12, color: "#aaa" }}></div>}
</div>
</div>
)}
<div className="filter-bar">
<input className="pill" placeholder="Suche (Beschreibung, Kategorie…)" value={filter.search} onChange={e => setFilter({ ...filter, search: e.target.value })} style={{ minWidth: 200 }} />
<select className="pill" value={filter.year} onChange={e => setFilter({ ...filter, year: e.target.value })}>
<option value="">Alle Jahre</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<select className="pill" value={filter.category} onChange={e => setFilter({ ...filter, category: e.target.value })}>
<option value="">Alle Kategorien</option>
{intCats.map(c => <option key={c} value={c}>{c}</option>)}
</select>
{hasFilters && (
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => setFilter({ year: filter.year, category: "", search: "" })}>Zurücksetzen</button>
)}
<div style={{ marginLeft: "auto", fontSize: 12, color: "var(--text4)" }}>
<strong style={{ color: "var(--text)" }}>{allFiltered.length}</strong> {allFiltered.length === 1 ? "Eintrag" : "Einträge"} · {formatCHF(totalBrutto)}
</div>
</div>
<div className="filter-bar">
<span className="filter-label">GRUPPIEREN:</span>
{[{ id: "date", label: "Monat" }, { id: "category", label: "Kategorie" }].map(g => (
<button key={g.id} className={`pill${groupBy === g.id ? " active" : ""}`} onClick={() => setGroupBy(g.id)}>{g.label}</button>
))}
</div>
{allFiltered.length === 0 ? (
<div className="card" style={{ padding: 0 }}>
<table>
<thead><tr><th style={{ width: 100 }}>Datum</th><th style={{ width: 190 }}>Kategorie</th><th>Beschreibung</th><th style={{ width: 80 }}>Wiederk.</th><th style={{ textAlign: "right", width: 110 }}>Netto</th><th style={{ textAlign: "right", width: 100 }}>Vorsteuer</th><th style={{ textAlign: "right", width: 120 }}>Brutto</th><th style={{ width: 90 }}></th></tr></thead>
<tbody><tr><td colSpan={8} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>{items.length === 0 ? "Noch keine internen Ausgaben" : "Keine Treffer"}</td></tr></tbody>
</table>
</div>
) : (
<div className="card" style={{ padding: 0 }}>
<table>
<thead>
<tr>
<SortTh col="date" style={{ width: 100 }}>Datum</SortTh>
{groupBy !== "category" && <SortTh col="category" style={{ width: 190 }}>Kategorie</SortTh>}
<th>Beschreibung</th>
<th style={{ width: 80 }}>Wiederk.</th>
<SortTh col="amount" style={{ textAlign: "right", width: 110 }}>Netto</SortTh>
<th style={{ textAlign: "right", width: 100 }}>Vorsteuer</th>
<SortTh col="amount" style={{ textAlign: "right", width: 120 }}>Brutto</SortTh>
<th style={{ width: 90 }}></th>
</tr>
</thead>
<tbody>
{groupedData.map(group => {
const gNet = group.items.reduce((s, e) => s + getNet(e), 0);
const gTax = group.items.reduce((s, e) => s + getTax(e), 0);
const gBrutto = group.items.reduce((s, e) => s + (e.amount || 0), 0);
return (
<React.Fragment key={group.key}>
{groupedData.length > 1 && (
<tr style={{ background: "#f5f0e8" }}>
<td colSpan={groupBy === "category" ? 5 : 6} style={{ padding: "6px 12px", fontSize: 11, fontWeight: 600, letterSpacing: "0.04em", color: "#555" }}>
{group.label}
<span style={{ marginLeft: 10, fontWeight: 400, color: "#888", fontSize: 10 }}>{group.items.length} Einträge</span>
</td>
<td style={{ padding: "6px 12px", textAlign: "right", fontSize: 11, fontWeight: 600 }}>{formatCHF(gBrutto)}</td>
<td></td>
</tr>
)}
{group.items.map(e => {
const net = getNet(e);
const tax = getTax(e);
return (
<tr key={e.id}>
<td>{formatDate(e.date)}</td>
{groupBy !== "category" && <td>{e.category}</td>}
<td style={{ color: "#555" }}>{e.description || "—"}</td>
<td style={{ textAlign: "center", fontSize: 11, color: e.recurring ? "#2d6a4f" : "#ccc" }}>
{e.recurring ? <span title={e.recurringInterval || "monatlich"}> {e.recurringInterval || "mtl."}</span> : "—"}
</td>
<td style={{ textAlign: "right" }}>{formatCHF(net)}</td>
<td style={{ textAlign: "right", color: "#888" }}>{e.mwstRate > 0 ? formatCHF(tax) : "—"}</td>
<td style={{ textAlign: "right" }}><strong>{formatCHF(e.amount)}</strong></td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn btn-ghost" style={{ padding: "5px 10px", fontSize: 12, marginRight: 4 }} onClick={() => openEdit(e)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => del(e.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
{groupedData.length > 1 && (
<tr style={{ background: "#faf8f5", borderTop: "1px solid #e0dbd4" }}>
<td colSpan={groupBy === "category" ? 4 : 5} style={{ padding: "5px 12px", fontSize: 11, color: "#888", fontStyle: "italic" }}>Total {group.label}</td>
<td style={{ textAlign: "right", padding: "5px 12px", fontWeight: 600, fontSize: 11 }}>{formatCHF(gNet)}</td>
<td style={{ textAlign: "right", padding: "5px 12px", fontWeight: 600, fontSize: 11, color: "#888" }}>{formatCHF(gTax)}</td>
<td style={{ textAlign: "right", padding: "5px 12px", fontWeight: 700, fontSize: 11 }}>{formatCHF(gBrutto)}</td>
<td></td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
<tfoot>
<tr style={{ borderTop: "2px solid #1a1a18" }}>
<td colSpan={groupBy === "category" ? 4 : 5} style={{ color: "#888", fontSize: 12 }}>
{allFiltered.length} {allFiltered.length === 1 ? "Eintrag" : "Einträge"} {filter.year || ""}
</td>
<td style={{ textAlign: "right", fontWeight: 600 }}>{formatCHF(totalNet)}</td>
<td style={{ textAlign: "right", fontWeight: 600, color: "#888" }}>{formatCHF(totalTax)}</td>
<td style={{ textAlign: "right", fontWeight: 700 }}>{formatCHF(totalBrutto)}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
)}
{modal && (
<Modal title={modal === "new" ? "Ausgabe erfassen" : "Ausgabe bearbeiten"} onClose={closeModal} onSave={save}>
<div className="form-row">
<FormField label="Datum"><DateInput value={form.date} onChange={e => setForm({ ...form, date: e.target.value })} /></FormField>
<FormField label="Kategorie">
<select value={form.category} onChange={e => setForm({ ...form, category: e.target.value })}>
{intCats.map(c => <option key={c}>{c}</option>)}
</select>
</FormField>
</div>
<FormField label="Beschreibung"><input value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="z.B. Jahresmiete Büro, Adobe CC Lizenz…" autoFocus /></FormField>
<div className="form-row">
<FormField label="Betrag CHF"><input type="number" step="0.05" value={form.amount} onChange={e => setForm({ ...form, amount: +e.target.value })} /></FormField>
<FormField label="MWST-Satz %"><input type="number" step="0.1" value={form.mwstRate} onChange={e => setForm({ ...form, mwstRate: +e.target.value })} /></FormField>
<FormField label=" ">
<label style={{ display: "flex", alignItems: "center", gap: 6, height: 36, cursor: "pointer", textTransform: "none", fontSize: 13 }}>
<input type="checkbox" checked={!!form.inclMwst} onChange={e => setForm({ ...form, inclMwst: e.target.checked })} style={{ width: "auto" }} />
Betrag inkl. MWST
</label>
</FormField>
</div>
{form.amount > 0 && (
<div style={{ fontSize: 11, color: "#888", padding: "8px 12px", background: "#faf8f5", borderRadius: 4, marginBottom: 12 }}>
{form.inclMwst
? `Netto: ${formatCHF(form.amount / (1 + (form.mwstRate || 0) / 100))} · MWST: ${formatCHF(form.amount - form.amount / (1 + (form.mwstRate || 0) / 100))}`
: `Brutto inkl. ${form.mwstRate}% MWST: ${formatCHF(form.amount * (1 + (form.mwstRate || 0) / 100))}`}
</div>
)}
<div style={{ borderTop: "1px solid var(--border2)", paddingTop: 12, marginTop: 4 }}>
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", textTransform: "none", fontSize: 13, marginBottom: 10 }}>
<input type="checkbox" checked={!!form.recurring} onChange={e => setForm({ ...form, recurring: e.target.checked })} style={{ width: "auto" }} />
Wiederkehrende Ausgabe
</label>
{form.recurring && (
<FormField label="Intervall">
<select value={form.recurringInterval || "monatlich"} onChange={e => setForm({ ...form, recurringInterval: e.target.value })}>
<option value="monatlich">Monatlich</option>
<option value="quartalsweise">Quartalsweise</option>
<option value="jährlich">Jährlich</option>
</select>
</FormField>
)}
</div>
</Modal>
)}
</div>
);
}
+847
View File
@@ -0,0 +1,847 @@
import React, { useState } from "react";
import { generateId, formatCHF, calcLohn } from "../utils.js";
import { Header, FormField } from "../components/UI.jsx";
export default
function StudioBudget({ data, update, setView, setPrintContent }) {
const employees = data.employees || [];
const internalExpenses = data.internalExpenses || [];
const currentYear = new Date().getFullYear();
const saved = data.settings.studioBudget || {};
// ── Hilfsfunktion: Auto-Werte für einen MA berechnen ──────────
const calcEmpAuto = (emp, agZuschlag) => {
const pensum = (emp.pensum || 100) / 100;
const brutto12 = (emp.monatslohn || 0) * 12;
const brutto13 = emp.dreizehnterLohn ? brutto12 + (emp.monatslohn || 0) : brutto12;
const agAnteil = brutto13 * ((agZuschlag || 20) / 100);
const totalKosten = brutto13 + agAnteil;
const ferienWochen = emp.ferienWochen || data.settings.defaultFerienWochen || 5;
const wochenstunden = (emp.wochenstunden || data.settings.defaultWochenstunden || 35) * pensum;
const nettoWochen = 52 - ferienWochen - 1.5;
const jahresStunden = Math.round(wochenstunden * nettoWochen);
return { brutto13, agAnteil, totalKosten, jahresStunden };
};
// ── Hilfsfunktion: Auto-Werte für interne Ausgaben ───────────
const calcFixAuto = () => {
const rows = [];
internalExpenses.forEach(e => {
const year = (e.date || "").slice(0, 4);
const net = e.inclMwst ? e.amount / (1 + (e.mwstRate || 0) / 100) : e.amount;
let annualized = net;
if (e.recurring) {
if (e.recurringInterval === "monatlich") annualized = net * 12;
else if (e.recurringInterval === "quartalsweise") annualized = net * 4;
} else if (year !== String(currentYear)) return;
rows.push({ id: e.id, label: e.description || e.category, cat: e.category, amount: annualized });
});
return rows;
};
// ── State: ein Eintrag pro MA, pro Fixkostenposten, plus globale Parameter ──
const initState = () => {
const sv = saved;
const agZuschlag = sv.agZuschlag ?? 20;
// Mitarbeiter-Zeilen
const empRows = employees.map(emp => {
const auto = calcEmpAuto(emp, agZuschlag);
const saved_emp = (sv.empRows || []).find(r => r.id === emp.id) || {};
return {
id: emp.id,
aktiv: saved_emp.aktiv ?? true,
kostenOverride: saved_emp.kostenOverride ?? false,
kostenManual: saved_emp.kostenManual ?? auto.totalKosten,
stundenOverride: saved_emp.stundenOverride ?? false,
stundenManual: saved_emp.stundenManual ?? auto.jahresStunden,
};
});
// Fixkosten-Zeilen (aus internen Ausgaben)
const autoFix = calcFixAuto();
const fixRows = autoFix.map(f => {
const saved_fix = (sv.fixRows || []).find(r => r.id === f.id) || {};
return {
id: f.id,
aktiv: saved_fix.aktiv ?? true,
amountOverride: saved_fix.amountOverride ?? false,
amountManual: saved_fix.amountManual ?? f.amount,
};
});
// Zusätzliche manuelle Fixkostenposten
const extraRows = (sv.extraRows || []);
// Manuelle Einnahmen + Rechnungs-Toggles
const extraIncome = sv.extraIncome || [];
return {
agZuschlag,
empRows,
fixRows,
extraRows, // { id, label, amount, aktiv }
extraIncome, // { id, label, amount, aktiv }
inkludiereRechnungen: sv.inkludiereRechnungen ?? true,
inkludiereEntwuerfe: sv.inkludiereEntwuerfe ?? true,
inkludiereOfferten: sv.inkludiereOfferten ?? false,
inkludiereOffertEntwuerfe: sv.inkludiereOffertEntwuerfe ?? false,
reserve: sv.reserve ?? 10,
produktivQuote: sv.produktivQuote ?? 70,
zielMarge: sv.zielMarge ?? 25,
rateOverride: sv.rateOverride ?? false,
rateManual: sv.rateManual ?? (data.settings.defaultHourlyRate || 120),
};
};
const [b, setB] = useState(initState);
const [newExtra, setNewExtra] = useState({ label: "", amount: 0 });
const [newIncome, setNewIncome] = useState({ label: "", amount: 0 });
const setField = (k, v) => setB(prev => ({ ...prev, [k]: v }));
const setEmpRow = (id, changes) => setB(prev => ({
...prev,
empRows: prev.empRows.map(r => r.id === id ? { ...r, ...changes } : r),
}));
const setFixRow = (id, changes) => setB(prev => ({
...prev,
fixRows: prev.fixRows.map(r => r.id === id ? { ...r, ...changes } : r),
}));
const addExtra = () => {
if (!newExtra.label.trim() || !newExtra.amount) return;
setB(prev => ({ ...prev, extraRows: [...prev.extraRows, { id: generateId(), label: newExtra.label, amount: newExtra.amount, aktiv: true }] }));
setNewExtra({ label: "", amount: 0 });
};
const setExtraRow = (id, changes) => setB(prev => ({
...prev,
extraRows: prev.extraRows.map(r => r.id === id ? { ...r, ...changes } : r),
}));
const delExtra = (id) => setB(prev => ({ ...prev, extraRows: prev.extraRows.filter(r => r.id !== id) }));
const addIncome = () => {
if (!newIncome.label.trim() || !newIncome.amount) return;
setB(prev => ({ ...prev, extraIncome: [...(prev.extraIncome || []), { id: generateId(), label: newIncome.label, amount: newIncome.amount, aktiv: true }] }));
setNewIncome({ label: "", amount: 0 });
};
const setIncomeRow = (id, changes) => setB(prev => ({
...prev,
extraIncome: (prev.extraIncome || []).map(r => r.id === id ? { ...r, ...changes } : r),
}));
const delIncome = (id) => setB(prev => ({ ...prev, extraIncome: (prev.extraIncome || []).filter(r => r.id !== id) }));
// ── Berechnungen ──────────────────────────────────────────────
const autoFix = calcFixAuto();
const empCalcRows = employees.map(emp => {
const auto = calcEmpAuto(emp, b.agZuschlag);
const row = b.empRows.find(r => r.id === emp.id) || { aktiv: true, kostenOverride: false, kostenManual: 0, stundenOverride: false, stundenManual: 0 };
const kosten = row.kostenOverride ? (row.kostenManual || 0) : auto.totalKosten;
const stunden = row.stundenOverride ? (row.stundenManual || 0) : auto.jahresStunden;
return { emp, auto, row, kosten, stunden, aktiv: row.aktiv };
});
const personalKosten = empCalcRows.filter(r => r.aktiv).reduce((s, r) => s + r.kosten, 0);
const jahresStundenTotal = empCalcRows.filter(r => r.aktiv).reduce((s, r) => s + r.stunden, 0);
const jahresStundenFallback = employees.length === 0 ? 1800 : jahresStundenTotal;
const fixCalcRows = autoFix.map(f => {
const row = b.fixRows.find(r => r.id === f.id) || { aktiv: true, amountOverride: false, amountManual: f.amount };
const amount = row.amountOverride ? (row.amountManual || 0) : f.amount;
return { ...f, row, amount, aktiv: row.aktiv };
});
const fixKosten = fixCalcRows.filter(r => r.aktiv).reduce((s, r) => s + r.amount, 0);
const extraKosten = b.extraRows.filter(r => r.aktiv).reduce((s, r) => s + (r.amount || 0), 0);
const basisKosten = personalKosten + fixKosten + extraKosten;
const reserve = basisKosten * ((b.reserve || 0) / 100);
const gesamtKosten = basisKosten + reserve;
const produktivStunden = Math.round(jahresStundenFallback * ((b.produktivQuote || 70) / 100));
const selbstkosten = produktivStunden > 0 ? gesamtKosten / produktivStunden : 0;
const zielHonorar = selbstkosten > 0 ? selbstkosten / (1 - (b.zielMarge || 25) / 100) : 0;
const defaultRate = data.settings.defaultHourlyRate || 120;
const currentRate = b.rateOverride ? (b.rateManual || defaultRate) : defaultRate;
// ── Einnahmen ─────────────────────────────────────────────────
const yearInvoices = (data.invoices || []).filter(i => (i.date || "").startsWith(String(currentYear)));
const paidRevenue = yearInvoices.filter(i => i.status === "bezahlt").reduce((s, i) => s + (i.sub || 0), 0);
const sentRevenue = yearInvoices.filter(i => i.status === "gesendet" || i.status === "überfällig").reduce((s, i) => s + (i.sub || 0), 0);
const draftRevenue = yearInvoices.filter(i => i.status === "entwurf").reduce((s, i) => s + (i.sub || 0), 0);
const invoiceAutoRevenue = b.inkludiereRechnungen
? paidRevenue + sentRevenue + (b.inkludiereEntwuerfe ? draftRevenue : 0)
: 0;
const yearQuotes = (data.quotes || []).filter(q => (q.date || "").startsWith(String(currentYear)));
const acceptedQuoteRevenue = yearQuotes.filter(q => q.status === "angenommen").reduce((s, q) => s + (q.sub || 0), 0);
const sentQuoteRevenue = yearQuotes.filter(q => q.status === "gesendet").reduce((s, q) => s + (q.sub || 0), 0);
const draftQuoteRevenue = yearQuotes.filter(q => q.status === "entwurf").reduce((s, q) => s + (q.sub || 0), 0);
const quoteAutoRevenue = b.inkludiereOfferten
? acceptedQuoteRevenue + sentQuoteRevenue + (b.inkludiereOffertEntwuerfe ? draftQuoteRevenue : 0)
: 0;
const autoRevenue = invoiceAutoRevenue + quoteAutoRevenue;
const manualIncomeTotal = (b.extraIncome || []).filter(r => r.aktiv).reduce((s, r) => s + (r.amount || 0), 0);
const totalRevenue = autoRevenue + manualIncomeTotal;
const ergebnis = totalRevenue - gesamtKosten;
// ── IST-Daten aus Zeiterfassung ───────────────────────────────
const NON_BILLING_CATEGORIES = ["Wettbewerb"];
const projects = data.projects || [];
const wbProjIds = new Set(projects.filter(p => NON_BILLING_CATEGORIES.includes(p.category)).map(p => p.id));
const yearEntries = (data.timeEntries || []).filter(e => (e.date || "").startsWith(String(currentYear)));
const totalTrackedMins = yearEntries.reduce((s, e) => s + (e.minutes || 0), 0);
const billingMins = yearEntries.filter(e => e.projectId && !wbProjIds.has(e.projectId)).reduce((s, e) => s + (e.minutes || 0), 0);
const wbTotalMins = yearEntries.filter(e => wbProjIds.has(e.projectId)).reduce((s, e) => s + (e.minutes || 0), 0);
const noProjectMins = yearEntries.filter(e => !e.projectId).reduce((s, e) => s + (e.minutes || 0), 0);
const istProduktivQuote = totalTrackedMins > 0 ? (billingMins / totalTrackedMins) * 100 : null;
const wbProjects = projects.filter(p => NON_BILLING_CATEGORIES.includes(p.category));
const wbRows = wbProjects.map(p => {
const mins = yearEntries.filter(e => e.projectId === p.id).reduce((s, e) => s + (e.minutes || 0), 0);
return { id: p.id, name: p.name, mins, hours: mins / 60, cost: (mins / 60) * selbstkosten };
}).filter(r => r.mins > 0).sort((a, b) => b.mins - a.mins);
const wbTotalHours = wbTotalMins / 60;
const rateDiff = currentRate - zielHonorar;
const rateOk = rateDiff >= 0;
// ── Versionen ────────────────────────────────────────────────
const versions = data.settings.studioBudgetVersions || [];
const [showVersions, setShowVersions] = useState(false);
const [saveName, setSaveName] = useState("");
const buildSnapshot = (name) => ({
id: generateId(),
name: name || `Budget ${new Date().toLocaleDateString("de-CH")}`,
savedAt: new Date().toISOString(),
b: { agZuschlag: b.agZuschlag, empRows: b.empRows, fixRows: b.fixRows, extraRows: b.extraRows, extraIncome: b.extraIncome, inkludiereRechnungen: b.inkludiereRechnungen, inkludiereEntwuerfe: b.inkludiereEntwuerfe, inkludiereOfferten: b.inkludiereOfferten, inkludiereOffertEntwuerfe: b.inkludiereOffertEntwuerfe, reserve: b.reserve, produktivQuote: b.produktivQuote, zielMarge: b.zielMarge },
results: { personalKosten, fixKosten, extraKosten, basisKosten, reserve: basisKosten * ((b.reserve || 0) / 100), gesamtKosten, totalRevenue, ergebnis, jahresStundenTotal: jahresStundenFallback, produktivStunden, selbstkosten, zielHonorar, currentRate },
empSnapshot: empCalcRows.map(r => ({ name: r.emp.name, kosten: r.kosten, stunden: r.stunden, aktiv: r.aktiv })),
fixSnapshot: [...fixCalcRows.filter(r => r.aktiv).map(r => ({ label: r.label, amount: r.amount })), ...b.extraRows.filter(r => r.aktiv).map(r => ({ label: r.label, amount: r.amount }))],
});
const saveVersion = () => {
const name = saveName.trim() || `Budget ${new Date().toLocaleDateString("de-CH")}`;
const snap = buildSnapshot(name);
update("settings", { ...data.settings, studioBudgetVersions: [...versions, snap], studioBudget: { agZuschlag: b.agZuschlag, empRows: b.empRows, fixRows: b.fixRows, extraRows: b.extraRows, reserve: b.reserve, produktivQuote: b.produktivQuote, zielMarge: b.zielMarge } });
setSaveName("");
};
const loadVersion = (v) => {
setB(prev => ({ ...prev, ...v.b }));
setShowVersions(false);
};
const deleteVersion = (id) => {
update("settings", { ...data.settings, studioBudgetVersions: versions.filter(v => v.id !== id) });
};
const saveSettings = () => {
update("settings", { ...data.settings, studioBudget: {
agZuschlag: b.agZuschlag,
empRows: b.empRows,
fixRows: b.fixRows,
extraRows: b.extraRows,
extraIncome: b.extraIncome,
inkludiereRechnungen: b.inkludiereRechnungen,
inkludiereEntwuerfe: b.inkludiereEntwuerfe,
inkludiereOfferten: b.inkludiereOfferten,
inkludiereOffertEntwuerfe: b.inkludiereOffertEntwuerfe,
reserve: b.reserve,
produktivQuote: b.produktivQuote,
zielMarge: b.zielMarge,
rateOverride: b.rateOverride,
rateManual: b.rateManual,
}});
};
// ── Kleines Inline-Override-Feld ─────────────────────────────
const OverrideInput = ({ label, aktiv, checked, onToggle, value, onChange, autoVal, unit = "CHF", step = 100 }) => (
<div style={{ display: "flex", alignItems: "center", gap: 6, opacity: aktiv ? 1 : 0.4 }}>
<label style={{ display: "flex", alignItems: "center", gap: 4, textTransform: "none", fontSize: 11, color: "var(--text4)", whiteSpace: "nowrap", cursor: "pointer" }}>
<input type="checkbox" checked={checked} onChange={e => onToggle(e.target.checked)} style={{ width: "auto" }} disabled={!aktiv} />
übersteuern
</label>
{checked && aktiv ? (
<input type="number" step={step} value={value} onChange={e => onChange(+e.target.value)}
style={{ width: 110, height: 28, fontSize: 12, padding: "0 8px" }} />
) : (
<span style={{ fontSize: 12, color: "var(--text3)" }}>{unit === "CHF" ? formatCHF(autoVal) : `${autoVal}h`}</span>
)}
</div>
);
const SectionLabel = ({ children }) => (
<div className="section-label">{children}</div>
);
return (
<div>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
</div>
<Header title="Budget" action={
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<button className="btn btn-ghost" onClick={() => setShowVersions(v => !v)}>
Versionen {versions.length > 0 && <span style={{ marginLeft: 4, background: "#b07848", color: "#1a1a18", borderRadius: 10, fontSize: 10, padding: "1px 6px", fontWeight: 700 }}>{versions.length}</span>}
</button>
<button className="btn btn-ghost" onClick={() => setPrintContent({ type: "studioBudget", snapshot: buildSnapshot("Aktuell"), settings: data.settings })}>PDF</button>
<button className="btn btn-primary" onClick={saveSettings}>Speichern</button>
</div>
} />
{/* Versions-Panel */}
{showVersions && (
<div className="card" style={{ marginBottom: 20, borderLeft: "4px solid #b07848" }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "var(--text4)", marginBottom: 14 }}>VERSIONEN</div>
{/* Neue Version speichern */}
<div style={{ display: "flex", gap: 8, marginBottom: 16, alignItems: "center" }}>
<input
value={saveName}
onChange={e => setSaveName(e.target.value)}
placeholder={`z.B. Budget ${new Date().getFullYear()}, Szenario A…`}
style={{ flex: 1, height: 34, fontSize: 12 }}
onKeyDown={e => e.key === "Enter" && saveVersion()}
/>
<button className="btn btn-primary" style={{ whiteSpace: "nowrap" }} onClick={saveVersion}>
+ Als Version speichern
</button>
</div>
{versions.length === 0 ? (
<div style={{ fontSize: 12, color: "var(--text4)" }}>Noch keine Versionen gespeichert.</div>
) : (
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "left", padding: "4px 8px 8px 0", borderBottom: "1px solid var(--border)" }}>Name</th>
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 8px 8px", borderBottom: "1px solid var(--border)" }}>Selbstkosten/h</th>
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 8px 8px", borderBottom: "1px solid var(--border)" }}>Ziel-Honorar/h</th>
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 8px 8px", borderBottom: "1px solid var(--border)" }}>Gesamtkosten</th>
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 0 8px 8px", borderBottom: "1px solid var(--border)" }}>Gespeichert</th>
<th style={{ borderBottom: "1px solid var(--border)", width: 110 }}></th>
</tr>
</thead>
<tbody>
{[...versions].reverse().map(v => (
<tr key={v.id}>
<td style={{ padding: "8px 8px 8px 0", fontWeight: 500, fontSize: 13, borderBottom: "1px solid var(--border2)" }}>{v.name}</td>
<td style={{ padding: "8px", textAlign: "right", fontSize: 12, borderBottom: "1px solid var(--border2)" }}>{formatCHF(Math.round(v.results?.selbstkosten || 0))}</td>
<td style={{ padding: "8px", textAlign: "right", fontSize: 12, fontWeight: 600, color: "#b07848", borderBottom: "1px solid var(--border2)" }}>{formatCHF(Math.round(v.results?.zielHonorar || 0))}</td>
<td style={{ padding: "8px", textAlign: "right", fontSize: 12, borderBottom: "1px solid var(--border2)" }}>{formatCHF(Math.round(v.results?.gesamtKosten || 0))}</td>
<td style={{ padding: "8px 0 8px 8px", textAlign: "right", fontSize: 11, color: "var(--text4)", borderBottom: "1px solid var(--border2)" }}>
{new Date(v.savedAt).toLocaleDateString("de-CH")}
</td>
<td style={{ padding: "8px 0", textAlign: "right", whiteSpace: "nowrap", borderBottom: "1px solid var(--border2)" }}>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 8px", marginRight: 4 }}
onClick={() => setPrintContent({ type: "studioBudget", snapshot: v, settings: data.settings })}>PDF</button>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 8px", marginRight: 4 }}
onClick={() => loadVersion(v)}>Laden</button>
<button className="btn btn-danger" style={{ fontSize: 11, padding: "4px 8px" }}
onClick={() => deleteVersion(v.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{/* ── ANALYSE — full width, ganz oben ── */}
<div className="card" style={{ borderTop: `3px solid ${rateOk ? "#2d6a4f" : "#8a1a1a"}`, marginBottom: 20 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr auto", gap: 0, alignItems: "stretch" }}>
<div style={{ padding: "14px 20px 14px 0", borderRight: "1px solid var(--border2)" }}>
<div style={{ fontSize: 10, color: "var(--text4)", letterSpacing: "0.1em", marginBottom: 8 }}>SELBSTKOSTENSATZ</div>
<div style={{ fontSize: 32, fontFamily: "'Playfair Display', serif", fontWeight: 700, lineHeight: 1 }}>{formatCHF(Math.round(selbstkosten))}</div>
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 6 }}>pro produktive Stunde</div>
</div>
<div style={{ padding: "14px 20px", borderRight: "1px solid var(--border2)" }}>
<div style={{ fontSize: 10, color: "var(--text4)", letterSpacing: "0.1em", marginBottom: 8 }}>ZIEL-HONORAR</div>
<div style={{ fontSize: 32, fontFamily: "'Playfair Display', serif", fontWeight: 700, color: "#b07848", lineHeight: 1 }}>{formatCHF(Math.round(zielHonorar))}</div>
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 6 }}>inkl. {b.zielMarge}% Marge</div>
</div>
<div style={{ padding: "14px 20px", borderRight: "1px solid var(--border2)" }}>
<div style={{ fontSize: 10, color: "var(--text4)", letterSpacing: "0.1em", marginBottom: 8 }}>GESAMTKOSTEN / JAHR</div>
<div style={{ fontSize: 32, fontFamily: "'Playfair Display', serif", fontWeight: 700, lineHeight: 1 }}>{formatCHF(gesamtKosten)}</div>
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 6 }}>{jahresStundenFallback}h verfügbar · {produktivStunden}h produktiv</div>
</div>
<div style={{ padding: "14px 0 14px 20px", display: "flex", flexDirection: "column", justifyContent: "center", minWidth: 180 }}>
<div style={{ padding: "10px 14px", borderRadius: 6, border: `1.5px solid ${rateOk ? "#b8dcc8" : "#e8b0b0"}`, background: rateOk ? "#f0f8f4" : "#fdf2f2" }}>
<div style={{ fontSize: 11, fontWeight: 600, color: rateOk ? "#2d6a4f" : "#8a1a1a", marginBottom: 4 }}>
{rateOk ? "✓ Ansatz ausreichend" : "⚠ Ansatz zu tief"}
</div>
<div style={{ fontSize: 20, fontWeight: 700, fontFamily: "'Playfair Display', serif", color: rateOk ? "#2d6a4f" : "#8a1a1a" }}>
{rateOk ? "+" : ""}{formatCHF(Math.round(rateDiff))}
</div>
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 3 }}>Aktuell {formatCHF(currentRate)}/h</div>
</div>
{selbstkosten > 0 && (
<div style={{ marginTop: 10 }}>
<div style={{ height: 4, background: "var(--border)", borderRadius: 2, overflow: "hidden" }}>
<div style={{ width: `${Math.min((produktivStunden / Math.ceil(gesamtKosten / currentRate)) * 100, 100)}%`, height: "100%", background: rateOk ? "#2d6a4f" : "#b5621e", borderRadius: 2 }} />
</div>
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 3 }}>Break-even: {Math.ceil(gesamtKosten / currentRate)}h</div>
</div>
)}
</div>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "3fr 2fr", gap: 20, alignItems: "start" }}>
{/* ── LINKE SPALTE ── */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Personalkosten */}
<div className="card">
<SectionLabel>PERSONALKOSTEN / JAHR</SectionLabel>
<div style={{ marginBottom: 12 }}>
<FormField label="AG-Sozialleistungen %">
<input type="number" step="0.5" min={0} max={50} value={b.agZuschlag}
onChange={e => { const v = +e.target.value; setField("agZuschlag", v); setB(prev => ({ ...prev, agZuschlag: v })); }} />
<div style={{ fontSize: 11, color: "var(--text4)", marginTop: 3 }}>AHV, UVG, BVG, KTG AG-Anteil (typisch 1822%)</div>
</FormField>
</div>
{employees.length === 0 ? (
<div style={{ fontSize: 12, color: "var(--text4)", padding: "8px 0" }}>Keine Mitarbeiter erfasst unter «Mitarbeiter» Löhne hinterlegen.</div>
) : (
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 4 }}>
<thead>
<tr>
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "left", padding: "4px 6px 4px 0", borderBottom: "1px solid var(--border)" }}>Mitarbeiter</th>
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 6px", borderBottom: "1px solid var(--border)" }}>Kosten/Jahr</th>
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 0 4px 6px", borderBottom: "1px solid var(--border)" }}>Jahresstunden</th>
</tr>
</thead>
<tbody>
{empCalcRows.map(({ emp, auto, row, kosten, stunden, aktiv }) => (
<tr key={emp.id} style={{ opacity: aktiv ? 1 : 0.45 }}>
<td style={{ padding: "8px 6px 8px 0", borderBottom: "1px solid var(--border2)", verticalAlign: "top" }}>
<label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer", textTransform: "none", fontSize: 12 }}>
<input type="checkbox" checked={aktiv} onChange={e => setEmpRow(emp.id, { aktiv: e.target.checked })} style={{ width: "auto" }} />
<div>
<div style={{ fontWeight: 500 }}>{emp.name}</div>
<div style={{ fontSize: 10, color: "var(--text4)" }}>{emp.pensum || 100}% · {emp.dreizehnterLohn ? "13 Mt." : "12 Mt."}</div>
</div>
</label>
</td>
<td style={{ padding: "8px 6px", borderBottom: "1px solid var(--border2)", verticalAlign: "top", textAlign: "right" }}>
<OverrideInput aktiv={aktiv} checked={row.kostenOverride} onToggle={v => setEmpRow(emp.id, { kostenOverride: v })}
value={row.kostenManual} onChange={v => setEmpRow(emp.id, { kostenManual: v })} autoVal={auto.totalKosten} step={500} />
</td>
<td style={{ padding: "8px 0 8px 6px", borderBottom: "1px solid var(--border2)", verticalAlign: "top", textAlign: "right" }}>
<OverrideInput aktiv={aktiv} checked={row.stundenOverride} onToggle={v => setEmpRow(emp.id, { stundenOverride: v })}
value={row.stundenManual} onChange={v => setEmpRow(emp.id, { stundenManual: v })} autoVal={auto.jahresStunden} unit="h" step={10} />
</td>
</tr>
))}
</tbody>
</table>
)}
<div style={{ display: "flex", justifyContent: "space-between", fontWeight: 600, fontSize: 13, paddingTop: 8, borderTop: "1.5px solid var(--border)" }}>
<span>Total Personalkosten</span>
<span style={{ fontFamily: "'Playfair Display', serif" }}>{formatCHF(personalKosten)}</span>
</div>
</div>
{/* Fixkosten */}
<div className="card">
<SectionLabel>FIXKOSTEN / JAHR</SectionLabel>
{fixCalcRows.length === 0 && b.extraRows.length === 0 ? (
<div style={{ fontSize: 12, color: "var(--text4)", marginBottom: 12 }}>
Keine internen Ausgaben erfasst unter «Interne Ausgaben» hinterlegen oder manuell unten hinzufügen.
</div>
) : (
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 12 }}>
<thead>
<tr>
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "left", padding: "4px 6px 4px 0", borderBottom: "1px solid var(--border)" }}>Posten</th>
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 0", borderBottom: "1px solid var(--border)" }}>CHF/Jahr</th>
</tr>
</thead>
<tbody>
{fixCalcRows.map(f => (
<tr key={f.id} style={{ opacity: f.aktiv ? 1 : 0.45 }}>
<td style={{ padding: "7px 6px 7px 0", borderBottom: "1px solid var(--border2)", verticalAlign: "middle" }}>
<label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer", textTransform: "none", fontSize: 12 }}>
<input type="checkbox" checked={f.aktiv} onChange={e => setFixRow(f.id, { aktiv: e.target.checked })} style={{ width: "auto" }} />
<div>
<div style={{ fontWeight: 500 }}>{f.label}</div>
<div style={{ fontSize: 10, color: "var(--text4)" }}>{f.cat}</div>
</div>
</label>
</td>
<td style={{ padding: "7px 0 7px 6px", borderBottom: "1px solid var(--border2)", textAlign: "right" }}>
<OverrideInput aktiv={f.aktiv} checked={f.row.amountOverride} onToggle={v => setFixRow(f.id, { amountOverride: v })}
value={f.row.amountManual} onChange={v => setFixRow(f.id, { amountManual: v })} autoVal={f.amount} step={100} />
</td>
</tr>
))}
{b.extraRows.map(r => (
<tr key={r.id} style={{ opacity: r.aktiv ? 1 : 0.45 }}>
<td style={{ padding: "7px 6px 7px 0", borderBottom: "1px solid var(--border2)" }}>
<label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer", textTransform: "none", fontSize: 12 }}>
<input type="checkbox" checked={r.aktiv} onChange={e => setExtraRow(r.id, { aktiv: e.target.checked })} style={{ width: "auto" }} />
<span style={{ fontWeight: 500, fontStyle: "italic", color: "var(--text3)" }}>{r.label}</span>
</label>
</td>
<td style={{ padding: "7px 0 7px 6px", borderBottom: "1px solid var(--border2)", textAlign: "right" }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, justifyContent: "flex-end" }}>
<input type="number" step="100" value={r.amount} onChange={e => setExtraRow(r.id, { amount: +e.target.value })}
style={{ width: 110, height: 28, fontSize: 12, padding: "0 8px" }} />
<button onClick={() => delExtra(r.id)} style={{ background: "none", border: "none", color: "var(--text4)", cursor: "pointer", fontSize: 14, padding: 0 }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Neuen Posten hinzufügen */}
<div style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 12 }}>
<input value={newExtra.label} onChange={e => setNewExtra(p => ({ ...p, label: e.target.value }))}
placeholder="Bezeichnung (z.B. Buchführung)" style={{ flex: 1, height: 32, fontSize: 12 }} />
<input type="number" step="100" value={newExtra.amount || ""} onChange={e => setNewExtra(p => ({ ...p, amount: +e.target.value }))}
placeholder="CHF/Jahr" style={{ width: 100, height: 32, fontSize: 12 }} />
<button className="btn btn-ghost" style={{ height: 32, padding: "0 12px", fontSize: 12, whiteSpace: "nowrap" }} onClick={addExtra}>+ Hinzufügen</button>
</div>
<div style={{ display: "flex", justifyContent: "space-between", fontWeight: 600, fontSize: 13, paddingTop: 8, borderTop: "1.5px solid var(--border)" }}>
<span>Total Fixkosten</span>
<span style={{ fontFamily: "'Playfair Display', serif" }}>{formatCHF(fixKosten + extraKosten)}</span>
</div>
</div>
{/* Einnahmen */}
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<SectionLabel>EINNAHMEN / JAHR</SectionLabel>
</div>
<div style={{ display: "flex", gap: 12, marginBottom: 4, flexWrap: "wrap" }}>
<label style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11, color: "var(--text3)", cursor: "pointer", textTransform: "none" }}>
<input type="checkbox" checked={b.inkludiereRechnungen} onChange={e => setField("inkludiereRechnungen", e.target.checked)} style={{ width: "auto" }} />
Rechnungen aus Buchhaltung
</label>
<label style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11, color: "var(--text3)", cursor: "pointer", textTransform: "none" }}>
<input type="checkbox" checked={b.inkludiereOfferten} onChange={e => setField("inkludiereOfferten", e.target.checked)} style={{ width: "auto" }} />
Offerten
</label>
</div>
{b.inkludiereRechnungen && (
<div style={{ marginBottom: 14, padding: "10px 12px", background: "var(--surface2)", borderRadius: 6 }}>
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "var(--text4)", marginBottom: 8 }}>AUS RECHNUNGEN {currentYear}</div>
{[
{ label: "Bezahlt", value: paidRevenue, color: "#2d6a4f" },
{ label: "Versendet / Überfällig", value: sentRevenue, color: "#b07848" },
].map(({ label, value, color }) => value > 0 && (
<div key={label} style={{ display: "flex", justifyContent: "space-between", fontSize: 12, marginBottom: 4 }}>
<span style={{ color: "var(--text3)" }}>{label}</span>
<span style={{ fontWeight: 500, color }}>{formatCHF(value)}</span>
</div>
))}
{draftRevenue > 0 && (
<div style={{ marginTop: 6, paddingTop: 6, borderTop: "1px solid var(--border2)" }}>
<label style={{ display: "flex", alignItems: "center", justifyContent: "space-between", cursor: "pointer", textTransform: "none" }}>
<span style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11, color: "var(--text3)" }}>
<input type="checkbox" checked={b.inkludiereEntwuerfe} onChange={e => setField("inkludiereEntwuerfe", e.target.checked)} style={{ width: "auto" }} />
Entwürfe einbeziehen
</span>
<span style={{ fontSize: 12, fontWeight: 500, color: b.inkludiereEntwuerfe ? "#b5621e" : "var(--text4)", fontStyle: b.inkludiereEntwuerfe ? "normal" : "italic" }}>
{b.inkludiereEntwuerfe ? formatCHF(draftRevenue) : "—"}
{b.inkludiereEntwuerfe && <span style={{ fontSize: 10, color: "var(--text4)", marginLeft: 4 }}>erwartet</span>}
</span>
</label>
</div>
)}
{paidRevenue === 0 && sentRevenue === 0 && draftRevenue === 0 && (
<div style={{ fontSize: 11, color: "var(--text4)" }}>Keine Rechnungen in {currentYear} gefunden.</div>
)}
</div>
)}
{b.inkludiereOfferten && (
<div style={{ marginBottom: 14, padding: "10px 12px", background: "var(--surface2)", borderRadius: 6 }}>
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "var(--text4)", marginBottom: 8 }}>AUS OFFERTEN {currentYear}</div>
{[
{ label: "Angenommen", value: acceptedQuoteRevenue, color: "#2d6a4f" },
{ label: "Gesendet", value: sentQuoteRevenue, color: "#b07848" },
].map(({ label, value, color }) => value > 0 && (
<div key={label} style={{ display: "flex", justifyContent: "space-between", fontSize: 12, marginBottom: 4 }}>
<span style={{ color: "var(--text3)" }}>{label}</span>
<span style={{ fontWeight: 500, color }}>{formatCHF(value)}</span>
</div>
))}
{draftQuoteRevenue > 0 && (
<div style={{ marginTop: 6, paddingTop: 6, borderTop: "1px solid var(--border2)" }}>
<label style={{ display: "flex", alignItems: "center", justifyContent: "space-between", cursor: "pointer", textTransform: "none" }}>
<span style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11, color: "var(--text3)" }}>
<input type="checkbox" checked={b.inkludiereOffertEntwuerfe} onChange={e => setField("inkludiereOffertEntwuerfe", e.target.checked)} style={{ width: "auto" }} />
Entwürfe einbeziehen
</span>
<span style={{ fontSize: 12, fontWeight: 500, color: b.inkludiereOffertEntwuerfe ? "#b5621e" : "var(--text4)", fontStyle: b.inkludiereOffertEntwuerfe ? "normal" : "italic" }}>
{b.inkludiereOffertEntwuerfe ? formatCHF(draftQuoteRevenue) : "—"}
{b.inkludiereOffertEntwuerfe && <span style={{ fontSize: 10, color: "var(--text4)", marginLeft: 4 }}>erwartet</span>}
</span>
</label>
</div>
)}
{acceptedQuoteRevenue === 0 && sentQuoteRevenue === 0 && draftQuoteRevenue === 0 && (
<div style={{ fontSize: 11, color: "var(--text4)" }}>Keine Offerten in {currentYear} gefunden.</div>
)}
</div>
)}
{(b.extraIncome || []).length > 0 && (
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 12 }}>
<tbody>
{(b.extraIncome || []).map(r => (
<tr key={r.id} style={{ opacity: r.aktiv ? 1 : 0.45 }}>
<td style={{ padding: "7px 6px 7px 0", borderBottom: "1px solid var(--border2)" }}>
<label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer", textTransform: "none", fontSize: 12 }}>
<input type="checkbox" checked={r.aktiv} onChange={e => setIncomeRow(r.id, { aktiv: e.target.checked })} style={{ width: "auto" }} />
<span style={{ fontWeight: 500, fontStyle: "italic", color: "var(--text3)" }}>{r.label}</span>
</label>
</td>
<td style={{ padding: "7px 0 7px 6px", borderBottom: "1px solid var(--border2)", textAlign: "right" }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, justifyContent: "flex-end" }}>
<input type="number" step="100" value={r.amount} onChange={e => setIncomeRow(r.id, { amount: +e.target.value })}
style={{ width: 110, height: 28, fontSize: 12, padding: "0 8px" }} />
<button onClick={() => delIncome(r.id)} style={{ background: "none", border: "none", color: "var(--text4)", cursor: "pointer", fontSize: 14, padding: 0 }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
<div style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 12 }}>
<input value={newIncome.label} onChange={e => setNewIncome(p => ({ ...p, label: e.target.value }))}
placeholder="z.B. Subvention, Förderung…" style={{ flex: 1, height: 32, fontSize: 12 }}
onKeyDown={e => e.key === "Enter" && addIncome()} />
<input type="number" step="100" value={newIncome.amount || ""} onChange={e => setNewIncome(p => ({ ...p, amount: +e.target.value }))}
placeholder="CHF/Jahr" style={{ width: 100, height: 32, fontSize: 12 }} />
<button className="btn btn-ghost" style={{ height: 32, padding: "0 12px", fontSize: 12, whiteSpace: "nowrap" }} onClick={addIncome}>+ Hinzufügen</button>
</div>
<div style={{ display: "flex", justifyContent: "space-between", fontWeight: 600, fontSize: 13, paddingTop: 8, borderTop: "1.5px solid var(--border)" }}>
<span>Total Einnahmen</span>
<span style={{ fontFamily: "'Playfair Display', serif", color: "#2d6a4f" }}>{formatCHF(totalRevenue)}</span>
</div>
</div>
{/* Reserve & Stunden & Marge — kompakt in einer Zeile */}
<div className="card">
<SectionLabel>PARAMETER</SectionLabel>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16, marginBottom: 16 }}>
{[
{ label: "Reserve %", key: "reserve", min: 0, max: 50, hint: "Puffer für Unvorhergesehenes" },
{ label: "Produktiv-Quote %", key: "produktivQuote", min: 10, max: 100, hint: "Verrechenbare Stunden (6075%)" },
{ label: "Ziel-Marge %", key: "zielMarge", min: 0, max: 80, hint: "Aufschlag für Gewinn / Reinvestition" },
].map(({ label, key, min, max, hint }) => (
<div key={key}>
<div style={{ fontSize: 11, color: "var(--text4)", marginBottom: 5 }}>{label}</div>
<input type="number" step="1" min={min} max={max} value={b[key]}
onChange={e => setField(key, +e.target.value)}
style={{ width: "100%", height: 34, fontSize: 13, padding: "0 10px" }} />
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 4 }}>{hint}</div>
</div>
))}
</div>
<div style={{ paddingTop: 12, borderTop: "1px solid var(--border2)" }}>
<div style={{ fontSize: 11, color: "var(--text4)", marginBottom: 6 }}>Aktueller Stundensatz (Vergleichswert)</div>
<OverrideInput
label="übersteuern"
aktiv={true}
checked={b.rateOverride}
onToggle={v => setField("rateOverride", v)}
value={b.rateManual}
onChange={v => setField("rateManual", v)}
autoVal={defaultRate}
unit="CHF"
step={5}
/>
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 4 }}>Standard aus Einstellungen: {formatCHF(defaultRate)}/h</div>
</div>
</div>
</div>
{/* ── RECHTE SPALTE: Ergebnis ── */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Kostenstruktur */}
<div className="card">
<SectionLabel>KOSTENSTRUKTUR / JAHR</SectionLabel>
{[
{ label: "Personalkosten", value: personalKosten },
{ label: "Fixkosten", value: fixKosten + extraKosten },
{ label: "Basiskosten", value: basisKosten, bold: true },
{ label: `+ Reserve (${b.reserve}%)`, value: reserve, indent: true },
{ label: "Gesamtkosten", value: gesamtKosten, total: true },
].map((r, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: r.total ? "8px 0 2px" : r.indent ? "2px 0 2px 12px" : "4px 0", borderTop: r.total ? "2px solid var(--text)" : "none", borderBottom: r.total ? "none" : "1px solid var(--border2)", marginTop: r.total ? 4 : 0 }}>
<span style={{ fontSize: r.total ? 13 : r.indent ? 11 : 12, fontWeight: r.total || r.bold ? 700 : 400, color: r.total || r.bold ? "var(--text)" : "var(--text2)" }}>{r.label}</span>
<span style={{ fontSize: r.total ? 15 : 12, fontWeight: r.total || r.bold ? 700 : 400, fontFamily: r.total || r.bold ? "'Playfair Display', serif" : "inherit" }}>{formatCHF(r.value)}</span>
</div>
))}
{totalRevenue > 0 && (
<>
<div style={{ display: "flex", justifyContent: "space-between", padding: "8px 0 4px", marginTop: 8, borderTop: "1px dashed var(--border)" }}>
<span style={{ fontSize: 12, color: "var(--text2)" }}>
Einnahmen
{b.inkludiereEntwuerfe && draftRevenue > 0 && b.inkludiereRechnungen && (
<span style={{ fontSize: 10, color: "#b5621e", marginLeft: 5 }}>inkl. {formatCHF(draftRevenue)} erwartet (Rechnungen)</span>
)}
{b.inkludiereOffertEntwuerfe && draftQuoteRevenue > 0 && b.inkludiereOfferten && (
<span style={{ fontSize: 10, color: "#b5621e", marginLeft: 5 }}>inkl. {formatCHF(draftQuoteRevenue)} erwartet (Offerten)</span>
)}
</span>
<span style={{ fontSize: 12, fontWeight: 600, color: "#2d6a4f" }}>{formatCHF(totalRevenue)}</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", padding: "8px 0 2px", borderTop: "2px solid var(--text)", marginTop: 4 }}>
<span style={{ fontSize: 13, fontWeight: 700 }}>Ergebnis</span>
<span style={{ fontSize: 15, fontWeight: 700, fontFamily: "'Playfair Display', serif", color: ergebnis >= 0 ? "#2d6a4f" : "#8a1a1a" }}>
{ergebnis >= 0 ? "+" : ""}{formatCHF(ergebnis)}
</span>
</div>
</>
)}
</div>
{/* Stunden — Soll + Ist zusammen */}
<div className="card">
<SectionLabel>STUNDEN</SectionLabel>
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 0", borderBottom: "1px solid var(--border2)", fontSize: 12 }}>
<span style={{ color: "var(--text2)" }}>Verfügbar</span>
<span>{jahresStundenFallback}h {employees.length === 0 && <span style={{ color: "var(--text4)", fontSize: 10 }}>Fallback</span>}</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 0", borderBottom: "1px solid var(--border2)", fontSize: 12 }}>
<span style={{ color: "var(--text2)" }}>Produktiv ({b.produktivQuote}%)</span>
<span style={{ fontWeight: 600 }}>{produktivStunden}h</span>
</div>
<div style={{ marginTop: 10 }}>
<div style={{ height: 5, background: "var(--border)", borderRadius: 3, overflow: "hidden" }}>
<div style={{ width: `${b.produktivQuote}%`, height: "100%", background: b.produktivQuote >= 80 ? "#8a1a1a" : b.produktivQuote >= 70 ? "#b5621e" : "#2d6a4f", borderRadius: 3 }} />
</div>
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 3 }}>Über 75% ist anspruchsvoll</div>
</div>
{totalTrackedMins > 0 && (
<div style={{ marginTop: 14, paddingTop: 14, borderTop: "1px solid var(--border2)" }}>
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "var(--text4)", marginBottom: 10 }}>IST {currentYear}</div>
{[
{ label: "Verrechenbar", mins: billingMins, color: "#2d6a4f" },
{ label: "Wettbewerbe", mins: wbTotalMins, color: "#b5621e" },
{ label: "Ohne Projekt", mins: noProjectMins, color: "#bbb" },
].map(({ label, mins, color }) => {
if (mins === 0) return null;
const pct = (mins / totalTrackedMins) * 100;
return (
<div key={label} style={{ marginBottom: 8 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, marginBottom: 3 }}>
<span style={{ color: "var(--text2)" }}>{label}</span>
<span>{Math.round(mins / 60)}h <span style={{ color: "var(--text4)" }}>({Math.round(pct)}%)</span></span>
</div>
<div style={{ height: 4, background: "var(--border)", borderRadius: 2, overflow: "hidden" }}>
<div style={{ width: `${pct}%`, height: "100%", background: color, borderRadius: 2 }} />
</div>
</div>
);
})}
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, paddingTop: 6, borderTop: "1px solid var(--border2)", marginTop: 2 }}>
<span style={{ color: "var(--text2)" }}>Total erfasst</span>
<span style={{ fontWeight: 600 }}>{Math.round(totalTrackedMins / 60)}h</span>
</div>
{istProduktivQuote !== null && (
<div style={{ marginTop: 8, display: "flex", justifyContent: "space-between", fontSize: 11, padding: "6px 10px", borderRadius: 5, background: "var(--surface2)" }}>
<span style={{ color: "var(--text3)" }}>Ist-Produktivquote</span>
<span>
<strong style={{ color: Math.abs(istProduktivQuote - b.produktivQuote) > 10 ? "#b5621e" : "var(--text)" }}>{Math.round(istProduktivQuote)}%</strong>
<span style={{ color: "var(--text4)", marginLeft: 5 }}>Soll: {b.produktivQuote}%</span>
</span>
</div>
)}
</div>
)}
</div>
{/* Wettbewerbe Interner Aufwand */}
{wbProjects.length > 0 && (
<div className="card">
<SectionLabel>WETTBEWERBE INTERNER AUFWAND</SectionLabel>
{wbRows.length === 0 ? (
<div style={{ fontSize: 12, color: "var(--text4)" }}>Keine Wettbewerb-Stunden in {currentYear} erfasst.</div>
) : (
<>
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 10 }}>
<thead>
<tr>
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "left", padding: "4px 6px 6px 0", borderBottom: "1px solid var(--border)" }}>Projekt</th>
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 6px 6px", borderBottom: "1px solid var(--border)" }}>Std.</th>
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 0 6px", borderBottom: "1px solid var(--border)" }}>Interner Aufwand</th>
</tr>
</thead>
<tbody>
{wbRows.map(r => (
<tr key={r.id}>
<td style={{ padding: "6px 6px 6px 0", borderBottom: "1px solid var(--border2)", fontSize: 12 }}>{r.name}</td>
<td style={{ padding: "6px 6px", borderBottom: "1px solid var(--border2)", textAlign: "right", fontSize: 12 }}>{Math.round(r.hours * 10) / 10}h</td>
<td style={{ padding: "6px 0", borderBottom: "1px solid var(--border2)", textAlign: "right", fontSize: 12 }}>{selbstkosten > 0 ? formatCHF(Math.round(r.cost)) : "—"}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td style={{ padding: "7px 6px 2px 0", fontWeight: 600, fontSize: 12 }}>Total</td>
<td style={{ padding: "7px 6px 2px", textAlign: "right", fontWeight: 600, fontSize: 12 }}>{Math.round(wbTotalHours * 10) / 10}h</td>
<td style={{ padding: "7px 0 2px", textAlign: "right", fontWeight: 600, fontSize: 12 }}>{selbstkosten > 0 ? formatCHF(Math.round(wbTotalHours * selbstkosten)) : "—"}</td>
</tr>
</tfoot>
</table>
{selbstkosten > 0 && (
<div style={{ fontSize: 11, color: "var(--text4)", paddingTop: 8, borderTop: "1px solid var(--border2)" }}>
Entgangenes Honorar: {Math.round(wbTotalHours)}h × {formatCHF(currentRate)}/h = <strong style={{ color: "var(--text3)" }}>{formatCHF(Math.round(wbTotalHours * currentRate))}</strong>
</div>
)}
</>
)}
</div>
)}
{/* Rollenvergleich */}
{(data.settings.roles || []).length > 0 && selbstkosten > 0 && (
<div className="card">
<SectionLabel>ROLLEN-VERGLEICH</SectionLabel>
{(data.settings.roles || []).map(role => {
const diff = role.rate - zielHonorar;
const ok = diff >= 0;
return (
<div key={role.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "5px 0", borderBottom: "1px solid var(--border2)" }}>
<div>
<span style={{ fontSize: 12 }}>{role.label}</span>
<span style={{ fontSize: 10, color: "var(--text4)", marginLeft: 6 }}>{formatCHF(role.rate)}/h</span>
</div>
<span style={{ fontSize: 12, fontWeight: 600, color: ok ? "#2d6a4f" : "#8a1a1a" }}>
{ok ? "+" : ""}{formatCHF(Math.round(diff))} {ok ? "▲" : "▼"}
</span>
</div>
);
})}
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 8 }}>Differenz zum Ziel-Honorar von {formatCHF(Math.round(zielHonorar))}/h</div>
</div>
)}
</div>
</div>
</div>
);
}
+1494
View File
File diff suppressed because it is too large Load Diff
Executable
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})