Route task completion through director approval

Employees clicking "Готово" now move the task to
status="pending_review" instead of "done". The task stays in
the active table with a purple "На утверждении" badge until
Абделазиз acts on it:
- ✓ Утвердить → status="done" → goes to archive
- ✗ Отклонить → status="in_progress" → back to work

Employees can revoke their own submission while it is pending.
Абделазиз's own "Готово" still completes immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
sultan 2026-05-25 09:43:41 +05:00
parent 9de998a80c
commit 0429d31f0c
3 changed files with 55 additions and 6 deletions

55
app.js
View File

@ -90,6 +90,7 @@ const DIRECTIONS = [
const STATUS_LABEL = { const STATUS_LABEL = {
new: 'Новая', new: 'Новая',
in_progress: 'В работе', in_progress: 'В работе',
pending_review: 'На утверждении',
done: 'Выполнена', done: 'Выполнена',
overdue: 'Просрочена', overdue: 'Просрочена',
}; };
@ -140,6 +141,7 @@ function computePriority(deadline) {
function effectiveStatus(task) { function effectiveStatus(task) {
if (task.status === 'done') return 'done'; if (task.status === 'done') return 'done';
if (task.status === 'pending_review') return 'pending_review';
if (task.deadline && task.deadline < todayISO()) return 'overdue'; if (task.deadline && task.deadline < todayISO()) return 'overdue';
return task.status; return task.status;
} }
@ -244,6 +246,35 @@ function renderDashboard(tasks) {
if (!loadEl.children.length) loadEl.textContent = '—'; if (!loadEl.children.length) loadEl.textContent = '—';
} }
function renderRowActions(t, st) {
const id = t.id;
const isOwner = t.assignee === state.currentUser;
const dir = isDirector();
const progBtn = `<button class="btn btn-sm btn-icon btn-ghost" data-act="progress" data-id="${id}" title="Промежуточные результаты">💬${(t.updates && t.updates.length) ? `<span class="upd-count">${t.updates.length}</span>` : ''}</button>`;
const delBtn = `<button class="btn btn-sm btn-icon btn-danger director-only" data-act="delete" data-id="${id}" title="Удалить">✕</button>`;
if (st === 'pending_review') {
let main = '';
if (dir) {
main = `
<button class="btn btn-sm btn-success" data-act="approve" data-id="${id}">✓ Утвердить</button>
<button class="btn btn-sm btn-ghost" data-act="reject" data-id="${id}">✗ Отклонить</button>
`;
} else if (isOwner) {
main = `<button class="btn btn-sm btn-ghost" data-act="revoke" data-id="${id}">↺ Отозвать</button>`;
} else {
main = `<span class="hint" style="margin-right:auto;">Ожидает решения Абделазиза</span>`;
}
return `${main}${progBtn}${delBtn}`;
}
const startBtn = (t.status === 'new') ? `<button class="btn btn-sm btn-primary" data-act="start" data-id="${id}">▶ В работу</button>` : '';
const doneAct = dir ? 'done' : 'submit_review';
const doneLbl = dir ? '✓ Готово' : '✓ На утверждение';
const doneBtn = `<button class="btn btn-sm btn-success" data-act="${doneAct}" data-id="${id}">${doneLbl}</button>`;
return `${startBtn}${doneBtn}${progBtn}${delBtn}`;
}
function renderTable(tasks) { function renderTable(tasks) {
const body = $('#tasksBody'); const body = $('#tasksBody');
body.innerHTML = ''; body.innerHTML = '';
@ -287,10 +318,7 @@ function renderTable(tasks) {
</td> </td>
<td><span class="badge status-${st}">${STATUS_LABEL[st]}</span></td> <td><span class="badge status-${st}">${STATUS_LABEL[st]}</span></td>
<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>` : ''} ${renderRowActions(t, st)}
${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-danger director-only" data-act="delete" data-id="${t.id}" title="Удалить">✕</button>
</td> </td>
`; `;
body.appendChild(tr); body.appendChild(tr);
@ -1207,6 +1235,25 @@ function init() {
if (act === 'progress') { openProgress(t.id); 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 === 'submit_review') {
if (t.assignee !== state.currentUser && !isDirector()) return;
t.status = 'pending_review';
showToast(`Поручение «${t.title}» отправлено Абделазизу на утверждение`);
}
if (act === 'approve') {
if (!isDirector()) return;
t.status = 'done';
showToast(`Утверждено: «${t.title}»`);
}
if (act === 'reject') {
if (!isDirector()) return;
t.status = 'in_progress';
showToast(`Отклонено: «${t.title}» возвращено в работу`);
}
if (act === 'revoke') {
if (t.assignee !== state.currentUser && !isDirector()) return;
t.status = 'in_progress';
}
if (act === 'reopen') t.status = 'in_progress'; if (act === 'reopen') t.status = 'in_progress';
if (act === 'delete') { if (act === 'delete') {
if (!isDirector()) return; if (!isDirector()) return;

View File

@ -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=37" /> <link rel="stylesheet" href="styles.css?v=38" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@ -249,6 +249,7 @@
<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="pending_review">На утверждении</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>
@ -355,6 +356,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=34"></script> <script src="app.js?v=35"></script>
</body> </body>
</html> </html>

View File

@ -790,6 +790,7 @@ td.title { max-width: 380px; }
.badge.status-in_progress { background: rgba(255, 149, 0, 0.14); color: var(--warn); } .badge.status-in_progress { background: rgba(255, 149, 0, 0.14); color: var(--warn); }
.badge.status-done { background: rgba(52, 199, 89, 0.14); color: var(--ok); } .badge.status-done { background: rgba(52, 199, 89, 0.14); color: var(--ok); }
.badge.status-overdue { background: rgba(255, 59, 48, 0.12); color: var(--danger); } .badge.status-overdue { background: rgba(255, 59, 48, 0.12); color: var(--danger); }
.badge.status-pending_review { background: rgba(175, 82, 222, 0.14); color: #AF52DE; }
td.actions-cell { white-space: nowrap; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } td.actions-cell { white-space: nowrap; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }