summaryrefslogtreecommitdiff
path: root/src/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'src/index.html')
-rw-r--r--src/index.html430
1 files changed, 383 insertions, 47 deletions
diff --git a/src/index.html b/src/index.html
index 86ff355..6a6d871 100644
--- a/src/index.html
+++ b/src/index.html
@@ -461,11 +461,22 @@
.toggle-sidebar { display: block !important; }
.header-right .btn .btn-label { display: none; }
.ai-panel { width: min(var(--ai-panel-w), 100vw); }
+ /* Header must wrap instead of pushing the document wider than the phone. */
+ .header { flex-wrap: wrap; row-gap: 6px; }
+ .header-left, .nav-center, .header-right { flex-wrap: wrap; }
+ .header-right { justify-content: flex-end; }
}
@media (max-width: 600px) {
.header { padding: 8px 12px; gap: 6px; }
+ .header-left, .nav-center, .header-right { gap: 5px; }
+ .logo { font-size: 1.05rem; }
+ .logo-ai { padding: 1px 5px; font-size: 0.65rem; margin-left: 4px; }
+ .stat-chip { font-size: 0.6rem; padding: 2px 5px; }
+ .header-divider { display: none; }
+ .btn { padding: 5px 9px; }
+ .ai-toggle-btn { padding: 5px 9px; font-size: 0.72rem; }
.nav-btn { padding: 5px 10px; font-size: 0.8rem; }
- .month-display { min-width: 140px; font-size: 1rem; }
+ .month-display { min-width: 120px; font-size: 0.9rem; }
.calendar-grid { grid-template-columns: repeat(7, minmax(120px, 1fr)); }
.day-cell { min-height: 130px; }
}
@@ -790,14 +801,14 @@
<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">
+ <input type="range" class="slider" id="maxConsecutive" min="3" max="7" value="5" oninput="document.getElementById('maxConsecVal').textContent=this.value" onchange="persistGenSetting('maxConsecutive', 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">
+ <input type="range" class="slider" id="minRestHours" min="8" max="14" value="11" oninput="document.getElementById('minRestVal').textContent=this.value" onchange="persistGenSetting('minRestHours', this.value)">
</div>
<div class="form-group">
<label class="form-checkbox">
@@ -916,6 +927,7 @@
let currentMonth, currentYear;
let genState = { step: 1, selectedWorkers: new Set(), generatedPlan: null };
let tempDayOffs = [], tempSickDays = [], tempVacations = [];
+ let pendingWorkerId = null;
// AI state
let aiEndpoint = 'http://localhost:11434';
@@ -932,38 +944,268 @@
const now = new Date();
currentMonth = now.getMonth();
currentYear = now.getFullYear();
- renderAll();
+ // A render must not be able to leave the page half built: if one
+ // section throws, the rest still renders and the user is told.
+ try {
+ renderAll();
+ } catch (e) {
+ console.error('Initial render failed:', e);
+ showToast('Part of the schedule could not be displayed. Export JSON to back up your data.', 'error');
+ }
applyTheme();
initAI();
+ setupStorageSync();
+ if (window.innerWidth <= 900) document.getElementById('sidebar').classList.add('collapsed');
}
- 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);
+ // ---- persistence ------------------------------------------------
+ // A saved plan must never be lost silently: anything that cannot be
+ // parsed or repaired is copied to a recovery key before we move on.
+ const RECOVERY_PREFIX = STORAGE_KEY + '_recovery_';
+
+ function backupRaw(raw) {
+ const key = RECOVERY_PREFIX + new Date().toISOString().replace(/[:.]/g, '-');
+ try {
+ localStorage.setItem(key, raw);
+ return key;
+ } catch (e) {
+ console.warn('Could not store recovery copy:', e);
+ return null;
+ }
+ }
+
+ function defaultSettings() {
+ return {
+ darkMode: false, enforceRest: true, enforce5Day: true,
+ minRestHours: 11, maxConsecutive: 5, maxWorkers: DEFAULT_MAX_WORKERS
+ };
+ }
+
+ function isObj(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
+ function asArray(v) { return Array.isArray(v) ? v : []; }
+ function asObject(v) { return isObj(v) ? v : {}; }
+ function asDateKey(v) {
+ return (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)) ? v : null;
+ }
+ function asTime(v) {
+ return (typeof v === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(v)) ? v : null;
+ }
+ function asInt(v, dflt, min, max) {
+ const n = parseInt(v, 10);
+ if (!isFinite(n)) return dflt;
+ return Math.min(max, Math.max(min, n));
+ }
+ function asColor(v) {
+ return (typeof v === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(v)) ? v : WORKER_COLORS[0];
+ }
+ const SAFE_ID = /^[A-Za-z0-9_.:-]{1,80}$/;
+
+ // Validate every field and coerce wrong types instead of letting a
+ // render throw half way through. Returns {data, repaired}; data is null
+ // when the payload is not an object at all.
+ function normalizeState(parsed) {
+ const repaired = [];
+ if (!isObj(parsed)) return { data: null, repaired: ['root'] };
+
+ const data = {};
+ Object.keys(parsed).forEach(k => {
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') return;
+ data[k] = parsed[k]; // unknown fields survive a re-save
+ });
+ data.version = '2.1';
+
+ const rawSettings = asObject(parsed.settings);
+ const settings = defaultSettings();
+ ['darkMode', 'enforceRest', 'enforce5Day'].forEach(k => {
+ if (!(k in rawSettings)) return;
+ if (typeof rawSettings[k] === 'boolean') settings[k] = rawSettings[k];
+ else repaired.push('settings.' + k);
+ });
+ settings.minRestHours = asInt(rawSettings.minRestHours, 11, 0, 24);
+ settings.maxConsecutive = asInt(rawSettings.maxConsecutive, 5, 1, 31);
+ settings.maxWorkers = asInt(rawSettings.maxWorkers, DEFAULT_MAX_WORKERS, 1, 500);
+ data.settings = settings;
+
+ const idMap = Object.create(null);
+ const mapId = (raw, prefix, i) => {
+ if (SAFE_ID.test(raw || '')) return raw;
+ return prefix + Date.now().toString(36) + '_' + i;
+ };
+
+ data.workers = [];
+ asArray(parsed.workers).forEach((w, i) => {
+ if (!isObj(w) || typeof w.name !== 'string' || !w.name.trim()) {
+ repaired.push('workers[' + i + ']');
+ return;
}
+ const id = mapId(w.id, 'w_', i);
+ if (id !== w.id) repaired.push('workers[' + i + '].id');
+ if (w.id) idMap[w.id] = id;
+ data.workers.push({
+ id,
+ name: w.name.trim().slice(0, 200),
+ role: w.role === 'shiftleader' ? 'shiftleader' : 'employee',
+ contractedHours: asInt(w.contractedHours, 40, 0, 168),
+ color: asColor(w.color),
+ dayOffRequests: asArray(w.dayOffRequests).filter(asDateKey),
+ sickDays: asArray(w.sickDays)
+ .filter(s => isObj(s) && asDateKey(s.date))
+ .map(s => ({ date: s.date, note: typeof s.note === 'string' ? s.note.slice(0, 200) : '' })),
+ vacation: asArray(w.vacation)
+ .filter(v => isObj(v) && asDateKey(v.start) && asDateKey(v.end))
+ .map(v => ({ start: v.start, end: v.end, note: typeof v.note === 'string' ? v.note.slice(0, 200) : '' })),
+ notes: typeof w.notes === 'string' ? w.notes : ''
+ });
+ });
+
+ const normalizeShift = (s, i, prefix) => {
+ if (!isObj(s) || typeof s.name !== 'string' || !s.name.trim()) return null;
+ const startTime = asTime(s.startTime);
+ const endTime = asTime(s.endTime);
+ if (!startTime || !endTime) return null; // a shift without valid hours is unusable
+ const id = mapId(s.id, prefix, i);
+ if (id !== s.id) repaired.push(prefix + 'id[' + i + ']');
+ if (s.id) idMap[s.id] = id;
+ return {
+ id,
+ name: s.name.trim().slice(0, 120),
+ startTime, endTime,
+ minWorkers: asInt(s.minWorkers, 1, 0, 500),
+ maxWorkers: asInt(s.maxWorkers, 5, 1, 500),
+ requiresLeader: !!s.requiresLeader
+ };
+ };
+
+ data.shifts = [];
+ asArray(parsed.shifts).forEach((s, i) => {
+ const shift = normalizeShift(s, i, 's_');
+ if (!shift) { repaired.push('shifts[' + i + ']'); return; }
+ data.shifts.push(shift);
+ });
+
+ data.customShifts = {};
+ const rawCustom = asObject(parsed.customShifts);
+ Object.keys(rawCustom).forEach(date => {
+ if (!asDateKey(date)) { repaired.push('customShifts[' + date + ']'); return; }
+ const list = asArray(rawCustom[date]).map((s, i) => normalizeShift(s, i, 'cs_')).filter(Boolean);
+ if (list.length !== asArray(rawCustom[date]).length) repaired.push('customShifts[' + date + ']');
+ if (list.length) data.customShifts[date] = list;
+ });
+
+ const shiftExists = (dateStr, shiftId) =>
+ data.shifts.some(s => s.id === shiftId) ||
+ (data.customShifts[dateStr] || []).some(s => s.id === shiftId);
+
+ data.assignments = [];
+ asArray(parsed.assignments).forEach((a, i) => {
+ if (!isObj(a) || !asDateKey(a.date) || typeof a.shiftId !== 'string' || typeof a.workerId !== 'string') {
+ repaired.push('assignments[' + i + ']');
+ return;
+ }
+ const workerId = idMap[a.workerId] || a.workerId;
+ const shiftId = idMap[a.shiftId] || a.shiftId;
+ if (!data.workers.some(w => w.id === workerId) || !shiftExists(a.date, shiftId)) {
+ repaired.push('assignments[' + i + '] (orphaned)');
+ return;
+ }
+ data.assignments.push({ date: a.date, shiftId, workerId });
+ });
+
+ data.templates = [];
+ asArray(parsed.templates).forEach((t, i) => {
+ if (!isObj(t) || typeof t.name !== 'string') { repaired.push('templates[' + i + ']'); return; }
+ data.templates.push({
+ id: SAFE_ID.test(t.id || '') ? t.id : 't_' + Date.now().toString(36) + '_' + i,
+ name: t.name.slice(0, 120),
+ data: {
+ shifts: asArray(asObject(t.data).shifts).map((s, j) => normalizeShift(s, j, 's_')).filter(Boolean),
+ assignments: asArray(asObject(t.data).assignments)
+ .filter(a => isObj(a) && asDateKey(a.date) && typeof a.shiftId === 'string' && typeof a.workerId === 'string')
+ .map(a => ({ date: a.date, shiftId: idMap[a.shiftId] || a.shiftId, workerId: idMap[a.workerId] || a.workerId }))
+ }
+ });
+ });
+
+ data.notes = {};
+ const rawNotes = asObject(parsed.notes);
+ Object.keys(rawNotes).forEach(date => {
+ if (asDateKey(date) && typeof rawNotes[date] === 'string') data.notes[date] = rawNotes[date];
+ else repaired.push('notes[' + date + ']');
+ });
+
+ return { data, repaired };
+ }
+
+ function loadFromStorage() {
+ let saved = null;
+ try {
+ saved = localStorage.getItem(STORAGE_KEY);
+ } catch (e) {
+ console.warn('localStorage is not available:', e);
+ return;
+ }
+ if (!saved) return;
+
+ let parsed;
+ try {
+ parsed = JSON.parse(saved);
+ } catch (e) {
+ console.warn('Failed to load state:', e);
+ const key = backupRaw(saved);
+ showToast('Saved schedule could not be read. A copy was kept in this browser' +
+ (key ? ' under ' + key : '') + '.', 'error');
+ return;
+ }
+
+ const result = normalizeState(parsed);
+ if (!result.data) {
+ const key = backupRaw(saved);
+ showToast('Saved schedule had an unexpected format. A copy was kept in this browser' +
+ (key ? ' under ' + key : '') + '.', 'error');
+ return;
+ }
+ state = result.data;
+ if (result.repaired.length) {
+ const key = backupRaw(saved);
+ console.warn('Repaired saved data:', result.repaired);
+ showToast('Skipped ' + result.repaired.length + ' invalid saved entr' +
+ (result.repaired.length === 1 ? 'y' : 'ies') + '; the original data is kept' +
+ (key ? ' under ' + key : '') + '.', 'warning');
}
}
function saveToStorage() {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+ return true;
+ } catch (e) {
+ console.error('Failed to save state:', e);
+ showToast('Could not save — browser storage is full or blocked. Use Export JSON to keep your plan.', 'error');
+ return false;
+ }
+ }
+
+ // Another tab (or a manual clear) changed the stored plan: reload it so
+ // the two views cannot drift apart and silently overwrite each other.
+ function setupStorageSync() {
+ window.addEventListener('storage', function (e) {
+ if (e.key === null) {
+ state = normalizeState({}).data;
+ renderAll();
+ applyTheme();
+ showToast('Site data was cleared in another tab — the plan is now empty here too.', 'warning');
+ } else if (e.key === STORAGE_KEY) {
+ loadFromStorage();
+ renderAll();
+ applyTheme();
+ showToast('Schedule updated from another tab.', 'info');
+ } else if (e.key === 'sp_ai_endpoint') {
+ aiEndpoint = e.newValue || 'http://localhost:11434';
+ document.getElementById('aiEndpointDisplay').textContent =
+ aiEndpoint.replace(/^https?:\/\//, '');
+ testAIConnection();
+ }
+ });
}
// ============================================================
@@ -1096,7 +1338,8 @@
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>';
+ html += '<span style="color:' + leaderColor + ';">' + escapeHtml(shift.name) + ' ' +
+ escapeHtml(shift.startTime) + '–' + escapeHtml(shift.endTime) + leaderStatus + '</span>';
if (customTag) html += customTag;
html += '</div>';
@@ -1105,11 +1348,11 @@
if (!w) return;
const bgColor = w.role === 'shiftleader'
? 'var(--warning)'
- : w.color + '30';
+ : escapeHtml(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>';
+ html += '<span class="dot" style="background:' + escapeHtml(w.color) + ';"></span>';
+ html += '<span class="name">' + escapeHtml(w.name) + '</span>';
if (w.role === 'shiftleader') html += '<span class="leader-badge">★</span>';
html += '<span class="remove-assign">×</span>';
html += '</div>';
@@ -1130,13 +1373,13 @@
const badges = [];
state.workers.forEach(w => {
if (w.sickDays?.some(s => s.date === dateStr)) {
- badges.push('<span class="badge sick">🏥 ' + w.name + '</span>');
+ badges.push('<span class="badge sick">🏥 ' + escapeHtml(w.name) + '</span>');
}
if (w.vacation?.some(v => dateStr >= v.start && dateStr <= v.end)) {
- badges.push('<span class="badge vacation">✈️ ' + w.name + '</span>');
+ badges.push('<span class="badge vacation">✈️ ' + escapeHtml(w.name) + '</span>');
}
if (w.dayOffRequests?.includes(dateStr)) {
- badges.push('<span class="badge request">📋 ' + w.name + '</span>');
+ badges.push('<span class="badge request">📋 ' + escapeHtml(w.name) + '</span>');
}
});
return badges;
@@ -1186,7 +1429,7 @@
: '';
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>' +
+ '<span class="shift-time">' + escapeHtml(s.startTime) + '–' + escapeHtml(s.endTime) + '</span>' +
leaderTag +
'<span style="font-size:0.7rem;color:var(--muted);">min' + s.minWorkers + '</span>' +
'<div class="item-actions">' +
@@ -1211,11 +1454,27 @@
// ============================================================
// WORKERS CRUD
// ============================================================
+ // Ask for the modal, then let the openModal hook (the single place that
+ // knows the modal is really opening) populate the form. The header
+ // "+ Worker" button calls openModal('workerModal') directly, so a reset
+ // here alone would be bypassed.
function openWorkerModal(id = null) {
+ pendingWorkerId = id || null;
+ openModal('workerModal');
+ }
+
+ function populateWorkerModal(id) {
tempDayOffs = [];
tempSickDays = [];
tempVacations = [];
document.getElementById('workerForm').reset();
+ // Always start from a clean slate: a stale #workerId turns "Add Worker"
+ // into "overwrite the worker that was last edited".
+ document.getElementById('workerId').value = '';
+ document.getElementById('workerName').value = '';
+ document.getElementById('workerRole').value = 'employee';
+ document.getElementById('workerHours').value = 40;
+ document.getElementById('workerNotes').value = '';
document.getElementById('colorPicker').innerHTML = '';
document.getElementById('deleteWorkerBtn').style.display = 'none';
@@ -1252,7 +1511,6 @@
}
renderTempData();
- openModal('workerModal');
}
function updateMaxWorkers() {
@@ -1789,6 +2047,42 @@
seen.add(id);
});
+ // Real time-overlap. A shift ending after midnight can collide with a
+ // shift starting the same day or the next one, so compare absolute
+ // intervals instead of only "same calendar day" duplicates.
+ const spanDates = [getPrevDay(dateStr), dateStr, getNextDay(dateStr)];
+ [...new Set(workerIds)].forEach(wId => {
+ const spans = [];
+ spanDates.forEach(ds => {
+ state.assignments
+ .filter(a => a.date === ds && a.workerId === wId)
+ .forEach(a => {
+ const sh = getShiftById(a.shiftId) ||
+ (state.customShifts[ds] || []).find(s => s.id === a.shiftId);
+ if (!sh) return;
+ const base = dateToEpochMinutes(ds);
+ const start = base + timeToMinutes(sh.startTime);
+ let end = base + timeToMinutes(sh.endTime);
+ if (end <= start) end += 24 * 60;
+ spans.push({ date: ds, shift: sh, start, end });
+ });
+ });
+ spans.sort((a, b) => a.start - b.start || b.end - a.end);
+ for (let i = 1; i < spans.length; i++) {
+ const prev = spans[i - 1], cur = spans[i];
+ if (cur.start < prev.end && (prev.date === dateStr || cur.date === dateStr)) {
+ const w = state.workers.find(x => x.id === wId);
+ conflicts.push({
+ type: 'error',
+ msg: (w?.name || 'Worker') + ': shifts overlap (' +
+ prev.shift.startTime + '–' + prev.shift.endTime + ' on ' + prev.date +
+ ' overlaps ' + cur.shift.startTime + '–' + cur.shift.endTime +
+ ' on ' + cur.date + ')'
+ });
+ }
+ }
+ });
+
const allShifts = [
...state.shifts,
...(state.customShifts[dateStr] || [])
@@ -1952,16 +2246,29 @@
return dates;
}
+ // Date-string maths stays in the local calendar: going through
+ // Date#toISOString() converts to UTC and shifts the day west of UTC.
+ function formatDateLocal(y, m, d) {
+ const dt = new Date(y, m, d);
+ return dt.getFullYear() + '-' +
+ String(dt.getMonth() + 1).padStart(2, '0') + '-' +
+ String(dt.getDate()).padStart(2, '0');
+ }
+
function getPrevDay(dateStr) {
- const d = new Date(dateStr + 'T12:00:00');
- d.setDate(d.getDate() - 1);
- return d.toISOString().split('T')[0];
+ const [y, m, d] = dateStr.split('-').map(Number);
+ return formatDateLocal(y, m - 1, d - 1);
}
function getNextDay(dateStr) {
- const d = new Date(dateStr + 'T12:00:00');
- d.setDate(d.getDate() + 1);
- return d.toISOString().split('T')[0];
+ const [y, m, d] = dateStr.split('-').map(Number);
+ return formatDateLocal(y, m - 1, d + 1);
+ }
+
+ // Absolute minutes for a date, independent of the browser time zone.
+ function dateToEpochMinutes(dateStr) {
+ const [y, m, d] = dateStr.split('-').map(Number);
+ return Date.UTC(y, m - 1, d) / 60000;
}
function timeToMinutes(time) {
@@ -2080,9 +2387,21 @@
if (genState.step > 1) setGenStep(genState.step - 1);
}
+ function persistGenSetting(key, val) {
+ const n = parseInt(val, 10);
+ if (!isFinite(n)) return;
+ state.settings[key] = n;
+ saveToStorage();
+ }
+
function generatePlan() {
const maxConsec = parseInt(document.getElementById('maxConsecutive').value);
const minRest = parseInt(document.getElementById('minRestHours').value);
+ // The wizard values are the live rules: persist them so conflict
+ // detection agrees with what the user just configured.
+ if (isFinite(maxConsec)) state.settings.maxConsecutive = maxConsec;
+ if (isFinite(minRest)) state.settings.minRestHours = minRest;
+ saveToStorage();
const priority = document.getElementById('fairnessPriority').value;
const includeWeekends = document.getElementById('includeWeekends').checked;
@@ -2356,10 +2675,20 @@
event.target.value = '';
}
+ // One quoting rule for every CSV field: always quoted, embedded quotes
+ // doubled, and a leading ' for anything a spreadsheet would run as a
+ // formula (= + - @ or a leading tab/CR).
+ function csvCell(value) {
+ let s = (value === null || value === undefined) ? '' : String(value);
+ if (/^[=+\-@\t\r]/.test(s.trim())) s = "'" + s;
+ return '"' + s.replace(/"/g, '""') + '"';
+ }
+
function exportCSV() {
const dayNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
- let csv = 'Date,Day,Shift,Start,End,Type,Workers\n';
-
+ const row = cells => cells.map(csvCell).join(',') + '\n';
+ let csv = row(['Date','Day','Shift','Start','End','Type','Workers']);
+ let rows = 0;
getMonthDates().forEach(d => {
const dow = new Date(d + 'T12:00:00').getDay();
const dayCustomShifts = state.customShifts[d] || [];
@@ -2371,7 +2700,8 @@
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';
+ csv += row([d, dayNames[dow], s.name, s.startTime, s.endTime, 'template', workerNames]);
+ rows++;
}
});
@@ -2381,11 +2711,12 @@
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';
+ csv += row([d, dayNames[dow], cs.name, cs.startTime, cs.endTime, 'one-off', workerNames]);
+ rows++;
});
});
- if (csv.split('\n').length <= 1) {
+ if (rows === 0) {
showToast('No data to export', 'warning');
return;
}
@@ -2422,7 +2753,8 @@
document.getElementById('settingRestRule').checked = state.settings.enforceRest;
document.getElementById('setting5DayRule').checked = state.settings.enforce5Day;
document.getElementById('aiEndpointInput').value = aiEndpoint;
- openModal('settingsModal');
+ // The modal-open hook is the single caller of openSettings(); opening
+ // the modal from here would recurse forever.
}
function saveSettings() {
@@ -2779,6 +3111,10 @@ Rules:
_openModal(id);
if (id === 'generatorModal') initGenerator();
if (id === 'settingsModal') openSettings();
+ if (id === 'workerModal') {
+ populateWorkerModal(pendingWorkerId);
+ pendingWorkerId = null;
+ }
};
// ============================================================