4313 lines
172 KiB
HTML
4313 lines
172 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>BNI Chapter Sonne – Vorstellungsrunde</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js"></script>
|
||
<style>
|
||
:root {
|
||
--red: #cc0000;
|
||
--red-dark: #990000;
|
||
--red-light: rgba(204,0,0,0.12);
|
||
--navy: #1a2744;
|
||
--bg: #f0f2f5;
|
||
--card: #ffffff;
|
||
--text: #1a1a2e;
|
||
--muted: #6b7280;
|
||
--border: #e5e7eb;
|
||
--success: #16a34a;
|
||
--warning: #d97706;
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: var(--bg);
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ============================================================
|
||
ROUTING
|
||
============================================================ */
|
||
.view { display: none; width: 100vw; height: 100vh; }
|
||
.view.active { display: flex; }
|
||
|
||
/* ============================================================
|
||
DISPLAY VIEW
|
||
============================================================ */
|
||
#view-display {
|
||
background: linear-gradient(160deg, #0f1f45 0%, #1a2744 40%, #0d1b35 100%);
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
padding-top: clamp(90px, 12vh, 160px);
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Decorative background circles */
|
||
#view-display::before {
|
||
content: '';
|
||
position: absolute;
|
||
width: 600px; height: 600px;
|
||
background: radial-gradient(circle, rgba(204,0,0,0.15) 0%, transparent 70%);
|
||
top: -150px; right: -150px;
|
||
pointer-events: none;
|
||
}
|
||
#view-display::after {
|
||
content: '';
|
||
position: absolute;
|
||
width: 400px; height: 400px;
|
||
background: radial-gradient(circle, rgba(255,255,255,0.05) 0%, transparent 70%);
|
||
bottom: -100px; left: -100px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
#fullscreen-btn {
|
||
position: fixed;
|
||
bottom: 20px; right: 20px;
|
||
z-index: 999;
|
||
background: rgba(255,255,255,0.08);
|
||
border: 1px solid rgba(255,255,255,0.15);
|
||
color: rgba(255,255,255,0.4);
|
||
border-radius: 8px;
|
||
padding: 6px 10px;
|
||
font-size: 1.2rem;
|
||
cursor: pointer;
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
}
|
||
#view-display:hover #fullscreen-btn { opacity: 1; }
|
||
#fullscreen-btn:hover { background: rgba(255,255,255,0.18); color: white; }
|
||
.display-header {
|
||
position: absolute;
|
||
top: clamp(14px, 2vh, 32px); left: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: clamp(10px, 1.5vh, 18px);
|
||
z-index: 10;
|
||
}
|
||
|
||
.bni-badge {
|
||
height: clamp(44px, 6vh, 80px);
|
||
width: auto;
|
||
display: block;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.chapter-name {
|
||
color: rgba(255,255,255,0.7);
|
||
font-size: clamp(1rem, 1.8vh, 2rem);
|
||
font-weight: 300;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.progress-indicator {
|
||
position: absolute;
|
||
top: clamp(28px, 4vh, 50px); right: 40px;
|
||
color: rgba(255,255,255,0.5);
|
||
font-size: clamp(0.85rem, 1.5vh, 1.8rem);
|
||
letter-spacing: 1px;
|
||
z-index: 10;
|
||
}
|
||
|
||
.round-badge {
|
||
position: absolute;
|
||
top: 96px; right: 40px;
|
||
background: var(--red);
|
||
color: white;
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
padding: 4px 12px;
|
||
border-radius: 20px;
|
||
letter-spacing: 1px;
|
||
text-transform: uppercase;
|
||
display: none;
|
||
}
|
||
.round-badge.visible { display: block; }
|
||
|
||
.main-card {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: clamp(40px, 5vw, 100px);
|
||
background: rgba(255,255,255,0.07);
|
||
backdrop-filter: blur(24px);
|
||
border: 1px solid rgba(255,255,255,0.12);
|
||
border-radius: clamp(20px, 3vh, 36px);
|
||
padding: clamp(32px, 5vh, 80px) clamp(40px, 5vw, 100px);
|
||
max-width: none;
|
||
width: 96vw;
|
||
flex: 1;
|
||
position: relative;
|
||
z-index: 1;
|
||
margin-bottom: clamp(16px, 2.5vh, 40px);
|
||
}
|
||
|
||
.photo-section {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 20px;
|
||
}
|
||
|
||
.member-photo-wrap {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
border: 4px solid var(--red);
|
||
overflow: hidden;
|
||
background: rgba(255,255,255,0.1);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 0 40px rgba(204,0,0,0.3);
|
||
}
|
||
|
||
.member-photo-wrap img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.initials-avatar {
|
||
font-size: clamp(4rem, 12vh, 12rem);
|
||
font-weight: 700;
|
||
color: white;
|
||
}
|
||
|
||
.qr-corner {
|
||
position: absolute;
|
||
top: clamp(102px, 14vh, 180px);
|
||
right: clamp(24px, 3vw, 60px);
|
||
z-index: 3;
|
||
background: white;
|
||
padding: clamp(8px, 1vh, 14px);
|
||
border-radius: 14px;
|
||
box-shadow: 0 4px 24px rgba(0,0,0,0.35);
|
||
}
|
||
|
||
.qr-label {
|
||
color: #1a2744;
|
||
font-size: clamp(0.6rem, 1vh, 0.9rem);
|
||
text-align: center;
|
||
margin-top: 5px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.info-section {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: clamp(14px, 2.5vh, 36px);
|
||
}
|
||
|
||
.substitute-label {
|
||
color: rgba(255,255,255,0.5);
|
||
font-size: clamp(0.75rem, 1.5vh, 1.5rem);
|
||
text-transform: uppercase;
|
||
letter-spacing: 2px;
|
||
border-left: 3px solid var(--red);
|
||
padding-left: 10px;
|
||
}
|
||
|
||
.display-name {
|
||
font-size: clamp(3rem, max(5vw, 7vh), 10rem);
|
||
font-weight: 800;
|
||
color: white;
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.display-company {
|
||
font-size: clamp(1.6rem, max(2.8vw, 4vh), 6rem);
|
||
color: rgba(255,255,255,0.75);
|
||
font-weight: 400;
|
||
}
|
||
|
||
.display-specialty {
|
||
display: inline-block;
|
||
background: rgba(204,0,0,0.25);
|
||
border: 1px solid rgba(204,0,0,0.5);
|
||
color: #ff8080;
|
||
font-size: clamp(1rem, 2.2vh, 2.4rem);
|
||
padding: clamp(6px, 1vh, 14px) clamp(12px, 1.8vw, 28px);
|
||
border-radius: 30px;
|
||
align-self: flex-start;
|
||
}
|
||
|
||
.visitor-tag {
|
||
display: inline-block;
|
||
background: rgba(234,179,8,0.2);
|
||
border: 1px solid rgba(234,179,8,0.4);
|
||
color: #fcd34d;
|
||
font-size: clamp(0.8rem, 1.6vh, 1.6rem);
|
||
padding: clamp(4px, 0.8vh, 10px) clamp(10px, 1.5vw, 20px);
|
||
border-radius: 20px;
|
||
align-self: flex-start;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.presenter-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-top: 20px;
|
||
padding: 12px 16px;
|
||
background: rgba(255,255,255,0.07);
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.presenter-avatar {
|
||
width: clamp(36px, 4.5vh, 64px); height: clamp(36px, 4.5vh, 64px);
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
border: 2px solid rgba(255,255,255,0.2);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: clamp(0.8rem, 1.4vh, 1.4rem); font-weight: 700; color: white;
|
||
}
|
||
|
||
.presenter-avatar img {
|
||
width: 100%; height: 100%; object-fit: cover;
|
||
}
|
||
|
||
.presenter-label {
|
||
font-size: clamp(0.65rem, 1.2vh, 1.2rem);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1.5px;
|
||
color: rgba(255,255,255,0.4);
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.presenter-name {
|
||
font-size: clamp(0.9rem, 1.8vh, 1.8rem);
|
||
font-weight: 600;
|
||
color: rgba(255,255,255,0.85);
|
||
}
|
||
|
||
.presenter-company {
|
||
font-size: clamp(0.75rem, 1.4vh, 1.4rem);
|
||
color: rgba(255,255,255,0.5);
|
||
}
|
||
|
||
.empty-state-display {
|
||
color: rgba(255,255,255,0.4);
|
||
font-size: 1.5rem;
|
||
text-align: center;
|
||
}
|
||
|
||
/* Next Bar */
|
||
.next-bar {
|
||
position: absolute;
|
||
top: 0; left: 0; right: 0;
|
||
background: rgba(0,0,0,0.6);
|
||
backdrop-filter: blur(12px);
|
||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||
padding: clamp(12px, 1.8vh, 24px) 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: clamp(16px, 2.5vh, 36px);
|
||
z-index: 5;
|
||
}
|
||
|
||
#next-bar {
|
||
padding-left: 320px;
|
||
}
|
||
|
||
|
||
|
||
.next-bar-divider {
|
||
width: 1px;
|
||
height: clamp(40px, 7vh, 96px);
|
||
background: rgba(255,255,255,0.15);
|
||
}
|
||
|
||
.next-photo-small {
|
||
width: clamp(56px, 8.5vh, 120px); height: clamp(56px, 8.5vh, 120px);
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
border: 2px solid rgba(255,255,255,0.3);
|
||
background: rgba(255,255,255,0.1);
|
||
}
|
||
|
||
.next-info .next-name-text {
|
||
color: white;
|
||
font-size: clamp(1.5rem, 3.8vh, 4.2rem);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.next-info .next-company-text {
|
||
color: rgba(255,255,255,0.6);
|
||
font-size: clamp(1rem, 2.5vh, 2.8rem);
|
||
}
|
||
|
||
.next-bar-label {
|
||
color: rgba(255,255,255,0.5);
|
||
font-size: clamp(0.8rem, 1.8vh, 2rem);
|
||
text-transform: uppercase;
|
||
letter-spacing: 2px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.next-bar-end-text {
|
||
color: rgba(255,255,255,0.4);
|
||
font-style: italic;
|
||
font-size: clamp(1.2rem, 2.5vh, 2.8rem);
|
||
}
|
||
|
||
/* ============================================================
|
||
CONTROL VIEW
|
||
============================================================ */
|
||
#view-control {
|
||
flex-direction: column;
|
||
background: var(--bg);
|
||
}
|
||
|
||
.ctrl-header {
|
||
background: var(--navy);
|
||
color: white;
|
||
padding: 14px 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
flex-shrink: 0;
|
||
box-shadow: 0 2px 12px rgba(0,0,0,0.3);
|
||
}
|
||
|
||
.ctrl-header .bni-badge { height: 38px; border-radius: 6px; }
|
||
|
||
.ctrl-title {
|
||
font-size: 1.1rem;
|
||
font-weight: 600;
|
||
flex: 1;
|
||
}
|
||
|
||
.btn {
|
||
padding: 9px 20px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.btn:active { transform: scale(0.97); }
|
||
|
||
.btn-primary {
|
||
background: var(--red);
|
||
color: white;
|
||
}
|
||
.btn-primary:hover { background: var(--red-dark); }
|
||
|
||
.btn-secondary {
|
||
background: rgba(255,255,255,0.15);
|
||
color: white;
|
||
border: 1px solid rgba(255,255,255,0.2);
|
||
}
|
||
.btn-secondary:hover { background: rgba(255,255,255,0.25); }
|
||
|
||
.btn-ghost {
|
||
background: transparent;
|
||
color: var(--muted);
|
||
border: 1px solid var(--border);
|
||
}
|
||
.btn-ghost:hover { background: white; color: var(--text); }
|
||
|
||
.btn-sm {
|
||
padding: 5px 12px;
|
||
font-size: 0.82rem;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.btn-danger {
|
||
background: transparent;
|
||
color: #dc2626;
|
||
border: 1px solid #fca5a5;
|
||
}
|
||
.btn-danger:hover { background: #fef2f2; }
|
||
|
||
.btn-next {
|
||
background: var(--red);
|
||
color: white;
|
||
font-size: 1rem;
|
||
padding: 11px 28px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.btn-next:hover { background: var(--red-dark); }
|
||
.btn-next:disabled { background: #9ca3af; cursor: not-allowed; }
|
||
.btn-next:disabled:active { transform: none; }
|
||
|
||
.ctrl-body {
|
||
flex: 1;
|
||
display: grid;
|
||
grid-template-columns: 1fr 320px;
|
||
gap: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Left panel: member queue */
|
||
.panel-left {
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
border-right: 1px solid var(--border);
|
||
background: var(--bg);
|
||
}
|
||
|
||
.panel-section-header {
|
||
padding: 14px 20px 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
border-bottom: 1px solid var(--border);
|
||
background: white;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.panel-section-title {
|
||
font-size: 0.8rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1.5px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.queue-tabs {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.tab-btn {
|
||
padding: 6px 14px;
|
||
border-radius: 6px;
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
border: 1px solid var(--border);
|
||
background: white;
|
||
color: var(--muted);
|
||
transition: all 0.15s;
|
||
}
|
||
.tab-btn.active {
|
||
background: var(--navy);
|
||
color: white;
|
||
border-color: var(--navy);
|
||
}
|
||
|
||
.member-queue {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 12px 16px;
|
||
min-height: 80px;
|
||
}
|
||
|
||
#queue-resize-handle {
|
||
flex-shrink: 0;
|
||
height: 10px;
|
||
background: #edf0f3;
|
||
border-top: 1px solid var(--border);
|
||
border-bottom: 1px solid var(--border);
|
||
cursor: row-resize;
|
||
user-select: none;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 3px;
|
||
transition: background 0.15s;
|
||
}
|
||
#queue-resize-handle::before,
|
||
#queue-resize-handle::after {
|
||
content: '';
|
||
display: block;
|
||
width: 28px; height: 2px;
|
||
background: #c0c8d0;
|
||
border-radius: 2px;
|
||
}
|
||
#queue-resize-handle:hover,
|
||
#queue-resize-handle.is-dragging { background: #dde2e8; }
|
||
|
||
#absent-members-section {
|
||
overflow-y: auto;
|
||
min-height: 60px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.queue-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
background: white;
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 10px 14px;
|
||
margin-bottom: 6px;
|
||
cursor: grab;
|
||
transition: all 0.15s;
|
||
position: relative;
|
||
}
|
||
|
||
.queue-item:active { cursor: grabbing; }
|
||
|
||
.queue-item.current {
|
||
border-color: var(--red);
|
||
background: #fff5f5;
|
||
box-shadow: 0 0 0 2px rgba(204,0,0,0.15);
|
||
}
|
||
|
||
.queue-item.next-up {
|
||
border-color: var(--navy);
|
||
background: #f0f4ff;
|
||
}
|
||
|
||
.queue-item.done {
|
||
opacity: 0.45;
|
||
}
|
||
|
||
.queue-item.sortable-ghost {
|
||
opacity: 0.35;
|
||
border: 2px dashed var(--red);
|
||
}
|
||
|
||
.queue-badge {
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
position: absolute;
|
||
top: 8px; right: 8px;
|
||
}
|
||
|
||
.badge-current { background: var(--red); color: white; }
|
||
.badge-next { background: var(--navy); color: white; }
|
||
.badge-done { background: #e5e7eb; color: #6b7280; }
|
||
.badge-visitor { background: #fef3c7; color: #92400e; }
|
||
.badge-substitute { background: #e0e7ff; color: #3730a3; }
|
||
|
||
.drag-handle {
|
||
color: #d1d5db;
|
||
cursor: grab;
|
||
font-size: 1.2rem;
|
||
user-select: none;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.queue-photo-wrap {
|
||
width: 44px; height: 44px;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
background: #e5e7eb;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.queue-photo-wrap img {
|
||
width: 100%; height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.queue-initials {
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
color: white;
|
||
background: var(--navy);
|
||
width: 100%; height: 100%;
|
||
display: flex; align-items: center; justify-content: center;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.queue-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.queue-name {
|
||
font-size: 0.95rem;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.queue-company {
|
||
font-size: 0.8rem;
|
||
color: var(--muted);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.queue-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sub-info {
|
||
font-size: 0.75rem;
|
||
color: #4f46e5;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
/* Absent / Medical status in queue */
|
||
.status-tag {
|
||
font-size: 0.68rem; font-weight: 700; padding: 2px 7px;
|
||
border-radius: 4px; letter-spacing: 0.5px; cursor: pointer;
|
||
border: none; white-space: nowrap;
|
||
}
|
||
.status-tag-absent { background: #fef3c7; color: #92400e; }
|
||
.status-tag-medical { background: #ede9fe; color: #5b21b6; }
|
||
|
||
/* Absent section below the main queue */
|
||
.absent-section {
|
||
padding: 10px 16px 12px;
|
||
background: #fafafa;
|
||
overflow-y: auto;
|
||
}
|
||
.absent-section-title {
|
||
font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
|
||
letter-spacing: 1px; color: var(--muted); margin-bottom: 6px;
|
||
}
|
||
.absent-section-title + .absent-section-title { margin-top: 10px; }
|
||
|
||
/* Display: Absent summary overlay */
|
||
#display-absent-summary {
|
||
position: fixed; inset: 0; z-index: 5;
|
||
display: flex; flex-direction: column;
|
||
align-items: center; justify-content: center;
|
||
padding: 56px 80px 48px;
|
||
gap: 28px;
|
||
overflow: hidden;
|
||
}
|
||
.absent-summary-heading {
|
||
color: white; font-size: 1.6rem; font-weight: 800;
|
||
letter-spacing: 3px; text-transform: uppercase;
|
||
opacity: 0.9; flex-shrink: 0;
|
||
}
|
||
.absent-summary-cols {
|
||
display: flex; gap: 60px; align-items: flex-start;
|
||
width: 100%; justify-content: center;
|
||
overflow: hidden;
|
||
}
|
||
.absent-summary-col {
|
||
flex: 1; min-width: 0; max-width: 600px;
|
||
}
|
||
.absent-summary-col-title {
|
||
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||
letter-spacing: 2px; margin-bottom: 14px; flex-shrink: 0;
|
||
}
|
||
.absent-summary-col-title.medical { color: #a78bfa; }
|
||
.absent-summary-col-title.absent { color: #fcd34d; }
|
||
.absent-summary-name {
|
||
color: white; font-size: var(--absent-name-size, 1.3rem); font-weight: 600;
|
||
margin-bottom: 0.4em; opacity: 0.92;
|
||
break-inside: avoid; line-height: 1.25;
|
||
}
|
||
#absent-list-medical, #absent-list-absent {
|
||
column-gap: 36px;
|
||
}
|
||
|
||
/* ============================================================
|
||
TIMER
|
||
============================================================ */
|
||
.photo-timer-wrap {
|
||
position: relative;
|
||
width: min(42vh, 45vmin); height: min(42vh, 45vmin);
|
||
flex-shrink: 0;
|
||
}
|
||
.timer-ring-svg {
|
||
position: absolute;
|
||
top: -14px; left: -14px;
|
||
width: calc(min(42vh, 45vmin) + 28px); height: calc(min(42vh, 45vmin) + 28px);
|
||
transform: rotate(-90deg);
|
||
pointer-events: none;
|
||
display: none;
|
||
}
|
||
.timer-ring-track {
|
||
fill: none;
|
||
stroke: rgba(255,255,255,0.12);
|
||
stroke-width: 7;
|
||
}
|
||
.timer-ring-progress {
|
||
fill: none;
|
||
stroke: rgba(255,255,255,0.75);
|
||
stroke-width: 7;
|
||
stroke-linecap: round;
|
||
transition: stroke-dashoffset 0.95s linear, stroke 0.4s;
|
||
}
|
||
.timer-ring-progress.warn { stroke: #fbbf24; }
|
||
.timer-ring-progress.danger { stroke: #ef4444; }
|
||
.timer-ring-progress.expired { stroke: #ef4444; animation: timerBlink 0.5s alternate infinite; }
|
||
@keyframes timerBlink { to { opacity: 0.15; } }
|
||
|
||
#display-timer-text {
|
||
font-size: clamp(2rem, 4vh, 5rem); font-weight: 700;
|
||
color: rgba(255,255,255,0.95);
|
||
font-variant-numeric: tabular-nums;
|
||
letter-spacing: 3px;
|
||
text-align: center;
|
||
text-shadow: 0 2px 12px rgba(0,0,0,0.5);
|
||
line-height: 1;
|
||
display: none;
|
||
}
|
||
#display-timer-text.warn { color: #fbbf24; }
|
||
#display-timer-text.danger { color: #ef4444; }
|
||
#display-timer-text.expired { color: #ef4444; animation: timerBlink 0.5s alternate infinite; }
|
||
|
||
.queue-timer-btn {
|
||
font-size: 0.75rem; font-weight: 700;
|
||
color: var(--muted);
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 5px;
|
||
padding: 3px 7px;
|
||
cursor: pointer;
|
||
font-variant-numeric: tabular-nums;
|
||
letter-spacing: 0.5px;
|
||
line-height: 1;
|
||
}
|
||
.queue-timer-btn:hover { border-color: var(--red); color: var(--red); }
|
||
.queue-timer-btn.custom { background: #fff5f5; border-color: var(--red); color: var(--red); font-weight: 800; }
|
||
|
||
#ctrl-timer-display {
|
||
font-variant-numeric: tabular-nums;
|
||
letter-spacing: 1px;
|
||
}
|
||
#ctrl-timer-display.warn { color: #fbbf24; }
|
||
#ctrl-timer-display.danger, #ctrl-timer-display.expired { color: #ef4444; }
|
||
|
||
/* Right panel */
|
||
.panel-right {
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: white;
|
||
}
|
||
|
||
.panel-right-tabs {
|
||
display: flex;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.panel-right-tab {
|
||
flex: 1;
|
||
padding: 10px 4px;
|
||
text-align: center;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.4px;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.panel-right-tab.active {
|
||
color: var(--red);
|
||
border-bottom-color: var(--red);
|
||
}
|
||
|
||
.panel-right-content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.right-panel-section { display: none; }
|
||
.right-panel-section.active { display: block; }
|
||
|
||
/* Visitors panel */
|
||
.visitor-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.visitor-item .v-name {
|
||
flex: 1;
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.visitor-item .v-company {
|
||
font-size: 0.8rem;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.add-form {
|
||
padding: 16px;
|
||
background: #f9fafb;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.add-form input, .add-form textarea, .add-form select {
|
||
width: 100%;
|
||
padding: 8px 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
font-size: 0.88rem;
|
||
font-family: inherit;
|
||
margin-bottom: 8px;
|
||
color: var(--text);
|
||
}
|
||
|
||
.add-form input:focus, .add-form textarea:focus {
|
||
outline: none;
|
||
border-color: var(--red);
|
||
}
|
||
|
||
.add-form label {
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.form-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* Members management panel */
|
||
.members-list-manage {
|
||
padding: 12px;
|
||
}
|
||
|
||
.member-manage-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
margin-bottom: 6px;
|
||
background: #fafafa;
|
||
}
|
||
|
||
.member-manage-item .m-name {
|
||
flex: 1;
|
||
font-size: 0.88rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.member-manage-item .m-company {
|
||
font-size: 0.78rem;
|
||
color: var(--muted);
|
||
}
|
||
|
||
/* Modal */
|
||
.modal-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.55);
|
||
z-index: 1000;
|
||
align-items: center;
|
||
justify-content: center;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
|
||
.modal-overlay.open { display: flex; }
|
||
|
||
.modal {
|
||
background: white;
|
||
border-radius: 16px;
|
||
padding: 28px;
|
||
width: min(560px, 95vw);
|
||
max-height: 85vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 1.1rem;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
}
|
||
|
||
.modal-close {
|
||
background: none;
|
||
border: none;
|
||
font-size: 1.4rem;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
padding: 4px;
|
||
}
|
||
|
||
.modal input, .modal textarea, .modal select {
|
||
width: 100%;
|
||
padding: 9px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
font-size: 0.9rem;
|
||
font-family: inherit;
|
||
color: var(--text);
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.modal input:focus, .modal textarea:focus {
|
||
outline: none;
|
||
border-color: var(--red);
|
||
}
|
||
|
||
.modal label {
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.modal-footer {
|
||
display: flex;
|
||
gap: 10px;
|
||
justify-content: flex-end;
|
||
margin-top: 20px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
.btn-save {
|
||
background: var(--red);
|
||
color: white;
|
||
}
|
||
.btn-save:hover { background: var(--red-dark); }
|
||
|
||
/* Profile Extras Section in Modal */
|
||
.extras-section-divider {
|
||
border: none;
|
||
border-top: 1px solid var(--border);
|
||
margin: 16px 0 12px;
|
||
}
|
||
.extras-section-title {
|
||
font-size: 0.72rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1.5px;
|
||
color: var(--muted);
|
||
margin-bottom: 12px;
|
||
}
|
||
.offline-photo-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 12px;
|
||
background: var(--bg);
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border);
|
||
margin-bottom: 8px;
|
||
}
|
||
.offline-photo-thumb {
|
||
width: 52px; height: 52px;
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
border: 2px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
.offline-photo-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
.btn-file-label {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 5px 12px;
|
||
font-size: 0.82rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border);
|
||
background: white;
|
||
color: var(--muted);
|
||
transition: all 0.15s;
|
||
}
|
||
.btn-file-label:hover { background: white; color: var(--text); }
|
||
|
||
/* Import/Export */
|
||
.import-export-panel {
|
||
padding: 16px;
|
||
}
|
||
|
||
.import-export-panel textarea {
|
||
width: 100%;
|
||
height: 200px;
|
||
font-family: monospace;
|
||
font-size: 0.8rem;
|
||
padding: 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
resize: vertical;
|
||
}
|
||
|
||
/* ============================================================
|
||
UTILITY
|
||
============================================================ */
|
||
.flex-gap-8 { display: flex; align-items: center; gap: 8px; }
|
||
.mt-8 { margin-top: 8px; }
|
||
.mt-16 { margin-top: 16px; }
|
||
.text-muted { color: var(--muted); font-size: 0.85rem; }
|
||
.text-red { color: var(--red); }
|
||
.w-full { width: 100%; }
|
||
|
||
.empty-hint {
|
||
text-align: center;
|
||
color: var(--muted);
|
||
padding: 32px 16px;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.empty-hint h3 {
|
||
font-size: 1rem;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.status-dot {
|
||
width: 8px; height: 8px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.dot-live { background: #22c55e; }
|
||
.dot-idle { background: #9ca3af; }
|
||
|
||
/* Scrollbar */
|
||
::-webkit-scrollbar { width: 6px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }
|
||
|
||
/* Prevent photo load errors showing broken image icon */
|
||
img { font-size: 0; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ============================================================
|
||
DISPLAY VIEW (Monitor 2)
|
||
============================================================ -->
|
||
<div id="view-display" class="view">
|
||
<button id="fullscreen-btn" onclick="toggleFullscreen()" title="Vollbild">⛶</button>
|
||
<div class="display-header">
|
||
<img class="bni-badge" id="display-logo" src="logo.jpeg" alt="BNI">
|
||
<div class="chapter-name" id="display-chapter-name">Vorstellungsrunde</div>
|
||
<a href="#" onclick="window.location.hash=''" style="margin-left:16px;color:rgba(255,255,255,0.25);font-size:0.78rem;text-decoration:none;letter-spacing:1px;" title="Zur Steuerung">⚙ Steuerung</a>
|
||
</div>
|
||
|
||
<div class="progress-indicator" id="progress-text"></div>
|
||
<div class="qr-corner" id="qr-wrap" style="display:none">
|
||
<div id="display-qr"></div>
|
||
<div class="qr-label">Profil</div>
|
||
</div>
|
||
<div class="round-badge" id="round-badge">2. Runde – Besucher</div>
|
||
|
||
<!-- Abwesenheits-Zusammenfassung -->
|
||
<div id="display-absent-summary" style="display:none;">
|
||
<div class="absent-summary-heading">Abwesenheiten</div>
|
||
<div class="absent-summary-cols">
|
||
<div id="absent-col-medical" class="absent-summary-col" style="display:none;">
|
||
<div class="absent-summary-col-title medical">Medical</div>
|
||
<div id="absent-list-medical"></div>
|
||
</div>
|
||
<div id="absent-col-absent" class="absent-summary-col" style="display:none;">
|
||
<div class="absent-summary-col-title absent">Abwesend</div>
|
||
<div id="absent-list-absent"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Startbild: außerhalb der Karte, damit es unabhängig angezeigt werden kann -->
|
||
<div id="display-empty" style="position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:10;">
|
||
<img id="display-logo-empty" src="logo.jpeg" alt="BNI" style="width:min(520px,72vw);height:auto;border-radius:24px;box-shadow:0 8px 60px rgba(0,0,0,0.5);">
|
||
</div>
|
||
|
||
<div class="main-card" id="main-card">
|
||
<div id="display-content" style="display:none; width:100%; display:none; flex-direction:row; align-items:center; gap:56px;">
|
||
<div class="photo-section">
|
||
<div class="photo-timer-wrap">
|
||
<svg class="timer-ring-svg" id="timer-ring-svg" viewBox="0 0 248 248">
|
||
<circle class="timer-ring-track" cx="124" cy="124" r="116"/>
|
||
<circle class="timer-ring-progress" id="timer-ring-progress" cx="124" cy="124" r="116"
|
||
stroke-dasharray="729" stroke-dashoffset="0"/>
|
||
</svg>
|
||
<div class="member-photo-wrap" id="photo-wrap">
|
||
<div class="initials-avatar" id="display-initials">?</div>
|
||
</div>
|
||
</div>
|
||
<div id="display-timer-text"></div>
|
||
</div>
|
||
|
||
<div class="info-section">
|
||
<div class="display-name" id="display-name">—</div>
|
||
<div class="display-company" id="display-company">—</div>
|
||
<div class="display-specialty" id="display-specialty" style="display:none"></div>
|
||
<div class="visitor-tag" id="visitor-tag" style="display:none">Besucher</div>
|
||
<div class="presenter-row" id="presenter-row" style="display:none">
|
||
<div class="presenter-avatar" id="presenter-avatar"></div>
|
||
<div class="presenter-text">
|
||
<div class="presenter-label" id="presenter-label">Vertreten durch</div>
|
||
<div class="presenter-name" id="presenter-name"></div>
|
||
<div class="presenter-company" id="presenter-company"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="next-bar" id="next-bar" style="display:none">
|
||
<span class="next-bar-label">Als nächstes</span>
|
||
<div class="next-bar-divider"></div>
|
||
<img class="next-photo-small" id="next-photo-img" src="" alt="" onerror="this.style.display='none'">
|
||
<div class="next-info">
|
||
<div class="next-name-text" id="next-name-text">—</div>
|
||
<div class="next-company-text" id="next-company-text"></div>
|
||
<div id="next-sub-text" style="display:none;font-size:clamp(0.7rem,1.5vh,1.5rem);color:rgba(255,255,255,0.4);margin-top:2px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="next-bar" id="next-bar-end" style="display:none">
|
||
<span class="next-bar-end-text">Das war die letzte Vorstellung – Danke!</span>
|
||
</div>
|
||
|
||
<div style="position:fixed;bottom:14px;right:80px;display:flex;align-items:center;gap:8px;opacity:0.35;pointer-events:none;">
|
||
<span style="color:white;font-size:0.7rem;letter-spacing:1px;text-transform:uppercase;">powered by</span>
|
||
<img src="digiseed.png" alt="Digiseed" style="height:28px;width:auto;border-radius:4px;">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ============================================================
|
||
CONTROL VIEW (Monitor 1)
|
||
============================================================ -->
|
||
<div id="view-control" class="view">
|
||
|
||
<!-- Header -->
|
||
<div class="ctrl-header">
|
||
<img class="bni-badge" id="ctrl-logo" src="logo.jpeg" alt="BNI">
|
||
<div class="ctrl-title" id="ctrl-chapter-name">Vorstellungsrunde steuern</div>
|
||
<div class="flex-gap-8">
|
||
<div class="status-dot dot-idle" id="sync-dot" title="Display-Verbindung"></div>
|
||
<button class="btn btn-secondary btn-sm" onclick="resetMeeting()">↺ Neu starten</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="openPdfImport()">📄 PDF importieren</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="editDefaultTimer()" title="Standard-Redezeit anpassen" style="font-variant-numeric:tabular-nums;">⏱ <span id="ctrl-timer-default">1:00</span></button>
|
||
<button class="btn btn-secondary btn-sm" id="ctrl-timer-toggle" onclick="toggleTimer()" style="font-variant-numeric:tabular-nums;min-width:80px;">▶ Start</button>
|
||
<button class="btn btn-secondary btn-sm" id="ctrl-timer-reset" onclick="resetTimer()" title="Timer zurücksetzen">↺ <span id="ctrl-timer-display" style="min-width:36px;display:inline-block;"></span></button>
|
||
<button class="btn btn-secondary btn-sm" onclick="openMemberSync()">🔄 Mitglieder aktualisieren</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="openImportExport()">⬇ Import / Export</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="window.open(location.href.split('#')[0] + '#display', '_blank', 'noopener')">🖥 Anzeige öffnen</button>
|
||
<button class="btn btn-next" id="btn-next" onclick="goNext()" disabled>
|
||
Weiter <span id="btn-next-name"></span> →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Body -->
|
||
<div class="ctrl-body">
|
||
|
||
<!-- LEFT: Queue -->
|
||
<div class="panel-left">
|
||
<div class="panel-section-header">
|
||
<span class="panel-section-title">Reihenfolge (ziehen zum Sortieren)</span>
|
||
<div class="queue-tabs">
|
||
<div class="tab-btn active" id="tab-members" onclick="switchTab('members')">Mitglieder</div>
|
||
<div class="tab-btn" id="tab-visitors" onclick="switchTab('visitors')">Besucher</div>
|
||
</div>
|
||
</div>
|
||
<div class="member-queue" id="member-queue">
|
||
<!-- Member items rendered here -->
|
||
</div>
|
||
<div id="queue-resize-handle" title="Ziehen zum Anpassen der Höhe"></div>
|
||
<div id="absent-members-section" style="display:none;"></div>
|
||
<div class="member-queue" id="visitor-queue" style="display:none">
|
||
<!-- Visitor items rendered here -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RIGHT: Visitors + Members Management -->
|
||
<div class="panel-right">
|
||
<div class="panel-right-tabs">
|
||
<div class="panel-right-tab active" data-panel="visitors-panel" onclick="switchRightPanel('visitors-panel')">Besucher</div>
|
||
<div class="panel-right-tab" data-panel="members-panel" onclick="switchRightPanel('members-panel')">Mitglieder</div>
|
||
<div class="panel-right-tab" data-panel="substitutes-panel" onclick="switchRightPanel('substitutes-panel')">Vertretungen</div>
|
||
<div class="panel-right-tab" data-panel="admin-panel" onclick="switchRightPanel('admin-panel')">Admin</div>
|
||
</div>
|
||
|
||
<div class="panel-right-content">
|
||
<!-- VISITORS -->
|
||
<div id="visitors-panel" class="right-panel-section active">
|
||
<div class="add-form">
|
||
<div style="font-size:0.85rem; font-weight:700; margin-bottom:10px; color:var(--text)">Besucher hinzufügen</div>
|
||
<label>Name</label>
|
||
<input id="v-name" type="text" placeholder="Max Mustermann">
|
||
<div class="form-row">
|
||
<div>
|
||
<label>Firma</label>
|
||
<input id="v-company" type="text" placeholder="Firma GmbH">
|
||
</div>
|
||
<div>
|
||
<label>Fachgebiet</label>
|
||
<input id="v-specialty" type="text" placeholder="Steuerberatung">
|
||
</div>
|
||
</div>
|
||
<label>Eingeladen von (optional)</label>
|
||
<select id="v-invited-by">
|
||
<option value="">— kein Mitglied —</option>
|
||
</select>
|
||
<label>Foto-URL (optional)</label>
|
||
<input id="v-photo" type="text" placeholder="https://...">
|
||
<button class="btn btn-primary btn-sm w-full mt-8" onclick="addVisitor()">+ Besucher hinzufügen</button>
|
||
</div>
|
||
<div id="visitors-list">
|
||
<!-- visitor items -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MEMBERS MANAGEMENT -->
|
||
<div id="members-panel" class="right-panel-section">
|
||
<div style="padding: 12px 16px 6px; display:flex; align-items:center; justify-content:space-between;">
|
||
<span class="text-muted">Alle Mitglieder verwalten</span>
|
||
<button class="btn btn-primary btn-sm" onclick="openAddMember()">+ Mitglied</button>
|
||
</div>
|
||
<div id="members-manage-list" class="members-list-manage">
|
||
<!-- member management items -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SUBSTITUTES -->
|
||
<div id="admin-panel" class="right-panel-section">
|
||
<!-- rendered by renderAdminPanel() -->
|
||
</div>
|
||
|
||
<div id="substitutes-panel" class="right-panel-section">
|
||
<div style="padding: 14px 16px 8px;">
|
||
<div style="font-size:0.85rem; color:var(--muted); margin-bottom:12px;">Mitglied als vertreten markieren:</div>
|
||
<label style="font-size:0.78rem; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:1px; display:block; margin-bottom:4px;">Mitglied</label>
|
||
<select id="sub-member-select" style="width:100%; padding:8px; border:1px solid var(--border); border-radius:6px; font-size:0.88rem; margin-bottom:10px;">
|
||
<option value="">— Mitglied auswählen —</option>
|
||
</select>
|
||
<label style="font-size:0.78rem; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:1px; display:block; margin-bottom:4px;">Vertreter Name</label>
|
||
<input id="sub-rep-name" type="text" placeholder="Name der Vertretung" style="width:100%; padding:8px; border:1px solid var(--border); border-radius:6px; font-size:0.88rem; margin-bottom:8px;">
|
||
<label style="font-size:0.78rem; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:1px; display:block; margin-bottom:4px;">Firma</label>
|
||
<input id="sub-rep-company" type="text" placeholder="Firma der Vertretung" style="width:100%; padding:8px; border:1px solid var(--border); border-radius:6px; font-size:0.88rem; margin-bottom:8px;">
|
||
<label style="font-size:0.78rem; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:1px; display:block; margin-bottom:4px;">Fachgebiet</label>
|
||
<input id="sub-rep-specialty" type="text" placeholder="Fachgebiet" style="width:100%; padding:8px; border:1px solid var(--border); border-radius:6px; font-size:0.88rem; margin-bottom:8px;">
|
||
<label style="font-size:0.78rem; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:1px; display:block; margin-bottom:4px;">Foto-URL (optional)</label>
|
||
<input id="sub-rep-photo" type="text" placeholder="https://..." style="width:100%; padding:8px; border:1px solid var(--border); border-radius:6px; font-size:0.88rem; margin-bottom:10px;">
|
||
<button class="btn btn-primary btn-sm w-full" onclick="saveSubstitute()">Vertretung speichern</button>
|
||
</div>
|
||
|
||
<div style="padding: 0 16px; border-top:1px solid var(--border); margin-top:8px;">
|
||
<div style="padding:12px 0 6px; font-size:0.78rem; font-weight:700; color:var(--muted); text-transform:uppercase; letter-spacing:1px;">Aktive Vertretungen</div>
|
||
<div id="substitutes-list"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ============================================================
|
||
ACTIVATION VIEW
|
||
============================================================ -->
|
||
<div id="view-activation" class="view" style="background:linear-gradient(160deg,#0f1f45 0%,#1a2744 40%,#0d1b35 100%);align-items:center;justify-content:center;flex-direction:column;">
|
||
<img id="activation-logo" src="logo.jpeg" alt="BNI" style="height:80px;margin-bottom:32px;border-radius:8px;object-fit:contain;">
|
||
<div style="background:rgba(255,255,255,0.08);backdrop-filter:blur(20px);border:1px solid rgba(255,255,255,0.15);border-radius:20px;padding:40px;width:min(420px,90vw);color:white;">
|
||
<div style="font-size:1.4rem;font-weight:700;margin-bottom:6px;">BNI Vorstellungsrunde</div>
|
||
<div style="font-size:0.88rem;color:rgba(255,255,255,0.6);margin-bottom:24px;">Bitte gib deinen Lizenzcode ein, um dein Chapter freizuschalten.</div>
|
||
<input id="activation-code-input" type="text" placeholder="chapter-lizenzcode"
|
||
style="width:100%;padding:12px 14px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.25);border-radius:10px;color:white;font-size:1rem;outline:none;margin-bottom:12px;letter-spacing:1px;"
|
||
onkeydown="if(event.key==='Enter')activateChapter()"
|
||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
|
||
<div id="activation-status" style="font-size:0.85rem;min-height:20px;margin-bottom:12px;"></div>
|
||
<button id="activation-btn" onclick="activateChapter()"
|
||
style="width:100%;padding:12px;background:#cc0000;color:white;border:none;border-radius:10px;font-size:1rem;font-weight:700;cursor:pointer;">
|
||
Aktivieren
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ============================================================
|
||
MODALS
|
||
============================================================ -->
|
||
<!-- Add/Edit Member Modal -->
|
||
<div class="modal-overlay" id="modal-member">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div class="modal-title" id="modal-member-title">Mitglied hinzufügen</div>
|
||
<button class="modal-close" onclick="closeMemberModal()">✕</button>
|
||
</div>
|
||
<input type="hidden" id="edit-member-id">
|
||
|
||
<label>Name *</label>
|
||
<input id="m-name" type="text" placeholder="Vorname Nachname">
|
||
|
||
<div class="form-row">
|
||
<div>
|
||
<label>Firma *</label>
|
||
<input id="m-company" type="text" placeholder="Firma GmbH">
|
||
</div>
|
||
<div>
|
||
<label>Fachgebiet *</label>
|
||
<input id="m-specialty" type="text" placeholder="Webentwicklung">
|
||
</div>
|
||
</div>
|
||
|
||
<label>Foto-URL (von BNI-Profil kopieren)</label>
|
||
<input id="m-photo" type="text" placeholder="https://cdn.bniconnectglobal.com/...">
|
||
|
||
<label>Profil-URL (von BNI, wird für Sync genutzt)</label>
|
||
<input id="m-profile" type="text" placeholder="https://bni-ulm.com/sonne/de/memberdetails?...">
|
||
|
||
<div id="photo-preview-wrap" style="display:none; text-align:center; margin:8px 0;">
|
||
<img id="photo-preview" src="" alt="" style="width:80px; height:80px; border-radius:50%; object-fit:cover; border:2px solid var(--border);">
|
||
</div>
|
||
|
||
<hr class="extras-section-divider">
|
||
<div class="extras-section-title">Dauerhaft gespeichert (bleibt nach BNI-Sync erhalten)</div>
|
||
|
||
<label>Kurzlink (für QR-Code) <span style="font-weight:400;color:var(--muted);">— überschreibt Profil-URL im QR</span></label>
|
||
<input id="m-kurzlink" type="text" placeholder="z.B. https://qr.bp.com/kai">
|
||
|
||
<label>Offline-Foto <span style="font-weight:400;color:var(--muted);">— lokal gespeichert, kein Internet nötig</span></label>
|
||
<div class="offline-photo-row" id="offline-photo-row">
|
||
<img class="offline-photo-thumb" id="offline-photo-thumb" src="" alt="" style="display:none;">
|
||
<div class="offline-photo-actions">
|
||
<label class="btn-file-label">
|
||
📁 Datei wählen
|
||
<input type="file" id="m-photo-file" accept="image/*" style="display:none" onchange="handleOfflinePhotoFile(this)">
|
||
</label>
|
||
<button class="btn btn-ghost btn-sm" onclick="loadOfflinePhotoFromUrl()">⬇ Von Profil-URL laden</button>
|
||
<button class="btn btn-danger btn-sm" id="btn-remove-offline-photo" onclick="removeOfflinePhoto()" style="display:none;">✕ Entfernen</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" onclick="closeMemberModal()">Abbrechen</button>
|
||
<button class="btn btn-save btn-primary" onclick="saveMember()">Speichern</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Import/Export Modal -->
|
||
<div class="modal-overlay" id="modal-importexport">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div class="modal-title">Import / Export</div>
|
||
<button class="modal-close" onclick="closeModal('modal-importexport')">✕</button>
|
||
</div>
|
||
<p style="font-size:0.88rem; color:var(--muted); margin-bottom:14px;">
|
||
Mitgliederdaten als JSON exportieren oder importieren. Daten werden im Browser gespeichert.
|
||
</p>
|
||
<textarea id="json-textarea" placeholder="JSON hier einfügen..."></textarea>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" onclick="exportJSON()">Exportieren</button>
|
||
<button class="btn btn-ghost" onclick="closeModal('modal-importexport')">Abbrechen</button>
|
||
<button class="btn btn-save btn-primary" onclick="importJSON()">Importieren</button>
|
||
</div>
|
||
|
||
<hr class="extras-section-divider" style="margin-top:24px;">
|
||
<div class="extras-section-title">Profildaten (Kurzlinks + Offline-Fotos)</div>
|
||
<p style="font-size:0.85rem; color:var(--muted); margin-bottom:14px;">
|
||
Kurzlinks und lokale Fotos werden <strong>getrennt</strong> gespeichert und bleiben nach jedem BNI-Sync erhalten.
|
||
Diese Datei im Projektordner ablegen und nach einem Browser-Reset wieder importieren.
|
||
</p>
|
||
<div style="display:flex; gap:8px; align-items:center;">
|
||
<button class="btn btn-ghost" onclick="exportProfildaten()">⬇ profildaten.json speichern</button>
|
||
<label class="btn-file-label" style="cursor:pointer;">
|
||
⬆ profildaten.json laden
|
||
<input type="file" id="profildaten-file-input" accept=".json" style="display:none" onchange="importProfildaten(this)">
|
||
</label>
|
||
<span id="profildaten-status" style="font-size:0.82rem;color:var(--muted);"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Substitute Quick-Pick Modal -->
|
||
<div class="modal-overlay" id="modal-substitute">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div class="modal-title">Vertretung für <span id="sub-modal-member-name"></span></div>
|
||
<button class="modal-close" onclick="closeModal('modal-substitute')">✕</button>
|
||
</div>
|
||
<input type="hidden" id="sub-modal-member-id">
|
||
|
||
<label>Vertreter Name *</label>
|
||
<input id="sub-modal-name" type="text" placeholder="Name">
|
||
|
||
<div class="form-row">
|
||
<div>
|
||
<label>Firma</label>
|
||
<input id="sub-modal-company" type="text" placeholder="Firma GmbH">
|
||
</div>
|
||
<div>
|
||
<label>Fachgebiet</label>
|
||
<input id="sub-modal-specialty" type="text" placeholder="Fachgebiet">
|
||
</div>
|
||
</div>
|
||
|
||
<label>Foto-URL (optional)</label>
|
||
<input id="sub-modal-photo" type="text" placeholder="https://...">
|
||
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:10px;cursor:pointer;font-weight:normal;">
|
||
<input type="checkbox" id="sub-modal-skip-round2" style="width:16px;height:16px;cursor:pointer;">
|
||
Tritt nicht in der 2. Runde auf <span style="font-size:0.75rem;color:var(--muted);">(z.B. Mitarbeiter, Dauervertreter)</span>
|
||
</label>
|
||
|
||
<div class="modal-footer">
|
||
<button class="btn btn-danger btn-sm" onclick="removeSubstitute(document.getElementById('sub-modal-member-id').value)">Vertretung entfernen</button>
|
||
<button class="btn btn-ghost" onclick="closeModal('modal-substitute')">Abbrechen</button>
|
||
<button class="btn btn-save btn-primary" onclick="saveSubstituteModal()">Speichern</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Besucher bearbeiten Modal -->
|
||
<div class="modal-overlay" id="modal-visitor-edit">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<div class="modal-title">Besucher bearbeiten</div>
|
||
<button class="modal-close" onclick="closeModal('modal-visitor-edit')">✕</button>
|
||
</div>
|
||
<input type="hidden" id="ve-id">
|
||
|
||
<label>Name *</label>
|
||
<input id="ve-name" type="text" placeholder="Vor- und Nachname">
|
||
|
||
<div class="form-row">
|
||
<div>
|
||
<label>Firma</label>
|
||
<input id="ve-company" type="text" placeholder="Firma GmbH">
|
||
</div>
|
||
<div>
|
||
<label>Fachgebiet / Beruf</label>
|
||
<input id="ve-specialty" type="text" placeholder="Fachgebiet">
|
||
</div>
|
||
</div>
|
||
|
||
<label>Eingeladen von</label>
|
||
<input id="ve-invited-by" type="text" placeholder="Name des Mitglieds">
|
||
|
||
<label>Foto-URL (optional)</label>
|
||
<input id="ve-photo" type="text" placeholder="https://...">
|
||
|
||
<div class="modal-footer">
|
||
<button class="btn btn-danger btn-sm" onclick="removeVisitor(document.getElementById('ve-id').value);closeModal('modal-visitor-edit')">Entfernen</button>
|
||
<button class="btn btn-ghost" onclick="closeModal('modal-visitor-edit')">Abbrechen</button>
|
||
<button class="btn btn-primary" onclick="saveVisitorEdit()">Speichern</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Schnellerfassung Modal (ersetzt PDF-Import) -->
|
||
<div class="modal-overlay" id="modal-pdf-import">
|
||
<div class="modal" style="width:min(760px,97vw);max-height:90vh;">
|
||
<div class="modal-header">
|
||
<div class="modal-title">Besucher- & Vertreterliste erfassen</div>
|
||
<button class="modal-close" onclick="closeModal('modal-pdf-import')">✕</button>
|
||
</div>
|
||
|
||
<div id="pdf-drop-zone"
|
||
onclick="document.getElementById('pdf-file-input').click()"
|
||
ondragover="event.preventDefault();this.classList.add('dragging')"
|
||
ondragleave="this.classList.remove('dragging')"
|
||
ondrop="event.preventDefault();this.classList.remove('dragging');handlePdfImportFile(event.dataTransfer.files[0])"
|
||
style="border:2px dashed var(--border);border-radius:10px;padding:14px 20px;text-align:center;cursor:pointer;margin-bottom:10px;transition:border-color .2s,background .2s;background:#fafafa;">
|
||
<input type="file" id="pdf-file-input" accept=".pdf" style="display:none"
|
||
onchange="handlePdfImportFile(this.files[0]);this.value=''">
|
||
<span style="font-size:0.88rem;color:var(--muted);">📄 <strong>Chapterportal-PDF hier ablegen</strong> oder klicken – Einträge werden automatisch erkannt</span>
|
||
</div>
|
||
<div id="pdf-parse-status" style="display:none;font-size:0.82rem;padding:8px 12px;border-radius:8px;margin-bottom:10px;"></div>
|
||
<p style="font-size:0.82rem;color:var(--muted);margin-bottom:14px;">
|
||
Einträge können nach dem Import noch angepasst werden. Nur ausgefüllte Zeilen werden importiert.
|
||
</p>
|
||
|
||
<div style="overflow-x:auto;">
|
||
<table id="quick-entry-table" style="width:100%;border-collapse:collapse;font-size:0.85rem;">
|
||
<thead>
|
||
<tr style="background:#f9fafb;font-size:0.75rem;text-transform:uppercase;letter-spacing:1px;color:var(--muted);">
|
||
<th style="padding:8px 4px;border-bottom:2px solid var(--border);width:24px;"></th>
|
||
<th style="padding:8px 6px;text-align:left;border-bottom:2px solid var(--border);width:120px;">Typ</th>
|
||
<th style="padding:8px 6px;text-align:left;border-bottom:2px solid var(--border);">Name</th>
|
||
<th style="padding:8px 6px;text-align:left;border-bottom:2px solid var(--border);">Fachgebiet / Beruf</th>
|
||
<th style="padding:8px 6px;text-align:left;border-bottom:2px solid var(--border);">Vertreter für / Eingeladen von</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="quick-entry-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" onclick="closeModal('modal-pdf-import')">Abbrechen</button>
|
||
<button class="btn btn-primary" onclick="confirmQuickEntry()">Importieren</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.qe-row td { padding: 4px 4px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||
.qe-row:last-child td { border-bottom: none; }
|
||
.qe-num { color: var(--muted); font-size: 0.78rem; text-align: center; width: 20px; }
|
||
.qe-row select, .qe-row input {
|
||
width: 100%;
|
||
padding: 6px 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
font-size: 0.85rem;
|
||
font-family: inherit;
|
||
color: var(--text);
|
||
background: white;
|
||
}
|
||
.qe-row select:focus, .qe-row input:focus {
|
||
outline: none;
|
||
border-color: var(--red);
|
||
box-shadow: 0 0 0 2px rgba(204,0,0,0.1);
|
||
}
|
||
#pdf-drop-zone.dragging { border-color: var(--red); background: var(--red-light); }
|
||
</style>
|
||
|
||
<!-- Mitglieder-Sync Modal -->
|
||
<div class="modal-overlay" id="modal-sync">
|
||
<div class="modal" style="width:min(600px,95vw)">
|
||
<div class="modal-header">
|
||
<div class="modal-title">🔄 Mitglieder mit BNI-Website abgleichen</div>
|
||
<button class="modal-close" onclick="closeModal('modal-sync')">✕</button>
|
||
</div>
|
||
|
||
<!-- Step 1: Bookmarklet -->
|
||
<div style="background:#f0f9f0;border:1px solid #bbf0bb;border-radius:10px;padding:16px;margin-bottom:14px;">
|
||
<div style="font-weight:700;font-size:0.9rem;margin-bottom:6px;">Schritt 1 – Bookmarklets einrichten <span style="font-weight:400;color:var(--muted)">(einmalig)</span></div>
|
||
<p style="font-size:0.85rem;color:var(--muted);margin-bottom:10px;">
|
||
Ziehe beide Buttons in die Lesezeichenleiste:
|
||
</p>
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||
<a id="bookmarklet-link" href="" style="display:inline-block;background:var(--red);color:white;padding:8px 18px;border-radius:8px;font-weight:700;font-size:0.9rem;text-decoration:none;cursor:grab;">
|
||
📋 BNI Mitglieder exportieren
|
||
</a>
|
||
<a id="bookmarklet-diag" href="" style="display:inline-block;background:#555;color:white;padding:8px 18px;border-radius:8px;font-weight:700;font-size:0.9rem;text-decoration:none;cursor:grab;">
|
||
🔍 Diagnose (einmalig)
|
||
</a>
|
||
</div>
|
||
<p style="font-size:0.78rem;color:var(--muted);margin-top:8px;">
|
||
Zuerst <strong>🔍 Diagnose</strong> auf der Mitgliederliste ausführen und die heruntergeladene Datei hier hochladen – dann wird der Export-Button automatisch angepasst.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Diagnose upload -->
|
||
<div id="diag-upload-area" style="background:#fffbe6;border:1px solid #f0d060;border-radius:10px;padding:12px 16px;margin-bottom:14px;display:none;">
|
||
<div style="font-weight:700;font-size:0.85rem;margin-bottom:6px;">🔍 Diagnose-Datei auswerten</div>
|
||
<input type="file" id="diag-file-input" accept=".json" style="display:none" onchange="handleDiagFile(this.files[0])">
|
||
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('diag-file-input').click()">bni-diagnose.json laden</button>
|
||
<div id="diag-result" style="margin-top:8px;font-size:0.82rem;color:var(--muted);"></div>
|
||
</div>
|
||
|
||
<!-- Step 2: Use it -->
|
||
<div style="background:#f0f4ff;border:1px solid #c0c8f0;border-radius:10px;padding:16px;margin-bottom:14px;">
|
||
<div style="font-weight:700;font-size:0.9rem;margin-bottom:6px;">Schritt 2 – Daten exportieren</div>
|
||
<ol style="font-size:0.85rem;color:var(--muted);padding-left:18px;line-height:1.8;">
|
||
<li>BNI-Mitgliederliste öffnen:
|
||
<a id="bni-sync-link" href="https://bni-ulm.com/sonne/de/memberlist?chapterName=6035®ionIds=43671&isChapterwebsite=true" target="_blank" style="color:var(--red);font-weight:600;">bni-ulm.com → Chapter Sonne</a>
|
||
</li>
|
||
<li>Warten bis alle Mitglieder geladen sind</li>
|
||
<li>Bookmarklet <strong>"BNI Mitglieder exportieren"</strong> klicken</li>
|
||
<li>JSON-Datei wird automatisch heruntergeladen</li>
|
||
</ol>
|
||
</div>
|
||
|
||
<!-- Step 3: Import -->
|
||
<div style="background:#fff8f0;border:1px solid #f0d8b0;border-radius:10px;padding:16px;">
|
||
<div style="font-weight:700;font-size:0.9rem;margin-bottom:10px;">Schritt 3 – Importieren</div>
|
||
<p style="font-size:0.85rem;color:var(--muted);margin-bottom:10px;">
|
||
Heruntergeladene JSON-Datei hier einlesen. Bestehende Mitglieder werden aktualisiert (Foto, Fachgebiet), neue werden hinzugefügt. Niemand wird gelöscht.
|
||
</p>
|
||
<input type="file" id="sync-file-input" accept=".json" style="display:none" onchange="handleSyncFile(this.files[0])">
|
||
<button class="btn btn-primary" onclick="document.getElementById('sync-file-input').click()">
|
||
📂 JSON-Datei laden & synchronisieren
|
||
</button>
|
||
<div id="sync-result" style="margin-top:10px;font-size:0.85rem;display:none;"></div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button class="btn btn-ghost" onclick="closeModal('modal-sync')">Schließen</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ============================================================
|
||
// CONSTANTS & CHAPTER CONFIG
|
||
// ============================================================
|
||
const APP_VERSION = '1.1.0';
|
||
const GITEA_RAW = 'http://72.60.134.73:3000/api/v1/repos/cheubaum/BNI-Sonne/raw';
|
||
const GITEA_API = 'http://72.60.134.73:3000/api/v1/repos/cheubaum/BNI-Sonne';
|
||
function giteaUrl(path) { return `${GITEA_RAW}/${path}?ref=main`; }
|
||
const CHAPTER_CONFIG_KEY = 'bni-active-chapter';
|
||
|
||
function loadChapterConfig() {
|
||
try { const s = localStorage.getItem(CHAPTER_CONFIG_KEY); return s ? JSON.parse(s) : null; } catch(e) { return null; }
|
||
}
|
||
function saveChapterConfig(cfg) {
|
||
localStorage.setItem(CHAPTER_CONFIG_KEY, JSON.stringify(cfg));
|
||
}
|
||
function clearChapterData() {
|
||
if (activeChapter) localStorage.removeItem(`bni-chapter-${activeChapter.code}-v2`);
|
||
if (activeChapter) localStorage.removeItem(`bni-extras-${activeChapter.code}`);
|
||
localStorage.removeItem(CHAPTER_CONFIG_KEY);
|
||
activeChapter = null;
|
||
}
|
||
|
||
let activeChapter = loadChapterConfig();
|
||
let STORAGE_KEY = activeChapter ? `bni-chapter-${activeChapter.code}-v2` : 'bni-chapter-default-v2';
|
||
|
||
// ============================================================
|
||
// STATE
|
||
// ============================================================
|
||
|
||
let state = loadState();
|
||
let sortableInstance = null;
|
||
let sortableVisitorInstance = null;
|
||
let qrInstance = null;
|
||
let activeTab = 'members'; // 'members' | 'visitors'
|
||
|
||
function defaultState() {
|
||
return {
|
||
members: [], // { id, name, company, specialty, photoUrl, profileUrl }
|
||
meetingOrder: [], // member IDs in meeting order
|
||
currentIndex: -1, // -1 = not started, 0..n-1 = presenting
|
||
substitutes: {}, // { memberId: { name, company, specialty, photoUrl } }
|
||
absentIds: [], // member IDs absent today (no substitute)
|
||
medicalIds: [], // member IDs on medical leave
|
||
visitors: [], // { id, name, company, specialty, photoUrl }
|
||
visitorsOrder: [], // visitor IDs in order
|
||
visitorsCurrentIndex: -1,
|
||
mode: 'members', // 'members' | 'absent-summary' | 'visitors'
|
||
defaultTimer: 60, // seconds (standard speaking time)
|
||
memberTimers: {}, // { memberId: customSeconds }
|
||
};
|
||
}
|
||
|
||
function loadState() {
|
||
try {
|
||
const s = localStorage.getItem(STORAGE_KEY);
|
||
const parsed = s ? JSON.parse(s) : defaultState();
|
||
// Ensure all fields exist (for upgrades)
|
||
return Object.assign(defaultState(), parsed);
|
||
} catch(e) { return defaultState(); }
|
||
}
|
||
|
||
function saveState(broadcast = true) {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||
if (broadcast) {
|
||
try { channel.postMessage({ type: 'STATE_UPDATE', state }); } catch(e) {}
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// PROFILE EXTRAS (Kurzlinks + Offline-Fotos, persistent)
|
||
// ============================================================
|
||
let EXTRAS_KEY = activeChapter ? `bni-extras-${activeChapter.code}` : 'bni-profile-extras';
|
||
|
||
function loadExtras() {
|
||
try {
|
||
const s = localStorage.getItem(EXTRAS_KEY);
|
||
return s ? JSON.parse(s) : {};
|
||
} catch(e) { return {}; }
|
||
}
|
||
|
||
function saveExtras(extras) {
|
||
localStorage.setItem(EXTRAS_KEY, JSON.stringify(extras));
|
||
}
|
||
|
||
function extrasKey(name) {
|
||
return (name || '').toLowerCase().trim();
|
||
}
|
||
|
||
// Resize an image File to base64 JPEG (max maxPx on longest side)
|
||
function resizePhotoToBase64(file, maxPx = 400) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
let w = img.width, h = img.height;
|
||
if (w > maxPx || h > maxPx) {
|
||
if (w > h) { h = Math.round(h * maxPx / w); w = maxPx; }
|
||
else { w = Math.round(w * maxPx / h); h = maxPx; }
|
||
}
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = w; canvas.height = h;
|
||
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
||
resolve(canvas.toDataURL('image/jpeg', 0.82));
|
||
};
|
||
img.onerror = reject;
|
||
img.src = e.target.result;
|
||
};
|
||
reader.onerror = reject;
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
// Try to load a remote photo as base64 via canvas (requires CORS)
|
||
function loadPhotoFromUrl(url) {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
img.onload = () => {
|
||
const maxPx = 400;
|
||
let w = img.width, h = img.height;
|
||
if (w > maxPx || h > maxPx) {
|
||
if (w > h) { h = Math.round(h * maxPx / w); w = maxPx; }
|
||
else { w = Math.round(w * maxPx / h); h = maxPx; }
|
||
}
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = w; canvas.height = h;
|
||
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
||
resolve(canvas.toDataURL('image/jpeg', 0.82));
|
||
};
|
||
img.onerror = () => reject(new Error('Foto konnte nicht geladen werden (CORS oder URL ungültig).'));
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
// Module-level state for currently editing offline photo
|
||
let _editingOfflinePhotoB64 = null;
|
||
|
||
function _updateOfflinePhotoUI(b64) {
|
||
_editingOfflinePhotoB64 = b64 || null;
|
||
const thumb = document.getElementById('offline-photo-thumb');
|
||
const removeBtn = document.getElementById('btn-remove-offline-photo');
|
||
if (b64) {
|
||
thumb.src = b64;
|
||
thumb.style.display = 'block';
|
||
removeBtn.style.display = 'inline-flex';
|
||
} else {
|
||
thumb.src = '';
|
||
thumb.style.display = 'none';
|
||
removeBtn.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function handleOfflinePhotoFile(input) {
|
||
const file = input.files && input.files[0];
|
||
if (!file) return;
|
||
resizePhotoToBase64(file)
|
||
.then(b64 => _updateOfflinePhotoUI(b64))
|
||
.catch(e => alert('Fehler beim Laden des Fotos: ' + e.message));
|
||
input.value = '';
|
||
}
|
||
|
||
function loadOfflinePhotoFromUrl() {
|
||
const url = (document.getElementById('m-photo').value || '').trim();
|
||
if (!url) { alert('Bitte zuerst eine Foto-URL eingeben.'); return; }
|
||
// Remove any previous hint
|
||
const existingHint = document.getElementById('offline-photo-url-hint');
|
||
if (existingHint) existingHint.remove();
|
||
loadPhotoFromUrl(url)
|
||
.then(b64 => _updateOfflinePhotoUI(b64))
|
||
.catch(() => {
|
||
// Show inline hint with direct link instead of an alert
|
||
const hint = document.createElement('div');
|
||
hint.id = 'offline-photo-url-hint';
|
||
hint.style.cssText = 'font-size:0.82rem;color:var(--muted);margin-top:6px;line-height:1.5;';
|
||
hint.innerHTML = `Server erlaubt keinen direkten Import. <a href="${url}" target="_blank" rel="noopener" style="color:var(--red);font-weight:600;">Foto öffnen ↗</a> → Rechtsklick → Bild speichern → dann über <strong>Datei wählen</strong> hochladen.`;
|
||
document.getElementById('offline-photo-row').insertAdjacentElement('afterend', hint);
|
||
});
|
||
}
|
||
|
||
function removeOfflinePhoto() {
|
||
_updateOfflinePhotoUI(null);
|
||
}
|
||
|
||
// Export profildaten.json
|
||
function exportProfildaten() {
|
||
const extras = loadExtras();
|
||
const blob = new Blob([JSON.stringify({ version: 1, extras }, null, 2)], { type: 'application/json' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = 'profildaten.json';
|
||
a.click();
|
||
document.getElementById('profildaten-status').textContent = '✓ Gespeichert';
|
||
setTimeout(() => { document.getElementById('profildaten-status').textContent = ''; }, 3000);
|
||
}
|
||
|
||
// Import profildaten.json
|
||
function importProfildaten(input) {
|
||
const file = input.files && input.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
try {
|
||
const data = JSON.parse(e.target.result);
|
||
const incoming = data.extras || data; // support both formats
|
||
const existing = loadExtras();
|
||
// Merge: incoming data wins for kurzlink/photoB64 per member
|
||
Object.entries(incoming).forEach(([k, v]) => {
|
||
existing[k] = Object.assign(existing[k] || {}, v);
|
||
});
|
||
saveExtras(existing);
|
||
const count = Object.keys(incoming).length;
|
||
document.getElementById('profildaten-status').textContent = `✓ ${count} Einträge geladen`;
|
||
setTimeout(() => { document.getElementById('profildaten-status').textContent = ''; }, 4000);
|
||
renderCurrentView();
|
||
} catch(err) {
|
||
alert('Ungültige Datei: ' + err.message);
|
||
}
|
||
input.value = '';
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
// ============================================================
|
||
// BROADCAST CHANNEL (cross-tab sync)
|
||
// ============================================================
|
||
let channel;
|
||
let syncDot;
|
||
|
||
function initChannel() {
|
||
try {
|
||
channel = new BroadcastChannel('bni-channel-' + (activeChapter ? activeChapter.code : 'default'));
|
||
channel.onmessage = (ev) => {
|
||
if (ev.data.type === 'STATE_UPDATE') {
|
||
state = ev.data.state;
|
||
saveState(false);
|
||
renderCurrentView();
|
||
}
|
||
if (ev.data.type === 'PING') {
|
||
channel.postMessage({ type: 'PONG' });
|
||
if (syncDot) { syncDot.className = 'status-dot dot-live'; }
|
||
}
|
||
if (ev.data.type === 'PONG') {
|
||
if (syncDot) { syncDot.className = 'status-dot dot-live'; }
|
||
}
|
||
if (ev.data.type === 'TIMER_START') {
|
||
_t.total = ev.data.total;
|
||
_t.startedAt = ev.data.startedAt;
|
||
_t.remaining = Math.max(0, ev.data.total - Math.floor((Date.now() - ev.data.startedAt) / 1000));
|
||
_t.running = true;
|
||
if (_t.iv) clearInterval(_t.iv);
|
||
_t.iv = setInterval(_timerTick, 500);
|
||
updateTimerDisplays();
|
||
}
|
||
if (ev.data.type === 'TIMER_STOP') {
|
||
_t.total = 0; _t.remaining = 0; _t.running = false;
|
||
if (_t.iv) { clearInterval(_t.iv); _t.iv = null; }
|
||
updateTimerDisplays();
|
||
}
|
||
};
|
||
// Ping other tabs every 5s
|
||
setInterval(() => {
|
||
try { channel.postMessage({ type: 'PING' }); } catch(e) {}
|
||
}, 5000);
|
||
} catch(e) { /* BroadcastChannel not supported */ }
|
||
}
|
||
|
||
// ============================================================
|
||
// ROUTING
|
||
// ============================================================
|
||
function getView() {
|
||
return window.location.hash === '#display' ? 'display' : 'control';
|
||
}
|
||
|
||
function renderCurrentView() {
|
||
const hasChapter = !!activeChapter;
|
||
const view = getView();
|
||
document.getElementById('view-activation').classList.toggle('active', !hasChapter);
|
||
document.getElementById('view-display').classList.toggle('active', hasChapter && view === 'display');
|
||
document.getElementById('view-control').classList.toggle('active', hasChapter && view === 'control');
|
||
if (!hasChapter) return;
|
||
|
||
if (view === 'display') {
|
||
renderDisplay();
|
||
} else {
|
||
renderControl();
|
||
}
|
||
}
|
||
|
||
window.addEventListener('hashchange', renderCurrentView);
|
||
|
||
// ============================================================
|
||
// DISPLAY VIEW RENDERING
|
||
// ============================================================
|
||
function getInitials(name) {
|
||
if (!name) return '?';
|
||
return name.trim().split(/\s+/).map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||
}
|
||
|
||
function getColor(name) {
|
||
const colors = ['#1a2744','#cc0000','#0f3460','#1e3a5f','#4a1942'];
|
||
let h = 0;
|
||
for (let c of (name || '')) h = (h * 31 + c.charCodeAt(0)) & 0xffffffff;
|
||
return colors[Math.abs(h) % colors.length];
|
||
}
|
||
|
||
function renderDisplay() {
|
||
const presenter = getCurrentPresenter();
|
||
const next = getNextPresenter();
|
||
|
||
const emptyEl = document.getElementById('display-empty');
|
||
const contentEl = document.getElementById('display-content');
|
||
const nextBar = document.getElementById('next-bar');
|
||
const nextBarEnd = document.getElementById('next-bar-end');
|
||
const progressText = document.getElementById('progress-text');
|
||
const roundBadge = document.getElementById('round-badge');
|
||
const mainCard = document.getElementById('main-card');
|
||
const displayHeader = document.querySelector('#view-display .display-header');
|
||
const absentSummaryEl = document.getElementById('display-absent-summary');
|
||
|
||
// ABSENT SUMMARY MODE
|
||
if (state.mode === 'absent-summary') {
|
||
emptyEl.style.display = 'none';
|
||
mainCard.style.display = 'none';
|
||
contentEl.style.display = 'none';
|
||
nextBar.style.display = 'none';
|
||
roundBadge.classList.remove('visible');
|
||
progressText.textContent = '';
|
||
if (displayHeader) displayHeader.style.display = '';
|
||
absentSummaryEl.style.display = 'flex';
|
||
|
||
const medicalIds = state.medicalIds || [];
|
||
const medicalMembers = medicalIds.map(id => state.members.find(m => m.id === id)).filter(Boolean);
|
||
const absentMembers = state.absentIds.map(id => state.members.find(m => m.id === id)).filter(Boolean);
|
||
|
||
const colMedical = document.getElementById('absent-col-medical');
|
||
const colAbsent = document.getElementById('absent-col-absent');
|
||
const listMedical = document.getElementById('absent-list-medical');
|
||
const listAbsent = document.getElementById('absent-list-absent');
|
||
|
||
const total = medicalMembers.length + absentMembers.length;
|
||
|
||
// Scale font size down as list grows
|
||
const fontSize = total <= 8 ? '1.35rem'
|
||
: total <= 14 ? '1.1rem'
|
||
: total <= 22 ? '0.95rem'
|
||
: total <= 32 ? '0.82rem'
|
||
: '0.7rem';
|
||
absentSummaryEl.style.setProperty('--absent-name-size', fontSize);
|
||
|
||
// Auto-columns within each section
|
||
const absentCols = absentMembers.length > 20 ? 3
|
||
: absentMembers.length > 10 ? 2 : 1;
|
||
const medicalCols = medicalMembers.length > 10 ? 2 : 1;
|
||
|
||
if (medicalMembers.length > 0) {
|
||
colMedical.style.display = 'block';
|
||
listMedical.style.columns = medicalCols;
|
||
listMedical.innerHTML = medicalMembers.map(m =>
|
||
`<div class="absent-summary-name">${escHtml(m.name)}</div>`).join('');
|
||
} else {
|
||
colMedical.style.display = 'none';
|
||
}
|
||
if (absentMembers.length > 0) {
|
||
colAbsent.style.display = 'block';
|
||
listAbsent.style.columns = absentCols;
|
||
listAbsent.innerHTML = absentMembers.map(m =>
|
||
`<div class="absent-summary-name">${escHtml(m.name)}</div>`).join('');
|
||
} else {
|
||
colAbsent.style.display = 'none';
|
||
}
|
||
|
||
if (state.visitorsOrder.length > 0) {
|
||
nextBarEnd.style.display = 'none';
|
||
// Show a hint that visitors round is next
|
||
nextBar.style.display = 'none'; // keep it simple
|
||
} else {
|
||
nextBarEnd.style.display = 'flex';
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Hide absent summary
|
||
if (absentSummaryEl) absentSummaryEl.style.display = 'none';
|
||
|
||
// Logo transition between rounds
|
||
if (state.mode === 'round-transition') {
|
||
emptyEl.style.display = 'flex';
|
||
mainCard.style.display = 'none';
|
||
if (displayHeader) displayHeader.style.display = 'none';
|
||
contentEl.style.display = 'none';
|
||
nextBar.style.display = 'none';
|
||
nextBarEnd.style.display = 'none';
|
||
progressText.textContent = '';
|
||
roundBadge.classList.remove('visible');
|
||
return;
|
||
}
|
||
|
||
if (!presenter || (state.currentIndex < 0 && state.visitorsCurrentIndex < 0)) {
|
||
emptyEl.style.display = 'flex';
|
||
mainCard.style.display = 'none';
|
||
if (displayHeader) displayHeader.style.display = 'none';
|
||
contentEl.style.display = 'none';
|
||
nextBar.style.display = 'none';
|
||
nextBarEnd.style.display = 'none';
|
||
progressText.textContent = '';
|
||
roundBadge.classList.remove('visible');
|
||
return;
|
||
}
|
||
|
||
emptyEl.style.display = 'none';
|
||
mainCard.style.display = '';
|
||
if (displayHeader) displayHeader.style.display = '';
|
||
contentEl.style.display = 'flex';
|
||
|
||
// Round badge
|
||
if (state.mode === 'visitors') {
|
||
roundBadge.classList.add('visible');
|
||
} else {
|
||
roundBadge.classList.remove('visible');
|
||
}
|
||
|
||
// Progress
|
||
if (state.mode === 'members') {
|
||
const total = state.meetingOrder.length;
|
||
progressText.textContent = total > 0 ? `${state.currentIndex + 1} / ${total}` : '';
|
||
} else {
|
||
const total = state.visitorsOrder.length;
|
||
progressText.textContent = total > 0 ? `Besucher ${state.visitorsCurrentIndex + 1} / ${total}` : '';
|
||
}
|
||
|
||
// Get display data
|
||
let data = getDisplayData(presenter);
|
||
|
||
// Photo
|
||
const photoWrap = document.getElementById('photo-wrap');
|
||
const initialsEl = document.getElementById('display-initials');
|
||
// Remove existing img
|
||
const existingImg = photoWrap.querySelector('img');
|
||
if (existingImg) existingImg.remove();
|
||
|
||
if (data.photoUrl) {
|
||
const img = document.createElement('img');
|
||
img.src = data.photoUrl;
|
||
img.alt = data.name;
|
||
img.style.cssText = 'width:100%;height:100%;object-fit:cover;';
|
||
img.onerror = () => {
|
||
img.remove();
|
||
initialsEl.textContent = getInitials(data.name);
|
||
initialsEl.style.background = getColor(data.name);
|
||
initialsEl.style.display = 'flex';
|
||
};
|
||
initialsEl.style.display = 'none';
|
||
photoWrap.appendChild(img);
|
||
} else {
|
||
initialsEl.textContent = getInitials(data.name);
|
||
initialsEl.style.background = getColor(data.name);
|
||
initialsEl.style.display = 'flex';
|
||
}
|
||
|
||
// Text
|
||
document.getElementById('display-name').textContent = data.name || '—';
|
||
document.getElementById('display-company').textContent = data.company || '';
|
||
|
||
const specEl = document.getElementById('display-specialty');
|
||
if (data.specialty) {
|
||
specEl.textContent = data.specialty;
|
||
specEl.style.display = 'inline-block';
|
||
} else {
|
||
specEl.style.display = 'none';
|
||
}
|
||
|
||
const visitorTag = document.getElementById('visitor-tag');
|
||
if (data.isVisitor || data.isSubstituteVisitor) {
|
||
visitorTag.style.display = 'inline-block';
|
||
visitorTag.textContent = data.isSubstituteVisitor ? 'Vertreter' : 'Besucher';
|
||
} else {
|
||
visitorTag.style.display = 'none';
|
||
}
|
||
|
||
// Presenter row: substitute (members) or invited-by (visitors)
|
||
const presenterRow = document.getElementById('presenter-row');
|
||
const rowPerson = data.presentedBy || data.invitedBy || null;
|
||
const rowLabel = data.presentedBy ? 'Vertreten durch' : 'Eingeladen von';
|
||
if (rowPerson) {
|
||
presenterRow.style.display = 'flex';
|
||
document.getElementById('presenter-label').textContent = rowLabel;
|
||
document.getElementById('presenter-name').textContent = rowPerson.name;
|
||
document.getElementById('presenter-company').textContent = rowPerson.company || '';
|
||
|
||
const avatarEl = document.getElementById('presenter-avatar');
|
||
avatarEl.innerHTML = '';
|
||
avatarEl.textContent = '';
|
||
avatarEl.style.background = '';
|
||
if (rowPerson.photoUrl) {
|
||
const img = document.createElement('img');
|
||
img.src = rowPerson.photoUrl;
|
||
img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;border-radius:50%;';
|
||
img.onerror = () => {
|
||
img.remove();
|
||
avatarEl.textContent = getInitials(rowPerson.name);
|
||
avatarEl.style.background = getColor(rowPerson.name);
|
||
};
|
||
avatarEl.appendChild(img);
|
||
} else {
|
||
avatarEl.textContent = getInitials(rowPerson.name);
|
||
avatarEl.style.background = getColor(rowPerson.name);
|
||
}
|
||
} else {
|
||
presenterRow.style.display = 'none';
|
||
}
|
||
|
||
// QR Code
|
||
const qrWrap = document.getElementById('qr-wrap');
|
||
const qrEl = document.getElementById('display-qr');
|
||
|
||
if (data.profileUrl) {
|
||
qrWrap.style.display = 'block';
|
||
qrEl.innerHTML = '';
|
||
try {
|
||
const qrSize = Math.round(Math.min(window.innerHeight * 0.24, window.innerWidth * 0.14, 300));
|
||
new QRCode(qrEl, {
|
||
text: data.profileUrl,
|
||
width: qrSize,
|
||
height: qrSize,
|
||
colorDark: '#1a2744',
|
||
colorLight: '#ffffff',
|
||
correctLevel: QRCode.CorrectLevel.L,
|
||
});
|
||
} catch(e) { qrWrap.style.display = 'none'; }
|
||
} else {
|
||
qrWrap.style.display = 'none';
|
||
}
|
||
|
||
// Next bar
|
||
if (next) {
|
||
nextBar.style.display = 'flex';
|
||
nextBarEnd.style.display = 'none';
|
||
const nextData = getDisplayData(next);
|
||
const nextImg = document.getElementById('next-photo-img');
|
||
if (nextData.photoUrl) {
|
||
nextImg.src = nextData.photoUrl;
|
||
nextImg.style.display = 'block';
|
||
} else {
|
||
nextImg.style.display = 'none';
|
||
}
|
||
document.getElementById('next-name-text').textContent = nextData.name;
|
||
document.getElementById('next-company-text').textContent = nextData.company;
|
||
const nextSubEl = document.getElementById('next-sub-text');
|
||
if (nextData.presentedBy) {
|
||
nextSubEl.textContent = 'Vertreten durch: ' + nextData.presentedBy.name;
|
||
nextSubEl.style.display = 'block';
|
||
} else {
|
||
nextSubEl.style.display = 'none';
|
||
}
|
||
} else {
|
||
nextBar.style.display = 'none';
|
||
nextBarEnd.style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// ABSENT / MEDICAL STATUS
|
||
// ============================================================
|
||
function toggleMemberStatus(id) {
|
||
const isAbsent = state.absentIds.includes(id);
|
||
const isMedical = (state.medicalIds || []).includes(id);
|
||
|
||
if (!isAbsent && !isMedical) {
|
||
// Active → Abwesend
|
||
state.absentIds.push(id);
|
||
state.meetingOrder = state.meetingOrder.filter(mid => mid !== id);
|
||
if (state.currentIndex >= state.meetingOrder.length) {
|
||
state.currentIndex = state.meetingOrder.length - 1;
|
||
}
|
||
} else if (isAbsent) {
|
||
// Abwesend → Medical
|
||
state.absentIds = state.absentIds.filter(aid => aid !== id);
|
||
if (!state.medicalIds) state.medicalIds = [];
|
||
state.medicalIds.push(id);
|
||
} else {
|
||
// Medical → Aktiv (add back to end of queue)
|
||
state.medicalIds = state.medicalIds.filter(mid => mid !== id);
|
||
if (!state.meetingOrder.includes(id)) {
|
||
state.meetingOrder.push(id);
|
||
}
|
||
}
|
||
saveState();
|
||
renderControl();
|
||
updateNextButton();
|
||
renderDisplay();
|
||
}
|
||
|
||
function renderAbsentSection() {
|
||
const section = document.getElementById('absent-members-section');
|
||
if (!section) return;
|
||
|
||
const medicalIds = state.medicalIds || [];
|
||
const medicalMembers = medicalIds.map(id => state.members.find(m => m.id === id)).filter(Boolean);
|
||
const absentMembers = state.absentIds.map(id => state.members.find(m => m.id === id)).filter(Boolean);
|
||
|
||
if (medicalMembers.length === 0 && absentMembers.length === 0) {
|
||
section.style.display = 'none';
|
||
setQueueResizeVisible(false);
|
||
return;
|
||
}
|
||
|
||
setQueueResizeVisible(true);
|
||
section.style.display = 'block';
|
||
section.className = 'absent-section';
|
||
let html = '';
|
||
|
||
if (medicalMembers.length > 0) {
|
||
html += `<div class="absent-section-title">Medical</div>`;
|
||
medicalMembers.forEach(m => { html += absentItemHtml(m, 'medical'); });
|
||
}
|
||
if (absentMembers.length > 0) {
|
||
html += `<div class="absent-section-title">Abwesend</div>`;
|
||
absentMembers.forEach(m => { html += absentItemHtml(m, 'absent'); });
|
||
}
|
||
section.innerHTML = html;
|
||
}
|
||
|
||
function absentItemHtml(member, status) {
|
||
const photoHtml = member.photoUrl
|
||
? `<img src="${escHtml(member.photoUrl)}" alt="" style="width:36px;height:36px;border-radius:50%;object-fit:cover;flex-shrink:0;">`
|
||
: `<div class="queue-initials" style="width:36px;height:36px;min-width:36px;font-size:0.8rem;background:${getColor(member.name)}">${getInitials(member.name)}</div>`;
|
||
const tagCls = status === 'medical' ? 'status-tag-medical' : 'status-tag-absent';
|
||
const label = status === 'medical' ? 'Medical' : 'Abwesend';
|
||
return `<div class="queue-item" style="cursor:default;margin-bottom:6px;opacity:0.75;">
|
||
<div class="queue-photo-wrap" style="width:36px;height:36px;">${photoHtml}</div>
|
||
<div class="queue-info"><div class="queue-name" style="font-size:0.9rem;">${escHtml(member.name)}</div></div>
|
||
<div class="queue-actions">
|
||
<button class="btn btn-ghost btn-sm status-tag ${tagCls}" onclick="toggleMemberStatus('${member.id}')" title="Status ändern (klicken zum Wechseln)">${label}</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function getCurrentPresenter() {
|
||
if (state.mode === 'visitors') {
|
||
const id = state.visitorsOrder[state.visitorsCurrentIndex];
|
||
return state.visitors.find(v => v.id === id) || null;
|
||
}
|
||
if (state.mode === 'absent-summary') return null;
|
||
if (state.currentIndex < 0) return null;
|
||
const id = state.meetingOrder[state.currentIndex];
|
||
return state.members.find(m => m.id === id) || null;
|
||
}
|
||
|
||
function getNextPresenter() {
|
||
if (state.mode === 'visitors') {
|
||
const id = state.visitorsOrder[state.visitorsCurrentIndex + 1];
|
||
return state.visitors.find(v => v.id === id) || null;
|
||
}
|
||
if (state.mode === 'absent-summary') return null;
|
||
// Try next in member queue
|
||
const nextId = state.meetingOrder[state.currentIndex + 1];
|
||
if (nextId) return state.members.find(m => m.id === nextId) || null;
|
||
// Absent summary comes next if there are absent/medical members
|
||
const hasAbsent = state.absentIds.length > 0 || (state.medicalIds || []).length > 0;
|
||
if (hasAbsent) return null; // signal: absent-summary is next (not a regular presenter)
|
||
// Try first visitor
|
||
if (state.visitorsOrder.length > 0) {
|
||
const vid = state.visitorsOrder[0];
|
||
return state.visitors.find(v => v.id === vid) || null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getDisplayData(presenter) {
|
||
// Visitor entries have no substitutes
|
||
if (presenter.isVisitor) {
|
||
// For substitute-visitors, always pull fresh data from state.substitutes
|
||
// (the visitor entry in state.visitors may have been created before a photo was added)
|
||
let name = presenter.name;
|
||
let company = presenter.company;
|
||
let specialty = presenter.specialty;
|
||
let photoUrl = presenter.photoUrl || '';
|
||
|
||
if (presenter.isSubstituteVisitor && presenter.forMemberId) {
|
||
const sub = state.substitutes[presenter.forMemberId];
|
||
if (sub) {
|
||
name = sub.name || name;
|
||
company = sub.company || company;
|
||
specialty = sub.specialty || specialty;
|
||
photoUrl = sub.photoUrl || photoUrl;
|
||
}
|
||
}
|
||
|
||
// If still no photo, try matching by name in members list
|
||
if (!photoUrl) {
|
||
const match = state.members.find(m =>
|
||
m.name.toLowerCase().trim() === name.toLowerCase().trim()
|
||
);
|
||
if (match) photoUrl = match.photoUrl || '';
|
||
}
|
||
|
||
let invitedByMember = null;
|
||
if (presenter.invitedBy) {
|
||
const m = state.members.find(m => m.id === presenter.invitedBy);
|
||
if (m) invitedByMember = { name: m.name, company: m.company, photoUrl: m.photoUrl || '' };
|
||
}
|
||
|
||
return {
|
||
name,
|
||
company,
|
||
specialty,
|
||
photoUrl,
|
||
profileUrl: presenter.profileUrl || '',
|
||
presentedBy: null,
|
||
invitedBy: invitedByMember,
|
||
isVisitor: true,
|
||
isSubstituteVisitor: presenter.isSubstituteVisitor || false,
|
||
};
|
||
}
|
||
const sub = state.substitutes[presenter.id];
|
||
let resolvedSub = null;
|
||
if (sub && sub.name) {
|
||
// If substitute has no photo, try to find them as a chapter member
|
||
let photoUrl = sub.photoUrl || '';
|
||
if (!photoUrl) {
|
||
const match = state.members.find(m =>
|
||
m.name.toLowerCase().trim() === sub.name.toLowerCase().trim()
|
||
);
|
||
if (match) photoUrl = match.photoUrl || '';
|
||
}
|
||
resolvedSub = { ...sub, photoUrl };
|
||
}
|
||
// Merge profile extras (kurzlink + offline photo override)
|
||
const ex = loadExtras()[extrasKey(presenter.name)] || {};
|
||
return {
|
||
// Member stays in foreground
|
||
name: presenter.name,
|
||
company: presenter.company,
|
||
specialty: presenter.specialty,
|
||
photoUrl: ex.photoB64 || presenter.photoUrl || '',
|
||
profileUrl: ex.kurzlink || presenter.profileUrl || '',
|
||
// Substitute shown as secondary presenter
|
||
presentedBy: resolvedSub,
|
||
isVisitor: false,
|
||
};
|
||
}
|
||
|
||
// ============================================================
|
||
// CONTROL VIEW RENDERING
|
||
// ============================================================
|
||
function renderControl() {
|
||
renderMemberQueue();
|
||
renderVisitorQueue();
|
||
renderVisitorsList();
|
||
renderMembersManage();
|
||
renderSubstitutesList();
|
||
renderSubstituteMemberSelect();
|
||
renderInvitedBySelect();
|
||
renderAdminPanel();
|
||
updateNextButton();
|
||
syncDot = document.getElementById('sync-dot');
|
||
}
|
||
|
||
function renderInvitedBySelect() {
|
||
const sel = document.getElementById('v-invited-by');
|
||
if (!sel) return;
|
||
const val = sel.value;
|
||
sel.innerHTML = '<option value="">— kein Mitglied —</option>';
|
||
state.members.slice().sort((a,b) => a.name.localeCompare(b.name)).forEach(m => {
|
||
const opt = document.createElement('option');
|
||
opt.value = m.id;
|
||
opt.textContent = m.name;
|
||
if (m.id === val) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
function renderMemberQueue() {
|
||
const container = document.getElementById('member-queue');
|
||
container.innerHTML = '';
|
||
|
||
if (state.meetingOrder.length === 0) {
|
||
container.innerHTML = `<div class="empty-hint">
|
||
<h3>Keine Mitglieder</h3>
|
||
<p>Füge Mitglieder unter "Mitglieder" hinzu, dann erscheinen sie hier.</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
state.meetingOrder.forEach((id, idx) => {
|
||
const member = state.members.find(m => m.id === id);
|
||
if (!member) return;
|
||
|
||
const isDone = idx < state.currentIndex || (state.mode === 'visitors' && state.currentIndex >= 0);
|
||
const isCurrent = idx === state.currentIndex && state.mode === 'members';
|
||
const isNext = !isCurrent && (
|
||
(state.mode === 'members' && idx === state.currentIndex + 1) ||
|
||
(state.mode === 'members' && state.currentIndex < 0 && idx === 0)
|
||
);
|
||
|
||
const sub = state.substitutes[id];
|
||
const isAbsent = state.absentIds.includes(id);
|
||
|
||
const item = document.createElement('div');
|
||
item.className = 'queue-item' + (isCurrent ? ' current' : '') + (isNext ? ' next-up' : '') + (isDone ? ' done' : '');
|
||
item.dataset.id = id;
|
||
|
||
let badge = '';
|
||
if (isCurrent) badge = `<span class="queue-badge badge-current">Jetzt</span>`;
|
||
else if (isNext) badge = `<span class="queue-badge badge-next">Nächster</span>`;
|
||
else if (isDone) badge = `<span class="queue-badge badge-done">✓</span>`;
|
||
|
||
const photoHtml = member.photoUrl
|
||
? `<img src="${escHtml(member.photoUrl)}" alt="" style="width:44px;height:44px;border-radius:50%;object-fit:cover;">`
|
||
: `<div class="queue-initials" style="background:${getColor(member.name)}">${getInitials(member.name)}</div>`;
|
||
|
||
let subInfo = '';
|
||
if (sub && sub.name) {
|
||
const skipTag = sub.skipRound2 ? ` <span style="font-size:0.68rem;background:#fef3c7;color:#92400e;padding:1px 5px;border-radius:3px;">keine eigene Vorstellung</span>` : '';
|
||
subInfo = `<div class="sub-info">↩ Vertreten durch: ${escHtml(sub.name)}${skipTag}</div>`;
|
||
}
|
||
|
||
item.innerHTML = `
|
||
${badge}
|
||
<div class="drag-handle" title="Ziehen zum Sortieren">⠿</div>
|
||
<div class="queue-photo-wrap">${photoHtml}</div>
|
||
<div class="queue-info">
|
||
<div class="queue-name">${escHtml(member.name)}</div>
|
||
<div class="queue-company">${escHtml(member.company)}</div>
|
||
${subInfo}
|
||
</div>
|
||
<div class="queue-actions">
|
||
${renderTimerBadge(id)}
|
||
<button class="btn btn-ghost btn-sm" onclick="openSubstituteModal('${id}')" title="Vertretung eintragen">↩</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="toggleMemberStatus('${id}')" title="Als abwesend/medical markieren" style="font-size:1rem;">🚫</button>
|
||
</div>
|
||
`;
|
||
|
||
container.appendChild(item);
|
||
});
|
||
|
||
// Render absent/medical members below the queue
|
||
renderAbsentSection();
|
||
|
||
// Init Sortable
|
||
if (sortableInstance) sortableInstance.destroy();
|
||
sortableInstance = Sortable.create(container, {
|
||
animation: 150,
|
||
handle: '.drag-handle',
|
||
ghostClass: 'sortable-ghost',
|
||
filter: '.empty-hint',
|
||
onEnd: (evt) => {
|
||
const moved = state.meetingOrder.splice(evt.oldIndex, 1)[0];
|
||
state.meetingOrder.splice(evt.newIndex, 0, moved);
|
||
// Adjust currentIndex if needed
|
||
if (state.currentIndex >= 0) {
|
||
// Recalculate current: find item that was current
|
||
// Simple approach: keep currentIndex clamped
|
||
state.currentIndex = Math.min(state.currentIndex, state.meetingOrder.length - 1);
|
||
}
|
||
saveState();
|
||
renderControl();
|
||
updateNextButton();
|
||
},
|
||
});
|
||
}
|
||
|
||
function renderVisitorQueue() {
|
||
const container = document.getElementById('visitor-queue');
|
||
container.innerHTML = '';
|
||
|
||
if (state.visitorsOrder.length === 0) {
|
||
container.innerHTML = `<div class="empty-hint">
|
||
<h3>Keine Besucher</h3>
|
||
<p>Füge Besucher im rechten Panel hinzu.</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
state.visitorsOrder.forEach((id, idx) => {
|
||
const visitor = state.visitors.find(v => v.id === id);
|
||
if (!visitor) return;
|
||
|
||
const isDone = idx < state.visitorsCurrentIndex;
|
||
const isCurrent = idx === state.visitorsCurrentIndex && state.mode === 'visitors';
|
||
const isNext = state.mode === 'visitors' && idx === state.visitorsCurrentIndex + 1;
|
||
|
||
const item = document.createElement('div');
|
||
item.className = 'queue-item' + (isCurrent ? ' current' : '') + (isNext ? ' next-up' : '') + (isDone ? ' done' : '');
|
||
item.dataset.id = id;
|
||
|
||
let badge = '';
|
||
if (isCurrent) badge = `<span class="queue-badge badge-current">Jetzt</span>`;
|
||
else if (isNext) badge = `<span class="queue-badge badge-next">Nächster</span>`;
|
||
else if (isDone) badge = `<span class="queue-badge badge-done">✓</span>`;
|
||
|
||
const photoHtml = visitor.photoUrl
|
||
? `<img src="${escHtml(visitor.photoUrl)}" alt="" style="width:44px;height:44px;border-radius:50%;object-fit:cover;">`
|
||
: `<div class="queue-initials" style="background:${getColor(visitor.name)}">${getInitials(visitor.name)}</div>`;
|
||
|
||
const subTag = visitor.isSubstituteVisitor
|
||
? `<div style="font-size:0.72rem;color:#4f46e5;">Vertretung (stellt sich vor)</div>`
|
||
: '';
|
||
const removeBtn = visitor.isSubstituteVisitor
|
||
? '' // substitutes are managed via substitutes panel
|
||
: `<button class="btn btn-danger btn-sm" onclick="removeVisitor('${id}')">✕</button>`;
|
||
|
||
item.innerHTML = `
|
||
${badge}
|
||
<div class="drag-handle">⠿</div>
|
||
<div class="queue-photo-wrap">${photoHtml}</div>
|
||
<div class="queue-info">
|
||
<div class="queue-name">${escHtml(visitor.name)}</div>
|
||
<div class="queue-company">${escHtml(visitor.company || '')}</div>
|
||
${subTag}
|
||
</div>
|
||
<div class="queue-actions">${renderTimerBadge(id)}${removeBtn}</div>
|
||
`;
|
||
|
||
container.appendChild(item);
|
||
});
|
||
|
||
if (sortableVisitorInstance) sortableVisitorInstance.destroy();
|
||
sortableVisitorInstance = Sortable.create(container, {
|
||
animation: 150,
|
||
handle: '.drag-handle',
|
||
ghostClass: 'sortable-ghost',
|
||
filter: '.empty-hint',
|
||
onEnd: (evt) => {
|
||
const moved = state.visitorsOrder.splice(evt.oldIndex, 1)[0];
|
||
state.visitorsOrder.splice(evt.newIndex, 0, moved);
|
||
saveState();
|
||
renderControl();
|
||
},
|
||
});
|
||
}
|
||
|
||
function renderVisitorsList() {
|
||
const container = document.getElementById('visitors-list');
|
||
container.innerHTML = '';
|
||
|
||
if (state.visitors.length === 0) {
|
||
container.innerHTML = `<div class="empty-hint"><p>Noch keine Besucher eingetragen.</p></div>`;
|
||
return;
|
||
}
|
||
|
||
state.visitors.forEach(v => {
|
||
const div = document.createElement('div');
|
||
div.className = 'visitor-item';
|
||
const photoHtml = v.photoUrl
|
||
? `<img src="${escHtml(v.photoUrl)}" alt="" style="width:36px;height:36px;border-radius:50%;object-fit:cover;">`
|
||
: `<div style="width:36px;height:36px;border-radius:50%;background:${getColor(v.name)};display:flex;align-items:center;justify-content:center;color:white;font-weight:700;font-size:0.85rem;">${getInitials(v.name)}</div>`;
|
||
|
||
div.innerHTML = `
|
||
${photoHtml}
|
||
<div style="flex:1">
|
||
<div class="v-name">${escHtml(v.name)}</div>
|
||
<div class="v-company">${escHtml(v.company || '')} ${v.specialty ? '· ' + escHtml(v.specialty) : ''}</div>
|
||
</div>
|
||
${renderTimerBadge(v.id)}
|
||
<button class="btn btn-ghost btn-sm" onclick="openVisitorEdit('${v.id}')" title="Bearbeiten" style="padding:4px 8px;margin-right:4px;">✎</button>
|
||
<button class="btn btn-danger btn-sm" onclick="removeVisitor('${v.id}')">✕</button>
|
||
`;
|
||
container.appendChild(div);
|
||
});
|
||
}
|
||
|
||
function renderMembersManage() {
|
||
const container = document.getElementById('members-manage-list');
|
||
container.innerHTML = '';
|
||
|
||
if (state.members.length === 0) {
|
||
container.innerHTML = `<div class="empty-hint"><p>Noch keine Mitglieder angelegt.</p></div>`;
|
||
return;
|
||
}
|
||
|
||
state.members.forEach(m => {
|
||
const div = document.createElement('div');
|
||
div.className = 'member-manage-item';
|
||
const photoHtml = m.photoUrl
|
||
? `<img src="${escHtml(m.photoUrl)}" alt="" style="width:36px;height:36px;border-radius:50%;object-fit:cover;">`
|
||
: `<div style="width:36px;height:36px;border-radius:50%;background:${getColor(m.name)};display:flex;align-items:center;justify-content:center;color:white;font-weight:700;font-size:0.85rem;">${getInitials(m.name)}</div>`;
|
||
|
||
div.innerHTML = `
|
||
${photoHtml}
|
||
<div style="flex:1">
|
||
<div class="m-name">${escHtml(m.name)}</div>
|
||
<div class="m-company">${escHtml(m.company)} · ${escHtml(m.specialty || '')}</div>
|
||
</div>
|
||
<button class="btn btn-ghost btn-sm" onclick="editMember('${m.id}')">✎</button>
|
||
<button class="btn btn-danger btn-sm" onclick="deleteMember('${m.id}')">✕</button>
|
||
`;
|
||
container.appendChild(div);
|
||
});
|
||
}
|
||
|
||
function renderSubstitutesList() {
|
||
const container = document.getElementById('substitutes-list');
|
||
container.innerHTML = '';
|
||
const subs = Object.entries(state.substitutes).filter(([, v]) => v && v.name);
|
||
|
||
if (subs.length === 0) {
|
||
container.innerHTML = `<div style="color:var(--muted);font-size:0.85rem;padding:8px 0;">Keine aktiven Vertretungen.</div>`;
|
||
return;
|
||
}
|
||
|
||
subs.forEach(([memberId, sub]) => {
|
||
const member = state.members.find(m => m.id === memberId);
|
||
if (!member) return;
|
||
const div = document.createElement('div');
|
||
div.style.cssText = 'padding:8px 0;border-bottom:1px solid var(--border);font-size:0.88rem;';
|
||
div.innerHTML = `
|
||
<div style="font-weight:600;">${escHtml(member.name)}</div>
|
||
<div style="color:var(--muted);">↩ ${escHtml(sub.name)} (${escHtml(sub.company || '')})${sub.skipRound2 ? ' <span style="font-size:0.72rem;background:#fef3c7;color:#92400e;padding:1px 5px;border-radius:3px;margin-left:4px;">keine eigene Vorstellung</span>' : ''}</div>
|
||
<button class="btn btn-danger btn-sm" style="margin-top:4px;" onclick="removeSubstitute('${memberId}')">Entfernen</button>
|
||
`;
|
||
container.appendChild(div);
|
||
});
|
||
}
|
||
|
||
function renderSubstituteMemberSelect() {
|
||
const sel = document.getElementById('sub-member-select');
|
||
const val = sel.value;
|
||
sel.innerHTML = '<option value="">— Mitglied auswählen —</option>';
|
||
state.members.forEach(m => {
|
||
const opt = document.createElement('option');
|
||
opt.value = m.id;
|
||
opt.textContent = m.name;
|
||
if (m.id === val) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
function updateNextButton() {
|
||
const btn = document.getElementById('btn-next');
|
||
const nameEl = document.getElementById('btn-next-name');
|
||
if (!btn) return;
|
||
|
||
if (state.mode === 'round-transition') {
|
||
btn.disabled = false;
|
||
nameEl.textContent = '2. Runde';
|
||
return;
|
||
}
|
||
|
||
const next = getNextPresenter();
|
||
const hasStarted = state.currentIndex >= 0 || state.mode === 'visitors';
|
||
|
||
if (!hasStarted) {
|
||
// Not started yet
|
||
const first = state.meetingOrder.length > 0
|
||
? state.members.find(m => m.id === state.meetingOrder[0])
|
||
: null;
|
||
if (first) {
|
||
btn.disabled = false;
|
||
btn.querySelector('span').textContent = first.name;
|
||
} else {
|
||
btn.disabled = true;
|
||
btn.querySelector('span').textContent = '';
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Absent-summary mode
|
||
if (state.mode === 'absent-summary') {
|
||
btn.disabled = false;
|
||
nameEl.textContent = state.visitorsOrder.length > 0 ? '2. Runde' : '↩ Beenden';
|
||
return;
|
||
}
|
||
|
||
if (next) {
|
||
btn.disabled = false;
|
||
const nextData = getDisplayData(next);
|
||
nameEl.textContent = nextData.name;
|
||
} else {
|
||
// Check if we just finished
|
||
const membersDone = state.mode === 'members' && state.currentIndex >= state.meetingOrder.length - 1;
|
||
const visitorsDone = state.mode === 'visitors' && state.visitorsCurrentIndex >= state.visitorsOrder.length - 1;
|
||
const hasAbsent = state.absentIds.length > 0 || (state.medicalIds || []).length > 0;
|
||
|
||
if (membersDone && hasAbsent) {
|
||
btn.disabled = false;
|
||
nameEl.textContent = 'Abwesenheiten';
|
||
} else if (membersDone && state.visitorsOrder.length === 0) {
|
||
btn.disabled = false;
|
||
nameEl.textContent = '↩ Beenden';
|
||
} else if (visitorsDone) {
|
||
btn.disabled = false;
|
||
nameEl.textContent = '↩ Beenden';
|
||
} else {
|
||
btn.disabled = false;
|
||
nameEl.textContent = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// ACTIONS
|
||
// ============================================================
|
||
function startRoundTransition() {
|
||
state.mode = 'round-transition';
|
||
saveState();
|
||
renderControl();
|
||
renderDisplay();
|
||
}
|
||
|
||
function goNext() {
|
||
if (state.mode === 'round-transition') {
|
||
state.mode = 'visitors';
|
||
state.visitorsCurrentIndex = 0;
|
||
switchTab('visitors');
|
||
saveState();
|
||
renderControl();
|
||
renderDisplay();
|
||
return;
|
||
}
|
||
if (state.mode === 'members') {
|
||
if (state.currentIndex < state.meetingOrder.length - 1) {
|
||
state.currentIndex++;
|
||
} else {
|
||
// End of members: show absent summary if applicable, otherwise go to visitors
|
||
const hasAbsent = state.absentIds.length > 0 || (state.medicalIds || []).length > 0;
|
||
if (hasAbsent) {
|
||
state.mode = 'absent-summary';
|
||
} else if (state.visitorsOrder.length > 0) {
|
||
startRoundTransition();
|
||
return;
|
||
}
|
||
}
|
||
} else if (state.mode === 'absent-summary') {
|
||
if (state.visitorsOrder.length > 0) {
|
||
startRoundTransition();
|
||
return;
|
||
} else {
|
||
// End → back to logo
|
||
state.currentIndex = -1;
|
||
state.visitorsCurrentIndex = -1;
|
||
state.mode = 'members';
|
||
switchTab('members');
|
||
}
|
||
} else if (state.mode === 'visitors') {
|
||
if (state.visitorsCurrentIndex < state.visitorsOrder.length - 1) {
|
||
state.visitorsCurrentIndex++;
|
||
} else {
|
||
// End → back to logo
|
||
state.currentIndex = -1;
|
||
state.visitorsCurrentIndex = -1;
|
||
state.mode = 'members';
|
||
switchTab('members');
|
||
}
|
||
} else if (state.mode === 'members' && state.currentIndex >= state.meetingOrder.length - 1) {
|
||
// End of members, no absent, no visitors → back to logo
|
||
const hasAbsent = state.absentIds.length > 0 || (state.medicalIds || []).length > 0;
|
||
if (!hasAbsent && state.visitorsOrder.length === 0) {
|
||
state.currentIndex = -1;
|
||
state.visitorsCurrentIndex = -1;
|
||
state.mode = 'members';
|
||
switchTab('members');
|
||
}
|
||
}
|
||
// Start timer for new presenter (2s delay)
|
||
if (state.mode === 'members' || state.mode === 'visitors') {
|
||
const nowPresenter = getCurrentPresenter();
|
||
if (nowPresenter) scheduleTimer(getPresenterSeconds(nowPresenter));
|
||
else stopTimer();
|
||
} else {
|
||
stopTimer();
|
||
}
|
||
saveState();
|
||
renderControl();
|
||
renderDisplay();
|
||
}
|
||
|
||
function resetMeeting() {
|
||
if (!confirm('Vorstellungsrunde neu starten? Der Fortschritt wird zurückgesetzt.')) return;
|
||
stopTimer();
|
||
state.currentIndex = -1;
|
||
state.visitorsCurrentIndex = -1;
|
||
state.mode = 'members';
|
||
switchTab('members');
|
||
saveState();
|
||
renderControl();
|
||
renderDisplay();
|
||
}
|
||
|
||
function switchTab(tab) {
|
||
activeTab = tab;
|
||
document.getElementById('member-queue').style.display = tab === 'members' ? '' : 'none';
|
||
document.getElementById('visitor-queue').style.display = tab === 'visitors' ? '' : 'none';
|
||
document.getElementById('tab-members').classList.toggle('active', tab === 'members');
|
||
document.getElementById('tab-visitors').classList.toggle('active', tab === 'visitors');
|
||
}
|
||
|
||
function switchRightPanel(panelId) {
|
||
document.querySelectorAll('.right-panel-section').forEach(el => el.classList.remove('active'));
|
||
document.querySelectorAll('.panel-right-tab').forEach(el => el.classList.remove('active'));
|
||
document.getElementById(panelId).classList.add('active');
|
||
document.querySelector(`[data-panel="${panelId}"]`).classList.add('active');
|
||
}
|
||
|
||
// ============================================================
|
||
// MEMBER CRUD
|
||
// ============================================================
|
||
function openAddMember() {
|
||
document.getElementById('edit-member-id').value = '';
|
||
document.getElementById('modal-member-title').textContent = 'Mitglied hinzufügen';
|
||
document.getElementById('m-name').value = '';
|
||
document.getElementById('m-company').value = '';
|
||
document.getElementById('m-specialty').value = '';
|
||
document.getElementById('m-photo').value = '';
|
||
document.getElementById('m-profile').value = '';
|
||
document.getElementById('m-kurzlink').value = '';
|
||
document.getElementById('photo-preview-wrap').style.display = 'none';
|
||
_updateOfflinePhotoUI(null);
|
||
openModal('modal-member');
|
||
}
|
||
|
||
function editMember(id) {
|
||
const m = state.members.find(m => m.id === id);
|
||
if (!m) return;
|
||
document.getElementById('edit-member-id').value = id;
|
||
document.getElementById('modal-member-title').textContent = 'Mitglied bearbeiten';
|
||
document.getElementById('m-name').value = m.name || '';
|
||
document.getElementById('m-company').value = m.company || '';
|
||
document.getElementById('m-specialty').value = m.specialty || '';
|
||
document.getElementById('m-photo').value = m.photoUrl || '';
|
||
document.getElementById('m-profile').value = m.profileUrl || '';
|
||
|
||
if (m.photoUrl) {
|
||
document.getElementById('photo-preview').src = m.photoUrl;
|
||
document.getElementById('photo-preview-wrap').style.display = 'block';
|
||
} else {
|
||
document.getElementById('photo-preview-wrap').style.display = 'none';
|
||
}
|
||
|
||
// Load extras for this member
|
||
const ex = loadExtras()[extrasKey(m.name)] || {};
|
||
document.getElementById('m-kurzlink').value = ex.kurzlink || '';
|
||
_updateOfflinePhotoUI(ex.photoB64 || null);
|
||
|
||
openModal('modal-member');
|
||
}
|
||
|
||
document.getElementById('m-photo').addEventListener('input', (e) => {
|
||
const url = e.target.value.trim();
|
||
const wrap = document.getElementById('photo-preview-wrap');
|
||
const img = document.getElementById('photo-preview');
|
||
if (url) {
|
||
img.src = url;
|
||
wrap.style.display = 'block';
|
||
} else {
|
||
wrap.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
function saveMember() {
|
||
const name = document.getElementById('m-name').value.trim();
|
||
const company = document.getElementById('m-company').value.trim();
|
||
const specialty = document.getElementById('m-specialty').value.trim();
|
||
const photoUrl = document.getElementById('m-photo').value.trim();
|
||
const profileUrl = document.getElementById('m-profile').value.trim();
|
||
const kurzlink = document.getElementById('m-kurzlink').value.trim();
|
||
|
||
if (!name) { alert('Bitte Name eingeben.'); return; }
|
||
|
||
const editId = document.getElementById('edit-member-id').value;
|
||
|
||
if (editId) {
|
||
const m = state.members.find(m => m.id === editId);
|
||
if (m) Object.assign(m, { name, company, specialty, photoUrl, profileUrl });
|
||
} else {
|
||
const id = 'member-' + Date.now() + '-' + Math.random().toString(36).slice(2,7);
|
||
state.members.push({ id, name, company, specialty, photoUrl, profileUrl });
|
||
// Add to meeting order if not already there
|
||
if (!state.meetingOrder.includes(id)) {
|
||
state.meetingOrder.push(id);
|
||
}
|
||
}
|
||
|
||
// Save extras (kurzlink + offline photo) — keyed by name
|
||
const extras = loadExtras();
|
||
const key = extrasKey(name);
|
||
const entry = extras[key] || {};
|
||
if (kurzlink) entry.kurzlink = kurzlink;
|
||
else delete entry.kurzlink;
|
||
if (_editingOfflinePhotoB64) entry.photoB64 = _editingOfflinePhotoB64;
|
||
else delete entry.photoB64;
|
||
if (Object.keys(entry).length > 0) extras[key] = entry;
|
||
else delete extras[key];
|
||
saveExtras(extras);
|
||
|
||
saveState();
|
||
closeMemberModal();
|
||
renderControl();
|
||
}
|
||
|
||
function deleteMember(id) {
|
||
if (!confirm('Mitglied entfernen?')) return;
|
||
state.members = state.members.filter(m => m.id !== id);
|
||
state.meetingOrder = state.meetingOrder.filter(mid => mid !== id);
|
||
delete state.substitutes[id];
|
||
state.absentIds = state.absentIds.filter(aid => aid !== id);
|
||
if (state.medicalIds) state.medicalIds = state.medicalIds.filter(mid => mid !== id);
|
||
if (state.currentIndex >= state.meetingOrder.length) {
|
||
state.currentIndex = state.meetingOrder.length - 1;
|
||
}
|
||
saveState();
|
||
renderControl();
|
||
}
|
||
|
||
function closeMemberModal() {
|
||
const hint = document.getElementById('offline-photo-url-hint');
|
||
if (hint) hint.remove();
|
||
closeModal('modal-member');
|
||
}
|
||
|
||
// ============================================================
|
||
// VISITOR CRUD
|
||
// ============================================================
|
||
function addVisitor() {
|
||
const name = document.getElementById('v-name').value.trim();
|
||
if (!name) { alert('Bitte Name eingeben.'); return; }
|
||
|
||
const id = 'visitor-' + Date.now() + '-' + Math.random().toString(36).slice(2,7);
|
||
const visitor = {
|
||
id,
|
||
name,
|
||
company: document.getElementById('v-company').value.trim(),
|
||
specialty: document.getElementById('v-specialty').value.trim(),
|
||
photoUrl: document.getElementById('v-photo').value.trim(),
|
||
invitedBy: document.getElementById('v-invited-by').value || '',
|
||
isVisitor: true,
|
||
};
|
||
|
||
state.visitors.push(visitor);
|
||
state.visitorsOrder.push(id);
|
||
|
||
document.getElementById('v-name').value = '';
|
||
document.getElementById('v-company').value = '';
|
||
document.getElementById('v-specialty').value = '';
|
||
document.getElementById('v-photo').value = '';
|
||
document.getElementById('v-invited-by').value = '';
|
||
|
||
saveState();
|
||
renderControl();
|
||
}
|
||
|
||
function removeVisitor(id) {
|
||
state.visitors = state.visitors.filter(v => v.id !== id);
|
||
state.visitorsOrder = state.visitorsOrder.filter(vid => vid !== id);
|
||
if (state.visitorsCurrentIndex >= state.visitorsOrder.length) {
|
||
state.visitorsCurrentIndex = state.visitorsOrder.length - 1;
|
||
}
|
||
saveState();
|
||
renderControl();
|
||
}
|
||
|
||
function openVisitorEdit(id) {
|
||
const v = state.visitors.find(v => v.id === id);
|
||
if (!v) return;
|
||
document.getElementById('ve-id').value = v.id;
|
||
document.getElementById('ve-name').value = v.name || '';
|
||
document.getElementById('ve-company').value = v.company || '';
|
||
document.getElementById('ve-specialty').value = v.specialty || '';
|
||
document.getElementById('ve-invited-by').value = v.invitedBy || '';
|
||
document.getElementById('ve-photo').value = v.photoUrl || '';
|
||
openModal('modal-visitor-edit');
|
||
}
|
||
|
||
function saveVisitorEdit() {
|
||
const id = document.getElementById('ve-id').value;
|
||
const v = state.visitors.find(v => v.id === id);
|
||
if (!v) return;
|
||
const name = document.getElementById('ve-name').value.trim();
|
||
if (!name) { alert('Bitte Name eingeben.'); return; }
|
||
v.name = name;
|
||
v.company = document.getElementById('ve-company').value.trim();
|
||
v.specialty = document.getElementById('ve-specialty').value.trim();
|
||
v.invitedBy = document.getElementById('ve-invited-by').value.trim();
|
||
v.photoUrl = document.getElementById('ve-photo').value.trim();
|
||
saveState();
|
||
renderControl();
|
||
closeModal('modal-visitor-edit');
|
||
}
|
||
|
||
// ============================================================
|
||
// SUBSTITUTE MANAGEMENT
|
||
// ============================================================
|
||
function openSubstituteModal(memberId) {
|
||
const member = state.members.find(m => m.id === memberId);
|
||
if (!member) return;
|
||
|
||
const sub = state.substitutes[memberId] || {};
|
||
document.getElementById('sub-modal-member-id').value = memberId;
|
||
document.getElementById('sub-modal-member-name').textContent = member.name;
|
||
document.getElementById('sub-modal-name').value = sub.name || '';
|
||
document.getElementById('sub-modal-company').value = sub.company || '';
|
||
document.getElementById('sub-modal-specialty').value = sub.specialty || '';
|
||
document.getElementById('sub-modal-photo').value = sub.photoUrl || '';
|
||
document.getElementById('sub-modal-skip-round2').checked = sub.skipRound2 || false;
|
||
openModal('modal-substitute');
|
||
}
|
||
|
||
function saveSubstituteModal() {
|
||
const memberId = document.getElementById('sub-modal-member-id').value;
|
||
const name = document.getElementById('sub-modal-name').value.trim();
|
||
if (!name) { alert('Bitte Name der Vertretung eingeben.'); return; }
|
||
|
||
const subData = {
|
||
name,
|
||
company: document.getElementById('sub-modal-company').value.trim(),
|
||
specialty: document.getElementById('sub-modal-specialty').value.trim(),
|
||
photoUrl: document.getElementById('sub-modal-photo').value.trim(),
|
||
skipRound2: document.getElementById('sub-modal-skip-round2').checked,
|
||
};
|
||
state.substitutes[memberId] = subData;
|
||
syncSubstituteToVisitors(memberId, subData);
|
||
|
||
saveState();
|
||
closeModal('modal-substitute');
|
||
renderControl();
|
||
}
|
||
|
||
function saveSubstitute() {
|
||
const memberId = document.getElementById('sub-member-select').value;
|
||
const name = document.getElementById('sub-rep-name').value.trim();
|
||
if (!memberId) { alert('Bitte Mitglied auswählen.'); return; }
|
||
if (!name) { alert('Bitte Name der Vertretung eingeben.'); return; }
|
||
|
||
const subData = {
|
||
name,
|
||
company: document.getElementById('sub-rep-company').value.trim(),
|
||
specialty: document.getElementById('sub-rep-specialty').value.trim(),
|
||
photoUrl: document.getElementById('sub-rep-photo').value.trim(),
|
||
};
|
||
state.substitutes[memberId] = subData;
|
||
syncSubstituteToVisitors(memberId, subData);
|
||
|
||
document.getElementById('sub-member-select').value = '';
|
||
document.getElementById('sub-rep-name').value = '';
|
||
document.getElementById('sub-rep-company').value = '';
|
||
document.getElementById('sub-rep-specialty').value = '';
|
||
document.getElementById('sub-rep-photo').value = '';
|
||
|
||
saveState();
|
||
renderControl();
|
||
}
|
||
|
||
function removeSubstitute(memberId) {
|
||
removeSubstituteVisitor(memberId);
|
||
delete state.substitutes[memberId];
|
||
saveState();
|
||
closeModal('modal-substitute');
|
||
renderControl();
|
||
}
|
||
|
||
// Automatically add/update substitute as visitor for round 2
|
||
function syncSubstituteToVisitors(memberId, subData) {
|
||
const visitorId = 'sub-visitor-' + memberId;
|
||
// Remove old entry if exists
|
||
removeSubstituteVisitor(memberId);
|
||
// Skip round 2 if flagged (e.g. same-company employee or recurring substitute)
|
||
if (subData.skipRound2) return;
|
||
// Add new visitor entry
|
||
const visitor = {
|
||
id: visitorId,
|
||
name: subData.name,
|
||
company: subData.company || '',
|
||
specialty: subData.specialty || '',
|
||
photoUrl: subData.photoUrl || '',
|
||
isVisitor: true,
|
||
isSubstituteVisitor: true,
|
||
forMemberId: memberId,
|
||
};
|
||
state.visitors.push(visitor);
|
||
state.visitorsOrder.push(visitorId);
|
||
}
|
||
|
||
function removeSubstituteVisitor(memberId) {
|
||
const visitorId = 'sub-visitor-' + memberId;
|
||
state.visitors = state.visitors.filter(v => v.id !== visitorId);
|
||
state.visitorsOrder = state.visitorsOrder.filter(id => id !== visitorId);
|
||
}
|
||
|
||
// ============================================================
|
||
// IMPORT / EXPORT
|
||
// ============================================================
|
||
function openImportExport() {
|
||
const exportData = {
|
||
members: state.members,
|
||
meetingOrder: state.meetingOrder,
|
||
substitutes: state.substitutes,
|
||
visitors: state.visitors,
|
||
visitorsOrder: state.visitorsOrder,
|
||
};
|
||
document.getElementById('json-textarea').value = JSON.stringify(exportData, null, 2);
|
||
openModal('modal-importexport');
|
||
}
|
||
|
||
function exportJSON() {
|
||
const exportData = {
|
||
members: state.members,
|
||
meetingOrder: state.meetingOrder,
|
||
substitutes: state.substitutes,
|
||
visitors: state.visitors,
|
||
visitorsOrder: state.visitorsOrder,
|
||
};
|
||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = 'bni-sonne-mitglieder.json';
|
||
a.click();
|
||
}
|
||
|
||
function importJSON() {
|
||
try {
|
||
const data = JSON.parse(document.getElementById('json-textarea').value);
|
||
if (data.members) state.members = data.members;
|
||
if (data.meetingOrder) state.meetingOrder = data.meetingOrder;
|
||
if (data.substitutes) state.substitutes = data.substitutes;
|
||
if (data.visitors) state.visitors = data.visitors;
|
||
if (data.visitorsOrder) state.visitorsOrder = data.visitorsOrder;
|
||
state.currentIndex = -1;
|
||
state.visitorsCurrentIndex = -1;
|
||
state.mode = 'members';
|
||
saveState();
|
||
closeModal('modal-importexport');
|
||
renderControl();
|
||
alert('Import erfolgreich! ' + state.members.length + ' Mitglieder geladen.');
|
||
} catch(e) {
|
||
alert('Ungültiges JSON: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// MODAL HELPERS
|
||
// ============================================================
|
||
function openModal(id) {
|
||
document.getElementById(id).classList.add('open');
|
||
}
|
||
|
||
function closeModal(id) {
|
||
document.getElementById(id).classList.remove('open');
|
||
}
|
||
|
||
// Close on overlay click
|
||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) overlay.classList.remove('open');
|
||
});
|
||
});
|
||
|
||
// ============================================================
|
||
// UTILITY
|
||
// ============================================================
|
||
function escHtml(str) {
|
||
if (!str) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function uid() {
|
||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||
}
|
||
|
||
// ============================================================
|
||
// KEYBOARD SHORTCUTS
|
||
// ============================================================
|
||
document.addEventListener('keydown', (e) => {
|
||
if (getView() !== 'control') return;
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||
|
||
if (e.key === 'ArrowRight' || e.key === ' ') {
|
||
e.preventDefault();
|
||
const btn = document.getElementById('btn-next');
|
||
if (btn && !btn.disabled) goNext();
|
||
}
|
||
});
|
||
|
||
// ============================================================
|
||
// PDF IMPORT
|
||
// ============================================================
|
||
|
||
function openPdfImport() {
|
||
const lib = window['pdfjs-dist/build/pdf'] || window.pdfjsLib;
|
||
if (lib) lib.GlobalWorkerOptions.workerSrc =
|
||
'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
|
||
const status = document.getElementById('pdf-parse-status');
|
||
if (status) status.style.display = 'none';
|
||
renderQuickEntryTable();
|
||
openModal('modal-pdf-import');
|
||
}
|
||
|
||
async function handlePdfImportFile(file) {
|
||
if (!file || !file.name.toLowerCase().endsWith('.pdf')) return;
|
||
const status = document.getElementById('pdf-parse-status');
|
||
status.style.display = '';
|
||
status.style.cssText += ';background:#f0f4ff;border:1px solid #c0c8f0;color:#1a2744;';
|
||
status.textContent = 'PDF wird analysiert…';
|
||
try {
|
||
const buf = await file.arrayBuffer();
|
||
const rows = await parseBNIListPdf(buf);
|
||
if (rows.length === 0) {
|
||
status.style.cssText += ';background:#fff3cd;border:1px solid #ffc107;color:#664d03;';
|
||
status.textContent = 'Keine Einträge erkannt – ist das die BNI Besucher-/Vertreterliste?';
|
||
return;
|
||
}
|
||
fillTableFromPdfRows(rows);
|
||
status.style.cssText += ';background:#d1fae5;border:1px solid #6ee7b7;color:#065f46;';
|
||
status.textContent = `✓ ${rows.length} Eintr${rows.length===1?'ag':'äge'} aus PDF erkannt und eingetragen.`;
|
||
} catch(err) {
|
||
status.style.cssText += ';background:#fee2e2;border:1px solid #fca5a5;color:#7f1d1d;';
|
||
status.textContent = 'Fehler beim Lesen der PDF: ' + err.message;
|
||
}
|
||
}
|
||
|
||
async function parseBNIListPdf(arrayBuffer) {
|
||
const lib = window['pdfjs-dist/build/pdf'] || window.pdfjsLib;
|
||
if (!lib) throw new Error('pdf.js nicht geladen');
|
||
|
||
const pdf = await lib.getDocument({ data: arrayBuffer }).promise;
|
||
const rows = [];
|
||
|
||
// Column X boundaries derived from the BNI Chapterportal PDF layout:
|
||
// type (B/V): x < 77
|
||
// name: 77 ≤ x < 198
|
||
// phone: 198 ≤ x < 275
|
||
// email: 275 ≤ x < 395
|
||
// specialty: 395 ≤ x < 548
|
||
// forMember: 548 ≤ x < 670
|
||
function colOf(x) {
|
||
if (x < 77) return 'type';
|
||
if (x < 198) return 'name';
|
||
if (x < 275) return 'phone';
|
||
if (x < 395) return 'email';
|
||
if (x < 548) return 'specialty';
|
||
if (x < 670) return 'forMember';
|
||
return null;
|
||
}
|
||
|
||
for (let p = 1; p <= pdf.numPages; p++) {
|
||
const page = await pdf.getPage(p);
|
||
const tc = await page.getTextContent();
|
||
|
||
const items = tc.items
|
||
.map(i => ({ x: i.transform[4], y: i.transform[5], text: i.str.trim() }))
|
||
.filter(i => i.text && i.y < 462 && i.y >= 140) // skip header & footer bands
|
||
.sort((a, b) => b.y - a.y || a.x - b.x); // top→bottom, left→right
|
||
|
||
// Group by Y proximity (≤15 units covers multi-line cells within one row)
|
||
const groups = [];
|
||
for (const item of items) {
|
||
const last = groups[groups.length - 1];
|
||
if (last && Math.abs(last.refY - item.y) <= 15) {
|
||
last.items.push(item);
|
||
} else {
|
||
groups.push({ refY: item.y, items: [item] });
|
||
}
|
||
}
|
||
|
||
for (const g of groups) {
|
||
const cols = {};
|
||
for (const item of g.items) {
|
||
const c = colOf(item.x);
|
||
if (!c) continue;
|
||
cols[c] = cols[c] ? cols[c] + ' ' + item.text : item.text;
|
||
}
|
||
// Rows start with a number: "1. B", "2. V", "3." (empty), etc.
|
||
const m = (cols.type || '').match(/^\d+\.?\s*([BV])?/i);
|
||
if (!m) continue;
|
||
const name = (cols.name || '').trim();
|
||
if (!name) continue;
|
||
rows.push({
|
||
type: (m[1] || '').toUpperCase(),
|
||
name,
|
||
specialty: (cols.specialty || '').trim(),
|
||
forMember: (cols.forMember || '').trim(),
|
||
});
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function fillTableFromPdfRows(rows) {
|
||
renderQuickEntryTable(); // reset to 10 blank rows first
|
||
|
||
const memberOptions = state.members.map(m =>
|
||
`<option value="${escHtml(m.id)}">${escHtml(m.name)}</option>`
|
||
).join('');
|
||
|
||
// Ensure enough table rows exist
|
||
const tbody = document.getElementById('quick-entry-tbody');
|
||
while (tbody.querySelectorAll('.qe-row').length < rows.length) {
|
||
const i = tbody.querySelectorAll('.qe-row').length + 1;
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'qe-row';
|
||
tr.dataset.row = i;
|
||
tr.innerHTML = `
|
||
<td style="width:20px" class="qe-num">${i}</td>
|
||
<td style="width:105px"><select class="qe-type">
|
||
<option value="">—</option>
|
||
<option value="B">B – Besucher</option>
|
||
<option value="V">V – Vertreter</option>
|
||
</select></td>
|
||
<td><input type="text" class="qe-name" placeholder="Vor- und Nachname" autocomplete="off"></td>
|
||
<td><input type="text" class="qe-specialty" placeholder="Berufsbezeichnung" autocomplete="off"></td>
|
||
<td><select class="qe-for-member">
|
||
<option value="">— Mitglied auswählen —</option>${memberOptions}
|
||
</select></td>`;
|
||
tbody.appendChild(tr);
|
||
}
|
||
|
||
const tableRows = tbody.querySelectorAll('.qe-row');
|
||
rows.forEach((row, i) => {
|
||
const tr = tableRows[i];
|
||
tr.querySelector('.qe-type').value = row.type || '';
|
||
tr.querySelector('.qe-name').value = row.name;
|
||
tr.querySelector('.qe-specialty').value = row.specialty;
|
||
|
||
if (row.forMember) {
|
||
const sel = tr.querySelector('.qe-for-member');
|
||
const needle = row.forMember.toLowerCase();
|
||
const match = state.members.find(m => {
|
||
const mn = m.name.toLowerCase();
|
||
return mn === needle
|
||
|| mn.includes(needle)
|
||
|| needle.includes(mn)
|
||
|| needle.includes(mn.split(' ').pop());
|
||
});
|
||
if (match) sel.value = match.id;
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderQuickEntryTable() {
|
||
const tbody = document.getElementById('quick-entry-tbody');
|
||
tbody.innerHTML = '';
|
||
const memberOptions = state.members.map(m =>
|
||
`<option value="${escHtml(m.id)}">${escHtml(m.name)}</option>`
|
||
).join('');
|
||
|
||
for (let i = 1; i <= 10; i++) {
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'qe-row';
|
||
tr.dataset.row = i;
|
||
tr.innerHTML = `
|
||
<td style="width:20px" class="qe-num">${i}</td>
|
||
<td style="width:105px">
|
||
<select class="qe-type">
|
||
<option value="">—</option>
|
||
<option value="B">B – Besucher</option>
|
||
<option value="V">V – Vertreter</option>
|
||
</select>
|
||
</td>
|
||
<td><input type="text" class="qe-name" placeholder="Vor- und Nachname" autocomplete="off"></td>
|
||
<td><input type="text" class="qe-specialty" placeholder="Berufsbezeichnung" autocomplete="off"></td>
|
||
<td>
|
||
<select class="qe-for-member">
|
||
<option value="">— Mitglied auswählen —</option>
|
||
${memberOptions}
|
||
</select>
|
||
</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
}
|
||
}
|
||
|
||
function confirmQuickEntry() {
|
||
const rows = document.querySelectorAll('#quick-entry-tbody .qe-row');
|
||
let importedCount = 0;
|
||
|
||
for (const row of rows) {
|
||
const type = row.querySelector('.qe-type').value;
|
||
const name = row.querySelector('.qe-name').value.trim();
|
||
const specialty = row.querySelector('.qe-specialty').value.trim();
|
||
const memberId = row.querySelector('.qe-for-member').value;
|
||
|
||
if (!name) continue; // skip empty rows
|
||
|
||
if (type === 'V' && memberId) {
|
||
// Vertreter → als Vertretung eintragen + automatisch Besucher-Runde
|
||
const subData = { name, company: '', specialty, photoUrl: '' };
|
||
state.substitutes[memberId] = subData;
|
||
syncSubstituteToVisitors(memberId, subData);
|
||
importedCount++;
|
||
} else {
|
||
// Besucher (oder Vertreter ohne Mitglied-Zuordnung)
|
||
const id = 'visitor-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7);
|
||
state.visitors.push({ id, name, company: '', specialty, photoUrl: '', isVisitor: true });
|
||
state.visitorsOrder.push(id);
|
||
importedCount++;
|
||
}
|
||
}
|
||
|
||
saveState();
|
||
closeModal('modal-pdf-import');
|
||
renderControl();
|
||
switchRightPanel('visitors-panel');
|
||
switchTab('visitors');
|
||
alert(`✓ ${importedCount} Eintr${importedCount === 1 ? 'ag' : 'äge'} importiert.`);
|
||
}
|
||
|
||
// ============================================================
|
||
// MEMBER SYNC
|
||
// ============================================================
|
||
|
||
const BOOKMARKLET_CODE = `(async function(){
|
||
/* ── helper: load a profile page in a hidden iframe, wait for AJAX to render ── */
|
||
function loadProfile(url){
|
||
return new Promise(function(resolve){
|
||
var iframe = document.createElement('iframe');
|
||
iframe.style.cssText='position:fixed;opacity:0;pointer-events:none;top:0;left:0;width:1200px;height:900px;z-index:-1;';
|
||
var done=false;
|
||
var timer;
|
||
function finish(){
|
||
if(done)return; done=true; clearTimeout(timer);
|
||
try{ document.body.removeChild(iframe); }catch(e){}
|
||
resolve(null);
|
||
}
|
||
function extract(){
|
||
if(done)return;
|
||
try{
|
||
var doc=iframe.contentDocument||iframe.contentWindow.document;
|
||
var det=doc.getElementById('memberDetail');
|
||
if(!det||det.innerHTML.trim().length<100){ return false; }
|
||
/* Photo */
|
||
var img=det.querySelector('img[src*="ImageDownload"],img[src*="bniconnect"],img[src*="cdn.bni"]')
|
||
||det.querySelector('.photoCol img,[class*="photo"] img,img');
|
||
var photoUrl=img?(img.getAttribute('src')||''):'';
|
||
if(photoUrl&&!photoUrl.startsWith('http')) photoUrl=location.origin+photoUrl;
|
||
/* Texts – collect all leaf text nodes */
|
||
var leaves=Array.from(det.querySelectorAll('*'))
|
||
.filter(function(el){return el.children.length===0&&el.textContent.trim().length>1;})
|
||
.map(function(el){return el.textContent.trim().replace(/\\s+/g,' ');});
|
||
/* company & specialty: heuristic – look for known BNI class keywords */
|
||
function t(sel){var e=det.querySelector(sel);return e?e.textContent.trim().replace(/\\s+/g,' '):'';}
|
||
var company =t('[class*="company"],[class*="firm"],[class*="Firm"],[class*="unternehm"]');
|
||
var specialty=t('[class*="classif"],[class*="categ"],[class*="spec"],[class*="beruf"],[class*="wirtschaft"],[class*="branch"]');
|
||
if(done)return false; done=true; clearTimeout(timer);
|
||
try{document.body.removeChild(iframe);}catch(e){}
|
||
resolve({photoUrl:photoUrl,company:company,specialty:specialty,leaves:leaves.slice(0,10)});
|
||
return true;
|
||
}catch(e){return false;}
|
||
}
|
||
/* Poll until content appears or timeout */
|
||
var polls=0;
|
||
var poll=setInterval(function(){
|
||
polls++;
|
||
if(extract()||polls>30) clearInterval(poll);
|
||
},400);
|
||
timer=setTimeout(function(){ clearInterval(poll); finish(); },14000);
|
||
iframe.onload=function(){ /* polling starts above */ };
|
||
document.body.appendChild(iframe);
|
||
iframe.src=url;
|
||
});
|
||
}
|
||
/* ──────────────────────────────────────────────────────────────── */
|
||
/* Step 1: collect all unique member profile links */
|
||
var links = Array.from(document.querySelectorAll('a[href*="memberdetails"]'));
|
||
var seen = {}; links = links.filter(function(a){ if(seen[a.href]) return false; return (seen[a.href]=true); });
|
||
if(!links.length){ alert('Keine Mitglieder gefunden.\\nBitte warten bis alle Kacheln geladen sind.'); return; }
|
||
|
||
/* Progress overlay */
|
||
var ov = document.createElement('div');
|
||
ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:999999;display:flex;align-items:center;justify-content:center;font-family:sans-serif;';
|
||
var box = document.createElement('div');
|
||
box.style.cssText='background:#fff;border-radius:14px;padding:28px 36px;text-align:center;min-width:320px;box-shadow:0 8px 40px rgba(0,0,0,.4);';
|
||
box.innerHTML='<div style="font-weight:700;font-size:1.1rem;margin-bottom:10px;">BNI Mitglieder werden geladen…</div>'
|
||
+'<div id="_bni_prog" style="color:#555;font-size:.9rem;">0 / '+links.length+'</div>'
|
||
+'<div style="margin-top:12px;background:#eee;border-radius:6px;height:8px;"><div id="_bni_bar" style="background:#cc0000;height:8px;border-radius:6px;width:0%;transition:width .2s;"></div></div>'
|
||
+'<div style="margin-top:10px;font-size:.78rem;color:#999;">Bitte nicht weg navigieren</div>';
|
||
ov.appendChild(box); document.body.appendChild(ov);
|
||
|
||
/* Step 2: for each member fetch profile detail via AJAX endpoint */
|
||
var members = [];
|
||
|
||
|
||
for(var i=0;i<links.length;i++){
|
||
var link = links[i];
|
||
var url = new URL(link.href);
|
||
var encId = url.searchParams.get('encryptedMemberId') || '';
|
||
var rawName = (url.searchParams.get('name')||'').replace(/\\+/g,' ');
|
||
var name = decodeURIComponent(rawName);
|
||
|
||
document.getElementById('_bni_prog').textContent = (i+1)+' / '+links.length+' – '+name;
|
||
document.getElementById('_bni_bar').style.width = Math.round((i+1)/links.length*100)+'%';
|
||
|
||
var member = { name:name, company:'', specialty:'', photoUrl:'', profileUrl:link.href };
|
||
|
||
var detail = await loadProfile(link.href);
|
||
if(detail){
|
||
member.photoUrl = detail.photoUrl || '';
|
||
member.company = detail.company || '';
|
||
member.specialty = detail.specialty || '';
|
||
/* fallback: if class-based selectors missed, try leaf texts */
|
||
if(!member.company && detail.leaves && detail.leaves.length>1) member.company = detail.leaves[1]||'';
|
||
if(!member.specialty && detail.leaves && detail.leaves.length>2) member.specialty = detail.leaves[2]||'';
|
||
}
|
||
|
||
members.push(member);
|
||
}
|
||
|
||
document.body.removeChild(ov);
|
||
|
||
/* Download JSON */
|
||
var blob = new Blob([JSON.stringify({members:members,exportedAt:new Date().toISOString()},null,2)],{type:'application/json'});
|
||
var a = document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='bni-mitglieder-export.json';
|
||
document.body.appendChild(a); a.click(); setTimeout(function(){ document.body.removeChild(a); },1000);
|
||
alert('✓ '+members.length+' Mitglieder exportiert!\\n('+members.filter(function(m){return m.photoUrl;}).length+' mit Foto)');
|
||
})();`;
|
||
|
||
const DIAG_CODE = `(function(){
|
||
var out = {url:location.href, cards:[]};
|
||
var links = Array.from(document.querySelectorAll('a[href*="memberdetails"]'));
|
||
var seen={}; links=links.filter(function(a){if(seen[a.href])return false;return(seen[a.href]=true);});
|
||
out.totalLinks = links.length;
|
||
/* sample first 3 cards */
|
||
links.slice(0,3).forEach(function(link){
|
||
var c=link; for(var i=0;i<10;i++){var p=c.parentElement;if(!p)break;var r=p.getBoundingClientRect();if(r.width>100&&r.height>60){c=p;break;}c=p;}
|
||
/* collect all img srcs in card */
|
||
var imgs=Array.from(c.querySelectorAll('img')).map(function(i){return{src:i.src,style:i.getAttribute('style')||'',cls:i.className};});
|
||
/* background images */
|
||
var bgImgs=[];
|
||
Array.from(c.querySelectorAll('*')).forEach(function(el){var s=window.getComputedStyle(el).backgroundImage;if(s&&s!=='none'&&s.includes('url'))bgImgs.push({cls:el.className,tag:el.tagName,bg:s});});
|
||
/* text content of direct children */
|
||
var texts=Array.from(c.querySelectorAll('*')).filter(function(el){return el.children.length===0&&el.textContent.trim();}).map(function(el){return{tag:el.tagName,cls:el.className,text:el.textContent.trim().substring(0,80)};});
|
||
out.cards.push({linkText:link.textContent.trim(),containerTag:c.tagName,containerClass:c.className,imgs:imgs,bgImgs:bgImgs,texts:texts,html:c.outerHTML.substring(0,1500)});
|
||
});
|
||
var blob=new Blob([JSON.stringify(out,null,2)],{type:'application/json'});
|
||
var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='bni-diagnose.json';document.body.appendChild(a);a.click();setTimeout(function(){document.body.removeChild(a);},1000);
|
||
alert('Diagnose-Datei heruntergeladen!\\nBitte in der App unter "Mitglieder aktualisieren" hochladen.');
|
||
})();`;
|
||
|
||
function openMemberSync() {
|
||
document.getElementById('bookmarklet-link').href = 'javascript:' + encodeURIComponent(BOOKMARKLET_CODE);
|
||
document.getElementById('bookmarklet-diag').href = 'javascript:' + encodeURIComponent(DIAG_CODE);
|
||
document.getElementById('sync-result').style.display = 'none';
|
||
document.getElementById('sync-file-input').value = '';
|
||
document.getElementById('diag-upload-area').style.display = 'block';
|
||
document.getElementById('diag-result').textContent = '';
|
||
openModal('modal-sync');
|
||
}
|
||
|
||
function handleDiagFile(file) {
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
try {
|
||
const diag = JSON.parse(e.target.result);
|
||
const res = document.getElementById('diag-result');
|
||
|
||
if (!diag.cards || diag.cards.length === 0) {
|
||
res.textContent = 'Keine Karten gefunden – Seite war noch nicht vollständig geladen?';
|
||
return;
|
||
}
|
||
|
||
// Analyse: find img selector that gives photos
|
||
const card = diag.cards[0];
|
||
let photoSelector = '';
|
||
let photoFound = '';
|
||
|
||
// Check real img tags
|
||
const photoImg = card.imgs.find(i =>
|
||
i.src && (i.src.includes('ImageDownload') || i.src.includes('bniconnect') || i.src.includes('cdn.bni') || i.src.includes('member'))
|
||
&& !i.src.includes('logo') && !i.src.includes('icon') && !i.src.includes('gif')
|
||
);
|
||
if (photoImg) {
|
||
photoFound = photoImg.src;
|
||
photoSelector = photoImg.cls ? `img.${photoImg.cls.trim().split(' ')[0]}` : 'img[src*="ImageDownload"],img[src*="bniconnect"]';
|
||
}
|
||
|
||
// Check background images
|
||
if (!photoFound && card.bgImgs.length) {
|
||
const bg = card.bgImgs.find(b => !b.bg.includes('gradient'));
|
||
if (bg) {
|
||
photoFound = bg.bg;
|
||
photoSelector = bg.cls ? `.${bg.cls.trim().split(' ')[0]}` : bg.tag;
|
||
}
|
||
}
|
||
|
||
// Find specialty/company text patterns
|
||
const texts = card.texts.map(t => `[${t.tag}.${t.cls}] "${t.text}"`).slice(0, 8).join('\n');
|
||
|
||
res.innerHTML = `<strong>Gefunden:</strong><br>
|
||
Foto-URL: ${photoFound ? `<span style="color:green">✓ ${photoFound.substring(0,60)}...</span>` : '<span style="color:red">✗ kein Foto-Img gefunden</span>'}<br>
|
||
Container-Klasse: <code>${card.containerClass}</code><br>
|
||
Texte in Karte:<br><pre style="font-size:0.75rem;background:#f5f5f5;padding:6px;border-radius:4px;overflow:auto;max-height:120px;">${texts}</pre>`;
|
||
|
||
// Store analysis for potential selector improvement
|
||
window._bniDiag = diag;
|
||
res.innerHTML += `<br><button class="btn btn-primary btn-sm" onclick="downloadDiagReport()">📋 Diagnose-Report herunterladen (für Entwickler)</button>`;
|
||
|
||
} catch(e) { document.getElementById('diag-result').textContent = 'Fehler: ' + e.message; }
|
||
};
|
||
reader.readAsText(file);
|
||
document.getElementById('diag-upload-area').style.display = 'block';
|
||
}
|
||
|
||
function downloadDiagReport() {
|
||
if (!window._bniDiag) return;
|
||
const blob = new Blob([JSON.stringify(window._bniDiag, null, 2)], { type: 'application/json' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = 'bni-diagnose-report.json';
|
||
document.body.appendChild(a); a.click();
|
||
setTimeout(() => document.body.removeChild(a), 1000);
|
||
}
|
||
|
||
function handleSyncFile(file) {
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
try {
|
||
const data = JSON.parse(e.target.result);
|
||
if (!data.members || !Array.isArray(data.members)) {
|
||
throw new Error('Ungültiges Format – "members"-Array fehlt.');
|
||
}
|
||
const result = syncMembers(data.members);
|
||
const el = document.getElementById('sync-result');
|
||
el.style.display = '';
|
||
el.innerHTML = `<span style="color:var(--success)">✓ Synchronisiert:</span>
|
||
${result.added} neu hinzugefügt ·
|
||
${result.updated} aktualisiert ·
|
||
${result.unchanged} unverändert`;
|
||
saveState();
|
||
renderControl();
|
||
} catch(err) {
|
||
alert('Fehler: ' + err.message);
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
function syncMembers(incoming) {
|
||
let added = 0, updated = 0, unchanged = 0;
|
||
|
||
for (const inc of incoming) {
|
||
if (!inc.name || inc.name.length < 2) continue;
|
||
|
||
// Match by name (case-insensitive)
|
||
const existing = state.members.find(m =>
|
||
m.name.toLowerCase().trim() === inc.name.toLowerCase().trim()
|
||
);
|
||
|
||
if (existing) {
|
||
let changed = false;
|
||
// Update photo if we now have one and didn't before
|
||
if (inc.photoUrl && !existing.photoUrl) { existing.photoUrl = inc.photoUrl; changed = true; }
|
||
// Update specialty if empty
|
||
if (inc.specialty && !existing.specialty) { existing.specialty = inc.specialty; changed = true; }
|
||
// Update company if empty
|
||
if (inc.company && !existing.company) { existing.company = inc.company; changed = true; }
|
||
// Always update profileUrl if we got one (encryptedMemberId might change)
|
||
if (inc.profileUrl && inc.profileUrl !== existing.profileUrl) { existing.profileUrl = inc.profileUrl; changed = true; }
|
||
changed ? updated++ : unchanged++;
|
||
} else {
|
||
// New member – add to list and meeting order
|
||
const id = 'member-' + Date.now() + '-' + Math.random().toString(36).slice(2,7);
|
||
state.members.push({
|
||
id,
|
||
name: inc.name,
|
||
company: inc.company || '',
|
||
specialty: inc.specialty || '',
|
||
photoUrl: inc.photoUrl || '',
|
||
profileUrl: inc.profileUrl || '',
|
||
});
|
||
if (!state.meetingOrder.includes(id)) state.meetingOrder.push(id);
|
||
added++;
|
||
}
|
||
}
|
||
|
||
return { added, updated, unchanged };
|
||
}
|
||
|
||
// ============================================================
|
||
// TIMER ENGINE
|
||
// ============================================================
|
||
|
||
const _t = { total: 0, remaining: 0, running: false, startedAt: 0, pending: null, iv: null };
|
||
|
||
function fmtSecs(s) {
|
||
if (s === undefined || s === null || isNaN(s)) return '0:00';
|
||
const m = Math.floor(s / 60);
|
||
return `${m}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
|
||
}
|
||
|
||
function timerCls(rem, total) {
|
||
if (!total || rem <= 0) return 'expired';
|
||
const r = rem / total;
|
||
return r <= 0.15 ? 'danger' : r <= 0.30 ? 'warn' : '';
|
||
}
|
||
|
||
function updateTimerDisplays() {
|
||
const s = _t.remaining;
|
||
const tot = _t.total;
|
||
const cls = tot > 0 ? timerCls(s, tot) : '';
|
||
const txt = tot > 0 ? fmtSecs(s) : '';
|
||
const CIRC = 729; // 2 * PI * 116
|
||
|
||
// SVG ring
|
||
const ring = document.getElementById('timer-ring-progress');
|
||
const ringEl = document.getElementById('timer-ring-svg');
|
||
if (ring && ringEl) {
|
||
const offset = tot > 0 ? CIRC * (1 - s / tot) : 0;
|
||
ring.style.strokeDashoffset = offset;
|
||
ring.className = 'timer-ring-progress' + (cls ? ' ' + cls : '');
|
||
ringEl.style.display = tot > 0 ? 'block' : 'none';
|
||
}
|
||
|
||
// Display timer text
|
||
const dt = document.getElementById('display-timer-text');
|
||
if (dt) {
|
||
dt.textContent = txt;
|
||
dt.className = cls;
|
||
dt.style.display = tot > 0 ? 'block' : 'none';
|
||
}
|
||
|
||
// Control timer display (live countdown)
|
||
const ct = document.getElementById('ctrl-timer-display');
|
||
if (ct) { ct.textContent = txt; ct.className = cls; }
|
||
|
||
// Control default timer label
|
||
const cd = document.getElementById('ctrl-timer-default');
|
||
if (cd) cd.textContent = fmtSecs(state.defaultTimer || 60);
|
||
|
||
// Toggle button label
|
||
const tb = document.getElementById('ctrl-timer-toggle');
|
||
if (tb) tb.textContent = (_t.running || _t.pending) ? '⏸ Stop' : '▶ Start';
|
||
}
|
||
|
||
function _timerTick() {
|
||
const elapsed = Math.floor((Date.now() - _t.startedAt) / 1000);
|
||
_t.remaining = Math.max(0, _t.total - elapsed);
|
||
updateTimerDisplays();
|
||
if (_t.remaining <= 0) { clearInterval(_t.iv); _t.iv = null; _t.running = false; }
|
||
}
|
||
|
||
function stopTimer() {
|
||
if (_t.pending) { clearTimeout(_t.pending); _t.pending = null; }
|
||
if (_t.iv) { clearInterval(_t.iv); _t.iv = null; }
|
||
_t.running = false; _t.total = 0; _t.remaining = 0;
|
||
if (channel) try { channel.postMessage({ type: 'TIMER_STOP' }); } catch(e) {}
|
||
updateTimerDisplays();
|
||
}
|
||
|
||
function toggleTimer() {
|
||
if (_t.running || _t.pending) {
|
||
stopTimer();
|
||
} else {
|
||
const presenter = getCurrentPresenter();
|
||
const secs = presenter ? getPresenterSeconds(presenter) : (state.defaultTimer || 60);
|
||
scheduleTimer(secs);
|
||
}
|
||
}
|
||
|
||
function resetTimer() {
|
||
const presenter = getCurrentPresenter();
|
||
const secs = presenter ? getPresenterSeconds(presenter) : (state.defaultTimer || 60);
|
||
scheduleTimer(secs);
|
||
}
|
||
|
||
function scheduleTimer(seconds) {
|
||
stopTimer();
|
||
_t.total = seconds; _t.remaining = seconds;
|
||
updateTimerDisplays();
|
||
_t.pending = setTimeout(() => {
|
||
_t.pending = null;
|
||
_t.startedAt = Date.now();
|
||
_t.running = true;
|
||
if (channel) try { channel.postMessage({ type: 'TIMER_START', total: seconds, startedAt: _t.startedAt }); } catch(e) {}
|
||
_t.iv = setInterval(_timerTick, 500);
|
||
updateTimerDisplays();
|
||
}, 2000);
|
||
}
|
||
|
||
function getPresenterSeconds(presenter) {
|
||
const def = state.defaultTimer > 0 ? state.defaultTimer : 60;
|
||
if (!presenter) return def;
|
||
return (state.memberTimers && state.memberTimers[presenter.id]) > 0
|
||
? state.memberTimers[presenter.id] : def;
|
||
}
|
||
|
||
function renderTimerBadge(memberId) {
|
||
const def = state.defaultTimer || 60;
|
||
const custom = state.memberTimers && state.memberTimers[memberId];
|
||
const secs = custom > 0 ? custom : def;
|
||
const isCustom = custom > 0 && custom !== def;
|
||
return `<button class="queue-timer-btn${isCustom ? ' custom' : ''}"
|
||
onclick="editMemberTimer('${memberId}')"
|
||
title="Redezeit anpassen">${fmtSecs(secs)}</button>`;
|
||
}
|
||
|
||
function editMemberTimer(memberId) {
|
||
const def = state.defaultTimer || 60;
|
||
const current = (state.memberTimers && state.memberTimers[memberId]) || def;
|
||
const answer = prompt(
|
||
`Individuelle Redezeit (M:SS oder Minuten)\nStandard: ${fmtSecs(def)}\nLeer lassen = Standard wiederherstellen`,
|
||
current !== def ? fmtSecs(current) : ''
|
||
);
|
||
if (answer === null) return;
|
||
const trimmed = answer.trim();
|
||
if (!trimmed) {
|
||
if (state.memberTimers) delete state.memberTimers[memberId];
|
||
} else {
|
||
const parts = trimmed.split(':');
|
||
const secs = parts.length === 2
|
||
? parseInt(parts[0]) * 60 + parseInt(parts[1])
|
||
: parseInt(parts[0]) * 60;
|
||
if (!isNaN(secs) && secs > 0) {
|
||
if (!state.memberTimers) state.memberTimers = {};
|
||
state.memberTimers[memberId] = secs;
|
||
} else {
|
||
if (state.memberTimers) delete state.memberTimers[memberId];
|
||
}
|
||
}
|
||
saveState();
|
||
renderControl();
|
||
}
|
||
|
||
function toggleFullscreen() {
|
||
if (!document.fullscreenElement) {
|
||
document.documentElement.requestFullscreen().catch(() => {});
|
||
} else {
|
||
document.exitFullscreen().catch(() => {});
|
||
}
|
||
}
|
||
|
||
document.addEventListener('fullscreenchange', () => {
|
||
const btn = document.getElementById('fullscreen-btn');
|
||
if (btn) btn.textContent = document.fullscreenElement ? '✕' : '⛶';
|
||
});
|
||
|
||
function editDefaultTimer() {
|
||
const current = state.defaultTimer || 60;
|
||
const answer = prompt(
|
||
`Standard-Redezeit für alle Mitglieder (M:SS oder Minuten)\nAktuell: ${fmtSecs(current)}`,
|
||
fmtSecs(current)
|
||
);
|
||
if (answer === null) return;
|
||
const parts = answer.trim().split(':');
|
||
const secs = parts.length === 2
|
||
? parseInt(parts[0]) * 60 + parseInt(parts[1])
|
||
: parseInt(parts[0]) * 60;
|
||
if (!isNaN(secs) && secs > 0) {
|
||
state.defaultTimer = secs;
|
||
saveState();
|
||
renderControl();
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// QUEUE RESIZE HANDLE
|
||
// ============================================================
|
||
|
||
let _queueResizeRatio = parseFloat(localStorage.getItem('bni-queue-ratio') || '0.6');
|
||
|
||
function setQueueResizeVisible(show) {
|
||
const handle = document.getElementById('queue-resize-handle');
|
||
const queueEl = document.getElementById('member-queue');
|
||
if (!handle) return;
|
||
if (show) {
|
||
handle.style.display = 'flex';
|
||
applyQueueRatio(_queueResizeRatio);
|
||
} else {
|
||
handle.style.display = 'none';
|
||
queueEl.style.flex = '1';
|
||
queueEl.style.height = '';
|
||
const absentEl = document.getElementById('absent-members-section');
|
||
if (absentEl) absentEl.style.height = '';
|
||
}
|
||
}
|
||
|
||
function applyQueueRatio(ratio) {
|
||
_queueResizeRatio = Math.max(0.15, Math.min(0.85, ratio));
|
||
const panelLeft = document.querySelector('.panel-left');
|
||
const header = panelLeft.querySelector('.panel-section-header');
|
||
const handle = document.getElementById('queue-resize-handle');
|
||
const queueEl = document.getElementById('member-queue');
|
||
const absentEl = document.getElementById('absent-members-section');
|
||
if (!panelLeft || !queueEl || !absentEl) return;
|
||
|
||
const available = panelLeft.clientHeight
|
||
- (header ? header.offsetHeight : 0)
|
||
- (handle ? handle.offsetHeight : 0);
|
||
const queueH = Math.round(available * _queueResizeRatio);
|
||
const absentH = available - queueH;
|
||
|
||
queueEl.style.flex = 'none';
|
||
queueEl.style.height = queueH + 'px';
|
||
absentEl.style.flex = 'none';
|
||
absentEl.style.height = absentH + 'px';
|
||
}
|
||
|
||
(function initQueueResize() {
|
||
const handle = document.getElementById('queue-resize-handle');
|
||
if (!handle) return;
|
||
let dragging = false, startY = 0, startQH = 0;
|
||
|
||
handle.addEventListener('mousedown', e => {
|
||
dragging = true;
|
||
startY = e.clientY;
|
||
startQH = document.getElementById('member-queue').getBoundingClientRect().height;
|
||
handle.classList.add('is-dragging');
|
||
document.body.style.cursor = 'row-resize';
|
||
document.body.style.userSelect = 'none';
|
||
e.preventDefault();
|
||
});
|
||
|
||
document.addEventListener('mousemove', e => {
|
||
if (!dragging) return;
|
||
const panelLeft = document.querySelector('.panel-left');
|
||
const hdr = panelLeft.querySelector('.panel-section-header');
|
||
const hdl = document.getElementById('queue-resize-handle');
|
||
const available = panelLeft.clientHeight
|
||
- (hdr ? hdr.offsetHeight : 0)
|
||
- (hdl ? hdl.offsetHeight : 0);
|
||
const newQH = startQH + (e.clientY - startY);
|
||
applyQueueRatio(newQH / available);
|
||
});
|
||
|
||
document.addEventListener('mouseup', () => {
|
||
if (!dragging) return;
|
||
dragging = false;
|
||
handle.classList.remove('is-dragging');
|
||
document.body.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
localStorage.setItem('bni-queue-ratio', _queueResizeRatio);
|
||
});
|
||
|
||
// Touch support
|
||
handle.addEventListener('touchstart', e => {
|
||
dragging = true;
|
||
startY = e.touches[0].clientY;
|
||
startQH = document.getElementById('member-queue').getBoundingClientRect().height;
|
||
e.preventDefault();
|
||
}, { passive: false });
|
||
|
||
document.addEventListener('touchmove', e => {
|
||
if (!dragging) return;
|
||
const panelLeft = document.querySelector('.panel-left');
|
||
const hdr = panelLeft.querySelector('.panel-section-header');
|
||
const hdl = document.getElementById('queue-resize-handle');
|
||
const available = panelLeft.clientHeight
|
||
- (hdr ? hdr.offsetHeight : 0)
|
||
- (hdl ? hdl.offsetHeight : 0);
|
||
const newQH = startQH + (e.touches[0].clientY - startY);
|
||
applyQueueRatio(newQH / available);
|
||
e.preventDefault();
|
||
}, { passive: false });
|
||
|
||
document.addEventListener('touchend', () => {
|
||
if (!dragging) return;
|
||
dragging = false;
|
||
localStorage.setItem('bni-queue-ratio', _queueResizeRatio);
|
||
});
|
||
})();
|
||
|
||
// ============================================================
|
||
// CHAPTER ACTIVATION & LICENSE
|
||
// ============================================================
|
||
async function activateChapter() {
|
||
const input = document.getElementById('activation-code-input');
|
||
const code = (input ? input.value : '').trim().toLowerCase();
|
||
if (!code) return;
|
||
const btn = document.getElementById('activation-btn');
|
||
const status = document.getElementById('activation-status');
|
||
btn.disabled = true;
|
||
status.textContent = 'Überprüfe Lizenzcode…';
|
||
status.style.color = 'rgba(255,255,255,0.6)';
|
||
try {
|
||
const res = await fetch(giteaUrl(`chapters/${code}/config.json`), { cache: 'no-store' });
|
||
if (!res.ok) {
|
||
status.textContent = 'Ungültiger oder abgelaufener Lizenzcode.';
|
||
status.style.color = '#f87171';
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
const config = await res.json();
|
||
activeChapter = { code, config, activatedAt: new Date().toISOString(), lastChecked: new Date().toISOString() };
|
||
saveChapterConfig(activeChapter);
|
||
STORAGE_KEY = `bni-chapter-${code}-v2`;
|
||
EXTRAS_KEY = `bni-extras-${code}`;
|
||
state = loadState();
|
||
if (config.defaultTimer && !state.meetingOrder.length) {
|
||
state.defaultTimer = config.defaultTimer;
|
||
saveState(false);
|
||
}
|
||
applyChapterUI(config);
|
||
status.textContent = `✓ Chapter "${config.chapterName}" aktiviert!`;
|
||
status.style.color = '#4ade80';
|
||
setTimeout(() => {
|
||
renderCurrentView();
|
||
fetchChapterMembersFromGitea();
|
||
}, 800);
|
||
} catch(e) {
|
||
status.textContent = 'Verbindungsfehler. Bitte Internetverbindung prüfen.';
|
||
status.style.color = '#f87171';
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function applyChapterUI(config) {
|
||
if (!config) return;
|
||
const name = config.chapterName || 'BNI Chapter';
|
||
document.title = `${name} – Vorstellungsrunde`;
|
||
const el = document.getElementById('display-chapter-name');
|
||
if (el) el.textContent = name;
|
||
const ctrlName = document.getElementById('ctrl-chapter-name');
|
||
if (ctrlName) ctrlName.textContent = `${name} – Steuerung`;
|
||
if (config.logoUrl && activeChapter) {
|
||
const src = config.logoUrl.startsWith('http') ? config.logoUrl
|
||
: giteaUrl(`chapters/${activeChapter.code}/${config.logoUrl}`);
|
||
['display-logo','display-logo-empty','ctrl-logo'].forEach(id => {
|
||
const img = document.getElementById(id);
|
||
if (img) img.src = src;
|
||
});
|
||
}
|
||
const syncLink = document.getElementById('bni-sync-link');
|
||
if (syncLink && config.syncUrl) {
|
||
syncLink.href = config.syncUrl;
|
||
syncLink.textContent = name + ' – Mitgliederliste';
|
||
}
|
||
}
|
||
|
||
async function checkLicenseAndUpdates() {
|
||
if (!activeChapter) return;
|
||
try {
|
||
const res = await fetch(giteaUrl(`chapters/${activeChapter.code}/config.json`), { cache: 'no-store' });
|
||
if (!res.ok) {
|
||
clearChapterData();
|
||
state = defaultState();
|
||
alert('Deine Lizenz wurde widerrufen. Bitte gib einen neuen Code ein.');
|
||
renderCurrentView();
|
||
return;
|
||
}
|
||
const config = await res.json();
|
||
activeChapter.config = config;
|
||
activeChapter.lastChecked = new Date().toISOString();
|
||
saveChapterConfig(activeChapter);
|
||
applyChapterUI(config);
|
||
await checkForUpdates();
|
||
} catch(e) {
|
||
// Offline → continue with cached data
|
||
const dot = document.getElementById('sync-dot');
|
||
if (dot) dot.title = 'Offline – letzte Prüfung: ' + (activeChapter.lastChecked ? new Date(activeChapter.lastChecked).toLocaleDateString('de') : 'unbekannt');
|
||
}
|
||
}
|
||
|
||
async function checkForUpdates() {
|
||
try {
|
||
const res = await fetch(giteaUrl('version.json'), { cache: 'no-store' });
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
if (data.version && data.version > APP_VERSION) showUpdateBanner(data.version, data.notes);
|
||
} catch(e) { /* ignore */ }
|
||
}
|
||
|
||
function showUpdateBanner(version, notes) {
|
||
let banner = document.getElementById('update-banner');
|
||
if (!banner) {
|
||
banner = document.createElement('div');
|
||
banner.id = 'update-banner';
|
||
banner.style.cssText = 'background:#1a2744;color:white;padding:8px 16px;display:flex;align-items:center;gap:12px;font-size:0.85rem;flex-shrink:0;z-index:100;';
|
||
const ctrlView = document.getElementById('view-control');
|
||
ctrlView.insertBefore(banner, ctrlView.firstChild);
|
||
}
|
||
banner.innerHTML = `🆕 Update ${version} verfügbar${notes ? ' – ' + notes : ''} <button onclick="downloadUpdate('${version}')" style="background:#cc0000;color:white;border:none;border-radius:6px;padding:4px 12px;cursor:pointer;font-size:0.82rem;font-weight:600;">Jetzt laden</button> <button onclick="this.parentElement.style.display='none'" style="background:transparent;border:none;color:rgba(255,255,255,0.5);cursor:pointer;margin-left:auto;font-size:1rem;">✕</button>`;
|
||
}
|
||
|
||
async function downloadUpdate(version) {
|
||
try {
|
||
const licRes = await fetch(giteaUrl(`chapters/${activeChapter.code}/config.json`), { cache: 'no-store' });
|
||
if (!licRes.ok) {
|
||
clearChapterData();
|
||
state = defaultState();
|
||
alert('Lizenz nicht mehr gültig. Bitte neuen Code eingeben.');
|
||
renderCurrentView();
|
||
return;
|
||
}
|
||
} catch(e) { alert('Keine Serververbindung möglich.'); return; }
|
||
try {
|
||
const res = await fetch(giteaUrl('index.html'), { cache: 'no-store' });
|
||
const html = await res.text();
|
||
const blob = new Blob([html], { type: 'text/html' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = 'index.html';
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
} catch(e) { alert('Download fehlgeschlagen: ' + e.message); }
|
||
}
|
||
|
||
async function fetchChapterMembersFromGitea() {
|
||
if (!activeChapter) return;
|
||
try {
|
||
const res = await fetch(giteaUrl(`chapters/${activeChapter.code}/members.json`), { cache: 'no-store' });
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
if (!data.members || !data.members.length) return;
|
||
const extras = loadExtras();
|
||
const mergedMap = new Map(state.members.map(m => [m.id, m]));
|
||
data.members.forEach(m => {
|
||
const ex = mergedMap.get(m.id);
|
||
mergedMap.set(m.id, ex ? Object.assign({}, ex, m) : m);
|
||
// Embed photoData into extras so existing photo display picks it up
|
||
if (m.photoData) {
|
||
const key = extrasKey(m.name);
|
||
if (!extras[key]) extras[key] = {};
|
||
extras[key].offlinePhotoB64 = m.photoData;
|
||
}
|
||
});
|
||
saveExtras(extras);
|
||
state.members = Array.from(mergedMap.values());
|
||
const knownIds = new Set(state.meetingOrder);
|
||
state.members.forEach(m => { if (!knownIds.has(m.id)) state.meetingOrder.push(m.id); });
|
||
activeChapter.lastMemberSync = new Date().toISOString();
|
||
saveChapterConfig(activeChapter);
|
||
saveState(false);
|
||
renderCurrentView();
|
||
renderAdminPanel();
|
||
} catch(e) { /* ignore */ }
|
||
}
|
||
|
||
function buildMembersJson() {
|
||
const extras = loadExtras();
|
||
const members = state.members.map(m => {
|
||
const ex = extras[extrasKey(m.name)] || {};
|
||
const entry = { id: m.id, name: m.name, company: m.company, specialty: m.specialty, photoUrl: m.photoUrl || '', profileUrl: m.profileUrl || '' };
|
||
if (ex.offlinePhotoB64) entry.photoData = ex.offlinePhotoB64;
|
||
return entry;
|
||
});
|
||
return { lastUpdated: new Date().toISOString().slice(0, 10), members };
|
||
}
|
||
|
||
function saveAdminToken() {
|
||
const val = (document.getElementById('admin-token-input').value || '').trim();
|
||
if (val) localStorage.setItem('bni-gitea-admin-token', val);
|
||
else localStorage.removeItem('bni-gitea-admin-token');
|
||
renderAdminPanel();
|
||
}
|
||
|
||
async function uploadMembersToGitea() {
|
||
const token = localStorage.getItem('bni-gitea-admin-token');
|
||
if (!token) { alert('Bitte zuerst den Admin-Token speichern.'); return; }
|
||
const statusEl = document.getElementById('upload-status');
|
||
statusEl.textContent = 'Lädt hoch…';
|
||
statusEl.style.color = 'var(--muted)';
|
||
|
||
const data = buildMembersJson();
|
||
const jsonStr = JSON.stringify(data, null, 2);
|
||
const content = btoa(unescape(encodeURIComponent(jsonStr)));
|
||
const path = `chapters/${activeChapter.code}/members.json`;
|
||
|
||
try {
|
||
// Get current SHA
|
||
let sha = null;
|
||
const shaRes = await fetch(`${GITEA_API}/contents/${path}`, { headers: { 'Authorization': `token ${token}` } });
|
||
if (shaRes.ok) sha = (await shaRes.json()).sha;
|
||
|
||
const body = { message: `Mitglieder aktualisiert ${new Date().toLocaleDateString('de')}`, content };
|
||
if (sha) body.sha = sha;
|
||
|
||
const res = await fetch(`${GITEA_API}/contents/${path}`, {
|
||
method: sha ? 'PUT' : 'POST',
|
||
headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (res.ok) {
|
||
activeChapter.lastMemberUpload = new Date().toISOString();
|
||
saveChapterConfig(activeChapter);
|
||
statusEl.textContent = `✓ ${data.members.length} Mitglieder hochgeladen`;
|
||
statusEl.style.color = 'var(--success)';
|
||
} else {
|
||
statusEl.textContent = 'Fehler beim Upload. Token prüfen.';
|
||
statusEl.style.color = '#dc2626';
|
||
}
|
||
} catch(e) {
|
||
statusEl.textContent = 'Verbindungsfehler.';
|
||
statusEl.style.color = '#dc2626';
|
||
}
|
||
}
|
||
|
||
function renderAdminPanel() {
|
||
const panel = document.getElementById('admin-panel');
|
||
if (!panel) return;
|
||
const cfg = activeChapter && activeChapter.config;
|
||
const code = activeChapter && activeChapter.code;
|
||
const lastChecked = activeChapter && activeChapter.lastChecked ? new Date(activeChapter.lastChecked).toLocaleString('de') : '—';
|
||
const lastSync = activeChapter && activeChapter.lastMemberSync ? new Date(activeChapter.lastMemberSync).toLocaleString('de') : '—';
|
||
const lastUpload = activeChapter && activeChapter.lastMemberUpload ? new Date(activeChapter.lastMemberUpload).toLocaleString('de') : '—';
|
||
const hasToken = !!localStorage.getItem('bni-gitea-admin-token');
|
||
|
||
panel.innerHTML = `<div style="padding:16px;overflow-y:auto;height:100%;">
|
||
<div style="font-weight:700;font-size:0.9rem;margin-bottom:14px;">Chapter-Administration</div>
|
||
${cfg ? `
|
||
<div style="background:#f8f9fa;border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:14px;">
|
||
<div style="font-size:0.78rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;">Aktives Chapter</div>
|
||
<div style="font-weight:600;margin-bottom:2px;">${cfg.chapterName || '—'}</div>
|
||
<div style="font-size:0.82rem;color:var(--muted);">Version: <strong>${APP_VERSION}</strong> · Lizenzprüfung: ${lastChecked}</div>
|
||
</div>
|
||
|
||
<div style="font-size:0.78rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;">Mitglieder laden</div>
|
||
<button class="btn btn-secondary btn-sm" style="width:100%;margin-bottom:4px;" onclick="fetchChapterMembersFromGitea()">🔄 Vom Server laden</button>
|
||
<div style="font-size:0.78rem;color:var(--muted);margin-bottom:14px;">Letzter Download: ${lastSync}</div>
|
||
|
||
<div style="font-size:0.78rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;">Mitglieder hochladen (Admin)</div>
|
||
<div style="display:flex;gap:6px;margin-bottom:6px;">
|
||
<input id="admin-token-input" type="password" placeholder="Gitea API Token"
|
||
value="${hasToken ? '••••••••' : ''}"
|
||
style="flex:1;padding:6px 10px;border:1px solid var(--border);border-radius:6px;font-size:0.82rem;"
|
||
onfocus="if(this.value==='••••••••')this.value=''">
|
||
<button class="btn btn-secondary btn-sm" onclick="saveAdminToken()">Speichern</button>
|
||
</div>
|
||
<button class="btn btn-primary btn-sm" style="width:100%;margin-bottom:4px;" onclick="uploadMembersToGitea()">📤 Auf Gitea hochladen (inkl. Fotos)</button>
|
||
<div id="upload-status" style="font-size:0.78rem;min-height:18px;margin-bottom:4px;"></div>
|
||
<div style="font-size:0.78rem;color:var(--muted);margin-bottom:14px;">Letzter Upload: ${lastUpload}</div>
|
||
|
||
<button class="btn btn-secondary btn-sm" style="width:100%;margin-bottom:16px;" onclick="checkForUpdates()">🔍 Auf Programm-Updates prüfen</button>
|
||
` : `<div style="color:var(--muted);font-size:0.88rem;margin-bottom:20px;">Kein Chapter aktiviert.</div>`}
|
||
<div style="border-top:1px solid var(--border);padding-top:14px;">
|
||
<button class="btn btn-ghost btn-sm" style="width:100%;" onclick="confirmSwitchChapter()">↔ Chapter wechseln</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function confirmSwitchChapter() {
|
||
if (!confirm('Chapter wechseln?\n\nAlle lokalen Daten dieses Chapters werden gelöscht. Fortfahren?')) return;
|
||
clearChapterData();
|
||
state = defaultState();
|
||
renderCurrentView();
|
||
}
|
||
|
||
// ============================================================
|
||
// INIT
|
||
// ============================================================
|
||
initChannel();
|
||
if (activeChapter) applyChapterUI(activeChapter.config);
|
||
renderCurrentView();
|
||
checkLicenseAndUpdates();
|
||
|
||
// Keyboard shortcut hint
|
||
if (getView() === 'control') {
|
||
console.log('Tastaturkürzel: Leertaste oder → = Weiter');
|
||
}
|
||
</script>
|
||
|
||
|
||
</body>
|
||
</html>
|