Initial commit — RAPPORT App (Stand Mai 2026)
@@ -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?
|
||||||
@@ -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.
|
||||||
@@ -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 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -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 |
@@ -0,0 +1,4 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/gen/schemas
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -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");
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 13 KiB |
@@ -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 |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
];
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; height: 100%; }
|
||||||
|
#root { height: 100%; }
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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, 2000–2099)
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 31–33" /></FormField>
|
||||||
|
<FormField label="Bemerkung"><input value={honorarForm.note} onChange={e => setHonorarForm(f => ({ ...f, note: e.target.value }))} /></FormField>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit modal */}
|
||||||
|
{firmModal && (
|
||||||
|
<Modal title="Kontakt bearbeiten" onClose={() => setFirmModal(null)} onSave={saveFirm} wide>
|
||||||
|
{firmFormFields(false)}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List view ──
|
||||||
|
const allTypes = [...new Set(contacts.map(c => c.type).filter(Boolean))].sort();
|
||||||
|
const filtered = contacts
|
||||||
|
.filter(c =>
|
||||||
|
(!typeFilter || c.type === typeFilter) &&
|
||||||
|
(!search || c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(c.type || "").toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(c.contacts || []).some(p => p.name.toLowerCase().includes(search.toLowerCase())))
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, "de"));
|
||||||
|
|
||||||
|
const contactGroups = (() => {
|
||||||
|
if (groupBy === "none") return [{ key: "_all", label: null, items: filtered }];
|
||||||
|
if (groupBy === "alpha") {
|
||||||
|
const g = {};
|
||||||
|
filtered.forEach(c => { const k = c.name[0]?.toUpperCase() || "#"; (g[k] = g[k] || []).push(c); });
|
||||||
|
return Object.entries(g).sort((a, b) => a[0].localeCompare(b[0])).map(([k, items]) => ({ key: k, label: k, items }));
|
||||||
|
}
|
||||||
|
if (groupBy === "type") {
|
||||||
|
const g = {};
|
||||||
|
filtered.forEach(c => { const k = c.type || "Ohne Typ"; (g[k] = g[k] || []).push(c); });
|
||||||
|
return Object.entries(g).sort((a, b) => a[0].localeCompare(b[0])).map(([k, items]) => ({ key: k, label: k, items }));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const ContactTable = ({ items }) => (
|
||||||
|
<div className="card" style={{ padding: 0 }}>
|
||||||
|
<table style={{ width: "100%" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Firma</th>
|
||||||
|
<th style={{ width: 140 }}>Typ</th>
|
||||||
|
<th style={{ width: 160 }}>Adresse</th>
|
||||||
|
<th>Hauptkontakt</th>
|
||||||
|
<th style={{ width: 80, textAlign: "center" }}>Personen</th>
|
||||||
|
<th style={{ width: 80, textAlign: "center" }}>Projekte</th>
|
||||||
|
<th style={{ width: 80 }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.length === 0 && <tr><td colSpan={7} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>Keine Treffer</td></tr>}
|
||||||
|
{items.map(c => {
|
||||||
|
const persons = c.contacts || [];
|
||||||
|
const haupt = persons[0];
|
||||||
|
const city = [c.zip, c.city].filter(Boolean).join(" ");
|
||||||
|
const projCount = (data.projects || []).filter(p => (p.projectContacts || []).some(pc => pc.contactId === c.id)).length;
|
||||||
|
return (
|
||||||
|
<tr key={c.id} style={{ cursor: "pointer" }} onClick={() => setSelectedId(c.id)}>
|
||||||
|
<td>
|
||||||
|
<strong>{c.name}</strong>
|
||||||
|
{c.email && <div style={{ fontSize: 11, color: "#888" }}>{c.email}</div>}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 12, color: "#666" }}>{c.type || <span style={{ color: "#ccc" }}>—</span>}</td>
|
||||||
|
<td style={{ fontSize: 12, color: "#666" }}>
|
||||||
|
{c.street && <div>{c.street}</div>}
|
||||||
|
{city && <div>{city}</div>}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 12 }}>
|
||||||
|
{haupt ? (
|
||||||
|
<>
|
||||||
|
<div style={{ fontWeight: 500 }}>{haupt.name}</div>
|
||||||
|
{haupt.position && <div style={{ fontSize: 11, color: "#888" }}>{haupt.position}</div>}
|
||||||
|
</>
|
||||||
|
) : <span style={{ color: "#ccc" }}>—</span>}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: "center", fontSize: 12, color: "#888" }}>{persons.length || "—"}</td>
|
||||||
|
<td style={{ textAlign: "center", fontSize: 12, color: projCount > 0 ? "#1a4e8a" : "#ccc", fontWeight: projCount > 0 ? 600 : 400 }}>{projCount || "—"}</td>
|
||||||
|
<td style={{ textAlign: "right", whiteSpace: "nowrap" }} onClick={e => e.stopPropagation()}>
|
||||||
|
<button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12 }} onClick={() => openEdit(c)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
|
||||||
|
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => delFirm(c.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{ConfirmModalEl}
|
||||||
|
<Header title="Kontakte" action={<button className="btn btn-primary" onClick={openNew}>+ Neuer Kontakt</button>} />
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Suchen…"
|
||||||
|
style={{ flex: "1 1 200px", maxWidth: 300, fontSize: 12 }} />
|
||||||
|
{allTypes.length > 0 && (
|
||||||
|
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)} style={{ fontSize: 12, minWidth: 160 }}>
|
||||||
|
<option value="">Alle Typen</option>
|
||||||
|
{allTypes.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<select value={groupBy} onChange={e => setGroupBy(e.target.value)} style={{ fontSize: 12, width: 160 }}>
|
||||||
|
<option value="alpha">Alphabetisch</option>
|
||||||
|
<option value="type">Nach Typ</option>
|
||||||
|
<option value="none">Keine Gruppierung</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{contacts.length === 0 ? (
|
||||||
|
<div className="card" style={{ padding: 40, textAlign: "center", color: "#aaa" }}>Noch keine Kontakte erfasst.</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="card" style={{ padding: 40, textAlign: "center", color: "#aaa" }}>Keine Treffer</div>
|
||||||
|
) : contactGroups.map(group => (
|
||||||
|
<div key={group.key} style={{ marginBottom: 20 }}>
|
||||||
|
{group.label && (
|
||||||
|
<div style={{ fontSize: 10, letterSpacing: "0.14em", color: "#aaa", fontWeight: 600, marginBottom: 8, paddingLeft: 2 }}>
|
||||||
|
{group.label.toUpperCase()} <span style={{ opacity: 0.55 }}>{group.items.length}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ContactTable items={group.items} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{firmModal && (
|
||||||
|
<Modal title={firmModal.id ? "Kontakt bearbeiten" : "Neuer Kontakt"} onClose={() => setFirmModal(null)} onSave={saveFirm} wide>
|
||||||
|
{firmFormFields(!firmModal.id)}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ──────────────────────────────────────────────────
|
||||||
@@ -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 & 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 ──────────────────────────────────────────────────
|
||||||
@@ -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}
|
||||||
|
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>YY</code> = {_yy}
|
||||||
|
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>MM</code> = Monat
|
||||||
|
<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
|
||||||
|
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>PPP</code> = Projektnr. komplett
|
||||||
|
<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>
|
||||||
|
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{typ}"}</code>
|
||||||
|
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{nummer}"}</code>
|
||||||
|
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{kunde}"}</code>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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ürich–Bern, 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 18–22%)</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 (60–75%)" },
|
||||||
|
{ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||