activity: global feed, per-case log, sidebar section, today UX
- migration 009: target_type, target_id, target_path columns - new Event fields: TargetType, TargetID, TargetPath - ListActivityFeed (paginated global), ListActivityByNode (per-case) - all Record() callsites pass target info - frontend: Активность sidebar section with chronological feed - per-case Активность tab with real data (was placeholder) - today events: clickable, target-type badges, event counts
This commit is contained in:
parent
5a1c4c6d7f
commit
3672e3133b
|
|
@ -120,13 +120,15 @@ type SearchResultDTO struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventDTO struct {
|
type EventDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
NodeID string `json:"nodeId"`
|
NodeID string `json:"nodeId"`
|
||||||
ParentID string `json:"parentId"`
|
EventType string `json:"eventType"`
|
||||||
EventType string `json:"eventType"`
|
TargetType string `json:"targetType"`
|
||||||
Title string `json:"title"`
|
TargetID string `json:"targetId"`
|
||||||
Metadata string `json:"metadata"`
|
TargetPath string `json:"targetPath"`
|
||||||
CreatedAt string `json:"createdAt"`
|
Title string `json:"title"`
|
||||||
|
DetailsJSON string `json:"detailsJson"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CaseActivityDTO struct {
|
type CaseActivityDTO struct {
|
||||||
|
|
@ -166,6 +168,7 @@ func (a *App) ListSections() []SectionDTO {
|
||||||
return []SectionDTO{
|
return []SectionDTO{
|
||||||
{ID: "today", Label: "Сегодня"},
|
{ID: "today", Label: "Сегодня"},
|
||||||
{ID: "inbox", Label: "Неразобранное"},
|
{ID: "inbox", Label: "Неразобранное"},
|
||||||
|
{ID: "activity", Label: "Активность"},
|
||||||
{ID: "clients", Label: "Клиенты"},
|
{ID: "clients", Label: "Клиенты"},
|
||||||
{ID: "projects", Label: "Проекты"},
|
{ID: "projects", Label: "Проекты"},
|
||||||
{ID: "recipes", Label: "Рецепты"},
|
{ID: "recipes", Label: "Рецепты"},
|
||||||
|
|
@ -200,10 +203,13 @@ func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
|
||||||
todayNodes, _ := a.nodes.ListTodayNodes()
|
todayNodes, _ := a.nodes.ListTodayNodes()
|
||||||
|
|
||||||
type rawEvent struct {
|
type rawEvent struct {
|
||||||
NodeID string
|
NodeID string
|
||||||
EventType string
|
EventType string
|
||||||
Title string
|
TargetType string
|
||||||
CreatedAt string
|
TargetID string
|
||||||
|
TargetPath string
|
||||||
|
Title string
|
||||||
|
CreatedAt string
|
||||||
}
|
}
|
||||||
type caseInfo struct {
|
type caseInfo struct {
|
||||||
Node nodes.Node
|
Node nodes.Node
|
||||||
|
|
@ -228,10 +234,13 @@ func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
|
||||||
ci := ensureCase(pid)
|
ci := ensureCase(pid)
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
ci.Events = append(ci.Events, rawEvent{
|
ci.Events = append(ci.Events, rawEvent{
|
||||||
NodeID: e.NodeID,
|
NodeID: e.NodeID,
|
||||||
EventType: e.EventType,
|
EventType: e.EventType,
|
||||||
Title: e.Title,
|
TargetType: e.TargetType,
|
||||||
CreatedAt: e.CreatedAt,
|
TargetID: e.TargetID,
|
||||||
|
TargetPath: e.TargetPath,
|
||||||
|
Title: e.Title,
|
||||||
|
CreatedAt: e.CreatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -257,12 +266,13 @@ func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
|
||||||
dtoEvents := make([]EventDTO, 0, len(ci.Events))
|
dtoEvents := make([]EventDTO, 0, len(ci.Events))
|
||||||
for _, re := range ci.Events {
|
for _, re := range ci.Events {
|
||||||
dtoEvents = append(dtoEvents, EventDTO{
|
dtoEvents = append(dtoEvents, EventDTO{
|
||||||
ID: ci.Node.ID + "/" + re.NodeID,
|
ID: ci.Node.ID + "/" + re.NodeID + "/" + re.CreatedAt,
|
||||||
NodeID: re.NodeID,
|
NodeID: re.NodeID,
|
||||||
ParentID: ci.Node.ID,
|
EventType: re.EventType,
|
||||||
EventType: re.EventType,
|
TargetType: re.TargetType,
|
||||||
Title: re.Title,
|
TargetID: re.TargetID,
|
||||||
Metadata: "{}",
|
TargetPath: re.TargetPath,
|
||||||
|
Title: re.Title,
|
||||||
CreatedAt: re.CreatedAt,
|
CreatedAt: re.CreatedAt,
|
||||||
})
|
})
|
||||||
switch re.EventType {
|
switch re.EventType {
|
||||||
|
|
@ -306,6 +316,48 @@ func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toEventDTO(e activity.Event) EventDTO {
|
||||||
|
return EventDTO{
|
||||||
|
ID: e.ID,
|
||||||
|
NodeID: e.NodeID,
|
||||||
|
EventType: e.EventType,
|
||||||
|
TargetType: e.TargetType,
|
||||||
|
TargetID: e.TargetID,
|
||||||
|
TargetPath: e.TargetPath,
|
||||||
|
Title: e.Title,
|
||||||
|
DetailsJSON: e.DetailsJSON,
|
||||||
|
CreatedAt: e.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ListActivityFeed(limit, offset int) ([]EventDTO, error) {
|
||||||
|
events, err := a.activity.ListRecent(limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]EventDTO, len(events))
|
||||||
|
for i, e := range events {
|
||||||
|
result[i] = toEventDTO(e)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO, error) {
|
||||||
|
events, err := a.activity.ListByNode(nodeID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]EventDTO, len(events))
|
||||||
|
for i, e := range events {
|
||||||
|
result[i] = toEventDTO(e)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) CountActivityByNode(nodeID string) (int, error) {
|
||||||
|
return a.activity.CountByNode(nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
|
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
|
||||||
list, err := a.nodes.ListChildren(parentID, false)
|
list, err := a.nodes.ListChildren(parentID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -331,7 +383,7 @@ func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, e
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(n.ID, parentID, activity.TypeNodeCreated, title, "")
|
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "")
|
||||||
dto := toNodeDTO(n)
|
dto := toNodeDTO(n)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
@ -365,7 +417,7 @@ func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(node.ID, parentID, activity.TypeNoteCreated, title, "")
|
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
|
||||||
dto := toNodeDTO(node)
|
dto := toNodeDTO(node)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
@ -386,7 +438,7 @@ func (a *App) SaveNote(noteID, content string) error {
|
||||||
if n.ParentID != nil {
|
if n.ParentID != nil {
|
||||||
pid = *n.ParentID
|
pid = *n.ParentID
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(noteID, pid, activity.TypeNoteUpdated, n.Title, "")
|
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -458,7 +510,7 @@ func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
_ = a.activity.Record(n.ID, nodeID, activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||||
}
|
}
|
||||||
return toNodeDTOs(nodes), nil
|
return toNodeDTOs(nodes), nil
|
||||||
}
|
}
|
||||||
|
|
@ -469,7 +521,7 @@ func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
_ = a.activity.Record(n.ID, nodeID, activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||||
}
|
}
|
||||||
return toNodeDTOs(nodes), nil
|
return toNodeDTOs(nodes), nil
|
||||||
}
|
}
|
||||||
|
|
@ -482,10 +534,12 @@ func (a *App) DeleteFileOrFolder(nodeID string) error {
|
||||||
pid = *n.ParentID
|
pid = *n.ParentID
|
||||||
}
|
}
|
||||||
evType := activity.TypeFileDeleted
|
evType := activity.TypeFileDeleted
|
||||||
|
targetType := activity.TargetFile
|
||||||
if n.Type == nodes.TypeFolder {
|
if n.Type == nodes.TypeFolder {
|
||||||
evType = activity.TypeFolderDeleted
|
evType = activity.TypeFolderDeleted
|
||||||
|
targetType = activity.TargetFolder
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(nodeID, pid, evType, n.Title, "")
|
_ = a.activity.Record(pid, targetType, nodeID, "", evType, n.Title, "")
|
||||||
}
|
}
|
||||||
return a.files.DeleteNodeAndChildren(nodeID)
|
return a.files.DeleteNodeAndChildren(nodeID)
|
||||||
}
|
}
|
||||||
|
|
@ -495,7 +549,7 @@ func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(node.ID, parentID, activity.TypeFileAdded, filename, "")
|
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
|
||||||
dto := toNodeDTO(node)
|
dto := toNodeDTO(node)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
@ -511,7 +565,7 @@ func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
|
||||||
if err2 == nil && n.ParentID != nil {
|
if err2 == nil && n.ParentID != nil {
|
||||||
pid = *n.ParentID
|
pid = *n.ParentID
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(node.ID, pid, activity.TypeFileCopied, node.Title, "")
|
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
|
||||||
dto := toNodeDTO(node)
|
dto := toNodeDTO(node)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
@ -530,10 +584,12 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||||
pid = *n.ParentID
|
pid = *n.ParentID
|
||||||
}
|
}
|
||||||
evType := activity.TypeFileRenamed
|
evType := activity.TypeFileRenamed
|
||||||
|
targetType := activity.TargetFile
|
||||||
if n.Type == nodes.TypeFolder {
|
if n.Type == nodes.TypeFolder {
|
||||||
evType = activity.TypeFolderRenamed
|
evType = activity.TypeFolderRenamed
|
||||||
|
targetType = activity.TargetFolder
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(nodeID, pid, evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
|
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -568,7 +624,7 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||||
if node.ParentID != nil {
|
if node.ParentID != nil {
|
||||||
pid = *node.ParentID
|
pid = *node.ParentID
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(nodeID, pid, activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`)
|
_ = a.activity.Record(pid, activity.TargetFile, nodeID, "", activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,11 @@
|
||||||
let sections = []
|
let sections = []
|
||||||
let nodes = []
|
let nodes = []
|
||||||
let todayDashboard = null
|
let todayDashboard = null
|
||||||
|
let activityFeed = []
|
||||||
|
let activityOffset = 0
|
||||||
|
let activityHasMore = true
|
||||||
|
let activityLoading = false
|
||||||
|
let caseActivity = []
|
||||||
let version = ''
|
let version = ''
|
||||||
let error = ''
|
let error = ''
|
||||||
let selectedSection = ''
|
let selectedSection = ''
|
||||||
|
|
@ -100,6 +105,7 @@
|
||||||
sections = [
|
sections = [
|
||||||
{ id: 'today', label: 'Сегодня' },
|
{ id: 'today', label: 'Сегодня' },
|
||||||
{ id: 'inbox', label: 'Неразобранное' },
|
{ id: 'inbox', label: 'Неразобранное' },
|
||||||
|
{ id: 'activity', label: 'Активность' },
|
||||||
{ id: 'clients', label: 'Клиенты' },
|
{ id: 'clients', label: 'Клиенты' },
|
||||||
{ id: 'projects', label: 'Проекты' },
|
{ id: 'projects', label: 'Проекты' },
|
||||||
{ id: 'recipes', label: 'Рецепты' },
|
{ id: 'recipes', label: 'Рецепты' },
|
||||||
|
|
@ -136,10 +142,17 @@
|
||||||
showCreateNode = false
|
showCreateNode = false
|
||||||
error = ''
|
error = ''
|
||||||
todayDashboard = null
|
todayDashboard = null
|
||||||
|
activityFeed = []
|
||||||
|
activityOffset = 0
|
||||||
|
activityHasMore = true
|
||||||
nodes = []
|
nodes = []
|
||||||
try {
|
try {
|
||||||
if (id === 'today') {
|
if (id === 'today') {
|
||||||
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
||||||
|
} else if (id === 'activity') {
|
||||||
|
activityFeed = await wailsCall('ListActivityFeed', 50, 0) || []
|
||||||
|
activityOffset = activityFeed.length
|
||||||
|
activityHasMore = activityFeed.length === 50
|
||||||
} else {
|
} else {
|
||||||
nodes = await wailsCall('ListNodesBySection', id) || []
|
nodes = await wailsCall('ListNodesBySection', id) || []
|
||||||
}
|
}
|
||||||
|
|
@ -147,6 +160,7 @@
|
||||||
error = String(e)
|
error = String(e)
|
||||||
nodes = []
|
nodes = []
|
||||||
todayDashboard = { cases: [] }
|
todayDashboard = { cases: [] }
|
||||||
|
activityFeed = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,6 +185,7 @@
|
||||||
showCreateNode = false
|
showCreateNode = false
|
||||||
showCreateNote = false
|
showCreateNote = false
|
||||||
error = ''
|
error = ''
|
||||||
|
caseActivity = []
|
||||||
await loadTabData(node.id)
|
await loadTabData(node.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,6 +194,7 @@
|
||||||
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
|
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
|
||||||
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
|
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
|
||||||
try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {}
|
try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {}
|
||||||
|
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTree(nodeID) {
|
async function loadTree(nodeID) {
|
||||||
|
|
@ -831,7 +847,7 @@
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox'}
|
{#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-label">Дела {#if nodes.length > 0}({nodes.length}){/if}</div>
|
<div class="nav-label">Дела {#if nodes.length > 0}({nodes.length}){/if}</div>
|
||||||
{#each nodes as node}
|
{#each nodes as node}
|
||||||
|
|
@ -1097,7 +1113,23 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if activeTab === 'activity'}
|
{:else if activeTab === 'activity'}
|
||||||
<div class="empty-state"><p>Активность появится позже</p></div>
|
<div class="activity-tab">
|
||||||
|
{#if caseActivity.length === 0}
|
||||||
|
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
|
||||||
|
{:else}
|
||||||
|
<div class="activity-events">
|
||||||
|
{#each caseActivity as ev}
|
||||||
|
<div class="activity-event">
|
||||||
|
<span class="activity-event-icon">{eventIcon(ev.eventType)}</span>
|
||||||
|
<span class="activity-event-title">{ev.title}</span>
|
||||||
|
<span class="activity-event-type">{eventLabel(ev.eventType)}</span>
|
||||||
|
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
|
||||||
|
<span class="activity-event-time">{formatTime(ev.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1121,21 +1153,23 @@
|
||||||
<div class="today-case-header" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
|
<div class="today-case-header" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
|
||||||
<span class="today-case-title">{group.nodeTitle}</span>
|
<span class="today-case-title">{group.nodeTitle}</span>
|
||||||
<span class="today-case-type">{nodeKindLabel(group.nodeKind)}</span>
|
<span class="today-case-type">{nodeKindLabel(group.nodeKind)}</span>
|
||||||
|
{#if group.events}<span class="today-case-count">{group.events.length} {pluralize(group.events.length, 'событие', 'события', 'событий')}</span>{/if}
|
||||||
<span class="today-case-time">{formatTime(group.lastActivityAt)}</span>
|
<span class="today-case-time">{formatTime(group.lastActivityAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if group.events && group.events.length > 0}
|
{#if group.events && group.events.length > 0}
|
||||||
<div class="today-events">
|
<div class="today-events">
|
||||||
{#each group.events as ev}
|
{#each group.events as ev}
|
||||||
<div class="today-event">
|
<div class="today-event" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
|
||||||
<span class="today-event-icon">{eventIcon(ev.eventType)}</span>
|
<span class="today-event-icon">{eventIcon(ev.eventType)}</span>
|
||||||
<span class="today-event-title">{ev.title}</span>
|
<span class="today-event-title">{ev.title}</span>
|
||||||
<span class="today-event-type">{eventLabel(ev.eventType)}</span>
|
<span class="today-event-type">{eventLabel(ev.eventType)}</span>
|
||||||
|
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
|
||||||
<span class="today-event-time">{formatTime(ev.createdAt)}</span>
|
<span class="today-event-time">{formatTime(ev.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="today-events-empty">Изменён сегодня, подробная история пока недоступна</div>
|
<div class="today-events-empty">Изменён сегодня</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -1144,7 +1178,7 @@
|
||||||
<div class="today-timeline">
|
<div class="today-timeline">
|
||||||
<h3>Лента за сегодня</h3>
|
<h3>Лента за сегодня</h3>
|
||||||
{#each todayDashboard.events as ev}
|
{#each todayDashboard.events as ev}
|
||||||
<div class="timeline-event">
|
<div class="timeline-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
|
||||||
<span class="timeline-dot"></span>
|
<span class="timeline-dot"></span>
|
||||||
<span class="timeline-title">{ev.title}</span>
|
<span class="timeline-title">{ev.title}</span>
|
||||||
<span class="timeline-type">{eventLabel(ev.eventType)}</span>
|
<span class="timeline-type">{eventLabel(ev.eventType)}</span>
|
||||||
|
|
@ -1161,6 +1195,32 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{:else if selectedSection === 'activity'}
|
||||||
|
<div class="activity-feed">
|
||||||
|
<div class="activity-feed-header">
|
||||||
|
<h2>Активность</h2>
|
||||||
|
</div>
|
||||||
|
{#if activityFeed.length === 0}
|
||||||
|
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
|
||||||
|
{:else}
|
||||||
|
<div class="activity-feed-events">
|
||||||
|
{#each activityFeed as ev}
|
||||||
|
<div class="activity-feed-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
|
||||||
|
<span class="activity-feed-icon">{eventIcon(ev.eventType)}</span>
|
||||||
|
<div class="activity-feed-body">
|
||||||
|
<span class="activity-feed-title">{ev.title}</span>
|
||||||
|
<div class="activity-feed-meta">
|
||||||
|
<span class="activity-feed-type">{eventLabel(ev.eventType)}</span>
|
||||||
|
{#if ev.targetType}<span class="activity-feed-target">{ev.targetType}</span>{/if}
|
||||||
|
<span class="activity-feed-time">{formatDate(ev.createdAt)} {formatTime(ev.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="welcome">
|
<div class="welcome">
|
||||||
<h2>Верстак</h2>
|
<h2>Верстак</h2>
|
||||||
|
|
@ -1172,7 +1232,7 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox'}
|
{#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
||||||
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div>
|
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -1188,7 +1248,7 @@
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Раздел</label>
|
<label>Раздел</label>
|
||||||
<select bind:value={newNodeSection}>
|
<select bind:value={newNodeSection}>
|
||||||
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox') as s}
|
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox' && s.id !== 'activity') as s}
|
||||||
<option value={s.id}>{s.label}</option>
|
<option value={s.id}>{s.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -1412,9 +1472,11 @@
|
||||||
.today-case-header:hover { background: #1e1e30; }
|
.today-case-header:hover { background: #1e1e30; }
|
||||||
.today-case-title { font-weight: 500; }
|
.today-case-title { font-weight: 500; }
|
||||||
.today-case-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
|
.today-case-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
|
||||||
|
.today-case-count { font-size: 11px; color: #6366f1; margin-left: 4px; }
|
||||||
.today-case-time { font-size: 11px; color: #555; margin-left: auto; }
|
.today-case-time { font-size: 11px; color: #555; margin-left: auto; }
|
||||||
.today-events { padding: 8px 16px; }
|
.today-events { padding: 8px 16px; }
|
||||||
.today-event { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; color: #b0b0c0; }
|
.today-event { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; color: #b0b0c0; cursor: pointer; }
|
||||||
|
.today-event:hover { color: #e4e4ef; }
|
||||||
.today-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; }
|
.today-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; }
|
||||||
.today-event-title { flex: 1; }
|
.today-event-title { flex: 1; }
|
||||||
.today-event-type { font-size: 11px; color: #666; }
|
.today-event-type { font-size: 11px; color: #666; }
|
||||||
|
|
@ -1425,9 +1487,36 @@
|
||||||
.today-empty .hint { font-size: 12px; color: #555; margin-top: 8px; }
|
.today-empty .hint { font-size: 12px; color: #555; margin-top: 8px; }
|
||||||
.today-timeline { margin-top: 24px; }
|
.today-timeline { margin-top: 24px; }
|
||||||
.today-timeline h3 { font-size: 13px; color: #666; text-transform: uppercase; margin-bottom: 12px; }
|
.today-timeline h3 { font-size: 13px; color: #666; text-transform: uppercase; margin-bottom: 12px; }
|
||||||
.timeline-event { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; color: #b0b0c0; border-left: 2px solid #2a2a3c; padding-left: 16px; margin-left: 4px; }
|
.timeline-event { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; color: #b0b0c0; border-left: 2px solid #2a2a3c; padding-left: 16px; margin-left: 4px; cursor: pointer; }
|
||||||
|
.timeline-event:hover { color: #e4e4ef; }
|
||||||
.timeline-dot { width: 6px; height: 6px; border-radius: 50%; background: #6366f1; margin-left: -19px; flex-shrink: 0; }
|
.timeline-dot { width: 6px; height: 6px; border-radius: 50%; background: #6366f1; margin-left: -19px; flex-shrink: 0; }
|
||||||
.timeline-title { flex: 1; }
|
.timeline-title { flex: 1; }
|
||||||
.timeline-type { font-size: 11px; color: #666; }
|
.timeline-type { font-size: 11px; color: #666; }
|
||||||
.timeline-time { font-size: 11px; color: #555; }
|
.timeline-time { font-size: 11px; color: #555; }
|
||||||
|
|
||||||
|
/* Activity tab (per-case) */
|
||||||
|
.activity-tab { padding: 24px; }
|
||||||
|
.activity-events { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.activity-event { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 13px; color: #b0b0c0; cursor: pointer; }
|
||||||
|
.activity-event:hover { background: #1a1a28; color: #e4e4ef; }
|
||||||
|
.activity-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; flex-shrink: 0; }
|
||||||
|
.activity-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.activity-event-type { font-size: 11px; color: #666; flex-shrink: 0; }
|
||||||
|
.activity-event-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; flex-shrink: 0; }
|
||||||
|
.activity-event-time { font-size: 11px; color: #555; margin-left: 8px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Activity feed (global section) */
|
||||||
|
.activity-feed { padding: 24px; overflow-y: auto; flex: 1; }
|
||||||
|
.activity-feed-header { margin-bottom: 20px; }
|
||||||
|
.activity-feed-header h2 { font-size: 24px; }
|
||||||
|
.activity-feed-events { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.activity-feed-event { display: flex; align-items: flex-start; gap: 10px; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
|
||||||
|
.activity-feed-event:hover { background: #1a1a28; color: #e4e4ef; }
|
||||||
|
.activity-feed-icon { width: 20px; text-align: center; color: #6366f1; font-size: 14px; flex-shrink: 0; margin-top: 1px; }
|
||||||
|
.activity-feed-body { flex: 1; min-width: 0; }
|
||||||
|
.activity-feed-title { font-size: 14px; color: #e4e4ef; }
|
||||||
|
.activity-feed-meta { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
|
||||||
|
.activity-feed-type { font-size: 11px; color: #666; }
|
||||||
|
.activity-feed-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; }
|
||||||
|
.activity-feed-time { font-size: 11px; color: #555; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package activity
|
package activity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
|
|
@ -9,29 +10,44 @@ import (
|
||||||
|
|
||||||
// Event types.
|
// Event types.
|
||||||
const (
|
const (
|
||||||
TypeNoteCreated = "note_created"
|
TypeNoteCreated = "note_created"
|
||||||
TypeNoteUpdated = "note_updated"
|
TypeNoteUpdated = "note_updated"
|
||||||
TypeFileAdded = "file_added"
|
TypeFileAdded = "file_added"
|
||||||
TypeFileDeleted = "file_deleted"
|
TypeFileDeleted = "file_deleted"
|
||||||
TypeFileRenamed = "file_renamed"
|
TypeFileRenamed = "file_renamed"
|
||||||
TypeFileCopied = "file_copied"
|
TypeFileCopied = "file_copied"
|
||||||
TypeFileMoved = "file_moved"
|
TypeFileMoved = "file_moved"
|
||||||
TypeFolderAdded = "folder_added"
|
TypeFolderAdded = "folder_added"
|
||||||
TypeFolderDeleted = "folder_deleted"
|
TypeFolderDeleted = "folder_deleted"
|
||||||
TypeFolderRenamed = "folder_renamed"
|
TypeFolderRenamed = "folder_renamed"
|
||||||
TypeNodeCreated = "node_created"
|
TypeNodeCreated = "node_created"
|
||||||
TypeNodeUpdated = "node_updated"
|
TypeNodeUpdated = "node_updated"
|
||||||
|
TypeActionCreated = "action_created"
|
||||||
|
TypeActionDone = "action_done"
|
||||||
|
TypeWorklogAdded = "worklog_added"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Target types.
|
||||||
|
const (
|
||||||
|
TargetNote = "note"
|
||||||
|
TargetFile = "file"
|
||||||
|
TargetFolder = "folder"
|
||||||
|
TargetAction = "action"
|
||||||
|
TargetNode = "node"
|
||||||
|
TargetWorklog = "worklog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event represents an activity event.
|
// Event represents an activity event.
|
||||||
type Event struct {
|
type Event struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
ParentID string `json:"parent_id,omitempty"`
|
EventType string `json:"event_type"`
|
||||||
EventType string `json:"event_type"`
|
TargetType string `json:"target_type,omitempty"`
|
||||||
Title string `json:"title"`
|
TargetID string `json:"target_id,omitempty"`
|
||||||
Metadata string `json:"metadata"`
|
TargetPath string `json:"target_path,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
Title string `json:"title"`
|
||||||
|
DetailsJSON string `json:"details_json,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service records and queries activity events.
|
// Service records and queries activity events.
|
||||||
|
|
@ -44,27 +60,16 @@ func NewService(db *storage.DB) *Service {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record inserts a new activity event.
|
// Record inserts a new activity event.
|
||||||
func (s *Service) Record(nodeID, parentID, eventType, title, metadata string) error {
|
func (s *Service) Record(nodeID, targetType, targetID, targetPath, eventType, title, detailsJSON string) error {
|
||||||
id := util.UUID7()
|
id := util.UUID7()
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
if metadata == "" {
|
|
||||||
metadata = "{}"
|
|
||||||
}
|
|
||||||
if parentID != "" {
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
`INSERT INTO activity_events(id,node_id,parent_id,event_type,title,metadata,created_at)
|
|
||||||
VALUES(?,?,?,?,?,?,?)`,
|
|
||||||
id, nodeID, parentID, eventType, title, metadata, now)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO activity_events(id,node_id,event_type,title,metadata,created_at)
|
`INSERT INTO activity_events(id,node_id,event_type,target_type,target_id,target_path,title,metadata,created_at)
|
||||||
VALUES(?,?,?,?,?,?)`,
|
VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||||
id, nodeID, eventType, title, metadata, now)
|
id, nodeID, eventType, targetType, targetID, targetPath, title, detailsJSON, now)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// todayBoundaries returns the current local day in UTC.
|
|
||||||
// TodayBoundaries returns the current local day in UTC as RFC3339 strings.
|
// TodayBoundaries returns the current local day in UTC as RFC3339 strings.
|
||||||
func TodayBoundaries() (string, string) {
|
func TodayBoundaries() (string, string) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
@ -74,11 +79,21 @@ func TodayBoundaries() (string, string) {
|
||||||
return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339)
|
return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scanEvent scans a single event row.
|
||||||
|
func scanEvent(rows *sql.Rows) (Event, error) {
|
||||||
|
var e Event
|
||||||
|
err := rows.Scan(&e.ID, &e.NodeID, &e.EventType,
|
||||||
|
&e.TargetType, &e.TargetID, &e.TargetPath,
|
||||||
|
&e.Title, &e.DetailsJSON, &e.CreatedAt)
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
|
||||||
// ListTodayEvents returns all activity events from today.
|
// ListTodayEvents returns all activity events from today.
|
||||||
func (s *Service) ListTodayEvents() ([]Event, error) {
|
func (s *Service) ListTodayEvents() ([]Event, error) {
|
||||||
start, end := TodayBoundaries()
|
start, end := TodayBoundaries()
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT id,node_id,COALESCE(parent_id,''),event_type,title,metadata,created_at
|
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
|
||||||
|
title,COALESCE(metadata,'{}'),created_at
|
||||||
FROM activity_events
|
FROM activity_events
|
||||||
WHERE created_at >= ? AND created_at < ?
|
WHERE created_at >= ? AND created_at < ?
|
||||||
ORDER BY created_at DESC`, start, end)
|
ORDER BY created_at DESC`, start, end)
|
||||||
|
|
@ -88,8 +103,8 @@ func (s *Service) ListTodayEvents() ([]Event, error) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var events []Event
|
var events []Event
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e Event
|
e, err := scanEvent(rows)
|
||||||
if err := rows.Scan(&e.ID, &e.NodeID, &e.ParentID, &e.EventType, &e.Title, &e.Metadata, &e.CreatedAt); err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
events = append(events, e)
|
events = append(events, e)
|
||||||
|
|
@ -97,7 +112,7 @@ func (s *Service) ListTodayEvents() ([]Event, error) {
|
||||||
return events, rows.Err()
|
return events, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTodayEventsByParent returns activity events grouped by parent node.
|
// ListTodayEventsByParent returns today's events grouped by their NodeID.
|
||||||
func (s *Service) ListTodayEventsByParent() (map[string][]Event, error) {
|
func (s *Service) ListTodayEventsByParent() (map[string][]Event, error) {
|
||||||
events, err := s.ListTodayEvents()
|
events, err := s.ListTodayEvents()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -105,12 +120,63 @@ func (s *Service) ListTodayEventsByParent() (map[string][]Event, error) {
|
||||||
}
|
}
|
||||||
grouped := make(map[string][]Event, 8)
|
grouped := make(map[string][]Event, 8)
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
pid := e.ParentID
|
pid := e.NodeID
|
||||||
if pid == "" {
|
|
||||||
pid = e.NodeID
|
|
||||||
}
|
|
||||||
grouped[pid] = append(grouped[pid], e)
|
grouped[pid] = append(grouped[pid], e)
|
||||||
}
|
}
|
||||||
return grouped, nil
|
return grouped, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListRecent returns a paginated global activity feed.
|
||||||
|
func (s *Service) ListRecent(limit, offset int) ([]Event, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
|
||||||
|
title,COALESCE(metadata,'{}'),created_at
|
||||||
|
FROM activity_events
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?`, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var events []Event
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanEvent(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
return events, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByNode returns paginated events for a specific node.
|
||||||
|
func (s *Service) ListByNode(nodeID string, limit, offset int) ([]Event, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
|
||||||
|
title,COALESCE(metadata,'{}'),created_at
|
||||||
|
FROM activity_events
|
||||||
|
WHERE node_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?`, nodeID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var events []Event
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanEvent(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
return events, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByNode returns the total number of events for a node.
|
||||||
|
func (s *Service) CountByNode(nodeID string) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM activity_events WHERE node_id = ?`, nodeID).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,9 @@ var validSections = map[string]struct{}{
|
||||||
|
|
||||||
// serviceSections are sidebar entries that are not stored as node sections.
|
// serviceSections are sidebar entries that are not stored as node sections.
|
||||||
var serviceSections = map[string]struct{}{
|
var serviceSections = map[string]struct{}{
|
||||||
"today": {},
|
"today": {},
|
||||||
"inbox": {},
|
"inbox": {},
|
||||||
|
"activity": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsValidType checks whether a type string is recognized.
|
// IsValidType checks whether a type string is recognized.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
// migration009 — add target_type, target_id, target_path to activity_events.
|
||||||
|
const migration009 = `
|
||||||
|
ALTER TABLE activity_events ADD COLUMN target_type TEXT;
|
||||||
|
ALTER TABLE activity_events ADD COLUMN target_id TEXT;
|
||||||
|
ALTER TABLE activity_events ADD COLUMN target_path TEXT;
|
||||||
|
`
|
||||||
|
|
@ -65,6 +65,7 @@ var migrationFiles = map[int]string{
|
||||||
6: migration006,
|
6: migration006,
|
||||||
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
|
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
|
||||||
8: migration008,
|
8: migration008,
|
||||||
|
9: migration009,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) runInitialSchema() error {
|
func (db *DB) runInitialSchema() error {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue