Replace planner queue + inline forms with searchable add-task modal
- Single "Add task" button in the planner header opens a modal - Day tabs (Mon–Fri with dates) let you switch the target day without closing - Live search filters all course items across all courses instantly - Items pre-fetched in background when planner loads — no per-click delay - Clicking an item schedules it immediately (no separate Save step) - Column "+ Add" buttons still work and open the modal pre-selected to that day - Offline task field at the bottom for non-course entries - Backdrop click or ✕ closes modal and re-renders the planner Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b7b77829e
commit
7580257ba0
3 files changed
+151
-72
No files matched your search
+18
-1
@@ -336,7 +336,7 @@
|
|||||||
<button id="planner-next" class="secondary">Next →</button>
|
<button id="planner-next" class="secondary">Next →</button>
|
||||||
<button id="planner-copy" class="secondary">Copy last week</button>
|
<button id="planner-copy" class="secondary">Copy last week</button>
|
||||||
<button id="planner-print">🖨 Print</button>
|
<button id="planner-print">🖨 Print</button>
|
||||||
<button id="planner-save-all" hidden>💾 Save 0 items</button>
|
<button id="planner-add-btn">+ Add task</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details id="auto-schedule-details" class="admin-card" style="margin-bottom:1rem">
|
<details id="auto-schedule-details" class="admin-card" style="margin-bottom:1rem">
|
||||||
@@ -546,6 +546,23 @@
|
|||||||
|
|
||||||
<div id="toast" role="status" aria-live="polite" hidden></div>
|
<div id="toast" role="status" aria-live="polite" hidden></div>
|
||||||
|
|
||||||
|
<!-- Planner add-task modal -->
|
||||||
|
<div id="planner-add-modal" class="modal" hidden>
|
||||||
|
<div class="modal-box planner-add-box">
|
||||||
|
<div class="planner-modal-header">
|
||||||
|
<h3>Add to planner</h3>
|
||||||
|
<button id="planner-modal-close" class="secondary small">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="planner-day-tabs" class="day-tabs"></div>
|
||||||
|
<input id="planner-item-search" type="search" class="planner-item-search" placeholder="Search course items…" autocomplete="off">
|
||||||
|
<div id="planner-item-list" class="item-list"></div>
|
||||||
|
<div class="planner-offline-row">
|
||||||
|
<input id="planner-offline-input" placeholder="Or type an offline task…">
|
||||||
|
<button id="planner-offline-add">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+110
-71
@@ -1168,7 +1168,6 @@ function addDays(dateStr, n) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let plannerWeekStart = null; // initialized lazily after settings load
|
let plannerWeekStart = null; // initialized lazily after settings load
|
||||||
let plannerQueue = [];
|
|
||||||
let plannerStudentId = null;
|
let plannerStudentId = null;
|
||||||
let plannerCoursesCache = [];
|
let plannerCoursesCache = [];
|
||||||
let plannerMode = 'week'; // 'week' | 'month'
|
let plannerMode = 'week'; // 'week' | 'month'
|
||||||
@@ -1190,6 +1189,7 @@ function monthLabel(dateStr) {
|
|||||||
|
|
||||||
async function loadPlannerPanel() {
|
async function loadPlannerPanel() {
|
||||||
if (!plannerWeekStart) plannerWeekStart = weekStartOf(new Date());
|
if (!plannerWeekStart) plannerWeekStart = weekStartOf(new Date());
|
||||||
|
allCourseItems = [];
|
||||||
const [students, courses] = await Promise.all([api('/api/students'), api('/api/admin/courses')]);
|
const [students, courses] = await Promise.all([api('/api/students'), api('/api/admin/courses')]);
|
||||||
if (students.length === 0) {
|
if (students.length === 0) {
|
||||||
$('#planner-grid').innerHTML = `<p class="hint">Add a kid first.</p>`;
|
$('#planner-grid').innerHTML = `<p class="hint">Add a kid first.</p>`;
|
||||||
@@ -1201,6 +1201,7 @@ async function loadPlannerPanel() {
|
|||||||
$('#auto-course').innerHTML = courses.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
|
$('#auto-course').innerHTML = courses.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
|
||||||
if (!$('#auto-start-date').value) $('#auto-start-date').value = plannerWeekStart;
|
if (!$('#auto-start-date').value) $('#auto-start-date').value = plannerWeekStart;
|
||||||
await renderPlanner();
|
await renderPlanner();
|
||||||
|
loadAllCourseItems(); // pre-fetch in background, no need to await
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#auto-schedule-btn').addEventListener('click', async () => {
|
$('#auto-schedule-btn').addEventListener('click', async () => {
|
||||||
@@ -1218,8 +1219,7 @@ $('#auto-schedule-btn').addEventListener('click', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('#planner-student').addEventListener('change', () => {
|
$('#planner-student').addEventListener('change', () => {
|
||||||
plannerQueue = [];
|
allCourseItems = [];
|
||||||
updatePlannerSaveBtn();
|
|
||||||
plannerStudentId = Number($('#planner-student').value);
|
plannerStudentId = Number($('#planner-student').value);
|
||||||
renderPlanner();
|
renderPlanner();
|
||||||
});
|
});
|
||||||
@@ -1312,7 +1312,7 @@ function attachPlannerEvents() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
document.querySelectorAll('[data-add-task]').forEach((btn) =>
|
document.querySelectorAll('[data-add-task]').forEach((btn) =>
|
||||||
btn.addEventListener('click', () => toggleAddTaskForm(btn.dataset.addTask))
|
btn.addEventListener('click', () => openPlannerAddModal(btn.dataset.addTask))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1333,7 +1333,6 @@ function renderDayCell(day, tasks, compact = false, spellingTests = []) {
|
|||||||
</div>`).join('') || (!testBadges ? `<p class="hint tiny">—</p>` : '')}
|
</div>`).join('') || (!testBadges ? `<p class="hint tiny">—</p>` : '')}
|
||||||
</div>
|
</div>
|
||||||
<button class="secondary small" data-add-task="${day.date}">+ Add</button>
|
<button class="secondary small" data-add-task="${day.date}">+ Add</button>
|
||||||
<div class="add-task-form" data-form-for="${day.date}" hidden></div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1383,81 +1382,121 @@ async function renderPlanner() {
|
|||||||
attachPlannerEvents();
|
attachPlannerEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAddTaskForm(date) {
|
// ---------- planner add-task modal ----------
|
||||||
const box = document.querySelector(`[data-form-for="${date}"]`);
|
|
||||||
if (!box.hidden) { box.hidden = true; return; }
|
|
||||||
const itemOptions = plannerCoursesCache
|
|
||||||
.map((c) => `<optgroup label="${esc(c.name)}"></optgroup>`)
|
|
||||||
.join('');
|
|
||||||
box.hidden = false;
|
|
||||||
box.innerHTML = `
|
|
||||||
<select class="task-course-select"><option value="">— pick a course item —</option></select>
|
|
||||||
<div class="or-divider">or</div>
|
|
||||||
<input class="task-offline-input" placeholder="Offline task, e.g. Read Ch. 3">
|
|
||||||
<button type="button" class="task-save-btn">Add</button>`;
|
|
||||||
|
|
||||||
const courseSelect = box.querySelector('.task-course-select');
|
let allCourseItems = []; // {id, type, title, courseName}
|
||||||
(async () => {
|
let plannerAddDate = null;
|
||||||
const details = await Promise.all(plannerCoursesCache.map((c) => api(`/api/admin/courses/${c.id}`)));
|
|
||||||
for (const detail of details) {
|
|
||||||
const group = document.createElement('optgroup');
|
|
||||||
group.label = detail.name;
|
|
||||||
for (const u of detail.units) {
|
|
||||||
for (const it of u.items) {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = it.id;
|
|
||||||
opt.textContent = `${TYPE_ICON[it.type]} ${it.title}`;
|
|
||||||
group.appendChild(opt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
courseSelect.appendChild(group);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
box.querySelector('.task-save-btn').addEventListener('click', () => {
|
async function loadAllCourseItems() {
|
||||||
const itemId = courseSelect.value;
|
const courses = await api(`/api/courses/mine/${plannerStudentId}`);
|
||||||
const title = box.querySelector('.task-offline-input').value.trim();
|
const details = await Promise.all(courses.map((c) => api(`/api/admin/courses/${c.id}`)));
|
||||||
if (!itemId && !title) return;
|
allCourseItems = [];
|
||||||
const label = itemId
|
for (const course of details) {
|
||||||
? courseSelect.options[courseSelect.selectedIndex].textContent
|
for (const unit of course.units) {
|
||||||
: `📌 ${title}`;
|
for (const it of unit.items) {
|
||||||
plannerQueue.push({ date, itemId: itemId || null, title, label });
|
allCourseItems.push({ id: it.id, type: it.type, title: it.title, courseName: course.name });
|
||||||
updatePlannerSaveBtn();
|
|
||||||
// Show as pending in the day cell without a full re-render
|
|
||||||
const tasksEl = document.querySelector(`.planner-tasks[data-drop-date="${date}"]`);
|
|
||||||
if (tasksEl) {
|
|
||||||
const hint = tasksEl.querySelector('.hint.tiny');
|
|
||||||
if (hint) hint.remove();
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = 'planner-task planner-pending';
|
|
||||||
el.textContent = label;
|
|
||||||
tasksEl.appendChild(el);
|
|
||||||
}
|
}
|
||||||
courseSelect.value = '';
|
}
|
||||||
box.querySelector('.task-offline-input').value = '';
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPlannerAddModal(date) {
|
||||||
|
plannerAddDate = date || addDays(plannerWeekStart, 0);
|
||||||
|
renderDayTabs();
|
||||||
|
renderItemList('');
|
||||||
|
$('#planner-item-search').value = '';
|
||||||
|
$('#planner-offline-input').value = '';
|
||||||
|
$('#planner-add-modal').hidden = false;
|
||||||
|
setTimeout(() => $('#planner-item-search').focus(), 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePlannerAddModal() {
|
||||||
|
$('#planner-add-modal').hidden = true;
|
||||||
|
renderPlanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDayTabs() {
|
||||||
|
const days = DAY_NAMES().map((name, i) => ({ name, date: addDays(plannerWeekStart, i) }));
|
||||||
|
$('#planner-day-tabs').innerHTML = days.map((d) =>
|
||||||
|
`<button class="day-tab${d.date === plannerAddDate ? ' active' : ''}" data-tab-date="${d.date}">
|
||||||
|
<span>${d.name.slice(0, 3)}</span>
|
||||||
|
<small>${d.date.slice(5)}</small>
|
||||||
|
</button>`
|
||||||
|
).join('');
|
||||||
|
$('#planner-day-tabs').querySelectorAll('.day-tab').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
plannerAddDate = btn.dataset.tabDate;
|
||||||
|
document.querySelectorAll('.day-tab').forEach((b) => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePlannerSaveBtn() {
|
function renderItemList(query) {
|
||||||
const btn = $('#planner-save-all');
|
const q = query.toLowerCase();
|
||||||
const n = plannerQueue.length;
|
const filtered = q
|
||||||
btn.hidden = n === 0;
|
? allCourseItems.filter((it) => it.title.toLowerCase().includes(q) || it.courseName.toLowerCase().includes(q))
|
||||||
btn.textContent = `💾 Save ${n} item${n === 1 ? '' : 's'}`;
|
: allCourseItems;
|
||||||
|
|
||||||
|
if (!filtered.length) {
|
||||||
|
$('#planner-item-list').innerHTML = '<p class="hint" style="padding:1rem;text-align:center">No items match.</p>';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#planner-save-all').addEventListener('click', async () => {
|
const groups = {};
|
||||||
const count = plannerQueue.length;
|
for (const it of filtered) {
|
||||||
if (!count) return;
|
if (!groups[it.courseName]) groups[it.courseName] = [];
|
||||||
await Promise.all(plannerQueue.map((item) =>
|
groups[it.courseName].push(it);
|
||||||
api('/api/schedule', {
|
}
|
||||||
|
|
||||||
|
$('#planner-item-list').innerHTML = Object.entries(groups).map(([course, items]) =>
|
||||||
|
`<div class="item-group-header">${esc(course)}</div>` +
|
||||||
|
items.map((it) =>
|
||||||
|
`<div class="item-list-item" tabindex="0" data-item-id="${it.id}">
|
||||||
|
<span>${TYPE_ICON[it.type] || '📄'}</span>
|
||||||
|
<span class="item-label">${esc(it.title)}</span>
|
||||||
|
<span class="item-course">${TYPE_LABEL[it.type] || ''}</span>
|
||||||
|
</div>`
|
||||||
|
).join('')
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
$('#planner-item-list').querySelectorAll('.item-list-item').forEach((el) => {
|
||||||
|
el.addEventListener('click', () => scheduleItem(Number(el.dataset.itemId), null, el));
|
||||||
|
el.addEventListener('keydown', (e) => { if (e.key === 'Enter') scheduleItem(Number(el.dataset.itemId), null, el); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scheduleItem(itemId, title, el) {
|
||||||
|
if (!plannerAddDate) return;
|
||||||
|
if (el) {
|
||||||
|
el.classList.add('added');
|
||||||
|
el.innerHTML = '<span>✓</span><span class="item-label">Added</span>';
|
||||||
|
el.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
|
await api('/api/schedule', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { studentId: plannerStudentId, date: item.date, itemId: item.itemId, title: item.title },
|
body: { studentId: plannerStudentId, date: plannerAddDate, itemId: itemId || null, title: title || null },
|
||||||
})
|
});
|
||||||
));
|
if (title) {
|
||||||
plannerQueue = [];
|
msg('Added to planner.');
|
||||||
updatePlannerSaveBtn();
|
$('#planner-offline-input').value = '';
|
||||||
msg(`${count} item${count === 1 ? '' : 's'} added to planner.`);
|
}
|
||||||
renderPlanner();
|
if (el) setTimeout(() => renderItemList($('#planner-item-search').value), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#planner-item-search').addEventListener('input', (e) => renderItemList(e.target.value));
|
||||||
|
|
||||||
|
$('#planner-modal-close').addEventListener('click', closePlannerAddModal);
|
||||||
|
$('#planner-add-modal').addEventListener('click', (e) => { if (e.target === $('#planner-add-modal')) closePlannerAddModal(); });
|
||||||
|
|
||||||
|
$('#planner-add-btn').addEventListener('click', () => openPlannerAddModal(plannerWeekStart || addDays(weekStartOf(new Date()), 0)));
|
||||||
|
|
||||||
|
$('#planner-offline-add').addEventListener('click', () => {
|
||||||
|
const title = $('#planner-offline-input').value.trim();
|
||||||
|
if (title) scheduleItem(null, title, null);
|
||||||
|
});
|
||||||
|
$('#planner-offline-input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') { const t = e.target.value.trim(); if (t) scheduleItem(null, t, null); }
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderPrintTaskBlock(t) {
|
function renderPrintTaskBlock(t) {
|
||||||
|
|||||||
@@ -1127,6 +1127,29 @@ body.bg-bubbles {
|
|||||||
#toast.toast-out { opacity: 0; transform: translateY(0.5rem); }
|
#toast.toast-out { opacity: 0; transform: translateY(0.5rem); }
|
||||||
.planner-pending { border: 2px dashed var(--accent) !important; background: color-mix(in srgb, var(--accent) 10%, var(--card)) !important; color: var(--accent) !important; opacity: 0.85; cursor: default; }
|
.planner-pending { border: 2px dashed var(--accent) !important; background: color-mix(in srgb, var(--accent) 10%, var(--card)) !important; color: var(--accent) !important; opacity: 0.85; cursor: default; }
|
||||||
|
|
||||||
|
/* ---------- planner add-task modal ---------- */
|
||||||
|
.planner-add-box { max-width: 620px; width: 95%; max-height: 88vh; display: flex; flex-direction: column; gap: 0.75rem; padding: 1.25rem; }
|
||||||
|
.planner-modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.25rem; }
|
||||||
|
.planner-modal-header h3 { margin: 0; font-size: 1.1rem; }
|
||||||
|
.day-tabs { display: flex; gap: 0.35rem; }
|
||||||
|
.day-tab { flex: 1; padding: 0.45rem 0.3rem; border: 2px solid #d8d2c0; border-radius: 8px; background: var(--bg); cursor: pointer; font-family: inherit; font-size: 0.8rem; text-align: center; line-height: 1.25; }
|
||||||
|
.day-tab span { display: block; font-weight: 700; }
|
||||||
|
.day-tab small { color: var(--muted); font-size: 0.7rem; }
|
||||||
|
.day-tab.active { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, var(--card)); color: var(--accent); }
|
||||||
|
.day-tab.active small { color: var(--accent); opacity: 0.75; }
|
||||||
|
.planner-item-search { width: 100%; box-sizing: border-box; padding: 0.55rem 0.75rem; font-size: 0.95rem; font-family: inherit; border: 2px solid #d8d2c0; border-radius: 10px; background: var(--bg); color: var(--ink); }
|
||||||
|
.planner-item-search:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.item-list { flex: 1; overflow-y: auto; border: 1.5px solid #e0ddd0; border-radius: 10px; min-height: 180px; max-height: 340px; }
|
||||||
|
.item-group-header { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); padding: 0.45rem 0.75rem 0.2rem; background: var(--bg); position: sticky; top: 0; }
|
||||||
|
.item-list-item { padding: 0.5rem 0.75rem; cursor: pointer; border-bottom: 1px solid #ede9dc; display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; }
|
||||||
|
.item-list-item:last-child { border-bottom: none; }
|
||||||
|
.item-list-item:hover, .item-list-item:focus { background: color-mix(in srgb, var(--accent) 10%, var(--card)); outline: none; }
|
||||||
|
.item-list-item.added { background: var(--good-bg); color: var(--good); cursor: default; }
|
||||||
|
.item-list-item .item-label { flex: 1; }
|
||||||
|
.item-list-item .item-course { font-size: 0.72rem; color: var(--muted); margin-left: auto; white-space: nowrap; }
|
||||||
|
.planner-offline-row { display: flex; gap: 0.5rem; padding-top: 0.75rem; border-top: 1.5px solid #e0ddd0; }
|
||||||
|
.planner-offline-row input { flex: 1; }
|
||||||
|
|
||||||
/* ---------- crossword ---------- */
|
/* ---------- crossword ---------- */
|
||||||
.cw-layout { display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: flex-start; margin-top: 1rem; }
|
.cw-layout { display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: flex-start; margin-top: 1rem; }
|
||||||
.cw-grid-wrap { overflow: auto; }
|
.cw-grid-wrap { overflow: auto; }
|
||||||
|
|||||||
Reference in new issue
Block a user