diff options
Diffstat (limited to 'src/index.html')
| -rw-r--r-- | src/index.html | 2790 |
1 files changed, 2790 insertions, 0 deletions
diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..86ff355 --- /dev/null +++ b/src/index.html @@ -0,0 +1,2790 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Shiftplanner AI — Therme Employee Scheduling</title> + <style> + :root { + --bg: #F8F6F2; --surface: #FFFFFF; --primary: #7EB8C9; --primary-hover: #6aa8b9; + --secondary: #C9A7D5; --accent: #F4B8A5; --warning: #F9D89C; --danger: #E8A5A5; + --success: #A5D4B8; --text: #3A3A3A; --muted: #8A8A8A; --border: #E5E2DC; --shadow: rgba(0,0,0,0.08); + --w1:#FFB3BA;--w2:#BAE1FF;--w3:#BAFFC9;--w4:#FFFFBA;--w5:#FFD9BA;--w6:#E8BAFF; + --w7:#C9BAFF;--w8:#BAFFEE;--w9:#FFBAC9;--w10:#D9BAFF;--w11:#BAFFFA;--w12:#FFE4BA; + --ai-panel-w: 380px; + } + [data-theme="dark"] { --bg: #1E1E24; --surface: #2A2A32; --text: #E8E6E1; --muted: #9A9A9A; --border: #3A3A44; --shadow: rgba(0,0,0,0.3); } + * { margin: 0; padding: 0; box-sizing: border-box; } + body { font-family: "Segoe UI", "SF Pro Display", -apple-system, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; transition: background 0.3s, color 0.3s; } + .header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 10px 20px; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 8px var(--shadow); } + .header-left, .nav-center, .header-right { display: flex; align-items: center; gap: 8px; } + .logo { font-size: 1.3rem; font-weight: 700; color: var(--primary); white-space: nowrap; } + .logo-ai { font-size: 0.75rem; padding: 2px 8px; border-radius: 12px; background: linear-gradient(135deg, #7EB8C9, #C9A7D5); color: white; font-weight: 700; margin-left: 6px; vertical-align: middle; } + .nav-btn { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 6px 14px; cursor: pointer; font-size: 0.9rem; color: var(--text); transition: all 0.2s; } + .nav-btn:hover { background: var(--primary); color: white; border-color: var(--primary); } + .month-display { font-size: 1.1rem; font-weight: 600; min-width: 180px; text-align: center; } + .btn { padding: 6px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); cursor: pointer; font-size: 0.825rem; transition: all 0.2s; display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; } + .btn:hover { background: var(--primary); color: white; border-color: var(--primary); } + .btn-primary { background: var(--primary); color: white; border-color: var(--primary); } + .btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); } + .btn-danger { background: var(--danger); color: var(--text); border-color: var(--danger); } + .btn-success { background: var(--success); color: var(--text); border-color: var(--success); } + .btn-sm { padding: 4px 10px; font-size: 0.75rem; } + .btn-xs { padding: 2px 8px; font-size: 0.7rem; } + .btn-ai { background: linear-gradient(135deg, #7EB8C9, #C9A7D5); color: white; border: none; font-weight: 700; } + .btn-ai:hover { background: linear-gradient(135deg, #6aa8b9, #b898c9); } + .btn-ai.active { background: linear-gradient(135deg, #C9A7D5, #7EB8C9); } + .conflict-badge { background: var(--danger); color: white; border-radius: 50%; min-width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; font-weight: 700; cursor: pointer; padding: 0 4px; } + .conflict-badge:empty, .conflict-badge[style*="display:none"] { display: none; } + .main-container { display: flex; min-height: calc(100vh - 56px); position: relative; } + .sidebar { width: 300px; background: var(--surface); border-right: 1px solid var(--border); padding: 14px; overflow-y: auto; max-height: calc(100vh - 56px); flex-shrink: 0; transition: width 0.3s, padding 0.3s; } + .sidebar.collapsed { width: 0; padding: 0; overflow: hidden; } + .sidebar-section { margin-bottom: 20px; } + .sidebar-title { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted); margin-bottom: 10px; display: flex; align-items: center; gap: 6px; } + .sidebar-tabs { display: flex; gap: 4px; margin-bottom: 14px; } + .tab-btn { flex: 1; padding: 7px; border: 1px solid var(--border); background: var(--bg); border-radius: 6px; cursor: pointer; font-size: 0.75rem; color: var(--muted); transition: all 0.2s; } + .tab-btn.active { background: var(--primary); color: white; border-color: var(--primary); } + .worker-item, .shift-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 6px; cursor: pointer; transition: all 0.2s; background: var(--surface); } + .worker-item:hover, .shift-item:hover { border-color: var(--primary); transform: translateX(2px); } + .worker-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; } + .worker-name { flex: 1; font-size: 0.825rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .worker-role { font-size: 0.6rem; padding: 2px 5px; border-radius: 4px; background: var(--secondary); color: white; font-weight: 700; white-space: nowrap; } + .worker-role.leader { background: var(--accent); color: white; } + .worker-hours { font-size: 0.7rem; color: var(--muted); white-space: nowrap; } + .shift-time { font-size: 0.75rem; color: var(--muted); white-space: nowrap; } + .shift-leader-tag { font-size: 0.6rem; padding: 1px 5px; border-radius: 4px; background: var(--warning); color: var(--text); font-weight: 700; } + .item-actions { display: flex; gap: 3px; opacity: 0; transition: opacity 0.2s; } + .worker-item:hover .item-actions, .shift-item:hover .item-actions { opacity: 1; } + .icon-btn { width: 26px; height: 26px; border: none; border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; background: var(--bg); color: var(--muted); transition: all 0.2s; font-size: 0.8rem; } + .icon-btn:hover { background: var(--primary); color: white; } + .icon-btn.danger:hover { background: var(--danger); color: white; } + .calendar-container { flex: 1; padding: 14px; overflow-x: auto; min-width: 0; } + .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(150px, 1fr)); gap: 2px; background: var(--border); border-radius: 12px; overflow: hidden; box-shadow: 0 2px 12px var(--shadow); } + .calendar-header { background: var(--surface); padding: 10px 8px; text-align: center; font-weight: 600; font-size: 0.8rem; color: var(--muted); } + .calendar-header.weekend { color: var(--accent); } + .day-cell { background: var(--surface); min-height: 160px; padding: 8px; position: relative; transition: all 0.15s; cursor: pointer; } + .day-cell:hover { background: var(--bg); } + .day-cell.outside { background: var(--bg); opacity: 0.3; } + .day-cell.today { box-shadow: inset 0 0 0 2px var(--primary); } + .day-cell.weekend { background: rgba(244,184,165,0.06); } + .day-cell.has-conflict { box-shadow: inset 0 0 0 2px var(--danger); } + .day-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 4px; } + .day-number { font-size: 1rem; font-weight: 600; } + .day-cell.weekend .day-number { color: var(--accent); } + .day-badges { display: flex; gap: 3px; flex-wrap: wrap; margin-bottom: 3px; } + .badge { font-size: 0.58rem; padding: 1px 4px; border-radius: 3px; background: var(--warning); color: var(--text); font-weight: 600; white-space: nowrap; } + .badge.vacation { background: var(--secondary); color: white; } + .badge.sick { background: var(--danger); color: white; } + .badge.request { background: var(--primary); color: white; } + .badge.custom { background: var(--accent); color: white; } + .shift-block { margin-bottom: 6px; } + .shift-label { font-size: 0.62rem; color: var(--muted); margin-bottom: 2px; font-weight: 600; display: flex; align-items: center; gap: 3px; } + .shift-label.custom-shift { color: var(--accent); } + .shift-label .custom-tag { font-size: 0.55rem; padding: 0 3px; border-radius: 3px; background: var(--accent); color: white; font-weight: 700; } + .assignment { display: flex; align-items: center; gap: 4px; padding: 3px 5px; border-radius: 4px; margin-bottom: 2px; font-size: 0.72rem; cursor: pointer; transition: all 0.15s; } + .assignment:hover { filter: brightness(0.93); } + .assignment .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } + .assignment .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500; } + .assignment .leader-badge { color: var(--accent); font-weight: 700; font-size: 0.7rem; } + .assignment .remove-assign { width: 14px; height: 14px; border-radius: 50%; background: rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.15s; font-size: 0.65rem; flex-shrink: 0; } + .assignment:hover .remove-assign { opacity: 1; } + .quick-add-btn { width: 100%; margin-top: 2px; padding: 2px; font-size: 0.65rem; opacity: 0; transition: opacity 0.15s; } + .shift-block:hover .quick-add-btn { opacity: 1; } + .conflict-indicator { position: absolute; top: 3px; right: 3px; font-size: 0.7rem; z-index: 1; } + .empty-state { text-align: center; padding: 30px 16px; color: var(--muted); } + .empty-state-icon { font-size: 2.5rem; margin-bottom: 8px; } + .empty-state-text { font-size: 0.8rem; line-height: 1.5; } + .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; opacity: 0; visibility: hidden; transition: all 0.2s; backdrop-filter: blur(4px); } + .modal-overlay.open { opacity: 1; visibility: visible; } + .modal { background: var(--surface); border-radius: 16px; padding: 22px; max-width: 550px; width: 95%; max-height: 90vh; overflow-y: auto; transform: scale(0.95); transition: transform 0.2s; box-shadow: 0 20px 60px var(--shadow); } + .modal-overlay.open .modal { transform: scale(1); } + .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; } + .modal-title { font-size: 1.2rem; font-weight: 700; } + .close-btn { width: 30px; height: 30px; border: none; background: var(--bg); border-radius: 8px; cursor: pointer; font-size: 1.1rem; color: var(--muted); transition: all 0.2s; } + .close-btn:hover { background: var(--danger); color: white; } + .form-group { margin-bottom: 14px; } + .form-label { display: block; font-size: 0.75rem; font-weight: 600; margin-bottom: 5px; color: var(--muted); } + .form-label.required::after { content: " *"; color: var(--danger); } + .form-input, .form-select, .form-textarea { width: 100%; padding: 8px 11px; border: 1px solid var(--border); border-radius: 8px; font-size: 0.825rem; background: var(--bg); color: var(--text); transition: all 0.2s; } + .form-input:focus, .form-select:focus, .form-textarea:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(126,184,201,0.2); } + .form-row { display: flex; gap: 10px; } + .form-row .form-group { flex: 1; } + .form-checkbox { display: flex; align-items: center; gap: 7px; cursor: pointer; font-size: 0.825rem; } + .form-checkbox input { width: 16px; height: 16px; accent-color: var(--primary); } + .modal-footer { display: flex; justify-content: flex-end; gap: 7px; margin-top: 20px; } + .conflict-list { margin-top: 14px; } + .conflict-item { display: flex; align-items: center; gap: 7px; padding: 8px 11px; background: rgba(232,165,165,0.2); border-radius: 8px; margin-bottom: 6px; font-size: 0.8rem; } + .conflict-item.warning { background: rgba(249,216,156,0.3); } + .conflict-item.info { background: rgba(126,184,201,0.15); } + .conflict-icon { font-size: 1rem; flex-shrink: 0; } + .wizard-step { display: none; } + .wizard-step.active { display: block; } + .wizard-progress { display: flex; justify-content: center; gap: 8px; margin-bottom: 20px; } + .wizard-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--border); transition: all 0.2s; } + .wizard-dot.active { background: var(--primary); transform: scale(1.3); } + .wizard-dot.completed { background: var(--success); } + .slider-group { margin-bottom: 16px; } + .slider-header { display: flex; justify-content: space-between; margin-bottom: 6px; } + .slider-value { font-weight: 700; color: var(--primary); font-size: 0.9rem; } + .slider { width: 100%; accent-color: var(--primary); height: 5px; cursor: pointer; } + .worker-select-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 6px; margin-top: 10px; max-height: 200px; overflow-y: auto; } + .worker-check-item { display: flex; align-items: center; gap: 7px; padding: 7px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; transition: all 0.2s; font-size: 0.8rem; } + .worker-check-item:hover { border-color: var(--primary); } + .worker-check-item.selected { border-color: var(--primary); background: rgba(126,184,201,0.12); } + .date-list { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; } + .date-chip { display: flex; align-items: center; gap: 4px; padding: 3px 9px; background: var(--primary); color: white; border-radius: 6px; font-size: 0.7rem; font-weight: 500; } + .date-chip button { background: none; border: none; color: white; cursor: pointer; padding: 0; font-size: 0.85rem; opacity: 0.8; } + .date-chip button:hover { opacity: 1; } + .date-chip.warning-chip { background: var(--warning); color: var(--text); } + .date-chip.sick { background: var(--danger); color: white; } + .date-chip.vacation { background: var(--secondary); } + .color-picker { display: flex; gap: 7px; flex-wrap: wrap; } + .color-option { width: 30px; height: 30px; border-radius: 50%; cursor: pointer; border: 3px solid transparent; transition: all 0.2s; } + .color-option:hover { transform: scale(1.15); } + .color-option.selected { border-color: var(--text); } + .toast-container { position: fixed; bottom: 18px; right: 18px; z-index: 2000; display: flex; flex-direction: column; gap: 7px; } + .toast { padding: 10px 18px; border-radius: 8px; background: var(--surface); border: 1px solid var(--border); box-shadow: 0 4px 20px var(--shadow); display: flex; align-items: center; gap: 9px; animation: slideIn 0.3s ease; max-width: 340px; font-size: 0.825rem; } + .toast.success { border-left: 4px solid var(--success); } + .toast.error { border-left: 4px solid var(--danger); } + .toast.warning { border-left: 4px solid var(--warning); } + .toast.info { border-left: 4px solid var(--primary); } + @keyframes slideIn { from { transform: translateX(110%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } + .stats-bar { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 4px; } + .stat-chip { font-size: 0.65rem; padding: 2px 7px; border-radius: 4px; background: var(--bg); color: var(--muted); font-weight: 600; } + .help-text { font-size: 0.75rem; color: var(--muted); margin-top: 6px; } + .gen-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; } + .gen-stat-item { background: var(--bg); border-radius: 8px; padding: 10px; text-align: center; } + .gen-stat-value { font-size: 1.4rem; font-weight: 700; color: var(--primary); } + .gen-stat-label { font-size: 0.65rem; color: var(--muted); text-transform: uppercase; margin-top: 2px; } + .toggle-sidebar { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 6px 12px; cursor: pointer; font-size: 1.1rem; color: var(--text); } + .header-divider { width: 1px; height: 24px; background: var(--border); } + .leader-note { font-size: 0.7rem; color: var(--muted); background: var(--bg); padding: 6px 10px; border-radius: 6px; margin-top: 8px; } + .note-indicator { font-size: 0.7rem; color: var(--primary); cursor: help; } + .custom-shift-card { border: 1px dashed var(--accent); background: rgba(244,184,165,0.08); border-radius: 8px; } + .add-shift-btn { width: 100%; padding: 7px; border: 2px dashed var(--border); border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; font-size: 0.8rem; transition: all 0.2s; margin-top: 8px; } + .add-shift-btn:hover { border-color: var(--primary); color: var(--primary); background: rgba(126,184,201,0.08); } + .custom-shift-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 11px; border-bottom: 1px dashed var(--border); } + .custom-shift-actions { display: flex; gap: 4px; } + + /* ================================================================ + AI ADVISOR PANEL + ================================================================ */ + .ai-toggle-btn { + background: linear-gradient(135deg, #7EB8C9, #C9A7D5); + color: white; + border: none; + border-radius: 10px; + padding: 6px 14px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 700; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.25s; + box-shadow: 0 2px 8px rgba(126,184,201,0.3); + position: relative; + } + .ai-toggle-btn:hover { + background: linear-gradient(135deg, #6aa8b9, #b898c9); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(126,184,201,0.4); + } + .ai-toggle-btn.open { + background: linear-gradient(135deg, #C9A7D5, #7EB8C9); + } + .ai-toggle-btn .ai-pulse { + width: 8px; + height: 8px; + border-radius: 50%; + background: #A5D4B8; + animation: pulse 2s infinite; + } + @keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.8); } + } + .ai-panel { + position: fixed; + top: 56px; + right: 0; + width: var(--ai-panel-w); + height: calc(100vh - 56px); + background: var(--surface); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 200; + box-shadow: -4px 0 20px var(--shadow); + } + .ai-panel.open { transform: translateX(0); } + .ai-panel-header { + padding: 14px 16px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + background: linear-gradient(135deg, rgba(126,184,201,0.08), rgba(201,167,213,0.08)); + } + .ai-panel-title { + font-size: 0.95rem; + font-weight: 700; + color: var(--primary); + display: flex; + align-items: center; + gap: 7px; + } + .ai-panel-close { + width: 28px; + height: 28px; + border: none; + background: var(--bg); + border-radius: 8px; + cursor: pointer; + font-size: 1rem; + color: var(--muted); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + } + .ai-panel-close:hover { background: var(--danger); color: white; } + .ai-status-bar { + padding: 8px 16px; + border-bottom: 1px solid var(--border); + font-size: 0.7rem; + color: var(--muted); + display: flex; + align-items: center; + gap: 8px; + background: var(--bg); + } + .ai-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + } + .ai-status-dot.connected { background: var(--success); } + .ai-status-dot.disconnected { background: var(--danger); } + .ai-status-dot.loading { background: var(--warning); animation: pulse 1s infinite; } + .ai-messages { + flex: 1; + overflow-y: auto; + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 12px; + } + .ai-msg { display: flex; gap: 9px; max-width: 100%; } + .ai-msg.user { flex-direction: row-reverse; } + .ai-msg-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + font-weight: 700; + } + .ai-msg.user .ai-msg-avatar { background: var(--primary); color: white; } + .ai-msg.assistant .ai-msg-avatar { background: linear-gradient(135deg, #7EB8C9, #C9A7D5); color: white; } + .ai-msg-bubble { + max-width: calc(var(--ai-panel-w) - 60px); + padding: 10px 13px; + border-radius: 12px; + font-size: 0.825rem; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + } + .ai-msg.user .ai-msg-bubble { + background: var(--primary); + color: white; + border-bottom-right-radius: 3px; + } + .ai-msg.assistant .ai-msg-bubble { + background: var(--bg); + color: var(--text); + border-bottom-left-radius: 3px; + } + .ai-msg.typing .ai-msg-bubble { + background: var(--bg); + color: var(--muted); + font-style: italic; + } + .ai-msg-actions { + display: flex; + gap: 6px; + margin-top: 5px; + flex-wrap: wrap; + } + .ai-quick-btn { + padding: 3px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--muted); + font-size: 0.68rem; + cursor: pointer; + transition: all 0.2s; + } + .ai-quick-btn:hover { background: var(--primary); color: white; border-color: var(--primary); } + .ai-input-area { + padding: 12px 16px; + border-top: 1px solid var(--border); + background: var(--bg); + } + .ai-suggestions { + display: flex; + gap: 5px; + flex-wrap: wrap; + margin-bottom: 8px; + } + .ai-suggestion-chip { + padding: 4px 10px; + border: 1px dashed var(--secondary); + border-radius: 20px; + background: rgba(201,167,213,0.08); + color: var(--secondary); + font-size: 0.68rem; + cursor: pointer; + transition: all 0.2s; + } + .ai-suggestion-chip:hover { + background: var(--secondary); + color: white; + border-style: solid; + } + .ai-input-row { display: flex; gap: 7px; align-items: flex-end; } + .ai-input { + flex: 1; + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + color: var(--text); + font-size: 0.825rem; + resize: none; + max-height: 120px; + font-family: inherit; + transition: all 0.2s; + } + .ai-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(126,184,201,0.15); } + .ai-send-btn { + width: 36px; + height: 36px; + border: none; + border-radius: 10px; + background: linear-gradient(135deg, #7EB8C9, #C9A7D5); + color: white; + cursor: pointer; + font-size: 1rem; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + flex-shrink: 0; + } + .ai-send-btn:hover { transform: scale(1.08); } + .ai-send-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } + .ai-empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--muted); + padding: 30px; + text-align: center; + gap: 12px; + } + .ai-empty-icon { font-size: 3rem; } + .ai-empty-text { font-size: 0.825rem; line-height: 1.6; max-width: 240px; } + .ai-config-bar { + padding: 8px 16px; + border-top: 1px solid var(--border); + background: var(--surface); + display: flex; + align-items: center; + gap: 8px; + font-size: 0.68rem; + color: var(--muted); + } + .ai-config-bar select { + font-size: 0.68rem; + padding: 2px 5px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text); + } + .ai-loading-dots { + display: inline-flex; + gap: 3px; + margin-left: 4px; + } + .ai-loading-dots span { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--muted); + animation: dot-bounce 1.2s infinite; + } + .ai-loading-dots span:nth-child(2) { animation-delay: 0.2s; } + .ai-loading-dots span:nth-child(3) { animation-delay: 0.4s; } + @keyframes dot-bounce { + 0%, 80%, 100% { transform: translateY(0); } + 40% { transform: translateY(-4px); } + } + /* AI info callout boxes */ + .ai-callout { + padding: 8px 11px; + border-radius: 8px; + margin-top: 8px; + font-size: 0.78rem; + line-height: 1.5; + } + .ai-callout.info { background: rgba(126,184,201,0.12); border-left: 3px solid var(--primary); } + .ai-callout.warning { background: rgba(249,216,156,0.2); border-left: 3px solid var(--warning); } + .ai-callout.danger { background: rgba(232,165,165,0.15); border-left: 3px solid var(--danger); } + .ai-callout.success { background: rgba(165,212,184,0.15); border-left: 3px solid var(--success); } + .ai-callout.accent { background: rgba(201,167,213,0.1); border-left: 3px solid var(--secondary); } + .ai-callout strong { font-weight: 700; } + + @media (max-width: 900px) { + .sidebar { position: fixed; left: 0; top: 56px; height: calc(100vh - 56px); z-index: 50; width: 280px; } + .sidebar.collapsed { left: -280px; } + .toggle-sidebar { display: block !important; } + .header-right .btn .btn-label { display: none; } + .ai-panel { width: min(var(--ai-panel-w), 100vw); } + } + @media (max-width: 600px) { + .header { padding: 8px 12px; gap: 6px; } + .nav-btn { padding: 5px 10px; font-size: 0.8rem; } + .month-display { min-width: 140px; font-size: 1rem; } + .calendar-grid { grid-template-columns: repeat(7, minmax(120px, 1fr)); } + .day-cell { min-height: 130px; } + } + @media print { + .header, .sidebar, .modal-overlay, .toast-container, .ai-panel, .ai-toggle-btn { display: none !important; } + .main-container { display: block; } + .calendar-container { padding: 0; } + .calendar-grid { box-shadow: none; border: 1px solid #ccc; } + .day-cell { min-height: 80px; break-inside: avoid; } + } + </style> +</head> +<body> + <header class="header"> + <div class="header-left"> + <button class="toggle-sidebar" onclick="toggleSidebar()">☰</button> + <span class="logo">📅 Shiftplanner<span class="logo-ai">AI</span></span> + <span class="stat-chip" id="globalWorkerCount" title="Workers">👥 0</span> + <span class="stat-chip" id="globalLeaderCount" title="Shift Leaders">★ 0</span> + </div> + <div class="nav-center"> + <button class="nav-btn" onclick="changeMonth(-1)">←</button> + <span class="month-display" id="monthDisplay"></span> + <button class="nav-btn" onclick="changeMonth(1)">→</button> + </div> + <div class="header-right"> + <span class="conflict-badge" id="conflictBadge" title="Conflicts" onclick="showConflicts()"></span> + <button class="btn" onclick="openModal('generatorModal')" title="Auto-Generate">✨ <span class="btn-label">Auto</span></button> + <button class="btn" onclick="openModal('templateModal')" title="Templates">📋</button> + <div class="header-divider"></div> + <button class="btn" onclick="openModal('shiftModal')" title="Add Shift">+ <span class="btn-label">Shift</span></button> + <button class="btn" onclick="openModal('workerModal')" title="Add Worker">+ <span class="btn-label">Worker</span></button> + <div class="header-divider"></div> + <button class="btn" onclick="toggleTheme()" id="themeBtn" title="Toggle Theme">☀</button> + <button class="btn" onclick="exportData()" title="Export JSON">📤</button> + <label class="btn" style="cursor:pointer;" title="Import JSON">📥<input type="file" accept=".json" onchange="importData(event)" style="display:none;"></label> + <button class="btn" onclick="exportCSV()" title="Export CSV">📊</button> + <button class="btn" onclick="openModal('settingsModal')" title="Settings">⚙️</button> + <div class="header-divider"></div> + <button class="ai-toggle-btn" id="aiToggleBtn" onclick="toggleAIPanel()"> + 🤖 AI Advisor + <span class="ai-pulse"></span> + </button> + </div> + </header> + + <div class="main-container"> + <aside class="sidebar" id="sidebar"> + <div class="sidebar-tabs"> + <button class="tab-btn active" data-tab="workers" onclick="switchTab('workers')">👥 Workers</button> + <button class="tab-btn" data-tab="shifts" onclick="switchTab('shifts')">🕐 Shifts</button> + </div> + <div id="workersTab"> + <div class="sidebar-section"> + <div class="sidebar-title"> + Team (<span id="workerCount">0</span>/<span id="maxWorkerDisplay">22</span>) + <span id="workerLimitWarning" style="color:var(--danger);display:none;">⚠️ Limit!</span> + </div> + <div id="workersList"></div> + <button class="btn btn-primary" style="width:100%;margin-top:10px;" onclick="openWorkerModal()">+ Add Worker</button> + </div> + </div> + <div id="shiftsTab" style="display:none;"> + <div class="sidebar-section"> + <div class="sidebar-title">Shift Templates (<span id="shiftCount">0</span>)</div> + <div class="leader-note">★ = Shift Leader required · Custom shifts are added per-day</div> + <div id="shiftsList"></div> + <button class="btn btn-primary" style="width:100%;margin-top:10px;" onclick="openModal('shiftModal')">+ Add Shift Template</button> + </div> + </div> + </aside> + <main class="calendar-container"> + <div class="calendar-grid" id="calendarGrid"></div> + </main> + </div> + + <!-- AI Advisor Panel --> + <div class="ai-panel" id="aiPanel"> + <div class="ai-panel-header"> + <div class="ai-panel-title">🤖 AI Advisor</div> + <button class="ai-panel-close" onclick="toggleAIPanel()">×</button> + </div> + <div class="ai-status-bar" id="aiStatusBar"> + <span class="ai-status-dot disconnected" id="aiStatusDot"></span> + <span id="aiStatusText">Not connected</span> + </div> + <div class="ai-messages" id="aiMessages"> + <div class="ai-empty-state"> + <div class="ai-empty-icon">💬</div> + <div class="ai-empty-text"> + Ask me anything about your schedule!<br><br> + <strong>Try:</strong><br> + → "Who's overloaded this month?"<br> + → "Suggest improvements"<br> + → "Any conflicts to fix?" + </div> + </div> + </div> + <div class="ai-input-area"> + <div class="ai-suggestions"> + <span class="ai-suggestion-chip" onclick="askAI('Who has the most shifts this month?')">Who's overloaded?</span> + <span class="ai-suggestion-chip" onclick="askAI('Any conflicts I should fix?')">Fix conflicts</span> + <span class="ai-suggestion-chip" onclick="askAI('Suggest fair improvements')">Fairness tips</span> + <span class="ai-suggestion-chip" onclick="askAI('Summarize this month')">Month summary</span> + </div> + <div class="ai-input-row"> + <textarea class="ai-input" id="aiInput" placeholder="Ask about your schedule..." rows="1" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendAIMessage()}"></textarea> + <button class="ai-send-btn" id="aiSendBtn" onclick="sendAIMessage()">➤</button> + </div> + </div> + <div class="ai-config-bar"> + <span>Model:</span> + <select id="aiModelSelect" onchange="saveAIModel()"> + <option value="llama3.2">llama3.2</option> + <option value="mistral">mistral</option> + <option value="llama3.1">llama3.1</option> + <option value="phi3">phi3</option> + <option value="gemma2">gemma2</option> + <option value="minimax-m3:cloud">minimax-m3:cloud</option> + </select> + <span>·</span> + <span id="aiEndpointDisplay">localhost:11434</span> + <span style="margin-left:auto;">Press Enter to send</span> + </div> + </div> + + <!-- Worker Modal --> + <div class="modal-overlay" id="workerModal"><div class="modal"> + <div class="modal-header"><h2 class="modal-title" id="workerModalTitle">Add Worker</h2><button class="close-btn" onclick="closeModal('workerModal')">×</button></div> + <form id="workerForm" onsubmit="saveWorker(event)"> + <input type="hidden" id="workerId"> + <div class="form-row"> + <div class="form-group" style="flex:2;"> + <label class="form-label required">Name</label> + <input type="text" class="form-input" id="workerName" required placeholder="e.g., Maria Schmidt"> + </div> + <div class="form-group"> + <label class="form-label">Role</label> + <select class="form-select" id="workerRole"> + <option value="employee">Employee</option> + <option value="shiftleader">★ Shift Leader</option> + </select> + </div> + </div> + <div class="form-row"> + <div class="form-group"> + <label class="form-label">Contracted Hours/Week</label> + <input type="number" class="form-input" id="workerHours" value="40" min="1" max="60" style="max-width:100px;"> + </div> + <div class="form-group"> + <label class="form-label">Max Workers</label> + <input type="number" class="form-input" id="maxWorkersInput" value="22" min="1" max="200" style="max-width:80px;" onchange="updateMaxWorkers()"> + </div> + </div> + <div class="form-group"> + <label class="form-label">Color</label> + <div class="color-picker" id="colorPicker"></div> + <input type="hidden" id="workerColor"> + </div> + + <div class="form-group"> + <label class="form-label">Day-Off Requests (unavailable dates — no limit)</label> + <input type="date" class="form-input" id="dayOffInput" onchange="addDayOff()"> + <div class="date-list" id="dayOffList"></div> + <div class="help-text">Workers (including shift leaders) can request any number of days off.</div> + </div> + + <div class="form-group"> + <label class="form-label">Sick Days (retroactive — any date)</label> + <div style="display:flex;gap:7px;align-items:center;"> + <input type="date" class="form-input" id="sickDayInput" style="flex:1;" placeholder="Date"> + <input type="text" class="form-input" id="sickDayNote" placeholder="Note (optional)" style="flex:2;"> + <button type="button" class="btn btn-sm btn-primary" onclick="addSickDay()">+ Add</button> + </div> + <div class="date-list" id="sickDayList"></div> + </div> + + <div class="form-group"> + <label class="form-label">Vacation (start → end date)</label> + <div style="display:flex;gap:7px;align-items:center;"> + <input type="date" class="form-input" id="vacStart" placeholder="From" style="flex:1;"> + <span style="color:var(--muted);">→</span> + <input type="date" class="form-input" id="vacEnd" placeholder="To" style="flex:1;"> + <button type="button" class="btn btn-sm btn-primary" onclick="addVacation()">+ Add</button> + </div> + <div class="date-list" id="vacationList"></div> + </div> + + <div class="form-group"> + <label class="form-label">Notes</label> + <textarea class="form-textarea" id="workerNotes" rows="2" placeholder="Preferences, skills, contact..."></textarea> + </div> + + <div class="modal-footer"> + <button type="button" class="btn btn-danger" id="deleteWorkerBtn" style="margin-right:auto;display:none;" onclick="deleteWorker()">🗑️ Delete</button> + <button type="button" class="btn" onclick="closeModal('workerModal')">Cancel</button> + <button type="submit" class="btn btn-primary">Save</button> + </div> + </form> + </div></div> + + <!-- Shift Modal (Global Shift Template) --> + <div class="modal-overlay" id="shiftModal"><div class="modal"> + <div class="modal-header"><h2 class="modal-title" id="shiftModalTitle">Add Shift Template</h2><button class="close-btn" onclick="closeModal('shiftModal')">×</button></div> + <form id="shiftForm" onsubmit="saveShift(event)"> + <input type="hidden" id="shiftId"> + <div class="form-group"> + <label class="form-label required">Shift Name</label> + <input type="text" class="form-input" id="shiftName" required placeholder="e.g., Morning, Late, Night"> + </div> + <div class="form-row"> + <div class="form-group"> + <label class="form-label required">Start Time</label> + <input type="time" class="form-input" id="shiftStart" required> + </div> + <div class="form-group"> + <label class="form-label required">End Time</label> + <input type="time" class="form-input" id="shiftEnd" required> + </div> + </div> + <div class="form-row"> + <div class="form-group"> + <label class="form-label">Min Workers</label> + <input type="number" class="form-input" id="shiftMinWorkers" value="1" min="1" max="22"> + </div> + <div class="form-group"> + <label class="form-label">Max Workers</label> + <input type="number" class="form-input" id="shiftMaxWorkers" value="5" min="1" max="22"> + </div> + </div> + <div class="form-group"> + <label class="form-checkbox"> + <input type="checkbox" id="shiftRequiresLeader" checked> + <span>★ Requires Shift Leader</span> + </label> + <div class="help-text">If checked, at least one shift leader must be assigned to this shift.</div> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-danger" id="deleteShiftBtn" style="margin-right:auto;display:none;" onclick="deleteShift()">🗑️ Delete</button> + <button type="button" class="btn" onclick="closeModal('shiftModal')">Cancel</button> + <button type="submit" class="btn btn-primary">Save</button> + </div> + </form> + </div></div> + + <!-- Custom Shift Modal (for adding one-off shift to a specific day) --> + <div class="modal-overlay" id="customShiftModal"><div class="modal" style="max-width:480px;"> + <div class="modal-header"><h2 class="modal-title">➕ Add Shift to <span id="customShiftDateLabel"></span></h2><button class="close-btn" onclick="closeModal('customShiftModal')">×</button></div> + <form id="customShiftForm" onsubmit="saveCustomShift(event)"> + <input type="hidden" id="customShiftDate"> + <div class="form-group"> + <label class="form-label required">Shift Name</label> + <input type="text" class="form-input" id="customShiftName" required placeholder="e.g., Extra, On-Call, Overtime"> + </div> + <div class="form-row"> + <div class="form-group"> + <label class="form-label required">Start Time</label> + <input type="time" class="form-input" id="customShiftStart" required> + </div> + <div class="form-group"> + <label class="form-label required">End Time</label> + <input type="time" class="form-input" id="customShiftEnd" required> + </div> + </div> + <div class="form-row"> + <div class="form-group"> + <label class="form-label">Min Workers</label> + <input type="number" class="form-input" id="customShiftMin" value="1" min="1" max="22"> + </div> + <div class="form-group"> + <label class="form-label">Max Workers</label> + <input type="number" class="form-input" id="customShiftMax" value="3" min="1" max="22"> + </div> + </div> + <div class="form-group"> + <label class="form-checkbox"> + <input type="checkbox" id="customShiftLeader"> + <span>★ Requires Shift Leader</span> + </label> + </div> + <div class="modal-footer"> + <button type="button" class="btn" onclick="closeModal('customShiftModal')">Cancel</button> + <button type="submit" class="btn btn-primary">Add Shift</button> + </div> + </form> + </div></div> + + <!-- Day Modal --> + <div class="modal-overlay" id="dayModal"><div class="modal" style="max-width:650px;"> + <div class="modal-header"> + <h2 class="modal-title" id="dayModalTitle">Day Details</h2> + <button class="close-btn" onclick="closeModal('dayModal')">×</button> + </div> + <div id="dayModalContent"></div> + <button class="add-shift-btn" id="addShiftToDayBtn" onclick="openAddCustomShiftForm()"> + ➕ Add Shift to This Day + </button> + <div class="form-group" style="margin-top:14px;"> + <label class="form-label">Day Note</label> + <textarea class="form-textarea" id="dayNoteInput" rows="2" placeholder="Add a note for this day..."></textarea> + </div> + <div id="dayConflicts" class="conflict-list"></div> + <div class="modal-footer"> + <button class="btn btn-danger" onclick="clearDayAssignments()">Clear Day</button> + <button class="btn" onclick="closeModal('dayModal')">Close</button> + <button class="btn btn-primary" onclick="saveDayNote()">💾 Save</button> + </div> + </div></div> + + <!-- Generator Modal --> + <div class="modal-overlay" id="generatorModal"><div class="modal" style="max-width:650px;"> + <div class="modal-header"><h2 class="modal-title">✨ Auto-Generate Shift Plan</h2><button class="close-btn" onclick="closeModal('generatorModal')">×</button></div> + <div class="wizard-progress"> + <div class="wizard-dot active" id="wiz1"></div> + <div class="wizard-dot" id="wiz2"></div> + <div class="wizard-dot" id="wiz3"></div> + </div> + <div class="wizard-step active" id="genStep1"> + <h3 style="margin-bottom:14px;">Step 1: Configure</h3> + <div class="slider-group"> + <div class="slider-header"> + <span class="form-label" style="margin:0;">Max Consecutive Days</span> + <span class="slider-value" id="maxConsecVal">5</span> + </div> + <input type="range" class="slider" id="maxConsecutive" min="3" max="7" value="5" oninput="document.getElementById('maxConsecVal').textContent=this.value"> + </div> + <div class="slider-group"> + <div class="slider-header"> + <span class="form-label" style="margin:0;">Min Rest Hours</span> + <span class="slider-value" id="minRestVal">11</span> + </div> + <input type="range" class="slider" id="minRestHours" min="8" max="14" value="11" oninput="document.getElementById('minRestVal').textContent=this.value"> + </div> + <div class="form-group"> + <label class="form-checkbox"> + <input type="checkbox" id="includeWeekends" checked> + <span>Distribute weekend shifts fairly</span> + </label> + </div> + <div class="form-group"> + <label class="form-label">Fairness Priority</label> + <select class="form-select" id="fairnessPriority"> + <option value="balanced">Balanced (All — recommended)</option> + <option value="shifts">Equal Number of Shifts</option> + <option value="hours">Equal Total Hours</option> + <option value="weekends">Equal Weekend Shifts</option> + </select> + </div> + <div class="form-group"> + <label class="form-label">Include Workers</label> + <div class="worker-select-grid" id="genWorkerSelect"></div> + </div> + </div> + <div class="wizard-step" id="genStep2"> + <h3 style="margin-bottom:14px;">Step 2: Preview</h3> + <p style="color:var(--muted);margin-bottom:14px;font-size:0.825rem;">Review fairness distribution, then click Next.</p> + <div id="genPreview" style="max-height:280px;overflow-y:auto;"></div> + </div> + <div class="wizard-step" id="genStep3"> + <h3 style="margin-bottom:14px;">Step 3: Apply</h3> + <div id="genResult"></div> + <div id="genConflicts" class="conflict-list"></div> + </div> + <div class="modal-footer" id="genFooter"> + <button class="btn" onclick="closeModal('generatorModal')">Cancel</button> + <button class="btn" id="genBackBtn" style="display:none;" onclick="genPrevStep()">← Back</button> + <button class="btn btn-primary" id="genNextBtn" onclick="genNextStep()">Generate →</button> + </div> + </div></div> + + <!-- Template Modal --> + <div class="modal-overlay" id="templateModal"><div class="modal"> + <div class="modal-header"><h2 class="modal-title">📋 Templates</h2><button class="close-btn" onclick="closeModal('templateModal')">×</button></div> + <div class="form-group"> + <label class="form-label">Save Current Month as Template</label> + <div style="display:flex;gap:7px;"> + <input type="text" class="form-input" id="templateName" placeholder="Template name..." style="flex:1;"> + <button class="btn btn-primary" onclick="saveTemplate()">Save</button> + </div> + </div> + <div class="form-group"> + <label class="form-label">Apply Template</label> + <div style="display:flex;gap:7px;"> + <select class="form-select" id="templateSelect" style="flex:1;"><option value="">Select template...</option></select> + <input type="month" class="form-input" id="templateMonth" style="flex:1;"> + <button class="btn btn-primary" onclick="applyTemplate()">Apply</button> + </div> + </div> + <div id="templateList" style="margin-top:14px;"></div> + <div class="modal-footer"><button class="btn btn-primary" onclick="closeModal('templateModal')">Done</button></div> + </div></div> + + <!-- Settings Modal --> + <div class="modal-overlay" id="settingsModal"><div class="modal"> + <div class="modal-header"><h2 class="modal-title">⚙️ Settings</h2><button class="close-btn" onclick="closeModal('settingsModal')">×</button></div> + <div class="form-group"><label class="form-checkbox"><input type="checkbox" id="settingDarkMode"><span>🌙 Dark Mode</span></label></div> + <div class="form-group"><label class="form-checkbox"><input type="checkbox" id="settingRestRule" checked><span>⏰ Enforce 11-Hour Rest Rule</span></label></div> + <div class="form-group"><label class="form-checkbox"><input type="checkbox" id="setting5DayRule" checked><span>📅 Enforce Max Consecutive Days</span></label></div> + <div class="form-group"> + <label class="form-label">AI Endpoint</label> + <input type="text" class="form-input" id="aiEndpointInput" value="http://localhost:11434" placeholder="http://localhost:11434"> + <div class="help-text">Ollama server URL. Leave default for local Ollama.</div> + </div> + <div class="help-text" style="margin-top:14px;">These rules apply to the auto-generator and manual assignment.</div> + <div class="modal-footer"><button class="btn btn-primary" onclick="saveSettings()">Save</button></div> + </div></div> + + <!-- Conflicts Modal --> + <div class="modal-overlay" id="conflictsModal"><div class="modal"> + <div class="modal-header"><h2 class="modal-title">⚠️ Conflicts This Month</h2><button class="close-btn" onclick="closeModal('conflictsModal')">×</button></div> + <div id="conflictsList"></div> + <div class="modal-footer"><button class="btn btn-primary" onclick="closeModal('conflictsModal')">Close</button></div> + </div></div> + + <div class="toast-container" id="toastContainer"></div> + + <script> + // ============================================================ + // SHIFT PLANNER v2.1 + AI ADVISOR + // ============================================================ + + const STORAGE_KEY = 'shiftplanner_v2'; + const DEFAULT_MAX_WORKERS = 22; + const WORKER_COLORS = [ + '#FFB3BA','#BAE1FF','#BAFFC9','#FFFFBA','#FFD9BA','#E8BAFF', + '#C9BAFF','#BAFFEE','#FFBAC9','#D9BAFF','#BAFFFA','#FFE4BA' + ]; + + // App state + let state = { + version: '2.1', + settings: { + darkMode: false, + enforceRest: true, + enforce5Day: true, + minRestHours: 11, + maxConsecutive: 5, + maxWorkers: 22 + }, + workers: [], + shifts: [], + assignments: [], + customShifts: {}, + templates: [], + notes: {} + }; + + let currentMonth, currentYear; + let genState = { step: 1, selectedWorkers: new Set(), generatedPlan: null }; + let tempDayOffs = [], tempSickDays = [], tempVacations = []; + + // AI state + let aiEndpoint = 'http://localhost:11434'; + let aiModel = localStorage.getItem('sp_ai_model') || 'llama3.2'; + let aiConnected = false; + let aiLoading = false; + let aiConversationHistory = []; + + // ============================================================ + // INIT + // ============================================================ + function init() { + loadFromStorage(); + const now = new Date(); + currentMonth = now.getMonth(); + currentYear = now.getFullYear(); + renderAll(); + applyTheme(); + initAI(); + } + + function loadFromStorage() { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + try { + const parsed = JSON.parse(saved); + state = { + version: '2.1', + settings: { + darkMode: false, enforceRest: true, enforce5Day: true, + minRestHours: 11, maxConsecutive: 5, maxWorkers: DEFAULT_MAX_WORKERS, + ...(parsed.settings || {}) + }, + workers: parsed.workers || [], + shifts: parsed.shifts || [], + assignments: parsed.assignments || [], + customShifts: parsed.customShifts || {}, + templates: parsed.templates || [], + notes: parsed.notes || {} + }; + } catch(e) { + console.warn('Failed to load state:', e); + } + } + } + + function saveToStorage() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } + + // ============================================================ + // RENDER + // ============================================================ + function renderAll() { + renderMonthDisplay(); + renderCalendar(); + renderWorkersList(); + renderShiftsList(); + updateConflictBadge(); + updateTemplateList(); + updateGlobalStats(); + } + + function getMaxWorkers() { + return state.settings.maxWorkers || DEFAULT_MAX_WORKERS; + } + + function updateGlobalStats() { + const workers = state.workers; + const leaders = workers.filter(w => w.role === 'shiftleader').length; + const maxW = getMaxWorkers(); + document.getElementById('globalWorkerCount').textContent = '👥 ' + workers.length; + document.getElementById('globalLeaderCount').textContent = '★ ' + leaders; + document.getElementById('globalWorkerCount').style.color = workers.length >= maxW ? 'var(--danger)' : 'var(--muted)'; + document.getElementById('maxWorkerDisplay').textContent = maxW; + } + + function renderMonthDisplay() { + const months = ['January','February','March','April','May','June', + 'July','August','September','October','November','December']; + document.getElementById('monthDisplay').textContent = months[currentMonth] + ' ' + currentYear; + } + + function renderCalendar() { + const grid = document.getElementById('calendarGrid'); + const days = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; + let html = days.map((d, i) => + '<div class="calendar-header' + (i >= 5 ? ' weekend' : '') + '">' + d + '</div>' + ).join(''); + + const firstDay = new Date(currentYear, currentMonth, 1); + const lastDay = new Date(currentYear, currentMonth + 1, 0); + const daysInMonth = lastDay.getDate(); + let startDay = firstDay.getDay() - 1; + if (startDay < 0) startDay = 6; + + const today = new Date(); + const todayStr = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0'); + + const prevMonth = new Date(currentYear, currentMonth, 0); + for (let i = startDay - 1; i >= 0; i--) { + html += '<div class="day-cell outside"><div class="day-number">' + (prevMonth.getDate() - i) + '</div></div>'; + } + + for (let day = 1; day <= daysInMonth; day++) { + const dateStr = currentYear + '-' + String(currentMonth + 1).padStart(2,'0') + '-' + String(day).padStart(2,'0'); + const isToday = dateStr === todayStr; + const dow = new Date(currentYear, currentMonth, day).getDay(); + const isWeekend = dow === 0 || dow === 6; + const conflicts = checkDayConflicts(dateStr); + const note = state.notes[dateStr]; + const dayCustomShifts = state.customShifts[dateStr] || []; + + html += '<div class="day-cell' + + (isWeekend ? ' weekend' : '') + + (isToday ? ' today' : '') + + (conflicts.some(c => c.type === 'error') ? ' has-conflict' : '') + + '" onclick="openDayModal(\'' + dateStr + '\')">'; + + html += '<div class="day-header">'; + html += '<span class="day-number">' + day + '</span>'; + if (note) html += '<span class="note-indicator" title="' + escapeHtml(note) + '">📝</span>'; + html += '</div>'; + + const dayBadges = getDayBadges(dateStr); + if (dayCustomShifts.length > 0) { + dayBadges.push('<span class="badge custom">⏰ ' + dayCustomShifts.length + '</span>'); + } + if (dayBadges.length) html += '<div class="day-badges">' + dayBadges.join('') + '</div>'; + + const dayAssignments = state.assignments.filter(a => a.date === dateStr); + const shiftGroups = {}; + dayAssignments.forEach(a => { + if (!shiftGroups[a.shiftId]) shiftGroups[a.shiftId] = []; + shiftGroups[a.shiftId].push(a); + }); + + state.shifts.forEach(shift => { + html += renderShiftBlock(dateStr, shift, shiftGroups[shift.id] || [], false); + }); + + dayCustomShifts.forEach(cs => { + html += renderShiftBlock(dateStr, cs, shiftGroups[cs.id] || [], true); + }); + + if (conflicts.some(c => c.type === 'error')) { + html += '<span class="conflict-indicator">⚠️</span>'; + } + + html += '</div>'; + } + + const totalCells = startDay + daysInMonth; + const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7); + for (let i = 1; i <= remaining; i++) { + html += '<div class="day-cell outside"><div class="day-number">' + i + '</div></div>'; + } + + grid.innerHTML = html; + } + + function renderShiftBlock(dateStr, shift, assigns, isCustom) { + const hasLeader = assigns.some(a => { + const w = state.workers.find(wk => wk.id === a.workerId); + return w && w.role === 'shiftleader'; + }); + + if (assigns.length === 0 && !isCustom) return ''; + + const leaderStatus = shift.requiresLeader + ? (hasLeader ? ' ✓' : ' ⚠️') + : ''; + const leaderColor = shift.requiresLeader + ? (hasLeader ? 'var(--success)' : 'var(--danger)') + : (isCustom ? 'var(--accent)' : 'var(--muted)'); + const customTag = isCustom ? '<span class="custom-tag">ONE-OFF</span>' : ''; + const shiftClass = isCustom ? 'custom-shift' : ''; + + let html = '<div class="shift-block">'; + html += '<div class="shift-label ' + shiftClass + '">'; + html += '<span style="color:' + leaderColor + ';">' + escapeHtml(shift.name) + ' ' + shift.startTime + '–' + shift.endTime + leaderStatus + '</span>'; + if (customTag) html += customTag; + html += '</div>'; + + assigns.forEach(a => { + const w = state.workers.find(wk => wk.id === a.workerId); + if (!w) return; + const bgColor = w.role === 'shiftleader' + ? 'var(--warning)' + : w.color + '30'; + html += '<div class="assignment" style="background:' + bgColor + ';" ' + + 'onclick="event.stopPropagation();removeAssignment(\'' + dateStr + '\',\'' + shift.id + '\',\'' + w.id + '\')">'; + html += '<span class="dot" style="background:' + w.color + ';"></span>'; + html += '<span class="name">' + w.name + '</span>'; + if (w.role === 'shiftleader') html += '<span class="leader-badge">★</span>'; + html += '<span class="remove-assign">×</span>'; + html += '</div>'; + }); + + if (assigns.length === 0) { + html += '<p style="font-size:0.65rem;color:var(--muted);margin-bottom:3px;">No one assigned</p>'; + } + + html += '<button class="btn btn-xs quick-add-btn" style="background:var(--primary);color:white;border:none;" ' + + 'onclick="event.stopPropagation();quickAdd(\'' + dateStr + '\',\'' + shift.id + '\')">+ Add</button>'; + html += '</div>'; + + return html; + } + + function getDayBadges(dateStr) { + const badges = []; + state.workers.forEach(w => { + if (w.sickDays?.some(s => s.date === dateStr)) { + badges.push('<span class="badge sick">🏥 ' + w.name + '</span>'); + } + if (w.vacation?.some(v => dateStr >= v.start && dateStr <= v.end)) { + badges.push('<span class="badge vacation">✈️ ' + w.name + '</span>'); + } + if (w.dayOffRequests?.includes(dateStr)) { + badges.push('<span class="badge request">📋 ' + w.name + '</span>'); + } + }); + return badges; + } + + function renderWorkersList() { + const maxW = getMaxWorkers(); + document.getElementById('workerCount').textContent = state.workers.length; + document.getElementById('maxWorkerDisplay').textContent = maxW; + const limitWarn = document.getElementById('workerLimitWarning'); + limitWarn.style.display = state.workers.length >= maxW ? 'inline' : 'none'; + + const list = document.getElementById('workersList'); + if (state.workers.length === 0) { + list.innerHTML = '<div class="empty-state"><div class="empty-state-icon">👥</div><div class="empty-state-text">No workers yet.<br>Click "+ Add Worker"</div></div>'; + return; + } + const sorted = [...state.workers].sort((a, b) => { + if (a.role !== b.role) return a.role === 'shiftleader' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + list.innerHTML = sorted.map(w => { + const noteIcon = w.notes ? ' <span class="note-indicator" title="' + escapeHtml(w.notes) + '">📝</span>' : ''; + return '<div class="worker-item" onclick="editWorker(\'' + w.id + '\')">' + + '<span class="worker-dot" style="background:' + w.color + ';"></span>' + + '<span class="worker-name">' + escapeHtml(w.name) + noteIcon + '</span>' + + '<span class="worker-role ' + (w.role === 'shiftleader' ? 'leader' : '') + '">' + + (w.role === 'shiftleader' ? '★ Lead' : 'Emp') + '</span>' + + '<span class="worker-hours">' + w.contractedHours + 'h</span>' + + '<div class="item-actions">' + + '<button class="icon-btn" onclick="event.stopPropagation();editWorker(\'' + w.id + '\')" title="Edit">✏️</button>' + + '</div></div>'; + }).join(''); + } + + function renderShiftsList() { + document.getElementById('shiftCount').textContent = state.shifts.length; + const list = document.getElementById('shiftsList'); + if (state.shifts.length === 0) { + list.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🕐</div><div class="empty-state-text">No shift templates yet.<br>Add one or use "Add Shift to This Day" in the calendar.</div></div>'; + return; + } + list.innerHTML = state.shifts.map(s => { + const leaderTag = s.requiresLeader + ? '<span class="shift-leader-tag">★ Leader</span>' + : ''; + return '<div class="shift-item" onclick="editShift(\'' + s.id + '\')">' + + '<span style="font-weight:600;font-size:0.85rem;">' + escapeHtml(s.name) + '</span>' + + '<span class="shift-time">' + s.startTime + '–' + s.endTime + '</span>' + + leaderTag + + '<span style="font-size:0.7rem;color:var(--muted);">min' + s.minWorkers + '</span>' + + '<div class="item-actions">' + + '<button class="icon-btn" onclick="event.stopPropagation();editShift(\'' + s.id + '\')" title="Edit">✏️</button>' + + '</div></div>'; + }).join(''); + } + + // ============================================================ + // MODALS + // ============================================================ + function openModal(id) { document.getElementById(id).classList.add('open'); } + function closeModal(id) { document.getElementById(id).classList.remove('open'); } + function switchTab(tab) { + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + document.querySelector('.tab-btn[data-tab="' + tab + '"]').classList.add('active'); + document.getElementById('workersTab').style.display = tab === 'workers' ? 'block' : 'none'; + document.getElementById('shiftsTab').style.display = tab === 'shifts' ? 'block' : 'none'; + } + function toggleSidebar() { document.getElementById('sidebar').classList.toggle('collapsed'); } + + // ============================================================ + // WORKERS CRUD + // ============================================================ + function openWorkerModal(id = null) { + tempDayOffs = []; + tempSickDays = []; + tempVacations = []; + document.getElementById('workerForm').reset(); + document.getElementById('colorPicker').innerHTML = ''; + document.getElementById('deleteWorkerBtn').style.display = 'none'; + + WORKER_COLORS.forEach((c, i) => { + const div = document.createElement('div'); + div.className = 'color-option' + (i === 0 ? ' selected' : ''); + div.style.background = c; + div.dataset.color = c; + div.onclick = () => selectColor(div); + document.getElementById('colorPicker').appendChild(div); + }); + document.getElementById('workerColor').value = WORKER_COLORS[0]; + + if (id) { + const w = state.workers.find(x => x.id === id); + document.getElementById('workerModalTitle').textContent = 'Edit Worker'; + document.getElementById('workerId').value = w.id; + document.getElementById('workerName').value = w.name; + document.getElementById('workerRole').value = w.role; + document.getElementById('workerHours').value = w.contractedHours || 40; + document.getElementById('workerNotes').value = w.notes || ''; + document.getElementById('workerColor').value = w.color; + document.getElementById('maxWorkersInput').value = getMaxWorkers(); + document.querySelectorAll('.color-option').forEach(el => + el.classList.toggle('selected', el.dataset.color === w.color) + ); + tempDayOffs = [...(w.dayOffRequests || [])]; + tempSickDays = [...(w.sickDays || [])]; + tempVacations = [...(w.vacation || [])]; + document.getElementById('deleteWorkerBtn').style.display = 'block'; + } else { + document.getElementById('workerModalTitle').textContent = 'Add Worker'; + document.getElementById('maxWorkersInput').value = getMaxWorkers(); + } + + renderTempData(); + openModal('workerModal'); + } + + function updateMaxWorkers() { + const val = parseInt(document.getElementById('maxWorkersInput').value) || DEFAULT_MAX_WORKERS; + state.settings.maxWorkers = val; + saveToStorage(); + updateGlobalStats(); + renderWorkersList(); + } + + function editWorker(id) { openWorkerModal(id); } + + function selectColor(el) { + document.querySelectorAll('.color-option').forEach(e => e.classList.remove('selected')); + el.classList.add('selected'); + document.getElementById('workerColor').value = el.dataset.color; + } + + function addDayOff() { + const v = document.getElementById('dayOffInput').value; + if (v && !tempDayOffs.includes(v)) { + tempDayOffs.push(v); + renderTempData(); + document.getElementById('dayOffInput').value = ''; + } + } + + function removeDayOff(d) { + tempDayOffs = tempDayOffs.filter(x => x !== d); + renderTempData(); + } + + function addSickDay() { + const d = document.getElementById('sickDayInput').value; + const n = document.getElementById('sickDayNote').value.trim(); + if (!d) return; + if (tempSickDays.some(s => s.date === d)) { + showToast('Sick day already added', 'warning'); + return; + } + tempSickDays.push({ date: d, note: n }); + document.getElementById('sickDayInput').value = ''; + document.getElementById('sickDayNote').value = ''; + renderTempData(); + } + + function removeSickDay(d) { + tempSickDays = tempSickDays.filter(s => s.date !== d); + renderTempData(); + } + + function addVacation() { + const s = document.getElementById('vacStart').value; + const e = document.getElementById('vacEnd').value; + if (!s || !e) { + showToast('Select both start and end dates', 'warning'); + return; + } + if (s > e) { + showToast('Start must be before end', 'error'); + return; + } + tempVacations.push({ start: s, end: e, note: '' }); + document.getElementById('vacStart').value = ''; + document.getElementById('vacEnd').value = ''; + renderTempData(); + } + + function removeVacation(start) { + tempVacations = tempVacations.filter(v => v.start !== start); + renderTempData(); + } + + function renderTempData() { + document.getElementById('dayOffList').innerHTML = tempDayOffs + .sort() + .map(d => '<div class="date-chip"><span>' + d + '</span><button onclick="removeDayOff(\'' + d + '\')">×</button></div>') + .join(''); + + document.getElementById('sickDayList').innerHTML = tempSickDays + .sort((a, b) => a.date.localeCompare(b.date)) + .map(s => '<div class="date-chip sick"><span>' + s.date + (s.note ? ' (' + s.note + ')' : '') + '</span><button onclick="removeSickDay(\'' + s.date + '\')">×</button></div>') + .join(''); + + document.getElementById('vacationList').innerHTML = tempVacations + .sort((a, b) => a.start.localeCompare(b.start)) + .map(v => '<div class="date-chip vacation"><span>' + v.start + ' → ' + v.end + '</span><button onclick="removeVacation(\'' + v.start + '\')">×</button></div>') + .join(''); + } + + function saveWorker(e) { + e.preventDefault(); + const name = document.getElementById('workerName').value.trim(); + if (!name) { + showToast('Name is required', 'error'); + return; + } + + const maxW = getMaxWorkers(); + const workerId = document.getElementById('workerId').value; + if (!workerId && state.workers.length >= maxW) { + showToast('Maximum of ' + maxW + ' workers reached', 'error'); + return; + } + + const id = workerId || 'w_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); + const worker = { + id, + name, + role: document.getElementById('workerRole').value, + contractedHours: parseInt(document.getElementById('workerHours').value) || 40, + color: document.getElementById('workerColor').value, + dayOffRequests: [...tempDayOffs].sort(), + sickDays: [...tempSickDays], + vacation: [...tempVacations], + notes: document.getElementById('workerNotes').value.trim() + }; + + const idx = state.workers.findIndex(w => w.id === id); + if (idx >= 0) { + state.workers[idx] = worker; + showToast('Worker updated!', 'success'); + } else { + state.workers.push(worker); + showToast('Worker added!', 'success'); + } + + saveToStorage(); + renderAll(); + closeModal('workerModal'); + } + + function deleteWorker() { + const id = document.getElementById('workerId').value; + if (!confirm('Delete this worker? All their shift assignments will also be removed.')) return; + state.workers = state.workers.filter(w => w.id !== id); + state.assignments = state.assignments.filter(a => a.workerId !== id); + saveToStorage(); + renderAll(); + closeModal('workerModal'); + showToast('Worker deleted', 'success'); + } + + // ============================================================ + // SHIFTS CRUD (Global Templates) + // ============================================================ + function openShiftModal() { + document.getElementById('shiftForm').reset(); + document.getElementById('shiftId').value = ''; + document.getElementById('shiftModalTitle').textContent = 'Add Shift Template'; + document.getElementById('shiftMinWorkers').value = 1; + document.getElementById('shiftMaxWorkers').value = 5; + document.getElementById('shiftRequiresLeader').checked = true; + document.getElementById('deleteShiftBtn').style.display = 'none'; + openModal('shiftModal'); + } + + function editShift(id) { + const s = state.shifts.find(x => x.id === id); + document.getElementById('shiftModalTitle').textContent = 'Edit Shift Template'; + document.getElementById('shiftId').value = s.id; + document.getElementById('shiftName').value = s.name; + document.getElementById('shiftStart').value = s.startTime; + document.getElementById('shiftEnd').value = s.endTime; + document.getElementById('shiftMinWorkers').value = s.minWorkers || 1; + document.getElementById('shiftMaxWorkers').value = s.maxWorkers || 5; + document.getElementById('shiftRequiresLeader').checked = !!s.requiresLeader; + document.getElementById('deleteShiftBtn').style.display = 'block'; + openModal('shiftModal'); + } + + function saveShift(e) { + e.preventDefault(); + const id = document.getElementById('shiftId').value || 's_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); + const shift = { + id, + name: document.getElementById('shiftName').value.trim(), + startTime: document.getElementById('shiftStart').value, + endTime: document.getElementById('shiftEnd').value, + minWorkers: parseInt(document.getElementById('shiftMinWorkers').value) || 1, + maxWorkers: parseInt(document.getElementById('shiftMaxWorkers').value) || 5, + requiresLeader: document.getElementById('shiftRequiresLeader').checked + }; + + if (!shift.name) { + showToast('Shift name is required', 'error'); + return; + } + + const idx = state.shifts.findIndex(s => s.id === id); + if (idx >= 0) { + state.shifts[idx] = shift; + showToast('Shift template updated!', 'success'); + } else { + state.shifts.push(shift); + showToast('Shift template added!', 'success'); + } + + saveToStorage(); + renderAll(); + closeModal('shiftModal'); + } + + function deleteShift() { + const id = document.getElementById('shiftId').value; + if (!confirm('Delete this shift template? All assignments for this shift will also be removed.')) return; + state.shifts = state.shifts.filter(s => s.id !== id); + state.assignments = state.assignments.filter(a => a.shiftId !== id); + saveToStorage(); + renderAll(); + closeModal('shiftModal'); + showToast('Shift template deleted', 'success'); + } + + // ============================================================ + // CUSTOM SHIFTS (Per-Day) + // ============================================================ + function openAddCustomShiftForm() { + document.getElementById('customShiftForm').reset(); + document.getElementById('customShiftDate').value = currentDayDate; + document.getElementById('customShiftDateLabel').textContent = currentDayDate; + document.getElementById('customShiftMin').value = 1; + document.getElementById('customShiftMax').value = 3; + document.getElementById('customShiftLeader').checked = false; + openModal('customShiftModal'); + } + + function saveCustomShift(e) { + e.preventDefault(); + const dateStr = document.getElementById('customShiftDate').value; + const name = document.getElementById('customShiftName').value.trim(); + const startTime = document.getElementById('customShiftStart').value; + const endTime = document.getElementById('customShiftEnd').value; + + if (!name || !startTime || !endTime) { + showToast('Fill in all required fields', 'error'); + return; + } + + const id = 'cs_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); + const customShift = { + id, + name, + startTime, + endTime, + minWorkers: parseInt(document.getElementById('customShiftMin').value) || 1, + maxWorkers: parseInt(document.getElementById('customShiftMax').value) || 3, + requiresLeader: document.getElementById('customShiftLeader').checked + }; + + if (!state.customShifts[dateStr]) { + state.customShifts[dateStr] = []; + } + state.customShifts[dateStr].push(customShift); + + saveToStorage(); + closeModal('customShiftModal'); + openDayModal(dateStr); + renderCalendar(); + showToast('Shift "' + name + '" added to ' + dateStr, 'success'); + } + + function removeCustomShift(dateStr, shiftId) { + if (!confirm('Remove this shift from ' + dateStr + '?\nAll worker assignments for this shift will also be removed.')) return; + + if (state.customShifts[dateStr]) { + state.customShifts[dateStr] = state.customShifts[dateStr].filter(s => s.id !== shiftId); + if (state.customShifts[dateStr].length === 0) { + delete state.customShifts[dateStr]; + } + } + + state.assignments = state.assignments.filter(a => !(a.date === dateStr && a.shiftId === shiftId)); + + saveToStorage(); + openDayModal(dateStr); + renderCalendar(); + updateConflictBadge(); + showToast('Shift removed from ' + dateStr, 'success'); + } + + // ============================================================ + // DAY MODAL & ASSIGNMENTS + // ============================================================ + let currentDayDate = ''; + + function openDayModal(dateStr) { + currentDayDate = dateStr; + const date = new Date(dateStr + 'T12:00:00'); + const days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; + document.getElementById('dayModalTitle').textContent = days[date.getDay()] + ', ' + dateStr; + + const dayAssignments = state.assignments.filter(a => a.date === dateStr); + const dayCustomShifts = state.customShifts[dateStr] || []; + let html = ''; + + if (state.shifts.length > 0) { + html += '<h4 style="font-size:0.8rem;color:var(--muted);margin-bottom:8px;margin-top:4px;">📅 Shift Templates</h4>'; + state.shifts.forEach(shift => { + html += renderDayModalShiftSection(dateStr, shift, dayAssignments, false); + }); + } + + if (dayCustomShifts.length > 0) { + html += '<h4 style="font-size:0.8rem;color:var(--accent);margin-bottom:8px;margin-top:16px;">⏰ One-Off Shifts for This Day</h4>'; + dayCustomShifts.forEach(cs => { + html += renderDayModalShiftSection(dateStr, cs, dayAssignments, true); + }); + } + + if (state.shifts.length === 0 && dayCustomShifts.length === 0) { + html = '<p style="color:var(--muted);text-align:center;padding:12px;font-size:0.85rem;">' + + 'No shifts defined. Add a shift template in the sidebar, or use "Add Shift to This Day" below.</p>'; + } + + document.getElementById('dayModalContent').innerHTML = html; + document.getElementById('dayNoteInput').value = state.notes[dateStr] || ''; + + const conflicts = checkDayConflicts(dateStr); + document.getElementById('dayConflicts').innerHTML = conflicts.length > 0 + ? conflicts.map(c => '<div class="conflict-item ' + c.type + '"><span class="conflict-icon">' + + (c.type === 'error' ? '❌' : '⚠️') + '</span> ' + c.msg + '</div>').join('') + : '<div class="conflict-item info" style="background:rgba(126,184,201,0.15);"><span class="conflict-icon">✅</span> No conflicts</div>'; + + document.getElementById('addShiftToDayBtn').style.display = 'block'; + + openModal('dayModal'); + } + + function renderDayModalShiftSection(dateStr, shift, dayAssignments, isCustom) { + const shiftAssigns = dayAssignments.filter(a => a.shiftId === shift.id); + const hasLeader = shiftAssigns.some(a => { + const w = state.workers.find(wk => wk.id === a.workerId); + return w && w.role === 'shiftleader'; + }); + + const headerColor = isCustom ? 'var(--accent)' : 'var(--primary)'; + const cardStyle = isCustom + ? 'style="margin-bottom:14px;padding:11px;background:var(--bg);border-radius:8px;border-left:3px solid var(--accent);"' + : 'style="margin-bottom:14px;padding:11px;background:var(--bg);border-radius:8px;border-left:3px solid var(--primary);"'; + + let html = '<div ' + cardStyle + '>'; + + html += '<div style="display:flex;align-items:center;gap:6px;margin-bottom:7px;flex-wrap:wrap;">'; + html += '<span style="font-weight:600;font-size:0.85rem;color:' + headerColor + ';">' + escapeHtml(shift.name) + '</span>'; + html += '<span style="font-size:0.75rem;color:var(--muted);">' + shift.startTime + '–' + shift.endTime + '</span>'; + if (isCustom) { + html += '<span class="badge custom" style="font-size:0.6rem;">ONE-OFF</span>'; + } + if (shift.requiresLeader) { + const badge = hasLeader + ? '<span class="badge" style="background:var(--success);color:white;">✓ Leader</span>' + : '<span class="badge" style="background:var(--danger);color:white;">⚠️ No Leader!</span>'; + html += badge; + } + html += '<span class="stat-chip">' + shiftAssigns.length + '/' + (shift.minWorkers || 1) + ' min</span>'; + if (isCustom) { + html += '<button class="btn btn-xs btn-danger" style="margin-left:auto;" onclick="removeCustomShift(\'' + dateStr + '\',\'' + shift.id + '\')">🗑️ Remove Shift</button>'; + } + html += '</div>'; + + if (shiftAssigns.length > 0) { + shiftAssigns.forEach(a => { + const w = state.workers.find(wk => wk.id === a.workerId); + if (!w) return; + const bgColor = w.role === 'shiftleader' ? 'var(--warning)' : 'var(--surface)'; + html += '<div style="display:flex;align-items:center;gap:7px;padding:5px 8px;background:' + bgColor + ';border-radius:6px;margin-bottom:4px;">'; + html += '<span class="worker-dot" style="background:' + w.color + ';width:10px;height:10px;border-radius:50%;flex-shrink:0;"></span>'; + html += '<span style="flex:1;font-size:0.825rem;">' + escapeHtml(w.name) + (w.role === 'shiftleader' ? ' <span style="color:var(--accent);">★</span>' : '') + '</span>'; + html += '<button class="btn btn-xs btn-danger" onclick="removeAssignment(\'' + dateStr + '\',\'' + shift.id + '\',\'' + w.id + '\');openDayModal(\'' + dateStr + '\');">×</button>'; + html += '</div>'; + }); + } else { + html += '<p style="color:var(--muted);font-size:0.75rem;margin-bottom:7px;">No one assigned yet.</p>'; + } + + html += '<div style="display:flex;gap:7px;margin-top:7px;">'; + html += '<select class="form-select" id="addWorker_' + shift.id + '" style="flex:1;font-size:0.8rem;">'; + html += '<option value="">Select worker...</option>'; + state.workers.forEach(w => { + const assigned = shiftAssigns.some(a => a.workerId === w.id); + const isUnavail = !assigned && isWorkerUnavailable(w, dateStr, shift.id); + html += '<option value="' + w.id + '"' + + (assigned ? 'disabled' : '') + + (isUnavail ? ' style="color:var(--danger);"' : '') + + '>' + escapeHtml(w.name) + + (w.role === 'shiftleader' ? ' ★' : '') + + (isUnavail ? ' ⚠️' : '') + '</option>'; + }); + html += '</select>'; + html += '<button class="btn btn-sm btn-primary" onclick="addWorkerToShift(\'' + dateStr + '\',\'' + shift.id + '\')">+</button>'; + html += '</div>'; + + html += '</div>'; + return html; + } + + function isWorkerUnavailable(worker, dateStr, shiftId) { + if (worker.dayOffRequests?.includes(dateStr)) return true; + if (worker.sickDays?.some(s => s.date === dateStr)) return true; + if (worker.vacation?.some(v => dateStr >= v.start && dateStr <= v.end)) return true; + return false; + } + + function addWorkerToShift(dateStr, shiftId) { + const workerId = document.getElementById('addWorker_' + shiftId).value; + if (!workerId) return; + + const shift = state.shifts.find(s => s.id === shiftId) || + (state.customShifts[dateStr] || []).find(s => s.id === shiftId); + const worker = state.workers.find(w => w.id === workerId); + if (!shift || !worker) return; + + const conflicts = checkAssignmentConflicts(dateStr, shiftId, workerId); + + const errors = conflicts.filter(c => c.type === 'error'); + if (errors.length > 0) { + if (!confirm('⚠️ Conflict:\n' + errors.map(c => c.msg).join('\n') + '\n\nAssign anyway?')) return; + } else if (conflicts.length > 0) { + if (!confirm('⚠️ Warning:\n' + conflicts.map(c => c.msg).join('\n') + '\n\nAssign anyway?')) return; + } + + assignWorker(dateStr, shiftId, workerId); + openDayModal(dateStr); + showToast(worker.name + ' → ' + shift.name, 'success'); + } + + function assignWorker(dateStr, shiftId, workerId) { + if (!state.assignments.some(a => a.date === dateStr && a.shiftId === shiftId && a.workerId === workerId)) { + state.assignments.push({ date: dateStr, shiftId, workerId }); + saveToStorage(); + renderCalendar(); + updateConflictBadge(); + } + } + + function removeAssignment(dateStr, shiftId, workerId) { + state.assignments = state.assignments.filter(a => !( + a.date === dateStr && a.shiftId === shiftId && a.workerId === workerId + )); + saveToStorage(); + renderCalendar(); + updateConflictBadge(); + showToast('Removed', 'success'); + } + + function clearDayAssignments() { + if (!confirm('Clear ALL assignments for ' + currentDayDate + '?')) return; + state.assignments = state.assignments.filter(a => a.date !== currentDayDate); + saveToStorage(); + closeModal('dayModal'); + renderCalendar(); + updateConflictBadge(); + showToast('Day cleared', 'success'); + } + + function quickAdd(dateStr, shiftId) { + const dayCustomShifts = state.customShifts[dateStr] || []; + const isCustom = dayCustomShifts.some(cs => cs.id === shiftId); + const shift = isCustom + ? dayCustomShifts.find(cs => cs.id === shiftId) + : state.shifts.find(s => s.id === shiftId); + + if (!shift) return; + + const avail = state.workers.filter(w => { + if (state.assignments.some(a => a.date === dateStr && a.shiftId === shiftId && a.workerId === w.id)) return false; + if (w.sickDays?.some(s => s.date === dateStr)) return false; + if (w.vacation?.some(v => dateStr >= v.start && dateStr <= v.end)) return false; + if (w.dayOffRequests?.includes(dateStr)) return false; + return true; + }); + + if (avail.length === 0) { + showToast('No available workers', 'warning'); + return; + } + + const names = avail.map(w => w.name + (w.role === 'shiftleader' ? ' ★' : '')).join('\n'); + const nameMap = {}; + avail.forEach(w => nameMap[w.name.toLowerCase()] = w); + + const input = prompt('Add worker to ' + shift.name + ' on ' + dateStr + ':\n\nAvailable:\n' + names + '\n\nEnter name (or number 1-' + avail.length + '):'); + + if (!input) return; + + const num = parseInt(input.trim()); + if (!isNaN(num) && num >= 1 && num <= avail.length) { + const w = avail[num - 1]; + assignWorker(dateStr, shiftId, w.id); + showToast(w.name + ' assigned!', 'success'); + return; + } + + const w = nameMap[input.trim().toLowerCase()]; + if (w) { + assignWorker(dateStr, shiftId, w.id); + showToast(w.name + ' assigned!', 'success'); + } else { + showToast('Worker not found', 'error'); + } + } + + function saveDayNote() { + const note = document.getElementById('dayNoteInput').value.trim(); + if (note) { + state.notes[currentDayDate] = note; + } else { + delete state.notes[currentDayDate]; + } + saveToStorage(); + renderCalendar(); + showToast('Note saved', 'success'); + } + + // ============================================================ + // CONFLICT DETECTION + // ============================================================ + function getShiftById(shiftId) { + return state.shifts.find(s => s.id === shiftId) || null; + } + + function checkDayConflicts(dateStr) { + const conflicts = []; + const dayAssignments = state.assignments.filter(a => a.date === dateStr); + const workerIds = dayAssignments.map(a => a.workerId); + + const seen = new Set(); + workerIds.forEach(id => { + if (seen.has(id)) { + const w = state.workers.find(x => x.id === id); + conflicts.push({ type: 'error', msg: (w?.name || 'Worker') + ' is double-booked' }); + } + seen.add(id); + }); + + const allShifts = [ + ...state.shifts, + ...(state.customShifts[dateStr] || []) + ]; + allShifts.forEach(shift => { + if (!shift.requiresLeader) return; + const sa = dayAssignments.filter(a => a.shiftId === shift.id); + if (sa.length > 0) { + const hasLeader = sa.some(a => { + const w = state.workers.find(wk => wk.id === a.workerId); + return w && w.role === 'shiftleader'; + }); + if (!hasLeader) { + conflicts.push({ type: 'error', msg: shift.name + ' requires a Shift Leader' }); + } + } + }); + + if (state.settings.enforceRest) { + const minRest = state.settings.minRestHours || 11; + dayAssignments.forEach(a => { + const prevDate = getPrevDay(dateStr); + state.assignments + .filter(x => x.date === prevDate && x.workerId === a.workerId) + .forEach(prev => { + const prevShift = getShiftById(prev.shiftId) || + (state.customShifts[prevDate] || []).find(s => s.id === prev.shiftId); + const currShift = getShiftById(a.shiftId) || + (state.customShifts[dateStr] || []).find(s => s.id === a.shiftId); + if (!prevShift || !currShift) return; + const gap = getRestGap(prevShift.endTime, currShift.startTime); + if (gap < minRest * 60) { + const w = state.workers.find(x => x.id === a.workerId); + conflicts.push({ + type: 'warning', + msg: (w?.name || 'Worker') + ': only ' + Math.floor(gap / 60) + 'h rest (min ' + minRest + 'h)' + }); + } + }); + }); + } + + if (state.settings.enforce5Day) { + const maxConsec = state.settings.maxConsecutive || 5; + [...new Set(workerIds)].forEach(wId => { + const streak = getConsecutiveStreak(wId, dateStr); + if (streak > maxConsec) { + const w = state.workers.find(x => x.id === wId); + conflicts.push({ + type: 'warning', + msg: (w?.name || 'Worker') + ': ' + streak + ' consecutive days (max ' + maxConsec + ')' + }); + } + }); + } + + return conflicts; + } + + function checkAssignmentConflicts(dateStr, shiftId, workerId) { + const conflicts = []; + const shift = getShiftById(shiftId) || + (state.customShifts[dateStr] || []).find(s => s.id === shiftId); + const worker = state.workers.find(w => w.id === workerId); + if (!shift || !worker) return conflicts; + + if (state.assignments.some(a => a.date === dateStr && a.shiftId === shiftId && a.workerId === workerId)) { + // Already assigned + } else if (state.assignments.some(a => a.date === dateStr && a.workerId === workerId)) { + conflicts.push({ type: 'error', msg: worker.name + ' is already assigned this day' }); + } + + if (worker.sickDays?.some(s => s.date === dateStr)) { + conflicts.push({ type: 'error', msg: worker.name + ' is marked as sick' }); + } + + if (worker.vacation?.some(v => dateStr >= v.start && dateStr <= v.end)) { + conflicts.push({ type: 'error', msg: worker.name + ' is on vacation' }); + } + + if (worker.dayOffRequests?.includes(dateStr)) { + conflicts.push({ type: 'warning', msg: worker.name + ' requested this day off' }); + } + + if (state.settings.enforceRest) { + const minRest = state.settings.minRestHours || 11; + const prevDate = getPrevDay(dateStr); + state.assignments + .filter(a => a.date === prevDate && a.workerId === workerId) + .forEach(prev => { + const prevShift = getShiftById(prev.shiftId) || + (state.customShifts[prevDate] || []).find(s => s.id === prev.shiftId); + if (!prevShift) return; + const gap = getRestGap(prevShift.endTime, shift.startTime); + if (gap < minRest * 60) { + conflicts.push({ + type: 'warning', + msg: 'Only ' + Math.floor(gap / 60) + 'h rest from previous shift (min ' + minRest + 'h)' + }); + } + }); + } + + if (state.settings.enforce5Day) { + const maxConsec = state.settings.maxConsecutive || 5; + const streak = getConsecutiveStreak(workerId, dateStr); + if (streak >= maxConsec) { + conflicts.push({ + type: 'warning', + msg: worker.name + ' would work ' + (streak + 1) + ' days in a row (max ' + maxConsec + ')' + }); + } + } + + return conflicts; + } + + function updateConflictBadge() { + const allConflicts = new Map(); + getMonthDates().forEach(d => { + const conflicts = checkDayConflicts(d); + conflicts.filter(c => c.type === 'error').forEach(c => { + allConflicts.set(d + ':' + c.msg, c); + }); + }); + const badge = document.getElementById('conflictBadge'); + badge.textContent = allConflicts.size || ''; + badge.style.display = allConflicts.size ? 'flex' : 'none'; + } + + function showConflicts() { + const allConflicts = []; + getMonthDates().forEach(d => { + checkDayConflicts(d).forEach(c => { + allConflicts.push({ date: d, ...c }); + }); + }); + document.getElementById('conflictsList').innerHTML = allConflicts.length > 0 + ? allConflicts.map(c => '<div class="conflict-item ' + c.type + '"><span class="conflict-icon">' + + (c.type === 'error' ? '❌' : '⚠️') + '</span><span><strong>' + c.date + ':</strong> ' + c.msg + '</span></div>').join('') + : '<p style="color:var(--success);text-align:center;padding:24px;">✅ No conflicts this month!</p>'; + openModal('conflictsModal'); + } + + // ============================================================ + // HELPERS + // ============================================================ + function changeMonth(delta) { + currentMonth += delta; + if (currentMonth > 11) { currentMonth = 0; currentYear++; } + if (currentMonth < 0) { currentMonth = 11; currentYear--; } + renderAll(); + } + + function getMonthDates() { + const dates = []; + const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate(); + for (let d = 1; d <= daysInMonth; d++) { + dates.push(currentYear + '-' + String(currentMonth + 1).padStart(2,'0') + '-' + String(d).padStart(2,'0')); + } + return dates; + } + + function getPrevDay(dateStr) { + const d = new Date(dateStr + 'T12:00:00'); + d.setDate(d.getDate() - 1); + return d.toISOString().split('T')[0]; + } + + function getNextDay(dateStr) { + const d = new Date(dateStr + 'T12:00:00'); + d.setDate(d.getDate() + 1); + return d.toISOString().split('T')[0]; + } + + function timeToMinutes(time) { + const [h, m] = time.split(':').map(Number); + return h * 60 + m; + } + + function getRestGap(prevEnd, currStart) { + const prev = timeToMinutes(prevEnd); + const curr = timeToMinutes(currStart); + if (curr > prev) return curr - prev; + return (24 * 60 - prev) + curr; + } + + function getShiftHours(shift) { + const start = timeToMinutes(shift.startTime); + let end = timeToMinutes(shift.endTime); + if (end <= start) end += 24 * 60; + return Math.round((end - start) / 60 * 10) / 10; + } + + function getConsecutiveStreak(workerId, dateStr) { + let streak = 0; + let d = getPrevDay(dateStr); + while (state.assignments.some(a => a.date === d && a.workerId === workerId)) { + streak++; + d = getPrevDay(d); + } + return streak; + } + + function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); + } + + // ============================================================ + // AUTO-GENERATE + // ============================================================ + function initGenerator() { + genState = { + step: 1, + selectedWorkers: new Set(state.workers.map(w => w.id)), + generatedPlan: null, + workerStats: null + }; + + document.getElementById('maxConsecutive').value = state.settings.maxConsecutive || 5; + document.getElementById('maxConsecVal').textContent = state.settings.maxConsecutive || 5; + document.getElementById('minRestHours').value = state.settings.minRestHours || 11; + document.getElementById('minRestVal').textContent = state.settings.minRestHours || 11; + + const grid = document.getElementById('genWorkerSelect'); + if (state.workers.length === 0) { + grid.innerHTML = '<p style="color:var(--muted);padding:10px;">Add workers first before generating.</p>'; + } else { + grid.innerHTML = state.workers.map(w => + '<div class="worker-check-item selected" data-id="' + w.id + '" onclick="toggleGenWorker(this)">' + + '<span class="worker-dot" style="background:' + w.color + ';width:10px;height:10px;border-radius:50%;"></span>' + + '<span>' + escapeHtml(w.name) + (w.role === 'shiftleader' ? ' ★' : '') + '</span>' + + '</div>' + ).join(''); + } + + setGenStep(1); + } + + function toggleGenWorker(el) { + el.classList.toggle('selected'); + const id = el.dataset.id; + if (genState.selectedWorkers.has(id)) { + genState.selectedWorkers.delete(id); + } else { + genState.selectedWorkers.add(id); + } + } + + function setGenStep(step) { + genState.step = step; + document.querySelectorAll('.wizard-dot').forEach((d, i) => { + d.classList.remove('active', 'completed'); + if (i + 1 < step) d.classList.add('completed'); + if (i + 1 === step) d.classList.add('active'); + }); + document.querySelectorAll('.wizard-step').forEach((s, i) => { + s.classList.toggle('active', i + 1 === step); + }); + document.getElementById('genBackBtn').style.display = step > 1 ? 'inline-flex' : 'none'; + document.getElementById('genNextBtn').textContent = step === 3 ? '✓ Apply Plan' : (step === 2 ? 'Next →' : 'Generate →'); + } + + function genNextStep() { + if (genState.step === 1) { + if (genState.selectedWorkers.size === 0) { + showToast('Select at least one worker', 'error'); + return; + } + if (state.shifts.length === 0) { + showToast('Add at least one shift template first', 'error'); + return; + } + setGenStep(2); + generatePlan(); + } else if (genState.step === 2) { + setGenStep(3); + } else if (genState.step === 3) { + applyGeneratedPlan(); + } + } + + function genPrevStep() { + if (genState.step > 1) setGenStep(genState.step - 1); + } + + function generatePlan() { + const maxConsec = parseInt(document.getElementById('maxConsecutive').value); + const minRest = parseInt(document.getElementById('minRestHours').value); + const priority = document.getElementById('fairnessPriority').value; + const includeWeekends = document.getElementById('includeWeekends').checked; + + const workers = state.workers.filter(w => genState.selectedWorkers.has(w.id)); + const dates = getMonthDates(); + const plan = {}; + const workerStats = {}; + const consecutiveDays = {}; + + workers.forEach(w => { + workerStats[w.id] = { shifts: 0, hours: 0, weekendShifts: 0 }; + consecutiveDays[w.id] = 0; + }); + + dates.forEach(dateStr => { + plan[dateStr] = {}; + const dow = new Date(dateStr + 'T12:00:00').getDay(); + const isWeekend = dow === 0 || dow === 6; + + workers.forEach(w => { + if (!state.assignments.some(a => a.date === getPrevDay(dateStr) && a.workerId === w.id)) { + consecutiveDays[w.id] = 0; + } + }); + + const sortedShifts = [...state.shifts].sort((a, b) => { + if (a.requiresLeader && !b.requiresLeader) return -1; + if (b.requiresLeader && !a.requiresLeader) return 1; + return (b.minWorkers || 1) - (a.minWorkers || 1); + }); + + sortedShifts.forEach(shift => { + const needed = shift.minWorkers || 1; + const assigned = []; + + let eligible = workers.filter(w => { + if (plan[dateStr][shift.id]?.some(a => a.workerId === w.id)) return false; + if (w.vacation?.some(v => dateStr >= v.start && dateStr <= v.end)) return false; + if (w.sickDays?.some(s => s.date === dateStr)) return false; + if (w.dayOffRequests?.includes(dateStr)) return false; + if (consecutiveDays[w.id] >= maxConsec) return false; + return true; + }); + + eligible = eligible.filter(w => { + const prevAssignments = state.assignments.filter( + a => a.date === getPrevDay(dateStr) && a.workerId === w.id + ); + for (const prev of prevAssignments) { + const prevShift = getShiftById(prev.shiftId); + if (!prevShift) continue; + const gap = getRestGap(prevShift.endTime, shift.startTime); + if (gap < minRest * 60) return false; + } + return true; + }); + + const sortFn = (a, b) => { + const sa = workerStats[a.id]; + const sb = workerStats[b.id]; + if (priority === 'shifts') return sa.shifts - sb.shifts; + if (priority === 'hours') return sa.hours - sb.hours; + if (priority === 'weekends' && includeWeekends && isWeekend) { + return sa.weekendShifts - sb.weekendShifts; + } + const aScore = sa.shifts + sa.hours / 10 + (includeWeekends ? sa.weekendShifts * 2 : 0); + const bScore = sb.shifts + sb.hours / 10 + (includeWeekends ? sb.weekendShifts * 2 : 0); + return aScore - bScore; + }; + + if (shift.requiresLeader) { + const leaders = eligible.filter(w => w.role === 'shiftleader').sort(sortFn); + if (leaders.length > 0) { + assigned.push(leaders[0]); + eligible = eligible.filter(w => w.id !== leaders[0].id); + } + } + + while (assigned.length < needed && eligible.length > 0) { + eligible.sort(sortFn); + const chosen = eligible.shift(); + assigned.push(chosen); + eligible = eligible.filter(w => w.id !== chosen.id); + } + + if (assigned.length > 0) { + plan[dateStr][shift.id] = assigned.map(w => ({ workerId: w.id })); + assigned.forEach(w => { + const hours = getShiftHours(shift); + workerStats[w.id].shifts++; + workerStats[w.id].hours += hours; + if (isWeekend) workerStats[w.id].weekendShifts++; + consecutiveDays[w.id]++; + }); + workers.filter(w => !assigned.find(a => a.id === w.id)).forEach(w => { + consecutiveDays[w.id] = 0; + }); + } + }); + }); + + genState.generatedPlan = plan; + genState.workerStats = workerStats; + + const preview = document.getElementById('genPreview'); + const totalShifts = Object.values(workerStats).reduce((s, w) => s + w.shifts, 0); + const totalHours = Object.values(workerStats).reduce((s, w) => s + w.hours, 0); + const totalWeekend = Object.values(workerStats).reduce((s, w) => s + w.weekendShifts, 0); + + let previewHTML = '<div class="gen-stats-grid"><div class="gen-stat-item"><div class="gen-stat-value">' + totalShifts + '</div><div class="gen-stat-label">Total Shifts</div></div>'; + previewHTML += '<div class="gen-stat-item"><div class="gen-stat-value">' + totalHours + 'h</div><div class="gen-stat-label">Total Hours</div></div>'; + previewHTML += '<div class="gen-stat-item"><div class="gen-stat-value">' + dates.length + '</div><div class="gen-stat-label">Days Covered</div></div>'; + previewHTML += '<div class="gen-stat-item"><div class="gen-stat-value">' + totalWeekend + '</div><div class="gen-stat-label">Weekend Shifts</div></div></div>'; + previewHTML += '<p style="margin-top:14px;margin-bottom:8px;font-weight:600;font-size:0.825rem;">Per-Worker Distribution:</p>'; + + const sortedWorkers = [...workers].sort((a, b) => workerStats[b.id].shifts - workerStats[a.id].shifts); + sortedWorkers.forEach(w => { + const s = workerStats[w.id]; + const variance = workers.length > 1 + ? Math.round((s.shifts / (totalShifts / workers.length) - 1) * 100) + : 0; + const varianceStr = variance === 0 ? '(balanced)' : (variance > 0 ? '(+' + variance + '%)' : '(' + variance + '%)'); + previewHTML += '<div style="display:flex;justify-content:space-between;padding:7px 9px;background:var(--bg);border-radius:6px;margin-bottom:5px;align-items:center;">'; + previewHTML += '<div style="display:flex;align-items:center;gap:8px;">'; + previewHTML += '<span class="worker-dot" style="background:' + w.color + ';width:9px;height:9px;border-radius:50%;"></span>'; + previewHTML += '<span style="font-size:0.8rem;">' + escapeHtml(w.name) + (w.role === 'shiftleader' ? ' ★' : '') + '</span>'; + previewHTML += '</div>'; + previewHTML += '<div style="display:flex;gap:10px;font-size:0.75rem;color:var(--muted);">'; + previewHTML += '<span>' + s.shifts + ' shifts</span>'; + previewHTML += '<span>' + s.hours + 'h</span>'; + previewHTML += '<span>' + s.weekendShifts + ' wk</span>'; + previewHTML += '<span style="color:var(--primary);">' + varianceStr + '</span>'; + previewHTML += '</div></div>'; + }); + + preview.innerHTML = previewHTML; + } + + function applyGeneratedPlan() { + if (!genState.generatedPlan) return; + + const dates = getMonthDates(); + state.assignments = state.assignments.filter(a => !dates.includes(a.date)); + + for (const dateStr in genState.generatedPlan) { + for (const shiftId in genState.generatedPlan[dateStr]) { + genState.generatedPlan[dateStr][shiftId].forEach(a => { + state.assignments.push({ date: dateStr, shiftId, workerId: a.workerId }); + }); + } + } + + const allConflicts = []; + dates.forEach(d => { + checkDayConflicts(d).forEach(c => allConflicts.push({ date: d, ...c })); + }); + + const errorCount = allConflicts.filter(c => c.type === 'error').length; + const warnCount = allConflicts.filter(c => c.type === 'warning').length; + + saveToStorage(); + renderAll(); + closeModal('generatorModal'); + + if (errorCount > 0 || warnCount > 0) { + showToast('Plan applied with ' + (errorCount ? errorCount + ' errors, ' : '') + warnCount + ' warnings', 'warning'); + } else { + showToast('Plan generated perfectly! No conflicts.', 'success'); + } + } + + // ============================================================ + // TEMPLATES + // ============================================================ + function saveTemplate() { + const name = document.getElementById('templateName').value.trim(); + if (!name) { showToast('Enter a template name', 'warning'); return; } + state.templates.push({ + id: 't_' + Date.now(), + name, + data: JSON.parse(JSON.stringify({ shifts: state.shifts, assignments: state.assignments })) + }); + saveToStorage(); + document.getElementById('templateName').value = ''; + updateTemplateList(); + showToast('Template saved!', 'success'); + } + + function applyTemplate() { + const templateId = document.getElementById('templateSelect').value; + const month = document.getElementById('templateMonth').value; + if (!templateId) { showToast('Select a template', 'warning'); return; } + if (!month) { showToast('Select a month', 'warning'); return; } + const [y, m] = month.split('-').map(Number); + const template = state.templates.find(t => t.id === templateId); + if (!template) return; + state.shifts = JSON.parse(JSON.stringify(template.data.shifts || [])); + saveToStorage(); + currentYear = y; + currentMonth = m - 1; + renderAll(); + showToast('Template applied to ' + month, 'success'); + } + + function updateTemplateList() { + const list = document.getElementById('templateList'); + const select = document.getElementById('templateSelect'); + select.innerHTML = '<option value="">Select template...</option>' + + state.templates.map(t => '<option value="' + t.id + '">' + escapeHtml(t.name) + '</option>').join(''); + + if (state.templates.length === 0) { + list.innerHTML = '<p style="color:var(--muted);font-size:0.8rem;">No templates saved yet.</p>'; + } else { + list.innerHTML = state.templates.map(t => + '<div style="display:flex;justify-content:space-between;align-items:center;padding:7px 9px;background:var(--bg);border-radius:6px;margin-bottom:5px;">' + + '<span style="font-size:0.825rem;">' + escapeHtml(t.name) + '</span>' + + '<button class="btn btn-xs btn-danger" onclick="deleteTemplate(\'' + t.id + '\')">Delete</button>' + + '</div>' + ).join(''); + } + } + + function deleteTemplate(id) { + state.templates = state.templates.filter(t => t.id !== id); + saveToStorage(); + updateTemplateList(); + showToast('Template deleted', 'success'); + } + + // ============================================================ + // IMPORT / EXPORT + // ============================================================ + function exportData() { + const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'shiftplan_' + currentYear + '_' + String(currentMonth + 1).padStart(2,'0') + '.json'; + a.click(); + showToast('Exported JSON!', 'success'); + } + + function importData(event) { + const file = event.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + try { + const imported = JSON.parse(e.target.result); + if (imported.version || imported.workers || imported.shifts) { + state = { + version: '2.1', + settings: { darkMode: false, enforceRest: true, enforce5Day: true, minRestHours: 11, maxConsecutive: 5, maxWorkers: DEFAULT_MAX_WORKERS, ...(imported.settings || {}) }, + workers: imported.workers || [], + shifts: imported.shifts || [], + assignments: imported.assignments || [], + customShifts: imported.customShifts || {}, + templates: imported.templates || [], + notes: imported.notes || {} + }; + saveToStorage(); + renderAll(); + showToast('Data imported successfully!', 'success'); + } else { + showToast('Invalid file format', 'error'); + } + } catch(err) { + showToast('Failed to parse file', 'error'); + } + }; + reader.readAsText(file); + event.target.value = ''; + } + + function exportCSV() { + const dayNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; + let csv = 'Date,Day,Shift,Start,End,Type,Workers\n'; + + getMonthDates().forEach(d => { + const dow = new Date(d + 'T12:00:00').getDay(); + const dayCustomShifts = state.customShifts[d] || []; + + state.shifts.forEach(s => { + const assigns = state.assignments.filter(a => a.date === d && a.shiftId === s.id); + if (assigns.length > 0) { + const workerNames = assigns.map(a => { + const w = state.workers.find(wk => wk.id === a.workerId); + return w ? w.name + (w.role === 'shiftleader' ? ' ★' : '') : ''; + }).filter(Boolean).join('; '); + csv += '"' + d + '","' + dayNames[dow] + '","' + s.name + '","' + s.startTime + '","' + s.endTime + '","template","' + workerNames + '"\n'; + } + }); + + dayCustomShifts.forEach(cs => { + const assigns = state.assignments.filter(a => a.date === d && a.shiftId === cs.id); + const workerNames = assigns.map(a => { + const w = state.workers.find(wk => wk.id === a.workerId); + return w ? w.name + (w.role === 'shiftleader' ? ' ★' : '') : ''; + }).filter(Boolean).join('; '); + csv += '"' + d + '","' + dayNames[dow] + '","' + cs.name + '","' + cs.startTime + '","' + cs.endTime + '","one-off","' + workerNames + '"\n'; + }); + }); + + if (csv.split('\n').length <= 1) { + showToast('No data to export', 'warning'); + return; + } + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'shiftplan_' + currentYear + '_' + String(currentMonth + 1).padStart(2,'0') + '.csv'; + a.click(); + showToast('CSV exported!', 'success'); + } + + // ============================================================ + // THEME & SETTINGS + // ============================================================ + function toggleTheme() { + state.settings.darkMode = !state.settings.darkMode; + applyTheme(); + saveToStorage(); + } + + function applyTheme() { + if (state.settings.darkMode) { + document.documentElement.setAttribute('data-theme', 'dark'); + document.getElementById('themeBtn').textContent = '🌙'; + } else { + document.documentElement.removeAttribute('data-theme'); + document.getElementById('themeBtn').textContent = '☀'; + } + } + + function openSettings() { + document.getElementById('settingDarkMode').checked = state.settings.darkMode; + document.getElementById('settingRestRule').checked = state.settings.enforceRest; + document.getElementById('setting5DayRule').checked = state.settings.enforce5Day; + document.getElementById('aiEndpointInput').value = aiEndpoint; + openModal('settingsModal'); + } + + function saveSettings() { + state.settings.darkMode = document.getElementById('settingDarkMode').checked; + state.settings.enforceRest = document.getElementById('settingRestRule').checked; + state.settings.enforce5Day = document.getElementById('setting5DayRule').checked; + const newEndpoint = document.getElementById('aiEndpointInput').value.trim(); + if (newEndpoint && newEndpoint !== aiEndpoint) { + aiEndpoint = newEndpoint; + aiConnected = false; + localStorage.setItem('sp_ai_endpoint', aiEndpoint); + testAIConnection(); + } + saveToStorage(); + applyTheme(); + renderCalendar(); + updateConflictBadge(); + closeModal('settingsModal'); + showToast('Settings saved!', 'success'); + } + + // ============================================================ + // TOAST + // ============================================================ + function showToast(msg, type = 'info') { + const container = document.getElementById('toastContainer'); + const toast = document.createElement('div'); + toast.className = 'toast ' + type; + toast.innerHTML = '<span>' + escapeHtml(msg) + '</span>'; + container.appendChild(toast); + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateX(110%)'; + setTimeout(() => toast.remove(), 300); + }, 4000); + } + + // ============================================================ + // AI ADVISOR SYSTEM + // ============================================================ + + function initAI() { + // Load saved settings + aiModel = localStorage.getItem('sp_ai_model') || 'llama3.2'; + aiEndpoint = localStorage.getItem('sp_ai_endpoint') || 'http://localhost:11434'; + document.getElementById('aiModelSelect').value = aiModel; + document.getElementById('aiEndpointDisplay').textContent = aiEndpoint.replace('http://', '').replace('https://', ''); + testAIConnection(); + } + + function testAIConnection() { + updateAIStatus('loading', 'Connecting...'); + fetch(aiEndpoint + '/api/tags', { + method: 'GET', + headers: { 'Content-Type': 'application/json' } + }) + .then(r => r.json()) + .then(data => { + aiConnected = true; + const models = data.models || []; + updateAIStatus('connected', aiConnected ? 'Connected' : 'Disconnected'); + // Update model dropdown if models returned + if (models.length > 0) { + const select = document.getElementById('aiModelSelect'); + const current = select.value; + select.innerHTML = ''; + models.forEach(m => { + const opt = document.createElement('option'); + opt.value = m.name; + opt.textContent = m.name; + select.appendChild(opt); + }); + if (models.find(m => m.name === current)) { + select.value = current; + } else { + select.value = models[0].name; + aiModel = models[0].name; + } + } + }) + .catch(() => { + aiConnected = false; + updateAIStatus('disconnected', 'Not connected (start Ollama)'); + }); + } + + function updateAIStatus(status, text) { + const dot = document.getElementById('aiStatusDot'); + const textEl = document.getElementById('aiStatusText'); + dot.className = 'ai-status-dot ' + status; + textEl.textContent = text; + } + + function toggleAIPanel() { + const panel = document.getElementById('aiPanel'); + const btn = document.getElementById('aiToggleBtn'); + panel.classList.toggle('open'); + btn.classList.toggle('open'); + if (panel.classList.contains('open')) { + document.getElementById('aiInput').focus(); + } + } + + function saveAIModel() { + aiModel = document.getElementById('aiModelSelect').value; + localStorage.setItem('sp_ai_model', aiModel); + } + + function askAI(question) { + document.getElementById('aiInput').value = question; + sendAIMessage(); + } + + function sendAIMessage() { + const input = document.getElementById('aiInput'); + const message = input.value.trim(); + if (!message || aiLoading) return; + + input.value = ''; + addAIMessage(message, 'user'); + showAITyping(); + + // Build context from current state + const context = buildScheduleContext(); + const systemPrompt = buildSystemPrompt(); + const fullPrompt = systemPrompt + '\n\n' + context + '\n\nUser: ' + message + '\n\nAssistant:'; + + aiLoading = true; + document.getElementById('aiSendBtn').disabled = true; + + fetch(aiEndpoint + '/api/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: aiModel, + prompt: fullPrompt, + stream: false, + options: { + temperature: 0.4, + num_predict: 1024 + } + }) + }) + .then(r => { + if (!r.ok) throw new Error('API error: ' + r.status); + return r.json(); + }) + .then(data => { + removeAITyping(); + const response = data.response || 'I analyzed your schedule but could not generate a response.'; + addAIMessage(response, 'assistant'); + aiConversationHistory.push({ role: 'user', content: message }); + aiConversationHistory.push({ role: 'assistant', content: response }); + }) + .catch(err => { + removeAITyping(); + addAIMessage('⚠️ AI connection failed: ' + err.message + '\n\nMake sure Ollama is running at:\n' + aiEndpoint + '\n\nClick ⚙️ in the header to check the endpoint.', 'assistant'); + aiConnected = false; + updateAIStatus('disconnected', 'Connection failed'); + }) + .finally(() => { + aiLoading = false; + document.getElementById('aiSendBtn').disabled = false; + }); + } + + function buildSystemPrompt() { + return `You are an expert shift scheduling advisor for a team management app called "Shiftplanner". You have full access to the current schedule data provided after "SCHEDULE DATA:" in each message. + +Your role: +- Analyze shift assignments and identify fairness issues +- Suggest better shift distributions among workers +- Detect potential conflicts or rule violations +- Answer questions about the schedule in the user's language +- Give practical, actionable advice + +Rules: +- Be concise and helpful (max 3-4 sentences per answer) +- Use emojis sparingly to highlight key points +- If data is empty or missing, say so honestly +- Never make up worker names or shift data not in the provided context +- Focus on fairness, conflicts, and practical scheduling improvements +- Use the user's language (German if they write in German, English otherwise)`; + } + + function buildScheduleContext() { + const months = ['January','February','March','April','May','June', + 'July','August','September','October','November','December']; + + const monthDates = getMonthDates(); + const monthName = months[currentMonth] + ' ' + currentYear; + + // Per-worker stats + const workerStats = {}; + state.workers.forEach(w => { + workerStats[w.id] = { + name: w.name, + role: w.role, + shifts: 0, + hours: 0, + weekendShifts: 0, + sickDays: (w.sickDays || []).filter(s => monthDates.includes(s.date)).length, + vacation: (w.vacation || []).filter(v => { + return monthDates.some(d => d >= v.start && d <= v.end); + }).length, + dayOffRequests: (w.dayOffRequests || []).filter(d => monthDates.includes(d)).length + }; + }); + + state.assignments + .filter(a => monthDates.includes(a.date)) + .forEach(a => { + const ws = workerStats[a.workerId]; + if (!ws) return; + ws.shifts++; + const shift = state.shifts.find(s => s.id === a.shiftId) || + Object.values(state.customShifts).flat().find(s => s.id === a.shiftId); + if (shift) ws.hours += getShiftHours(shift); + const dow = new Date(a.date + 'T12:00:00').getDay(); + if (dow === 0 || dow === 6) ws.weekendShifts++; + }); + + // Conflicts + const allConflicts = []; + monthDates.forEach(d => { + checkDayConflicts(d).forEach(c => { + allConflicts.push({ date: d, ...c }); + }); + }); + + // Build context string + let ctx = 'SCHEDULE DATA:\n'; + ctx += 'Month: ' + monthName + ' (' + monthDates.length + ' days)\n'; + ctx += 'Settings: maxConsecutive=' + (state.settings.maxConsecutive || 5) + + ', minRestHours=' + (state.settings.minRestHours || 11) + + ', enforceRest=' + (state.settings.enforceRest !== false) + + ', enforce5Day=' + (state.settings.enforce5Day !== false) + '\n\n'; + + ctx += 'WORKERS (' + state.workers.length + '):\n'; + state.workers.forEach(w => { + const s = workerStats[w.id]; + ctx += '- ' + w.name + ' (' + (w.role === 'shiftleader' ? 'Shift Leader' : 'Employee') + ')\n'; + ctx += ' This month: ' + s.shifts + ' shifts, ' + Math.round(s.hours) + 'h total, ' + s.weekendShifts + ' weekend shifts\n'; + ctx += ' Unavailable: ' + s.sickDays + ' sick, ' + s.vacation + ' vacation days, ' + s.dayOffRequests + ' day-off requests\n'; + ctx += ' Contracted: ' + w.contractedHours + 'h/week\n'; + if (w.notes) ctx += ' Notes: ' + w.notes + '\n'; + }); + + ctx += '\nSHIFT TEMPLATES (' + state.shifts.length + '):\n'; + state.shifts.forEach(s => { + ctx += '- ' + s.name + ': ' + s.startTime + '–' + s.endTime + + ' (min ' + s.minWorkers + ', max ' + s.maxWorkers + ')' + + (s.requiresLeader ? ' [REQUIRES LEADER]' : '') + '\n'; + }); + + // Custom shifts this month + let customCount = 0; + monthDates.forEach(d => { + if (state.customShifts[d]) customCount += state.customShifts[d].length; + }); + if (customCount > 0) ctx += '\nCUSTOM/ONE-OFF SHIFTS: ' + customCount + ' this month\n'; + + // Conflicts + ctx += '\nCONFLICTS (' + allConflicts.length + ' total):\n'; + if (allConflicts.length === 0) { + ctx += 'No conflicts found.\n'; + } else { + allConflicts.slice(0, 10).forEach(c => { + ctx += '- [' + c.type.toUpperCase() + '] ' + c.date + ': ' + c.msg + '\n'; + }); + if (allConflicts.length > 10) ctx += '... and ' + (allConflicts.length - 10) + ' more\n'; + } + + // Fairness summary + const stats = Object.values(workerStats); + if (stats.length > 0) { + const avgShifts = stats.reduce((s, w) => s + w.shifts, 0) / stats.length; + const avgHours = stats.reduce((s, w) => s + w.hours, 0) / stats.length; + const avgWeekend = stats.reduce((s, w) => s + w.weekendShifts, 0) / stats.length; + const mostLoaded = [...stats].sort((a, b) => b.shifts - a.shifts)[0]; + const leastLoaded = [...stats].sort((a, b) => a.shifts - b.shifts)[0]; + const mostWeekend = [...stats].sort((a, b) => b.weekendShifts - a.weekendShifts)[0]; + + ctx += '\nFAIRNESS SUMMARY:\n'; + ctx += '- Average: ' + avgShifts.toFixed(1) + ' shifts, ' + avgHours.toFixed(0) + 'h, ' + avgWeekend.toFixed(1) + ' weekend shifts per worker\n'; + ctx += '- Most loaded: ' + mostLoaded.name + ' (' + mostLoaded.shifts + ' shifts, ' + Math.round(mostLoaded.hours) + 'h)\n'; + ctx += '- Least loaded: ' + leastLoaded.name + ' (' + leastLoaded.shifts + ' shifts, ' + Math.round(leastLoaded.hours) + 'h)\n'; + ctx += '- Most weekend shifts: ' + mostWeekend.name + ' (' + mostWeekend.weekendShifts + ')\n'; + + if (stats.length > 1 && avgShifts > 0) { + const variance = Math.max(...stats.map(w => Math.abs(w.shifts - avgShifts) / avgShifts * 100)); + ctx += '- Variance: ' + variance.toFixed(0) + '% (higher = more unfair)\n'; + } + } + + return ctx; + } + + function addAIMessage(content, role) { + const container = document.getElementById('aiMessages'); + + // Remove empty state if present + const empty = container.querySelector('.ai-empty-state'); + if (empty) empty.remove(); + + const div = document.createElement('div'); + div.className = 'ai-msg ' + role; + + const avatar = role === 'user' ? '👤' : '🤖'; + div.innerHTML = + '<div class="ai-msg-avatar">' + avatar + '</div>' + + '<div>' + + '<div class="ai-msg-bubble">' + escapeHtml(content) + '</div>' + + '<div class="ai-msg-actions">' + + '<button class="ai-quick-btn" onclick="copyAIMessage(this)" title="Copy">📋 Copy</button>' + + '</div>' + + '</div>'; + + container.appendChild(div); + container.scrollTop = container.scrollHeight; + } + + function showAITyping() { + const container = document.getElementById('aiMessages'); + const div = document.createElement('div'); + div.className = 'ai-msg assistant typing'; + div.id = 'aiTypingMsg'; + div.innerHTML = + '<div class="ai-msg-avatar">🤖</div>' + + '<div class="ai-msg-bubble">Thinking<span class="ai-loading-dots"><span></span><span></span><span></span></span></div>'; + container.appendChild(div); + container.scrollTop = container.scrollHeight; + } + + function removeAITyping() { + const typing = document.getElementById('aiTypingMsg'); + if (typing) typing.remove(); + } + + function copyAIMessage(btn) { + const bubble = btn.closest('.ai-msg').querySelector('.ai-msg-bubble'); + navigator.clipboard.writeText(bubble.textContent).then(() => { + const orig = btn.textContent; + btn.textContent = '✓ Copied!'; + setTimeout(() => { btn.textContent = orig; }, 1500); + }); + } + + // ============================================================ + // HOOKS + // ============================================================ + const _openModal = openModal; + openModal = function(id) { + _openModal(id); + if (id === 'generatorModal') initGenerator(); + if (id === 'settingsModal') openSettings(); + }; + + // ============================================================ + // START + // ============================================================ + init(); + </script> +</body> +</html>
\ No newline at end of file |