fix: trash duplicate path, journal tabs, today undefined, mouse back, inbox search

- Trash: removed duplicate fsPath display, folders/files open by clicking icon/name
- Trash: removed separate 'Open' button, only restore/delete right-aligned
- Trash: added ReadTrashFileContent binding + preview for files
- Trash: breadcrumb path under title, compact date display
- Journal: split into 'Предложения' + 'Журнал работы' tabs
- Journal: suggestions tab opens by default
- Today: fixed undefined in feed (null-guard on eventType/title)
- Today: improved event type label readability (blue badge style)
- Today: folder_deleted clicks navigate to trash
- Mouse Back: added mouseup fallback for Wails compat, handle button 3/4
- SearchNodes: case-insensitive with LOWER()
- Inbox assign: added search hint placeholder
This commit is contained in:
mirivlad 2026-06-05 16:49:00 +08:00
parent c512ada386
commit 5257789a4d
11 changed files with 314 additions and 251 deletions

View File

@ -298,6 +298,21 @@ func listTrashEntries(trashPath string) ([]TrashEntryDTO, error) {
return out, nil return out, nil
} }
func (a *App) ReadTrashFileContent(nodeID string) (string, error) {
if err := a.requireVault(); err != nil {
return "", err
}
path, err := a.findTrashEntryForNode(nodeID)
if err != nil {
return "", err
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
func (a *App) OpenTrashFolder() error { func (a *App) OpenTrashFolder() error {
if err := a.requireVault(); err != nil { if err := a.requireVault(); err != nil {
return err return err

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

@ -19,8 +19,8 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-CtkslTth.js"></script> <script type="module" crossorigin src="/assets/main-PQ2CZjSe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main--SNK_nBk.css"> <link rel="stylesheet" crossorigin href="/assets/main-Bomne4X7.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -55,6 +55,7 @@
let journalApproxFilter = 'all' let journalApproxFilter = 'all'
let journalFilteredNodeTitle = '' let journalFilteredNodeTitle = ''
let journalStatusMsg = '' let journalStatusMsg = ''
let journalActiveTab = 'suggestions'
let journalSearchQuery = '' let journalSearchQuery = ''
let journalSearchResults = [] let journalSearchResults = []
let journalShowResults = false let journalShowResults = false
@ -322,11 +323,13 @@
await restoreNavigation(snapshot) await restoreNavigation(snapshot)
} }
function handleAuxClick(e) { function handleMouseNav(e) {
if (e.button !== 3) return if (e.button !== 3 && e.button !== 4) return
if (isEditableTarget(e.target)) return if (isEditableTarget(e.target)) return
if (closeTopModalForBack() && e.button === 3) return
e.preventDefault() e.preventDefault()
goBack() e.stopPropagation()
if (e.button === 3) goBack()
} }
function setActiveTab(tabId) { function setActiveTab(tabId) {
@ -384,7 +387,8 @@
window.addEventListener('dragover', handleGlobalDragOver) window.addEventListener('dragover', handleGlobalDragOver)
window.addEventListener('dragleave', handleGlobalDragLeave) window.addEventListener('dragleave', handleGlobalDragLeave)
window.addEventListener('drop', handleGlobalDrop) window.addEventListener('drop', handleGlobalDrop)
window.addEventListener('auxclick', handleAuxClick) window.addEventListener('auxclick', handleMouseNav)
window.addEventListener('mouseup', handleMouseNav)
loading = false loading = false
loadSyncStatus() loadSyncStatus()
@ -398,7 +402,8 @@
window.removeEventListener('dragover', handleGlobalDragOver) window.removeEventListener('dragover', handleGlobalDragOver)
window.removeEventListener('dragleave', handleGlobalDragLeave) window.removeEventListener('dragleave', handleGlobalDragLeave)
window.removeEventListener('drop', handleGlobalDrop) window.removeEventListener('drop', handleGlobalDrop)
window.removeEventListener('auxclick', handleAuxClick) window.removeEventListener('auxclick', handleMouseNav)
window.removeEventListener('mouseup', handleMouseNav)
}) })
// ===== System view / Node selection ===== // ===== System view / Node selection =====
@ -1386,6 +1391,21 @@
trashSelectedIds = [] trashSelectedIds = []
} }
async function openTrashFilePreview(node) {
previewItem = { name: node.title, type: 'file', mime: 'text/plain', size: 0, fileId: node.id }
previewContent = ''
previewError = ''
previewLoading = true
try {
previewContent = await wailsCall('ReadTrashFileContent', node.id) || ''
const ext = (node.title || '').split('.').pop().toLowerCase()
if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext)) {
previewContent = 'data:image/' + (ext === 'svg' ? 'svg+xml' : ext) + ';base64,' + btoa(previewContent)
}
} catch (e) { previewError = String(e) }
previewLoading = false
}
function toggleTrashSelection(id) { function toggleTrashSelection(id) {
trashSelectedIds = trashSelectedIds.includes(id) trashSelectedIds = trashSelectedIds.includes(id)
? trashSelectedIds.filter(existing => existing !== id) ? trashSelectedIds.filter(existing => existing !== id)
@ -3031,23 +3051,22 @@
{#each visibleTrashNodes as node} {#each visibleTrashNodes as node}
<div class="trash-row" class:selected={trashSelectedIds.includes(node.id)} class:folder={node.type !== 'file' && node.type !== 'note'}> <div class="trash-row" class:selected={trashSelectedIds.includes(node.id)} class:folder={node.type !== 'file' && node.type !== 'note'}>
<input type="checkbox" checked={trashSelectedIds.includes(node.id)} on:change={() => toggleTrashSelection(node.id)} /> <input type="checkbox" checked={trashSelectedIds.includes(node.id)} on:change={() => toggleTrashSelection(node.id)} />
<span class="trash-row-icon" aria-hidden="true"> <span class="trash-row-icon"
role="button" tabindex="0"
title={node.type !== 'file' ? t('file.openFolder') : t('common.open')}
on:click|stopPropagation={() => node.type !== 'file' ? openTrashFolderNode(node) : openTrashFilePreview(node)}
on:keydown={(e) => e.key === 'Enter' && (node.type !== 'file' ? openTrashFolderNode(node) : openTrashFilePreview(node))}>
{@html actionIcon(node.type !== 'file' && node.type !== 'note' ? 'folder' : 'open')} {@html actionIcon(node.type !== 'file' && node.type !== 'note' ? 'folder' : 'open')}
</span> </span>
<div class="trash-row-main"> <button class="trash-row-main"
title={node.type !== 'file' ? t('file.openFolder') : t('common.open')}
on:click|stopPropagation={() => node.type !== 'file' ? openTrashFolderNode(node) : openTrashFilePreview(node)}
on:keydown={(e) => e.key === 'Enter' && (node.type !== 'file' ? openTrashFolderNode(node) : openTrashFilePreview(node))}>
<span class="trash-row-title">{node.title}</span> <span class="trash-row-title">{node.title}</span>
<span class="trash-row-meta">{node.nodePath || nodeKindLabel(node.type)}</span> <span class="trash-row-meta">{node.nodePath}</span>
<span class="trash-row-meta">{formatDate(node.deletedAt)}</span> <span class="trash-row-date">{formatDate(node.deletedAt)}</span>
</div> </button>
{#if node.fsPath}<span class="trash-row-path">{node.fsPath}</span>{/if}
<div class="trash-row-actions"> <div class="trash-row-actions">
{#if node.type !== 'file' && node.type !== 'note'}
<button class="inbox-icon-btn" title={t('common.open')} aria-label={t('common.open')} on:click={() => openTrashFolderNode(node)}>
{@html actionIcon('open')}
</button>
{:else}
<span class="inbox-icon-slot" aria-hidden="true"></span>
{/if}
<button class="inbox-icon-btn inbox-icon-btn-primary" title={t('trash.restore')} aria-label={t('trash.restore')} on:click={() => restoreTrash(trashSelectionOr(node.id))}> <button class="inbox-icon-btn inbox-icon-btn-primary" title={t('trash.restore')} aria-label={t('trash.restore')} on:click={() => restoreTrash(trashSelectionOr(node.id))}>
{@html actionIcon('restore')} {@html actionIcon('restore')}
</button> </button>
@ -3065,223 +3084,235 @@
{:else if selectedSection === 'journal'} {:else if selectedSection === 'journal'}
<div class="journal-screen"> <div class="journal-screen">
<div class="journal-header"> <div class="journal-tabs">
<h2>{t('journal.title')}</h2> <button class="journal-tab" class:active={journalActiveTab === 'suggestions'} on:click={() => journalActiveTab = 'suggestions'}>
<div class="journal-filter-section"> {t('suggest.title')}
<div class="journal-filter-heading">{t('journal.filterHeading')}</div> {#if suggestionCount > 0}<span class="tab-badge">{suggestionCount}</span>{/if}
<div class="journal-filters-row"> </button>
<label><span class="label-text">{t('journal.dateFrom')}</span> <button class="journal-tab" class:active={journalActiveTab === 'worklog'} on:click={() => journalActiveTab = 'worklog'}>
<input type="date" bind:value={journalDateFrom} /> {t('journal.worklogTab')}
</label> </button>
<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}
</div>
</label>
{#if journalNodeID}
<label class="checkbox-label">
<input type="checkbox" bind:checked={journalIncludeChildren} class="journal-include-chk" />
<span>{t('journal.includeChildren')}</span>
</label>
{/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> </div>
{#if suggestions.length > 0} {#if journalActiveTab === 'suggestions'}
<div class="journal-suggestions"> {#if suggestions.length === 0}
<div class="suggestions-title">{t('suggest.title')}</div> <div class="empty-state"><p>{t('suggest.noSuggestions')}</p></div>
{#each suggestions as s} {:else}
<div class="suggestion-card" class:expanded={s._expanded}> <div class="journal-suggestions">
<div class="suggestion-main" on:click={() => s._expanded = !s._expanded} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (s._expanded = !s._expanded)}> {#each suggestions as s}
<div class="suggestion-info"> <div class="suggestion-card" class:expanded={s._expanded}>
<button class="suggestion-node link-btn" on:click|stopPropagation={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button> <div class="suggestion-main" on:click={() => s._expanded = !s._expanded} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (s._expanded = !s._expanded)}>
<span class="suggestion-summary">{s.summary}</span> <div class="suggestion-info">
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')}{#if s.events && s.events.length > 0} · {s.events.length} {t('suggest.detectedEvents')}{/if}</span> <button class="suggestion-node link-btn" on:click|stopPropagation={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button>
<span class="suggestion-summary">{s.summary}</span>
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')}{#if s.events && s.events.length > 0} · {s.events.length} {t('suggest.detectedEvents')}{/if}</span>
</div>
<div class="suggestion-actions">
<span class="suggestion-confidence-dot" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'} title={t('suggest.confidence.' + s.confidence)} aria-label={t('suggest.confidence.' + s.confidence)}></span>
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
<button class="btn btn-sm" on:click|stopPropagation={() => openSuggestionWorklogModal(s)}>{t('suggest.edit')}</button>
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptJournalSuggestion(s)}>{t('suggest.apply')}</button>
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>{t('common.delete')}</button>
</div>
</div> </div>
<div class="suggestion-actions"> {#if s._expanded && s.events && s.events.length > 0}
<span class="suggestion-confidence-dot" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'} title={t('suggest.confidence.' + s.confidence)} aria-label={t('suggest.confidence.' + s.confidence)}></span> <div class="suggestion-detail">
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480" <div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} /> {#each s.events as ev}
<span class="suggestion-min-label">{t('suggest.minutes')}</span> <div class="suggestion-detail-event">
<button class="btn btn-sm" on:click|stopPropagation={() => openSuggestionWorklogModal(s)}>{t('suggest.edit')}</button> <span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptJournalSuggestion(s)}>{t('suggest.apply')}</button> {#if ev.nodePath}<span class="suggestion-event-path">{ev.nodePath}</span>{/if}
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>{t('common.delete')}</button> <span class="suggestion-event-type">{eventLabel(ev.eventType)}</span>
</div> <span class="suggestion-event-title">{ev.title}</span>
</div> <button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
{#if s._expanded && s.events && s.events.length > 0} {#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
<div class="suggestion-detail"> <button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
{#each s.events as ev}
<div class="suggestion-detail-event">
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
{#if ev.nodePath}<span class="suggestion-event-path">{ev.nodePath}</span>{/if}
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span>
<span class="suggestion-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{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>
{/each}
</div>
{/if}
{#if journalSummary}
<div class="journal-summary">
<div class="summary-total">{t('journal.total')}: {Math.floor(journalSummary.totalMinutes / 60)}ч {journalSummary.totalMinutes % 60}м ({journalSummary.totalEntries} {t('worklog.min')})</div>
{#if journalSummary.byDay && journalSummary.byDay.length > 0}
<div class="summary-section">
<div class="summary-label">{t('journal.byDay')}</div>
{#each journalSummary.byDay as g}
<div class="summary-row"><span>{g.label}</span><span>{Math.floor(g.minutes / 60)}ч {g.minutes % 60}м</span><span class="summary-count">{g.count}</span></div>
{/each}
</div>
{/if}
{#if journalSummary.byNode && journalSummary.byNode.length > 0}
<div class="summary-section">
<div class="summary-label">{t('journal.byNode')}</div>
{#each journalSummary.byNode as g}
<div class="summary-row"><span>{g.label}</span><span>{Math.floor(g.minutes / 60)}ч {g.minutes % 60}м</span><span class="summary-count">{g.count}</span></div>
{/each}
</div>
{/if}
</div>
{/if}
{#if journalRows.length === 0}
<div class="empty-state"><p>{t('journal.empty')}</p></div>
{:else}
<div class="journal-table-wrap">
<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>
<th>{t('worklog.minutes')}</th>
<th>{t('journal.billable')}</th>
<th>{t('journal.approx')}</th>
<th>{t('common.date')}</th>
</tr>
</thead>
<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>
<td class="journal-min-cell">{r.minutes}</td>
<td class="journal-bool-cell">{#if r.billable}{/if}</td>
<td class="journal-bool-cell">{#if r.approximate}~{/if}</td>
<td class="journal-date-cell">{r.date}</td>
</tr>
{#if r._expanded}
<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>
<p>{r.details}</p>
</div>
{/if}
<div class="journal-detail-section">
<span class="journal-detail-label">{t('worklog.source')}</span>
{#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">
<span class="journal-detail-label">{t('journal.relatedEvents')}</span>
{#each r._events as ev}
<div class="journal-event-row">
<span class="journal-event-time">{formatTime(ev.createdAt)}</span>
<span class="journal-event-type">{eventLabel(ev.eventType)}</span>
<span class="journal-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
</div>
{/each}
</div>
{/if} {/if}
</div> </div>
</td> {/each}
</tr> </div>
{/if} {/if}
{/each} </div>
</tbody> {/each}
</table> </div>
{/if}
{:else}
<div class="journal-header">
<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}
</div>
</label>
{#if journalNodeID}
<label class="checkbox-label">
<input type="checkbox" bind:checked={journalIncludeChildren} class="journal-include-chk" />
<span>{t('journal.includeChildren')}</span>
</label>
{/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> </div>
{#if journalSummary}
<div class="journal-summary">
<div class="summary-total">{t('journal.total')}: {Math.floor(journalSummary.totalMinutes / 60)}ч {journalSummary.totalMinutes % 60}м ({journalSummary.totalEntries} {t('worklog.min')})</div>
{#if journalSummary.byDay && journalSummary.byDay.length > 0}
<div class="summary-section">
<div class="summary-label">{t('journal.byDay')}</div>
{#each journalSummary.byDay as g}
<div class="summary-row"><span>{g.label}</span><span>{Math.floor(g.minutes / 60)}ч {g.minutes % 60}м</span><span class="summary-count">{g.count}</span></div>
{/each}
</div>
{/if}
{#if journalSummary.byNode && journalSummary.byNode.length > 0}
<div class="summary-section">
<div class="summary-label">{t('journal.byNode')}</div>
{#each journalSummary.byNode as g}
<div class="summary-row"><span>{g.label}</span><span>{Math.floor(g.minutes / 60)}ч {g.minutes % 60}м</span><span class="summary-count">{g.count}</span></div>
{/each}
</div>
{/if}
</div>
{/if}
{#if journalRows.length === 0}
<div class="empty-state"><p>{t('journal.empty')}</p></div>
{:else}
<div class="journal-table-wrap">
<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>
<th>{t('worklog.minutes')}</th>
<th>{t('journal.billable')}</th>
<th>{t('journal.approx')}</th>
<th>{t('common.date')}</th>
</tr>
</thead>
<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>
<td class="journal-min-cell">{r.minutes}</td>
<td class="journal-bool-cell">{#if r.billable}{/if}</td>
<td class="journal-bool-cell">{#if r.approximate}~{/if}</td>
<td class="journal-date-cell">{r.date}</td>
</tr>
{#if r._expanded}
<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>
<p>{r.details}</p>
</div>
{/if}
<div class="journal-detail-section">
<span class="journal-detail-label">{t('worklog.source')}</span>
{#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">
<span class="journal-detail-label">{t('journal.relatedEvents')}</span>
{#each r._events as ev}
<div class="journal-event-row">
<span class="journal-event-time">{formatTime(ev.createdAt)}</span>
<span class="journal-event-type">{eventLabel(ev.eventType)}</span>
<span class="journal-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
</div>
{/each}
</div>
{/if}
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
{/if} {/if}
</div> </div>
@ -3305,6 +3336,7 @@
onDeleteSuggestion={(s) => deleteSuggestion(s)} onDeleteSuggestion={(s) => deleteSuggestion(s)}
onOpenNodeFolder={(id) => openNodeFolder(id)} onOpenNodeFolder={(id) => openNodeFolder(id)}
onOpenInboxArtifact={(item) => openInboxArtifact(item)} onOpenInboxArtifact={(item) => openInboxArtifact(item)}
onOpenTrashNode={(nodeId) => { selectSystemView('trash'); openTrashFolderNode({ id: nodeId, title: '' }); refreshTrash() }}
/> />
{:else} {:else}
<div class="today-empty"> <div class="today-empty">
@ -3559,6 +3591,7 @@
<input type="text" placeholder={t('inbox.assignSearchPlaceholder')} bind:value={inboxAssignQuery} <input type="text" placeholder={t('inbox.assignSearchPlaceholder')} bind:value={inboxAssignQuery}
on:input={onInboxAssignSearchInput} on:input={onInboxAssignSearchInput}
on:keydown={(e) => e.key === 'Enter' && inboxAssignTarget && submitAssignInbox()} /> on:keydown={(e) => e.key === 'Enter' && inboxAssignTarget && submitAssignInbox()} />
<div class="assign-hint">{t('inbox.assignSearchHint')}</div>
</label> </label>
{#if inboxAssignResults.length > 0} {#if inboxAssignResults.length > 0}
<div class="assign-search-results"> <div class="assign-search-results">
@ -3837,21 +3870,26 @@
.trash-section-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; min-height: 36px; } .trash-section-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; min-height: 36px; }
.trash-section-head h3 { margin: 0 0 2px; } .trash-section-head h3 { margin: 0 0 2px; }
.trash-section-head p { margin: 0; color: #8888a0; font-size: 12px; } .trash-section-head p { margin: 0; color: #8888a0; font-size: 12px; }
.trash-row { display: grid; grid-template-columns: auto auto minmax(0, 1fr) minmax(120px, 220px) auto; align-items: center; gap: 10px; padding: 9px 10px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; margin-bottom: 8px; } .trash-row { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 9px 10px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; margin-bottom: 8px; cursor: default; }
.trash-row.folder { background: #1b2132; border-color: #303856; } .trash-row.folder { background: #1b2132; border-color: #303856; }
.trash-row.selected { border-color: #6366f1; background: #20203a; } .trash-row.selected { border-color: #6366f1; background: #20203a; }
.trash-row-icon { color: #a5b4fc; display: inline-flex; align-items: center; justify-content: center; } .trash-row-icon { color: #a5b4fc; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 4px; border-radius: 4px; }
.trash-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .trash-row-icon:hover { background: #222238; color: #e4e4ef; }
.trash-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; cursor: pointer; padding: 2px 0; }
.trash-row-main:hover .trash-row-title { color: #a5b4fc; }
.trash-row-title { min-width: 0; color: #e4e4ef; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .trash-row-title { min-width: 0; color: #e4e4ef; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-meta { color: #8888a0; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .trash-row-meta { color: #8ea0d8; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trash-row-path { color: #6f7390; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .trash-row-date { color: #666; font-size: 11px; }
.trash-row-actions { display: flex; align-items: center; gap: 6px; } .trash-row-actions { display: flex; align-items: center; gap: 6px; justify-content: flex-end; }
.trash-empty-line { color: #8888a0; font-size: 13px; margin: 0; } .trash-empty-line { color: #8888a0; font-size: 13px; margin: 0; }
/* Journal screen */ /* Journal screen */
.journal-screen { padding: 24px; overflow-y: auto; flex: 1; } .journal-screen { padding: 24px; overflow-y: auto; flex: 1; }
.journal-tabs { display: flex; gap: 0; border-bottom: 1px solid #2a2a3c; margin-bottom: 16px; }
.journal-tab { padding: 10px 16px; border: none; background: none; color: #888; font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; font-family: inherit; position: relative; }
.journal-tab:hover { color: #a5b4fc; }
.journal-tab.active { color: #e4e4ef; border-bottom-color: #818cf8; background: rgba(99,102,241,0.12); font-weight: 600; }
.journal-header { margin-bottom: 24px; } .journal-header { margin-bottom: 24px; }
.journal-header h2 { margin: 0 0 16px 0; }
.journal-summary { display: flex; flex-wrap: wrap; gap: 24px; margin-bottom: 24px; padding: 16px; background: #1a1a2e; border-radius: 8px; border: 1px solid #2a2a3c; } .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-total { font-size: 18px; font-weight: 700; color: #e4e4ef; width: 100%; margin-bottom: 4px; }
.summary-section { flex: 1; min-width: 200px; } .summary-section { flex: 1; min-width: 200px; }
@ -3945,6 +3983,7 @@
.assign-search-result { width: 100%; display: flex; justify-content: space-between; gap: 12px; padding: 8px 10px; border: 0; border-bottom: 1px solid #24243a; background: transparent; color: #e4e4ef; text-align: left; cursor: pointer; font-family: inherit; } .assign-search-result { width: 100%; display: flex; justify-content: space-between; gap: 12px; padding: 8px 10px; border: 0; border-bottom: 1px solid #24243a; background: transparent; color: #e4e4ef; text-align: left; cursor: pointer; font-family: inherit; }
.assign-search-result:hover { background: #222238; } .assign-search-result:hover { background: #222238; }
.assign-search-result span:last-child { color: #8888a0; font-size: 12px; flex-shrink: 0; } .assign-search-result span:last-child { color: #8888a0; font-size: 12px; flex-shrink: 0; }
.assign-hint { color: #8888a0; font-size: 11px; margin-top: 4px; }
.assign-status { color: #8888a0; font-size: 12px; } .assign-status { color: #8888a0; font-size: 12px; }
/* Buttons */ /* Buttons */

View File

@ -20,6 +20,7 @@
export let onDeleteSuggestion = (s) => {} export let onDeleteSuggestion = (s) => {}
export let onOpenNodeFolder = (id) => {} export let onOpenNodeFolder = (id) => {}
export let onOpenInboxArtifact = (item) => {} export let onOpenInboxArtifact = (item) => {}
export let onOpenTrashNode = (nodeId) => {}
let activeTab = 'feed' let activeTab = 'feed'
@ -47,6 +48,7 @@
} }
function feedItemLabel(ev) { function feedItemLabel(ev) {
if (!ev || !ev.eventType) return ''
return eventLabel(ev.eventType) return eventLabel(ev.eventType)
} }
@ -64,14 +66,15 @@
} }
function handleFeedTitleClick(ev) { function handleFeedTitleClick(ev) {
if (!ev || !ev.eventType) return
if (ev.eventType === 'folder_deleted') { if (ev.eventType === 'folder_deleted') {
onOpenNodeById(ev.nodeId) if (ev.targetId) onOpenTrashNode(ev.targetId)
return return
} }
if (['file_added','file_deleted','file_renamed','file_copied','file_moved','folder_added','folder_renamed','folder_moved'].includes(ev.eventType)) { if (['file_added','file_deleted','file_renamed','file_copied','file_moved','folder_added','folder_renamed','folder_moved'].includes(ev.eventType)) {
if (ev.targetId) { if (ev.targetId) {
onOpenActivityTarget(ev) onOpenActivityTarget(ev)
} else { } else if (ev.nodeId) {
onOpenNodeById(ev.nodeId) onOpenNodeById(ev.nodeId)
} }
return return
@ -79,20 +82,22 @@
if (['note_created','note_updated','note_deleted'].includes(ev.eventType)) { if (['note_created','note_updated','note_deleted'].includes(ev.eventType)) {
if (ev.targetType === 'note' && ev.targetId) { if (ev.targetType === 'note' && ev.targetId) {
onOpenActivityTarget(ev) onOpenActivityTarget(ev)
} else { } else if (ev.nodeId) {
onOpenNodeById(ev.nodeId) onOpenNodeById(ev.nodeId)
} }
return return
} }
if (ev.eventType === 'worklog_added') { if (ev.eventType === 'worklog_added') {
onOpenNodeById(ev.nodeId) if (ev.nodeId) onOpenNodeById(ev.nodeId)
return return
} }
if (['action_created','action_done'].includes(ev.eventType)) { if (['action_created','action_done'].includes(ev.eventType)) {
onOpenNodeById(ev.nodeId) if (ev.nodeId) onOpenNodeById(ev.nodeId)
return return
} }
onOpenActivityEvent(ev) if (ev.id || ev.nodeId) {
onOpenActivityEvent(ev)
}
} }
function feedItemSubtitle(ev) { function feedItemSubtitle(ev) {
@ -186,9 +191,9 @@
<span class="feed-icon">{feedItemIcon(ev.eventType)}</span> <span class="feed-icon">{feedItemIcon(ev.eventType)}</span>
<div class="feed-body"> <div class="feed-body">
<div class="feed-title-line"> <div class="feed-title-line">
<span class="feed-type">{feedItemLabel(ev.eventType)}</span> <span class="feed-type">{feedItemLabel(ev)}</span>
<span class="feed-colon">:</span> <span class="feed-colon">:</span>
<span class="feed-title link-btn">{ev.title}</span> <span class="feed-title link-btn">{ev.title || ''}</span>
</div> </div>
<div class="feed-meta-line"> <div class="feed-meta-line">
{#if feedItemSubtitle(ev)} {#if feedItemSubtitle(ev)}
@ -365,7 +370,7 @@
.feed-title:hover { text-decoration: underline; } .feed-title:hover { text-decoration: underline; }
.feed-meta-line { display: flex; align-items: center; gap: 8px; margin-top: 2px; } .feed-meta-line { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
.feed-path { font-size: 11px; color: #8ea0d8; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .feed-path { font-size: 11px; color: #8ea0d8; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.feed-event-type { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; } .feed-event-type { font-size: 10px; color: #a5b4fc; background: rgba(99,102,241,0.1); padding: 2px 8px; border-radius: 8px; font-weight: 500; }
.feed-time { font-size: 11px; color: #555; margin-left: auto; flex-shrink: 0; } .feed-time { font-size: 11px; color: #555; margin-left: auto; flex-shrink: 0; }
.feed-nav-btn { background: none; border: none; color: #555; cursor: pointer; padding: 4px; border-radius: 4px; flex-shrink: 0; margin-top: 2px; } .feed-nav-btn { background: none; border: none; color: #555; cursor: pointer; padding: 4px; border-radius: 4px; flex-shrink: 0; margin-top: 2px; }
.feed-nav-btn:hover { color: #a5b4fc; background: #1e1e30; } .feed-nav-btn:hover { color: #a5b4fc; background: #1e1e30; }

View File

@ -45,6 +45,7 @@ export default {
'inbox.assignTitle': 'Assign material', 'inbox.assignTitle': 'Assign material',
'inbox.assignTarget': 'Case', 'inbox.assignTarget': 'Case',
'inbox.assignSearchPlaceholder': 'Find case', 'inbox.assignSearchPlaceholder': 'Find case',
'inbox.assignSearchHint': 'Start typing a Case name',
'inbox.deleteTitle': 'Delete material', 'inbox.deleteTitle': 'Delete material',
'inbox.deleteConfirm': 'Delete "{title}" from inbox?', 'inbox.deleteConfirm': 'Delete "{title}" from inbox?',
'capture.kind.text': 'Text', 'capture.kind.text': 'Text',
@ -278,6 +279,7 @@ export default {
'today.sortAsc': 'ascending', 'today.sortAsc': 'ascending',
'today.sortDesc': 'descending', 'today.sortDesc': 'descending',
'journal.title': 'Work Log', 'journal.title': 'Work Log',
'journal.worklogTab': 'Work Log',
'journal.empty': 'No entries for the selected period', 'journal.empty': 'No entries for the selected period',
'journal.dateFrom': 'From', 'journal.dateFrom': 'From',
'journal.dateTo': 'To', 'journal.dateTo': 'To',

View File

@ -46,6 +46,7 @@ export default {
'inbox.assignTitle': 'Разложить материал', 'inbox.assignTitle': 'Разложить материал',
'inbox.assignTarget': 'Дело', 'inbox.assignTarget': 'Дело',
'inbox.assignSearchPlaceholder': 'Найти дело', 'inbox.assignSearchPlaceholder': 'Найти дело',
'inbox.assignSearchHint': 'Начните набирать название Дела',
'inbox.deleteTitle': 'Удалить материал', 'inbox.deleteTitle': 'Удалить материал',
'inbox.deleteConfirm': 'Удалить «{title}» из неразобранного?', 'inbox.deleteConfirm': 'Удалить «{title}» из неразобранного?',
@ -294,6 +295,7 @@ export default {
'today.sortDesc': 'по убыванию', 'today.sortDesc': 'по убыванию',
'journal.title': 'Журнал работы', 'journal.title': 'Журнал работы',
'journal.worklogTab': 'Журнал работы',
'journal.empty': 'Нет записей за выбранный период', 'journal.empty': 'Нет записей за выбранный период',
'journal.dateFrom': 'От', 'journal.dateFrom': 'От',
'journal.dateTo': 'До', 'journal.dateTo': 'До',

View File

@ -221,7 +221,7 @@ func (r *Repository) CountChildren(parentID string, types ...string) (int, error
// Search finds active nodes whose title contains the query (case-insensitive). // Search finds active nodes whose title contains the query (case-insensitive).
func (r *Repository) Search(query string, limit int) ([]Node, error) { func (r *Repository) Search(query string, limit int) ([]Node, error) {
q := `SELECT ` + nodeColumns + ` FROM nodes q := `SELECT ` + nodeColumns + ` FROM nodes
WHERE deleted_at IS NULL AND title LIKE ? ORDER BY sort_order, title LIMIT ?` WHERE deleted_at IS NULL AND LOWER(title) LIKE LOWER(?) ORDER BY sort_order, title LIMIT ?`
rows, err := r.db.Query(q, "%"+query+"%", limit) rows, err := r.db.Query(q, "%"+query+"%", limit)
if err != nil { if err != nil {
return nil, err return nil, err