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:
parent
0429d31f0c
commit
a4c319fe50
126
app.js
126
app.js
@ -4,6 +4,38 @@ const STORAGE_KEY = 'sultan.tasks.v6';
|
|||||||
const USER_KEY = 'sultan.currentUser.v1';
|
const USER_KEY = 'sultan.currentUser.v1';
|
||||||
const SESSION_KEY = 'sultan.session.v1';
|
const SESSION_KEY = 'sultan.session.v1';
|
||||||
const PASSWORDS_KEY = 'sultan.passwords.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() {
|
function getSession() {
|
||||||
try {
|
try {
|
||||||
@ -981,6 +1013,16 @@ function initAuth() {
|
|||||||
$('#profileBackdrop').addEventListener('click', closeProfile);
|
$('#profileBackdrop').addEventListener('click', closeProfile);
|
||||||
$('#profileForm').addEventListener('submit', submitChangePassword);
|
$('#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();
|
let sess = getSession();
|
||||||
if (sess && sess.user === 'Директор') {
|
if (sess && sess.user === 'Директор') {
|
||||||
@ -1055,6 +1097,8 @@ function submitProgress(e) {
|
|||||||
t.updates.push({ by: state.currentUser, at: new Date().toISOString(), text });
|
t.updates.push({ by: state.currentUser, at: new Date().toISOString(), text });
|
||||||
t.updated_at = new Date().toISOString();
|
t.updated_at = new Date().toISOString();
|
||||||
save(tasks);
|
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 = '';
|
$('#progressInput').value = '';
|
||||||
renderProgressList(t);
|
renderProgressList(t);
|
||||||
renderAll();
|
renderAll();
|
||||||
@ -1114,9 +1158,69 @@ function applySession(user) {
|
|||||||
$('#userChipRole').textContent = (user === 'Абделазиз') ? 'Директор' : 'Сотрудник';
|
$('#userChipRole').textContent = (user === 'Абделазиз') ? 'Директор' : 'Сотрудник';
|
||||||
$('#userChipAvatar').textContent = user.slice(0, 1).toUpperCase();
|
$('#userChipAvatar').textContent = user.slice(0, 1).toUpperCase();
|
||||||
if (!isDirector() && location.hash === '#/create') location.hash = '#/home';
|
if (!isDirector() && location.hash === '#/create') location.hash = '#/home';
|
||||||
|
refreshNotifUI();
|
||||||
applyRoute();
|
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() {
|
function showLogin() {
|
||||||
$('#loginOverlay').hidden = false;
|
$('#loginOverlay').hidden = false;
|
||||||
$('#userChip').hidden = true;
|
$('#userChip').hidden = true;
|
||||||
@ -1128,6 +1232,7 @@ function logout() {
|
|||||||
state.currentUser = null;
|
state.currentUser = null;
|
||||||
location.hash = '#/home';
|
location.hash = '#/home';
|
||||||
showLogin();
|
showLogin();
|
||||||
|
refreshNotifUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMenu() {
|
function openMenu() {
|
||||||
@ -1199,6 +1304,7 @@ function init() {
|
|||||||
if (!task.title) return;
|
if (!task.title) return;
|
||||||
tasks.push(task);
|
tasks.push(task);
|
||||||
save(tasks);
|
save(tasks);
|
||||||
|
notifyUser(task.assignee, 'new_task', `Новое поручение: «${task.title}» · дедлайн ${task.deadline}`, task.id);
|
||||||
showToast(`Поручение создано · «${task.title}» → ${task.assignee}`);
|
showToast(`Поручение создано · «${task.title}» → ${task.assignee}`);
|
||||||
e.target.reset();
|
e.target.reset();
|
||||||
const d2 = new Date(); d2.setDate(d2.getDate() + 3);
|
const d2 = new Date(); d2.setDate(d2.getDate() + 3);
|
||||||
@ -1233,33 +1339,47 @@ function init() {
|
|||||||
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 === 'progress') { openProgress(t.id); return; }
|
||||||
if (act === 'start') t.status = 'in_progress';
|
if (act === 'start') {
|
||||||
if (act === 'done') t.status = 'done';
|
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 (act === 'submit_review') {
|
||||||
if (t.assignee !== state.currentUser && !isDirector()) return;
|
if (t.assignee !== state.currentUser && !isDirector()) return;
|
||||||
t.status = 'pending_review';
|
t.status = 'pending_review';
|
||||||
showToast(`Поручение «${t.title}» отправлено Абделазизу на утверждение`);
|
showToast(`Поручение «${t.title}» отправлено Абделазизу на утверждение`);
|
||||||
|
notifyDirector('review', `На утверждение от ${state.currentUser}: «${t.title}»`, t.id);
|
||||||
}
|
}
|
||||||
if (act === 'approve') {
|
if (act === 'approve') {
|
||||||
if (!isDirector()) return;
|
if (!isDirector()) return;
|
||||||
t.status = 'done';
|
t.status = 'done';
|
||||||
showToast(`Утверждено: «${t.title}»`);
|
showToast(`Утверждено: «${t.title}»`);
|
||||||
|
notifyUser(t.assignee, 'approved', `Утверждено Абделазизом: «${t.title}»`, t.id);
|
||||||
}
|
}
|
||||||
if (act === 'reject') {
|
if (act === 'reject') {
|
||||||
if (!isDirector()) return;
|
if (!isDirector()) return;
|
||||||
t.status = 'in_progress';
|
t.status = 'in_progress';
|
||||||
showToast(`Отклонено: «${t.title}» возвращено в работу`);
|
showToast(`Отклонено: «${t.title}» возвращено в работу`);
|
||||||
|
notifyUser(t.assignee, 'rejected', `Возвращено в работу: «${t.title}»`, t.id);
|
||||||
}
|
}
|
||||||
if (act === 'revoke') {
|
if (act === 'revoke') {
|
||||||
if (t.assignee !== state.currentUser && !isDirector()) return;
|
if (t.assignee !== state.currentUser && !isDirector()) return;
|
||||||
t.status = 'in_progress';
|
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 (act === 'delete') {
|
||||||
if (!isDirector()) return;
|
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);
|
||||||
|
if (t.assignee !== state.currentUser) notifyUser(t.assignee, 'status', `Поручение удалено: «${t.title}»`, t.id);
|
||||||
}
|
}
|
||||||
t && (t.updated_at = new Date().toISOString());
|
t && (t.updated_at = new Date().toISOString());
|
||||||
save(tasks);
|
save(tasks);
|
||||||
|
|||||||
23
index.html
23
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=38" />
|
<link rel="stylesheet" href="styles.css?v=39" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@ -68,6 +68,10 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="who">
|
<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>
|
<div id="userChip" class="user-chip" hidden>
|
||||||
<button id="profileBtn" class="user-chip-main" type="button" title="Профиль">
|
<button id="profileBtn" class="user-chip-main" type="button" title="Профиль">
|
||||||
<span class="user-chip-avatar" id="userChipAvatar">—</span>
|
<span class="user-chip-avatar" id="userChipAvatar">—</span>
|
||||||
@ -82,6 +86,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 id="progressModal" class="modal" hidden>
|
||||||
<div class="modal-backdrop" id="progressBackdrop"></div>
|
<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/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=35"></script>
|
<script src="app.js?v=36"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
77
styles.css
77
styles.css
@ -211,6 +211,83 @@ body {
|
|||||||
.brand:hover .brand-mark-secondary { opacity: 1; }
|
.brand:hover .brand-mark-secondary { opacity: 1; }
|
||||||
|
|
||||||
.who { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
.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="employee"] .director-only { display: none !important; }
|
||||||
body[data-role="director"] .employee-only { display: none !important; }
|
body[data-role="director"] .employee-only { display: none !important; }
|
||||||
body:not([data-role]) .employee-only { display: none !important; }
|
body:not([data-role]) .employee-only { display: none !important; }
|
||||||
|
|||||||
Reference in New Issue
Block a user