feat: edit suggestions before accepting worklog
This commit is contained in:
parent
eb6a861310
commit
02d68ca3f4
|
|
@ -8,8 +8,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"verstak/internal/core/activity"
|
"verstak/internal/core/activity"
|
||||||
"verstak/internal/core/worklog"
|
|
||||||
syncsvc "verstak/internal/core/sync"
|
syncsvc "verstak/internal/core/sync"
|
||||||
|
"verstak/internal/core/worklog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetSuggestions analyzes today's activity and returns conservative suggestions.
|
// 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.
|
// 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.
|
// 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) {
|
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 {
|
if err := a.requireVault(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -159,7 +165,7 @@ func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date str
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create entry: %w", err)
|
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))
|
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
|
||||||
mins := 0
|
return entryToDTO(entry), nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HideSuggestion marks a suggestion as hidden for the session.
|
// 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;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</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">
|
<link rel="stylesheet" crossorigin href="/assets/main-DAyIHTpH.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -392,3 +392,31 @@ func TestApplyRemoteWorklogUpdate(t *testing.T) {
|
||||||
t.Fatalf("remote updated minutes/flags = %#v", got)
|
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 wlModalBillable = false
|
||||||
let wlModalApprox = false
|
let wlModalApprox = false
|
||||||
let editingWorklogEntry = null
|
let editingWorklogEntry = null
|
||||||
|
let acceptingSuggestion = null
|
||||||
let suggestions = []
|
let suggestions = []
|
||||||
let suggestionCount = 0
|
let suggestionCount = 0
|
||||||
let showCreateNode = false
|
let showCreateNode = false
|
||||||
|
|
@ -968,6 +969,7 @@
|
||||||
// ===== Worklog =====
|
// ===== Worklog =====
|
||||||
function openWorklogModal(entry = null) {
|
function openWorklogModal(entry = null) {
|
||||||
editingWorklogEntry = entry
|
editingWorklogEntry = entry
|
||||||
|
acceptingSuggestion = null
|
||||||
wlModalSummary = entry ? entry.summary : ''
|
wlModalSummary = entry ? entry.summary : ''
|
||||||
wlModalMinutes = entry ? String(entry.minutes || '') : ''
|
wlModalMinutes = entry ? String(entry.minutes || '') : ''
|
||||||
wlModalDate = entry ? (entry.date || '') : ''
|
wlModalDate = entry ? (entry.date || '') : ''
|
||||||
|
|
@ -980,6 +982,7 @@
|
||||||
function closeWorklogModal() {
|
function closeWorklogModal() {
|
||||||
showWorklogModal = false
|
showWorklogModal = false
|
||||||
editingWorklogEntry = null
|
editingWorklogEntry = null
|
||||||
|
acceptingSuggestion = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshWorklogViews(nodeID = '') {
|
async function refreshWorklogViews(nodeID = '') {
|
||||||
|
|
@ -995,9 +998,13 @@
|
||||||
async function submitWorklogModal() {
|
async function submitWorklogModal() {
|
||||||
const mins = parseInt(wlModalMinutes, 10)
|
const mins = parseInt(wlModalMinutes, 10)
|
||||||
if (!wlModalSummary.trim() || isNaN(mins) || mins <= 0) return
|
if (!wlModalSummary.trim() || isNaN(mins) || mins <= 0) return
|
||||||
if (!editingWorklogEntry && !selectedNode) return
|
if (!acceptingSuggestion && !editingWorklogEntry && !selectedNode) return
|
||||||
try {
|
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 wailsCall('UpdateWorklogEntry', editingWorklogEntry.id, wlModalSummary.trim(), wlModalDetails, wlModalDate, mins, wlModalApprox, wlModalBillable)
|
||||||
await refreshWorklogViews(editingWorklogEntry.nodeId)
|
await refreshWorklogViews(editingWorklogEntry.nodeId)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1010,6 +1017,7 @@
|
||||||
}
|
}
|
||||||
showWorklogModal = false
|
showWorklogModal = false
|
||||||
editingWorklogEntry = null
|
editingWorklogEntry = null
|
||||||
|
acceptingSuggestion = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteWorklogEntry(entry) {
|
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) {
|
function extractEventIds(s) {
|
||||||
if (s.eventIds && s.eventIds.length) return s.eventIds
|
if (s.eventIds && s.eventIds.length) return s.eventIds
|
||||||
if (s.events && s.events.length) return s.events.map(ev => ev.id).filter(Boolean)
|
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>
|
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')} · {t('suggest.confidence.' + s.confidence)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="suggestion-actions">
|
<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)}>
|
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>
|
||||||
{t('worklog.apply')}
|
{t('worklog.apply')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -2053,6 +2076,7 @@
|
||||||
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
|
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
|
||||||
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
|
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
|
||||||
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
|
<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>
|
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptJournalSuggestion(s)}>{t('suggest.apply')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2211,6 +2235,7 @@
|
||||||
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
|
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
|
||||||
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
|
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
|
||||||
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
|
<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>
|
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>{t('suggest.apply')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2400,7 +2425,7 @@
|
||||||
{#if showWorklogModal}
|
{#if showWorklogModal}
|
||||||
<div class="modal-overlay" role="button" tabindex="0" on:click|self={closeWorklogModal} on:keydown={onKeyActivate(closeWorklogModal)}>
|
<div class="modal-overlay" role="button" tabindex="0" on:click|self={closeWorklogModal} on:keydown={onKeyActivate(closeWorklogModal)}>
|
||||||
<div class="modal modal-worklog">
|
<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">
|
<div class="form-group">
|
||||||
<label><span class="label-text">{t('worklog.date')}</span>
|
<label><span class="label-text">{t('worklog.date')}</span>
|
||||||
<input type="date" bind:value={wlModalDate} />
|
<input type="date" bind:value={wlModalDate} />
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,7 @@ export default {
|
||||||
'worklog.min': 'min',
|
'worklog.min': 'min',
|
||||||
'worklog.log': 'Log',
|
'worklog.log': 'Log',
|
||||||
'worklog.addEntry': 'Add entry',
|
'worklog.addEntry': 'Add entry',
|
||||||
|
'worklog.acceptSuggestion': 'Accept suggestion',
|
||||||
'worklog.editEntry': 'Edit entry',
|
'worklog.editEntry': 'Edit entry',
|
||||||
'worklog.deleteEntry': 'Delete 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.',
|
'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.min': 'мин',
|
||||||
'worklog.log': 'Записать',
|
'worklog.log': 'Записать',
|
||||||
'worklog.addEntry': 'Добавить запись',
|
'worklog.addEntry': 'Добавить запись',
|
||||||
|
'worklog.acceptSuggestion': 'Принять предложение',
|
||||||
'worklog.editEntry': 'Редактировать запись',
|
'worklog.editEntry': 'Редактировать запись',
|
||||||
'worklog.deleteEntry': 'Удалить запись',
|
'worklog.deleteEntry': 'Удалить запись',
|
||||||
'worklog.deleteConfirm': 'Удалить эту запись работы? Связанные события останутся в активности, но связь с записью будет удалена.',
|
'worklog.deleteConfirm': 'Удалить эту запись работы? Связанные события останутся в активности, но связь с записью будет удалена.',
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,14 @@ export function DeleteWorklogEntry(arg1) {
|
||||||
return window['go']['main']['App']['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) {
|
export function Search(arg1) {
|
||||||
return window['go']['main']['App']['Search'](arg1);
|
return window['go']['main']['App']['Search'](arg1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,14 @@ async function runReadyScenario(cdp, url) {
|
||||||
|
|
||||||
await clickText(cdp, '.tab', 'Журнал')
|
await clickText(cdp, '.tab', 'Журнал')
|
||||||
await assertText(cdp, 'Manual smoke entry', 'worklog: entry visible')
|
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 clickText(cdp, '.worklog-toolbar .btn', 'Добавить запись')
|
||||||
await waitForSelector(cdp, '.modal-worklog')
|
await waitForSelector(cdp, '.modal-worklog')
|
||||||
await setInputValue(cdp, '.modal-worklog input[type="text"]', 'GUI smoke worklog')
|
await setInputValue(cdp, '.modal-worklog input[type="text"]', 'GUI smoke worklog')
|
||||||
|
|
@ -768,6 +776,11 @@ function wailsMockSource() {
|
||||||
events,
|
events,
|
||||||
}],
|
}],
|
||||||
AcceptSuggestionWith: async () => true,
|
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 }))),
|
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 }] }),
|
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 () => [],
|
GetWorklogEntryEvents: async () => [],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue