Compare commits
11 Commits
main
...
gitea-page
| Author | SHA1 | Date | |
|---|---|---|---|
| 19c11b6f24 | |||
| 972d2be0d0 | |||
| 276d188637 | |||
| 0addbff85e | |||
| bbddcca908 | |||
| c5935aad12 | |||
| 5234188b73 | |||
| 12544af64b | |||
| cf6f634ac3 | |||
| 6a44a81ee5 | |||
| a0c0f23869 |
255
app.js
255
app.js
@ -27,6 +27,7 @@ const CLIENT_STATUSES = ['active', 'on_hold', 'archived'];
|
||||
let STATE = loadState() ?? clone(window.SEED);
|
||||
let MODAL = null; // { title, body, footer } object
|
||||
let DROPDOWN = null; // 'notifications' | null
|
||||
let SIDEBAR_OPEN = false; // mobile drawer state
|
||||
|
||||
function clone(o) { return JSON.parse(JSON.stringify(o)); }
|
||||
function loadState() {
|
||||
@ -151,7 +152,7 @@ function parseHash() {
|
||||
const query = Object.fromEntries(new URLSearchParams(queryStr || ''));
|
||||
return { path: '/' + segments.join('/'), segments, query };
|
||||
}
|
||||
function navigate(hash) { location.hash = hash; }
|
||||
function navigate(hash) { SIDEBAR_OPEN = false; location.hash = hash; }
|
||||
window.addEventListener('hashchange', render);
|
||||
window.addEventListener('DOMContentLoaded', render);
|
||||
|
||||
@ -165,7 +166,12 @@ function render() {
|
||||
}
|
||||
const route = parseHash();
|
||||
root.innerHTML =
|
||||
`<div class="shell">${renderSidebar(route)}${renderTopbar(route)}<main class="main">${renderMain(route)}</main></div>` +
|
||||
`<div class="shell ${SIDEBAR_OPEN ? 'sidebar-open' : ''}">` +
|
||||
renderSidebar(route) +
|
||||
renderTopbar(route) +
|
||||
`<main class="main">${renderMain(route)}</main>` +
|
||||
(SIDEBAR_OPEN ? '<div class="sidebar-backdrop" data-action="close-sidebar"></div>' : '') +
|
||||
`</div>` +
|
||||
(MODAL ? renderModalEl() : '');
|
||||
attachGlobalListeners();
|
||||
}
|
||||
@ -175,6 +181,7 @@ function renderLogin() {
|
||||
return `
|
||||
<div class="login-shell">
|
||||
<div class="login-card">
|
||||
<div class="login-card-brand">C</div>
|
||||
<h1>ClientOS</h1>
|
||||
<p>Войдите как один из членов команды (демо).</p>
|
||||
${STATE.users.map((u) => `
|
||||
@ -186,7 +193,7 @@ function renderLogin() {
|
||||
</div>
|
||||
</button>
|
||||
`).join('')}
|
||||
<p style="margin-top:20px;font-size:11px;">Все данные хранятся локально в браузере (localStorage).</p>
|
||||
<p style="margin-top:24px;font-size:11.5px;">Данные хранятся локально в браузере (localStorage).</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@ -242,6 +249,7 @@ function renderTopbar(route) {
|
||||
const unread = unreadNotifications().length;
|
||||
return `
|
||||
<header class="topbar">
|
||||
<button class="icon-button mobile-menu-btn" data-action="toggle-sidebar" aria-label="Меню">☰</button>
|
||||
<form class="topbar-search" data-form="search">
|
||||
<input type="search" name="q" placeholder="Глобальный поиск…" value="${esc(q)}" />
|
||||
</form>
|
||||
@ -281,30 +289,34 @@ function renderDashboard() {
|
||||
const activeClients = STATE.clients.filter((c) => c.status === 'active');
|
||||
|
||||
return `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Привет, ${esc(me().name.split(' ')[0])}!</h1>
|
||||
<p class="page-subtitle">Сегодня ${fmtDate(new Date().toISOString())}. У вас ${myTasks.length} открытых задач и ${upcomingMeetings.length} ближайших встреч.</p>
|
||||
<div class="hero">
|
||||
<h1>Привет, ${esc(me().name.split(' ')[0])} 👋</h1>
|
||||
<p>Сегодня ${fmtDate(new Date().toISOString())}. Ниже — открытые задачи, ближайшие встречи и активные клиенты вашей команды.</p>
|
||||
<div class="hero-stats">
|
||||
<div class="hero-stat"><div class="num">${myTasks.length}</div><div class="lbl">Открытые задачи</div></div>
|
||||
<div class="hero-stat"><div class="num">${upcomingMeetings.length}</div><div class="lbl">Ближайшие встречи</div></div>
|
||||
<div class="hero-stat"><div class="num">${activeClients.length}</div><div class="lbl">Активные клиенты</div></div>
|
||||
<div class="hero-stat"><div class="num">${unreadNotifications().length}</div><div class="lbl">Непрочитанные</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stack-md">
|
||||
<section>
|
||||
<h2 style="font-size:14px;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.04em;color:var(--text-muted)">Мои задачи</h2>
|
||||
<h2 class="section-title">Мои задачи</h2>
|
||||
${myTasks.length === 0
|
||||
? '<div class="empty">Все задачи закрыты 🎉</div>'
|
||||
: myTasks.slice(0, 8).map(renderTaskRow).join('')}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 style="font-size:14px;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.04em;color:var(--text-muted)">Ближайшие встречи</h2>
|
||||
<h2 class="section-title">Ближайшие встречи</h2>
|
||||
${upcomingMeetings.length === 0
|
||||
? '<div class="empty">Встреч нет.</div>'
|
||||
: upcomingMeetings.map(renderMeetingRow).join('')}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 style="font-size:14px;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.04em;color:var(--text-muted)">Активные клиенты</h2>
|
||||
<h2 class="section-title">Активные клиенты</h2>
|
||||
<div class="grid-cards">
|
||||
${activeClients.map((c) => `
|
||||
<div class="card" style="padding:14px;cursor:pointer" data-nav="#/clients/${c.id}">
|
||||
@ -561,7 +573,13 @@ function activityVerbText(a) {
|
||||
}
|
||||
|
||||
/* ===== 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) {
|
||||
const cursor = query.m ? new Date(query.m + '-01') : new Date(todayIso().slice(0, 7) + '-01');
|
||||
@ -626,16 +644,16 @@ function renderCalendar(query) {
|
||||
<div class="calendar-grid">
|
||||
${dow.map((d) => `<div class="calendar-dow">${d}</div>`).join('')}
|
||||
${days.map((d) => {
|
||||
const ds = d.toISOString().slice(0, 10);
|
||||
const ds = isoDateLocal(d);
|
||||
const otherMonth = d.getMonth() !== month;
|
||||
const isToday = ds === today;
|
||||
const events = eventsByDate[ds] || [];
|
||||
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>
|
||||
${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 task" data-action="open-task" data-task-id="${ev.id}">${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" 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('')}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
@ -1014,28 +1032,75 @@ function openNewMeetingModal({ clientId, projectId } = {}) {
|
||||
render();
|
||||
}
|
||||
|
||||
/* ----- Modal: meeting detail ----- */
|
||||
/* ----- Modal: meeting detail / edit ----- */
|
||||
function openMeetingModal(meetingId) {
|
||||
const m = meetingById(meetingId);
|
||||
if (!m) return;
|
||||
const client = m.clientId ? clientById(m.clientId) : null;
|
||||
const project = m.projectId ? projectById(m.projectId) : null;
|
||||
const dateOnly = m.startsAt.slice(0, 10);
|
||||
const startTime = fmtTime(m.startsAt);
|
||||
const endTime = fmtTime(m.endsAt);
|
||||
MODAL = {
|
||||
title: m.title,
|
||||
title: 'Встреча',
|
||||
body: `
|
||||
<div class="muted" style="font-size:13px;margin-bottom:10px">
|
||||
${fmtDate(m.startsAt, true)} – ${fmtTime(m.endsAt)} · ${esc(m.location || '—')}
|
||||
<form data-form="edit-meeting" data-meeting-id="${m.id}">
|
||||
<div class="field">
|
||||
<label class="field-label">Заголовок</label>
|
||||
<input class="input" name="title" value="${esc(m.title)}" required />
|
||||
</div>
|
||||
<p style="margin:0 0 12px">${esc(m.description || '')}</p>
|
||||
<div class="row" style="gap:6px;flex-wrap:wrap;margin-bottom:14px">
|
||||
${client ? `<span class="chip" data-nav="#/clients/${client.id}" style="cursor:pointer">🏢 ${esc(client.name)}</span>` : ''}
|
||||
${project ? `<span class="chip" data-nav="#/projects/${project.id}" style="cursor:pointer">📁 ${esc(project.name)}</span>` : ''}
|
||||
<div class="field">
|
||||
<label class="field-label">Описание</label>
|
||||
<textarea class="textarea" name="description" rows="2">${esc(m.description || '')}</textarea>
|
||||
</div>
|
||||
<div class="field-label">Участники (${m.attendeeIds.length})</div>
|
||||
<div class="row" style="gap:6px;flex-wrap:wrap">
|
||||
${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>`,
|
||||
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>`,
|
||||
<div class="row" style="gap:10px">
|
||||
<div class="field" style="flex:1">
|
||||
<label class="field-label">Клиент</label>
|
||||
<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();
|
||||
}
|
||||
@ -1084,6 +1149,14 @@ function bindDelegatedListeners() {
|
||||
apply((s) => { s.currentUserId = el.dataset.userId; });
|
||||
location.hash = '#/';
|
||||
break;
|
||||
case 'toggle-sidebar':
|
||||
SIDEBAR_OPEN = !SIDEBAR_OPEN;
|
||||
render();
|
||||
break;
|
||||
case 'close-sidebar':
|
||||
SIDEBAR_OPEN = false;
|
||||
render();
|
||||
break;
|
||||
case 'logout':
|
||||
apply((s) => { s.currentUserId = null; });
|
||||
location.hash = '';
|
||||
@ -1162,8 +1235,18 @@ function bindDelegatedListeners() {
|
||||
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) => {
|
||||
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"]');
|
||||
if (!card) return;
|
||||
card.classList.add('dragging');
|
||||
@ -1171,20 +1254,33 @@ function bindDelegatedListeners() {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
document.addEventListener('dragend', (e) => {
|
||||
const card = e.target.closest('.task-card[draggable="true"]');
|
||||
if (card) card.classList.remove('dragging');
|
||||
const el = e.target.closest('.cal-event[draggable="true"], .task-card[draggable="true"]');
|
||||
if (el) el.classList.remove('dragging');
|
||||
});
|
||||
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');
|
||||
if (!col) return;
|
||||
e.preventDefault();
|
||||
col.classList.add('drag-over');
|
||||
if (col) { e.preventDefault(); col.classList.add('drag-over'); }
|
||||
});
|
||||
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');
|
||||
if (col && !col.contains(e.relatedTarget)) col.classList.remove('drag-over');
|
||||
});
|
||||
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');
|
||||
if (!col) return;
|
||||
e.preventDefault();
|
||||
@ -1211,6 +1307,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) {
|
||||
if (e.target.closest('#notifications-dropdown') || e.target.closest('[data-action="toggle-notifications"]')) {
|
||||
document.addEventListener('click', closeDropdownOnce, { once: true });
|
||||
@ -1373,6 +1508,54 @@ function handleFormSubmit(form) {
|
||||
openTaskModal(taskId);
|
||||
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': {
|
||||
const id = uid('m');
|
||||
const startsAt = new Date(data.date + 'T' + data.start + ':00+05:00').toISOString();
|
||||
|
||||
1334
index.html
1334
index.html
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user