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:
parent
c512ada386
commit
5257789a4d
|
|
@ -298,6 +298,21 @@ func listTrashEntries(trashPath string) ([]TrashEntryDTO, error) {
|
|||
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 {
|
||||
if err := a.requireVault(); err != nil {
|
||||
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
|
|
@ -19,8 +19,8 @@
|
|||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-CtkslTth.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main--SNK_nBk.css">
|
||||
<script type="module" crossorigin src="/assets/main-PQ2CZjSe.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Bomne4X7.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@
|
|||
let journalApproxFilter = 'all'
|
||||
let journalFilteredNodeTitle = ''
|
||||
let journalStatusMsg = ''
|
||||
let journalActiveTab = 'suggestions'
|
||||
let journalSearchQuery = ''
|
||||
let journalSearchResults = []
|
||||
let journalShowResults = false
|
||||
|
|
@ -322,11 +323,13 @@
|
|||
await restoreNavigation(snapshot)
|
||||
}
|
||||
|
||||
function handleAuxClick(e) {
|
||||
if (e.button !== 3) return
|
||||
function handleMouseNav(e) {
|
||||
if (e.button !== 3 && e.button !== 4) return
|
||||
if (isEditableTarget(e.target)) return
|
||||
if (closeTopModalForBack() && e.button === 3) return
|
||||
e.preventDefault()
|
||||
goBack()
|
||||
e.stopPropagation()
|
||||
if (e.button === 3) goBack()
|
||||
}
|
||||
|
||||
function setActiveTab(tabId) {
|
||||
|
|
@ -384,7 +387,8 @@
|
|||
window.addEventListener('dragover', handleGlobalDragOver)
|
||||
window.addEventListener('dragleave', handleGlobalDragLeave)
|
||||
window.addEventListener('drop', handleGlobalDrop)
|
||||
window.addEventListener('auxclick', handleAuxClick)
|
||||
window.addEventListener('auxclick', handleMouseNav)
|
||||
window.addEventListener('mouseup', handleMouseNav)
|
||||
|
||||
loading = false
|
||||
loadSyncStatus()
|
||||
|
|
@ -398,7 +402,8 @@
|
|||
window.removeEventListener('dragover', handleGlobalDragOver)
|
||||
window.removeEventListener('dragleave', handleGlobalDragLeave)
|
||||
window.removeEventListener('drop', handleGlobalDrop)
|
||||
window.removeEventListener('auxclick', handleAuxClick)
|
||||
window.removeEventListener('auxclick', handleMouseNav)
|
||||
window.removeEventListener('mouseup', handleMouseNav)
|
||||
})
|
||||
|
||||
// ===== System view / Node selection =====
|
||||
|
|
@ -1386,6 +1391,21 @@
|
|||
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) {
|
||||
trashSelectedIds = trashSelectedIds.includes(id)
|
||||
? trashSelectedIds.filter(existing => existing !== id)
|
||||
|
|
@ -3031,23 +3051,22 @@
|
|||
{#each visibleTrashNodes as node}
|
||||
<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)} />
|
||||
<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')}
|
||||
</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-meta">{node.nodePath || nodeKindLabel(node.type)}</span>
|
||||
<span class="trash-row-meta">{formatDate(node.deletedAt)}</span>
|
||||
</div>
|
||||
{#if node.fsPath}<span class="trash-row-path">{node.fsPath}</span>{/if}
|
||||
<span class="trash-row-meta">{node.nodePath}</span>
|
||||
<span class="trash-row-date">{formatDate(node.deletedAt)}</span>
|
||||
</button>
|
||||
<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))}>
|
||||
{@html actionIcon('restore')}
|
||||
</button>
|
||||
|
|
@ -3065,223 +3084,235 @@
|
|||
|
||||
{:else if selectedSection === 'journal'}
|
||||
<div class="journal-screen">
|
||||
<div class="journal-header">
|
||||
<h2>{t('journal.title')}</h2>
|
||||
<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 class="journal-tabs">
|
||||
<button class="journal-tab" class:active={journalActiveTab === 'suggestions'} on:click={() => journalActiveTab = 'suggestions'}>
|
||||
{t('suggest.title')}
|
||||
{#if suggestionCount > 0}<span class="tab-badge">{suggestionCount}</span>{/if}
|
||||
</button>
|
||||
<button class="journal-tab" class:active={journalActiveTab === 'worklog'} on:click={() => journalActiveTab = 'worklog'}>
|
||||
{t('journal.worklogTab')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if suggestions.length > 0}
|
||||
<div class="journal-suggestions">
|
||||
<div class="suggestions-title">{t('suggest.title')}</div>
|
||||
{#each suggestions as s}
|
||||
<div class="suggestion-card" class:expanded={s._expanded}>
|
||||
<div class="suggestion-main" on:click={() => s._expanded = !s._expanded} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (s._expanded = !s._expanded)}>
|
||||
<div class="suggestion-info">
|
||||
<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>
|
||||
{#if journalActiveTab === 'suggestions'}
|
||||
{#if suggestions.length === 0}
|
||||
<div class="empty-state"><p>{t('suggest.noSuggestions')}</p></div>
|
||||
{:else}
|
||||
<div class="journal-suggestions">
|
||||
{#each suggestions as s}
|
||||
<div class="suggestion-card" class:expanded={s._expanded}>
|
||||
<div class="suggestion-main" on:click={() => s._expanded = !s._expanded} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (s._expanded = !s._expanded)}>
|
||||
<div class="suggestion-info">
|
||||
<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 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>
|
||||
{#if s._expanded && s.events && s.events.length > 0}
|
||||
<div class="suggestion-detail">
|
||||
<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 s._expanded && s.events && s.events.length > 0}
|
||||
<div class="suggestion-detail">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
</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>
|
||||
|
||||
{#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}
|
||||
</div>
|
||||
|
||||
|
|
@ -3305,6 +3336,7 @@
|
|||
onDeleteSuggestion={(s) => deleteSuggestion(s)}
|
||||
onOpenNodeFolder={(id) => openNodeFolder(id)}
|
||||
onOpenInboxArtifact={(item) => openInboxArtifact(item)}
|
||||
onOpenTrashNode={(nodeId) => { selectSystemView('trash'); openTrashFolderNode({ id: nodeId, title: '' }); refreshTrash() }}
|
||||
/>
|
||||
{:else}
|
||||
<div class="today-empty">
|
||||
|
|
@ -3559,6 +3591,7 @@
|
|||
<input type="text" placeholder={t('inbox.assignSearchPlaceholder')} bind:value={inboxAssignQuery}
|
||||
on:input={onInboxAssignSearchInput}
|
||||
on:keydown={(e) => e.key === 'Enter' && inboxAssignTarget && submitAssignInbox()} />
|
||||
<div class="assign-hint">{t('inbox.assignSearchHint')}</div>
|
||||
</label>
|
||||
{#if inboxAssignResults.length > 0}
|
||||
<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 h3 { margin: 0 0 2px; }
|
||||
.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.selected { border-color: #6366f1; background: #20203a; }
|
||||
.trash-row-icon { color: #a5b4fc; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.trash-row-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.trash-row-icon { color: #a5b4fc; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 4px; border-radius: 4px; }
|
||||
.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-meta { color: #8888a0; font-size: 12px; 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-actions { display: flex; align-items: center; gap: 6px; }
|
||||
.trash-row-meta { color: #8ea0d8; font-size: 11px; 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; justify-content: flex-end; }
|
||||
.trash-empty-line { color: #8888a0; font-size: 13px; margin: 0; }
|
||||
|
||||
/* Journal screen */
|
||||
.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 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; }
|
||||
.summary-total { font-size: 18px; font-weight: 700; color: #e4e4ef; width: 100%; margin-bottom: 4px; }
|
||||
.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:hover { background: #222238; }
|
||||
.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; }
|
||||
|
||||
/* Buttons */
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
export let onDeleteSuggestion = (s) => {}
|
||||
export let onOpenNodeFolder = (id) => {}
|
||||
export let onOpenInboxArtifact = (item) => {}
|
||||
export let onOpenTrashNode = (nodeId) => {}
|
||||
|
||||
let activeTab = 'feed'
|
||||
|
||||
|
|
@ -47,6 +48,7 @@
|
|||
}
|
||||
|
||||
function feedItemLabel(ev) {
|
||||
if (!ev || !ev.eventType) return ''
|
||||
return eventLabel(ev.eventType)
|
||||
}
|
||||
|
||||
|
|
@ -64,14 +66,15 @@
|
|||
}
|
||||
|
||||
function handleFeedTitleClick(ev) {
|
||||
if (!ev || !ev.eventType) return
|
||||
if (ev.eventType === 'folder_deleted') {
|
||||
onOpenNodeById(ev.nodeId)
|
||||
if (ev.targetId) onOpenTrashNode(ev.targetId)
|
||||
return
|
||||
}
|
||||
if (['file_added','file_deleted','file_renamed','file_copied','file_moved','folder_added','folder_renamed','folder_moved'].includes(ev.eventType)) {
|
||||
if (ev.targetId) {
|
||||
onOpenActivityTarget(ev)
|
||||
} else {
|
||||
} else if (ev.nodeId) {
|
||||
onOpenNodeById(ev.nodeId)
|
||||
}
|
||||
return
|
||||
|
|
@ -79,20 +82,22 @@
|
|||
if (['note_created','note_updated','note_deleted'].includes(ev.eventType)) {
|
||||
if (ev.targetType === 'note' && ev.targetId) {
|
||||
onOpenActivityTarget(ev)
|
||||
} else {
|
||||
} else if (ev.nodeId) {
|
||||
onOpenNodeById(ev.nodeId)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ev.eventType === 'worklog_added') {
|
||||
onOpenNodeById(ev.nodeId)
|
||||
if (ev.nodeId) onOpenNodeById(ev.nodeId)
|
||||
return
|
||||
}
|
||||
if (['action_created','action_done'].includes(ev.eventType)) {
|
||||
onOpenNodeById(ev.nodeId)
|
||||
if (ev.nodeId) onOpenNodeById(ev.nodeId)
|
||||
return
|
||||
}
|
||||
onOpenActivityEvent(ev)
|
||||
if (ev.id || ev.nodeId) {
|
||||
onOpenActivityEvent(ev)
|
||||
}
|
||||
}
|
||||
|
||||
function feedItemSubtitle(ev) {
|
||||
|
|
@ -186,9 +191,9 @@
|
|||
<span class="feed-icon">{feedItemIcon(ev.eventType)}</span>
|
||||
<div class="feed-body">
|
||||
<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-title link-btn">{ev.title}</span>
|
||||
<span class="feed-title link-btn">{ev.title || ''}</span>
|
||||
</div>
|
||||
<div class="feed-meta-line">
|
||||
{#if feedItemSubtitle(ev)}
|
||||
|
|
@ -365,7 +370,7 @@
|
|||
.feed-title:hover { text-decoration: underline; }
|
||||
.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-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-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; }
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export default {
|
|||
'inbox.assignTitle': 'Assign material',
|
||||
'inbox.assignTarget': 'Case',
|
||||
'inbox.assignSearchPlaceholder': 'Find case',
|
||||
'inbox.assignSearchHint': 'Start typing a Case name',
|
||||
'inbox.deleteTitle': 'Delete material',
|
||||
'inbox.deleteConfirm': 'Delete "{title}" from inbox?',
|
||||
'capture.kind.text': 'Text',
|
||||
|
|
@ -278,6 +279,7 @@ export default {
|
|||
'today.sortAsc': 'ascending',
|
||||
'today.sortDesc': 'descending',
|
||||
'journal.title': 'Work Log',
|
||||
'journal.worklogTab': 'Work Log',
|
||||
'journal.empty': 'No entries for the selected period',
|
||||
'journal.dateFrom': 'From',
|
||||
'journal.dateTo': 'To',
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export default {
|
|||
'inbox.assignTitle': 'Разложить материал',
|
||||
'inbox.assignTarget': 'Дело',
|
||||
'inbox.assignSearchPlaceholder': 'Найти дело',
|
||||
'inbox.assignSearchHint': 'Начните набирать название Дела',
|
||||
'inbox.deleteTitle': 'Удалить материал',
|
||||
'inbox.deleteConfirm': 'Удалить «{title}» из неразобранного?',
|
||||
|
||||
|
|
@ -294,6 +295,7 @@ export default {
|
|||
'today.sortDesc': 'по убыванию',
|
||||
|
||||
'journal.title': 'Журнал работы',
|
||||
'journal.worklogTab': 'Журнал работы',
|
||||
'journal.empty': 'Нет записей за выбранный период',
|
||||
'journal.dateFrom': 'От',
|
||||
'journal.dateTo': 'До',
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
func (r *Repository) Search(query string, limit int) ([]Node, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
Loading…
Reference in New Issue