calendar editing + dnd

This commit is contained in:
Dinarassan 2026-05-22 14:18:31 +05:00
parent c5935aad12
commit bbddcca908

219
app.js
View File

@ -566,7 +566,13 @@ function activityVerbText(a) {
} }
/* ===== Calendar ===== */ /* ===== Calendar ===== */
function todayIso() { return new Date().toISOString().slice(0, 10); } function isoDateLocal(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function todayIso() { return isoDateLocal(new Date()); }
function renderCalendar(query) { function renderCalendar(query) {
const cursor = query.m ? new Date(query.m + '-01') : new Date(todayIso().slice(0, 7) + '-01'); const cursor = query.m ? new Date(query.m + '-01') : new Date(todayIso().slice(0, 7) + '-01');
@ -631,16 +637,16 @@ function renderCalendar(query) {
<div class="calendar-grid"> <div class="calendar-grid">
${dow.map((d) => `<div class="calendar-dow">${d}</div>`).join('')} ${dow.map((d) => `<div class="calendar-dow">${d}</div>`).join('')}
${days.map((d) => { ${days.map((d) => {
const ds = d.toISOString().slice(0, 10); const ds = isoDateLocal(d);
const otherMonth = d.getMonth() !== month; const otherMonth = d.getMonth() !== month;
const isToday = ds === today; const isToday = ds === today;
const events = eventsByDate[ds] || []; const events = eventsByDate[ds] || [];
return ` return `
<div class="calendar-day ${otherMonth ? 'other-month' : ''} ${isToday ? 'today' : ''}"> <div class="calendar-day ${otherMonth ? 'other-month' : ''} ${isToday ? 'today' : ''}" data-cal-date="${ds}">
<span class="calendar-day-num">${d.getDate()}</span> <span class="calendar-day-num">${d.getDate()}</span>
${events.map((ev) => ev.kind === 'meeting' ${events.map((ev) => ev.kind === 'meeting'
? `<span class="cal-event meeting" data-action="open-meeting" data-meeting-id="${ev.id}">${esc(ev.time)} ${esc(ev.title)}</span>` ? `<span class="cal-event meeting" draggable="true" data-action="open-meeting" data-meeting-id="${ev.id}" data-cal-kind="meeting" data-cal-id="${ev.id}">${esc(ev.time)} ${esc(ev.title)}</span>`
: `<span class="cal-event task" data-action="open-task" data-task-id="${ev.id}">${esc(ev.title)}</span>` : `<span class="cal-event task" draggable="true" data-action="open-task" data-task-id="${ev.id}" data-cal-kind="task" data-cal-id="${ev.id}">${esc(ev.title)}</span>`
).join('')} ).join('')}
</div>`; </div>`;
}).join('')} }).join('')}
@ -1019,28 +1025,75 @@ function openNewMeetingModal({ clientId, projectId } = {}) {
render(); render();
} }
/* ----- Modal: meeting detail ----- */ /* ----- Modal: meeting detail / edit ----- */
function openMeetingModal(meetingId) { function openMeetingModal(meetingId) {
const m = meetingById(meetingId); const m = meetingById(meetingId);
if (!m) return; if (!m) return;
const client = m.clientId ? clientById(m.clientId) : null; const dateOnly = m.startsAt.slice(0, 10);
const project = m.projectId ? projectById(m.projectId) : null; const startTime = fmtTime(m.startsAt);
const endTime = fmtTime(m.endsAt);
MODAL = { MODAL = {
title: m.title, title: 'Встреча',
body: ` body: `
<div class="muted" style="font-size:13px;margin-bottom:10px"> <form data-form="edit-meeting" data-meeting-id="${m.id}">
${fmtDate(m.startsAt, true)} – ${fmtTime(m.endsAt)} · ${esc(m.location || '—')} <div class="field">
</div> <label class="field-label">Заголовок</label>
<p style="margin:0 0 12px">${esc(m.description || '')}</p> <input class="input" name="title" value="${esc(m.title)}" required />
<div class="row" style="gap:6px;flex-wrap:wrap;margin-bottom:14px"> </div>
${client ? `<span class="chip" data-nav="#/clients/${client.id}" style="cursor:pointer">🏢 ${esc(client.name)}</span>` : ''} <div class="field">
${project ? `<span class="chip" data-nav="#/projects/${project.id}" style="cursor:pointer">📁 ${esc(project.name)}</span>` : ''} <label class="field-label">Описание</label>
</div> <textarea class="textarea" name="description" rows="2">${esc(m.description || '')}</textarea>
<div class="field-label">Участники (${m.attendeeIds.length})</div> </div>
<div class="row" style="gap:6px;flex-wrap:wrap"> <div class="row" style="gap:10px">
${m.attendeeIds.map((id) => `<span class="row" style="gap:4px">${avatar(id, 'sm')}<span style="font-size:12px">${esc(userById(id)?.name)}</span></span>`).join('')} <div class="field" style="flex:1">
</div>`, <label class="field-label">Клиент</label>
footer: `<button class="btn danger" data-action="delete-meeting" data-meeting-id="${m.id}">Удалить</button><div style="flex:1"></div><button class="btn" data-action="close-modal">Закрыть</button>`, <select class="select" name="clientId">
<option value="">— не выбран —</option>
${STATE.clients.map((c) => `<option value="${c.id}" ${c.id === m.clientId ? 'selected' : ''}>${esc(c.name)}</option>`).join('')}
</select>
</div>
<div class="field" style="flex:1">
<label class="field-label">Проект</label>
<select class="select" name="projectId">
<option value="">— не выбран —</option>
${STATE.projects.map((p) => `<option value="${p.id}" ${p.id === m.projectId ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
</select>
</div>
</div>
<div class="row" style="gap:10px">
<div class="field" style="flex:1">
<label class="field-label">Дата</label>
<input class="input" type="date" name="date" value="${esc(dateOnly)}" required />
</div>
<div class="field" style="flex:1">
<label class="field-label">Начало</label>
<input class="input" type="time" name="start" value="${esc(startTime)}" required />
</div>
<div class="field" style="flex:1">
<label class="field-label">Конец</label>
<input class="input" type="time" name="end" value="${esc(endTime)}" required />
</div>
</div>
<div class="field">
<label class="field-label">Локация / ссылка</label>
<input class="input" name="location" value="${esc(m.location || '')}" />
</div>
<div class="field">
<label class="field-label">Участники</label>
<div class="row" style="gap:10px;flex-wrap:wrap">
${STATE.users.map((u) => `
<label class="row" style="gap:6px;font-size:13px">
<input type="checkbox" name="attendees" value="${u.id}" ${m.attendeeIds.includes(u.id) ? 'checked' : ''} /> ${esc(u.name)}
</label>`).join('')}
</div>
</div>
</form>`,
footer: `
<button class="btn danger" data-action="delete-meeting" data-meeting-id="${m.id}">Удалить</button>
<div style="flex:1"></div>
<button class="btn" data-action="close-modal">Отмена</button>
<button class="btn primary" data-action="submit-form" data-target-form="edit-meeting">Сохранить</button>
`,
}; };
render(); render();
} }
@ -1167,8 +1220,18 @@ function bindDelegatedListeners() {
handleFormSubmit(f); handleFormSubmit(f);
}); });
// Kanban DnD — delegated on document so it survives re-renders. // Kanban + Calendar DnD — delegated on document so it survives re-renders.
document.addEventListener('dragstart', (e) => { document.addEventListener('dragstart', (e) => {
const calEv = e.target.closest('.cal-event[draggable="true"]');
if (calEv) {
calEv.classList.add('dragging');
e.dataTransfer.setData('application/clientos-cal', JSON.stringify({
kind: calEv.dataset.calKind,
id: calEv.dataset.calId,
}));
e.dataTransfer.effectAllowed = 'move';
return;
}
const card = e.target.closest('.task-card[draggable="true"]'); const card = e.target.closest('.task-card[draggable="true"]');
if (!card) return; if (!card) return;
card.classList.add('dragging'); card.classList.add('dragging');
@ -1176,20 +1239,33 @@ function bindDelegatedListeners() {
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
}); });
document.addEventListener('dragend', (e) => { document.addEventListener('dragend', (e) => {
const card = e.target.closest('.task-card[draggable="true"]'); const el = e.target.closest('.cal-event[draggable="true"], .task-card[draggable="true"]');
if (card) card.classList.remove('dragging'); if (el) el.classList.remove('dragging');
}); });
document.addEventListener('dragover', (e) => { document.addEventListener('dragover', (e) => {
const day = e.target.closest('.calendar-day[data-cal-date]');
if (day) { e.preventDefault(); day.classList.add('drag-over'); return; }
const col = e.target.closest('.kanban-column'); const col = e.target.closest('.kanban-column');
if (!col) return; if (col) { e.preventDefault(); col.classList.add('drag-over'); }
e.preventDefault();
col.classList.add('drag-over');
}); });
document.addEventListener('dragleave', (e) => { document.addEventListener('dragleave', (e) => {
const day = e.target.closest('.calendar-day[data-cal-date]');
if (day && !day.contains(e.relatedTarget)) day.classList.remove('drag-over');
const col = e.target.closest('.kanban-column'); const col = e.target.closest('.kanban-column');
if (col && !col.contains(e.relatedTarget)) col.classList.remove('drag-over'); if (col && !col.contains(e.relatedTarget)) col.classList.remove('drag-over');
}); });
document.addEventListener('drop', (e) => { document.addEventListener('drop', (e) => {
const day = e.target.closest('.calendar-day[data-cal-date]');
if (day) {
e.preventDefault();
day.classList.remove('drag-over');
const newDate = day.dataset.calDate;
const raw = e.dataTransfer.getData('application/clientos-cal');
if (!raw) return;
let payload; try { payload = JSON.parse(raw); } catch { return; }
handleCalendarDrop(payload.kind, payload.id, newDate);
return;
}
const col = e.target.closest('.kanban-column'); const col = e.target.closest('.kanban-column');
if (!col) return; if (!col) return;
e.preventDefault(); e.preventDefault();
@ -1216,6 +1292,45 @@ function bindDelegatedListeners() {
}); });
}); });
} }
function handleCalendarDrop(kind, id, newDate) {
if (kind === 'task') {
const t = taskById(id);
if (!t || t.dueDate === newDate) return;
apply((s) => {
const task = s.tasks.find((x) => x.id === id);
if (task) task.dueDate = newDate;
});
return;
}
if (kind === 'meeting') {
const m = meetingById(id);
if (!m) return;
const oldStart = new Date(m.startsAt);
const oldEnd = new Date(m.endsAt);
const durationMs = oldEnd - oldStart;
// Build new starts on the new date keeping same local time (+05:00 timezone)
const [y, mo, d] = newDate.split('-').map(Number);
const newStart = new Date(oldStart);
newStart.setFullYear(y, mo - 1, d);
if (m.startsAt === newStart.toISOString()) return;
const newEnd = new Date(newStart.getTime() + durationMs);
apply((s) => {
const meeting = s.meetings.find((x) => x.id === id);
if (!meeting) return;
meeting.startsAt = newStart.toISOString();
meeting.endsAt = newEnd.toISOString();
const scope = meeting.projectId ? { scopeType: 'project', scopeId: meeting.projectId } :
meeting.clientId ? { scopeType: 'client', scopeId: meeting.clientId } : null;
if (scope) {
s.activity.unshift({
id: uid('a'), actorId: s.currentUserId, ...scope, verb: 'updated',
payload: { meetingId: id, title: meeting.title }, createdAt: new Date().toISOString(),
});
}
});
}
}
function closeDropdownOnce(e) { function closeDropdownOnce(e) {
if (e.target.closest('#notifications-dropdown') || e.target.closest('[data-action="toggle-notifications"]')) { if (e.target.closest('#notifications-dropdown') || e.target.closest('[data-action="toggle-notifications"]')) {
document.addEventListener('click', closeDropdownOnce, { once: true }); document.addEventListener('click', closeDropdownOnce, { once: true });
@ -1378,6 +1493,54 @@ function handleFormSubmit(form) {
openTaskModal(taskId); openTaskModal(taskId);
break; break;
} }
case 'edit-meeting': {
const meetingId = form.dataset.meetingId;
const startsAt = new Date(data.date + 'T' + data.start + ':00+05:00').toISOString();
const endsAt = new Date(data.date + 'T' + data.end + ':00+05:00').toISOString();
apply((s) => {
const m = s.meetings.find((x) => x.id === meetingId);
if (!m) return;
const moved = m.startsAt !== startsAt || m.endsAt !== endsAt;
const oldAttendees = m.attendeeIds.slice();
Object.assign(m, {
title: data.title.trim(),
description: (data.description || '').trim(),
clientId: data.clientId || null,
projectId: data.projectId || null,
startsAt, endsAt,
location: (data.location || '').trim(),
attendeeIds: attendees.length ? attendees : [s.currentUserId],
});
const scope = m.projectId ? { scopeType: 'project', scopeId: m.projectId } :
m.clientId ? { scopeType: 'client', scopeId: m.clientId } : null;
if (scope && moved) {
s.activity.unshift({
id: uid('a'), actorId: s.currentUserId, ...scope, verb: 'updated',
payload: { meetingId, title: m.title }, createdAt: new Date().toISOString(),
});
}
// notify newly added attendees
m.attendeeIds.forEach((uid2) => {
if (uid2 === s.currentUserId || oldAttendees.includes(uid2)) return;
s.notifications.push({
id: uid('n'), userId: uid2, type: 'meeting_invitation',
payload: { meetingId, title: m.title }, readAt: null, createdAt: new Date().toISOString(),
});
});
// notify all attendees when meeting moved
if (moved) {
m.attendeeIds.forEach((uid2) => {
if (uid2 === s.currentUserId) return;
s.notifications.push({
id: uid('n'), userId: uid2, type: 'meeting_updated',
payload: { meetingId, title: m.title }, readAt: null, createdAt: new Date().toISOString(),
});
});
}
});
closeModal();
break;
}
case 'create-meeting': { case 'create-meeting': {
const id = uid('m'); const id = uid('m');
const startsAt = new Date(data.date + 'T' + data.start + ':00+05:00').toISOString(); const startsAt = new Date(data.date + 'T' + data.start + ':00+05:00').toISOString();