Files
wochenplaner/frontend/index.html
T
2026-06-01 13:26:56 +00:00

1666 lines
124 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Wochenplaner Pro</title>
<!-- PWA Meta Tags (iOS Support) -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Wochenplaner">
<meta name="theme-color" content="#ffffff">
<!-- Externe Bibliotheken -->
<script src="https://cdn.tailwindcss.com"></script>
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.sheetjs.com/xlsx-latest/package/dist/xlsx.full.min.js"></script>
<!-- Mobile Drag & Drop Polyfill -->
<script src="https://unpkg.com/mobile-drag-drop/index.min.js"></script>
<script src="https://unpkg.com/mobile-drag-drop/scroll-behaviour.min.js"></script>
<script>
// Polyfill aktivieren für Touch-Drag-Drop
MobileDragDrop.polyfill({
dragImageCenterOnTouch: true,
iterationInterval: 50
});
// Fix für iOS Scrolling während Drag
window.addEventListener('touchmove', function() {}, {passive: false});
</script>
<style>
/* --- GLOBAL RESET & TYPOGRAPHY --- */
body {
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background-color: #f8fafc; /* slate-50 */
color: #0f172a; /* slate-900 */
overscroll-behavior-y: none; /* Verhindert Pull-to-Refresh */
}
/* Alles eckig machen - Global Override */
* {
border-radius: 0 !important;
}
/* Basis Reset für Formularelemente */
input:not([type="checkbox"]):not([type="range"]),
textarea,
select,
button {
outline: none;
box-shadow: none;
font-size: 16px; /* Verhindert Auto-Zoom auf iOS */
}
/* Custom Scrollbars */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* --- HEADER STYLING (CLEAN & FIXED) --- */
.toolbar-container {
height: 44px; /* Etwas höher für Touch */
display: flex;
flex-direction: row;
align-items: stretch; /* Wichtig: Alle Kinder gleich hoch */
background-color: white;
border-bottom: 1px solid #e2e8f0;
overflow-x: auto; /* Scrollbar bei zu kleinen Screens */
overflow-y: hidden;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
/* Einheitliche Klasse für JEDES Element in der Leiste */
.toolbar-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0 12px;
border-right: 1px solid #e2e8f0;
background: white;
color: #334155;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.1s;
height: 100%; /* Füllt die 40px */
min-width: fit-content;
}
.toolbar-item:hover {
background-color: #f8fafc;
}
.toolbar-item:active {
background-color: #f1f5f9;
}
.toolbar-item.active {
background-color: #e2e8f0;
}
/* Inputs direkt in der Leiste */
.toolbar-input {
border: none;
background: transparent;
font-weight: 700;
color: #334155;
height: 100%;
text-align: center;
padding: 0 4px;
cursor: text;
font-size: 0.85rem;
}
.toolbar-input:focus {
background-color: #f0f9ff;
}
/* Selects in der Leiste */
.toolbar-select {
border: none;
background: transparent;
font-weight: 700;
color: #334155;
height: 100%;
cursor: pointer;
padding-right: 1.5rem;
padding-left: 0.5rem;
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23334155' stroke-width='2'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M19 9l-7 7-7-7'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1rem;
}
.toolbar-select:focus {
background-color: #f0f9ff;
}
/* Label in der Leiste (z.B. "KW") */
.toolbar-label {
font-size: 0.7rem;
color: #94a3b8;
margin-right: 6px;
text-transform: uppercase;
font-weight: 700;
pointer-events: none;
}
/* Datum-Input native Controls SICHTBAR lassen */
input[type="date"] {
font-family: inherit;
text-transform: uppercase;
font-size: 0.75rem;
font-weight: 600;
color: #334155;
border: none;
background: transparent;
height: 100%;
cursor: pointer;
}
input[type="date"]:hover {
background-color: #f8fafc;
}
/* --- SETTINGS & SIDEBAR --- */
.nice-input, .nice-select {
border: 1px solid #e2e8f0;
background-color: #ffffff;
padding: 6px 8px;
font-size: 14px; /* iOS safe font size */
width: 100%;
color: #334155;
transition: border-color 0.2s;
}
.nice-input:focus, .nice-select:focus {
border-color: #0d9488;
}
/* Accordion Animation */
.accordion-content {
transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out;
max-height: 0;
opacity: 0;
overflow: hidden;
}
.accordion-content.open {
max-height: 1000px;
opacity: 1;
}
/* --- SIDEBAR RESPONSIVE LOGIC (PWA Additions) --- */
.sidebar-panel {
transition: transform 0.3s ease-in-out;
z-index: 50;
background: white;
}
/* Desktop: Static Sidebars pushing content */
@media (min-width: 1024px) {
.sidebar-left { border-right: 1px solid #e2e8f0; }
.sidebar-right { border-left: 1px solid #e2e8f0; }
.sidebar-overlay { display: none !important; }
}
/* Mobile: Overlay Sidebars */
@media (max-width: 1023px) {
.sidebar-left {
position: absolute; top: 0; left: 0; bottom: 0;
width: 85vw !important; max-width: 320px;
transform: translateX(-100%);
box-shadow: 4px 0 10px rgba(0,0,0,0.1);
}
.sidebar-left.open { transform: translateX(0); }
.sidebar-right {
position: absolute; top: 0; right: 0; bottom: 0;
width: 85vw !important; max-width: 320px;
transform: translateX(100%);
box-shadow: -4px 0 10px rgba(0,0,0,0.1);
}
.sidebar-right.open { transform: translateX(0); }
.sidebar-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.3);
z-index: 40; backdrop-filter: blur(2px);
}
}
/* --- TABLE & CELLS --- */
input[type="checkbox"] {
appearance: auto;
accent-color: #0d9488;
width: 18px;
height: 18px;
cursor: pointer;
}
.readonly-input {
pointer-events: none;
color: #64748b;
}
/* Damit Textareas in Tabellenzellen gut aussehen */
.cell-textarea {
width: 100%;
height: 100%;
border: none;
outline: none;
background: transparent;
padding: 2px;
resize: none;
}
.cell-textarea:focus {
background-color: rgba(255, 255, 255, 0.8);
box-shadow: inset 0 0 0 1px #99f6e4;
}
/* Druck Styles */
@media print {
@page { size: landscape; margin: 5mm; }
.print-hide { display: none !important; }
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
background: white;
}
::-webkit-scrollbar { display: none; }
tr, td, th { page-break-inside: avoid; }
/* Tabellen-Zellen erzwingen */
table tbody tr.resource-row td {
padding: 0 !important;
margin: 0 !important;
overflow: hidden !important;
border: 1px solid #ccc !important;
}
input, textarea, select, .event-wrap {
border: none !important;
}
#login-screen { display: none !important; }
.sidebar-panel, .sidebar-overlay { display: none !important; }
}
</style>
<!-- PWA MANIFEST GENERATOR -->
<script>
(function() {
// Generiert ein Icon und Manifest zur Laufzeit
const iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" fill="#0d9488"/><path d="M128 128h256v256H128z" fill="white" fill-opacity="0.2"/><path d="M160 160h192v192H160z" fill="white"/><rect x="190" y="200" width="132" height="20" rx="4" fill="#0d9488"/><rect x="190" y="240" width="132" height="20" rx="4" fill="#ccfbf1"/><rect x="190" y="280" width="80" height="20" rx="4" fill="#ccfbf1"/></svg>`;
const iconUrl = "data:image/svg+xml;base64," + btoa(iconSvg);
const linkIcon = document.createElement('link'); linkIcon.rel = 'icon'; linkIcon.href = iconUrl; document.head.appendChild(linkIcon);
const linkApple = document.createElement('link'); linkApple.rel = 'apple-touch-icon'; linkApple.href = iconUrl; document.head.appendChild(linkApple);
const manifest = {
name: "Wochenplaner Pro", short_name: "Planer",
start_url: ".", display: "standalone",
background_color: "#ffffff", theme_color: "#0d9488",
icons: [{ src: iconUrl, sizes: "512x512", type: "image/svg+xml" }]
};
const linkManifest = document.createElement('link');
linkManifest.rel = 'manifest';
linkManifest.href = URL.createObjectURL(new Blob([JSON.stringify(manifest)], {type: 'application/manifest+json'}));
document.head.appendChild(linkManifest);
})();
</script>
</head>
<body class="bg-slate-50 text-slate-900 font-sans h-screen overflow-hidden flex flex-col">
<div id="root" class="h-full flex flex-col"></div>
<script type="text/babel">
const { useState, useEffect, useRef, useCallback } = React;
const API_URL = '/api';
// --- ICONS (Expanded for Readability) ---
const IconCalendar = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
);
const IconGear = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
);
const IconMenu = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
);
const IconPrint = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 6 2 18 2 18 9"/>
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/>
<rect x="6" y="14" width="12" height="8"/>
</svg>
);
const IconX = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
);
const IconTrash = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
);
const IconPlus = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
);
const IconChevronRight = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
);
const IconChevronDown = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
);
const IconGrip = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="9" cy="12" r="1"/>
<circle cx="9" cy="5" r="1"/>
<circle cx="9" cy="19" r="1"/>
<circle cx="15" cy="12" r="1"/>
<circle cx="15" cy="5" r="1"/>
<circle cx="15" cy="19" r="1"/>
</svg>
);
const IconMoveLeft = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
);
const IconMoveRight = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
);
const IconMoveUp = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="18 15 12 9 6 15"/>
</svg>
);
const IconMoveDown = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
);
const IconDownload = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
);
const IconExport = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
);
const IconFile = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
);
const IconCheck = () => (
<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
const IconSave = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
);
const IconRefresh = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
);
const IconLink = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
);
const IconKey = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7.5" cy="15.5" r="5.5"/>
<path d="m21 2-9.6 9.6"/>
<path d="m15.5 7.5 3 3L22 7l-3-3"/>
</svg>
);
const IconEye = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
);
const IconEyeOff = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/>
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/>
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7c.44 0 .87-.03 1.28-.09"/>
<line x1="2" x2="22" y1="2" y2="22"/>
</svg>
);
const IconClock = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
);
const IconMapPin = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
);
const IconAlignLeft = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="17" y1="10" x2="3" y2="10"/>
<line x1="21" y1="6" x2="3" y2="6"/>
<line x1="21" y1="14" x2="3" y2="14"/>
<line x1="17" y1="18" x2="3" y2="18"/>
</svg>
);
const IconView = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="7" height="7" x="3" y="3" rx="1"/>
<rect width="7" height="7" x="14" y="3" rx="1"/>
<rect width="7" height="7" x="14" y="14" rx="1"/>
<rect width="7" height="7" x="3" y="14" rx="1"/>
</svg>
);
const IconAlertCircle = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
);
const IconFileExcel = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<path d="M8 13h2"/>
<path d="M8 17h2"/>
<path d="M14 13h2"/>
<path d="M14 17h2"/>
</svg>
);
const IconInherit = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
);
const IconHeader = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
<line x1="4" y1="10" x2="20" y2="10"></line>
</svg>
);
const IconEdit = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
);
const IconInfo = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
);
const IconUser = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
);
const IconLock = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
);
const IconLogOut = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
);
// --- CONFIG ---
const RESOURCE_TYPES = { TEXT: 'text', TIME: 'time', DATE: 'date', CHECK: 'check', CHECK2: 'check2' };
const COLORS = [
{ name: 'Weiß', value: 'bg-white' },
{ name: 'Grau', value: 'bg-slate-100' },
{ name: 'Rot', value: 'bg-red-100' },
{ name: 'Orange', value: 'bg-orange-100' },
{ name: 'Gelb', value: 'bg-yellow-100' },
{ name: 'Grün', value: 'bg-green-100' },
{ name: 'Türkis', value: 'bg-teal-100' },
{ name: 'Blau', value: 'bg-blue-100' },
{ name: 'Indigo', value: 'bg-indigo-100' },
{ name: 'Lila', value: 'bg-purple-100' },
{ name: 'Pink', value: 'bg-pink-100' },
];
const TEXT_COLORS = [
{ name: 'Schwarz', value: 'text-slate-900' },
{ name: 'Grau', value: 'text-slate-500' },
{ name: 'Rot', value: 'text-red-600' },
{ name: 'Orange', value: 'text-orange-600' },
{ name: 'Grün', value: 'text-emerald-600' },
{ name: 'Blau', value: 'text-blue-600' },
{ name: 'Lila', value: 'text-purple-600' },
];
const CHECK_COLORS = [
{ name: 'Grün', value: 'bg-green-500', text: 'text-white' },
{ name: 'Rot', value: 'bg-red-500', text: 'text-white' },
{ name: 'Blau', value: 'bg-blue-500', text: 'text-white' },
{ name: 'Gelb', value: 'bg-yellow-400', text: 'text-black' },
{ name: 'Lila', value: 'bg-purple-500', text: 'text-white' },
{ name: 'Grau', value: 'bg-slate-500', text: 'text-white' },
{ name: 'Orange', value: 'bg-orange-500', text: 'text-white' },
];
const CAL_COLORS = [
{ name: 'Blau', bg: 'bg-blue-100', text: 'text-blue-800', border: 'border-blue-200' },
{ name: 'Grün', bg: 'bg-emerald-100', text: 'text-emerald-800', border: 'border-emerald-200' },
{ name: 'Orange', bg: 'bg-orange-100', text: 'text-orange-800', border: 'border-orange-200' },
{ name: 'Rot', bg: 'bg-rose-100', text: 'text-rose-800', border: 'border-rose-200' },
{ name: 'Lila', bg: 'bg-violet-100', text: 'text-violet-800', border: 'border-violet-200' },
{ name: 'Grau', bg: 'bg-slate-100', text: 'text-slate-800', border: 'border-slate-200' },
{ name: 'Gelb', bg: 'bg-yellow-100', text: 'text-yellow-800', border: 'border-yellow-200' },
{ name: 'Teal', bg: 'bg-teal-100', text: 'text-teal-800', border: 'border-teal-200' }
];
const MONTH_NAMES = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
// --- HELPER FUNCTIONS ---
const generateTimeOptions = () => {
const options = [];
for(let h=0; h<24; h++) {
for(let m=0; m<60; m+=15) {
const time = `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`;
options.push(time);
}
}
return options;
};
const TIME_OPTIONS = generateTimeOptions();
const getFontSizePx = (val) => {
if(typeof val === 'number') return val;
if(!val) return 14;
if(val === 'text-xs') return 12;
if(val === 'text-sm') return 14;
if(val === 'text-base') return 16;
if(val === 'text-lg') return 18;
return 14; // default
}
const formatDateDE = (isoStr) => {
if(!isoStr) return '';
const [y, m, d] = isoStr.split('-');
return `${d}.${m}.${y}`;
}
// --- Ical Logic (Expanded) ---
const parseICalData = (icalStr) => {
const events = [];
try {
if (!icalStr) return [];
const lines = icalStr.split(/\r\n|\n|\r/);
let inEvent = false;
let currentEvent = {};
for (let line of lines) {
if (line.startsWith('BEGIN:VEVENT')) {
inEvent = true;
currentEvent = {};
} else if (line.startsWith('END:VEVENT')) {
inEvent = false;
if (currentEvent.start && currentEvent.summary) events.push(currentEvent);
} else if (inEvent) {
if (line.startsWith('DTSTART')) {
const val = line.split(':')[1] || '';
if (val.length >= 8) {
const y = val.substring(0, 4), m = val.substring(4, 6), d = val.substring(6, 8);
currentEvent.start = `${y}-${m}-${d}`;
if (val.includes('T') && val.length >= 13) {
const h = val.substring(9, 11);
const min = val.substring(11, 13);
currentEvent.time = `${h}:${min}`;
}
}
} else if (line.startsWith('SUMMARY')) {
const parts = line.split(':');
if (parts.length > 1) {
currentEvent.summary = parts.slice(1).join(':').replace(/\\,/g, ',');
}
} else if (line.startsWith('LOCATION')) {
const parts = line.split(':');
if(parts.length > 1) {
currentEvent.location = parts.slice(1).join(':').replace(/\\,/g, ',');
}
} else if (line.startsWith('DESCRIPTION')) {
const parts = line.split(':');
if(parts.length > 1) {
currentEvent.description = parts.slice(1).join(':').replace(/\\,/g, ',');
}
}
}
}
} catch (e) {
console.error("iCal Error", e);
}
return events;
};
const generateICalString = (calendar) => {
let ics = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Putzplan//Local//DE\n";
calendar.events.forEach(e => {
ics += "BEGIN:VEVENT\n";
ics += `UID:${e.uid || Date.now() + Math.random()}\n`;
ics += `DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').split('.')[0]}Z\n`;
const dateStr = e.start.replace(/-/g, '');
ics += `DTSTART;VALUE=DATE:${dateStr}\n`;
ics += `SUMMARY:${e.summary}\n`;
if(e.time) ics += `DESCRIPTION:Time: ${e.time}\\n`;
if(e.location) ics += `LOCATION:${e.location}\n`;
if(e.description) ics += `DESCRIPTION:${e.description}\n`;
ics += "END:VEVENT\n";
});
ics += "END:VCALENDAR";
return ics;
};
const flattenTree = (nodes, depth = 0, visibleList = []) => {
if (!Array.isArray(nodes)) return visibleList;
nodes.forEach(node => {
visibleList.push({ ...node, depth });
if (node.isExpanded && node.children && node.children.length > 0) {
flattenTree(node.children, depth + 1, visibleList);
}
});
return visibleList;
};
const findNodePath = (nodes, targetId, path = []) => {
if (!Array.isArray(nodes)) return null;
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].id === targetId) return [...path, { list: nodes, index: i, node: nodes[i] }];
if (nodes[i].children) {
const result = findNodePath(nodes[i].children, targetId, [...path, { list: nodes, index: i, node: nodes[i] }]);
if (result) return result;
}
}
return null;
};
// --- COMPONENTS ---
const Toast = ({ message, type, onClose }) => {
useEffect(() => { const timer = setTimeout(onClose, 3000); return () => clearTimeout(timer); }, [onClose]);
const bgClass = type === 'error' ? 'bg-red-50 text-red-700 border-red-200' : type === 'conflict' ? 'bg-red-600 text-white border-red-700 shadow-xl' : type === 'success' ? 'bg-green-50 text-green-700 border-green-200' : 'bg-blue-50 text-blue-700 border-blue-200';
return (
<div className={`fixed bottom-4 right-4 z-[99] px-4 py-3 rounded shadow-lg border flex items-center gap-2 animate-fade-in ${bgClass}`}>
<span className="font-medium">{message}</span>
<button onClick={onClose}><IconX /></button>
</div>
);
};
const ConfirmModal = ({ message, onConfirm, onCancel }) => (
<div className="fixed inset-0 z-[100] bg-black/30 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-white p-6 shadow-2xl border max-w-sm w-full animate-fade-in rounded-none">
<div className="flex items-center gap-3 mb-4 text-slate-800 font-bold text-lg"><IconAlertCircle/> Bestätigung</div>
<p className="text-slate-600 mb-6">{message}</p>
<div className="flex justify-end gap-2">
<button onClick={onCancel} className="px-4 py-2 hover:bg-slate-100 text-slate-700 font-medium">Abbrechen</button>
<button onClick={onConfirm} className="px-4 py-2 bg-slate-900 hover:bg-slate-800 text-white font-medium shadow-sm">Bestätigen</button>
</div>
</div>
</div>
);
const AccordionSection = ({ title, isOpen, onToggle, children }) => (
<div className="border-b border-slate-100 last:border-0">
<button onClick={onToggle} className="w-full flex items-center justify-between p-3 text-left hover:bg-slate-50">
<span className="font-bold text-slate-700 text-xs uppercase">{title}</span>
<span className={`text-slate-400 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}><IconChevronDown/></span>
</button>
<div className={`accordion-content ${isOpen ? 'open' : ''} px-3 pb-3`}>
{children}
</div>
</div>
);
const LoginScreen = ({ onLogin, loading }) => {
const [user, setUser] = useState('');
const [pass, setPass] = useState('');
const [errorMsg, setErrorMsg] = useState('');
const [lockoutTime, setLockoutTime] = useState(null);
useEffect(() => {
const storedLockout = localStorage.getItem('login_lockout_until');
if (storedLockout && parseInt(storedLockout) > Date.now()) {
setLockoutTime(parseInt(storedLockout));
} else {
localStorage.removeItem('login_lockout_until');
}
}, []);
useEffect(() => {
let interval;
if (lockoutTime) {
interval = setInterval(() => {
if (Date.now() > lockoutTime) {
setLockoutTime(null);
localStorage.removeItem('login_lockout_until');
localStorage.removeItem('login_attempts');
} else {
setLockoutTime(prev => prev);
}
}, 1000);
}
return () => clearInterval(interval);
}, [lockoutTime]);
const handleAttempt = () => {
setErrorMsg('');
const success = onLogin(user, pass);
if (!success) {
let attempts = parseInt(localStorage.getItem('login_attempts') || '0') + 1;
localStorage.setItem('login_attempts', attempts);
if (attempts >= 5) {
const lockUntil = Date.now() + 10 * 60 * 1000;
localStorage.setItem('login_lockout_until', lockUntil);
setLockoutTime(lockUntil);
setErrorMsg('Zu viele Fehlversuche. Login für 10 Min gesperrt.');
} else {
setErrorMsg('Ungültige Zugangsdaten. Versuch ' + attempts + '/5');
}
} else {
localStorage.removeItem('login_attempts');
}
};
const remaining = lockoutTime ? Math.ceil((lockoutTime - Date.now()) / 60000) : 0;
return (
<div className="fixed inset-0 z-[200] bg-slate-100 flex items-center justify-center p-4" id="login-screen">
<div className="bg-white p-8 shadow-xl max-w-sm w-full border border-slate-200">
<h1 className="text-2xl font-bold text-center text-teal-700 mb-6">Wochenplaner Pro</h1>
{lockoutTime ? (
<div className="bg-red-50 text-red-700 p-4 rounded text-center border border-red-200">
<IconLock className="mx-auto mb-2 w-8 h-8"/>
<div className="font-bold">Login gesperrt</div>
<div className="text-sm mt-1">Bitte warte noch {remaining} Minuten.</div>
<button onClick={() => { localStorage.removeItem('login_lockout_until'); setLockoutTime(null); }} className="text-xs text-red-400 mt-2 underline hover:text-red-600">Reset Sperre (Test)</button>
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Benutzername</label>
<div className="relative">
<div className="absolute left-3 top-2.5 text-slate-400"><IconUser /></div>
<input autoFocus className="w-full pl-10 border border-slate-300 p-2 outline-none focus:border-teal-500" value={user} onChange={e=>setUser(e.target.value)} onKeyDown={e=>e.key==='Enter' && handleAttempt()} />
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Passwort</label>
<div className="relative">
<div className="absolute left-3 top-2.5 text-slate-400"><IconLock /></div>
<input type="password" className="w-full pl-10 border border-slate-300 p-2 outline-none focus:border-teal-500" value={pass} onChange={e=>setPass(e.target.value)} onKeyDown={e=>e.key==='Enter' && handleAttempt()} />
</div>
</div>
{errorMsg && <div className="text-red-600 text-xs font-bold text-center animate-fade-in">{errorMsg}</div>}
<button onClick={handleAttempt} disabled={loading} className="w-full bg-teal-600 hover:bg-teal-700 text-white font-bold py-2 mt-2 disabled:opacity-50">
{loading ? 'Lade...' : 'Anmelden'}
</button>
<div className="text-center text-xs text-slate-400 pt-4">Daten werden auf dem Server gespeichert.</div>
</div>
)}
</div>
</div>
);
};
// --- APP COMPONENT ---
function App() {
// STATE DEFINITIONS
const [session, setSession] = useState({ loggedIn: false, user: null, role: null, id: null });
const [users, setUsers] = useState([]);
// DATA STATE
const [generalConfig, setGeneralConfig] = useState({
appTitle: 'Wochenplaner',
resourceColTitle: 'Bereich',
dayColors: { 1: '#ffffff', 2: '#ffffff', 3: '#ffffff', 4: '#ffffff', 5: '#ffffff', 6: '#f8fafc', 0: '#f8fafc' }
});
const [resources, setResources] = useState([]);
const [calendars, setCalendars] = useState([]);
const [tasks, setTasks] = useState({});
// UI STATE
const [printOptions, setPrintOptions] = useState({ fontSize: 12, showCalendars: true, printRowHeight: 28 });
const [headerOptions, setHeaderOptions] = useState({
weekday: { fontSize: 10, color: '#64748b', bold: false },
date: { fontSize: 12, color: '#0f172a', bold: true }
});
const [globalRowHeight, setGlobalRowHeight] = useState(36);
const [baseFontSize, setBaseFontSize] = useState(14);
const [hiddenResourceIds, setHiddenResourceIds] = useState(new Set());
const [views, setViews] = useState([]);
const [activeOptionTab, setActiveOptionTab] = useState('labels');
const [toast, setToast] = useState(null);
const [confirmDialog, setConfirmDialog] = useState(null);
const [selectedEvent, setSelectedEvent] = useState(null);
// PWA STATE
const [installPrompt, setInstallPrompt] = useState(null);
// CALC STATE
const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
const [currentWeek, setCurrentWeek] = useState(1);
const [currentMonth, setCurrentMonth] = useState(new Date().getMonth());
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(false);
const [editingResource, setEditingResource] = useState(null);
const [newResName, setNewResName] = useState('');
const [rightTab, setRightTab] = useState('resources');
const [newCalUrl, setNewCalUrl] = useState('');
const [newCalName, setNewCalName] = useState('');
const [newViewName, setNewViewName] = useState('');
const [newCalType, setNewCalType] = useState('local'); // 'local' or 'remote'
const [draggedItemId, setDraggedItemId] = useState(null);
const [draggedCalId, setDraggedCalId] = useState(null);
const [draggedEvent, setDraggedEvent] = useState(null);
const [eventModal, setEventModal] = useState({ open: false, calId: null, eventIndex: null, data: {} });
// Loading State
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [lastServerVersion, setLastServerVersion] = useState(null); // For Conflict Handling
// User Management Helper
const [newUser, setNewUser] = useState({ name: '', pass: '', role: 'user' });
const [selfName, setSelfName] = useState('');
const [selfPass, setSelfPass] = useState('');
// Admin Edit State
const [editingUserId, setEditingUserId] = useState(null); // 'new', 'id', null
const [tempUserData, setTempUserData] = useState({ id: '', username: '', password: '', role: 'user' });
const showToast = (msg, type='info') => setToast({ message: msg, type });
const requestConfirm = (msg, onConfirm) => setConfirmDialog({ message: msg, onConfirm: () => { onConfirm(); setConfirmDialog(null); }, onCancel: () => setConfirmDialog(null) });
// DATE UTILS
const getCurrentWeek = (d) => { d = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return Math.ceil((((d - yearStart) / 86400000) + 1) / 7); };
const toLocalISO = (d) => { const offset = d.getTimezoneOffset(); const local = new Date(d.getTime() - (offset*60*1000)); return local.toISOString().split('T')[0]; };
const setDateFromKW = (kw, year) => { const simple = new Date(year, 0, 1 + (kw - 1) * 7); const dow = simple.getDay(); const ISOweekStart = simple; if (dow <= 4) ISOweekStart.setDate(simple.getDate() - simple.getDay() + 1); else ISOweekStart.setDate(simple.getDate() + 8 - simple.getDay()); const startStr = toLocalISO(ISOweekStart); const endD = new Date(ISOweekStart); endD.setDate(ISOweekStart.getDate() + 6); const endStr = toLocalISO(endD); setStartDate(startStr); setEndDate(endStr); };
const handleWeekChange = (val) => { const w = parseInt(val) || 1; setCurrentWeek(w); setDateFromKW(w, currentYear); };
const handleMonthChange = (val) => { const m = parseInt(val); setCurrentMonth(m); const firstDay = new Date(currentYear, m, 1); const lastDay = new Date(currentYear, m + 1, 0); setStartDate(toLocalISO(firstDay)); setEndDate(toLocalISO(lastDay)); };
const handleYearChange = (val) => { const y = parseInt(val) || new Date().getFullYear(); setCurrentYear(y); setDateFromKW(currentWeek, y); };
const handleStartDateChange = (e) => { setStartDate(e.target.value); const d = new Date(e.target.value); if(!isNaN(d.getTime())) { setCurrentWeek(getCurrentWeek(d)); setCurrentMonth(d.getMonth()); setCurrentYear(d.getFullYear()); } };
const getDateRange = (s, e) => { if(!s || !e) return []; const start = new Date(s); const end = new Date(e); const arr = []; let loop = new Date(start); let i=0; while (loop <= end && i<40) { arr.push(new Date(loop)); loop.setDate(loop.getDate() + 1); i++; } return arr; };
const dates = getDateRange(startDate, endDate);
// --- INITIAL LOAD ---
useEffect(() => {
loadFromServer();
// Init dates
const now = new Date();
setCurrentWeek(getCurrentWeek(now));
setDateFromKW(getCurrentWeek(now), currentYear);
// PWA Installer Listener
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
setInstallPrompt(e);
});
}, []);
const triggerInstall = async () => {
if (!installPrompt) return;
installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === 'accepted') setInstallPrompt(null);
};
// Update Self Edit inputs when opening user tab or login
useEffect(() => {
if(session.loggedIn) {
const currentUser = users.find(u => u.id === session.id) || users.find(u => u.username === session.user);
if(currentUser) {
setSelfName(currentUser.username);
setSelfPass(currentUser.password);
}
}
}, [session, users, rightTab]);
const loadFromServer = async () => {
setIsLoading(true);
try {
// Try/Catch um den Fetch, falls URL Parsing fehlschlägt (Preview Mode)
let res;
try {
res = await fetch(API_URL);
} catch (netErr) {
console.warn("Netzwerk/URL Fehler (wahrscheinlich Preview Mode):", netErr);
setIsLoading(false);
return;
}
if (!res.ok) throw new Error("API Fehler: " + res.status);
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch (jsonErr) {
console.error("Ungültiges JSON vom Server:", text);
throw new Error("Server antwortete nicht mit JSON.");
}
if (data.resources) setResources(data.resources);
if (data.tasks) setTasks(data.tasks);
if (data.calendars) setCalendars(data.calendars);
if (data.users) setUsers(data.users);
if (data.generalConfig) setGeneralConfig(data.generalConfig);
if (data.views) setViews(data.views);
if (data.printOptions) setPrintOptions(data.printOptions);
if (data.globalRowHeight) setGlobalRowHeight(data.globalRowHeight);
if (data.baseFontSize) setBaseFontSize(data.baseFontSize);
if (data.headerOptions) setHeaderOptions(data.headerOptions);
if (data.hiddenResourceIds) setHiddenResourceIds(new Set(data.hiddenResourceIds));
if (data.updatedAt) setLastServerVersion(data.updatedAt);
setIsLoading(false);
} catch (e) {
console.error(e);
showToast("Fehler beim Laden: " + e.message, "error");
setIsLoading(false);
}
};
const saveToServer = async (manual = false) => {
// Viewer dürfen nicht speichern
if (session.role === 'viewer') return;
setIsSaving(true);
const newData = {
resources, tasks, calendars, users,
generalConfig, views, printOptions,
globalRowHeight, baseFontSize, headerOptions,
hiddenResourceIds: Array.from(hiddenResourceIds),
updatedAt: new Date().toISOString(),
_lastKnownVersion: lastServerVersion // Für Konflikt-Prüfung
};
try {
let res;
try {
res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newData)
});
} catch(netErr) {
if(manual) showToast("Speichern nicht möglich (keine Verbindung/Preview).", "error");
setIsSaving(false);
return;
}
if (res.status === 409) {
// Konflikt!
const err = await res.json();
showToast(err.message || "Konflikt: Daten auf Server sind neuer!", "conflict");
setIsSaving(false);
return;
}
if (res.ok) {
setLastServerVersion(newData.updatedAt); // Update lokale Version
if(manual) showToast("Erfolgreich gespeichert", "success");
} else {
showToast("Fehler beim Speichern: " + res.status, "error");
}
} catch (e) {
showToast("Verbindungsfehler beim Speichern", "error");
} finally {
setIsSaving(false);
}
};
// Auto Save Debounce
useEffect(() => {
if(!session.loggedIn || session.role === 'viewer') return;
const timer = setTimeout(() => {
saveToServer();
}, 2000);
return () => clearTimeout(timer);
}, [resources, tasks, calendars, generalConfig, views, printOptions, users, globalRowHeight, baseFontSize, headerOptions, hiddenResourceIds]);
const handleLogin = (u, p) => {
// Notfall-Benutzer für Preview ohne Backend
if (users.length === 0) {
if ((u === 'admin' && p === 'admin') || (u === 'user' && p === 'user') || (u === 'viewer' && p === 'viewer')) {
const role = u;
setSession({ loggedIn: true, user: u, role: role, id: 'u'+(u==='admin'?1:u==='user'?2:3) });
setUsers([
{ id: "u1", username: "admin", password: "admin", role: "admin" },
{ id: "u2", username: "user", password: "user", role: "user" },
{ id: "u3", username: "viewer", password: "viewer", role: "viewer" }
]);
showToast(`Initialer Login (${role})`);
return true;
}
}
const found = users.find(x => x.username === u && x.password === p);
if (found) {
setSession({ loggedIn: true, user: found.username, role: found.role, id: found.id });
showToast(`Willkommen, ${found.username}!`);
return true;
} else {
return false;
}
};
const handleLogout = () => {
setSession({ loggedIn: false, user: null, role: null, id: null });
setIsRightSidebarOpen(false);
};
const toggleUserSidebar = () => {
if (isRightSidebarOpen && rightTab === 'user') {
setIsRightSidebarOpen(false);
} else {
setRightTab('user');
setIsRightSidebarOpen(true);
}
};
const toggleSettingsSidebar = () => {
// Nur für Admins erlaubt
if (session.role !== 'admin') return;
if (isRightSidebarOpen && (rightTab === 'settings' || rightTab === 'resources' || rightTab === 'calendars')) {
setIsRightSidebarOpen(false);
} else {
// Default to settings if opening, or keep current if it's one of the options tabs
if (rightTab === 'user') setRightTab('settings');
setIsRightSidebarOpen(true);
}
};
const toggleOptionTab = (tabName) => {
setActiveOptionTab(activeOptionTab === tabName ? null : tabName);
};
// User Management Functions
const startCreateUser = () => {
setEditingUserId('new');
setTempUserData({ id: '', username: '', password: '', role: 'user' });
};
const startEditUser = (user) => {
setEditingUserId(user.id);
setTempUserData({ ...user });
};
const cancelEditUser = () => {
setEditingUserId(null);
setTempUserData({ id: '', username: '', password: '', role: 'user' });
};
const saveUserAdmin = () => {
if (!tempUserData.username || !tempUserData.password) {
showToast("Bitte Name und Passwort ausfüllen", "error");
return;
}
if (editingUserId === 'new') {
const newUser = { ...tempUserData, id: Date.now().toString() };
setUsers([...users, newUser]);
showToast("Benutzer angelegt", "success");
} else {
setUsers(users.map(u => u.id === editingUserId ? tempUserData : u));
showToast("Benutzer aktualisiert", "success");
}
cancelEditUser();
};
const deleteUser = (id) => {
if(users.length <= 1) { showToast("Mindestens ein Benutzer muss existieren", "error"); return; }
setUsers(users.filter(u => u.id !== id));
};
const updateUserSelf = () => {
if(!selfName || !selfPass) return showToast("Bitte Namen und Passwort angeben", "error");
setUsers(prev => prev.map(u => (u.id === session.id || u.username === session.user) ? { ...u, username: selfName, password: selfPass } : u));
setSession(prev => ({...prev, user: selfName}));
showToast("Profil aktualisiert", "success");
};
// LOGIC FOR RESOURCES/TASKS
const allFlatRows = flattenTree(resources);
const visibleRows = allFlatRows.filter(r => !hiddenResourceIds.has(r.id));
const addResource = () => { if (!newResName.trim()) return; setResources([...resources, { id: Date.now().toString(), name: newResName.trim(), children: [], isExpanded: true, type: RESOURCE_TYPES.TEXT, headerColor: 'bg-white', cellColor: 'bg-white' }]); setNewResName(''); };
const deleteResource = (id) => { requestConfirm('Möchtest du diese Ressource wirklich löschen?', () => { const filter = (nodes) => nodes.filter(n => { if (n.id === id) return false; if (n.children) n.children = filter(n.children); return true; }); setResources(filter([...resources])); showToast("Ressource gelöscht", "success"); }); };
const toggleExpand = (id) => { const cp = JSON.parse(JSON.stringify(resources)); const path = findNodePath(cp, id); if (path) { const node = path[path.length - 1].node; node.isExpanded = !node.isExpanded; setResources(cp); } };
const updateRes = (id, key, val) => { const cp = JSON.parse(JSON.stringify(resources)); const path = findNodePath(cp, id); if (path) { path[path.length - 1].node[key] = val; setResources(cp); } };
const move = (id, dir) => { const cp = JSON.parse(JSON.stringify(resources)); const path = findNodePath(cp, id); if (!path) return; const curr = path[path.length - 1]; const parent = path.length > 1 ? path[path.length - 2] : null; if (dir === 'indent' && curr.index > 0) { const prev = curr.list[curr.index - 1]; curr.list.splice(curr.index, 1); prev.children.push(curr.node); prev.isExpanded = true; } else if (dir === 'outdent' && parent) { curr.list.splice(curr.index, 1); parent.list.splice(parent.index + 1, 0, curr.node); } setResources(cp); };
const onDrop = (e, targetId) => { e.preventDefault(); if (draggedItemId === targetId) return; const cp = JSON.parse(JSON.stringify(resources)); const srcP = findNodePath(cp, draggedItemId); const tgtP = findNodePath(cp, targetId); if (!srcP || !tgtP) return; const src = srcP[srcP.length - 1]; const tgt = tgtP[tgtP.length - 1]; src.list.splice(src.index, 1); tgt.list.splice(tgt.index + 1, 0, src.node); setResources(cp); setDraggedItemId(null); };
// CALENDAR SORTING
const moveCal = (id, dir) => {
const idx = calendars.findIndex(c => c.id === id);
if (idx < 0) return;
const newCals = [...calendars];
if (dir === 'up' && idx > 0) {
[newCals[idx], newCals[idx-1]] = [newCals[idx-1], newCals[idx]];
} else if (dir === 'down' && idx < newCals.length - 1) {
[newCals[idx], newCals[idx+1]] = [newCals[idx+1], newCals[idx]];
}
setCalendars(newCals);
};
const onCalDrop = (e, targetId) => {
e.preventDefault();
if (!draggedCalId || draggedCalId === targetId) return;
const fromIdx = calendars.findIndex(c => c.id === draggedCalId);
const toIdx = calendars.findIndex(c => c.id === targetId);
if (fromIdx < 0 || toIdx < 0) return;
const newCals = [...calendars];
const [moved] = newCals.splice(fromIdx, 1);
newCals.splice(toIdx, 0, moved);
setCalendars(newCals);
setDraggedCalId(null);
};
// TASK UPDATES & INHERITANCE
const handleTaskUpdate = (key, value, resourceId) => {
const newTasks = { ...tasks, [key]: value };
const dateStr = key.split('_')[0];
const updateChildren = (parentId, newVal) => { const parentNodePath = findNodePath(resources, parentId); if(!parentNodePath) return; const parentNode = parentNodePath[parentNodePath.length-1].node; if(parentNode.children && parentNode.children.length > 0) { parentNode.children.forEach(child => { if (child.inheritFromParent) { const childKey = `${dateStr}_${child.id}`; newTasks[childKey] = newVal; updateChildren(child.id, newVal); } }); } }; updateChildren(resourceId, value);
const checkParent = (childId, childValue) => { const childPath = findNodePath(resources, childId); if(!childPath || childPath.length < 2) return; const parentNode = childPath[childPath.length-2].node; const childNodePath = findNodePath(resources, childId); const childNode = childNodePath ? childNodePath[childNodePath.length-1].node : null; if (parentNode.autoCheckParent && childNode) { const isChildDone = (childNode.type === RESOURCE_TYPES.CHECK2 && childValue === '2') || (childNode.type === RESOURCE_TYPES.CHECK && childValue === '1'); if (isChildDone) { const parentKey = `${dateStr}_${parentNode.id}`; let parentDoneVal = parentNode.type === RESOURCE_TYPES.CHECK2 ? '2' : '1'; if (newTasks[parentKey] !== parentDoneVal) { newTasks[parentKey] = parentDoneVal; checkParent(parentNode.id, parentDoneVal); } } } }; checkParent(resourceId, value); setTasks(newTasks);
};
const handleTask = (k, v, resId) => handleTaskUpdate(k, v, resId);
const cycleStatus = (key, id, res) => { if (session.role === 'viewer') return; const val = tasks[key]; let newVal = ''; if (res.type === RESOURCE_TYPES.CHECK) { newVal = val === '1' ? '' : '1'; } else if (res.type === RESOURCE_TYPES.CHECK2) { if(!val) newVal = '1'; else if(val === '1') newVal = '2'; else newVal = ''; } handleTaskUpdate(key, newVal, id); };
// CALENDARS FETCH/ADD
const fetchCalendar = async (id, url, silent = false) => { const fetchWithProxy = async (targetUrl, useProxy = false) => { const finalUrl = useProxy ? `https://api.allorigins.win/raw?url=${encodeURIComponent(targetUrl)}` : targetUrl; const res = await fetch(finalUrl); if (!res.ok) throw new Error(`Status: ${res.status}`); return await res.text(); }; try { let text = ""; try { text = await fetchWithProxy(url, false); } catch (directError) { text = await fetchWithProxy(url, true); } const events = parseICalData(text); setCalendars(prev => prev.map(c => c.id === id ? { ...c, events, lastUpdated: new Date().toISOString(), error: null } : c)); if(!silent) showToast(`Kalender aktualisiert! ${events.length} Termine.`, 'success'); } catch (e) { setCalendars(prev => prev.map(c => c.id === id ? { ...c, error: 'Fehler: URL nicht erreichbar.' } : c)); if(!silent) showToast('Fehler beim Abrufen der URL', 'error'); } };
const addCal = (type) => {
if (!newCalName) { showToast("Name erforderlich", 'error'); return; }
let events = [];
let url = '';
if (type === 'remote') {
if(newCalUrl.startsWith('http')) { url = newCalUrl; }
else if (newCalUrl.includes('BEGIN:VCALENDAR')) { events = parseICalData(newCalUrl); }
}
const newId = Date.now();
setCalendars([...calendars, { id: newId, name: newCalName, url, events, active: true, lastUpdated: null, displayOptions: { showTime: true, showLocation: false }, color: CAL_COLORS[0], type: type }]);
if(url) fetchCalendar(newId, url);
setNewCalName(''); setNewCalUrl('');
showToast("Kalender hinzugefügt", "success");
};
const openCreateEventModal = (calId, dateStr) => { if (session.role === 'viewer') return; setEventModal({ open: true, calId, eventIndex: null, data: { summary: '', start: dateStr, time: '', location: '', description: '' } }); };
const openEditEventModal = (e, calId, eventIndex) => { e.stopPropagation(); if (session.role === 'viewer') return; const cal = calendars.find(c => c.id === calId); if (!cal || !cal.events[eventIndex]) return; const event = cal.events[eventIndex]; setEventModal({ open: true, calId, eventIndex, data: { summary: event.summary || '', start: event.start || '', time: event.time || '', location: event.location || '', description: event.description || '' } }); };
const handleSaveEvent = () => { const { calId, eventIndex, data } = eventModal; if (!data.summary) { showToast('Bitte einen Titel eingeben', 'error'); return; } setCalendars(prev => prev.map(c => { if (c.id !== calId) return c; const newEvents = [...c.events]; if (eventIndex === null) { newEvents.push({ uid: Date.now() + Math.random().toString(), ...data }); } else { newEvents[eventIndex] = { ...newEvents[eventIndex], ...data }; } return { ...c, events: newEvents }; })); setEventModal({ ...eventModal, open: false }); showToast("Termin gespeichert", "success"); };
const handleDeleteEvent = () => { requestConfirm("Termin wirklich löschen?", () => { const { calId, eventIndex } = eventModal; setCalendars(prev => prev.map(c => { if (c.id !== calId) return c; const newEvents = [...c.events]; newEvents.splice(eventIndex, 1); return { ...c, events: newEvents }; })); setEventModal({ ...eventModal, open: false }); showToast("Termin gelöscht", "success"); }); };
const handleEventDragStart = (e, calId, eventIndex) => { if (session.role === 'viewer') { e.preventDefault(); return; } e.dataTransfer.effectAllowed = "move"; setDraggedEvent({ calId, eventIndex }); };
const handleEventDrop = (e, targetCalId, targetDateStr) => { e.preventDefault(); if (!draggedEvent || session.role === 'viewer') return; setCalendars(prev => { const sourceCal = prev.find(c => c.id === draggedEvent.calId); if (!sourceCal) return prev; const eventToMove = { ...sourceCal.events[draggedEvent.eventIndex] }; eventToMove.start = targetDateStr; const newSourceEvents = sourceCal.events.filter((_, i) => i !== draggedEvent.eventIndex); if (draggedEvent.calId === targetCalId) { return prev.map(c => c.id === targetCalId ? { ...c, events: [...newSourceEvents, eventToMove] } : c); } else { return prev.map(c => { if (c.id === draggedEvent.calId) return { ...c, events: newSourceEvents }; if (c.id === targetCalId) return { ...c, events: [...c.events, eventToMove] }; return c; }); } }); setDraggedEvent(null); };
const exportLocalCal = (calId) => { const cal = calendars.find(c => c.id === calId); if(!cal) return; const blob = new Blob([generateICalString(cal)], { type: 'text/calendar' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${cal.name}.ics`; a.click(); };
const activeCalendars = calendars.filter(c => c.active !== false);
// EXPORTS
const exportBackup = () => { const data = JSON.stringify({ resources, tasks, calendars, users, generalConfig, views, printOptions, headerOptions, globalRowHeight, baseFontSize, hiddenResourceIds: Array.from(hiddenResourceIds) }, null, 2); const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'backup.json'; a.click(); };
const exportExcel = () => { if (typeof XLSX === 'undefined') { showToast("SheetJS Bibliothek nicht geladen.", "error"); return; } const header = ['Ressource', 'Typ', ...dates.map(d => d.toLocaleDateString('de-DE'))]; const rows = [header]; visibleRows.forEach(r => { const row = [r.name, r.type]; dates.forEach(d => { const key = `${d.toISOString().split('T')[0]}_${r.id}`; let val = tasks[key] || ''; if(val === '1') val = 'Geplant'; if(val === '2') val = 'Erledigt'; row.push(val); }); rows.push(row); }); const ws = XLSX.utils.aoa_to_sheet(rows); const wscols = [{wch: 30}, {wch: 10}]; dates.forEach(() => wscols.push({wch: 15})); ws['!cols'] = wscols; const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, `KW ${currentWeek}`); XLSX.writeFile(wb, `Wochenplan_KW${currentWeek}.xlsx`); showToast("Excel-Datei erstellt!", "success"); };
const resetAllData = () => { requestConfirm("Wirklich alle Daten löschen? Dies setzt die Datei auf dem Server zurück.", () => { setResources([]); setTasks({}); setCalendars([]); showToast("Daten gelöscht. Bitte speichern."); }); };
const saveView = () => { if(!newViewName) return; const activeCalIds = calendars.filter(c => c.active).map(c => c.id); const newView = { id: Date.now(), name: newViewName, config: { activeCalendars: activeCalIds, hiddenResources: Array.from(hiddenResourceIds) } }; setViews([...views, newView]); setNewViewName(''); showToast("Ansicht gespeichert", "success"); };
const loadView = (view) => { setHiddenResourceIds(new Set(view.config.hiddenResources)); setCalendars(prev => prev.map(c => ({ ...c, active: view.config.activeCalendars.includes(c.id) }))); showToast(`Ansicht "${view.name}" geladen`); };
const deleteView = (id) => { requestConfirm('Ansicht löschen?', () => { setViews(views.filter(v => v.id !== id)); showToast("Ansicht gelöscht"); }); };
if (!session.loggedIn) return (<><Toast message={toast?.message} type={toast?.type} onClose={()=>setToast(null)}/><LoginScreen onLogin={handleLogin} loading={isLoading} /></>);
return (
<div className={`flex flex-col h-screen bg-slate-50 text-slate-900 font-sans overflow-hidden text-sm ${printOptions.fontSize === 'text-xs' ? 'print-text-sm' : ''}`}>
<style>{`
tr.calendar-row { height: auto !important; }
tr.resource-row { height: ${globalRowHeight}px; }
body { font-size: ${baseFontSize}px; }
@media print {
tr.resource-row td { height: ${printOptions.printRowHeight || 28}px !important; padding: 0 !important; overflow: hidden; }
body, table, th, td, input, textarea, select, .event-wrap { font-size: ${printOptions.fontSize || 12}px !important; }
thead th { font-size: ${headerOptions.date.fontSize || 12}px !important; }
}
`}</style>
{toast && <Toast message={toast.message} type={toast.type} onClose={()=>setToast(null)} />}
{confirmDialog && <ConfirmModal message={confirmDialog.message} onConfirm={confirmDialog.onConfirm} onCancel={confirmDialog.onCancel} />}
{/* Overlay for mobile sidebars */}
{(isLeftSidebarOpen || isRightSidebarOpen) && <div className="sidebar-overlay" onClick={() => { setIsLeftSidebarOpen(false); setIsRightSidebarOpen(false); }}></div>}
{/* EVENT MODALS */}
{selectedEvent && (
<div className="fixed inset-0 z-[150] bg-black/30 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-white shadow-2xl w-full max-w-md border border-slate-200 overflow-hidden flex flex-col rounded-none animate-fade-in">
<div className="bg-slate-50 p-4 border-b border-slate-200 flex justify-between items-center"><div className="font-bold text-slate-700">Termin Details</div><button onClick={()=>setSelectedEvent(null)}><IconX/></button></div>
<div className="p-5 space-y-4">
<div><div className="text-[10px] uppercase font-bold text-slate-400 mb-1">Titel</div><div className="text-base font-bold text-slate-800">{selectedEvent.summary || 'Kein Titel'}</div></div>
<div className="grid grid-cols-2 gap-4"><div><div className="text-[10px] uppercase font-bold text-slate-400 mb-1">Datum</div><div className="text-sm">{selectedEvent.start}</div></div><div><div className="text-[10px] uppercase font-bold text-slate-400 mb-1">Uhrzeit</div><div className="text-sm flex items-center gap-1"><IconClock /> {selectedEvent.time || '-'}</div></div></div>
{selectedEvent.location && <div><div className="text-[10px] uppercase font-bold text-slate-400 mb-1">Ort</div><div className="text-sm flex items-center gap-1 text-slate-700"><IconMapPin /> {selectedEvent.location}</div></div>}
{selectedEvent.description && <div><div className="text-[10px] uppercase font-bold text-slate-400 mb-1">Beschreibung</div><div className="text-sm text-slate-600 bg-slate-50 p-2 border border-slate-100 rounded-sm whitespace-pre-wrap">{selectedEvent.description}</div></div>}
</div>
<div className="p-4 bg-slate-50 border-t flex justify-end"><button onClick={()=>setSelectedEvent(null)} className="px-4 py-2 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded-sm text-sm font-medium">Schließen</button></div>
</div>
</div>
)}
{eventModal.open && (
<div className="fixed inset-0 z-[150] bg-black/30 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-white shadow-2xl w-full max-w-md border border-slate-200 overflow-hidden flex flex-col rounded-none animate-fade-in">
<div className="bg-slate-50 p-4 border-b border-slate-200 flex justify-between items-center"><div className="font-bold text-slate-700">{eventModal.eventIndex === null ? 'Neuer Termin' : 'Termin bearbeiten'}</div><button onClick={()=>setEventModal({...eventModal, open: false})}><IconX/></button></div>
<div className="p-4 space-y-3">
<div><label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Titel</label><input className="w-full border border-slate-300 p-2 text-sm outline-none focus:border-teal-500" value={eventModal.data.summary} onChange={e => setEventModal({...eventModal, data: {...eventModal.data, summary: e.target.value}})} /></div>
<div className="grid grid-cols-2 gap-2"><div><label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Datum</label><input type="date" className="w-full border border-slate-300 p-2 text-sm outline-none focus:border-teal-500" value={eventModal.data.start} onChange={e => setEventModal({...eventModal, data: {...eventModal.data, start: e.target.value}})} /></div><div><label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Uhrzeit</label><input type="time" className="w-full border border-slate-300 p-2 text-sm outline-none focus:border-teal-500" value={eventModal.data.time} onChange={e => setEventModal({...eventModal, data: {...eventModal.data, time: e.target.value}})} /></div></div>
<div><label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Ort</label><input className="w-full border border-slate-300 p-2 text-sm outline-none focus:border-teal-500" value={eventModal.data.location} onChange={e => setEventModal({...eventModal, data: {...eventModal.data, location: e.target.value}})} /></div>
<div><label className="block text-xs font-bold text-slate-500 mb-1 uppercase">Beschreibung</label><textarea className="w-full border border-slate-300 p-2 text-sm outline-none focus:border-teal-500 min-h-[80px]" value={eventModal.data.description} onChange={e => setEventModal({...eventModal, data: {...eventModal.data, description: e.target.value}})} /></div>
</div>
<div className="p-4 bg-slate-50 border-t flex justify-between gap-2">{eventModal.eventIndex !== null ? (<button onClick={handleDeleteEvent} className="px-4 py-2 text-red-600 hover:bg-red-50 text-sm font-medium border border-red-200">Löschen</button>) : <div></div>}<div className="flex gap-2"><button onClick={()=>setEventModal({...eventModal, open: false})} className="px-4 py-2 text-slate-600 hover:bg-slate-200 text-sm font-medium">Abbrechen</button><button onClick={handleSaveEvent} className="px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium">Speichern</button></div></div></div></div>)}
{/* HEADER BAR (FIXED & CLEAN) */}
<div className="toolbar-container print-hide">
<div className="toolbar-item text-teal-700 pl-6 pr-12" onClick={()=>setIsLeftSidebarOpen(!isLeftSidebarOpen)}>
<IconMenu />
<span className="hidden md:inline ml-2 text-lg font-bold">{generalConfig.appTitle}</span>
</div>
{/* KW */}
<div className="toolbar-item">
<span className="toolbar-label">KW</span>
<input type="number" className="toolbar-input w-12" value={currentWeek} onChange={e=>handleWeekChange(e.target.value)} min="1" max="53" />
</div>
{/* Monat */}
<div className="toolbar-item px-0">
<select value={currentMonth} onChange={e=>handleMonthChange(e.target.value)} className="toolbar-select w-20 pl-3">
{MONTH_NAMES.map((m,i)=><option key={i} value={i}>{m}</option>)}
</select>
</div>
{/* Jahr */}
<div className="toolbar-item">
<input type="number" className="toolbar-input w-16" value={currentYear} onChange={e=>handleYearChange(e.target.value)} />
</div>
<div className="w-px h-6 bg-slate-200 mx-2 self-center hidden sm:block"></div>
{/* Zeitraum */}
<div className="toolbar-item px-2 gap-2">
<input type="date" value={startDate} onChange={handleStartDateChange} className="toolbar-input w-auto text-xs" />
<span className="text-slate-400">-</span>
<input type="date" value={endDate} onChange={e=>setEndDate(e.target.value)} className="toolbar-input w-auto text-xs" />
</div>
<div className="flex-1"></div> {/* Spacer */}
{/* INSTALL BUTTON (PWA) */}
{installPrompt && (
<button onClick={triggerInstall} className="toolbar-item bg-teal-50 text-teal-700 font-bold border-l border-teal-100 animate-pulse">
<IconDownload /> <span className="hidden sm:inline ml-2">App Installieren</span>
</button>
)}
{/* Status & Actions */}
{isSaving && <div className="text-xs text-orange-500 font-bold uppercase animate-pulse mr-4 flex items-center h-full">Speichert...</div>}
<button onClick={()=>window.print()} className="toolbar-item w-12" title="Drucken"><IconPrint /></button>
{session.role === 'admin' && (
<button onClick={toggleSettingsSidebar} className={`toolbar-item w-12 ${rightTab === 'settings' && isRightSidebarOpen ? 'active' : ''}`} title="Einstellungen"><IconGear /></button>
)}
<button onClick={toggleUserSidebar} className={`toolbar-item w-auto px-4 gap-2 text-xs ${rightTab === 'user' && isRightSidebarOpen ? 'active' : ''}`} title="Benutzer">
<IconUser className="w-4 h-4"/> <span className="hidden sm:inline">{session.user}</span>
</button>
<button onClick={handleLogout} className="toolbar-item w-12 text-red-600 hover:bg-red-50 hover:text-red-700 border-l" title="Abmelden"><IconLogOut /></button>
</div>
<div className="flex flex-1 overflow-hidden relative">
{/* LEFT SIDEBAR (Views/Filters) */}
<div className={`sidebar-left bg-white flex flex-col shrink-0 transition-all duration-300 print:hidden overflow-hidden sidebar-panel ${isLeftSidebarOpen ? 'w-64 open' : 'w-0'}`}>
<div className="p-3 bg-slate-50 border-b font-bold uppercase text-xs flex justify-between items-center">
<span>Ansichten</span>
<button className="lg:hidden" onClick={()=>setIsLeftSidebarOpen(false)}><IconX/></button>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div><div className="text-sm font-semibold text-slate-400 mb-2 uppercase">Gespeicherte</div>
{session.role !== 'viewer' && <div className="flex gap-1 mb-2"><input className="nice-input" placeholder="Name..." value={newViewName} onChange={e=>setNewViewName(e.target.value)} /><button onClick={saveView} className="bg-teal-600 text-white p-1.5 rounded-sm"><IconSave/></button></div>}
<div className="space-y-1">{views.map(v => (<div key={v.id} className="flex justify-between items-center group bg-slate-50 p-1.5 hover:bg-slate-100 cursor-pointer border border-transparent hover:border-slate-200" onClick={() => loadView(v)}><div className="flex items-center gap-2 text-sm font-medium text-slate-700"><IconView className="text-slate-400"/> {v.name}</div>{session.role !== 'viewer' && <button onClick={(e) => {e.stopPropagation(); deleteView(v.id)}} className="opacity-0 group-hover:opacity-100 text-red-400"><IconTrash/></button>}</div>))}</div></div>
<div><div className="text-sm font-semibold text-slate-400 mb-2 uppercase">Kalender</div>{calendars.map(c => <label key={c.id} className="flex gap-2 p-1 hover:bg-slate-50 cursor-pointer"><input type="checkbox" checked={c.active!==false} disabled={session.role === 'viewer'} onChange={()=>{ const nC = calendars.map(x=>x.id===c.id?{...x, active:!x.active}:x); setCalendars(nC); }}/> {c.name}</label>)}</div>
<div><div className="text-sm font-semibold text-slate-400 mb-2 uppercase">Ressourcen</div>{allFlatRows.map(r => (<label key={r.id} className="flex items-center gap-2 text-sm py-1 cursor-pointer hover:bg-slate-50" style={{paddingLeft: r.depth * 8}}><input type="checkbox" checked={!hiddenResourceIds.has(r.id)} onChange={() => setHiddenResourceIds(prev => { const next = new Set(prev); if (next.has(r.id)) next.delete(r.id); else next.add(r.id); return next; })} className="accent-teal-600" /><span className={hiddenResourceIds.has(r.id) ? "text-slate-400" : "text-slate-700"}>{r.name}</span></label>))}</div>
</div>
</div>
{/* TABLE AREA */}
<div className="flex-1 overflow-auto bg-slate-50 relative z-10">
<table className="w-full border-collapse bg-white text-sm table-fixed min-w-[800px]">
<thead className="sticky top-0 bg-white z-20 shadow-sm">
<tr><th className="p-2 border-b border-r w-[220px] min-w-[220px] text-left bg-white sticky left-0 z-30">{generalConfig.resourceColTitle}</th>{dates.map(d => { const isToday = new Date().toISOString().split('T')[0] === d.toISOString().split('T')[0]; const dayColor = generalConfig.dayColors && generalConfig.dayColors[d.getDay()] ? generalConfig.dayColors[d.getDay()] : (isToday ? '#f0fdfa' : '#ffffff'); return (<th key={d.toISOString()} className="p-1 border-b border-r w-[120px] min-w-[120px] max-w-[120px] text-center" style={{backgroundColor: dayColor}}><div style={{fontSize: (headerOptions.weekday.fontSize || 10)+'px', fontWeight: headerOptions.weekday.bold?'bold':'normal', color: headerOptions.weekday.color}}>{d.toLocaleDateString('de-DE',{weekday:'short'}).toUpperCase()}</div><div style={{fontSize: (headerOptions.date.fontSize || 12)+'px', fontWeight: headerOptions.date.bold?'bold':'normal', color: headerOptions.date.color}}>{d.getDate()}.{d.getMonth()+1}.</div></th>); })}</tr>
</thead>
<tbody>
{printOptions.showCalendars !== false && activeCalendars.map(c => (<tr key={c.id} className="bg-slate-50 h-auto text-xs calendar-row"><td className="p-2 border-b border-r sticky left-0 bg-slate-100 font-bold text-slate-500 z-10">{c.name}</td>{dates.map(d => { const dStr = d.toISOString().split('T')[0]; const evts = c.events.filter(e => e.start === dStr); const calColor = c.color || CAL_COLORS[0]; return (<td key={dStr} className={`border-b border-r p-0.5 align-top w-[120px] min-w-[120px] ${session.role!=='viewer'&&c.type==='local'?'cursor-pointer hover:bg-slate-100':''}`} onClick={()=>session.role!=='viewer'&&c.type==='local'?openCreateEventModal(c.id,dStr):null} onDragOver={e=>session.role!=='viewer'&&c.type==='local'&&e.preventDefault()} onDrop={e=>handleEventDrop(e,c.id,dStr)}><div className="flex flex-col gap-0.5">{evts.map((e,i)=><div key={i} draggable={session.role!=='viewer'&&c.type==='local'} onDragStart={ev=>handleEventDragStart(ev,c.id,c.events.indexOf(e))} onClick={ev=>session.role!=='viewer'&&c.type==='local'?openEditEventModal(ev,c.id,c.events.indexOf(e)):setSelectedEvent(e)} className={`${calColor.bg} ${calColor.text} px-1 rounded-none event-wrap text-[10px] leading-tight py-0.5 mb-0.5 border ${calColor.border} group relative cursor-pointer hover:brightness-95`}>{c.displayOptions?.showTime!==false&&e.time&&<span className="font-bold block">{e.time}</span>}{e.summary}</div>)}</div></td>); })}</tr>))}
{visibleRows.map(r => (<tr key={r.id} className="group hover:bg-slate-50 resource-row"><td className={`p-2 border-b border-r sticky left-0 z-10 font-medium text-slate-700 shadow-[2px_0_5px_-2px_rgba(0,0,0,0.05)] ${r.headerColor||'bg-white'}`}><div style={{paddingLeft: r.depth*16+'px'}} className="flex items-center truncate">{r.children&&r.children.length>0?(<button onClick={(e)=>{e.stopPropagation();toggleExpand(r.id)}} className="mr-1 text-slate-400 hover:text-teal-600 p-0.5">{r.isExpanded?<IconChevronDown/>:<IconChevronRight/>}</button>):<div className="w-4 mr-1"/>}{r.name}</div></td>{dates.map(d => { const key = `${d.toISOString().split('T')[0]}_${r.id}`; const val = tasks[key] || ''; let cellClass = `border-b border-r p-0 relative align-top transition-colors w-[120px] min-w-[120px] `; if (r.children && r.children.length > 0) cellClass += 'bg-slate-100/50 '; else if (r.cellColor && r.cellColor !== 'bg-white') cellClass += r.cellColor + ' '; return (<td key={key} className={cellClass}><div style={{height: globalRowHeight+'px', width: '100%', position: 'relative', display: 'flex', alignItems: r.verticalAlign==='align-middle'?'center':r.verticalAlign==='align-bottom'?'flex-end':'flex-start', justifyContent: r.textAlign==='text-center'?'center':r.textAlign==='text-right'?'flex-end':'flex-start'}}>{(r.type === RESOURCE_TYPES.CHECK || r.type === RESOURCE_TYPES.CHECK2) ? (<div onClick={() => !session.role.includes('viewer') && cycleStatus(key, r.id, r)} className={`absolute inset-0 flex items-center justify-center ${session.role==='viewer'?'':'cursor-pointer hover:bg-black/5'}`}>{val==='1' && <div className={`${r.checkSize==='small'?'w-4 h-4 p-0.5':'w-6 h-6 p-1'} ${r.checkColor1||'bg-green-500'} flex items-center justify-center ${r.checkColor1?.includes('text')?'':'text-white'}`}><IconCheck/></div>}{val==='2' && <div className={`${r.checkSize==='small'?'w-4 h-4 p-0.5':'w-6 h-6 p-1'} ${r.checkColor2||'bg-blue-500'} flex items-center justify-center text-white`}><IconCheck/></div>}</div>) : r.type === RESOURCE_TYPES.TIME ? (<div className="w-full h-full relative group/time"><div className={`absolute inset-0 pointer-events-none flex p-1 ${!val?'hidden':''}`} style={{fontSize:getFontSizePx(r.textSize)+'px', fontWeight:r.textBold?'bold':'normal', color:r.textColor||'#0f172a', justifyContent:r.textAlign==='text-center'?'center':r.textAlign==='text-right'?'flex-end':'flex-start', alignItems:r.verticalAlign==='align-middle'?'center':r.verticalAlign==='align-bottom'?'flex-end':'flex-start'}}>{val}</div>{session.role !== 'viewer' && <select value={val} onChange={e => handleTask(key, e.target.value, r.id)} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"><option value="">-</option>{TIME_OPTIONS.map(t=><option key={t} value={t}>{t}</option>)}</select>}</div>) : r.type === RESOURCE_TYPES.DATE ? (<div className="w-full h-full relative group/date"><div className={`absolute inset-0 pointer-events-none flex p-1 ${!val?'hidden':''}`} style={{fontSize:getFontSizePx(r.textSize)+'px', fontWeight:r.textBold?'bold':'normal', color:r.textColor||'#0f172a', justifyContent:r.textAlign==='text-center'?'center':r.textAlign==='text-right'?'flex-end':'flex-start', alignItems:r.verticalAlign==='align-middle'?'center':r.verticalAlign==='align-bottom'?'flex-end':'flex-start'}}>{val?formatDateDE(val):''}</div>{session.role !== 'viewer' && <input type="date" value={val} onChange={e => handleTask(key, e.target.value, r.id)} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"/>}</div>) : (<div className="w-full h-full relative group/text"><div className={`absolute inset-0 pointer-events-none flex p-1 ${!val?'hidden':''}`} style={{fontSize:getFontSizePx(r.textSize)+'px', fontWeight:r.textBold?'bold':'normal', color:r.textColor||'#0f172a', justifyContent:r.textAlign==='text-center'?'center':r.textAlign==='text-right'?'flex-end':'flex-start', alignItems:r.verticalAlign==='align-middle'?'center':r.verticalAlign==='align-bottom'?'flex-end':'flex-start', whiteSpace: 'pre-wrap'}}>{val}</div><textarea readOnly={session.role === 'viewer'} value={val} onChange={e => handleTask(key, e.target.value, r.id)} className={`w-full h-full border-none outline-none bg-transparent p-1 resize-none focus:bg-white/50 focus:ring-1 focus:ring-teal-200 absolute inset-0 text-transparent caret-black ${session.role === 'viewer' ? 'readonly-input' : ''}`} style={{fontSize:getFontSizePx(r.textSize)+'px', fontWeight:r.textBold?'bold':'normal', textAlign:r.textAlign==='text-center'?'center':r.textAlign==='text-right'?'right':'left'}}/></div>)}</div></td>); })}</tr>))}
</tbody>
</table>
</div>
<div className={`sidebar-right bg-white flex flex-col shrink-0 transition-all duration-300 print:hidden overflow-hidden sidebar-panel ${isRightSidebarOpen ? 'w-80 open' : 'w-0'}`}>
{/* TAB NAVIGATION - Nur anzeigen wenn NICHT User Tab UND Admin */}
{rightTab !== 'user' && session.role === 'admin' && (
<div className="flex border-b">
<button onClick={()=>setRightTab('resources')} className={`flex-1 py-3 text-xs font-bold uppercase ${rightTab==='resources'?'text-teal-600 border-b-2 border-teal-600':'text-slate-400'}`}>Ressourcen</button>
<button onClick={()=>setRightTab('calendars')} className={`flex-1 py-3 text-xs font-bold uppercase ${rightTab==='calendars'?'text-teal-600 border-b-2 border-teal-600':'text-slate-400'}`}>Kalender</button>
<button onClick={()=>setRightTab('settings')} className={`flex-1 py-3 text-xs font-bold uppercase ${rightTab==='settings'?'text-teal-600 border-b-2 border-teal-600':'text-slate-400'}`}>Optionen</button>
</div>
)}
{/* RESOURCES TAB (NUR ADMIN) */}
{rightTab === 'resources' && session.role === 'admin' && (
<div className="flex-1 overflow-y-auto flex flex-col">
<div className="p-2 bg-slate-50 border-b flex gap-1"><input className="nice-input" placeholder="Neu..." value={newResName} onChange={e=>setNewResName(e.target.value)} onKeyDown={e=>e.key==='Enter'&&addResource()} /><button onClick={addResource} className="bg-teal-600 text-white p-1.5 rounded-sm"><IconPlus/></button></div>
<div className="p-1">{allFlatRows.map(r => (<div key={r.id}><div draggable onDragStart={(e)=>setDraggedItemId(r.id)} onDragOver={(e)=>e.preventDefault()} onDrop={(e)=>onDrop(e,r.id)} className={`flex items-center gap-1 p-1 group hover:bg-slate-50 border border-transparent ${editingResource===r.id?'bg-blue-50 border-blue-200':''}`} style={{paddingLeft: (r.depth*16+4)+'px'}}><div className="text-slate-300 cursor-grab"><IconGrip/></div>{r.children?.length > 0 ? (<button onClick={(e)=>{e.stopPropagation(); toggleExpand(r.id)}} className="text-slate-400 hover:text-teal-600 p-0.5">{r.isExpanded ? <IconChevronDown/> : <IconChevronRight/>}</button>) : <div className="w-4"/>}<span className="flex-1 truncate cursor-pointer select-none" onClick={()=>setEditingResource(editingResource===r.id?null:r.id)}>{r.name}</span><button onClick={()=>deleteResource(r.id)} className="opacity-0 group-hover:opacity-100 text-red-400"><IconTrash/></button></div>{editingResource===r.id && (<div className="ml-8 p-2 bg-white border shadow-sm text-xs space-y-3 mb-2 rounded-none">
<div><label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Name</label><input className="nice-input" value={r.name} onChange={e=>updateRes(r.id, 'name', e.target.value)} /></div>
<div><label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Typ</label><div className="flex gap-1 flex-wrap">{Object.entries(RESOURCE_TYPES).map(([k,v])=><button key={k} onClick={()=>updateRes(r.id,'type',v)} className={`px-2 py-1 border rounded-sm text-[10px] ${r.type===v?'bg-blue-600 text-white':''}`}>{k}</button>)}</div></div>
{/* Auto Settings */}
<div className="bg-orange-50 border border-orange-200 p-2 rounded-sm"><div className="text-[10px] font-bold text-orange-800 uppercase mb-1 flex items-center gap-1"><IconGear className="w-3 h-3"/> Automatisierung</div>{r.children && r.children.length > 0 && (<label className="flex items-start gap-2 cursor-pointer mb-2"><input type="checkbox" className="mt-0.5 accent-orange-600" checked={r.autoCheckParent || false} onChange={e=>updateRes(r.id, 'autoCheckParent', e.target.checked)} /><span className="text-xs text-slate-700 leading-tight">Eltern-Status setzen wenn Kind erledigt</span></label>)}{r.depth > 0 && (<label className="flex items-start gap-2 cursor-pointer"><input type="checkbox" className="mt-0.5 accent-orange-600" checked={r.inheritFromParent || false} onChange={e=>updateRes(r.id, 'inheritFromParent', e.target.checked)} /><span className="text-xs text-slate-700 leading-tight">Status von Eltern erben</span></label>)}</div>
{/* Type Specific */}
{(r.type === RESOURCE_TYPES.TEXT || r.type === RESOURCE_TYPES.DATE || r.type === RESOURCE_TYPES.TIME) && (<div className="space-y-2"><div className="grid grid-cols-2 gap-2"><div><label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Größe (px)</label><input type="number" className="nice-input" value={getFontSizePx(r.textSize)} onChange={e=>updateRes(r.id, 'textSize', parseInt(e.target.value))} /></div><div><label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Stil</label><label className="flex items-center gap-2 mt-1.5"><input type="checkbox" checked={r.textBold || false} onChange={e=>updateRes(r.id, 'textBold', e.target.checked)} /> Fett</label></div></div><div className="grid grid-cols-2 gap-2"><div><label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Ausrichtung H.</label><select className="nice-select" value={r.textAlign || 'text-left'} onChange={e=>updateRes(r.id, 'textAlign', e.target.value)}><option value="text-left">Links</option><option value="text-center">Mitte</option><option value="text-right">Rechts</option></select></div><div><label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Ausrichtung V.</label><select className="nice-select" value={r.verticalAlign || 'align-top'} onChange={e=>updateRes(r.id, 'verticalAlign', e.target.value)}><option value="align-top">Oben</option><option value="align-middle">Mitte</option><option value="align-bottom">Unten</option></select></div></div><div><label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Textfarbe</label><div className="flex gap-1 flex-wrap">{TEXT_COLORS.map(c=><button key={c.name} title={c.name} onClick={()=>updateRes(r.id,'textColor',c.value)} className={`w-4 h-4 rounded-full border border-slate-200 ${c.value.replace('text-', 'bg-')} ${r.textColor===c.value ? 'ring-2 ring-offset-1 ring-slate-400':''}`}/>)}</div></div></div>)}
{(r.type === RESOURCE_TYPES.CHECK || r.type === RESOURCE_TYPES.CHECK2) && (<div><div className="mb-2"><label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Größe</label><select className="nice-select" value={r.checkSize || 'big'} onChange={e=>updateRes(r.id, 'checkSize', e.target.value)}><option value="big">Groß</option><option value="small">Klein</option></select></div><div className="mb-2"><label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Farbe 1 (Klick 1)</label><div className="flex gap-1 flex-wrap">{CHECK_COLORS.map(c=><button key={c.name} title={c.name} onClick={()=>updateRes(r.id,'checkColor1',c.value)} className={`w-4 h-4 rounded-none ${c.value} ${r.checkColor1===c.value ? 'ring-2 ring-offset-1 ring-slate-400':''}`}/>)}</div></div>{r.type === RESOURCE_TYPES.CHECK2 && (<div><label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Farbe 2 (Klick 2)</label><div className="flex gap-1 flex-wrap">{CHECK_COLORS.map(c=><button key={c.name} title={c.name} onClick={()=>updateRes(r.id,'checkColor2',c.value)} className={`w-4 h-4 rounded-none ${c.value} ${r.checkColor2===c.value ? 'ring-2 ring-offset-1 ring-slate-400':''}`}/>)}</div></div>)}</div>)}
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-slate-100"><div><label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Hintergrund (Name)</label><div className="flex gap-1 flex-wrap">{COLORS.map(c=><button key={c.name} title={c.name} onClick={()=>updateRes(r.id,'headerColor',c.value)} className={`w-3 h-3 rounded-none border border-slate-300 ${c.value} ${r.headerColor===c.value ? 'ring-1 ring-offset-1 ring-slate-400':''}`}/>)}</div></div><div><label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Hintergrund (Zellen)</label><div className="flex gap-1 flex-wrap">{COLORS.map(c=><button key={c.name} title={c.name} onClick={()=>updateRes(r.id,'cellColor',c.value)} className={`w-3 h-3 rounded-none border border-slate-300 ${c.value} ${r.cellColor===c.value ? 'ring-1 ring-offset-1 ring-slate-400':''}`}/>)}</div></div></div>
<div className="flex justify-between border-t pt-2 mt-1"><div className="flex gap-1"><button onClick={()=>move(r.id,'outdent')} className="p-1 bg-slate-100 rounded-none"><IconMoveLeft/></button><button onClick={()=>move(r.id,'indent')} className="p-1 bg-slate-100 rounded-none"><IconMoveRight/></button></div><button onClick={()=>setEditingResource(null)} className="text-blue-600 hover:underline">Fertig</button></div></div>)}</div>))}</div>
</div>
)}
{/* CALENDARS TAB (NUR ADMIN) */}
{rightTab === 'calendars' && session.role === 'admin' && (
<div className="flex-1 p-3 space-y-4 bg-slate-50 overflow-y-auto min-h-0">
<div className="bg-white p-2 border rounded-sm space-y-2">
<div className="font-bold text-xs text-slate-500 uppercase">Neuer Kalender</div>
<div className="flex gap-2">
<input className="nice-input flex-1" placeholder="Name" value={newCalName} onChange={e=>setNewCalName(e.target.value)} />
<select className="nice-select w-24" value={newCalType} onChange={e=>setNewCalType(e.target.value)}>
<option value="local">Lokal</option>
<option value="remote">Abo (URL)</option>
</select>
</div>
{newCalType === 'remote' && (
<div className="relative">
<textarea className="w-full border p-1.5 text-xs h-16 resize-y rounded-sm" placeholder="iCal URL oder Text..." value={newCalUrl} onChange={e=>setNewCalUrl(e.target.value)} />
<div className="absolute bottom-1 right-1 text-slate-400"><IconLink /></div>
</div>
)}
<button onClick={() => addCal(newCalType)} className="w-full bg-blue-600 text-white p-1.5 flex justify-center items-center gap-1 hover:bg-blue-700 transition-colors rounded-sm text-xs font-bold"><IconDownload/> Hinzufügen</button>
</div>
<div className="space-y-2" onDragOver={(e) => e.preventDefault()}>
{calendars.map((c, index) => (
<div
key={c.id}
draggable
onDragStart={() => setDraggedCalId(c.id)}
onDrop={(e) => onCalDrop(e, c.id)}
className={`bg-white p-2 border shadow-sm flex flex-col gap-1 rounded-sm ${!c.active && 'opacity-50 grayscale'}`}
>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<IconGrip className="text-slate-300 cursor-move"/>
<span className="font-bold text-slate-700 text-xs">{c.name} <span className="font-normal text-slate-400">({c.type === 'remote' ? 'Abo' : 'Lokal'})</span></span>
</div>
<div className="flex gap-1">
<div className="flex flex-col mr-1">
<button onClick={() => moveCal(c.id, 'up')} className="text-slate-400 hover:text-slate-600 text-[8px]"><IconMoveUp className="w-3 h-3"/></button>
<button onClick={() => moveCal(c.id, 'down')} className="text-slate-400 hover:text-slate-600 text-[8px]"><IconMoveDown className="w-3 h-3"/></button>
</div>
{c.type === 'remote' && c.url && <button onClick={() => fetchCalendar(c.id, c.url)} className="text-blue-500 p-1 hover:bg-blue-50 rounded-sm" title="Aktualisieren"><IconRefresh className="w-3 h-3"/></button>}
{c.type === 'local' && <button onClick={() => exportLocalCal(c.id)} className="text-green-600 p-1 hover:bg-green-50 rounded-sm" title="Export .ics"><IconExport className="w-3 h-3"/></button>}
<button onClick={()=>{ const nC = calendars.map(x=>x.id===c.id?{...x, active:!x.active}:x); setCalendars(nC); }} className="text-slate-400 hover:text-slate-600 p-1 hover:bg-slate-100 rounded-sm">{c.active !== false ? <IconEye className="w-3 h-3"/> : <IconEyeOff className="w-3 h-3"/>}</button>
<button onClick={()=>setCalendars(calendars.filter(x=>x.id!==c.id))} className="text-red-400 hover:text-red-600 p-1 hover:bg-red-50 rounded-sm"><IconX className="w-3 h-3"/></button>
</div>
</div>
<div className="flex gap-3 text-[10px] mt-1 pt-1 border-t border-slate-100">
<label className="flex items-center gap-1 cursor-pointer text-slate-500"><input type="checkbox" checked={c.displayOptions?.showTime !== false} onChange={() => setCalendars(prev => prev.map(x => x.id === c.id ? { ...x, displayOptions: { ...x.displayOptions, showTime: !x.displayOptions?.showTime } } : x))} className="accent-teal-600" /> Uhrzeit</label>
<label className="flex items-center gap-1 cursor-pointer text-slate-500"><input type="checkbox" checked={c.displayOptions?.showLocation} onChange={() => setCalendars(prev => prev.map(x => x.id === c.id ? { ...x, displayOptions: { ...x.displayOptions, showLocation: !x.displayOptions?.showLocation } } : x))} className="accent-teal-600" /> Ort</label>
</div>
<div className="flex gap-1 mt-1 pt-1 border-t border-slate-100 flex-wrap">
{CAL_COLORS.map(col => (
<button key={col.name} onClick={() => setCalendars(prev => prev.map(x => x.id === c.id ? { ...x, color: col } : x))} className={`w-3 h-3 rounded-full border ${c.color?.name === col.name ? 'border-slate-800 ring-1 ring-slate-400' : 'border-transparent'} ${col.bg}`} title={col.name}/>
))}
</div>
</div>
))}
{calendars.length === 0 && <div className="text-xs text-slate-400 text-center italic p-4">Keine Kalender vorhanden.</div>}
</div>
</div>
)}
{rightTab === 'user' && (
<div className="flex-1 p-4 space-y-6 bg-slate-50 overflow-y-auto min-h-0">
<div className="flex items-center justify-between mb-4 border-b pb-2">
<div className="font-bold text-slate-700 flex items-center gap-2"><IconUser/> Benutzerkonto</div>
<button onClick={()=>setIsRightSidebarOpen(false)} className="text-slate-400 hover:text-slate-600"><IconX/></button>
</div>
{/* Mein Profil (Immer sichtbar, auch für Nicht-Admins) */}
<div className="bg-white p-3 border border-slate-200 rounded-sm shadow-sm">
<div className="font-bold text-xs text-slate-500 uppercase mb-3">Mein Profil</div>
<div className="space-y-3">
<div>
<label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Benutzername</label>
<input className="nice-input" value={selfName} onChange={e=>setSelfName(e.target.value)} />
</div>
<div>
<label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Passwort</label>
<input className="nice-input" value={selfPass} onChange={e=>setSelfPass(e.target.value)} />
</div>
<button onClick={updateUserSelf} className="w-full bg-teal-600 hover:bg-teal-700 text-white py-1.5 text-xs font-bold rounded-sm mt-1">Speichern</button>
</div>
</div>
{/* Benutzerverwaltung (Nur für Admins) */}
{session.role === 'admin' && (
<div className="bg-white p-3 border border-slate-200 rounded-sm shadow-sm">
<div className="font-bold text-xs text-slate-500 uppercase mb-3 flex justify-between items-center">
<span>Benutzerverwaltung</span>
{editingUserId === null && <button onClick={startCreateUser} className="bg-teal-600 hover:bg-teal-700 text-white px-2 py-0.5 text-[10px] font-bold rounded-sm flex items-center gap-1"><IconPlus className="w-3 h-3"/> Neu</button>}
</div>
{/* LIST MODE */}
{editingUserId === null && (
<div className="space-y-1">
{users.map(u => (
<div key={u.id} className="flex justify-between items-center text-xs bg-slate-50 p-2 border border-slate-100 hover:border-slate-300 group transition-colors">
<div className="flex flex-col">
<span className="font-bold text-slate-700">{u.username} {u.id === session.id && <span className="text-[9px] text-teal-600 bg-teal-50 px-1 rounded-sm border border-teal-100 ml-1">Du</span>}</span>
<span className="text-[9px] text-slate-400 uppercase tracking-wider">{u.role}</span>
</div>
<div className="flex gap-1 opacity-60 group-hover:opacity-100 transition-opacity">
<button onClick={()=>startEditUser(u)} className="p-1.5 bg-white border border-slate-200 text-slate-500 hover:text-blue-600 hover:border-blue-300 rounded-sm shadow-sm transition-colors" title="Bearbeiten"><IconEdit className="w-3 h-3"/></button>
<button onClick={()=>deleteUser(u.id)} className="p-1.5 bg-white border border-slate-200 text-slate-500 hover:text-red-600 hover:border-red-300 rounded-sm shadow-sm transition-colors" title="Löschen"><IconTrash className="w-3 h-3"/></button>
</div>
</div>
))}
</div>
)}
{/* EDIT MODE */}
{editingUserId !== null && (
<div className="bg-slate-50 p-3 border border-slate-200 animate-fade-in space-y-3">
<div className="text-xs font-bold text-slate-800 border-b pb-1 mb-2">{editingUserId === 'new' ? 'Neuer Benutzer' : 'Benutzer bearbeiten'}</div>
<div>
<label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Name</label>
<input className="nice-input" placeholder="Name" value={tempUserData.username} onChange={e=>setTempUserData({...tempUserData, username:e.target.value})} />
</div>
<div>
<label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Passwort</label>
<input className="nice-input" placeholder="Passwort" value={tempUserData.password} onChange={e=>setTempUserData({...tempUserData, password:e.target.value})} />
</div>
<div>
<label className="block text-[10px] uppercase font-bold text-slate-400 mb-1">Rolle</label>
<select className="nice-select" value={tempUserData.role} onChange={e=>setTempUserData({...tempUserData, role:e.target.value})}>
<option value="user">User (Eingeschränkt)</option>
<option value="admin">Admin (Vollzugriff)</option>
<option value="viewer">Viewer (Nur Lesen)</option>
</select>
</div>
<div className="flex gap-2 pt-1">
<button onClick={cancelEditUser} className="flex-1 bg-white border border-slate-300 text-slate-600 py-1.5 text-xs font-bold rounded-sm hover:bg-slate-50 transition-colors">Abbrechen</button>
<button onClick={saveUserAdmin} className="flex-1 bg-slate-800 hover:bg-slate-900 text-white py-1.5 text-xs font-bold rounded-sm transition-colors">Speichern</button>
</div>
</div>
)}
</div>
)}
</div>
)}
{rightTab === 'settings' && session.role === 'admin' && (
<div className="flex-1 p-0 bg-slate-50 overflow-y-auto min-h-0">
<AccordionSection title="Beschriftung" isOpen={activeOptionTab === 'labels'} onToggle={() => toggleOptionTab('labels')}>
<div className="space-y-2 mt-2">
<div><label className="text-xs text-slate-500">App Titel</label><input className="nice-input" value={generalConfig.appTitle} onChange={e=>setGeneralConfig({...generalConfig, appTitle: e.target.value})} /></div>
<div><label className="text-xs text-slate-500">Bereich Spalte</label><input className="nice-input" value={generalConfig.resourceColTitle} onChange={e=>setGeneralConfig({...generalConfig, resourceColTitle: e.target.value})} /></div>
</div>
</AccordionSection>
<AccordionSection title="Kopfzeile (Datum)" isOpen={activeOptionTab === 'header'} onToggle={() => toggleOptionTab('header')}>
<div className="mb-3 border-b border-slate-100 pb-2 mt-2"><div className="text-[10px] font-bold text-slate-500 mb-1 uppercase">Wochentag</div><div className="grid grid-cols-2 gap-2 text-xs mb-2"><div><label className="block text-[9px] text-slate-400 mb-0.5">Größe</label><input type="number" className="nice-input" value={headerOptions.weekday?.fontSize || 10} onChange={e=>setHeaderOptions({...headerOptions, weekday: {...headerOptions.weekday, fontSize: parseInt(e.target.value)}})} /></div><div className="flex items-end"><label className="flex items-center gap-2 cursor-pointer mb-1.5"><input type="checkbox" checked={headerOptions.weekday?.bold !== false} onChange={e=>setHeaderOptions({...headerOptions, weekday: {...headerOptions.weekday, bold: e.target.checked}})} /> Fett</label></div></div><div className="flex items-center gap-2 text-xs"><label className="text-[9px] text-slate-400">Farbe:</label><input type="color" className="w-6 h-6 p-0 border-0 rounded-none cursor-pointer" value={headerOptions.weekday?.color || '#64748b'} onChange={e=>setHeaderOptions({...headerOptions, weekday: {...headerOptions.weekday, color: e.target.value}})} /></div></div>
<div className="mb-1 border-b border-slate-100 pb-2"><div className="text-[10px] font-bold text-slate-500 mb-1 uppercase">Datum</div><div className="grid grid-cols-2 gap-2 text-xs mb-2"><div><label className="block text-[9px] text-slate-400 mb-0.5">Größe</label><input type="number" className="nice-input" value={headerOptions.date?.fontSize || 12} onChange={e=>setHeaderOptions({...headerOptions, date: {...headerOptions.date, fontSize: parseInt(e.target.value)}})} /></div><div className="flex items-end"><label className="flex items-center gap-2 cursor-pointer mb-1.5"><input type="checkbox" checked={headerOptions.date?.bold !== false} onChange={e=>setHeaderOptions({...headerOptions, date: {...headerOptions.date, bold: e.target.checked}})} /> Fett</label></div></div><div className="flex items-center gap-2 text-xs"><label className="text-[9px] text-slate-400">Farbe:</label><input type="color" className="w-6 h-6 p-0 border-0 rounded-none cursor-pointer" value={headerOptions.date?.color || '#0f172a'} onChange={e=>setHeaderOptions({...headerOptions, date: {...headerOptions.date, color: e.target.value}})} /></div></div>
<div className="mt-2"><div className="text-[10px] font-bold text-slate-500 mb-2 uppercase">Hintergrundfarben (Mo-So)</div><div className="grid grid-cols-7 gap-1">{['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map((day, idx) => { const jsDayIndex = (idx + 1) % 7; return (<div key={day} className="flex flex-col items-center"><span className="text-[9px] text-slate-400 mb-1">{day}</span><input type="color" className="w-6 h-6 p-0 border border-slate-200 rounded-none cursor-pointer" value={generalConfig.dayColors?.[jsDayIndex] || '#ffffff'} onChange={e => { const newColors = { ...generalConfig.dayColors, [jsDayIndex]: e.target.value }; setGeneralConfig({ ...generalConfig, dayColors: newColors }); }} /></div>); })}</div></div>
</AccordionSection>
<AccordionSection title="Darstellung & Druck" isOpen={activeOptionTab === 'view'} onToggle={() => toggleOptionTab('view')}>
<div className="space-y-2 text-xs mt-2">
<div><label className="text-xs text-slate-500">Basis Schriftgröße: {baseFontSize}px</label><input type="range" min="8" max="24" value={baseFontSize} onChange={(e) => setBaseFontSize(parseInt(e.target.value))} className="w-full" /></div>
<div><label className="text-xs text-slate-500">Zeilenhöhe: {globalRowHeight}px</label><input type="range" min="8" max="80" value={globalRowHeight} onChange={(e) => setGlobalRowHeight(parseInt(e.target.value))} className="w-full" /></div>
<div><label className="text-xs text-slate-500">Druck Zeilenhöhe: {printOptions.printRowHeight || 28}px</label><input type="range" min="8" max="60" value={printOptions.printRowHeight || 28} onChange={(e) => setPrintOptions({...printOptions, printRowHeight: parseInt(e.target.value)})} className="w-full" /></div>
<label className="flex items-center gap-2 cursor-pointer mt-2"><input type="checkbox" checked={printOptions.showCalendars !== false} onChange={() => setPrintOptions({...printOptions, showCalendars: !printOptions.showCalendars})} className="sidebar-checkbox" /> Kalender drucken</label>
</div>
</AccordionSection>
<AccordionSection title="Aktionen" isOpen={activeOptionTab === 'actions'} onToggle={() => toggleOptionTab('actions')}>
<div className="space-y-2 mt-2">
<button onClick={()=>saveToServer(true)} className="w-full flex items-center justify-center gap-2 p-2 bg-teal-600 text-white text-xs font-bold rounded-sm hover:bg-teal-700"><IconSave/> Jetzt speichern (Server)</button>
<button onClick={exportBackup} className="w-full flex items-center justify-center gap-2 p-2 bg-white border border-slate-300 hover:bg-slate-50 text-xs rounded-sm"><IconDownload/> Backup Datei exportieren</button>
<button onClick={exportExcel} className="w-full flex items-center justify-center gap-2 p-2 bg-white border border-green-200 text-green-700 hover:bg-green-50 text-xs rounded-sm"><IconFileExcel/> Excel Export</button>
<button onClick={resetAllData} className="w-full flex items-center justify-center gap-2 p-2 bg-white border border-red-200 text-red-600 hover:bg-red-50 text-xs rounded-sm mt-4"><IconTrash/> Alles Löschen</button>
</div>
</AccordionSection>
</div>
)}
</div>
</div>
<div className="bg-white border-t p-1 text-xs text-slate-400 flex justify-between print:hidden"><span>{visibleRows.length} Reihen</span><span className="flex items-center gap-1"><IconSave /> {isSaving ? 'Speichert...' : 'Gespeichert'}</span></div>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>