feat: edit and delete worklog entries

This commit is contained in:
mirivlad 2026-06-05 00:48:12 +08:00
parent 272a7f870b
commit eb6a861310
15 changed files with 330 additions and 29 deletions

View File

@ -6,8 +6,8 @@ import (
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"verstak/internal/core/worklog"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/worklog"
)
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
@ -45,6 +45,32 @@ func (a *App) CreateWorklogFull(nodeID, summary, details, date string, minutes i
return entryToDTO(entry), nil
}
func (a *App) UpdateWorklogEntry(id, summary, details, date string, minutes int, approximate, billable bool) (*WorklogDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
if err := a.worklog.UpdateWithDate(id, summary, details, date, minutes, approximate, billable); err != nil {
return nil, err
}
entry, err := a.worklog.Get(id)
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpUpdate, worklogPayload(entry))
return entryToDTO(entry), nil
}
func (a *App) DeleteWorklogEntry(id string) error {
if err := a.requireVault(); err != nil {
return err
}
if err := a.worklog.Delete(id); err != nil {
return err
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, id, syncsvc.OpDelete, nil)
return nil
}
// --- report bindings ---
func (a *App) ListWorklogReport(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) ([]worklog.ReportRow, error) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -16,8 +16,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-HFhkxYxJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-D1RMkKjM.css">
<script type="module" crossorigin src="/assets/main-Cc1HprFt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DAyIHTpH.css">
</head>
<body>
<div id="app"></div>

View File

