family-tasks/app.js

677 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ===== Семейный таск-менеджер (чистая статика, 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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'},
};
const CATEGORIES = [
{key:'home', label:'🏠 Дом', color:'#10b981'},
{key:'work', label:'💼 Работа', color:'#3b82f6'},
{key:'study', label:'📚 Учёба', color:'#8b5cf6'},
{key:'shop', label:'🛒 Покупки', color:'#f59e0b'},
{key:'kids', label:'👶 Дети', color:'#ec4899'},
{key:'money', label:'💰 Финансы', color:'#06b6d4'},
{key:'health', label:'❤️ Здоровье', color:'#ef4444'},
{key:'other', label:'🎉 Другое', color:'#6366f1'},
];
function catByKey(k){ return CATEGORIES.find(c=>c.key===k) || CATEGORIES[CATEGORIES.length-1]; }
function catOptions(sel){ return CATEGORIES.map(c=>`<option value="${c.key}">${c.label}</option>`).join(''); }
function formatDuration(min){
min = Number(min); if(!min) return '';
if(min<60) return min+' мин';
const h=Math.floor(min/60), m=min%60;
return m? `${h} ч ${m} мин` : `${h} ч`;
}
/* ============================================================
ИНИЦИАЛИЗАЦИЯ
============================================================ */
function init(){
data = load();
if(data && data.members && data.members.length){
// миграции
data.events = data.events || [];
(data.tasks||[]).forEach(t=>{
if(!t.category) t.category = 'other';
if(t.duration===undefined) t.duration = null;
});
}
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-members').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:[], events:[] };
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;
const cat = catByKey(t.category);
const dur = formatDuration(t.duration);
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">
<span class="chip" style="background:${cat.color}22;color:${cat.color}">${cat.label}</span>
<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>`:''}
${dur?`<span class="badge bg-gray-100 text-gray-600">⏱ ${dur}</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>
<select id="flt-cat" class="field !w-auto !py-2"><option value="">Все виды</option>${CATEGORIES.map(c=>`<option value="${c.key}">${c.label}</option>`).join('')}</select>
</div>
<div id="list" class="space-y-3"></div>`;
const apply = ()=>{
const fm = $('#flt-member').value, fs = $('#flt-status').value, fc = $('#flt-cat').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);
if(fc) tasks = tasks.filter(t=>t.category===fc);
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; $('#flt-cat').onchange = apply;
apply();
}
/* ---------- Расписание (календарь + личные события) ---------- */
let calRef = new Date();
let selDay = todayStr();
let schedScope = 'mine'; // 'mine' | 'all'
function dayItems(ds, scope){
let evs = (data.events||[]).filter(e=>e.date===ds);
let tks = data.tasks.filter(t=>t.dueDate===ds && t.status!=='cancelled');
if(scope==='mine'){
evs = evs.filter(e=>e.ownerId===me);
tks = tks.filter(t=>t.assigneeId===me);
}
return [
...evs.map(e=>({type:'event', sort:e.start||'00:00', obj:e})),
...tks.map(t=>({type:'task', sort:t.time||'zz', obj:t})),
].sort((a,b)=>a.sort.localeCompare(b.sort));
}
function renderSchedule(){
if(!selDay) selDay = todayStr();
const y = calRef.getFullYear(), m = calRef.getMonth();
const first = new Date(y,m,1);
const startDow = (first.getDay()+6)%7;
const daysInMonth = new Date(y,m+1,0).getDate();
const monthName = first.toLocaleDateString('ru-RU',{month:'long', year:'numeric'});
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 items = dayItems(ds, schedScope);
cells += `<div class="cal-day ${ds===today?'today':''} ${ds===selDay?'selected':''}" data-day="${ds}">
<div class="cal-dnum">${d}</div>
${items.slice(0,3).map(it=>chipHtml(it)).join('')}
${items.length>3?`<div class="text-xs text-muted">+${items.length-3}</div>`:''}
</div>`;
}
$('#main').innerHTML = `
<div class="flex items-center justify-between mb-4 gap-2 flex-wrap">
<h2 class="text-2xl font-extrabold capitalize">${monthName}</h2>
<div class="flex gap-2 items-center">
<div class="flex bg-line rounded-lg p-1">
<button id="sc-mine" class="seg ${schedScope==='mine'?'active':''}">Мои</button>
<button id="sc-all" class="seg ${schedScope==='all'?'active':''}">Вся семья</button>
</div>
<button id="cal-prev" class="iconbtn"></button>
<button id="cal-today" class="btn-ghost text-sm">Сегодня</button>
<button id="cal-next" class="iconbtn"></button>
<button id="cal-addev" class="btn-primary text-sm">+ Событие</button>
</div>
</div>
<div class="cal-grid">${cells}</div>
<div id="day-panel" class="mt-5"></div>
<div class="flex flex-wrap gap-3 mt-4 text-xs text-muted">
${CATEGORIES.map(c=>`<span class="flex items-center gap-1"><span class="dot" style="background:${c.color}"></span>${c.label}</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(); selDay=todayStr(); renderSchedule(); };
$('#cal-addev').onclick=()=>openEventModal(null, selDay);
$('#sc-mine').onclick=()=>{ schedScope='mine'; renderSchedule(); };
$('#sc-all').onclick=()=>{ schedScope='all'; renderSchedule(); };
$$('#main .cal-day[data-day]').forEach(el=>el.onclick=e=>{ if(e.target.closest('.cal-task')) return; selDay=el.dataset.day; renderSchedule(); });
$$('#main .cal-task').forEach(el=>el.onclick=e=>{ e.stopPropagation(); el.dataset.type==='event'?openEventModal(el.dataset.id):openTaskModal(el.dataset.id); });
renderDayPanel();
}
function chipHtml(it){
if(it.type==='event'){
const c=catByKey(it.obj.category);
return `<div class="cal-task" style="background:${c.color}22;color:${c.color}" data-type="event" data-id="${it.obj.id}">${escapeHtml(it.obj.start)} ${escapeHtml(it.obj.title)}</div>`;
}
const t=it.obj;
return `<div class="cal-task" style="background:${colorFor(t.assigneeId)}22;color:${colorFor(t.assigneeId)};${t.status==='done'?'text-decoration:line-through;opacity:.6':''}" data-type="task" data-id="${t.id}">✓ ${escapeHtml(t.title)}</div>`;
}
function renderDayPanel(){
const panel = $('#day-panel'); if(!panel) return;
const items = dayItems(selDay, schedScope);
const d = parseDate(selDay);
const dname = d ? d.toLocaleDateString('ru-RU',{weekday:'long',day:'numeric',month:'long'}) : selDay;
const isToday = selDay===todayStr();
panel.innerHTML = `
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 class="font-bold text-lg capitalize">${dname} ${isToday?'<span class="badge bg-accent-50 text-ink">сегодня</span>':''}</h3>
<button id="dp-addev" class="btn-ghost text-sm">+ событие в этот день</button>
</div>
${items.length? `<div class="space-y-2">${items.map(it=>dayRowHtml(it)).join('')}</div>` : emptyHtml('📅','На этот день ничего не запланировано.')}`;
$('#dp-addev')?.addEventListener('click', ()=>openEventModal(null, selDay));
panel.querySelectorAll('.day-row').forEach(r=>{
r.onclick=()=>{ r.dataset.type==='event'?openEventModal(r.dataset.id):openTaskModal(r.dataset.id); };
});
}
function dayRowHtml(it){
if(it.type==='event'){
const e=it.obj, c=catByKey(e.category), owner=member(e.ownerId);
return `<div class="day-row stat-card flex items-center gap-3" data-type="event" data-id="${e.id}" style="cursor:pointer">
<div class="time-badge" style="background:${c.color}1a;color:${c.color}">${escapeHtml(e.start)}<br><span class="opacity-60">${escapeHtml(e.end)}</span></div>
<div class="dot" style="background:${c.color}"></div>
<div class="flex-1 min-w-0">
<div class="font-semibold">${escapeHtml(e.title)}</div>
<div class="text-xs text-muted">${avatarHtml(owner,14)} ${escapeHtml(owner?owner.name:'—')} · ${c.label}</div>
</div>
<span class="text-muted">✎</span>
</div>`;
}
const t=it.obj, cat=catByKey(t.category), a=member(t.assigneeId);
return `<div class="day-row stat-card flex items-center gap-3" data-type="task" data-id="${t.id}" style="cursor:pointer">
<div class="time-badge" style="background:${colorFor(t.assigneeId)}1a;color:${colorFor(t.assigneeId)}">${escapeHtml(t.time||'—')}</div>
<div class="dot" style="background:${colorFor(t.assigneeId)}"></div>
<div class="flex-1 min-w-0">
<div class="font-semibold ${t.status==='done'?'done':''}">${escapeHtml(t.title)}</div>
<div class="text-xs text-muted">${avatarHtml(a,14)} ${escapeHtml(a?a.name:'—')} · ${cat.label}${t.duration?' · ⏱ '+formatDuration(t.duration):''}</div>
</div>
<span class="badge ${STATUS[t.status].cls}">${STATUS[t.status].label}</span>
<span class="text-muted">✎</span>
</div>`;
}
/* ---------- модалка события ---------- */
let editingEventId = null;
function openEventModal(id, dateStr){
editingEventId = id;
const e = id ? data.events.find(x=>x.id===id) : null;
$('#ev-title').textContent = e ? 'Событие' : 'Новое событие';
$('#ev-name').value = e?.title||'';
$('#ev-date').value = e?.date || dateStr || selDay || todayStr();
$('#ev-start').value = e?.start||'';
$('#ev-end').value = e?.end||'';
$('#ev-owner').innerHTML = data.members.map(m=>`<option value="${m.id}" ${(e?e.ownerId===m.id:m.id===me)?'selected':''}>${escapeHtml(m.name)}</option>`).join('');
$('#ev-cat').innerHTML = catOptions();
$('#ev-cat').value = e?.category||'other';
$('#ev-delete').classList.toggle('hidden', !e);
$('#event-modal').classList.remove('hidden');
setTimeout(()=>$('#ev-name').focus(),50);
}
$('#ev-close').onclick = ()=>$('#event-modal').classList.add('hidden');
$('#event-modal').addEventListener('click', e=>{ if(e.target.id==='event-modal') $('#event-modal').classList.add('hidden'); });
$('#ev-save').onclick = ()=>{
const title=$('#ev-name').value.trim();
const date=$('#ev-date').value;
const start=$('#ev-start').value, end=$('#ev-end').value;
if(!title){ toast('Введите название'); $('#ev-name').focus(); return; }
if(!date){ toast('Выберите дату'); return; }
if(!start||!end){ toast('Укажите время начала и конца'); return; }
if(start>=end){ toast('Конец должен быть позже начала'); return; }
const payload={ title, date, start, end, ownerId:$('#ev-owner').value, category:$('#ev-cat').value };
if(editingEventId){ Object.assign(data.events.find(x=>x.id===editingEventId), payload); toast('Сохранено'); }
else { data.events.push(Object.assign({id:uid()}, payload)); toast('Событие добавлено 📅'); }
selDay = date;
save(); $('#event-modal').classList.add('hidden'); render();
};
$('#ev-delete').onclick = ()=>{
if(!editingEventId) return;
if(!confirm('Удалить событие?')) return;
data.events = data.events.filter(x=>x.id!==editingEventId);
save(); $('#event-modal').classList.add('hidden'); render();
toast('Событие удалено');
};
/* ---------- Семья ---------- */
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-duration').value = t?.duration||'';
$('#f-category').innerHTML = catOptions();
$('#f-category').value = t?.category||'other';
$('#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,
duration: $('#f-duration').value ? Number($('#f-duration').value) : null,
priority: $('#f-priority').value,
category: $('#f-category').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();