Add progress updates per task
Each task gets an "updates" timeline. Tap "💬 Прогресс" in the
table row to open a wide modal that shows past entries
(author + timestamp + text) and a textarea for a new one.
Only the assignee or Абделазиз can add entries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8652214f32
commit
a20600138e
70
app.js
70
app.js
@ -289,6 +289,7 @@ function renderTable(tasks) {
|
|||||||
<td class="actions-cell">
|
<td class="actions-cell">
|
||||||
${st !== 'done' && t.status === 'new' ? `<button class="btn btn-sm btn-primary" data-act="start" data-id="${t.id}">▶ В работу</button>` : ''}
|
${st !== 'done' && t.status === 'new' ? `<button class="btn btn-sm btn-primary" data-act="start" data-id="${t.id}">▶ В работу</button>` : ''}
|
||||||
${st !== 'done' ? `<button class="btn btn-sm btn-success" data-act="done" data-id="${t.id}">✓ Готово</button>` : `<button class="btn btn-sm btn-ghost" data-act="reopen" data-id="${t.id}">↺ Вернуть</button>`}
|
${st !== 'done' ? `<button class="btn btn-sm btn-success" data-act="done" data-id="${t.id}">✓ Готово</button>` : `<button class="btn btn-sm btn-ghost" data-act="reopen" data-id="${t.id}">↺ Вернуть</button>`}
|
||||||
|
<button class="btn btn-sm btn-ghost" data-act="progress" data-id="${t.id}" title="Промежуточный результат">💬 Прогресс${(t.updates && t.updates.length) ? ` <span class="upd-count">${t.updates.length}</span>` : ''}</button>
|
||||||
<button class="btn btn-sm btn-icon btn-danger" data-act="delete" data-id="${t.id}" title="Удалить">✕</button>
|
<button class="btn btn-sm btn-icon btn-danger" data-act="delete" data-id="${t.id}" title="Удалить">✕</button>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
@ -921,6 +922,70 @@ function initAuth() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let progressTaskId = null;
|
||||||
|
|
||||||
|
function canEditProgress(task) {
|
||||||
|
return isDirector() || task.assignee === state.currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openProgress(taskId) {
|
||||||
|
const task = getTasks().find(x => x.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
progressTaskId = taskId;
|
||||||
|
$('#progressTaskTitle').textContent = task.title;
|
||||||
|
$('#progressTaskMeta').textContent = `${task.assignee || '—'} · дедлайн ${task.deadline || '—'}`;
|
||||||
|
renderProgressList(task);
|
||||||
|
const form = $('#progressForm');
|
||||||
|
const ta = $('#progressInput');
|
||||||
|
ta.value = '';
|
||||||
|
const allowed = canEditProgress(task);
|
||||||
|
form.hidden = !allowed;
|
||||||
|
$('#progressLocked').hidden = allowed;
|
||||||
|
$('#progressModal').hidden = false;
|
||||||
|
if (allowed) setTimeout(() => ta.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeProgress() {
|
||||||
|
$('#progressModal').hidden = true;
|
||||||
|
progressTaskId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProgressList(task) {
|
||||||
|
const list = $('#progressList');
|
||||||
|
const updates = (task.updates || []).slice().reverse();
|
||||||
|
if (!updates.length) {
|
||||||
|
list.innerHTML = '<li class="progress-empty">Промежуточных результатов пока нет.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = updates.map(u => {
|
||||||
|
const dt = new Date(u.at);
|
||||||
|
const when = dt.toLocaleString('ru-RU', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||||
|
return `
|
||||||
|
<li class="progress-item">
|
||||||
|
<div class="progress-meta"><strong>${escapeHtml(u.by || '—')}</strong> · ${when}</div>
|
||||||
|
<div class="progress-text">${escapeHtml(u.text)}</div>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitProgress(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (progressTaskId == null) return;
|
||||||
|
const text = $('#progressInput').value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
const tasks = getTasks();
|
||||||
|
const t = tasks.find(x => x.id === progressTaskId);
|
||||||
|
if (!t || !canEditProgress(t)) return;
|
||||||
|
t.updates = t.updates || [];
|
||||||
|
t.updates.push({ by: state.currentUser, at: new Date().toISOString(), text });
|
||||||
|
t.updated_at = new Date().toISOString();
|
||||||
|
save(tasks);
|
||||||
|
$('#progressInput').value = '';
|
||||||
|
renderProgressList(t);
|
||||||
|
renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
function openProfile() {
|
function openProfile() {
|
||||||
$('#profileName').textContent = state.currentUser || '—';
|
$('#profileName').textContent = state.currentUser || '—';
|
||||||
$('#profileRole').textContent = isDirector() ? 'Директор' : 'Сотрудник';
|
$('#profileRole').textContent = isDirector() ? 'Директор' : 'Сотрудник';
|
||||||
@ -1093,6 +1158,7 @@ function init() {
|
|||||||
const tasks = getTasks();
|
const tasks = getTasks();
|
||||||
const t = tasks.find((x) => x.id === id);
|
const t = tasks.find((x) => x.id === id);
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
|
if (act === 'progress') { openProgress(t.id); return; }
|
||||||
if (act === 'start') t.status = 'in_progress';
|
if (act === 'start') t.status = 'in_progress';
|
||||||
if (act === 'done') t.status = 'done';
|
if (act === 'done') t.status = 'done';
|
||||||
if (act === 'reopen') t.status = 'in_progress';
|
if (act === 'reopen') t.status = 'in_progress';
|
||||||
@ -1106,6 +1172,10 @@ function init() {
|
|||||||
renderAll();
|
renderAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#progressClose').addEventListener('click', closeProgress);
|
||||||
|
$('#progressBackdrop').addEventListener('click', closeProgress);
|
||||||
|
$('#progressForm').addEventListener('submit', submitProgress);
|
||||||
|
|
||||||
$('#tasksBody').addEventListener('change', (e) => {
|
$('#tasksBody').addEventListener('change', (e) => {
|
||||||
const sel = e.target.closest('select.prio-select');
|
const sel = e.target.closest('select.prio-select');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
|
|||||||
26
index.html
26
index.html
@ -11,7 +11,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
|
||||||
<link rel="stylesheet" href="styles.css?v=33" />
|
<link rel="stylesheet" href="styles.css?v=34" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@ -68,6 +68,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Промежуточные результаты -->
|
||||||
|
<div id="progressModal" class="modal" hidden>
|
||||||
|
<div class="modal-backdrop" id="progressBackdrop"></div>
|
||||||
|
<div class="modal-card modal-wide" role="dialog" aria-modal="true" aria-labelledby="progressTaskTitle">
|
||||||
|
<button id="progressClose" class="modal-close" type="button" aria-label="Закрыть">×</button>
|
||||||
|
<h2 id="progressTaskTitle" class="modal-title">—</h2>
|
||||||
|
<div id="progressTaskMeta" class="progress-task-meta">—</div>
|
||||||
|
|
||||||
|
<h3 class="modal-sub">История</h3>
|
||||||
|
<ul id="progressList" class="progress-list">
|
||||||
|
<li class="progress-empty">Промежуточных результатов пока нет.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 class="modal-sub">Добавить результат</h3>
|
||||||
|
<form id="progressForm" class="progress-form">
|
||||||
|
<textarea id="progressInput" rows="3" placeholder="Что сделано? Что осталось? Риски, блокеры..." required></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary">+ Добавить</button>
|
||||||
|
</form>
|
||||||
|
<p id="progressLocked" class="hint" hidden>Только исполнитель или Абделазиз может добавлять записи.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Профиль / смена пароля -->
|
<!-- Профиль / смена пароля -->
|
||||||
<div id="profileModal" class="modal" hidden>
|
<div id="profileModal" class="modal" hidden>
|
||||||
<div class="modal-backdrop" id="profileBackdrop"></div>
|
<div class="modal-backdrop" id="profileBackdrop"></div>
|
||||||
@ -293,6 +315,6 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/ru.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/ru.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
<script src="app.js?v=30"></script>
|
<script src="app.js?v=31"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
76
styles.css
76
styles.css
@ -382,6 +382,82 @@ body:not([data-role]) .employee-only { display: none !important; }
|
|||||||
}
|
}
|
||||||
.profile-name { font-size: 16px; font-weight: 600; color: var(--ink); }
|
.profile-name { font-size: 16px; font-weight: 600; color: var(--ink); }
|
||||||
.profile-role { font-size: 12px; color: var(--muted); padding: 4px 10px; background: var(--surface); border-radius: var(--r-pill); }
|
.profile-role { font-size: 12px; color: var(--muted); padding: 4px 10px; background: var(--surface); border-radius: var(--r-pill); }
|
||||||
|
|
||||||
|
/* ---------- Progress modal ---------- */
|
||||||
|
.modal-wide { max-width: 560px; }
|
||||||
|
.progress-task-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: -10px 0 18px 0;
|
||||||
|
}
|
||||||
|
.progress-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.progress-empty {
|
||||||
|
padding: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.progress-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
.progress-item:first-child { border-top: none; }
|
||||||
|
.progress-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.progress-meta strong { color: var(--ink); font-weight: 600; }
|
||||||
|
.progress-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.progress-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.progress-form textarea {
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
outline: none;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.progress-form textarea:focus {
|
||||||
|
border-color: var(--kt-blue);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0,113,227,0.15);
|
||||||
|
}
|
||||||
|
.progress-form .btn-primary { align-self: flex-start; padding: 10px 18px; font-size: 14px; }
|
||||||
|
.upd-count {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--kt-blue);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
margin-left: 4px;
|
||||||
|
vertical-align: 1px;
|
||||||
|
}
|
||||||
.who label { color: var(--muted); font-size: 12px; font-weight: 400; }
|
.who label { color: var(--muted); font-size: 12px; font-weight: 400; }
|
||||||
.who select {
|
.who select {
|
||||||
padding: 6px 28px 6px 12px;
|
padding: 6px 28px 6px 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user