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:
parent
c25e75f839
commit
5732264fc5
|
|
@ -81,6 +81,14 @@ func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
|
|||
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) {
|
||||
tmpl, ok := a.templates.Get(templateID)
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -36,13 +36,8 @@ func (a *App) CreateWorklogFull(nodeID, summary, details, date string, minutes i
|
|||
|
||||
// --- report bindings ---
|
||||
|
||||
func (a *App) ListWorklogReport(dateFrom, dateTo, nodeID string, includeChildren bool) ([]worklog.ReportRow, error) {
|
||||
f := worklog.ReportFilter{
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
NodeID: nodeID,
|
||||
IncludeChildren: includeChildren,
|
||||
}
|
||||
func (a *App) ListWorklogReport(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) ([]worklog.ReportRow, error) {
|
||||
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
|
||||
rows, err := a.worklog.ListReport(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -51,34 +46,43 @@ func (a *App) ListWorklogReport(dateFrom, dateTo, nodeID string, includeChildren
|
|||
return rows, nil
|
||||
}
|
||||
|
||||
func (a *App) WorklogReportSummary(dateFrom, dateTo, nodeID string, includeChildren bool) (*worklog.ReportSummary, error) {
|
||||
f := worklog.ReportFilter{
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
NodeID: nodeID,
|
||||
IncludeChildren: includeChildren,
|
||||
}
|
||||
func (a *App) WorklogReportSummary(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (*worklog.ReportSummary, error) {
|
||||
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
|
||||
return a.worklog.Summary(f)
|
||||
}
|
||||
|
||||
func (a *App) ExportWorklogCSV(dateFrom, dateTo, nodeID string, includeChildren bool) (string, error) {
|
||||
f := worklog.ReportFilter{
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
NodeID: nodeID,
|
||||
IncludeChildren: includeChildren,
|
||||
}
|
||||
func (a *App) ExportWorklogCSV(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
|
||||
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
|
||||
return a.worklog.ExportCSV(f)
|
||||
}
|
||||
|
||||
func (a *App) ExportWorklogMarkdown(dateFrom, dateTo, nodeID string, includeChildren bool) (string, error) {
|
||||
f := worklog.ReportFilter{
|
||||
func (a *App) ExportWorklogMarkdown(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
|
||||
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,
|
||||
DateTo: dateTo,
|
||||
NodeID: nodeID,
|
||||
IncludeChildren: includeChildren,
|
||||
Billable: boolPtr(billableFilter),
|
||||
Approximate: boolPtr(approxFilter),
|
||||
}
|
||||
return a.worklog.ExportMarkdown(f)
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -16,7 +16,7 @@
|
|||
background: #13131f;
|
||||
}
|
||||
</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">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@
|
|||
let journalDateFrom = ''
|
||||
let journalDateTo = ''
|
||||
let journalIncludeChildren = false
|
||||
let journalNodeID = ''
|
||||
let journalBillableFilter = 'all'
|
||||
let journalApproxFilter = 'all'
|
||||
let journalFilteredNodeTitle = ''
|
||||
let activityLoading = false
|
||||
let caseActivity = []
|
||||
let version = ''
|
||||
|
|
@ -928,51 +932,49 @@
|
|||
worklogMinutes = ''
|
||||
}
|
||||
|
||||
async function acceptTodaySuggestion(s) {
|
||||
try {
|
||||
await wailsCall('AcceptSuggestionWith', s, s.suggestedMin, '')
|
||||
async function refreshAfterSuggestion() {
|
||||
suggestions = await wailsCall('GetSuggestions') || []
|
||||
suggestionCount = suggestions.length
|
||||
if (selectedNode) {
|
||||
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) }
|
||||
}
|
||||
|
||||
async function acceptJournalSuggestion(s) {
|
||||
try {
|
||||
await wailsCall('AcceptSuggestionWith', s, s.suggestedMin, '')
|
||||
suggestions = await wailsCall('GetSuggestions') || []
|
||||
suggestionCount = suggestions.length
|
||||
await loadJournal()
|
||||
await refreshAfterSuggestion()
|
||||
} 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 =====
|
||||
async function loadJournal() {
|
||||
try {
|
||||
const [rows, summary, sugs] = await Promise.all([
|
||||
wailsCall('ListWorklogReport', journalDateFrom, journalDateTo, '', journalIncludeChildren),
|
||||
wailsCall('WorklogReportSummary', journalDateFrom, journalDateTo, '', journalIncludeChildren),
|
||||
wailsCall('ListWorklogReport', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter),
|
||||
wailsCall('WorklogReportSummary', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter),
|
||||
wailsCall('GetSuggestions'),
|
||||
])
|
||||
journalRows = rows || []
|
||||
journalSummary = summary || null
|
||||
suggestions = sugs || []
|
||||
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) {
|
||||
journalRows = []
|
||||
journalSummary = null
|
||||
|
|
@ -983,18 +985,35 @@
|
|||
|
||||
async function exportJournalCSV() {
|
||||
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')
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function exportJournalMarkdown() {
|
||||
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')
|
||||
} 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) {
|
||||
const blob = new Blob([content], { type: mime })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
|
@ -1667,10 +1686,33 @@
|
|||
<label><span class="label-text">{t('journal.dateTo')}</span>
|
||||
<input type="date" bind:value={journalDateTo} />
|
||||
</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">
|
||||
<input type="checkbox" bind:checked={journalIncludeChildren} />
|
||||
<input type="checkbox" bind:checked={journalIncludeChildren} disabled={!journalNodeID} />
|
||||
<span>{t('journal.includeChildren')}</span>
|
||||
</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={exportJournalCSV}>{t('journal.exportCSV')}</button>
|
||||
<button class="btn btn-sm" on:click={exportJournalMarkdown}>{t('journal.exportMarkdown')}</button>
|
||||
|
|
|
|||
|
|
@ -124,6 +124,11 @@ export default {
|
|||
'worklog.suggestions': 'Suggestions for today',
|
||||
'worklog.apply': 'Apply',
|
||||
|
||||
'common.all': 'All',
|
||||
'common.no': 'No',
|
||||
'common.date': 'Date',
|
||||
'common.search': 'Search',
|
||||
|
||||
'nav.journal': 'Journal',
|
||||
|
||||
'journal.title': 'Work Log',
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ export default {
|
|||
'common.duplicate': 'Дублировать',
|
||||
'common.run': 'Запустить',
|
||||
'common.test': 'Test',
|
||||
'common.all': 'Все',
|
||||
'common.no': 'Нет',
|
||||
'common.date': 'Дата',
|
||||
'common.search': 'Найти',
|
||||
'common.testAgain': 'Проверить',
|
||||
'common.connect': 'Подключиться',
|
||||
'common.disconnect': 'Отключиться',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package worklog
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -231,9 +233,18 @@ func (s *Service) Summary(f ReportFilter) (*ReportSummary, error) {
|
|||
for day, min := range dayMap {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
|
@ -247,7 +258,8 @@ func (s *Service) ExportCSV(f ReportFilter) (string, error) {
|
|||
s.BuildReportPaths(rows)
|
||||
|
||||
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 {
|
||||
approx := "0"
|
||||
if r.Approximate {
|
||||
|
|
@ -257,11 +269,18 @@ func (s *Service) ExportCSV(f ReportFilter) (string, error) {
|
|||
if r.Billable {
|
||||
bill = "1"
|
||||
}
|
||||
summary := strings.ReplaceAll(r.Summary, "\"", "\"\"")
|
||||
b.WriteString(fmt.Sprintf("%s,\"%s\",\"%s\",\"%s\",%d,%s,%s,%s\n",
|
||||
r.Date, r.NodeTitle, r.NodePath, summary, r.Minutes, approx, bill, r.CreatedAt))
|
||||
w.Write([]string{r.Date, r.NodeTitle, r.NodePath, r.Summary,
|
||||
fmt.Sprintf("%d", 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.
|
||||
|
|
@ -295,7 +314,7 @@ func (s *Service) ExportMarkdown(f ReportFilter) (string, error) {
|
|||
approx = " ~"
|
||||
}
|
||||
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 {
|
||||
|
|
@ -307,7 +326,7 @@ func (s *Service) ExportMarkdown(f ReportFilter) (string, error) {
|
|||
b.WriteString("| Date | Minutes | Entries |\n")
|
||||
b.WriteString("|------|---------|--------|\n")
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue