Restrict delete to Абделазиз, move done tasks to archive
- The delete button is now director-only via CSS + JS guard. - The main tasks table only shows active tasks (not done). - A collapsible "Архив" section lists completed tasks (employees see their own; Абделазиз sees everyone's). Each archived row has "↺ Вернуть" and progress access; delete is director-only. - Department charts are now director-only too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2933fe6ccf
commit
32378c342c
52
app.js
52
app.js
@ -249,7 +249,7 @@ function renderTable(tasks) {
|
|||||||
body.innerHTML = '';
|
body.innerHTML = '';
|
||||||
|
|
||||||
const f = state.filters;
|
const f = state.filters;
|
||||||
let visible = tasks.slice();
|
let visible = tasks.filter((t) => effectiveStatus(t) !== 'done');
|
||||||
|
|
||||||
// Сотрудник видит только свои по умолчанию (если фильтр не задан явно).
|
// Сотрудник видит только свои по умолчанию (если фильтр не задан явно).
|
||||||
if (!isDirector() && !f.assignee) {
|
if (!isDirector() && !f.assignee) {
|
||||||
@ -258,7 +258,7 @@ function renderTable(tasks) {
|
|||||||
if (f.assignee) visible = visible.filter((t) => t.assignee === f.assignee);
|
if (f.assignee) visible = visible.filter((t) => t.assignee === f.assignee);
|
||||||
if (f.direction) visible = visible.filter((t) => t.direction === f.direction);
|
if (f.direction) visible = visible.filter((t) => t.direction === f.direction);
|
||||||
if (f.priority) visible = visible.filter((t) => t.priority === f.priority);
|
if (f.priority) visible = visible.filter((t) => t.priority === f.priority);
|
||||||
if (f.status) visible = visible.filter((t) => effectiveStatus(t) === f.status);
|
if (f.status && f.status !== 'done') visible = visible.filter((t) => effectiveStatus(t) === f.status);
|
||||||
if (f.deadline) visible = visible.filter((t) => t.deadline === f.deadline);
|
if (f.deadline) visible = visible.filter((t) => t.deadline === f.deadline);
|
||||||
|
|
||||||
visible.sort((a, b) => {
|
visible.sort((a, b) => {
|
||||||
@ -290,7 +290,7 @@ function renderTable(tasks) {
|
|||||||
${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-icon 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-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 director-only" data-act="delete" data-id="${t.id}" title="Удалить">✕</button>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
body.appendChild(tr);
|
body.appendChild(tr);
|
||||||
@ -306,10 +306,40 @@ function renderAll() {
|
|||||||
if (state.view === 'analytics') {
|
if (state.view === 'analytics') {
|
||||||
renderDashboard(tasks);
|
renderDashboard(tasks);
|
||||||
renderTable(tasks);
|
renderTable(tasks);
|
||||||
|
renderArchive(tasks);
|
||||||
renderWorkload(tasks);
|
renderWorkload(tasks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderArchive(tasks) {
|
||||||
|
const body = $('#archiveBody');
|
||||||
|
const empty = $('#archiveEmpty');
|
||||||
|
const countEl = $('#archiveCount');
|
||||||
|
if (!body) return;
|
||||||
|
let archived = tasks.filter((t) => effectiveStatus(t) === 'done');
|
||||||
|
if (!isDirector()) archived = archived.filter((t) => t.assignee === state.currentUser);
|
||||||
|
archived.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
|
||||||
|
countEl.textContent = archived.length;
|
||||||
|
empty.hidden = archived.length > 0;
|
||||||
|
body.innerHTML = archived.map((t) => {
|
||||||
|
const completed = t.updated_at ? new Date(t.updated_at).toLocaleDateString('ru-RU', { day: '2-digit', month: 'short', year: 'numeric' }) : '—';
|
||||||
|
return `
|
||||||
|
<tr class="archived">
|
||||||
|
<td>${escapeHtml(t.title)}</td>
|
||||||
|
<td>${escapeHtml(t.assignee || '')}</td>
|
||||||
|
<td>${escapeHtml(t.direction || '')}</td>
|
||||||
|
<td>${t.deadline || ''}</td>
|
||||||
|
<td>${completed}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<button class="btn btn-sm btn-ghost" data-act="reopen" data-id="${t.id}">↺ Вернуть</button>
|
||||||
|
<button class="btn btn-sm btn-icon 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 director-only" data-act="delete" data-id="${t.id}" title="Удалить">✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- workload view (графики) ----------
|
// ---------- workload view (графики) ----------
|
||||||
|
|
||||||
const charts = {};
|
const charts = {};
|
||||||
@ -1149,8 +1179,8 @@ function init() {
|
|||||||
renderAll();
|
renderAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Делегирование действий в таблице
|
// Делегирование действий в таблицах (активные + архив)
|
||||||
$('#tasksBody').addEventListener('click', (e) => {
|
const onActionClick = (e) => {
|
||||||
const btn = e.target.closest('button[data-act]');
|
const btn = e.target.closest('button[data-act]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const id = Number(btn.dataset.id);
|
const id = Number(btn.dataset.id);
|
||||||
@ -1163,6 +1193,7 @@ function init() {
|
|||||||
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';
|
||||||
if (act === 'delete') {
|
if (act === 'delete') {
|
||||||
|
if (!isDirector()) return;
|
||||||
if (!confirm('Удалить поручение?')) return;
|
if (!confirm('Удалить поручение?')) return;
|
||||||
const idx = tasks.indexOf(t);
|
const idx = tasks.indexOf(t);
|
||||||
tasks.splice(idx, 1);
|
tasks.splice(idx, 1);
|
||||||
@ -1170,6 +1201,17 @@ function init() {
|
|||||||
t && (t.updated_at = new Date().toISOString());
|
t && (t.updated_at = new Date().toISOString());
|
||||||
save(tasks);
|
save(tasks);
|
||||||
renderAll();
|
renderAll();
|
||||||
|
};
|
||||||
|
$('#tasksBody').addEventListener('click', onActionClick);
|
||||||
|
$('#archiveBody').addEventListener('click', onActionClick);
|
||||||
|
|
||||||
|
$('#archiveToggle').addEventListener('click', () => {
|
||||||
|
const box = $('#archiveBox');
|
||||||
|
const btn = $('#archiveToggle');
|
||||||
|
const isHidden = box.hidden;
|
||||||
|
box.hidden = !isHidden;
|
||||||
|
btn.textContent = isHidden ? 'Скрыть' : 'Показать';
|
||||||
|
btn.setAttribute('aria-expanded', String(isHidden));
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#progressClose').addEventListener('click', closeProgress);
|
$('#progressClose').addEventListener('click', closeProgress);
|
||||||
|
|||||||
32
index.html
32
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=35" />
|
<link rel="stylesheet" href="styles.css?v=36" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@ -251,7 +251,6 @@
|
|||||||
<option value="">Все статусы</option>
|
<option value="">Все статусы</option>
|
||||||
<option value="new">Новая</option>
|
<option value="new">Новая</option>
|
||||||
<option value="in_progress">В работе</option>
|
<option value="in_progress">В работе</option>
|
||||||
<option value="done">Выполнена</option>
|
|
||||||
<option value="overdue">Просрочена</option>
|
<option value="overdue">Просрочена</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="fDirection"><option value="">Все направления</option></select>
|
<select id="fDirection"><option value="">Все направления</option></select>
|
||||||
@ -282,16 +281,39 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
<div class="archive-head">
|
||||||
|
<h2>Архив <span class="archive-count" id="archiveCount">0</span></h2>
|
||||||
|
<button id="archiveToggle" class="btn btn-ghost" type="button" aria-expanded="false">Показать</button>
|
||||||
|
</div>
|
||||||
|
<div id="archiveBox" hidden>
|
||||||
|
<table id="archiveTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Заголовок</th>
|
||||||
|
<th>Исполнитель</th>
|
||||||
|
<th>Направление</th>
|
||||||
|
<th>Дедлайн</th>
|
||||||
|
<th>Завершено</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="archiveBody"></tbody>
|
||||||
|
</table>
|
||||||
|
<p id="archiveEmpty" class="hint" hidden>Архив пуст.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card director-only">
|
||||||
<h2>Задачи по сотрудникам (по статусу)</h2>
|
<h2>Задачи по сотрудникам (по статусу)</h2>
|
||||||
<div class="chart-wrap"><canvas id="chartStatus"></canvas></div>
|
<div class="chart-wrap"><canvas id="chartStatus"></canvas></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card director-only">
|
||||||
<h2>Задачи по сотрудникам (по приоритету)</h2>
|
<h2>Задачи по сотрудникам (по приоритету)</h2>
|
||||||
<div class="chart-wrap"><canvas id="chartPriority"></canvas></div>
|
<div class="chart-wrap"><canvas id="chartPriority"></canvas></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="charts-row">
|
<div class="charts-row director-only">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Распределение по дедлайнам</h2>
|
<h2>Распределение по дедлайнам</h2>
|
||||||
<div class="chart-wrap small"><canvas id="chartDeadlines"></canvas></div>
|
<div class="chart-wrap small"><canvas id="chartDeadlines"></canvas></div>
|
||||||
@ -315,6 +337,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=32"></script>
|
<script src="app.js?v=33"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
26
styles.css
26
styles.css
@ -459,6 +459,32 @@ body:not([data-role]) .employee-only { display: none !important; }
|
|||||||
vertical-align: 2px;
|
vertical-align: 2px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Archive ---------- */
|
||||||
|
.archive-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.archive-head h2 { margin: 0; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.archive-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 26px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
}
|
||||||
|
#archiveTable tbody tr.archived td { color: var(--muted); }
|
||||||
|
#archiveTable tbody tr.archived td:first-child { color: var(--ink-2); }
|
||||||
|
|
||||||
.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