521 lines
26 KiB
JavaScript
521 lines
26 KiB
JavaScript
/* ===== Семейный таск-менеджер (чистая статика, localStorage) ===== */
|
||
|
||
const KEY = 'family_tasks_v1';
|
||
const SESSION = 'family_tasks_user';
|
||
const AVATAR_COLORS = ['#ec4899','#3b82f6','#10b981','#f59e0b','#8b5cf6','#ef4444','#06b6d4','#84cc16','#f97316','#6366f1'];
|
||
const AVATAR_EMOJIS = ['👩','👨','👧','👦','🧑','👵','👴','🧒','👶','🧔','👩🦰','👨🦱'];
|
||
|
||
let data = null; // {familyName, members:[], tasks:[]}
|
||
let me = null; // текущий пользователь (member id)
|
||
let view = 'mine';
|
||
let editingId = null; // id задачи в модалке (null = создание)
|
||
let postponeId = null; // id задачи для переноса
|
||
|
||
/* ---------- storage ---------- */
|
||
function load(){ try{ return JSON.parse(localStorage.getItem(KEY)); }catch(e){ return null; } }
|
||
function save(){ localStorage.setItem(KEY, JSON.stringify(data)); }
|
||
function uid(){ return 'id'+Date.now().toString(36)+Math.random().toString(36).slice(2,6); }
|
||
|
||
/* ---------- helpers ---------- */
|
||
const $ = s => document.querySelector(s);
|
||
const $$ = s => [...document.querySelectorAll(s)];
|
||
function member(id){ return (data.members||[]).find(m=>m.id===id) || null; }
|
||
function escapeHtml(s){ return (s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||
function todayStr(){ const d=new Date(); return fmt(d); }
|
||
function fmt(d){ return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); }
|
||
function parseDate(s){ if(!s) return null; const [y,m,d]=s.split('-').map(Number); return new Date(y,m-1,d); }
|
||
function prettyDate(s){ const d=parseDate(s); if(!d) return '—'; return d.toLocaleDateString('ru-RU',{day:'numeric',month:'short}); }
|
||
function prettyDateTime(s,t){ let r=prettyDate(s); if(t) r+=' · '+t; return r; }
|
||
function relativeDue(s){ if(!s) return ''; const d=parseDate(s); const now=new Date(); now.setHours(0,0,0,0); const diff=Math.round((d-now)/86400000); if(diff<0) return 'просрочено'; if(diff===0) return 'сегодня'; if(diff===1) return 'завтра'; if(diff<7) return `через ${diff} дн.`; return prettyDate(s); }
|
||
function initials(name){ return (name||'?').trim().slice(0,1).toUpperCase(); }
|
||
function colorFor(id){ const m=member(id); return m?m.color:'#94a3b8'; }
|
||
|
||
function avatarHtml(m, size){
|
||
if(!m) return `<span class="avatar" style="background:#94a3b8;width:${size}px;height:${size}px;font-size:${size*0.45}px">?</span>`;
|
||
const content = m.emoji || initials(m.name);
|
||
return `<span class="avatar" style="background:${m.color};width:${size}px;height:${size}px;font-size:${size*0.42}px" title="${escapeHtml(m.name)}">${content}</span>`;
|
||
}
|
||
|
||
function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.remove('hidden'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.add('hidden'),2200); }
|
||
|
||
const STATUS = {
|
||
todo: {label:'В работе', cls:'bg-amber-100 text-amber-700'},
|
||
done: {label:'Готово', cls:'bg-emerald-100 text-emerald-700'},
|
||
postponed: {label:'Перенесено',cls:'bg-sky-100 text-sky-700'},
|
||
cancelled: {label:'Отменено', cls:'bg-gray-200 text-gray-600'},
|
||
};
|
||
const PRIORITY = {
|
||
low: {label:'🟢 Низкая', cls:'bg-gray-100 text-gray-600'},
|
||
medium: {label:'🟡 Средняя', cls:'bg-amber-50 text-amber-600'},
|
||
high: {label:'🔴 Высокая', cls:'bg-red-50 text-red-600'},
|
||
};
|
||
|
||
/* ============================================================
|
||
ИНИЦИАЛИЗАЦИЯ
|
||
============================================================ */
|
||
function init(){
|
||
data = load();
|
||
if(!data || !data.members || !data.members.length){ showOnboard(); return; }
|
||
me = localStorage.getItem(SESSION);
|
||
if(!me || !member(me)) me = data.members[0].id;
|
||
enterApp();
|
||
}
|
||
|
||
/* ---------- онбординг ---------- */
|
||
function showOnboard(){
|
||
$('#onboard').classList.remove('hidden');
|
||
$('#app').classList.add('hidden');
|
||
const ob = $('#ob-members');
|
||
ob.innerHTML='';
|
||
addObMember(); addObMember();
|
||
}
|
||
function addObMember(){
|
||
const idx = $('#ob-members').children.length;
|
||
const color = AVATAR_COLORS[idx % AVATAR_COLORS.length];
|
||
const emoji = AVATAR_EMOJIS[idx % AVATAR_EMOJIS.length];
|
||
const row = document.createElement('div');
|
||
row.className='flex items-center gap-2';
|
||
row.innerHTML = `
|
||
<span class="avatar w-9 h-9" style="background:${color};font-size:16px">${emoji}</span>
|
||
<input class="field ob-name" placeholder="Имя (Мама, Папа, Аня…)" value="">
|
||
<button class="iconbtn ob-del" title="убрать">🗑</button>`;
|
||
row.querySelector('.ob-del').onclick=()=>{ if($('#ob-members').children.length>1) row.remove(); };
|
||
ob.appendChild(row);
|
||
}
|
||
|
||
$('#ob-add-member').onclick = addObMember;
|
||
$('#ob-create').onclick = ()=>{
|
||
const familyName = $('#ob-family').value.trim() || 'Наша семья';
|
||
const names = $$('#ob-members .ob-name').map(i=>i.value.trim()).filter(Boolean);
|
||
if(names.length<1){ toast('Добавьте хотя бы одного члена семьи'); return; }
|
||
const members = names.map((n,i)=>({ id:uid(), name:n, color:AVATAR_COLORS[i%AVATAR_COLORS.length], emoji:AVATAR_EMOJIS[i%AVATAR_EMOJIS.length] }));
|
||
data = { familyName, members, tasks:[] };
|
||
me = members[0].id;
|
||
localStorage.setItem(SESSION, me);
|
||
save();
|
||
$('#onboard').classList.add('hidden');
|
||
enterApp();
|
||
toast('Семья создана! 🎉');
|
||
};
|
||
|
||
/* ============================================================
|
||
ВХОД В ПРИЛОЖЕНИЕ
|
||
============================================================ */
|
||
function enterApp(){
|
||
$('#onboard').classList.add('hidden');
|
||
$('#app').classList.remove('hidden');
|
||
$('#hd-family').textContent = data.familyName;
|
||
renderUserMenu();
|
||
setCurrentUser(me);
|
||
setView('mine');
|
||
}
|
||
|
||
/* ---------- переключение пользователя («вход») ---------- */
|
||
function renderUserMenu(){
|
||
const list = $('#hd-user-list');
|
||
list.innerHTML = data.members.map(m=>`
|
||
<div class="menu-row ${m.id===me?'active':''}" data-uid="${m.id}">
|
||
${avatarHtml(m,32)}
|
||
<span class="font-semibold text-sm">${escapeHtml(m.name)}</span>
|
||
${m.id===me?'<span class="ml-auto text-xs text-muted">вы тут</span>':''}
|
||
</div>`).join('');
|
||
list.querySelectorAll('.menu-row').forEach(r=>r.onclick=()=>{
|
||
setCurrentUser(r.dataset.uid);
|
||
$('#hd-menu').classList.add('hidden');
|
||
setView(view);
|
||
});
|
||
}
|
||
function setCurrentUser(id){
|
||
me = id;
|
||
localStorage.setItem(SESSION, id);
|
||
const m = member(id);
|
||
$('#hd-avatar').outerHTML = `<span id="hd-avatar" class="avatar w-8 h-8 text-base">${m.emoji||initials(m.name)}</span>`;
|
||
$('#hd-avatar').style.background = m.color;
|
||
$('#hd-name').textContent = m.name;
|
||
$('#f-from').textContent = m.name;
|
||
}
|
||
|
||
/* выпадающее меню пользователя */
|
||
$('#hd-user').onclick = e=>{ e.stopPropagation(); $('#hd-menu').classList.toggle('hidden'); };
|
||
document.addEventListener('click', e=>{ if(!e.target.closest('#hd-user') && !e.target.closest('#hd-menu')) $('#hd-menu').classList.add('hidden'); });
|
||
$('#hd-manage').onclick = ()=>{ $('#hd-menu').classList.add('hidden'); openFamilyModal(); };
|
||
|
||
/* ---------- навигация ---------- */
|
||
$$('.navtab').forEach(b=>b.onclick=()=>setView(b.dataset.view));
|
||
function setView(v){
|
||
view = v;
|
||
$$('.navtab').forEach(b=>b.classList.toggle('active', b.dataset.view===v));
|
||
render();
|
||
}
|
||
|
||
/* ---------- кнопка новая задача ---------- */
|
||
$('#hd-new').onclick = $('#hd-new-m').onclick = ()=>openTaskModal(null);
|
||
|
||
/* ============================================================
|
||
ГЛАВНЫЙ РЕНДЕР
|
||
============================================================ */
|
||
function render(){
|
||
$('#hd-family').textContent = data.familyName;
|
||
if(view==='mine') return renderMine();
|
||
if(view==='assigned') return renderAssigned();
|
||
if(view==='all') return renderAll();
|
||
if(view==='schedule') return renderSchedule();
|
||
if(view==='family') return renderFamily();
|
||
}
|
||
|
||
function taskRow(t){
|
||
const a = member(t.assigneeId);
|
||
const from = member(t.assignerId);
|
||
const overdue = t.status==='todo' && t.dueDate && parseDate(t.dueDate) < new Date(todayStr());
|
||
const st = STATUS[t.status] || STATUS.todo;
|
||
return `
|
||
<div class="task ${t.status==='done'?'done':''} ${t.status==='cancelled'?'cancelled':''} ${overdue?'overdue':''}" data-id="${t.id}">
|
||
<div class="flex items-start gap-3">
|
||
<button class="iconbtn primary t-done" title="Отметить выполненной" ${t.status==='done'?'disabled':''}>✓</button>
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-start gap-2 flex-wrap">
|
||
<div class="t-title font-semibold flex-1 min-w-0">${escapeHtml(t.title)}</div>
|
||
<span class="badge ${st.cls}">${st.label}</span>
|
||
</div>
|
||
${t.note?`<div class="text-sm text-muted mt-1">${escapeHtml(t.note)}</div>`:''}
|
||
<div class="flex items-center gap-2 flex-wrap mt-2 text-xs">
|
||
<span class="chip" style="background:${(a&&a.color)||'#94a3b8'}22;color:${(a&&a.color)||'#94a3b8'}">${avatarHtml(a,16)} ${escapeHtml(a?a.name:'—')}</span>
|
||
${from?`<span class="text-muted">от ${escapeHtml(from.name)}</span>`:''}
|
||
${t.dueDate?`<span class="badge ${overdue?'bg-red-100 text-red-700':'bg-line text-muted'}">⏰ ${prettyDateTime(t.dueDate,t.time)} · ${relativeDue(t.dueDate)}</span>`:''}
|
||
<span class="badge ${PRIORITY[t.priority].cls}">${PRIORITY[t.priority].label}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2 mt-3 pt-3 border-t border-line">
|
||
${t.status!=='done'?`<button class="iconbtn t-done" title="Выполнено">✓ Выполнить</button>`:`<button class="iconbtn t-reopen" title="Вернуть">↩ Вернуть</button>`}
|
||
${t.status!=='cancelled' && t.status!=='done'?`<button class="iconbtn t-postpone" title="Перенести">📅 Перенести</button>`:''}
|
||
${t.status!=='cancelled' && t.status!=='done'?`<button class="iconbtn t-cancel" title="Отменить">✕ Отменить</button>`:''}
|
||
<button class="iconbtn t-edit ml-auto" title="Изменить">✎</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function bindTaskRow(el){
|
||
const id = el.dataset.id;
|
||
const t = data.tasks.find(x=>x.id===id); if(!t) return;
|
||
const on = (sel, fn)=>el.querySelectorAll(sel).forEach(b=>{ if(!b.disabled) b.addEventListener('click', fn); });
|
||
on('.t-done', ()=>setStatus(id,'done'));
|
||
on('.t-reopen', ()=>setStatus(id,'todo'));
|
||
on('.t-postpone', ()=>openPostpone(id));
|
||
on('.t-cancel', ()=>setStatus(id,'cancelled'));
|
||
on('.t-edit', ()=>openTaskModal(id));
|
||
}
|
||
|
||
/* ---------- Мои задачи ---------- */
|
||
function renderMine(){
|
||
const me2 = member(me);
|
||
const tasks = data.tasks.filter(t=>t.assigneeId===me && (t.status==='todo'||t.status==='postponed'))
|
||
.sort((a,b)=> (a.dueDate||'9999').localeCompare(b.dueDate||'9999'));
|
||
const doneCount = data.tasks.filter(t=>t.assigneeId===me && t.status==='done').length;
|
||
const main = $('#main');
|
||
main.innerHTML = `
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h2 class="text-2xl font-extrabold">Мои задачи</h2>
|
||
<p class="text-muted text-sm">${avatarHtml(me2,20)} ${escapeHtml(me2.name)} · выполнено за всё время: ${doneCount}</p>
|
||
</div>
|
||
</div>
|
||
<div id="list" class="space-y-3"></div>`;
|
||
const list = $('#list');
|
||
if(!tasks.length){ list.innerHTML = emptyHtml('🎉','Активных задач нет. Можно отдохнуть!'); return; }
|
||
list.innerHTML = tasks.map(taskRow).join('');
|
||
list.querySelectorAll('.task').forEach(bindTaskRow);
|
||
}
|
||
|
||
/* ---------- Я поручил ---------- */
|
||
function renderAssigned(){
|
||
const tasks = data.tasks.filter(t=>t.assignerId===me)
|
||
.sort((a,b)=> (a.dueDate||'9999').localeCompare(b.dueDate||'9999'));
|
||
const active = tasks.filter(t=>t.status==='todo'||t.status==='postponed').length;
|
||
$('#main').innerHTML = `
|
||
<h2 class="text-2xl font-extrabold mb-1">Я поручил</h2>
|
||
<p class="text-muted text-sm mb-4">Задачи, которые вы поставили другим. Активных: ${active}</p>
|
||
<div id="list" class="space-y-3"></div>`;
|
||
const list = $('#list');
|
||
if(!tasks.length){ list.innerHTML = emptyHtml('📤','Вы ещё никому не поручали задач.','+ Новая задача','hd-new'); return; }
|
||
list.innerHTML = tasks.map(taskRow).join('');
|
||
list.querySelectorAll('.task').forEach(bindTaskRow);
|
||
}
|
||
|
||
/* ---------- Все задачи ---------- */
|
||
function renderAll(){
|
||
const statuses = ['todo','postponed','done','cancelled'];
|
||
$('#main').innerHTML = `
|
||
<h2 class="text-2xl font-extrabold mb-1">Все задачи семьи</h2>
|
||
<p class="text-muted text-sm mb-4">Всего: ${data.tasks.length}</p>
|
||
<div class="flex flex-wrap gap-2 mb-4" id="filters">
|
||
<select id="flt-member" class="field !w-auto !py-2"><option value="">Все члены</option>${data.members.map(m=>`<option value="${m.id}">${escapeHtml(m.name)}</option>`).join('')}</select>
|
||
<select id="flt-status" class="field !w-auto !py-2"><option value="">Любой статус</option>${statuses.map(s=>`<option value="${s}">${STATUS[s].label}</option>`).join('')}</select>
|
||
</div>
|
||
<div id="list" class="space-y-3"></div>`;
|
||
const apply = ()=>{
|
||
const fm = $('#flt-member').value, fs = $('#flt-status').value;
|
||
let tasks = data.tasks.slice().sort((a,b)=>(b.createdAt||0)-(a.createdAt||0));
|
||
if(fm) tasks = tasks.filter(t=>t.assigneeId===fm||t.assignerId===fm);
|
||
if(fs) tasks = tasks.filter(t=>t.status===fs);
|
||
const list = $('#list');
|
||
if(!tasks.length){ list.innerHTML = emptyHtml('🗂','Задач не найдено по фильтру.'); return; }
|
||
list.innerHTML = tasks.map(taskRow).join('');
|
||
list.querySelectorAll('.task').forEach(bindTaskRow);
|
||
};
|
||
$('#flt-member').onchange = apply; $('#flt-status').onchange = apply;
|
||
apply();
|
||
}
|
||
|
||
/* ---------- Расписание (календарь) ---------- */
|
||
let calRef = new Date();
|
||
function renderSchedule(){
|
||
const y = calRef.getFullYear(), m = calRef.getMonth();
|
||
const first = new Date(y,m,1);
|
||
const startDow = (first.getDay()+6)%7; // понедельник = 0
|
||
const daysInMonth = new Date(y,m+1,0).getDate();
|
||
const monthName = first.toLocaleDateString('ru-RU',{month:'long', year:'numeric'});
|
||
const tasksByDate = {};
|
||
data.tasks.filter(t=>t.dueDate && t.status!=='cancelled').forEach(t=>{
|
||
(tasksByDate[t.dueDate] = tasksByDate[t.dueDate]||[]).push(t);
|
||
});
|
||
let cells = '';
|
||
const dows = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
|
||
cells += dows.map(d=>`<div class="cal-dow">${d}</div>`).join('');
|
||
for(let i=0;i<startDow;i++) cells += `<div class="cal-day off"></div>`;
|
||
const today = todayStr();
|
||
for(let d=1; d<=daysInMonth; d++){
|
||
const ds = fmt(new Date(y,m,d));
|
||
const tlist = tasksByDate[ds]||[];
|
||
cells += `<div class="cal-day ${ds===today?'today':''}">
|
||
<div class="cal-dnum">${d}</div>
|
||
${tlist.slice(0,3).map(t=>`<div class="cal-task" style="background:${colorFor(t.assigneeId)}22;color:${colorFor(t.assigneeId)};${t.status==='done'?'text-decoration:line-through;opacity:.6':''}" data-id="${t.id}">${escapeHtml(t.title)}</div>`).join('')}
|
||
${tlist.length>3?`<div class="text-xs text-muted">+${tlist.length-3} ещё</div>`:''}
|
||
</div>`;
|
||
}
|
||
$('#main').innerHTML = `
|
||
<div class="flex items-center justify-between mb-4">
|
||
<h2 class="text-2xl font-extrabold capitalize">${monthName}</h2>
|
||
<div class="flex gap-2">
|
||
<button id="cal-prev" class="iconbtn">‹</button>
|
||
<button id="cal-today" class="btn-ghost text-sm">Сегодня</button>
|
||
<button id="cal-next" class="iconbtn">›</button>
|
||
</div>
|
||
</div>
|
||
<div class="cal-grid">${cells}</div>
|
||
<div class="flex flex-wrap gap-3 mt-4 text-xs text-muted">
|
||
${data.members.map(m=>`<span class="flex items-center gap-1">${avatarHtml(m,14)} ${escapeHtml(m.name)}</span>`).join('')}
|
||
</div>`;
|
||
$('#cal-prev').onclick=()=>{ calRef=new Date(y,m-1,1); renderSchedule(); };
|
||
$('#cal-next').onclick=()=>{ calRef=new Date(y,m+1,1); renderSchedule(); };
|
||
$('#cal-today').onclick=()=>{ calRef=new Date(); renderSchedule(); };
|
||
$$('#main .cal-task').forEach(el=>el.onclick=()=>openTaskModal(el.dataset.id));
|
||
}
|
||
|
||
/* ---------- Семья ---------- */
|
||
function renderFamily(){
|
||
const cards = data.members.map(m=>{
|
||
const assigned = data.tasks.filter(t=>t.assigneeId===m.id && (t.status==='todo'||t.status==='postponed')).length;
|
||
const done = data.tasks.filter(t=>t.assigneeId===m.id && t.status==='done').length;
|
||
const gave = data.tasks.filter(t=>t.assignerId===m.id).length;
|
||
const overdue = data.tasks.filter(t=>t.assigneeId===m.id && t.status==='todo' && t.dueDate && parseDate(t.dueDate)<new Date(todayStr())).length;
|
||
return `
|
||
<div class="stat-card flex items-center gap-4">
|
||
${avatarHtml(m,52)}
|
||
<div class="flex-1 min-w-0">
|
||
<div class="font-bold text-lg">${escapeHtml(m.name)}</div>
|
||
<div class="text-xs text-muted">поручил другим: ${gave}</div>
|
||
</div>
|
||
<div class="grid grid-cols-3 gap-3 text-center">
|
||
<div><div class="text-xl font-extrabold text-amber-600">${assigned}</div><div class="text-[11px] text-muted">активных</div></div>
|
||
<div><div class="text-xl font-extrabold text-emerald-600">${done}</div><div class="text-[11px] text-muted">готово</div></div>
|
||
<div><div class="text-xl font-extrabold ${overdue?'text-red-600':'text-gray-300'}">${overdue}</div><div class="text-[11px] text-muted">просроч.</div></div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
$('#main').innerHTML = `
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h2 class="text-2xl font-extrabold">${escapeHtml(data.familyName)}</h2>
|
||
<p class="text-muted text-sm">Членов: ${data.members.length} · задач всего: ${data.tasks.length}</p>
|
||
</div>
|
||
<button id="fam-manage" class="btn-ghost text-sm">⚙️ Управление</button>
|
||
</div>
|
||
<div class="grid sm:grid-cols-2 gap-3">${cards}</div>`;
|
||
$('#fam-manage').onclick = openFamilyModal;
|
||
}
|
||
|
||
function emptyHtml(emoji, text, btnLabel, btnId){
|
||
return `<div class="empty"><div class="big">${emoji}</div><p>${text}</p>${btnLabel?`<button class="btn-primary mt-4" onclick="document.getElementById('${btnId}').click()">${btnLabel}</button>`:''}</div>`;
|
||
}
|
||
|
||
/* ============================================================
|
||
ДЕЙСТВИЯ С ЗАДАЧАМИ
|
||
============================================================ */
|
||
function setStatus(id, status){
|
||
const t = data.tasks.find(x=>x.id===id); if(!t) return;
|
||
const prev = t.status;
|
||
t.status = status;
|
||
t.updatedAt = Date.now();
|
||
if(status==='done') t.completedAt = Date.now();
|
||
pushHistory(t, status);
|
||
save(); render();
|
||
toast(STATUS[status].label+': '+t.title);
|
||
}
|
||
|
||
function pushHistory(t, action, detail){
|
||
t.history = t.history||[];
|
||
const labels = {done:'✅ Выполнено', todo:'↩ Возвращено в работу', postponed:'📅 Перенесено', cancelled:'✕ Отменено', created:'➕ Создано', edited:'✎ Изменено', assigned:'🔁 Назначено'};
|
||
t.history.push({ at:Date.now(), byId:me, action, detail:detail||labels[action]||action });
|
||
}
|
||
|
||
function openPostpone(id){
|
||
postponeId = id;
|
||
const t = data.tasks.find(x=>x.id===id);
|
||
$('#pp-date').value = t.dueDate || todayStr();
|
||
$('#postpone-modal').classList.remove('hidden');
|
||
}
|
||
$('#pp-cancel').onclick = ()=>$('#postpone-modal').classList.add('hidden');
|
||
$('#pp-ok').onclick = ()=>{
|
||
const t = data.tasks.find(x=>x.id===postponeId); if(!t) return;
|
||
const newDate = $('#pp-date').value;
|
||
t.dueDate = newDate;
|
||
t.status = 'postponed';
|
||
t.updatedAt = Date.now();
|
||
pushHistory(t,'postponed','на '+prettyDate(newDate));
|
||
save();
|
||
$('#postpone-modal').classList.add('hidden');
|
||
render();
|
||
toast('Перенесено на '+prettyDate(newDate));
|
||
};
|
||
|
||
/* ============================================================
|
||
МОДАЛКА ЗАДАЧИ (создание/редактирование)
|
||
============================================================ */
|
||
function openTaskModal(id){
|
||
editingId = id;
|
||
const t = id ? data.tasks.find(x=>x.id===id) : null;
|
||
$('#modal-title').textContent = t ? 'Редактировать задачу' : 'Новая задача';
|
||
$('#f-title').value = t?.title||'';
|
||
$('#f-note').value = t?.note||'';
|
||
$('#f-date').value = t?.dueDate||'';
|
||
$('#f-time').value = t?.time||'';
|
||
$('#f-priority').value = t?.priority||'medium';
|
||
$('#f-from').textContent = member(me).name;
|
||
// список исполнителей
|
||
$('#f-assignee').innerHTML = data.members.map(m=>`<option value="${m.id}" ${t&&t.assigneeId===m.id?'selected':m.id===me&&!t?'selected':''}>${escapeHtml(m.name)}</option>`).join('');
|
||
// история
|
||
if(t && t.history && t.history.length){
|
||
$('#f-history').classList.remove('hidden');
|
||
$('#f-history-list').innerHTML = t.history.slice().reverse().map(h=>{
|
||
const by = member(h.byId);
|
||
return `<div>${new Date(h.at).toLocaleString('ru-RU',{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})} · ${by?escapeHtml(by.name):'?'} — ${escapeHtml(h.detail)}</div>`;
|
||
}).join('');
|
||
} else { $('#f-history').classList.add('hidden'); }
|
||
$('#modal-delete').classList.toggle('hidden', !t);
|
||
$('#modal').classList.remove('hidden');
|
||
setTimeout(()=>$('#f-title').focus(),50);
|
||
}
|
||
$('#modal-close').onclick = ()=>$('#modal').classList.add('hidden');
|
||
$('#modal').addEventListener('click', e=>{ if(e.target.id==='modal') $('#modal').classList.add('hidden'); });
|
||
|
||
$('#modal-save').onclick = ()=>{
|
||
const title = $('#f-title').value.trim();
|
||
if(!title){ toast('Введите название задачи'); $('#f-title').focus(); return; }
|
||
const assigneeId = $('#f-assignee').value;
|
||
const payload = {
|
||
title, note: $('#f-note').value.trim(),
|
||
assigneeId,
|
||
dueDate: $('#f-date').value || null,
|
||
time: $('#f-time').value || null,
|
||
priority: $('#f-priority').value,
|
||
};
|
||
if(editingId){
|
||
const t = data.tasks.find(x=>x.id===editingId);
|
||
const reassigned = t.assigneeId !== assigneeId;
|
||
Object.assign(t, payload, {updatedAt:Date.now()});
|
||
if(reassigned) pushHistory(t,'assigned','→ '+member(assigneeId).name);
|
||
pushHistory(t,'edited');
|
||
toast('Изменено');
|
||
} else {
|
||
const t = Object.assign({ id:uid(), assignerId:me, status:'todo', createdAt:Date.now(), updatedAt:Date.now(), history:[] }, payload);
|
||
pushHistory(t,'created');
|
||
data.tasks.push(t);
|
||
toast('Задача создана ✅');
|
||
}
|
||
save();
|
||
$('#modal').classList.add('hidden');
|
||
render();
|
||
};
|
||
|
||
$('#modal-delete').onclick = ()=>{
|
||
if(!editingId) return;
|
||
if(!confirm('Удалить задачу безвозвратно?')) return;
|
||
data.tasks = data.tasks.filter(t=>t.id!==editingId);
|
||
save();
|
||
$('#modal').classList.add('hidden');
|
||
render();
|
||
toast('Задача удалена');
|
||
};
|
||
|
||
/* ============================================================
|
||
УПРАВЛЕНИЕ СЕМЬЁЙ
|
||
============================================================ */
|
||
function openFamilyModal(){
|
||
$('#fm-name').value = data.familyName;
|
||
renderFmList();
|
||
$('#family-modal').classList.remove('hidden');
|
||
}
|
||
function renderFmList(){
|
||
$('#fm-list').innerHTML = data.members.map((m,i)=>`
|
||
<div class="flex items-center gap-2" data-mid="${m.id}">
|
||
<span class="avatar w-9 h-9 fm-emoji" style="background:${m.color};font-size:16px;cursor:pointer" title="сменить аватар">${m.emoji||initials(m.name)}</span>
|
||
<input class="field fm-name" value="${escapeHtml(m.name)}">
|
||
<button class="iconbtn fm-pick" title="цвет/эмодзи">🎨</button>
|
||
<button class="iconbtn fm-del" title="удалить">🗑</button>
|
||
</div>`).join('');
|
||
$('#fm-list').children.forEach(row=>{
|
||
const mid = row.dataset.mid;
|
||
row.querySelector('.fm-pick').onclick = ()=>cycleAvatar(row, mid);
|
||
row.querySelector('.fm-emoji').onclick = ()=>cycleAvatar(row, mid);
|
||
row.querySelector('.fm-del').onclick = ()=>{
|
||
const m = member(mid);
|
||
if(!confirm(`Удалить «${m.name}»? Его задачи останутся, но без исполнителя.`)) return;
|
||
data.members = data.members.filter(x=>x.id!==mid);
|
||
data.tasks.forEach(t=>{ if(t.assigneeId===mid) t.assigneeId=null; if(t.assignerId===mid) t.assignerId=null; });
|
||
if(me===mid) me = data.members[0]?.id;
|
||
renderFmList();
|
||
};
|
||
});
|
||
}
|
||
function cycleAvatar(row, mid){
|
||
const m = member(mid); if(!m) return;
|
||
const ci = AVATAR_COLORS.indexOf(m.color);
|
||
const ei = AVATAR_EMOJIS.indexOf(m.emoji);
|
||
m.color = AVATAR_COLORS[(ci+1)%AVATAR_COLORS.length];
|
||
m.emoji = AVATAR_EMOJIS[(ei+1)%AVATAR_EMOJIS.length];
|
||
const av = row.querySelector('.fm-emoji');
|
||
av.style.background = m.color; av.textContent = m.emoji||initials(m.name);
|
||
}
|
||
$('#fm-add').onclick = ()=>{
|
||
const i = data.members.length;
|
||
data.members.push({ id:uid(), name:'Новый член', color:AVATAR_COLORS[i%AVATAR_COLORS.length], emoji:AVATAR_EMOJIS[i%AVATAR_EMOJIS.length] });
|
||
renderFmList();
|
||
};
|
||
$('#fm-close').onclick = ()=>$('#family-modal').classList.add('hidden');
|
||
$('#family-modal').addEventListener('click', e=>{ if(e.target.id==='family-modal') $('#family-modal').classList.add('hidden'); });
|
||
$('#fm-save').onclick = ()=>{
|
||
data.familyName = $('#fm-name').value.trim() || data.familyName;
|
||
$$('#fm-list .fm-name').forEach((inp,i)=>{ if(data.members[i]) data.members[i].name = inp.value.trim() || data.members[i].name; });
|
||
if(!member(me)) me = data.members[0]?.id;
|
||
save();
|
||
$('#family-modal').classList.add('hidden');
|
||
renderUserMenu(); setCurrentUser(me); render();
|
||
toast('Семья обновлена');
|
||
};
|
||
|
||
/* ============================================================
|
||
ЗАПУСК
|
||
============================================================ */
|
||
init();
|