Add notifications inbox per user

Every user gets a bell button in the top bar with an unread
counter and a modal inbox. Events that emit a notification:
- new task → assignee gets "Новое поручение"
- ▶ В работу / ↺ Вернуть / удаление → director (or assignee)
- ✓ На утверждение → director
- ✓ Утвердить / ✗ Отклонить / ↺ Отозвать → relevant party
- 💬 Прогресс → director (employee) or assignee (director)
Storage event keeps the bell in sync across tabs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
sultan 2026-05-25 09:47:46 +05:00
parent 0429d31f0c
commit a4c319fe50
3 changed files with 221 additions and 5 deletions

126
app.js
View File

@ -4,6 +4,38 @@ const STORAGE_KEY = 'sultan.tasks.v6';
const USER_KEY = 'sultan.currentUser.v1';
const SESSION_KEY = 'sultan.session.v1';
const PASSWORDS_KEY = 'sultan.passwords.v1';
const NOTIF_KEY = 'sultan.notifications.v1';
function loadNotifs() {
try { return JSON.parse(localStorage.getItem(NOTIF_KEY) || '{}'); }
catch { return {}; }
}
function saveNotifs(map) { localStorage.setItem(NOTIF_KEY, JSON.stringify(map)); }
function notifyUser(user, type, text, taskId) {
if (!user || !EMPLOYEES.includes(user)) return;
if (user === state.currentUser) return;
const all = loadNotifs();
all[user] = all[user] || [];
all[user].unshift({ id: Date.now() + Math.random(), ts: new Date().toISOString(), type, text, taskId, read: false });
if (all[user].length > 100) all[user].length = 100;
saveNotifs(all);
if (state.currentUser === user) refreshNotifUI();
}
function notifyDirector(type, text, taskId) { notifyUser('Абделазиз', type, text, taskId); }
function getMyNotifs() {
if (!state.currentUser) return [];
return loadNotifs()[state.currentUser] || [];
}
function unreadCount() { return getMyNotifs().filter(n => !n.read).length; }
function markAllRead() {
if (!state.currentUser) return;
const all = loadNotifs();
(all[state.currentUser] || []).forEach(n => n.read = true);
saveNotifs(all);
refreshNotifUI();
}
function getSession() {
try {
@ -981,6 +1013,16 @@ function initAuth() {
$('#profileBackdrop').addEventListener('click', closeProfile);
$('#profileForm').addEventListener('submit', submitChangePassword);
$('#notifBtn').addEventListener('click', openNotifs);
$('#notifClose').addEventListener('click', closeNotifs);
$('#notifBackdrop').addEventListener('click', closeNotifs);
$('#notifMarkRead').addEventListener('click', () => { markAllRead(); renderNotifList(); });
window.addEventListener('storage', (e) => {
if (e.key === NOTIF_KEY) refreshNotifUI();
if (e.key === STORAGE_KEY) renderAll();
});
// Миграция старой сессии "Директор" → "Абделазиз"
let sess = getSession();
if (sess && sess.user === 'Директор') {
@ -1055,6 +1097,8 @@ function submitProgress(e) {
t.updates.push({ by: state.currentUser, at: new Date().toISOString(), text });
t.updated_at = new Date().toISOString();
save(tasks);
if (state.currentUser === t.assignee) notifyDirector('progress', `Прогресс по «${t.title}» от ${state.currentUser}`, t.id);
if (isDirector()) notifyUser(t.assignee, 'progress', `Комментарий Абделазиза по «${t.title}»`, t.id);
$('#progressInput').value = '';
renderProgressList(t);
renderAll();
@ -1114,9 +1158,69 @@ function applySession(user) {
$('#userChipRole').textContent = (user === 'Абделазиз') ? 'Директор' : 'Сотрудник';
$('#userChipAvatar').textContent = user.slice(0, 1).toUpperCase();
if (!isDirector() && location.hash === '#/create') location.hash = '#/home';
refreshNotifUI();
applyRoute();
}
function refreshNotifUI() {
const btn = $('#notifBtn');
const cnt = $('#notifCount');
if (!btn || !cnt) return;
const show = !!state.currentUser;
btn.hidden = !show;
if (!show) return;
const n = unreadCount();
if (n > 0) {
cnt.textContent = n > 99 ? '99+' : String(n);
cnt.hidden = false;
btn.classList.add('has-unread');
} else {
cnt.hidden = true;
btn.classList.remove('has-unread');
}
}
function openNotifs() {
renderNotifList();
$('#notifModal').hidden = false;
}
function closeNotifs() { $('#notifModal').hidden = true; refreshNotifUI(); }
function renderNotifList() {
const list = $('#notifList');
const items = getMyNotifs();
if (!items.length) {
list.innerHTML = '<li class="notif-empty">Уведомлений пока нет.</li>';
return;
}
list.innerHTML = items.map(n => {
const dt = new Date(n.ts);
const when = dt.toLocaleString('ru-RU', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
const cls = `notif-item notif-${n.type}${n.read ? '' : ' is-unread'}`;
const icon = NOTIF_ICONS[n.type] || '•';
return `
<li class="${cls}">
<span class="notif-icon">${icon}</span>
<div class="notif-body">
<div class="notif-text">${escapeHtml(n.text)}</div>
<div class="notif-when">${when}</div>
</div>
</li>
`;
}).join('');
// mark as read on open
setTimeout(() => { markAllRead(); }, 800);
}
const NOTIF_ICONS = {
new_task: '📋',
review: '⏳',
approved: '✅',
rejected: '↩️',
status: '🔄',
progress: '💬',
};
function showLogin() {
$('#loginOverlay').hidden = false;
$('#userChip').hidden = true;
@ -1128,6 +1232,7 @@ function logout() {
state.currentUser = null;
location.hash = '#/home';
showLogin();
refreshNotifUI();
}
function openMenu() {
@ -1199,6 +1304,7 @@ function init() {
if (!task.title) return;
tasks.push(task);
save(tasks);
notifyUser(task.assignee, 'new_task', `Новое поручение: «${task.title}» · дедлайн ${task.deadline}`, task.id);
showToast(`Поручение создано · «${task.title}» → ${task.assignee}`);
e.target.reset();
const d2 = new Date(); d2.setDate(d2.getDate() + 3);
@ -1233,33 +1339,47 @@ function init() {
const t = tasks.find((x) => x.id === id);
if (!t) return;
if (act === 'progress') { openProgress(t.id); return; }
if (act === 'start') t.status = 'in_progress';
if (act === 'done') t.status = 'done';
if (act === 'start') {
t.status = 'in_progress';
notifyDirector('status', `${t.assignee} взял в работу: «${t.title}»`, t.id);
}
if (act === 'done') {
t.status = 'done';
if (t.assignee !== state.currentUser) notifyUser(t.assignee, 'status', `Абделазиз закрыл «${t.title}»`, t.id);
}
if (act === 'submit_review') {
if (t.assignee !== state.currentUser && !isDirector()) return;
t.status = 'pending_review';
showToast(`Поручение «${t.title}» отправлено Абделазизу на утверждение`);
notifyDirector('review', `На утверждение от ${state.currentUser}: «${t.title}»`, t.id);
}
if (act === 'approve') {
if (!isDirector()) return;
t.status = 'done';
showToast(`Утверждено: «${t.title}»`);
notifyUser(t.assignee, 'approved', `Утверждено Абделазизом: «${t.title}»`, t.id);
}
if (act === 'reject') {
if (!isDirector()) return;
t.status = 'in_progress';
showToast(`Отклонено: «${t.title}» возвращено в работу`);
notifyUser(t.assignee, 'rejected', `Возвращено в работу: «${t.title}»`, t.id);
}
if (act === 'revoke') {
if (t.assignee !== state.currentUser && !isDirector()) return;
t.status = 'in_progress';
notifyDirector('status', `${state.currentUser} отозвал «${t.title}» с утверждения`, t.id);
}
if (act === 'reopen') {
t.status = 'in_progress';
if (t.assignee !== state.currentUser) notifyUser(t.assignee, 'status', `Возвращено в работу Абделазизом: «${t.title}»`, t.id);
}
if (act === 'reopen') t.status = 'in_progress';
if (act === 'delete') {
if (!isDirector()) return;
if (!confirm('Удалить поручение?')) return;
const idx = tasks.indexOf(t);
tasks.splice(idx, 1);
if (t.assignee !== state.currentUser) notifyUser(t.assignee, 'status', `Поручение удалено: «${t.title}»`, t.id);
}
t && (t.updated_at = new Date().toISOString());
save(tasks);

View File

@ -11,7 +11,7 @@
<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 rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
<link rel="stylesheet" href="styles.css?v=38" />
<link rel="stylesheet" href="styles.css?v=39" />
</head>
<body>
<header class="topbar">
@ -68,6 +68,10 @@
</a>
</div>
<div class="who">
<button id="notifBtn" class="notif-btn" type="button" title="Уведомления" hidden>
<span class="notif-bell" aria-hidden="true">🔔</span>
<span id="notifCount" class="notif-count" hidden>0</span>
</button>
<div id="userChip" class="user-chip" hidden>
<button id="profileBtn" class="user-chip-main" type="button" title="Профиль">
<span class="user-chip-avatar" id="userChipAvatar">—</span>
@ -82,6 +86,21 @@
</div>
</header>
<!-- Уведомления -->
<div id="notifModal" class="modal" hidden>
<div class="modal-backdrop" id="notifBackdrop"></div>
<div class="modal-card modal-wide" role="dialog" aria-modal="true" aria-labelledby="notifTitle">
<button id="notifClose" class="modal-close" type="button" aria-label="Закрыть">×</button>
<div class="notif-head">
<h2 id="notifTitle" class="modal-title">Уведомления</h2>
<button id="notifMarkRead" class="btn btn-ghost" type="button">Прочитать все</button>
</div>
<ul id="notifList" class="notif-list">
<li class="notif-empty">Уведомлений пока нет.</li>
</ul>
</div>
</div>
<!-- Промежуточные результаты -->
<div id="progressModal" class="modal" hidden>
<div class="modal-backdrop" id="progressBackdrop"></div>
@ -356,6 +375,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/l10n/ru.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=35"></script>
<script src="app.js?v=36"></script>
</body>
</html>

View File

@ -211,6 +211,83 @@ body {
.brand:hover .brand-mark-secondary { opacity: 1; }
.who { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.notif-btn {
position: relative;
width: 38px; height: 38px;
border-radius: 50%;
border: none;
background: var(--surface-2);
cursor: pointer;
font-size: 17px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease;
}
.notif-btn:hover { background: rgba(0,113,227,0.10); }
.notif-btn.has-unread { background: rgba(0,113,227,0.12); }
.notif-btn:focus-visible { outline: 2px solid var(--kt-blue); outline-offset: 2px; }
.notif-count {
position: absolute;
top: -2px;
right: -2px;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: #FF3B30;
color: #fff;
font-size: 10px;
font-weight: 700;
border-radius: 9px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 2px solid var(--bg, #fff);
}
.notif-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; }
.notif-head .modal-title { margin: 0; }
.notif-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 480px;
overflow-y: auto;
border: 1px solid var(--hairline);
border-radius: var(--r-md);
background: var(--surface-2);
}
.notif-empty {
padding: 24px;
text-align: center;
color: var(--muted);
font-size: 13px;
}
.notif-item {
display: grid;
grid-template-columns: 32px 1fr;
gap: 12px;
padding: 12px 16px;
border-top: 1px solid var(--hairline);
align-items: start;
background: var(--surface-2);
}
.notif-item:first-child { border-top: none; }
.notif-item.is-unread { background: rgba(0, 113, 227, 0.06); }
.notif-icon {
font-size: 18px;
line-height: 1;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--surface);
border-radius: 50%;
}
.notif-body { min-width: 0; }
.notif-text { font-size: 14px; color: var(--ink); line-height: 1.4; }
.notif-when { font-size: 11px; color: var(--muted); margin-top: 4px; }
body[data-role="employee"] .director-only { display: none !important; }
body[data-role="director"] .employee-only { display: none !important; }
body:not([data-role]) .employee-only { display: none !important; }