aboutsummaryrefslogtreecommitdiff
path: root/src/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'src/index.html')
-rw-r--r--src/index.html2790
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, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;');
+ }
+
+ // ============================================================
+ // 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