feat: TKB shift scheduler — personnel shift planning web app

Full rewrite of METRO HMG for TKB tunnel department:
- People-based grid (18 TKB + 5 IT), year-long calendar
- Color-coded shift values (4/6/8/12/A/B/D/N/U/O)
- Drag-and-drop cells, multi-cell selection (click/ctrl/shift/drag)
- Right-click context menu with color palette
- Tunnel closure + Metro + D8 info rows (toggleable)
- Czech holidays highlighted with names
- PDF export (2-page A4 landscape, DejaVu font for Czech chars)
- Improvement proposals system
- Sticky headers (vertical + horizontal scroll)
- Cell value filter toggles in legend

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Docker Config Backup
2026-04-02 09:48:38 +02:00
commit b4158d687f
47 changed files with 14185 additions and 0 deletions

166
web/import_excel.py Executable file
View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Parse Excel schedule file and output JSON to stdout."""
import json
import sys
from datetime import date
import openpyxl
filepath = sys.argv[1]
wb = openpyxl.load_workbook(filepath, data_only=True)
ws = wb.active
# Also load with styles for comments
wb2 = openpyxl.load_workbook(filepath)
ws2 = wb2.active
# --- Build dayIndex from row 9 (months) and row 11 (days) ---
month_starts = {}
for col in range(7, 300):
val = ws.cell(row=9, column=col).value
if val is not None and hasattr(val, 'month'):
month_starts[col] = (val.year, val.month)
if not month_starts:
print(json.dumps({"error": "No month data found in row 9"}), file=sys.stderr)
sys.exit(1)
day_index = []
for col in range(7, 300):
day_val = ws.cell(row=11, column=col).value
if day_val is None:
continue
day_num = int(day_val)
current_month = None
for mcol in sorted(month_starts.keys(), reverse=True):
if col >= mcol:
current_month = month_starts[mcol]
break
if current_month is None:
continue
year, month = current_month
idx = col - 7
try:
d = date(year, month, day_num)
is_weekend = d.weekday() >= 5
week = d.isocalendar()[1]
except ValueError:
is_weekend = False
week = 0
day_index.append({
"idx": idx,
"day": day_num,
"month": month,
"year": year,
"week": week,
"weekend": is_weekend,
})
# --- Extract station data ---
valid_idx = set(d["idx"] for d in day_index)
stations = []
for row in range(13, 40):
code = ws.cell(row=row, column=1).value
name = ws.cell(row=row, column=2).value or ""
server = ws.cell(row=row, column=3).value or ""
if not code:
continue
data = {}
for col in range(7, 300):
idx = col - 7
if idx not in valid_idx:
continue
val = ws.cell(row=row, column=col).value
if val is None:
continue
if isinstance(val, str) and val.strip() == "":
continue
entry = {}
if isinstance(val, (int, float)):
entry["v"] = int(val) if val == int(val) else val
else:
v = str(val).strip()
# Normalize: lowercase z -> uppercase Z
if v == 'z':
v = 'Z'
entry["v"] = v
data[str(idx)] = entry
stations.append({
"code": str(code).strip(),
"name": str(name).strip(),
"server": str(server).strip(),
"duration": None,
"data": data,
})
# --- Extract DEN row comments ---
day_comments = []
for col in range(7, 300):
cell = ws2.cell(row=11, column=col)
if cell.comment:
idx = col - 7
if idx not in valid_idx:
continue
text = cell.comment.text
# Extract actual comment from threaded format (Czech or English)
if "Komentář:\n" in text:
note = text.split("Komentář:\n")[-1].strip()
elif "Comment:\n" in text:
note = text.split("Comment:\n")[-1].strip()
else:
note = text.strip()
if note:
day_comments.append({"dayIdx": idx, "text": note})
# --- Extract cell comments from data rows ---
cell_comments = []
for row in range(13, 40):
code = ws2.cell(row=row, column=1).value
if not code:
continue
code = str(code).strip()
for col in range(7, 300):
cell = ws2.cell(row=row, column=col)
if cell.comment:
idx = col - 7
if idx not in valid_idx:
continue
text = cell.comment.text
if "Komentář:\n" in text:
note = text.split("Komentář:\n")[-1].strip()
elif "Comment:\n" in text:
note = text.split("Comment:\n")[-1].strip()
else:
note = text.strip()
if note:
cell_comments.append({"stationCode": code, "dayIdx": idx, "text": note})
# Deduplicate dayIndex — keep only first occurrence of each date
seen_dates = set()
deduped_day_index = []
for d in day_index:
date_key = (d["year"], d["month"], d["day"])
if date_key not in seen_dates:
seen_dates.add(date_key)
deduped_day_index.append(d)
day_index = deduped_day_index
result = {
"dayIndex": day_index,
"stations": stations,
"obstacles": [],
"dayComments": day_comments,
"cellComments": cell_comments,
}
print(json.dumps(result, ensure_ascii=False))