@ -6,6 +6,7 @@ import (
"time"
"verstak/internal/core/activity"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/util"
"verstak/internal/core/worklog"
)
@ -282,3 +283,112 @@ func TestManualWorklogEntry(t *testing.T) {
t.Errorf("dto.Source = %q, want %q", dto.Source, worklog.SourceManual)
}
}
func hasWorklogSyncOp(t *testing.T, app *App, entryID, opType string) bool {
t.Helper()
ops, err := app.sync.GetUnpushedOps()
if err != nil {
t.Fatalf("GetUnpushedOps: %v", err)
}
for _, op := range ops {
if op.EntityType == syncsvc.EntityWorklog && op.EntityID == entryID && op.OpType == opType {
return true
}
}
return false
}
func TestUpdateAndDeleteWorklogEntryBinding(t *testing.T) {
app, _ := setupTestApp(t)
n, err := app.CreateNodeFromTemplate("", "Editable Worklog Node", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
dto, err := app.CreateWorklogFull(n.ID, "Old summary", "Old details", "2026-01-01", 30, false, false)
if err != nil {
t.Fatalf("CreateWorklogFull: %v", err)
}
eventID := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "note-1", "Связанное событие")
if _, err := app.db.Exec(`INSERT INTO worklog_entry_events(entry_id,event_id) VALUES(?,?)`, dto.ID, eventID); err != nil {
t.Fatalf("insert worklog event link: %v", err)
}
updated, err := app.UpdateWorklogEntry(dto.ID, "New summary", "New details", "2026-01-02", 45, true, true)
if err != nil {
t.Fatalf("UpdateWorklogEntry: %v", err)
}
if updated.Summary != "New summary" || updated.Details != "New details" || updated.Date != "2026-01-02" {
t.Fatalf("updated DTO = %#v", updated)
}
if updated.Minutes != 45 || !updated.Approximate || !updated.Billable {
t.Fatalf("updated flags/minutes = %#v", updated)
}
if !hasWorklogSyncOp(t, app, dto.ID, syncsvc.OpUpdate) {
t.Fatal("missing worklog update sync op")
}
if n := countLinked(t, app, dto.ID); n != 1 {
t.Fatalf("event links after update = %d, want 1", n)
}
if err := app.DeleteWorklogEntry(dto.ID); err != nil {
t.Fatalf("DeleteWorklogEntry: %v", err)
}
if _, err := app.worklog.Get(dto.ID); err == nil {
t.Fatal("expected deleted worklog entry to be gone")
}
if n := countLinked(t, app, dto.ID); n != 0 {
t.Fatalf("event links after delete = %d, want 0", n)
}
if !hasWorklogSyncOp(t, app, dto.ID, syncsvc.OpDelete) {
t.Fatal("missing worklog delete sync op")
}
}
func TestApplyRemoteWorklogUpdate(t *testing.T) {
app, _ := setupTestApp(t)
n, err := app.CreateNodeFromTemplate("", "Remote Worklog Node", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
dto, err := app.CreateWorklogFull(n.ID, "Before remote", "", "2026-01-03", 15, false, false)
if err != nil {
t.Fatalf("CreateWorklogFull: %v", err)
}
payload, err := json.Marshal(map[string]interface{}{
"id": dto.ID,
"node_id": n.ID,
"summary": "After remote",
"details": "Remote details",
"minutes": 75,
"date": "2026-01-04",
"approximate": true,
"billable": true,
"updated_at": nowISO(),
})
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
if err := app.applyRemoteWorklogOp(syncsvc.Op{
EntityType: syncsvc.EntityWorklog,
EntityID: dto.ID,
OpType: syncsvc.OpUpdate,
PayloadJSON: string(payload),
}); err != nil {
t.Fatalf("applyRemoteWorklogOp update: %v", err)
}
got, err := app.worklog.Get(dto.ID)
if err != nil {
t.Fatalf("get updated entry: %v", err)
}
if got.Summary != "After remote" || got.Details != "Remote details" || got.Date != "2026-01-04" {
t.Fatalf("remote updated entry = %#v", got)
}
if got.Minutes == nil || *got.Minutes != 75 || !got.Approximate || !got.Billable {
t.Fatalf("remote updated minutes/flags = %#v", got)
}
}

View File

@ -885,7 +885,12 @@ func (a *App) applyRemoteWorklogOp(op syncsvc.Op) error {
switch op.OpType {
case syncsvc.OpCreate:
return a.applyRemoteWorklogCreate(op)
case syncsvc.OpUpdate:
return a.applyRemoteWorklogUpdate(op)
case syncsvc.OpDelete:
if _, err := a.db.Exec(`DELETE FROM worklog_entry_events WHERE entry_id=?`, op.EntityID); err != nil {
return err
}
_, err := a.db.Exec(`DELETE FROM worklog_entries WHERE id=?`, op.EntityID)
return err
}
@ -921,3 +926,36 @@ func (a *App) applyRemoteWorklogCreate(op syncsvc.Op) error {
payload.Summary, payload.Details, payload.CreatedAt, payload.UpdatedAt)
return err
}
func (a *App) applyRemoteWorklogUpdate(op syncsvc.Op) error {
var payload struct {
ID string `json:"id"`
Summary string `json:"summary"`
Details string `json:"details"`
Minutes int `json:"minutes"`
Date string `json:"date"`
Approximate bool `json:"approximate"`
Billable bool `json:"billable"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal worklog update: %w", err)
}
id := payload.ID
if id == "" {
id = op.EntityID
}
if id == "" {
return nil
}
updatedAt := payload.UpdatedAt
if updatedAt == "" {
updatedAt = time.Now().UTC().Format(time.RFC3339)
}
_, err := a.db.Exec(
`UPDATE worklog_entries SET date=?, minutes=?, approximate=?, billable=?,
summary=?, details=?, updated_at=? WHERE id=?`,
payload.Date, payload.Minutes, boolToInt(payload.Approximate), boolToInt(payload.Billable),
payload.Summary, payload.Details, updatedAt, id)
return err
}

View File

@ -87,6 +87,7 @@
let wlModalDetails = ''
let wlModalBillable = false
let wlModalApprox = false
let editingWorklogEntry = null
let suggestions = []
let suggestionCount = 0
let showCreateNode = false
@ -965,28 +966,67 @@
}
// ===== Worklog =====
function openWorklogModal() {
wlModalSummary = ''
wlModalMinutes = ''
wlModalDate = ''
wlModalDetails = ''
wlModalBillable = false
wlModalApprox = false
function openWorklogModal(entry = null) {
editingWorklogEntry = entry
wlModalSummary = entry ? entry.summary : ''
wlModalMinutes = entry ? String(entry.minutes || '') : ''
wlModalDate = entry ? (entry.date || '') : ''
wlModalDetails = entry ? (entry.details || '') : ''
wlModalBillable = entry ? !!entry.billable : false
wlModalApprox = entry ? !!entry.approximate : false
showWorklogModal = true
}
function closeWorklogModal() {
showWorklogModal = false
editingWorklogEntry = null
}
async function refreshWorklogViews(nodeID = '') {
const targetNodeID = nodeID || (selectedNode ? selectedNode.id : '')
if (selectedNode && selectedNode.id === targetNodeID) {
worklog = initWorklogEntries(await wailsCall('ListWorklog', selectedNode.id)) || worklog
}
if (selectedSection === 'journal') {
await loadJournal()
}
}
async function submitWorklogModal() {
const mins = parseInt(wlModalMinutes, 10)
if (!wlModalSummary.trim() || isNaN(mins) || mins <= 0 || !selectedNode) return
if (!wlModalSummary.trim() || isNaN(mins) || mins <= 0) return
if (!editingWorklogEntry && !selectedNode) return
try {
await wailsCall('CreateWorklogFull', selectedNode.id, wlModalSummary.trim(), wlModalDetails, wlModalDate, mins, wlModalApprox, wlModalBillable)
} catch (e) { /* fallback */ }
worklog = initWorklogEntries(await wailsCall('ListWorklog', selectedNode.id)) || worklog
if (editingWorklogEntry) {
await wailsCall('UpdateWorklogEntry', editingWorklogEntry.id, wlModalSummary.trim(), wlModalDetails, wlModalDate, mins, wlModalApprox, wlModalBillable)
await refreshWorklogViews(editingWorklogEntry.nodeId)
} else {
await wailsCall('CreateWorklogFull', selectedNode.id, wlModalSummary.trim(), wlModalDetails, wlModalDate, mins, wlModalApprox, wlModalBillable)
await refreshWorklogViews(selectedNode.id)
}
} catch (e) {
error = String(e)
return
}
showWorklogModal = false
editingWorklogEntry = null
}
function deleteWorklogEntry(entry) {
openConfirm({
title: t('worklog.deleteEntry'),
message: t('worklog.deleteConfirm'),
confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
try {
await wailsCall('DeleteWorklogEntry', entry.id)
await refreshWorklogViews(entry.nodeId)
} catch (e) {
error = String(e)
}
}
})
}
async function refreshAfterSuggestion() {
@ -1803,7 +1843,7 @@
{:else if activeTab === 'worklog'}
<div class="worklog-tab">
<div class="worklog-toolbar">
<button class="btn btn-primary btn-sm" on:click={openWorklogModal}>+ {t('worklog.addEntry')}</button>
<button class="btn btn-primary btn-sm" on:click={() => openWorklogModal()}>+ {t('worklog.addEntry')}</button>
</div>
{#if selectedNode && suggestions.filter(s => s.nodeId === selectedNode.id).length > 0}
<div class="worklog-tab-suggestions">
@ -1852,10 +1892,14 @@
<span class="worklog-entry-mins">{e.minutes} {t('worklog.min')}</span>
{#if e.billable}<span class="wl-tag-billable">{t('journal.billableYes')}</span>{/if}
{#if e.approximate}<span class="wl-tag-approx">{t('journal.approxEstimated')}</span>{/if}
<span class="worklog-entry-date">{formatDate(e.createdAt)}</span>
<span class="worklog-entry-date">{e.date}</span>
</div>
{#if e._expanded}
<div class="worklog-entry-detail">
<div class="worklog-entry-actions">
<button class="btn btn-sm" on:click|stopPropagation={() => openWorklogModal(e)}>{t('worklog.editEntry')}</button>
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteWorklogEntry(e)}>{t('worklog.deleteEntry')}</button>
</div>
{#if e.details}
<div class="wl-detail-section">
<span class="wl-detail-label">{t('worklog.details')}</span>
@ -2088,6 +2132,10 @@
<tr class="journal-row-detail">
<td colspan="8">
<div class="journal-detail-body">
<div class="journal-detail-actions">
<button class="btn btn-sm" on:click|stopPropagation={() => openWorklogModal(r)}>{t('worklog.editEntry')}</button>
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteWorklogEntry(r)}>{t('worklog.deleteEntry')}</button>
</div>
{#if r.details}
<div class="journal-detail-section">
<span class="journal-detail-label">{t('worklog.details')}</span>
@ -2352,7 +2400,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>{t('worklog.addEntry')}</h3>
<h3>{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} />
@ -2647,6 +2695,7 @@
.journal-summary-cell { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.journal-row-detail td { padding: 0 12px 12px; background: #16162a; }
.journal-detail-body { display: flex; flex-direction: column; gap: 12px; padding: 8px 0; }
.journal-detail-actions { display: flex; gap: 8px; justify-content: flex-end; }
.journal-detail-section { font-size: 13px; }
.journal-detail-label { font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; display: block; margin-bottom: 4px; }
.journal-detail-section p { margin: 0; color: #c0c0d0; }
@ -2883,6 +2932,7 @@
.worklog-entry-mins { color: #b0b0c8; font-variant-numeric: tabular-nums; white-space: nowrap; }
.worklog-entry-date { color: #b0b0c0; font-size: 12px; white-space: nowrap; }
.worklog-entry-detail { margin-top: 8px; padding-top: 8px; border-top: 1px solid #2a2a3c; display: flex; flex-direction: column; gap: 8px; }
.worklog-entry-actions { display: flex; gap: 8px; justify-content: flex-end; }
.wl-detail-section { font-size: 13px; }
.wl-detail-label { font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; display: block; margin-bottom: 2px; }
.wl-detail-section p { margin: 0; color: #c0c0d0; }

View File

@ -149,6 +149,9 @@ export default {
'worklog.min': 'min',
'worklog.log': 'Log',
'worklog.addEntry': 'Add entry',
'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.',
'worklog.date': 'Date',
'worklog.empty': 'No work logged yet',
'worklog.details': 'Details',

View File

@ -158,6 +158,9 @@ export default {
'worklog.min': 'мин',
'worklog.log': 'Записать',
'worklog.addEntry': 'Добавить запись',
'worklog.editEntry': 'Редактировать запись',
'worklog.deleteEntry': 'Удалить запись',
'worklog.deleteConfirm': 'Удалить эту запись работы? Связанные события останутся в активности, но связь с записью будет удалена.',
'worklog.date': 'Дата',
'worklog.empty': 'Записей работы пока нет',
'worklog.details': 'Детали',

View File

@ -82,6 +82,18 @@ export function CreateWorklog(arg1, arg2, arg3) {
return window['go']['main']['App']['CreateWorklog'](arg1, arg2, arg3);
}
export function CreateWorklogFull(arg1, arg2, arg3, arg4, arg5, arg6, arg7) {
return window['go']['main']['App']['CreateWorklogFull'](arg1, arg2, arg3, arg4, arg5, arg6, arg7);
}
export function UpdateWorklogEntry(arg1, arg2, arg3, arg4, arg5, arg6, arg7) {
return window['go']['main']['App']['UpdateWorklogEntry'](arg1, arg2, arg3, arg4, arg5, arg6, arg7);
}
export function DeleteWorklogEntry(arg1) {
return window['go']['main']['App']['DeleteWorklogEntry'](arg1);
}
export function Search(arg1) {
return window['go']['main']['App']['Search'](arg1);
}

View File

@ -124,12 +124,30 @@ func buildEntry(nodeID, summary, details, date string, minutes int, approximate,
// Update modifies an existing entry.
func (s *Service) Update(id, summary, details string, minutes int, approximate, billable bool) error {
return s.UpdateWithDate(id, summary, details, "", minutes, approximate, billable)
}
// UpdateWithDate modifies an existing entry, including its work date when provided.
func (s *Service) UpdateWithDate(id, summary, details, date string, minutes int, approximate, billable bool) error {
if summary == "" {
return fmt.Errorf("summary required")
}
t := time.Now().UTC().Format(time.RFC3339)
res, err := s.db.Exec(
`UPDATE worklog_entries SET summary=?, details=?, minutes=?,
approximate=?, billable=?, updated_at=? WHERE id=?`,
summary, details, &minutes, boolInt(approximate), boolInt(billable), t, id,
)
var res sql.Result
var err error
if date == "" {
res, err = s.db.Exec(
`UPDATE worklog_entries SET summary=?, details=?, minutes=?,
approximate=?, billable=?, updated_at=? WHERE id=?`,
summary, details, &minutes, boolInt(approximate), boolInt(billable), t, id,
)
} else {
res, err = s.db.Exec(
`UPDATE worklog_entries SET date=?, summary=?, details=?, minutes=?,
approximate=?, billable=?, updated_at=? WHERE id=?`,
date, summary, details, &minutes, boolInt(approximate), boolInt(billable), t, id,
)
}
if err != nil {
return err
}
@ -179,7 +197,16 @@ func (s *Service) ListByNode(nodeID string) ([]Entry, error) {
// Delete removes an entry.
func (s *Service) Delete(id string) error {
res, err := s.db.Exec("DELETE FROM worklog_entries WHERE id=?", id)
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("DELETE FROM worklog_entry_events WHERE entry_id=?", id); err != nil {
return err
}
res, err := tx.Exec("DELETE FROM worklog_entries WHERE id=?", id)
if err != nil {
return err
}
@ -187,7 +214,7 @@ func (s *Service) Delete(id string) error {
if n == 0 {
return fmt.Errorf("entry not found")
}
return nil
return tx.Commit()
}
// HasTodayEntries checks if any worklog entries exist for today.

View File

@ -76,12 +76,15 @@ func TestUpdate(t *testing.T) {
svc := NewService(db)
e, _ := svc.Add("node-1", "Old text", "Old details", 60, false, false)
err := svc.Update(e.ID, "New text", "New details", 90, true, true)
err := svc.UpdateWithDate(e.ID, "New text", "New details", "2026-01-02", 90, true, true)
if err != nil {
t.Fatal(err)
}
got, _ := svc.Get(e.ID)
if got.Date != "2026-01-02" {
t.Errorf("date = %q", got.Date)
}
if got.Summary != "New text" {
t.Errorf("summary = %q", got.Summary)
}

View File

@ -169,6 +169,18 @@ async function runReadyScenario(cdp, url) {
await clickText(cdp, '.modal-worklog .btn', 'Сохранить')
await waitForGone(cdp, '.modal-worklog')
await assertText(cdp, 'GUI smoke worklog', 'worklog: created entry appears')
await clickText(cdp, '.worklog-entry', 'GUI smoke worklog')
await clickText(cdp, '.worklog-entry-actions .btn', 'Редактировать запись')
await waitForSelector(cdp, '.modal-worklog')
await setInputValue(cdp, '.modal-worklog input[type="text"]', 'GUI smoke worklog edited')
await setInputValue(cdp, '.modal-worklog input[type="number"]', '25')
await clickText(cdp, '.modal-worklog .btn', 'Сохранить')
await waitForGone(cdp, '.modal-worklog')
await assertText(cdp, 'GUI smoke worklog edited', 'worklog: edited entry appears')
await clickText(cdp, '.worklog-entry', 'GUI smoke worklog edited')
await clickText(cdp, '.worklog-entry-actions .btn', 'Удалить запись')
await clickText(cdp, '.overlay .btn', 'Удалить')
await assertEval(cdp, `!document.body.innerText.includes('GUI smoke worklog edited')`, 'worklog: deleted entry disappears')
await clickText(cdp, '.tab', 'Активность')
await assertText(cdp, 'Smoke activity', 'activity: per-node activity visible')
@ -729,6 +741,23 @@ function wailsMockSource() {
state.worklog[nodeId] = [...(state.worklog[nodeId] || []), entry];
return clone(entry);
},
UpdateWorklogEntry: async (id, summary, details, date, minutes, approximate, billable) => {
for (const nodeId of Object.keys(state.worklog)) {
const idx = state.worklog[nodeId].findIndex((entry) => entry.id === id);
if (idx >= 0) {
const updated = { ...state.worklog[nodeId][idx], summary, details, date, minutes, approximate, billable, updatedAt: now };
state.worklog[nodeId][idx] = updated;
return clone(updated);
}
}
throw new Error('worklog entry not found');
},
DeleteWorklogEntry: async (id) => {
for (const nodeId of Object.keys(state.worklog)) {
state.worklog[nodeId] = state.worklog[nodeId].filter((entry) => entry.id !== id);
}
return true;
},
GetSuggestions: async () => [{
nodeId: 'node-project',
nodeTitle: 'Smoke Project',