fix(step16.1): review fixes — acceptance, filters, sorting, export

- Remove dead acceptSuggestion, unify into refreshAfterSuggestion()
- Journal: nodeID picker, includeChildren only with selected node
- Journal: billable/approximate filters (all/yes/no selects)
- Summary: ByDay sorted by date desc, ByNode by minutes desc
- CSV: proper encoding/csv writer (was manual fmt.Sprintf)
- Markdown: escape pipes and newlines via escMD()
- After suggestion: refresh suggestions + count + worklog + journal
- Add GetNodeTitle binding
- i18n: common.all/no/date/search
This commit is contained in:
mirivlad 2026-06-03 10:30:48 +08:00
parent c25e75f839
commit 5732264fc5
8 changed files with 143 additions and 58 deletions

View File

@ -81,6 +81,14 @@ func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
return &dto, nil return &dto, nil
} }
func (a *App) GetNodeTitle(nodeID string) (string, error) {
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return "", err
}
return n.Title, nil
}
func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeDTO, error) { func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeDTO, error) {
tmpl, ok := a.templates.Get(templateID) tmpl, ok := a.templates.Get(templateID)
if !ok { if !ok {

View File

@ -36,13 +36,8 @@ func (a *App) CreateWorklogFull(nodeID, summary, details, date string, minutes i
// --- report bindings --- // --- report bindings ---
func (a *App) ListWorklogReport(dateFrom, dateTo, nodeID string, includeChildren bool) ([]worklog.ReportRow, error) { func (a *App) ListWorklogReport(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) ([]worklog.ReportRow, error) {
f := worklog.ReportFilter{ f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
DateFrom: dateFrom,
DateTo: dateTo,
NodeID: nodeID,
IncludeChildren: includeChildren,
}
rows, err := a.worklog.ListReport(f) rows, err := a.worklog.ListReport(f)
if err != nil { if err != nil {
return nil, err return nil, err
@ -51,34 +46,43 @@ func (a *App) ListWorklogReport(dateFrom, dateTo, nodeID string, includeChildren
return rows, nil return rows, nil
} }
func (a *App) WorklogReportSummary(dateFrom, dateTo, nodeID string, includeChildren bool) (*worklog.ReportSummary, error) { func (a *App) WorklogReportSummary(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (*worklog.ReportSummary, error) {
f := worklog.ReportFilter{ f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
DateFrom: dateFrom,
DateTo: dateTo,
NodeID: nodeID,
IncludeChildren: includeChildren,
}
return a.worklog.Summary(f) return a.worklog.Summary(f)
} }
func (a *App) ExportWorklogCSV(dateFrom, dateTo, nodeID string, includeChildren bool) (string, error) { func (a *App) ExportWorklogCSV(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
f := worklog.ReportFilter{ f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
DateFrom: dateFrom,
DateTo: dateTo,
NodeID: nodeID,
IncludeChildren: includeChildren,
}
return a.worklog.ExportCSV(f) return a.worklog.ExportCSV(f)
} }
func (a *App) ExportWorklogMarkdown(dateFrom, dateTo, nodeID string, includeChildren bool) (string, error) { func (a *App) ExportWorklogMarkdown(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
f := worklog.ReportFilter{ f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
return a.worklog.ExportMarkdown(f)
}
func boolPtr(s string) *bool {
switch s {
case "yes":
v := true
return &v
case "no":
v := false
return &v
default:
return nil
}
}
func buildWorklogFilter(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) worklog.ReportFilter {
return worklog.ReportFilter{
DateFrom: dateFrom, DateFrom: dateFrom,
DateTo: dateTo, DateTo: dateTo,
NodeID: nodeID, NodeID: nodeID,
IncludeChildren: includeChildren, IncludeChildren: includeChildren,
Billable: boolPtr(billableFilter),
Approximate: boolPtr(approxFilter),
} }
return a.worklog.ExportMarkdown(f)
} }
// --- helpers --- // --- helpers ---

File diff suppressed because one or more lines are too long

View File

@ -16,7 +16,7 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-wlKdkTmp.js"></script> <script type="module" crossorigin src="/assets/main-DU1CFPIY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-B4G76NhT.css"> <link rel="stylesheet" crossorigin href="/assets/main-B4G76NhT.css">
</head> </head>
<body> <body>

View File

@ -39,6 +39,10 @@
let journalDateFrom = '' let journalDateFrom = ''
let journalDateTo = '' let journalDateTo = ''
let journalIncludeChildren = false let journalIncludeChildren = false
let journalNodeID = ''
let journalBillableFilter = 'all'
let journalApproxFilter = 'all'
let journalFilteredNodeTitle = ''
let activityLoading = false let activityLoading = false
let caseActivity = [] let caseActivity = []
let version = '' let version = ''
@ -928,51 +932,49 @@
worklogMinutes = '' worklogMinutes = ''
} }
async function acceptTodaySuggestion(s) { async function refreshAfterSuggestion() {
try {
await wailsCall('AcceptSuggestionWith', s, s.suggestedMin, '')
suggestions = await wailsCall('GetSuggestions') || [] suggestions = await wailsCall('GetSuggestions') || []
suggestionCount = suggestions.length suggestionCount = suggestions.length
if (selectedNode) { if (selectedNode) {
worklog = await wailsCall('ListWorklog', selectedNode.id) || [] worklog = await wailsCall('ListWorklog', selectedNode.id) || []
} }
if (selectedSection === 'journal') {
await loadJournal()
}
}
async function acceptTodaySuggestion(s) {
try {
await wailsCall('AcceptSuggestionWith', s, s.suggestedMin, '')
await refreshAfterSuggestion()
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
} }
async function acceptJournalSuggestion(s) { async function acceptJournalSuggestion(s) {
try { try {
await wailsCall('AcceptSuggestionWith', s, s.suggestedMin, '') await wailsCall('AcceptSuggestionWith', s, s.suggestedMin, '')
suggestions = await wailsCall('GetSuggestions') || [] await refreshAfterSuggestion()
suggestionCount = suggestions.length
await loadJournal()
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
} }
async function acceptSuggestion(s) {
try {
await wailsCall('AcceptSuggestionWith', s, s.suggestedMin, '')
suggestions = await wailsCall('GetSuggestions') || []
suggestionCount = suggestions.length
if (selectedNode) {
worklog = await wailsCall('ListWorklog', selectedNode.id) || []
}
} catch (e) {
console.error('accept suggestion', e)
}
}
// ===== Journal ===== // ===== Journal =====
async function loadJournal() { async function loadJournal() {
try { try {
const [rows, summary, sugs] = await Promise.all([ const [rows, summary, sugs] = await Promise.all([
wailsCall('ListWorklogReport', journalDateFrom, journalDateTo, '', journalIncludeChildren), wailsCall('ListWorklogReport', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter),
wailsCall('WorklogReportSummary', journalDateFrom, journalDateTo, '', journalIncludeChildren), wailsCall('WorklogReportSummary', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter),
wailsCall('GetSuggestions'), wailsCall('GetSuggestions'),
]) ])
journalRows = rows || [] journalRows = rows || []
journalSummary = summary || null journalSummary = summary || null
suggestions = sugs || [] suggestions = sugs || []
suggestionCount = suggestions.length suggestionCount = suggestions.length
// Resolve node title if filtered by node.
if (journalNodeID && rows && rows.length > 0) {
journalFilteredNodeTitle = rows[0].nodeTitle
} else if (journalNodeID) {
try { const n = await wailsCall('GetNodeTitle', journalNodeID); journalFilteredNodeTitle = n } catch(e) { journalFilteredNodeTitle = '' }
}
} catch (e) { } catch (e) {
journalRows = [] journalRows = []
journalSummary = null journalSummary = null
@ -983,18 +985,35 @@
async function exportJournalCSV() { async function exportJournalCSV() {
try { try {
const csv = await wailsCall('ExportWorklogCSV', journalDateFrom, journalDateTo, '', journalIncludeChildren) const csv = await wailsCall('ExportWorklogCSV', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter)
downloadFile('worklog.csv', csv, 'text/csv') downloadFile('worklog.csv', csv, 'text/csv')
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
} }
async function exportJournalMarkdown() { async function exportJournalMarkdown() {
try { try {
const md = await wailsCall('ExportWorklogMarkdown', journalDateFrom, journalDateTo, '', journalIncludeChildren) const md = await wailsCall('ExportWorklogMarkdown', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter)
downloadFile('worklog.md', md, 'text/markdown') downloadFile('worklog.md', md, 'text/markdown')
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
} }
async function pickJournalNode() {
// Simple prompt — in future make a proper tree picker.
const id = prompt('Введите ID дела (nodeId):')
if (id && id.trim()) {
journalNodeID = id.trim()
journalIncludeChildren = true
await loadJournal()
}
}
function clearJournalNode() {
journalNodeID = ''
journalIncludeChildren = false
journalFilteredNodeTitle = ''
loadJournal()
}
function downloadFile(name, content, mime) { function downloadFile(name, content, mime) {
const blob = new Blob([content], { type: mime }) const blob = new Blob([content], { type: mime })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
@ -1667,10 +1686,33 @@
<label><span class="label-text">{t('journal.dateTo')}</span> <label><span class="label-text">{t('journal.dateTo')}</span>
<input type="date" bind:value={journalDateTo} /> <input type="date" bind:value={journalDateTo} />
</label> </label>
<label><span class="label-text">{t('journal.node')}</span>
<div class="journal-node-picker">
<input type="text" placeholder="nodeId" bind:value={journalNodeID} />
<button class="btn btn-sm" on:click={pickJournalNode}>{t('common.search')}</button>
{#if journalNodeID}
<button class="btn btn-sm" on:click={clearJournalNode}>✕</button>
{/if}
</div>
</label>
<label class="checkbox-label"> <label class="checkbox-label">
<input type="checkbox" bind:checked={journalIncludeChildren} /> <input type="checkbox" bind:checked={journalIncludeChildren} disabled={!journalNodeID} />
<span>{t('journal.includeChildren')}</span> <span>{t('journal.includeChildren')}</span>
</label> </label>
<label><span class="label-text">{t('journal.billable')}</span>
<select bind:value={journalBillableFilter}>
<option value="all">{t('common.all')}</option>
<option value="yes">{t('journal.billable')}</option>
<option value="no">{t('common.no')}</option>
</select>
</label>
<label><span class="label-text">{t('journal.approximate')}</span>
<select bind:value={journalApproxFilter}>
<option value="all">{t('common.all')}</option>
<option value="yes">{t('journal.approximate')}</option>
<option value="no">{t('common.no')}</option>
</select>
</label>
<button class="btn btn-sm" on:click={loadJournal}>{t('journal.filter')}</button> <button class="btn btn-sm" on:click={loadJournal}>{t('journal.filter')}</button>
<button class="btn btn-sm" on:click={exportJournalCSV}>{t('journal.exportCSV')}</button> <button class="btn btn-sm" on:click={exportJournalCSV}>{t('journal.exportCSV')}</button>
<button class="btn btn-sm" on:click={exportJournalMarkdown}>{t('journal.exportMarkdown')}</button> <button class="btn btn-sm" on:click={exportJournalMarkdown}>{t('journal.exportMarkdown')}</button>

View File

@ -124,6 +124,11 @@ export default {
'worklog.suggestions': 'Suggestions for today', 'worklog.suggestions': 'Suggestions for today',
'worklog.apply': 'Apply', 'worklog.apply': 'Apply',
'common.all': 'All',
'common.no': 'No',
'common.date': 'Date',
'common.search': 'Search',
'nav.journal': 'Journal', 'nav.journal': 'Journal',
'journal.title': 'Work Log', 'journal.title': 'Work Log',

View File

@ -49,6 +49,10 @@ export default {
'common.duplicate': 'Дублировать', 'common.duplicate': 'Дублировать',
'common.run': 'Запустить', 'common.run': 'Запустить',
'common.test': 'Test', 'common.test': 'Test',
'common.all': 'Все',
'common.no': 'Нет',
'common.date': 'Дата',
'common.search': 'Найти',
'common.testAgain': 'Проверить', 'common.testAgain': 'Проверить',
'common.connect': 'Подключиться', 'common.connect': 'Подключиться',
'common.disconnect': 'Отключиться', 'common.disconnect': 'Отключиться',

View File

@ -1,7 +1,9 @@
package worklog package worklog
import ( import (
"encoding/csv"
"fmt" "fmt"
"sort"
"strings" "strings"
"time" "time"
@ -231,9 +233,18 @@ func (s *Service) Summary(f ReportFilter) (*ReportSummary, error) {
for day, min := range dayMap { for day, min := range dayMap {
sm.ByDay = append(sm.ByDay, SummaryGroup{Label: day, Minutes: min, Count: dayCount[day]}) sm.ByDay = append(sm.ByDay, SummaryGroup{Label: day, Minutes: min, Count: dayCount[day]})
} }
sort.Slice(sm.ByDay, func(i, j int) bool {
return sm.ByDay[i].Label > sm.ByDay[j].Label // descending date
})
for node, min := range nodeMap { for node, min := range nodeMap {
sm.ByNode = append(sm.ByNode, SummaryGroup{Label: node, Minutes: min, Count: nodeCount[node]}) sm.ByNode = append(sm.ByNode, SummaryGroup{Label: node, Minutes: min, Count: nodeCount[node]})
} }
sort.Slice(sm.ByNode, func(i, j int) bool {
if sm.ByNode[i].Minutes != sm.ByNode[j].Minutes {
return sm.ByNode[i].Minutes > sm.ByNode[j].Minutes
}
return sm.ByNode[i].Label < sm.ByNode[j].Label
})
return sm, nil return sm, nil
} }
@ -247,7 +258,8 @@ func (s *Service) ExportCSV(f ReportFilter) (string, error) {
s.BuildReportPaths(rows) s.BuildReportPaths(rows)
var b strings.Builder var b strings.Builder
b.WriteString("Date,Node,Path,Summary,Minutes,Approximate,Billable,Created\n") w := csv.NewWriter(&b)
w.Write([]string{"Date", "Node", "Path", "Summary", "Minutes", "Approximate", "Billable", "Created"})
for _, r := range rows { for _, r := range rows {
approx := "0" approx := "0"
if r.Approximate { if r.Approximate {
@ -257,11 +269,18 @@ func (s *Service) ExportCSV(f ReportFilter) (string, error) {
if r.Billable { if r.Billable {
bill = "1" bill = "1"
} }
summary := strings.ReplaceAll(r.Summary, "\"", "\"\"") w.Write([]string{r.Date, r.NodeTitle, r.NodePath, r.Summary,
b.WriteString(fmt.Sprintf("%s,\"%s\",\"%s\",\"%s\",%d,%s,%s,%s\n", fmt.Sprintf("%d", r.Minutes), approx, bill, r.CreatedAt})
r.Date, r.NodeTitle, r.NodePath, summary, r.Minutes, approx, bill, r.CreatedAt))
} }
return b.String(), nil w.Flush()
return b.String(), w.Error()
}
func escMD(s string) string {
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", "")
return s
} }
// ExportMarkdown returns a Markdown report. // ExportMarkdown returns a Markdown report.
@ -295,7 +314,7 @@ func (s *Service) ExportMarkdown(f ReportFilter) (string, error) {
approx = " ~" approx = " ~"
} }
b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %d%s |\n", b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %d%s |\n",
r.Date, r.NodeTitle, r.NodePath, r.Summary, r.Minutes, approx)) escMD(r.Date), escMD(r.NodeTitle), escMD(r.NodePath), escMD(r.Summary), r.Minutes, approx))
} }
if sm != nil { if sm != nil {
@ -307,7 +326,7 @@ func (s *Service) ExportMarkdown(f ReportFilter) (string, error) {
b.WriteString("| Date | Minutes | Entries |\n") b.WriteString("| Date | Minutes | Entries |\n")
b.WriteString("|------|---------|--------|\n") b.WriteString("|------|---------|--------|\n")
for _, d := range sm.ByDay { for _, d := range sm.ByDay {
b.WriteString(fmt.Sprintf("| %s | %dh %dm | %d |\n", d.Label, d.Minutes/60, d.Minutes%60, d.Count)) b.WriteString(fmt.Sprintf("| %s | %dh %dm | %d |\n", escMD(d.Label), d.Minutes/60, d.Minutes%60, d.Count))
} }
b.WriteString("\n") b.WriteString("\n")
} }