feat: journal UX overhaul — picker, export dialog, events, readability

- Sidebar i18n: added missing nav.journal to backend ru.json
- Export: SaveWorklogReport binding with native SaveFileDialog + os.WriteFile
- Filter: better IncludeChildren label with disabled tooltip
- Filter: renamed billable→К оплате, approximate→Тип времени with hints
- worklog_entry_events table (migration 013) linking entries to activity events
- Suggestion: EventIDs + Events details, expandable cards with timestamps
- Journal rows: expandable with details, source, linked events
- Contrast: improved readability for dates, timestamps, hover states
- i18n: added worklog.*, journal.*, suggest.* keys to ru.js/en.js
This commit is contained in:
2026-06-03 11:24:59 +08:00
parent d34100e2ed
commit 1472bb3e6f
13 changed files with 402 additions and 98 deletions
+23
View File
@@ -60,6 +60,21 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
min := estimateMinutes(bursts, spread, len(grp.events))
conf, reason := confidence(bursts, spread, len(grp.events))
eventIDs := make([]string, 0, len(grp.events))
evDetails := make([]activity.SuggestionDetail, 0, len(grp.events))
for _, e := range grp.events {
eventIDs = append(eventIDs, e.ID)
evDetails = append(evDetails, activity.SuggestionDetail{
ID: e.ID,
EventType: e.EventType,
TargetType: e.TargetType,
TargetID: e.TargetID,
Title: e.Title,
CreatedAt: e.CreatedAt,
NodeID: e.NodeID,
})
}
suggestions = append(suggestions, activity.Suggestion{
NodeID: nodeID,
NodeTitle: grp.title,
@@ -70,6 +85,8 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
Confidence: conf,
ConfidenceReason: reason,
TimeSpreadMin: spread,
EventIDs: eventIDs,
Events: evDetails,
})
}
@@ -95,6 +112,12 @@ func (a *App) AcceptSuggestionWith(s activity.Suggestion, minutes int, date stri
if err != nil {
return nil, err
}
// Link activity events to this worklog entry.
for _, eid := range s.EventIDs {
_, _ = a.db.Exec(
`INSERT OR IGNORE INTO worklog_entry_events (entry_id, event_id) VALUES (?,?)`,
entry.ID, eid)
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
mins := 0
if entry.Minutes != nil {
+94 -1
View File
@@ -1,8 +1,13 @@
package main
import (
syncsvc "verstak/internal/core/sync"
"fmt"
"os"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"verstak/internal/core/worklog"
syncsvc "verstak/internal/core/sync"
)
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
@@ -90,6 +95,94 @@ func buildWorklogFilter(dateFrom, dateTo, nodeID string, includeChildren bool, b
}
}
// SaveWorklogReport generates a worklog report and opens a SaveFileDialog.
func (a *App) SaveWorklogReport(format, dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
var data []byte
var ext string
switch format {
case "csv":
s, err := a.worklog.ExportCSV(f)
if err != nil {
return "", err
}
data = []byte(s)
ext = ".csv"
case "markdown":
s, err := a.worklog.ExportMarkdown(f)
if err != nil {
return "", err
}
data = []byte(s)
ext = ".md"
case "pdf":
var err error
data, err = a.worklog.ExportPDF(f)
if err != nil {
return "", err
}
ext = ".pdf"
default:
return "", fmt.Errorf("unknown format: %s", format)
}
from := dateFrom
if from == "" {
from = "all"
}
to := dateTo
if to == "" {
to = "all"
}
defaultName := fmt.Sprintf("verstak-worklog-%s--%s%s", from, to, ext)
path, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{
DefaultFilename: defaultName,
Filters: []wailsruntime.FileFilter{
{DisplayName: format, Pattern: "*" + ext},
},
})
if err != nil {
return "", err
}
if path == "" {
return "", fmt.Errorf("отменено пользователем")
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return "", fmt.Errorf("не удалось сохранить файл: %w", err)
}
return fmt.Sprintf("Отчёт сохранён: %s", path), nil
}
// GetWorklogEntryEvents returns activity events linked to a worklog entry.
func (a *App) GetWorklogEntryEvents(entryID string) ([]EventDTO, error) {
rows, err := a.db.Query(
`SELECT e.id, e.node_id, e.event_type, e.target_type, e.target_id, e.target_path,
e.title, e.details_json, e.created_at
FROM activity_events e
JOIN worklog_entry_events wle ON wle.event_id = e.id
WHERE wle.entry_id = ?
ORDER BY e.created_at ASC`, entryID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []EventDTO
for rows.Next() {
var d EventDTO
if err := rows.Scan(&d.ID, &d.NodeID, &d.EventType, &d.TargetType,
&d.TargetID, &d.TargetPath, &d.Title, &d.DetailsJSON, &d.CreatedAt); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// --- helpers ---
func toWorklogDTOs(list []worklog.Entry) []WorklogDTO {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -16,8 +16,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-BYxU8Qbt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-C7UPiZtQ.css">
<script type="module" crossorigin src="/assets/main-BbnSy6IG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bh5tqssv.css">
</head>
<body>
<div id="app"></div>