feat: node section assignment for sidebar filtering + search fix
Backend: - Migration 004: add 'section' column to nodes table (NULL=inbox, values: clients/projects/recipes/documents/archive) - Create(parentID, type, title, section) — section stored on root nodes - ListRoots(includeDeleted, section) — filters by section (section='inbox' returns nodes with NULL section) - GET /api/nodes?section=X filters root nodes by section - POST /api/nodes accepts 'section' field in body Frontend: - Sidebar separates 'НАВИГАЦИЯ' (virtual sections) from 'ДЕЛА' (real nodes) - Each section loads only its own nodes: GET /api/nodes?section=clients etc. - Creating from a section sets the section automatically - Inbox shows only nodes with no section - selectBySearch(id) closes result dropdown after selection - All types shown in Russian (Дело, Заметка, Папка, etc.) Acceptance: go build pass, go test pass (all packages), manual: Pro projects section shows only project-nodes, clients only client-nodes, inbox only unsectioned nodes.
This commit is contained in:
+54
-27
@@ -381,10 +381,8 @@ function renderTreeFromCache(activeId){
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════
|
||||
SECTION RENDERERS
|
||||
Each section shows its OWN content from the API,
|
||||
filtered by what belongs here. For now we fetch
|
||||
all roots and filter client-side by simple heuristics.
|
||||
SECTION RENDERERS — each section loads only
|
||||
its own nodes via ?section= filter.
|
||||
════════════════════════════════════════════ */
|
||||
async function renderSectionContent(section){
|
||||
const m=SEC_META[section]||{};
|
||||
@@ -404,7 +402,7 @@ async function renderSectionContent(section){
|
||||
|
||||
async function renderSectionToday(title){
|
||||
let items=[];
|
||||
try{items=await api('/api/nodes')}catch(e){}
|
||||
try{items=await api('/api/nodes?section=')}catch(e){}
|
||||
|
||||
let h='<div class="dash"><h2>📅 '+esc(title)+'</h2>';
|
||||
h+='<div class="subtitle">'+new Date().toLocaleDateString('ru',{weekday:'long',day:'numeric',month:'long',year:'numeric'})+'</div>';
|
||||
@@ -423,17 +421,14 @@ async function renderSectionToday(title){
|
||||
|
||||
async function renderSectionInbox(title){
|
||||
let items=[];
|
||||
try{items=await api('/api/nodes')}catch(e){}
|
||||
// Inbox = root-level nodes with no clear section (heuristic: all roots since
|
||||
// we don't have a parent yet). In v2 this will use a proper inbox flag.
|
||||
const inbox=items; // all roots = potential inbox
|
||||
try{items=await api('/api/nodes?section=inbox')}catch(e){}
|
||||
|
||||
let h='<div class="dash"><h2>☰ '+esc(title)+'</h2>';
|
||||
h+='<div class="subtitle">Элементы без категории</div>';
|
||||
|
||||
if(inbox.length){
|
||||
if(items.length){
|
||||
h+='<div class="dash-section"><div class="dash-section-title">Неразобранные дела</div><div class="cg">';
|
||||
for(const n of inbox){
|
||||
for(const n of items){
|
||||
h+='<div class="card" data-id="'+n.id+'" onclick="selectNode(this)"><div class="ct">'+esc(n.title)+'</div><div class="cy">'+TL[n.type]+'</div></div>';
|
||||
}
|
||||
h+='</div></div>';
|
||||
@@ -445,14 +440,15 @@ async function renderSectionInbox(title){
|
||||
|
||||
async function renderSectionList(title, section){
|
||||
let items=[];
|
||||
try{items=await api('/api/nodes')}catch(e){}
|
||||
const qs = section==='inbox' ? 'inbox' : section;
|
||||
try{items=await api('/api/nodes?section='+encodeURIComponent(qs))}catch(e){}
|
||||
|
||||
let h='<div class="dash"><h2>'+esc(title)+'</h2>';
|
||||
h+='<div class="subtitle">'+esc(title)+'</div>';
|
||||
h+='<div class="dash-section">';
|
||||
h+='<div class="qa-grid" style="margin-bottom:20px">';
|
||||
h+='<button class="qa-btn" onclick="doAdd(\'case\')">◆ '+esc(title.slice(0,-1))+'</button>';
|
||||
h+='<button class="qa-btn" onclick="doAdd(\'note\')">✍ Заметка</button>';
|
||||
h+='<button class="qa-btn" onclick="doAddSection(\''+section+'\',\'case\')">◆ '+esc(title.slice(0,-1))+'</button>';
|
||||
h+='<button class="qa-btn" onclick="doAddSection(\''+section+'\',\'note\')">✍ Заметка</button>';
|
||||
h+='</div>';
|
||||
|
||||
if(items.length){
|
||||
@@ -465,7 +461,7 @@ async function renderSectionList(title, section){
|
||||
const m=SEC_META[section]||{};
|
||||
h+='<div class="empty" style="margin-top:30px"><p>'+esc(m.empty||'Пусто')+'</p>';
|
||||
if(m.hint)h+='<p style="font-size:12px;color:var(--text3);margin-top:4px">'+esc(m.hint)+'</p>';
|
||||
h+='<button class="btn primary" style="margin-top:12px" onclick="doAdd(\''+(m.action||'case')+'\')">+ Создать</button></div>';
|
||||
h+='<button class="btn primary" style="margin-top:12px" onclick="doAddSection(\''+section+'\',\''+(m.action||'case')+'\')">+ Создать</button></div>';
|
||||
}
|
||||
h+='</div></div>';setCnt(h);
|
||||
}
|
||||
@@ -588,7 +584,6 @@ function doAdd(kind){
|
||||
else if(kind==='note'){
|
||||
if(sel.kind==='node')openM('m-note');
|
||||
else if(sel.kind==='section'&&(sel.section==='today'||sel.section==='inbox'||sel.section==='clients'||sel.section==='projects')){
|
||||
// create note under selected section — unclear target, use modal
|
||||
openM('m-note');
|
||||
}else{E('Выберите дело слева для заметки');return}
|
||||
}
|
||||
@@ -596,6 +591,24 @@ function doAdd(kind){
|
||||
else if(kind==='action')openM('m-action');
|
||||
else if(kind==='worklog')openM('m-worklog');
|
||||
}
|
||||
function doAddSection(section, kind){
|
||||
closeAddMenu();
|
||||
// create node directly in the section (no modal)
|
||||
const title = prompt('Название:');
|
||||
if(!title||!title.trim())return;
|
||||
submitSectionNode(section, kind, title.trim());
|
||||
}
|
||||
async function submitSectionNode(section, kind, title){
|
||||
const body = {parent_id:'', type:kind, title};
|
||||
if(section && section!=='today' && section!=='inbox') body.section=section;
|
||||
try{
|
||||
const n = await api('/api/nodes',{method:'POST',body:JSON.stringify(body)});
|
||||
closeM('m-node');
|
||||
const items=await api('/api/nodes?section='+encodeURIComponent(section||''));
|
||||
if(sel.section)renderSectionContent(sel.section); else renderTree(items);
|
||||
selectNode({dataset:{id:n.id}});
|
||||
}catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
function showAddMenu(e){
|
||||
e.stopPropagation();
|
||||
const m=G('add-menu');
|
||||
@@ -610,32 +623,41 @@ document.addEventListener('click',()=>closeAddMenu());
|
||||
async function submitNode(){
|
||||
const t=G('mn-type').value,title=G('mn-title').value.trim(),parentName=G('mn-parent').value.trim();
|
||||
if(!title)return;
|
||||
let parentId='';
|
||||
let parentId='', section='';
|
||||
if(parentName){
|
||||
// find node by name
|
||||
try{const items=await api('/api/nodes');
|
||||
try{const items=await api('/api/nodes?section=');
|
||||
const found=items.find(n=>n.title.toLowerCase().startsWith(parentName.toLowerCase()));
|
||||
if(found)parentId=found.id;
|
||||
}catch(e){}
|
||||
}else if(sel.kind==='node'){parentId=sel.nodeId;}
|
||||
else if(sel.kind==='section' && sel.section!=='today' && sel.section!=='inbox'){
|
||||
section=sel.section;
|
||||
}
|
||||
try{
|
||||
const n=await api('/api/nodes',{method:'POST',body:JSON.stringify({parent_id:parentId,type:t,title})});
|
||||
const n=await api('/api/nodes',{method:'POST',body:JSON.stringify({parent_id:parentId,type:t,title,section})});
|
||||
closeM('m-node');
|
||||
// refresh tree
|
||||
const items=await api('/api/nodes');renderTree(items);
|
||||
const qs = section||'';
|
||||
const items=await api('/api/nodes?section='+encodeURIComponent(qs));
|
||||
if(sel.section){renderSectionContent(sel.section);}else{renderTree(items);}
|
||||
selectNode({dataset:{id:n.id}});
|
||||
}catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
async function submitNote(){
|
||||
const title=G('mn2-title').value.trim();
|
||||
if(!title)return;
|
||||
let parentId='';
|
||||
let parentId='', section='';
|
||||
if(sel.kind==='node')parentId=sel.nodeId;
|
||||
else if(sel.kind==='section'&&sel.section!=='today'&&sel.section!=='inbox'&&sel.section!=='archive'){/* no node */return E('Выберите дело для заметки')}
|
||||
else if(sel.kind==='section' && sel.section!=='today' && sel.section!=='inbox' && sel.section!=='archive'){
|
||||
section=sel.section;
|
||||
}else{return E('Выберите дело для заметки')}
|
||||
try{
|
||||
const n=await api('/api/notes/'+(parentId||''),{method:'POST',body:JSON.stringify({parent_id:parentId,title})});
|
||||
const body = {parent_id:parentId, title};
|
||||
if(section) body.section=section;
|
||||
const n=await api('/api/notes/'+(parentId||''),{method:'POST',body:JSON.stringify(body)});
|
||||
closeM('m-note');
|
||||
const items=await api('/api/nodes');renderTree(items);
|
||||
const qs = section||'';
|
||||
const items=await api('/api/nodes?section='+encodeURIComponent(qs));
|
||||
if(sel.section){renderSectionContent(sel.section);}else{renderTree(items);}
|
||||
selectNode({dataset:{id:n.id}});
|
||||
}catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
@@ -651,6 +673,11 @@ async function submitWorklog(){
|
||||
/* ════════════════════════════════════════════
|
||||
SEARCH
|
||||
════════════════════════════════════════════ */
|
||||
function selectBySearch(id){
|
||||
G('sr-res').innerHTML='';
|
||||
const el=document.querySelector('.ti[data-id="'+id+'"]');
|
||||
if(el)el.click(); else selectNode({dataset:{id}});
|
||||
}
|
||||
let sT=null;
|
||||
async function handleSR(q){
|
||||
clearTimeout(sT);const b=G('sr-res');
|
||||
@@ -662,7 +689,7 @@ async function handleSR(q){
|
||||
if(!hits.length){b.innerHTML='';return}
|
||||
let h='';
|
||||
for(const r of hits){
|
||||
h+='<div class="sri" data-id="'+r.id+'" onclick="selectNode(this);b.innerHTML=\'\'"><span class="srt">'+TL[r.type]+'</span><span class="sr-title">'+esc(r.title)+'</span></div>';
|
||||
h+='<div class="sri" data-id="'+r.id+'" onclick="selectBySearch(\''+r.id+'\')"><span class="srt">'+TL[r.type]+'</span><span class="sr-title">'+esc(r.title)+'</span></div>';
|
||||
}
|
||||
b.innerHTML=h;
|
||||
}catch(e){b.innerHTML=''}
|
||||
|
||||
+11
-6
@@ -99,15 +99,16 @@ func jsonErr(w http.ResponseWriter, code int, msg string) {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// GET /api/nodes[?parent=ID] POST /api/nodes
|
||||
// GET /api/nodes[?parent=ID§ion=X] POST /api/nodes
|
||||
func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
parent := r.URL.Query().Get("parent")
|
||||
section := r.URL.Query().Get("section")
|
||||
var list interface{}
|
||||
var err error
|
||||
if parent == "" {
|
||||
list, err = s.nodes.ListRoots(false)
|
||||
list, err = s.nodes.ListRoots(false, section)
|
||||
} else {
|
||||
list, err = s.nodes.ListChildren(parent, false)
|
||||
}
|
||||
@@ -121,12 +122,13 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
ParentID string `json:"parent_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Section string `json:"section"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
n, err := s.nodes.Create(req.ParentID, req.Type, req.Title)
|
||||
n, err := s.nodes.Create(req.ParentID, req.Type, req.Title, req.Section)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -188,9 +190,12 @@ func (s *Server) handleNotes(w http.ResponseWriter, r *http.Request) {
|
||||
content, _ := s.notes.Read(path)
|
||||
jsonOK(w, map[string]interface{}{"record": rec, "content": content})
|
||||
case "POST":
|
||||
var req struct{ Title string `json:"title"` }
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Section string `json:"section"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
n, _, err := s.notes.Create(path, req.Title)
|
||||
n, _, err := s.notes.Create(path, req.Title, req.Section)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -238,7 +243,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOK(w, []interface{}{})
|
||||
return
|
||||
}
|
||||
roots, _ := s.nodes.ListRoots(false)
|
||||
roots, _ := s.nodes.ListRoots(false, "")
|
||||
var hits []map[string]interface{}
|
||||
for _, n := range roots {
|
||||
if strings.Contains(strings.ToLower(n.Title), q) {
|
||||
|
||||
Reference in New Issue
Block a user