3665 lines
142 KiB
HTML
3665 lines
142 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: center;
|
||
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;
|
||
}
|
||
|
||
.display-header {
|
||
position: absolute;
|
||
top: 32px; left: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
|
||
.bni-badge {
|
||
height: 52px;
|
||
width: auto;
|
||
display: block;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.chapter-name {
|
||
color: rgba(255,255,255,0.7);
|
||
font-size: 1.1rem;
|
||
font-weight: 300;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.progress-indicator {
|
||
position: absolute;
|
||
top: 36px; right: 40px;
|
||
color: rgba(255,255,255,0.5);
|
||
font-size: 0.95rem;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.round-badge {
|
||
position: absolute;
|
||
top: 70px; 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: 56px;
|
||
background: rgba(255,255,255,0.07);
|
||
backdrop-filter: blur(24px);
|
||
border: 1px solid rgba(255,255,255,0.12);
|
||
border-radius: 28px;
|
||
padding: 52px 60px;
|
||
max-width: 1000px;
|
||
width: 92%;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.photo-section {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 20px;
|
||
}
|
||
|
||
.member-photo-wrap {
|
||
width: 220px;
|
||
height: 220px;
|
||
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: 4rem;
|
||
font-weight: 700;
|
||
color: white;
|
||
}
|
||
|
||
.qr-wrap {
|
||
background: white;
|
||
padding: 10px;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.qr-label {
|
||
color: rgba(255,255,255,0.4);
|
||
font-size: 0.7rem;
|
||
text-align: center;
|
||
margin-top: 6px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.info-section {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.substitute-label {
|
||
color: rgba(255,255,255,0.5);
|
||
font-size: 0.85rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 2px;
|
||
border-left: 3px solid var(--red);
|
||
padding-left: 10px;
|
||
}
|
||
|
||
.display-name {
|
||
font-size: clamp(2rem, 4vw, 3.2rem);
|
||
font-weight: 800;
|
||
color: white;
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.display-company {
|
||
font-size: clamp(1.1rem, 2vw, 1.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: 1rem;
|
||
padding: 8px 18px;
|
||
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: 0.85rem;
|
||
padding: 4px 14px;
|
||
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: 44px; height: 44px;
|
||
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: 0.9rem; font-weight: 700; color: white;
|
||
}
|
||
|
||
.presenter-avatar img {
|
||
width: 100%; height: 100%; object-fit: cover;
|
||
}
|
||
|
||
.presenter-label {
|
||
font-size: 0.7rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1.5px;
|
||
color: rgba(255,255,255,0.4);
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.presenter-name {
|
||
font-size: 0.95rem;
|
||
font-weight: 600;
|
||
color: rgba(255,255,255,0.85);
|
||
}
|
||
|
||
.presenter-company {
|
||
font-size: 0.8rem;
|
||
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;
|
||
bottom: 0; left: 0; right: 0;
|
||
background: rgba(0,0,0,0.55);
|
||
backdrop-filter: blur(12px);
|
||
border-top: 1px solid rgba(255,255,255,0.08);
|
||
padding: 18px 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20px;
|
||
}
|
||
|
||
.next-bar-label {
|
||
color: rgba(255,255,255,0.4);
|
||
font-size: 0.8rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 2px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.next-bar-divider {
|
||
width: 1px;
|
||
height: 30px;
|
||
background: rgba(255,255,255,0.15);
|
||
}
|
||
|
||
.next-photo-small {
|
||
width: 44px; height: 44px;
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
border: 2px solid rgba(255,255,255,0.2);
|
||
background: rgba(255,255,255,0.1);
|
||
}
|
||
|
||
.next-info .next-name-text {
|
||
color: white;
|
||
font-size: 1.15rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.next-info .next-company-text {
|
||
color: rgba(255,255,255,0.5);
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.next-bar-end-text {
|
||
color: rgba(255,255,255,0.3);
|
||
font-style: italic;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
/* ============================================================
|
||
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: 220px; height: 220px;
|
||
flex-shrink: 0;
|
||
}
|
||
.timer-ring-svg {
|
||
position: absolute;
|
||
top: -14px; left: -14px;
|
||
width: 248px; height: 248px;
|
||
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: 2.6rem; 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: 12px 8px;
|
||
text-align: center;
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
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); }
|
||
|
||
/* 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">
|
||
<div class="display-header">
|
||
<img class="bni-badge" src="logo.jpeg" alt="BNI Sonne Ulm">
|
||
<div class="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="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 src="logo.jpeg" alt="BNI Sonne Ulm" 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 class="qr-wrap" id="qr-wrap" style="display:none">
|
||
<div id="display-qr"></div>
|
||
<div class="qr-label">Profil</div>
|
||
</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:0.78rem;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:20px;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" src="logo.jpeg" alt="BNI Sonne Ulm">
|
||
<div class="ctrl-title">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>
|
||
|
||
<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="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>
|
||
|
||
<!-- ============================================================
|
||
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 (für QR-Code)</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>
|
||
|
||
<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>
|
||
</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 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>
|
||
// ============================================================
|
||
// STATE
|
||
// ============================================================
|
||
const STORAGE_KEY = 'bni-sonne-v2';
|
||
|
||
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) {}
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// BROADCAST CHANNEL (cross-tab sync)
|
||
// ============================================================
|
||
let channel;
|
||
let syncDot;
|
||
|
||
function initChannel() {
|
||
try {
|
||
channel = new BroadcastChannel('bni-sonne-channel');
|
||
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 view = getView();
|
||
document.getElementById('view-display').classList.toggle('active', view === 'display');
|
||
document.getElementById('view-control').classList.toggle('active', view === 'control');
|
||
|
||
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 {
|
||
new QRCode(qrEl, {
|
||
text: data.profileUrl,
|
||
width: 100,
|
||
height: 100,
|
||
colorDark: '#1a2744',
|
||
colorLight: '#ffffff',
|
||
correctLevel: QRCode.CorrectLevel.M,
|
||
});
|
||
} 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 };
|
||
}
|
||
return {
|
||
// Member stays in foreground
|
||
name: presenter.name,
|
||
company: presenter.company,
|
||
specialty: presenter.specialty,
|
||
photoUrl: presenter.photoUrl || '',
|
||
profileUrl: presenter.profileUrl || '',
|
||
// Substitute shown as secondary presenter
|
||
presentedBy: resolvedSub,
|
||
isVisitor: false,
|
||
};
|
||
}
|
||
|
||
// ============================================================
|
||
// CONTROL VIEW RENDERING
|
||
// ============================================================
|
||
function renderControl() {
|
||
renderMemberQueue();
|
||
renderVisitorQueue();
|
||
renderVisitorsList();
|
||
renderMembersManage();
|
||
renderSubstitutesList();
|
||
renderSubstituteMemberSelect();
|
||
renderInvitedBySelect();
|
||
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('photo-preview-wrap').style.display = 'none';
|
||
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';
|
||
}
|
||
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();
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
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() {
|
||
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 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);
|
||
});
|
||
})();
|
||
|
||
// ============================================================
|
||
// INIT
|
||
// ============================================================
|
||
initChannel();
|
||
renderCurrentView();
|
||
|
||
// Keyboard shortcut hint
|
||
if (getView() === 'control') {
|
||
console.log('Tastaturkürzel: Leertaste oder → = Weiter');
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|