feat: edit suggestions before accepting worklog
This commit is contained in:
parent
eb6a861310
commit
02d68ca3f4
|
|
@ -8,8 +8,8 @@ import (
|
|||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/worklog"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
// GetSuggestions analyzes today's activity and returns conservative suggestions.
|
||||
|
|
@ -126,6 +126,12 @@ func (a *App) AcceptSuggestion(nodeID, summary string, minutes int, date string,
|
|||
// AcceptSuggestionWith creates a worklog entry and links events in a single transaction.
|
||||
// eventIDsJSON is a JSON-serialized string array to avoid Wails v2 []string marshalling issues.
|
||||
func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
|
||||
return a.AcceptSuggestionFull(nodeID, summary, "", date, minutes, true, false, eventIDsJSON)
|
||||
}
|
||||
|
||||
// AcceptSuggestionFull creates a worklog entry from an edited suggestion and links events in a single transaction.
|
||||
// eventIDsJSON is a JSON-serialized string array to avoid Wails v2 []string marshalling issues.
|
||||
func (a *App) AcceptSuggestionFull(nodeID, summary, details, date string, minutes int, approximate, billable bool, eventIDsJSON string) (*WorklogDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -159,7 +165,7 @@ func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date str
|
|||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
entry, err := a.worklog.AddWithSourceTx(tx, nodeID, summary, "", d, minutes, true, false, worklog.SourceSuggestion)
|
||||
entry, err := a.worklog.AddWithSourceTx(tx, nodeID, summary, details, d, minutes, approximate, billable, worklog.SourceSuggestion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create entry: %w", err)
|
||||
}
|
||||
|
|
@ -190,18 +196,7 @@ func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date str
|
|||
}
|
||||
|
||||
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
|
||||
mins := 0
|
||||
if entry.Minutes != nil {
|
||||
mins = *entry.Minutes
|
||||
}
|
||||
return &WorklogDTO{
|
||||
ID: entry.ID,
|
||||
NodeID: entry.NodeID,
|
||||
Summary: entry.Summary,
|
||||
Minutes: mins,
|
||||
Date: entry.Date,
|
||||
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}, nil
|
||||
return entryToDTO(entry), nil
|
||||
}
|
||||
|
||||
// HideSuggestion marks a suggestion as hidden for the session.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
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-Cc1HprFt.js"></script>
|
||||
<script type="module" crossorigin src="/assets/main-Cyhj7TEH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DAyIHTpH.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -392,3 +392,31 @@ func TestApplyRemoteWorklogUpdate(t *testing.T) {
|
|||
t.Fatalf("remote updated minutes/flags = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptSuggestionFullUsesEditedFields(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Edited Suggestion Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
eventID := insertTestEvent(t, app, n.ID, activity.TypeFileAdded, "file", "file-1", "Добавлен файл")
|
||||
eventIDsJSON, _ := json.Marshal([]string{eventID})
|
||||
|
||||
dto, err := app.AcceptSuggestionFull(n.ID, "Edited summary", "Edited details", "2026-01-05", 55, false, true, string(eventIDsJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptSuggestionFull: %v", err)
|
||||
}
|
||||
if dto.Summary != "Edited summary" || dto.Details != "Edited details" || dto.Date != "2026-01-05" {
|
||||
t.Fatalf("dto = %#v", dto)
|
||||
}
|
||||
if dto.Minutes != 55 || dto.Approximate || !dto.Billable {
|
||||
t.Fatalf("dto minutes/flags = %#v", dto)
|
||||
}
|
||||
if dto.Source != worklog.SourceSuggestion {
|
||||
t.Fatalf("dto.Source = %q, want %q", dto.Source, worklog.SourceSuggestion)
|
||||
}
|
||||
if n := countLinked(t, app, dto.ID); n != 1 {
|
||||
t.Fatalf("linked events = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@
|
|||
let wlModalBillable = false
|
||||
let wlModalApprox = false
|
||||
let editingWorklogEntry = null
|
||||
let acceptingSuggestion = null
|
||||
let suggestions = []
|
||||
let suggestionCount = 0
|
||||
let showCreateNode = false
|
||||
|
|
@ -968,6 +969,7 @@
|
|||
// ===== Worklog =====
|
||||
function openWorklogModal(entry = null) {
|
||||
editingWorklogEntry = entry
|
||||
acceptingSuggestion = null
|
||||
wlModalSummary = entry ? entry.summary : ''
|
||||
wlModalMinutes = entry ? String(entry.minutes || '') : ''
|
||||
wlModalDate = entry ? (entry.date || '') : ''
|
||||
|
|
@ -980,6 +982,7 @@
|
|||
function closeWorklogModal() {
|
||||
showWorklogModal = false
|
||||
editingWorklogEntry = null
|
||||
acceptingSuggestion = null
|
||||
}
|
||||
|
||||
async function refreshWorklogViews(nodeID = '') {
|
||||
|
|
@ -995,9 +998,13 @@
|
|||
async function submitWorklogModal() {
|
||||
const mins = parseInt(wlModalMinutes, 10)
|
||||
if (!wlModalSummary.trim() || isNaN(mins) || mins <= 0) return
|
||||
if (!editingWorklogEntry && !selectedNode) return
|
||||
if (!acceptingSuggestion && !editingWorklogEntry && !selectedNode) return
|
||||
try {
|
||||
if (editingWorklogEntry) {
|
||||
if (acceptingSuggestion) {
|
||||
const eventIdsJSON = JSON.stringify(extractEventIds(acceptingSuggestion))
|
||||
await wailsCall('AcceptSuggestionFull', acceptingSuggestion.nodeId, wlModalSummary.trim(), wlModalDetails, wlModalDate, mins, wlModalApprox, wlModalBillable, eventIdsJSON)
|
||||
await refreshAfterSuggestion()
|
||||
} else if (editingWorklogEntry) {
|
||||
await wailsCall('UpdateWorklogEntry', editingWorklogEntry.id, wlModalSummary.trim(), wlModalDetails, wlModalDate, mins, wlModalApprox, wlModalBillable)
|
||||
await refreshWorklogViews(editingWorklogEntry.nodeId)
|
||||
} else {
|
||||
|
|
@ -1010,6 +1017,7 @@
|
|||
}
|
||||
showWorklogModal = false
|
||||
editingWorklogEntry = null
|
||||
acceptingSuggestion = null
|
||||
}
|
||||
|
||||
function deleteWorklogEntry(entry) {
|
||||
|
|
@ -1040,6 +1048,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
function openSuggestionWorklogModal(s) {
|
||||
acceptingSuggestion = s
|
||||
editingWorklogEntry = null
|
||||
wlModalSummary = s.summary || ''
|
||||
wlModalMinutes = String(s.suggestedMin || '')
|
||||
wlModalDate = ''
|
||||
wlModalDetails = ''
|
||||
wlModalBillable = false
|
||||
wlModalApprox = true
|
||||
showWorklogModal = true
|
||||
}
|
||||
|
||||
function extractEventIds(s) {
|
||||
if (s.eventIds && s.eventIds.length) return s.eventIds
|
||||
if (s.events && s.events.length) return s.events.map(ev => ev.id).filter(Boolean)
|
||||
|
|
@ -1856,6 +1876,9 @@
|
|||
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')} · {t('suggest.confidence.' + s.confidence)}</span>
|
||||
</div>
|
||||
<div class="suggestion-actions">
|
||||
<button class="btn btn-sm" on:click|stopPropagation={() => openSuggestionWorklogModal(s)}>
|
||||
{t('suggest.edit')}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>
|
||||
{t('worklog.apply')}
|
||||
</button>
|
||||
|
|
@ -2053,6 +2076,7 @@
|
|||
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
|
||||
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
|
||||
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
|
||||
<button class="btn btn-sm" on:click|stopPropagation={() => openSuggestionWorklogModal(s)}>{t('suggest.edit')}</button>
|
||||
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptJournalSuggestion(s)}>{t('suggest.apply')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2211,6 +2235,7 @@
|
|||
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
|
||||
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
|
||||
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
|
||||
<button class="btn btn-sm" on:click|stopPropagation={() => openSuggestionWorklogModal(s)}>{t('suggest.edit')}</button>
|
||||
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>{t('suggest.apply')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2400,7 +2425,7 @@
|
|||
{#if showWorklogModal}
|
||||
<div class="modal-overlay" role="button" tabindex="0" on:click|self={closeWorklogModal} on:keydown={onKeyActivate(closeWorklogModal)}>
|
||||
<div class="modal modal-worklog">
|
||||
<h3>{editingWorklogEntry ? t('worklog.editEntry') : t('worklog.addEntry')}</h3>
|
||||
<h3>{acceptingSuggestion ? t('worklog.acceptSuggestion') : editingWorklogEntry ? t('worklog.editEntry') : t('worklog.addEntry')}</h3>
|
||||
<div class="form-group">
|
||||
<label><span class="label-text">{t('worklog.date')}</span>
|
||||
<input type="date" bind:value={wlModalDate} />
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ export default {
|
|||
'worklog.min': 'min',
|
||||
'worklog.log': 'Log',
|
||||
'worklog.addEntry': 'Add entry',
|
||||
'worklog.acceptSuggestion': 'Accept suggestion',
|
||||
'worklog.editEntry': 'Edit entry',
|
||||
'worklog.deleteEntry': 'Delete entry',
|
||||
'worklog.deleteConfirm': 'Delete this work entry? Related events stay in activity, but their link to this entry will be removed.',
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ export default {
|
|||
'worklog.min': 'мин',
|
||||
'worklog.log': 'Записать',
|
||||
'worklog.addEntry': 'Добавить запись',
|
||||
'worklog.acceptSuggestion': 'Принять предложение',
|
||||
'worklog.editEntry': 'Редактировать запись',
|
||||
'worklog.deleteEntry': 'Удалить запись',
|
||||
'worklog.deleteConfirm': 'Удалить эту запись работы? Связанные события останутся в активности, но связь с записью будет удалена.',
|
||||
|
|
|
|||
|
|
@ -94,6 +94,14 @@ export function DeleteWorklogEntry(arg1) {
|
|||
return window['go']['main']['App']['DeleteWorklogEntry'](arg1);
|
||||
}
|
||||
|
||||
export function AcceptSuggestionWith(arg1, arg2, arg3, arg4, arg5) {
|
||||
return window['go']['main']['App']['AcceptSuggestionWith'](arg1, arg2, arg3, arg4, arg5);
|
||||
}
|
||||
|
||||
export function AcceptSuggestionFull(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) {
|
||||
return window['go']['main']['App']['AcceptSuggestionFull'](arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8);
|
||||
}
|
||||
|
||||
export function Search(arg1) {
|
||||
return window['go']['main']['App']['Search'](arg1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,14 @@ async function runReadyScenario(cdp, url) {
|
|||
|
||||
await clickText(cdp, '.tab', 'Журнал')
|
||||
await assertText(cdp, 'Manual smoke entry', 'worklog: entry visible')
|
||||
await clickText(cdp, '.worklog-tab-suggestions .suggestion-actions .btn', 'Изменить')
|
||||
await waitForSelector(cdp, '.modal-worklog')
|
||||
await setInputValue(cdp, '.modal-worklog input[type="text"]', 'GUI smoke accepted suggestion')
|
||||
await setInputValue(cdp, '.modal-worklog input[type="number"]', '35')
|
||||
await setInputValue(cdp, '.modal-worklog textarea', 'Accepted after manual edit')
|
||||
await clickText(cdp, '.modal-worklog .btn', 'Сохранить')
|
||||
await waitForGone(cdp, '.modal-worklog')
|
||||
await assertText(cdp, 'GUI smoke accepted suggestion', 'worklog: edited suggestion accepted')
|
||||
await clickText(cdp, '.worklog-toolbar .btn', 'Добавить запись')
|
||||
await waitForSelector(cdp, '.modal-worklog')
|
||||
await setInputValue(cdp, '.modal-worklog input[type="text"]', 'GUI smoke worklog')
|
||||
|
|
@ -768,6 +776,11 @@ function wailsMockSource() {
|
|||
events,
|
||||
}],
|
||||
AcceptSuggestionWith: async () => true,
|
||||
AcceptSuggestionFull: async (nodeId, summary, details, date, minutes, approximate, billable) => {
|
||||
const entry = { id: 'wl-suggestion-' + Date.now(), nodeId, summary, details, date: date || '2026-06-04', minutes, approximate, billable, source: 'suggestion', createdAt: now };
|
||||
state.worklog[nodeId] = [...(state.worklog[nodeId] || []), entry];
|
||||
return clone(entry);
|
||||
},
|
||||
ListWorklogReport: async () => clone(state.worklog['node-project'].map((entry) => ({ ...entry, nodeTitle: 'Smoke Project', nodePath: '/Smoke Project', _hasEvents: false }))),
|
||||
WorklogReportSummary: async () => ({ totalMinutes: 45, totalEntries: 1, byDay: [{ label: '2026-06-04', minutes: 45, count: 1 }], byNode: [{ label: 'Smoke Project', minutes: 45, count: 1 }] }),
|
||||
GetWorklogEntryEvents: async () => [],
|
||||
|
|
|
|||
Loading…
Reference in New Issue