feat: edit and delete worklog entries
This commit is contained in:
parent
272a7f870b
commit
eb6a861310
|
|
@ -6,8 +6,8 @@ import (
|
||||||
|
|
||||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"verstak/internal/core/worklog"
|
|
||||||
syncsvc "verstak/internal/core/sync"
|
syncsvc "verstak/internal/core/sync"
|
||||||
|
"verstak/internal/core/worklog"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
|
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
|
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 ---
|
// --- report bindings ---
|
||||||
|
|
||||||
func (a *App) ListWorklogReport(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) ([]worklog.ReportRow, error) {
|
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
|
|
@ -16,8 +16,8 @@
|
||||||
background: #13131f;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-HFhkxYxJ.js"></script>
|
<script type="module" crossorigin src="/assets/main-Cc1HprFt.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-D1RMkKjM.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-DAyIHTpH.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"verstak/internal/core/activity"
|
"verstak/internal/core/activity"
|
||||||
|
syncsvc "verstak/internal/core/sync"
|
||||||
"verstak/internal/core/util"
|
"verstak/internal/core/util"
|
||||||
"verstak/internal/core/worklog"
|
"verstak/internal/core/worklog"
|
||||||
)
|
)
|
||||||
|
|
@ -282,3 +283,112 @@ func TestManualWorklogEntry(t *testing.T) {
|
||||||
t.Errorf("dto.Source = %q, want %q", dto.Source, worklog.SourceManual)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -885,7 +885,12 @@ func (a *App) applyRemoteWorklogOp(op syncsvc.Op) error {
|
||||||
switch op.OpType {
|
switch op.OpType {
|
||||||
case syncsvc.OpCreate:
|
case syncsvc.OpCreate:
|
||||||
return a.applyRemoteWorklogCreate(op)
|
return a.applyRemoteWorklogCreate(op)
|
||||||
|
case syncsvc.OpUpdate:
|
||||||
|
return a.applyRemoteWorklogUpdate(op)
|
||||||
case syncsvc.OpDelete:
|
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)
|
_, err := a.db.Exec(`DELETE FROM worklog_entries WHERE id=?`, op.EntityID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -921,3 +926,36 @@ func (a *App) applyRemoteWorklogCreate(op syncsvc.Op) error {
|
||||||
payload.Summary, payload.Details, payload.CreatedAt, payload.UpdatedAt)
|
payload.Summary, payload.Details, payload.CreatedAt, payload.UpdatedAt)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@
|
||||||
let wlModalDetails = ''
|
let wlModalDetails = ''
|
||||||
let wlModalBillable = false
|
let wlModalBillable = false
|
||||||
let wlModalApprox = false
|
let wlModalApprox = false
|
||||||
|
let editingWorklogEntry = null
|
||||||
let suggestions = []
|
let suggestions = []
|
||||||
let suggestionCount = 0
|
let suggestionCount = 0
|
||||||
let showCreateNode = false
|
let showCreateNode = false
|
||||||
|
|
@ -965,28 +966,67 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Worklog =====
|
// ===== Worklog =====
|
||||||
function openWorklogModal() {
|
function openWorklogModal(entry = null) {
|
||||||
wlModalSummary = ''
|
editingWorklogEntry = entry
|
||||||
wlModalMinutes = ''
|
wlModalSummary = entry ? entry.summary : ''
|
||||||
wlModalDate = ''
|
wlModalMinutes = entry ? String(entry.minutes || '') : ''
|
||||||
wlModalDetails = ''
|
wlModalDate = entry ? (entry.date || '') : ''
|
||||||
wlModalBillable = false
|
wlModalDetails = entry ? (entry.details || '') : ''
|
||||||
wlModalApprox = false
|
wlModalBillable = entry ? !!entry.billable : false
|
||||||
|
wlModalApprox = entry ? !!entry.approximate : false
|
||||||
showWorklogModal = true
|
showWorklogModal = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeWorklogModal() {
|
function closeWorklogModal() {
|
||||||
showWorklogModal = false
|
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() {
|
async function submitWorklogModal() {
|
||||||
const mins = parseInt(wlModalMinutes, 10)
|
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 {
|
try {
|
||||||
await wailsCall('CreateWorklogFull', selectedNode.id, wlModalSummary.trim(), wlModalDetails, wlModalDate, mins, wlModalApprox, wlModalBillable)
|
if (editingWorklogEntry) {
|
||||||
} catch (e) { /* fallback */ }
|
await wailsCall('UpdateWorklogEntry', editingWorklogEntry.id, wlModalSummary.trim(), wlModalDetails, wlModalDate, mins, wlModalApprox, wlModalBillable)
|
||||||
worklog = initWorklogEntries(await wailsCall('ListWorklog', selectedNode.id)) || worklog
|
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
|
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() {
|
async function refreshAfterSuggestion() {
|
||||||
|
|
@ -1803,7 +1843,7 @@
|
||||||
{:else if activeTab === 'worklog'}
|
{:else if activeTab === 'worklog'}
|
||||||
<div class="worklog-tab">
|
<div class="worklog-tab">
|
||||||
<div class="worklog-toolbar">
|
<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>
|
</div>
|
||||||
{#if selectedNode && suggestions.filter(s => s.nodeId === selectedNode.id).length > 0}
|
{#if selectedNode && suggestions.filter(s => s.nodeId === selectedNode.id).length > 0}
|
||||||
<div class="worklog-tab-suggestions">
|
<div class="worklog-tab-suggestions">
|
||||||
|
|
@ -1852,10 +1892,14 @@
|
||||||
<span class="worklog-entry-mins">{e.minutes} {t('worklog.min')}</span>
|
<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.billable}<span class="wl-tag-billable">{t('journal.billableYes')}</span>{/if}
|
||||||
{#if e.approximate}<span class="wl-tag-approx">{t('journal.approxEstimated')}</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>
|
</div>
|
||||||
{#if e._expanded}
|
{#if e._expanded}
|
||||||
<div class="worklog-entry-detail">
|
<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}
|
{#if e.details}
|
||||||
<div class="wl-detail-section">
|
<div class="wl-detail-section">
|
||||||
<span class="wl-detail-label">{t('worklog.details')}</span>
|
<span class="wl-detail-label">{t('worklog.details')}</span>
|
||||||
|
|
@ -2088,6 +2132,10 @@
|
||||||
<tr class="journal-row-detail">
|
<tr class="journal-row-detail">
|
||||||
<td colspan="8">
|
<td colspan="8">
|
||||||
<div class="journal-detail-body">
|
<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}
|
{#if r.details}
|
||||||
<div class="journal-detail-section">
|
<div class="journal-detail-section">
|
||||||
<span class="journal-detail-label">{t('worklog.details')}</span>
|
<span class="journal-detail-label">{t('worklog.details')}</span>
|
||||||
|
|
@ -2352,7 +2400,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>{t('worklog.addEntry')}</h3>
|
<h3>{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} />
|
||||||
|
|
@ -2647,6 +2695,7 @@
|
||||||
.journal-summary-cell { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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-row-detail td { padding: 0 12px 12px; background: #16162a; }
|
||||||
.journal-detail-body { display: flex; flex-direction: column; gap: 12px; padding: 8px 0; }
|
.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-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-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; }
|
.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-mins { color: #b0b0c8; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
.worklog-entry-date { color: #b0b0c0; font-size: 12px; 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-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-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-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; }
|
.wl-detail-section p { margin: 0; color: #c0c0d0; }
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,9 @@ export default {
|
||||||
'worklog.min': 'min',
|
'worklog.min': 'min',
|
||||||
'worklog.log': 'Log',
|
'worklog.log': 'Log',
|
||||||
'worklog.addEntry': 'Add entry',
|
'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.date': 'Date',
|
||||||
'worklog.empty': 'No work logged yet',
|
'worklog.empty': 'No work logged yet',
|
||||||
'worklog.details': 'Details',
|
'worklog.details': 'Details',
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,9 @@ export default {
|
||||||
'worklog.min': 'мин',
|
'worklog.min': 'мин',
|
||||||
'worklog.log': 'Записать',
|
'worklog.log': 'Записать',
|
||||||
'worklog.addEntry': 'Добавить запись',
|
'worklog.addEntry': 'Добавить запись',
|
||||||
|
'worklog.editEntry': 'Редактировать запись',
|
||||||
|
'worklog.deleteEntry': 'Удалить запись',
|
||||||
|
'worklog.deleteConfirm': 'Удалить эту запись работы? Связанные события останутся в активности, но связь с записью будет удалена.',
|
||||||
'worklog.date': 'Дата',
|
'worklog.date': 'Дата',
|
||||||
'worklog.empty': 'Записей работы пока нет',
|
'worklog.empty': 'Записей работы пока нет',
|
||||||
'worklog.details': 'Детали',
|
'worklog.details': 'Детали',
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,18 @@ export function CreateWorklog(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['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) {
|
export function Search(arg1) {
|
||||||
return window['go']['main']['App']['Search'](arg1);
|
return window['go']['main']['App']['Search'](arg1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,12 +124,30 @@ func buildEntry(nodeID, summary, details, date string, minutes int, approximate,
|
||||||
|
|
||||||
// Update modifies an existing entry.
|
// Update modifies an existing entry.
|
||||||
func (s *Service) Update(id, summary, details string, minutes int, approximate, billable bool) error {
|
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)
|
t := time.Now().UTC().Format(time.RFC3339)
|
||||||
res, err := s.db.Exec(
|
var res sql.Result
|
||||||
`UPDATE worklog_entries SET summary=?, details=?, minutes=?,
|
var err error
|
||||||
approximate=?, billable=?, updated_at=? WHERE id=?`,
|
if date == "" {
|
||||||
summary, details, &minutes, boolInt(approximate), boolInt(billable), t, id,
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -179,7 +197,16 @@ func (s *Service) ListByNode(nodeID string) ([]Entry, error) {
|
||||||
|
|
||||||
// Delete removes an entry.
|
// Delete removes an entry.
|
||||||
func (s *Service) Delete(id string) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -187,7 +214,7 @@ func (s *Service) Delete(id string) error {
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return fmt.Errorf("entry not found")
|
return fmt.Errorf("entry not found")
|
||||||
}
|
}
|
||||||
return nil
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasTodayEntries checks if any worklog entries exist for today.
|
// HasTodayEntries checks if any worklog entries exist for today.
|
||||||
|
|
|
||||||
|
|
@ -76,12 +76,15 @@ func TestUpdate(t *testing.T) {
|
||||||
svc := NewService(db)
|
svc := NewService(db)
|
||||||
|
|
||||||
e, _ := svc.Add("node-1", "Old text", "Old details", 60, false, false)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, _ := svc.Get(e.ID)
|
got, _ := svc.Get(e.ID)
|
||||||
|
if got.Date != "2026-01-02" {
|
||||||
|
t.Errorf("date = %q", got.Date)
|
||||||
|
}
|
||||||
if got.Summary != "New text" {
|
if got.Summary != "New text" {
|
||||||
t.Errorf("summary = %q", got.Summary)
|
t.Errorf("summary = %q", got.Summary)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,18 @@ async function runReadyScenario(cdp, url) {
|
||||||
await clickText(cdp, '.modal-worklog .btn', 'Сохранить')
|
await clickText(cdp, '.modal-worklog .btn', 'Сохранить')
|
||||||
await waitForGone(cdp, '.modal-worklog')
|
await waitForGone(cdp, '.modal-worklog')
|
||||||
await assertText(cdp, 'GUI smoke worklog', 'worklog: created entry appears')
|
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 clickText(cdp, '.tab', 'Активность')
|
||||||
await assertText(cdp, 'Smoke activity', 'activity: per-node activity visible')
|
await assertText(cdp, 'Smoke activity', 'activity: per-node activity visible')
|
||||||
|
|
@ -729,6 +741,23 @@ function wailsMockSource() {
|
||||||
state.worklog[nodeId] = [...(state.worklog[nodeId] || []), entry];
|
state.worklog[nodeId] = [...(state.worklog[nodeId] || []), entry];
|
||||||
return clone(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 () => [{
|
GetSuggestions: async () => [{
|
||||||
nodeId: 'node-project',
|
nodeId: 'node-project',
|
||||||
nodeTitle: 'Smoke Project',
|
nodeTitle: 'Smoke Project',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue