feat: worklog source field, suggestion logic fix, modal form, activity navigation
- Add source column to worklog_entries (migration 014): manual/suggestion/unknown - GetSuggestions now excludes only events linked in worklog_entry_events, not entire nodes — repeated activity same day now produces suggestions - Manual entry form replaced with '+' button + modal dialog - Source display shows correct origin (manual/suggestion/unknown/no-events) - Include-children checkbox hidden when no node selected - Activity events navigate to specific notes/files instead of just case - Expandable row reactivity fixed (journalRows/worklog reassignment)
This commit is contained in:
parent
1472bb3e6f
commit
fd99dd4f5c
21
AGENTS.md
21
AGENTS.md
|
|
@ -8,6 +8,27 @@
|
|||
5. **Context menu not closing on action** — `handleShowInFolder` calls `closeMenu()`.
|
||||
6. **Wrong folder when opening file's parent folder** — `OpenFolder` checks `n.FsPath == ""` for TypeFile and uses first file record path.
|
||||
7. **Tab highlight not updating visually** — was using `class={tabClass(tab.id)}` which didn't trigger reactive class updates in Svelte. Switched to `class="tab" class:active={activeTab === tab.id}`.
|
||||
8. **Journal table expand/collapse** — added explicit ▸/▾ toggle column so it's clear rows are expandable.
|
||||
9. **Per-node worklog entries** — made entries expandable with ▸/▾, showing details + billable/approximate tags.
|
||||
10. **Manual worklog entry form** — converted inline form to modal dialog ("+ Добавить запись") with all fields: date, summary, minutes, details, billable, approximate.
|
||||
11. **"С подзадачами" → "Учитывать вложенные дела"** — renamed, now hidden when no node selected.
|
||||
12. **Filter/export layout** — split into separate "Фильтры" and "Экспорт отчёта" sections with headings.
|
||||
13. **Suggestion events** — added "Показать в проводнике" button for file-type events in suggestion detail.
|
||||
14. **Removed duplicate i18n keys** in `ru.js` (worklog.suggestions, worklog.apply).
|
||||
15. **Removed unused CSS** (`.journal-filters`, `.wl-meta`, `.worklog-form`).
|
||||
16. **Added `openNodeFolder(nodeOrId)`** — accepts both string ID and node object.
|
||||
17. **Added `resetJournalFilters()`** — resets all filters and reloads.
|
||||
18. **Source field** — added `worklog_entries.source` column (migration 014). Values: manual, suggestion. Old entries default to 'unknown'.
|
||||
19. **Suggestions now use worklog_entry_events** instead of `HasTodayEntries` — only events already linked to worklog entries are excluded. Repeated activity on the same node today now produces new suggestions.
|
||||
20. **Activity target navigation** — clicking activity events for notes opens the note tab and loads the specific note. File events open the files tab.
|
||||
21. **Source display** — detail sections now show accurate source: "Ручная запись", "Из предложения", "Из предложения, но связанные события отсутствуют", or "Источник неизвестен".
|
||||
|
||||
## Key patterns
|
||||
- Always use explicit toggle icons (▸/▾) on expandable rows.
|
||||
- `CreateWorklogFull` supports all fields: nodeID, summary, details, date, minutes, approximate, billable.
|
||||
- `openNodeFolder(id)` accepts a string ID or a node object.
|
||||
- `GetSuggestions` filters out only events already in `worklog_entry_events`, not entire nodes.
|
||||
- New worklog entries get `source=manual` via `Add`/`AddWithDate`; suggestion entries get `source=suggestion` via `AcceptSuggestionWith`.
|
||||
|
||||
# Build instructions
|
||||
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ type WorklogDTO struct {
|
|||
Details string `json:"details,omitempty"`
|
||||
Approximate bool `json:"approximate"`
|
||||
Billable bool `json:"billable"`
|
||||
Source string `json:"source"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,16 +7,31 @@ import (
|
|||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/worklog"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
)
|
||||
|
||||
// GetSuggestions analyzes today's activity and returns conservative suggestions.
|
||||
// Only events not already linked in worklog_entry_events are considered.
|
||||
func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
||||
events, err := a.activity.ListTodayEvents()
|
||||
if err != nil || len(events) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine which event IDs are already accounted for in any worklog entry.
|
||||
accounted := make(map[string]bool)
|
||||
rows, err := a.db.Query(`SELECT DISTINCT event_id FROM worklog_entry_events`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var eid string
|
||||
if rows.Scan(&eid) == nil {
|
||||
accounted[eid] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type acc struct {
|
||||
title string
|
||||
kind string
|
||||
|
|
@ -24,6 +39,9 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
|||
}
|
||||
grouped := make(map[string]*acc)
|
||||
for _, e := range events {
|
||||
if accounted[e.ID] {
|
||||
continue
|
||||
}
|
||||
grp, ok := grouped[e.NodeID]
|
||||
if !ok {
|
||||
n, err := a.nodes.GetActive(e.NodeID)
|
||||
|
|
@ -41,11 +59,7 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
|||
|
||||
var suggestions []activity.Suggestion
|
||||
for nodeID, grp := range grouped {
|
||||
if grp.title == "" {
|
||||
continue
|
||||
}
|
||||
hasEntries, err := a.worklog.HasTodayEntries(nodeID)
|
||||
if err != nil || hasEntries {
|
||||
if grp.title == "" || len(grp.events) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +122,7 @@ func (a *App) AcceptSuggestionWith(s activity.Suggestion, minutes int, date stri
|
|||
if d == "" {
|
||||
d = time.Now().Format("2006-01-02")
|
||||
}
|
||||
entry, err := a.worklog.AddWithDate(s.NodeID, s.Summary, "", d, minutes, true, false)
|
||||
entry, err := a.worklog.AddWithSource(s.NodeID, s.Summary, "", d, minutes, true, false, worklog.SourceSuggestion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ func entryToDTO(e *worklog.Entry) *WorklogDTO {
|
|||
Details: e.Details,
|
||||
Approximate: e.Approximate,
|
||||
Billable: e.Billable,
|
||||
Source: e.Source,
|
||||
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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
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;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-BbnSy6IG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Bh5tqssv.css">
|
||||
<script type="module" crossorigin src="/assets/main-cq32hqy7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BafVhx43.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -61,6 +61,17 @@
|
|||
let worklog = []
|
||||
let worklogMinutes = ''
|
||||
let worklogSummary = ''
|
||||
let worklogDate = ''
|
||||
let worklogDetails = ''
|
||||
let worklogBillable = false
|
||||
let worklogApprox = false
|
||||
let showWorklogModal = false
|
||||
let wlModalSummary = ''
|
||||
let wlModalMinutes = ''
|
||||
let wlModalDate = ''
|
||||
let wlModalDetails = ''
|
||||
let wlModalBillable = false
|
||||
let wlModalApprox = false
|
||||
let suggestions = []
|
||||
let suggestionCount = 0
|
||||
let showCreateNode = false
|
||||
|
|
@ -239,7 +250,7 @@
|
|||
try { notes = await wailsCall('ListNotes', nodeID) || [] } catch(e) {}
|
||||
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
|
||||
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
|
||||
try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {}
|
||||
try { worklog = initWorklogEntries(await wailsCall('ListWorklog', nodeID)) } catch(e) {}
|
||||
try {
|
||||
suggestions = await wailsCall('GetSuggestions') || []
|
||||
suggestionCount = suggestions.length
|
||||
|
|
@ -795,10 +806,11 @@
|
|||
})
|
||||
}
|
||||
|
||||
async function openNodeFolder(node) {
|
||||
async function openNodeFolder(nodeOrId) {
|
||||
closeContextMenu()
|
||||
const id = typeof nodeOrId === 'string' ? nodeOrId : nodeOrId.id
|
||||
try {
|
||||
await wailsCall('OpenFolder', node.id)
|
||||
await wailsCall('OpenFolder', id)
|
||||
} catch (e) { error = String(e) }
|
||||
}
|
||||
|
||||
|
|
@ -923,24 +935,35 @@
|
|||
}
|
||||
|
||||
// ===== Worklog =====
|
||||
async function submitWorklog() {
|
||||
const mins = parseInt(worklogMinutes, 10)
|
||||
if (!worklogSummary.trim() || isNaN(mins) || mins <= 0 || !selectedNode) return
|
||||
function openWorklogModal() {
|
||||
wlModalSummary = ''
|
||||
wlModalMinutes = ''
|
||||
wlModalDate = ''
|
||||
wlModalDetails = ''
|
||||
wlModalBillable = false
|
||||
wlModalApprox = false
|
||||
showWorklogModal = true
|
||||
}
|
||||
|
||||
function closeWorklogModal() {
|
||||
showWorklogModal = false
|
||||
}
|
||||
|
||||
async function submitWorklogModal() {
|
||||
const mins = parseInt(wlModalMinutes, 10)
|
||||
if (!wlModalSummary.trim() || isNaN(mins) || mins <= 0 || !selectedNode) return
|
||||
try {
|
||||
const entry = await wailsCall('CreateWorklog', selectedNode.id, worklogSummary.trim(), mins)
|
||||
worklog = [...worklog, (entry && entry.id) ? entry : { id: Date.now().toString(), nodeId: selectedNode.id, summary: worklogSummary.trim(), minutes: mins, createdAt: new Date().toISOString() }]
|
||||
} catch (e) {
|
||||
worklog = [...worklog, { id: Date.now().toString(), nodeId: selectedNode.id, summary: worklogSummary.trim(), minutes: mins, createdAt: new Date().toISOString() }]
|
||||
}
|
||||
worklogSummary = ''
|
||||
worklogMinutes = ''
|
||||
await wailsCall('CreateWorklogFull', selectedNode.id, wlModalSummary.trim(), wlModalDetails, wlModalDate, mins, wlModalApprox, wlModalBillable)
|
||||
} catch (e) { /* fallback */ }
|
||||
worklog = initWorklogEntries(await wailsCall('ListWorklog', selectedNode.id)) || worklog
|
||||
showWorklogModal = false
|
||||
}
|
||||
|
||||
async function refreshAfterSuggestion() {
|
||||
suggestions = await wailsCall('GetSuggestions') || []
|
||||
suggestionCount = suggestions.length
|
||||
if (selectedNode) {
|
||||
worklog = await wailsCall('ListWorklog', selectedNode.id) || []
|
||||
worklog = initWorklogEntries(await wailsCall('ListWorklog', selectedNode.id))
|
||||
}
|
||||
if (selectedSection === 'journal') {
|
||||
await loadJournal()
|
||||
|
|
@ -969,7 +992,7 @@
|
|||
wailsCall('WorklogReportSummary', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter),
|
||||
wailsCall('GetSuggestions'),
|
||||
])
|
||||
journalRows = rows || []
|
||||
journalRows = (rows || []).map(r => { if (r._expanded === undefined) r._expanded = false; return r })
|
||||
journalSummary = summary || null
|
||||
suggestions = sugs || []
|
||||
suggestionCount = suggestions.length
|
||||
|
|
@ -1007,14 +1030,32 @@
|
|||
|
||||
async function toggleJournalRow(r) {
|
||||
r._expanded = !r._expanded
|
||||
journalRows = journalRows
|
||||
if (r._expanded && !r._events && r._hasEvents === undefined) {
|
||||
try {
|
||||
r._events = await wailsCall('GetWorklogEntryEvents', r.id) || []
|
||||
r._hasEvents = r._events.length > 0
|
||||
} catch(e) { r._events = []; r._hasEvents = false }
|
||||
journalRows = journalRows
|
||||
} catch(e) { r._events = []; r._hasEvents = false; journalRows = journalRows }
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleWorklogEntry(e) {
|
||||
e._expanded = !e._expanded
|
||||
if (e._expanded && e.source === 'suggestion' && !e._events && e._hasEvents === undefined) {
|
||||
try {
|
||||
e._events = await wailsCall('GetWorklogEntryEvents', e.id) || []
|
||||
e._hasEvents = e._events.length > 0
|
||||
} catch(e) { e._events = []; e._hasEvents = false }
|
||||
}
|
||||
worklog = worklog
|
||||
}
|
||||
|
||||
function initWorklogEntries(entries) {
|
||||
if (!entries) return []
|
||||
return entries.map(e => { if (e._expanded === undefined) e._expanded = false; return e })
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
|
|
@ -1059,6 +1100,20 @@
|
|||
loadJournal()
|
||||
}
|
||||
|
||||
function resetJournalFilters() {
|
||||
journalDateFrom = ''
|
||||
journalDateTo = ''
|
||||
journalNodeID = ''
|
||||
journalIncludeChildren = false
|
||||
journalBillableFilter = 'all'
|
||||
journalApproxFilter = 'all'
|
||||
journalFilteredNodeTitle = ''
|
||||
journalSearchQuery = ''
|
||||
journalSearchResults = []
|
||||
journalShowResults = false
|
||||
loadJournal()
|
||||
}
|
||||
|
||||
function downloadFile(name, content, mime) {
|
||||
const blob = new Blob([content], { type: mime })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
|
@ -1245,6 +1300,36 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function openActivityEvent(ev) {
|
||||
const targetNode = ev.nodeId
|
||||
if (!targetNode) return
|
||||
try {
|
||||
const node = await wailsCall('GetNodeDetail', targetNode)
|
||||
if (!node) return
|
||||
if (ev.targetType === 'note') {
|
||||
selectNode(node)
|
||||
activeTab = 'notes'
|
||||
// If we have a targetId, try to find and open the note
|
||||
if (ev.targetId) {
|
||||
// Load notes and open the specific note
|
||||
try { notes = await wailsCall('ListNotes', targetNode) || [] } catch(e) {}
|
||||
const note = notes.find(n => n.id === ev.targetId)
|
||||
if (note) {
|
||||
// Small delay to let the UI render the notes tab
|
||||
setTimeout(() => openNote(note), 100)
|
||||
}
|
||||
}
|
||||
} else if (ev.targetType === 'file' || ev.targetType === 'folder') {
|
||||
selectNode(node)
|
||||
activeTab = 'files'
|
||||
} else {
|
||||
selectNode(node)
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Sync =====
|
||||
async function loadSyncStatus() {
|
||||
try {
|
||||
|
|
@ -1659,11 +1744,8 @@
|
|||
|
||||
{:else if activeTab === 'worklog'}
|
||||
<div class="worklog-tab">
|
||||
<div class="worklog-form">
|
||||
<input type="text" placeholder={t('worklog.whatDone')} bind:value={worklogSummary} />
|
||||
<input type="number" placeholder={t('worklog.minutes')} bind:value={worklogMinutes} min="1" />
|
||||
<button class="btn btn-primary" on:click={submitWorklog}
|
||||
disabled={!worklogSummary.trim() || !worklogMinutes}>{t('worklog.log')}</button>
|
||||
<div class="worklog-toolbar">
|
||||
<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">
|
||||
|
|
@ -1690,6 +1772,9 @@
|
|||
<span class="suggestion-event-type">{t('event.' + ev.eventType) || ev.eventType}</span>
|
||||
<span class="suggestion-event-title">{ev.title}</span>
|
||||
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button>
|
||||
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
|
||||
<button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -1702,9 +1787,55 @@
|
|||
<div class="empty-state"><p>{t('worklog.empty')}</p></div>
|
||||
{:else}
|
||||
{#each worklog as e}
|
||||
<div class="worklog-entry">
|
||||
<div>{e.summary}</div>
|
||||
<div class="wl-meta">{e.minutes} {t('worklog.min')} · {formatDate(e.createdAt)}</div>
|
||||
<div class="worklog-entry" class:expanded={e._expanded} on:click={() => toggleWorklogEntry(e)} role="button" tabindex="0" on:keydown={(e2) => e2.key === 'Enter' && toggleWorklogEntry(e)}>
|
||||
<div class="worklog-entry-head">
|
||||
<span class="worklog-toggle">{e._expanded ? '▾' : '▸'}</span>
|
||||
<span class="worklog-entry-summary">{e.summary}</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.approximate}<span class="wl-tag-approx">{t('journal.approxEstimated')}</span>{/if}
|
||||
<span class="worklog-entry-date">{formatDate(e.createdAt)}</span>
|
||||
</div>
|
||||
{#if e._expanded}
|
||||
<div class="worklog-entry-detail">
|
||||
{#if e.details}
|
||||
<div class="wl-detail-section">
|
||||
<span class="wl-detail-label">{t('worklog.details')}</span>
|
||||
<p>{e.details}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="wl-detail-section">
|
||||
<span class="wl-detail-label">{t('worklog.source')}</span>
|
||||
{#if e.source === 'manual'}
|
||||
<p>{t('worklog.sourceManual')}</p>
|
||||
{:else if e.source === 'suggestion' && e._events && e._events.length > 0}
|
||||
<p>{t('worklog.sourceSuggestion')}</p>
|
||||
{:else if e.source === 'suggestion'}
|
||||
<p>{t('worklog.sourceSuggestionNoEvents')}</p>
|
||||
{:else if e.source === 'unknown' || e.source === 'imported'}
|
||||
<p>{t('worklog.sourceUnknown')}</p>
|
||||
{:else}
|
||||
<p>{t('worklog.sourceUnknown')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if e.source === 'suggestion' && e._events && e._events.length > 0}
|
||||
<div class="wl-detail-section">
|
||||
<span class="wl-detail-label">{t('journal.relatedEvents')}</span>
|
||||
{#each e._events as ev}
|
||||
<div class="journal-event-row">
|
||||
<span class="journal-event-time">{formatTime(ev.createdAt)}</span>
|
||||
<span class="journal-event-type">{t('event.' + ev.eventType) || ev.eventType}</span>
|
||||
<span class="journal-event-title">{ev.title}</span>
|
||||
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button>
|
||||
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
|
||||
<button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
|
@ -1717,7 +1848,7 @@
|
|||
{:else}
|
||||
<div class="activity-events">
|
||||
{#each caseActivity as ev}
|
||||
<div class="activity-event">
|
||||
<div class="activity-event" role="button" tabindex="0" on:click={() => openActivityEvent(ev)} on:keydown={(e) => e.key === 'Enter' && openActivityEvent(ev)}>
|
||||
<span class="activity-event-icon">{eventIcon(ev.eventType)}</span>
|
||||
<span class="activity-event-title">{ev.title}</span>
|
||||
<span class="activity-event-type">{eventLabel(ev.eventType)}</span>
|
||||
|
|
@ -1735,66 +1866,73 @@
|
|||
<div class="journal-screen">
|
||||
<div class="journal-header">
|
||||
<h2>{t('journal.title')}</h2>
|
||||
<div class="journal-filters">
|
||||
<label><span class="label-text">{t('journal.dateFrom')}</span>
|
||||
<input type="date" bind:value={journalDateFrom} />
|
||||
</label>
|
||||
<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" style="position:relative">
|
||||
{#if journalFilteredNodeTitle}
|
||||
<button class="journal-selected-node" on:click={() => { journalSearchQuery = ''; journalFilteredNodeTitle = ''; clearJournalNode() }}>
|
||||
{journalFilteredNodeTitle} <span class="journal-node-clear">✕</span>
|
||||
</button>
|
||||
{:else}
|
||||
<input type="text" placeholder={t('journal.nodeSearch')} bind:value={journalSearchQuery}
|
||||
on:input={onJournalSearchInput} on:blur={() => setTimeout(() => journalShowResults = false, 200)} />
|
||||
{#if journalShowResults}
|
||||
<div class="journal-search-dropdown">
|
||||
{#each journalSearchResults as r}
|
||||
<button class="journal-search-item" on:click={() => selectJournalNode(r)}>
|
||||
<span class="journal-search-title">{r.title}</span>
|
||||
<span class="journal-search-path">{r.path}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="journal-filter-section">
|
||||
<div class="journal-filter-heading">{t('journal.filterHeading')}</div>
|
||||
<div class="journal-filters-row">
|
||||
<label><span class="label-text">{t('journal.dateFrom')}</span>
|
||||
<input type="date" bind:value={journalDateFrom} />
|
||||
</label>
|
||||
<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" style="position:relative">
|
||||
{#if journalFilteredNodeTitle}
|
||||
<button class="journal-selected-node" on:click={() => { journalSearchQuery = ''; journalFilteredNodeTitle = ''; clearJournalNode() }}>
|
||||
{journalFilteredNodeTitle} <span class="journal-node-clear">✕</span>
|
||||
</button>
|
||||
{:else}
|
||||
<input type="text" placeholder={t('journal.nodeSearch')} bind:value={journalSearchQuery}
|
||||
on:input={onJournalSearchInput} on:blur={() => setTimeout(() => journalShowResults = false, 200)} />
|
||||
{#if journalShowResults}
|
||||
<div class="journal-search-dropdown">
|
||||
{#each journalSearchResults as r}
|
||||
<button class="journal-search-item" on:click={() => selectJournalNode(r)}>
|
||||
<span class="journal-search-title">{r.title}</span>
|
||||
<span class="journal-search-path">{r.path}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
<label class="checkbox-label" title={!journalNodeID ? t('journal.includeChildrenDisabledHint') : ''}>
|
||||
</div>
|
||||
</label>
|
||||
{#if journalNodeID}
|
||||
<input type="checkbox" bind:checked={journalIncludeChildren} />
|
||||
{:else}
|
||||
<input type="checkbox" disabled title={t('journal.includeChildrenDisabledHint')} />
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={journalIncludeChildren} class="journal-include-chk" />
|
||||
<span>{t('journal.includeChildren')}</span>
|
||||
</label>
|
||||
{/if}
|
||||
<span class:disabled-hint={!journalNodeID}>{t('journal.includeChildren')}</span>
|
||||
</label>
|
||||
<label title={t('journal.billableHint')}>
|
||||
<span class="label-text">{t('journal.billable')}</span>
|
||||
<select bind:value={journalBillableFilter}>
|
||||
<option value="all">{t('common.all')}</option>
|
||||
<option value="yes">{t('journal.billableYes')}</option>
|
||||
<option value="no">{t('journal.billableNo')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label title={t('journal.approxHint')}>
|
||||
<span class="label-text">{t('journal.approx')}</span>
|
||||
<select bind:value={journalApproxFilter}>
|
||||
<option value="all">{t('common.all')}</option>
|
||||
<option value="no">{t('journal.approxExact')}</option>
|
||||
<option value="yes">{t('journal.approxEstimated')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn btn-sm" on:click={loadJournal}>{t('journal.filter')}</button>
|
||||
<button class="btn btn-sm" on:click={() => saveJournalReport('csv')}>{t('journal.exportCSV')}</button>
|
||||
<button class="btn btn-sm" on:click={() => saveJournalReport('markdown')}>{t('journal.exportMarkdown')}</button>
|
||||
<button class="btn btn-sm" on:click={() => saveJournalReport('pdf')}>PDF</button>
|
||||
{#if journalStatusMsg}
|
||||
<span class="journal-status-msg">{journalStatusMsg}</span>
|
||||
{/if}
|
||||
<label title={t('journal.billableHint')}>
|
||||
<span class="label-text">{t('journal.billable')}</span>
|
||||
<select bind:value={journalBillableFilter}>
|
||||
<option value="all">{t('common.all')}</option>
|
||||
<option value="yes">{t('journal.billableYes')}</option>
|
||||
<option value="no">{t('journal.billableNo')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label title={t('journal.approxHint')}>
|
||||
<span class="label-text">{t('journal.approx')}</span>
|
||||
<select bind:value={journalApproxFilter}>
|
||||
<option value="all">{t('common.all')}</option>
|
||||
<option value="no">{t('journal.approxExact')}</option>
|
||||
<option value="yes">{t('journal.approxEstimated')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn btn-sm" on:click={loadJournal}>{t('journal.filter')}</button>
|
||||
<button class="btn btn-sm" on:click={resetJournalFilters}>{t('journal.reset')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="journal-export-section">
|
||||
<div class="journal-export-heading">{t('journal.exportHeading')}</div>
|
||||
<div class="journal-export-row">
|
||||
<button class="btn btn-sm" on:click={() => saveJournalReport('csv')}>{t('journal.exportCSV')}</button>
|
||||
<button class="btn btn-sm" on:click={() => saveJournalReport('markdown')}>{t('journal.exportMarkdown')}</button>
|
||||
<button class="btn btn-sm" on:click={() => saveJournalReport('pdf')}>PDF</button>
|
||||
{#if journalStatusMsg}
|
||||
<span class="journal-status-msg">{journalStatusMsg}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1825,6 +1963,9 @@
|
|||
<span class="suggestion-event-type">{t('event.' + ev.eventType) || ev.eventType}</span>
|
||||
<span class="suggestion-event-title">{ev.title}</span>
|
||||
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button>
|
||||
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
|
||||
<button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -1863,6 +2004,7 @@
|
|||
<table class="journal-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="journal-toggle-col"></th>
|
||||
<th>{t('journal.title')}</th>
|
||||
<th>{t('journal.node')}</th>
|
||||
<th>{t('journal.path')}</th>
|
||||
|
|
@ -1875,6 +2017,7 @@
|
|||
<tbody>
|
||||
{#each journalRows as r}
|
||||
<tr class="journal-row" class:expanded={r._expanded} on:click={() => toggleJournalRow(r)} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && toggleJournalRow(r)}>
|
||||
<td class="journal-toggle-col"><span class="journal-toggle-icon">{r._expanded ? '▾' : '▸'}</span></td>
|
||||
<td class="journal-summary-cell">{r.summary}</td>
|
||||
<td><button class="link-btn" on:click|stopPropagation={() => openNodeById(r.nodeId)}>{r.nodeTitle}</button></td>
|
||||
<td class="journal-path-cell">{r.nodePath}</td>
|
||||
|
|
@ -1885,7 +2028,7 @@
|
|||
</tr>
|
||||
{#if r._expanded}
|
||||
<tr class="journal-row-detail">
|
||||
<td colspan="7">
|
||||
<td colspan="8">
|
||||
<div class="journal-detail-body">
|
||||
{#if r.details}
|
||||
<div class="journal-detail-section">
|
||||
|
|
@ -1895,7 +2038,19 @@
|
|||
{/if}
|
||||
<div class="journal-detail-section">
|
||||
<span class="journal-detail-label">{t('worklog.source')}</span>
|
||||
<p>{#if r._hasEvents}{t('worklog.sourceSuggestion')}{:else}{t('worklog.sourceManual')}{/if}</p>
|
||||
{#if r.source === 'manual'}
|
||||
<p>{t('worklog.sourceManual')}</p>
|
||||
{:else if r.source === 'suggestion' && r._events && r._events.length > 0}
|
||||
<p>{t('worklog.sourceSuggestion')}</p>
|
||||
{:else if r.source === 'suggestion'}
|
||||
<p>{t('worklog.sourceSuggestionNoEvents')}</p>
|
||||
{:else if r.source === 'unknown' || r.source === 'imported'}
|
||||
<p>{t('worklog.sourceUnknown')}</p>
|
||||
{:else if r._hasEvents}
|
||||
<p>{t('worklog.sourceSuggestion')}</p>
|
||||
{:else}
|
||||
<p>{t('worklog.sourceUnknown')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if r._events}
|
||||
<div class="journal-detail-section">
|
||||
|
|
@ -1962,6 +2117,9 @@
|
|||
<span class="suggestion-event-type">{t('event.' + ev.eventType) || ev.eventType}</span>
|
||||
<span class="suggestion-event-title">{ev.title}</span>
|
||||
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button>
|
||||
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
|
||||
<button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -2002,7 +2160,7 @@
|
|||
<div class="today-timeline">
|
||||
<h3>{t('today.timeline')}</h3>
|
||||
{#each todayDashboard.events as ev}
|
||||
<div class="timeline-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
|
||||
<div class="timeline-event" role="button" tabindex="0" on:click={() => openActivityEvent(ev)} on:keydown={(e) => e.key === 'Enter' && openActivityEvent(ev)}>
|
||||
<span class="timeline-dot"></span>
|
||||
<span class="timeline-title">{ev.title}</span>
|
||||
<span class="timeline-type">{eventLabel(ev.eventType)}</span>
|
||||
|
|
@ -2029,7 +2187,7 @@
|
|||
{:else}
|
||||
<div class="activity-feed-events">
|
||||
{#each activityFeed as ev}
|
||||
<div class="activity-feed-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
|
||||
<div class="activity-feed-event" role="button" tabindex="0" on:click={() => openActivityEvent(ev)} on:keydown={(e) => e.key === 'Enter' && openActivityEvent(ev)}>
|
||||
<span class="activity-feed-icon">{eventIcon(ev.eventType)}</span>
|
||||
<div class="activity-feed-body">
|
||||
<span class="activity-feed-title">{ev.title}</span>
|
||||
|
|
@ -2133,6 +2291,49 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#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>
|
||||
<div class="form-group">
|
||||
<label><span class="label-text">{t('worklog.date')}</span>
|
||||
<input type="date" bind:value={wlModalDate} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><span class="label-text">{t('worklog.whatDone')}</span>
|
||||
<input type="text" placeholder={t('worklog.whatDone')} bind:value={wlModalSummary} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><span class="label-text">{t('worklog.minutes')}</span>
|
||||
<input type="number" bind:value={wlModalMinutes} min="1" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><span class="label-text">{t('worklog.details')}</span>
|
||||
<textarea bind:value={wlModalDetails}></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label-inline">
|
||||
<input type="checkbox" bind:checked={wlModalBillable} />
|
||||
<span>{t('journal.billableYes')}</span>
|
||||
</label>
|
||||
<label class="checkbox-label-inline">
|
||||
<input type="checkbox" bind:checked={wlModalApprox} />
|
||||
<span>{t('journal.approxEstimated')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" on:click={submitWorklogModal}
|
||||
disabled={!wlModalSummary.trim() || !wlModalMinutes}>{t('common.save')}</button>
|
||||
<button class="btn" on:click={closeWorklogModal}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showCreateAction}
|
||||
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelCreateAction} on:keydown={onKeyActivate(cancelCreateAction)}>
|
||||
<div class="modal">
|
||||
|
|
@ -2410,11 +2611,7 @@
|
|||
|
||||
/* Worklog tab */
|
||||
.worklog-tab { padding: 24px; }
|
||||
.worklog-form { display: flex; gap: 8px; margin-bottom: 24px; align-items: center; }
|
||||
.worklog-form input { padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; }
|
||||
.worklog-form input:focus { outline: none; border-color: #6366f1; }
|
||||
.worklog-form input[type="text"] { flex: 1; }
|
||||
.worklog-form input[type="number"] { width: 70px; }
|
||||
.worklog-toolbar { margin-bottom: 16px; }
|
||||
.worklog-entry { padding: 12px 0; border-bottom: 1px solid #1a1a28; }
|
||||
.suggestions-title { font-size: 13px; font-weight: 600; color: #a5b4fc; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.suggestion-summary { font-size: 14px; color: #e4e4ef; }
|
||||
|
|
@ -2446,11 +2643,6 @@
|
|||
.journal-screen { padding: 24px; overflow-y: auto; flex: 1; }
|
||||
.journal-header { margin-bottom: 24px; }
|
||||
.journal-header h2 { margin: 0 0 16px 0; }
|
||||
.journal-filters { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
|
||||
.journal-filters label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #8888a0; }
|
||||
.journal-filters input[type="date"] { padding: 6px 10px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 13px; font-family: inherit; }
|
||||
.journal-filters .checkbox-label { flex-direction: row; align-items: center; gap: 6px; cursor: pointer; }
|
||||
.journal-filters .checkbox-label input { width: auto; }
|
||||
.journal-summary { display: flex; flex-wrap: wrap; gap: 24px; margin-bottom: 24px; padding: 16px; background: #1a1a2e; border-radius: 8px; border: 1px solid #2a2a3c; }
|
||||
.summary-total { font-size: 18px; font-weight: 700; color: #e4e4ef; width: 100%; margin-bottom: 4px; }
|
||||
.summary-section { flex: 1; min-width: 200px; }
|
||||
|
|
@ -2491,7 +2683,7 @@
|
|||
.journal-selected-node:hover { background: #2a2a4a; }
|
||||
.journal-node-clear { color: #8888a0; font-size: 14px; margin-left: 4px; }
|
||||
.journal-status-msg { font-size: 12px; color: #34d399; padding: 4px 8px; background: #1a2a1e; border-radius: 4px; }
|
||||
.disabled-hint { opacity: 0.5; }
|
||||
.journal-include-chk { width: 16px; height: 16px; accent-color: #6366f1; cursor: pointer; }
|
||||
|
||||
/* Today suggestions */
|
||||
.today-suggestions { margin-bottom: 24px; }
|
||||
|
|
@ -2502,7 +2694,6 @@
|
|||
|
||||
/* Nav badge */
|
||||
.nav-badge { background: #6366f1; color: #fff; font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 6px; margin-left: 6px; line-height: 1.4; }
|
||||
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
|
||||
|
||||
/* Actions */
|
||||
.actions-tab { padding: 24px; }
|
||||
|
|
@ -2532,7 +2723,10 @@
|
|||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 12px; padding: 24px; width: 400px; max-width: 90vw; }
|
||||
.modal h3 { font-size: 18px; margin-bottom: 16px; }
|
||||
.modal textarea { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; min-height: 60px; resize: vertical; box-sizing: border-box; }
|
||||
.modal-worklog { width: 460px; }
|
||||
.form-group { margin-bottom: 12px; }
|
||||
.form-row { display: flex; gap: 16px; margin-bottom: 12px; }
|
||||
.form-group label { display: block; }
|
||||
.form-group .label-text, .form-group .form-label { display: block; font-size: 12px; color: #666; margin-bottom: 4px; }
|
||||
.form-group input, .form-group select { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; }
|
||||
|
|
@ -2646,4 +2840,39 @@
|
|||
.sync-value.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
|
||||
.sync-result { font-size: 12px; color: #6366f1; padding: 4px 0; }
|
||||
.sync-connected-actions { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
|
||||
/* Journal filter/export sections */
|
||||
.journal-filter-section { margin-bottom: 12px; padding: 12px 16px; background: #1a1a2e; border: 1px solid #2a2a3c; border-radius: 8px; }
|
||||
.journal-filter-heading { font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 8px; }
|
||||
.journal-filters-row { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: flex-end; }
|
||||
.journal-filters-row label { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: #8888a0; }
|
||||
.journal-filters-row input[type="date"] { padding: 5px 8px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 12px; font-family: inherit; }
|
||||
.journal-filters-row .checkbox-label { flex-direction: row; align-items: center; gap: 6px; cursor: pointer; }
|
||||
.journal-filters-row .checkbox-label input { width: auto; }
|
||||
.journal-export-section { padding: 8px 16px; background: #1a1a2e; border: 1px solid #2a2a3c; border-radius: 8px; }
|
||||
.journal-export-heading { font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 6px; }
|
||||
.journal-export-row { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
/* Journal toggle column */
|
||||
.journal-toggle-col { width: 32px; min-width: 32px; text-align: center; padding: 8px 4px !important; }
|
||||
.journal-toggle-icon { color: #6366f1; font-size: 12px; }
|
||||
|
||||
/* Per-node worklog expandable entries */
|
||||
.worklog-entry { padding: 8px 12px; background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 6px; cursor: pointer; }
|
||||
.worklog-entry:hover { border-color: #3a3a5c; }
|
||||
.worklog-entry.expanded { border-color: #6366f1; }
|
||||
.worklog-entry-head { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #e4e4ef; }
|
||||
.worklog-toggle { color: #6366f1; font-size: 12px; width: 16px; text-align: center; flex-shrink: 0; }
|
||||
.worklog-entry-summary { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; 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-detail { margin-top: 8px; padding-top: 8px; border-top: 1px solid #2a2a3c; display: flex; flex-direction: column; gap: 8px; }
|
||||
.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; }
|
||||
.wl-tag-billable { font-size: 10px; color: #34d399; background: #1a2a1e; padding: 1px 6px; border-radius: 8px; white-space: nowrap; }
|
||||
.wl-tag-approx { font-size: 10px; color: #fbbf24; background: #2a2a1a; padding: 1px 6px; border-radius: 8px; white-space: nowrap; }
|
||||
.checkbox-label-inline { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: #e4e4ef; cursor: pointer; }
|
||||
.checkbox-label-inline input { width: auto; }
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -128,11 +128,15 @@ export default {
|
|||
'worklog.minutes': 'Min',
|
||||
'worklog.min': 'min',
|
||||
'worklog.log': 'Log',
|
||||
'worklog.addEntry': 'Add entry',
|
||||
'worklog.date': 'Date',
|
||||
'worklog.empty': 'No work logged yet',
|
||||
'worklog.details': 'Details',
|
||||
'worklog.source': 'Source',
|
||||
'worklog.sourceSuggestion': 'Activity suggestion',
|
||||
'worklog.sourceSuggestion': 'From suggestion',
|
||||
'worklog.sourceSuggestionNoEvents': 'From suggestion, but related events missing',
|
||||
'worklog.sourceManual': 'Manual entry',
|
||||
'worklog.sourceUnknown': 'Unknown source',
|
||||
|
||||
'common.all': 'All',
|
||||
'common.no': 'No',
|
||||
|
|
@ -166,6 +170,9 @@ export default {
|
|||
'journal.includeChildren': 'Include subtasks',
|
||||
'journal.nodeSearch': 'Search case...',
|
||||
'journal.relatedEvents': 'Related events',
|
||||
'journal.filterHeading': 'Filters',
|
||||
'journal.exportHeading': 'Export Report',
|
||||
'journal.reset': 'Reset',
|
||||
|
||||
'suggest.title': 'Suggestions',
|
||||
'suggest.apply': 'Log',
|
||||
|
|
|
|||
|
|
@ -156,13 +156,15 @@ export default {
|
|||
'worklog.minutes': 'Мин',
|
||||
'worklog.min': 'мин',
|
||||
'worklog.log': 'Записать',
|
||||
'worklog.addEntry': 'Добавить запись',
|
||||
'worklog.date': 'Дата',
|
||||
'worklog.empty': 'Записей работы пока нет',
|
||||
'worklog.details': 'Детали',
|
||||
'worklog.source': 'Источник',
|
||||
'worklog.sourceSuggestion': 'Предложение activity',
|
||||
'worklog.sourceSuggestion': 'Из предложения',
|
||||
'worklog.sourceSuggestionNoEvents': 'Из предложения, но связанные события отсутствуют',
|
||||
'worklog.sourceManual': 'Ручная запись',
|
||||
'worklog.suggestions': 'Предложения',
|
||||
'worklog.apply': 'Применить',
|
||||
'worklog.sourceUnknown': 'Источник неизвестен',
|
||||
'worklog.suggestions': 'Предложения на сегодня',
|
||||
'worklog.apply': 'Применить',
|
||||
|
||||
|
|
@ -231,9 +233,12 @@ export default {
|
|||
'journal.path': 'Путь',
|
||||
'journal.byDay': 'По дням',
|
||||
'journal.byNode': 'По делам',
|
||||
'journal.includeChildren': 'С подзадачами',
|
||||
'journal.includeChildren': 'Учитывать вложенные дела',
|
||||
'journal.nodeSearch': 'Поиск дела...',
|
||||
'journal.relatedEvents': 'Связанные события',
|
||||
'journal.filterHeading': 'Фильтры',
|
||||
'journal.exportHeading': 'Экспорт отчёта',
|
||||
'journal.reset': 'Сбросить',
|
||||
|
||||
'suggest.title': 'Предложения на сегодня',
|
||||
'suggest.apply': 'Записать',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package storage
|
||||
|
||||
// migration014 — add source column to worklog_entries.
|
||||
const migration014 = `
|
||||
ALTER TABLE worklog_entries ADD COLUMN source TEXT NOT NULL DEFAULT 'unknown';
|
||||
`
|
||||
|
|
@ -70,6 +70,7 @@ var migrationFiles = map[int]string{
|
|||
11: migration011,
|
||||
12: migration012,
|
||||
13: migration013,
|
||||
14: migration014,
|
||||
}
|
||||
|
||||
func (db *DB) runInitialSchema() error {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"time"
|
||||
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
// ReportFilter specifies which worklog entries to include.
|
||||
|
|
@ -33,6 +32,7 @@ type ReportRow struct {
|
|||
Minutes int `json:"minutes"`
|
||||
Approximate bool `json:"approximate"`
|
||||
Billable bool `json:"billable"`
|
||||
Source string `json:"source"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
|
@ -145,7 +145,7 @@ func (s *Service) buildReportQuery(f ReportFilter) (string, []interface{}) {
|
|||
|
||||
q := `SELECT w.id, w.node_id, COALESCE(n.title,''), w.date, w.summary,
|
||||
COALESCE(w.details,''), COALESCE(w.minutes,0), w.approximate, w.billable,
|
||||
w.created_at, w.updated_at
|
||||
COALESCE(w.source,'unknown'), w.created_at, w.updated_at
|
||||
FROM worklog_entries w
|
||||
LEFT JOIN nodes n ON n.id = w.node_id` +
|
||||
whereClause +
|
||||
|
|
@ -170,7 +170,7 @@ func (s *Service) ListReport(f ReportFilter) ([]ReportRow, error) {
|
|||
var approxInt, billInt int
|
||||
err := rows.Scan(&r.ID, &r.NodeID, &r.NodeTitle, &r.Date,
|
||||
&r.Summary, &r.Details, &r.Minutes, &approxInt, &billInt,
|
||||
&createdStr, &updatedStr)
|
||||
&r.Source, &createdStr, &updatedStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -349,7 +349,7 @@ func (s *Service) ExportMarkdown(f ReportFilter) (string, error) {
|
|||
func (s *Service) GetByIDWithNode(id string) (*ReportRow, error) {
|
||||
q := `SELECT w.id, w.node_id, COALESCE(n.title,''), w.date, w.summary,
|
||||
COALESCE(w.details,''), COALESCE(w.minutes,0), w.approximate, w.billable,
|
||||
w.created_at, w.updated_at
|
||||
COALESCE(w.source,'unknown'), w.created_at, w.updated_at
|
||||
FROM worklog_entries w
|
||||
LEFT JOIN nodes n ON n.id = w.node_id
|
||||
WHERE w.id = ?`
|
||||
|
|
@ -358,7 +358,7 @@ func (s *Service) GetByIDWithNode(id string) (*ReportRow, error) {
|
|||
var approxInt, billInt int
|
||||
err := s.db.QueryRow(q, id).Scan(&r.ID, &r.NodeID, &r.NodeTitle, &r.Date,
|
||||
&r.Summary, &r.Details, &r.Minutes, &approxInt, &billInt,
|
||||
&createdStr, &updatedStr)
|
||||
&r.Source, &createdStr, &updatedStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -371,41 +371,7 @@ func (s *Service) GetByIDWithNode(id string) (*ReportRow, error) {
|
|||
|
||||
// AddWithDate inserts a worklog entry with a specific date.
|
||||
func (s *Service) AddWithDate(nodeID, summary, details, date string, minutes int, approximate, billable bool) (*Entry, error) {
|
||||
if nodeID == "" {
|
||||
return nil, fmt.Errorf("node_id required")
|
||||
}
|
||||
if summary == "" {
|
||||
return nil, fmt.Errorf("summary required")
|
||||
}
|
||||
if date == "" {
|
||||
date = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
e := &Entry{
|
||||
ID: util.UUID7(),
|
||||
NodeID: nodeID,
|
||||
Summary: summary,
|
||||
Details: details,
|
||||
Date: date,
|
||||
Minutes: &minutes,
|
||||
Approximate: approximate,
|
||||
Billable: billable,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO worklog_entries (id,node_id,date,minutes,approximate,billable,
|
||||
summary,details,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
e.ID, e.NodeID, e.Date, e.Minutes, boolInt(e.Approximate),
|
||||
boolInt(e.Billable), e.Summary, e.Details,
|
||||
e.CreatedAt.Format(time.RFC3339), e.UpdatedAt.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
return s.AddWithSource(nodeID, summary, details, date, minutes, approximate, billable, SourceManual)
|
||||
}
|
||||
|
||||
// UpdateDate updates the date of an entry.
|
||||
|
|
|
|||
|
|
@ -22,10 +22,18 @@ type Entry struct {
|
|||
Billable bool `json:"billable"`
|
||||
Summary string `json:"summary"`
|
||||
Details string `json:"details,omitempty"`
|
||||
Source string `json:"source"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
const (
|
||||
SourceManual = "manual"
|
||||
SourceSuggestion = "suggestion"
|
||||
SourceImported = "imported"
|
||||
SourceUnknown = "unknown"
|
||||
)
|
||||
|
||||
// Service manages worklog entries.
|
||||
type Service struct {
|
||||
db *storage.DB
|
||||
|
|
@ -36,34 +44,44 @@ func NewService(db *storage.DB) *Service {
|
|||
return &Service{db: db}
|
||||
}
|
||||
|
||||
// Add inserts a new worklog entry.
|
||||
// Add inserts a new worklog entry with manual source.
|
||||
func (s *Service) Add(nodeID, summary, details string, minutes int, approximate, billable bool) (*Entry, error) {
|
||||
date := time.Now().Format("2006-01-02")
|
||||
return s.AddWithSource(nodeID, summary, details, date, minutes, approximate, billable, SourceManual)
|
||||
}
|
||||
|
||||
// Add inserts a new worklog entry.
|
||||
func (s *Service) AddWithSource(nodeID, summary, details, date string, minutes int, approximate, billable bool, source string) (*Entry, error) {
|
||||
if nodeID == "" {
|
||||
return nil, fmt.Errorf("node_id required")
|
||||
}
|
||||
if summary == "" {
|
||||
return nil, fmt.Errorf("summary required")
|
||||
}
|
||||
if date == "" {
|
||||
date = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
e := &Entry{
|
||||
ID: util.UUID7(),
|
||||
NodeID: nodeID,
|
||||
Summary: summary,
|
||||
Details: details,
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
Date: date,
|
||||
Minutes: &minutes,
|
||||
Approximate: approximate,
|
||||
Billable: billable,
|
||||
Source: source,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO worklog_entries (id,node_id,date,minutes,approximate,billable,
|
||||
summary,details,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
summary,details,source,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
e.ID, e.NodeID, e.Date, e.Minutes, boolInt(e.Approximate),
|
||||
boolInt(e.Billable), e.Summary, e.Details,
|
||||
boolInt(e.Billable), e.Summary, e.Details, e.Source,
|
||||
e.CreatedAt.Format(time.RFC3339), e.UpdatedAt.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -94,7 +112,7 @@ func (s *Service) Update(id, summary, details string, minutes int, approximate,
|
|||
func (s *Service) Get(id string) (*Entry, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id,node_id,started_at,ended_at,date,minutes,approximate,
|
||||
billable,summary,details,created_at,updated_at
|
||||
billable,summary,details,COALESCE(source,'unknown'),created_at,updated_at
|
||||
FROM worklog_entries WHERE id=?`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -110,7 +128,7 @@ func (s *Service) Get(id string) (*Entry, error) {
|
|||
func (s *Service) ListByNode(nodeID string) ([]Entry, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id,node_id,started_at,ended_at,date,minutes,approximate,
|
||||
billable,summary,details,created_at,updated_at
|
||||
billable,summary,details,COALESCE(source,'unknown'),created_at,updated_at
|
||||
FROM worklog_entries WHERE node_id=? ORDER BY date DESC, created_at DESC`, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -210,14 +228,14 @@ type rowScanner interface {
|
|||
|
||||
func scanEntry(s rowScanner) (*Entry, error) {
|
||||
var e Entry
|
||||
var startedAt, endedAt, details sql.NullString
|
||||
var startedAt, endedAt, details, source sql.NullString
|
||||
var minutes sql.NullInt64
|
||||
var createdStr, updatedStr string
|
||||
var approxInt, billInt int
|
||||
|
||||
err := s.Scan(
|
||||
&e.ID, &e.NodeID, &startedAt, &endedAt, &e.Date, &minutes,
|
||||
&approxInt, &billInt, &e.Summary, &details, &createdStr, &updatedStr,
|
||||
&approxInt, &billInt, &e.Summary, &details, &source, &createdStr, &updatedStr,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -239,6 +257,10 @@ func scanEntry(s rowScanner) (*Entry, error) {
|
|||
if details.Valid {
|
||||
e.Details = details.String
|
||||
}
|
||||
e.Source = SourceUnknown
|
||||
if source.Valid {
|
||||
e.Source = source.String
|
||||
}
|
||||
e.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||
e.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
||||
return &e, nil
|
||||
|
|
|
|||
Loading…
Reference in New Issue