feat: journal UX overhaul — picker, export dialog, events, readability
- Sidebar i18n: added missing nav.journal to backend ru.json - Export: SaveWorklogReport binding with native SaveFileDialog + os.WriteFile - Filter: better IncludeChildren label with disabled tooltip - Filter: renamed billable→К оплате, approximate→Тип времени with hints - worklog_entry_events table (migration 013) linking entries to activity events - Suggestion: EventIDs + Events details, expandable cards with timestamps - Journal rows: expandable with details, source, linked events - Contrast: improved readability for dates, timestamps, hover states - i18n: added worklog.*, journal.*, suggest.* keys to ru.js/en.js
This commit is contained in:
parent
d34100e2ed
commit
1472bb3e6f
|
|
@ -60,6 +60,21 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
||||||
min := estimateMinutes(bursts, spread, len(grp.events))
|
min := estimateMinutes(bursts, spread, len(grp.events))
|
||||||
conf, reason := confidence(bursts, spread, len(grp.events))
|
conf, reason := confidence(bursts, spread, len(grp.events))
|
||||||
|
|
||||||
|
eventIDs := make([]string, 0, len(grp.events))
|
||||||
|
evDetails := make([]activity.SuggestionDetail, 0, len(grp.events))
|
||||||
|
for _, e := range grp.events {
|
||||||
|
eventIDs = append(eventIDs, e.ID)
|
||||||
|
evDetails = append(evDetails, activity.SuggestionDetail{
|
||||||
|
ID: e.ID,
|
||||||
|
EventType: e.EventType,
|
||||||
|
TargetType: e.TargetType,
|
||||||
|
TargetID: e.TargetID,
|
||||||
|
Title: e.Title,
|
||||||
|
CreatedAt: e.CreatedAt,
|
||||||
|
NodeID: e.NodeID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
suggestions = append(suggestions, activity.Suggestion{
|
suggestions = append(suggestions, activity.Suggestion{
|
||||||
NodeID: nodeID,
|
NodeID: nodeID,
|
||||||
NodeTitle: grp.title,
|
NodeTitle: grp.title,
|
||||||
|
|
@ -70,6 +85,8 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
||||||
Confidence: conf,
|
Confidence: conf,
|
||||||
ConfidenceReason: reason,
|
ConfidenceReason: reason,
|
||||||
TimeSpreadMin: spread,
|
TimeSpreadMin: spread,
|
||||||
|
EventIDs: eventIDs,
|
||||||
|
Events: evDetails,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,6 +112,12 @@ func (a *App) AcceptSuggestionWith(s activity.Suggestion, minutes int, date stri
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Link activity events to this worklog entry.
|
||||||
|
for _, eid := range s.EventIDs {
|
||||||
|
_, _ = a.db.Exec(
|
||||||
|
`INSERT OR IGNORE INTO worklog_entry_events (entry_id, event_id) VALUES (?,?)`,
|
||||||
|
entry.ID, eid)
|
||||||
|
}
|
||||||
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
|
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
|
||||||
mins := 0
|
mins := 0
|
||||||
if entry.Minutes != nil {
|
if entry.Minutes != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
syncsvc "verstak/internal/core/sync"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"verstak/internal/core/worklog"
|
"verstak/internal/core/worklog"
|
||||||
|
syncsvc "verstak/internal/core/sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
|
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
|
||||||
|
|
@ -90,6 +95,94 @@ func buildWorklogFilter(dateFrom, dateTo, nodeID string, includeChildren bool, b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveWorklogReport generates a worklog report and opens a SaveFileDialog.
|
||||||
|
func (a *App) SaveWorklogReport(format, dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
|
||||||
|
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
|
||||||
|
|
||||||
|
var data []byte
|
||||||
|
var ext string
|
||||||
|
switch format {
|
||||||
|
case "csv":
|
||||||
|
s, err := a.worklog.ExportCSV(f)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
data = []byte(s)
|
||||||
|
ext = ".csv"
|
||||||
|
case "markdown":
|
||||||
|
s, err := a.worklog.ExportMarkdown(f)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
data = []byte(s)
|
||||||
|
ext = ".md"
|
||||||
|
case "pdf":
|
||||||
|
var err error
|
||||||
|
data, err = a.worklog.ExportPDF(f)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ext = ".pdf"
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown format: %s", format)
|
||||||
|
}
|
||||||
|
|
||||||
|
from := dateFrom
|
||||||
|
if from == "" {
|
||||||
|
from = "all"
|
||||||
|
}
|
||||||
|
to := dateTo
|
||||||
|
if to == "" {
|
||||||
|
to = "all"
|
||||||
|
}
|
||||||
|
defaultName := fmt.Sprintf("verstak-worklog-%s--%s%s", from, to, ext)
|
||||||
|
|
||||||
|
path, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{
|
||||||
|
DefaultFilename: defaultName,
|
||||||
|
Filters: []wailsruntime.FileFilter{
|
||||||
|
{DisplayName: format, Pattern: "*" + ext},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return "", fmt.Errorf("отменено пользователем")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("не удалось сохранить файл: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Отчёт сохранён: %s", path), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWorklogEntryEvents returns activity events linked to a worklog entry.
|
||||||
|
func (a *App) GetWorklogEntryEvents(entryID string) ([]EventDTO, error) {
|
||||||
|
rows, err := a.db.Query(
|
||||||
|
`SELECT e.id, e.node_id, e.event_type, e.target_type, e.target_id, e.target_path,
|
||||||
|
e.title, e.details_json, e.created_at
|
||||||
|
FROM activity_events e
|
||||||
|
JOIN worklog_entry_events wle ON wle.event_id = e.id
|
||||||
|
WHERE wle.entry_id = ?
|
||||||
|
ORDER BY e.created_at ASC`, entryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []EventDTO
|
||||||
|
for rows.Next() {
|
||||||
|
var d EventDTO
|
||||||
|
if err := rows.Scan(&d.ID, &d.NodeID, &d.EventType, &d.TargetType,
|
||||||
|
&d.TargetID, &d.TargetPath, &d.Title, &d.DetailsJSON, &d.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
func toWorklogDTOs(list []worklog.Entry) []WorklogDTO {
|
func toWorklogDTOs(list []worklog.Entry) []WorklogDTO {
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -16,8 +16,8 @@
|
||||||
background: #13131f;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-BYxU8Qbt.js"></script>
|
<script type="module" crossorigin src="/assets/main-BbnSy6IG.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-C7UPiZtQ.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-Bh5tqssv.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
let journalBillableFilter = 'all'
|
let journalBillableFilter = 'all'
|
||||||
let journalApproxFilter = 'all'
|
let journalApproxFilter = 'all'
|
||||||
let journalFilteredNodeTitle = ''
|
let journalFilteredNodeTitle = ''
|
||||||
|
let journalStatusMsg = ''
|
||||||
let journalSearchQuery = ''
|
let journalSearchQuery = ''
|
||||||
let journalSearchResults = []
|
let journalSearchResults = []
|
||||||
let journalShowResults = false
|
let journalShowResults = false
|
||||||
|
|
@ -988,37 +989,38 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportJournalCSV() {
|
async function saveJournalReport(format) {
|
||||||
try {
|
try {
|
||||||
const csv = await wailsCall('ExportWorklogCSV', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter)
|
const msg = await wailsCall('SaveWorklogReport', format,
|
||||||
downloadFile('worklog.csv', csv, 'text/csv')
|
journalDateFrom, journalDateTo, journalNodeID,
|
||||||
} catch (e) { console.error(e) }
|
journalIncludeChildren, journalBillableFilter, journalApproxFilter)
|
||||||
}
|
journalStatusMsg = msg
|
||||||
|
setTimeout(() => journalStatusMsg = '', 4000)
|
||||||
async function exportJournalMarkdown() {
|
} catch (e) {
|
||||||
try {
|
if (String(e).includes('отменено')) return
|
||||||
const md = await wailsCall('ExportWorklogMarkdown', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter)
|
journalStatusMsg = 'Ошибка: ' + String(e)
|
||||||
downloadFile('worklog.md', md, 'text/markdown')
|
setTimeout(() => journalStatusMsg = '', 6000)
|
||||||
} catch (e) { console.error(e) }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function exportJournalPDF() {
|
|
||||||
try {
|
|
||||||
const data = await wailsCall('ExportWorklogPDF', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter)
|
|
||||||
let pdfBytes = data
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
const bin = atob(data)
|
|
||||||
pdfBytes = new Uint8Array(bin.length)
|
|
||||||
for (let i = 0; i < bin.length; i++) pdfBytes[i] = bin.charCodeAt(i)
|
|
||||||
}
|
|
||||||
const from = journalDateFrom || 'all'
|
|
||||||
const to = journalDateTo || 'all'
|
|
||||||
downloadFile(`verstak-worklog-${from}--${to}.pdf`, pdfBytes, 'application/pdf')
|
|
||||||
} catch (e) { console.error(e) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let journalSearchTimer
|
let journalSearchTimer
|
||||||
|
|
||||||
|
async function toggleJournalRow(r) {
|
||||||
|
r._expanded = !r._expanded
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
async function searchJournalNodes() {
|
async function searchJournalNodes() {
|
||||||
const q = journalSearchQuery.trim()
|
const q = journalSearchQuery.trim()
|
||||||
if (!q || q.length < 2) {
|
if (!q || q.length < 2) {
|
||||||
|
|
@ -1187,10 +1189,6 @@
|
||||||
if (type === 'file_moved') return '→'
|
if (type === 'file_moved') return '→'
|
||||||
return '•'
|
return '•'
|
||||||
}
|
}
|
||||||
function formatTime(str) {
|
|
||||||
if (!str) return ''
|
|
||||||
try { return new Date(str).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' }
|
|
||||||
}
|
|
||||||
function formatDate(str) {
|
function formatDate(str) {
|
||||||
if (!str) return ''
|
if (!str) return ''
|
||||||
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
|
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
|
||||||
|
|
@ -1671,16 +1669,31 @@
|
||||||
<div class="worklog-tab-suggestions">
|
<div class="worklog-tab-suggestions">
|
||||||
<div class="suggestions-title">{t('worklog.suggestions')}</div>
|
<div class="suggestions-title">{t('worklog.suggestions')}</div>
|
||||||
{#each suggestions.filter(s => s.nodeId === selectedNode.id) as s}
|
{#each suggestions.filter(s => s.nodeId === selectedNode.id) as s}
|
||||||
<div class="suggestion-card">
|
<div class="suggestion-card" class:expanded={s._expanded}>
|
||||||
<div class="suggestion-info">
|
<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')} · {t('suggest.confidence.' + s.confidence)}</span>
|
<span class="suggestion-summary">{s.summary}</span>
|
||||||
</div>
|
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')} · {t('suggest.confidence.' + s.confidence)}</span>
|
||||||
<div class="suggestion-actions">
|
</div>
|
||||||
<button class="btn btn-sm btn-primary" on:click={() => acceptTodaySuggestion(s)}>
|
<div class="suggestion-actions">
|
||||||
{t('worklog.apply')}
|
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>
|
||||||
</button>
|
{t('worklog.apply')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1751,28 +1764,37 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label" title={!journalNodeID ? t('journal.includeChildrenDisabledHint') : ''}>
|
||||||
<input type="checkbox" bind:checked={journalIncludeChildren} disabled={!journalNodeID} />
|
{#if journalNodeID}
|
||||||
<span>{t('journal.includeChildren')}</span>
|
<input type="checkbox" bind:checked={journalIncludeChildren} />
|
||||||
|
{:else}
|
||||||
|
<input type="checkbox" disabled title={t('journal.includeChildrenDisabledHint')} />
|
||||||
|
{/if}
|
||||||
|
<span class:disabled-hint={!journalNodeID}>{t('journal.includeChildren')}</span>
|
||||||
</label>
|
</label>
|
||||||
<label><span class="label-text">{t('journal.billable')}</span>
|
<label title={t('journal.billableHint')}>
|
||||||
|
<span class="label-text">{t('journal.billable')}</span>
|
||||||
<select bind:value={journalBillableFilter}>
|
<select bind:value={journalBillableFilter}>
|
||||||
<option value="all">{t('common.all')}</option>
|
<option value="all">{t('common.all')}</option>
|
||||||
<option value="yes">{t('journal.billable')}</option>
|
<option value="yes">{t('journal.billableYes')}</option>
|
||||||
<option value="no">{t('common.no')}</option>
|
<option value="no">{t('journal.billableNo')}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label><span class="label-text">{t('journal.approximate')}</span>
|
<label title={t('journal.approxHint')}>
|
||||||
|
<span class="label-text">{t('journal.approx')}</span>
|
||||||
<select bind:value={journalApproxFilter}>
|
<select bind:value={journalApproxFilter}>
|
||||||
<option value="all">{t('common.all')}</option>
|
<option value="all">{t('common.all')}</option>
|
||||||
<option value="yes">{t('journal.approximate')}</option>
|
<option value="no">{t('journal.approxExact')}</option>
|
||||||
<option value="no">{t('common.no')}</option>
|
<option value="yes">{t('journal.approxEstimated')}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-sm" on:click={loadJournal}>{t('journal.filter')}</button>
|
<button class="btn btn-sm" on:click={loadJournal}>{t('journal.filter')}</button>
|
||||||
<button class="btn btn-sm" on:click={exportJournalCSV}>{t('journal.exportCSV')}</button>
|
<button class="btn btn-sm" on:click={() => saveJournalReport('csv')}>{t('journal.exportCSV')}</button>
|
||||||
<button class="btn btn-sm" on:click={exportJournalMarkdown}>{t('journal.exportMarkdown')}</button>
|
<button class="btn btn-sm" on:click={() => saveJournalReport('markdown')}>{t('journal.exportMarkdown')}</button>
|
||||||
<button class="btn btn-sm" on:click={exportJournalPDF}>PDF</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>
|
||||||
|
|
||||||
|
|
@ -1780,18 +1802,33 @@
|
||||||
<div class="journal-suggestions">
|
<div class="journal-suggestions">
|
||||||
<div class="suggestions-title">{t('suggest.title')}</div>
|
<div class="suggestions-title">{t('suggest.title')}</div>
|
||||||
{#each suggestions as s}
|
{#each suggestions as s}
|
||||||
<div class="suggestion-card">
|
<div class="suggestion-card" class:expanded={s._expanded}>
|
||||||
<div class="suggestion-info">
|
<div class="suggestion-main" on:click={() => s._expanded = !s._expanded} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (s._expanded = !s._expanded)}>
|
||||||
<button class="suggestion-node link-btn" on:click={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button>
|
<div class="suggestion-info">
|
||||||
<span class="suggestion-summary">{s.summary}</span>
|
<button class="suggestion-node link-btn" on:click|stopPropagation={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button>
|
||||||
<span class="suggestion-confidence" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'}>{t('suggest.confidence.' + s.confidence)}</span>
|
<span class="suggestion-summary">{s.summary}</span>
|
||||||
</div>
|
<span class="suggestion-confidence" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'}>{t('suggest.confidence.' + s.confidence)}</span>
|
||||||
<div class="suggestion-actions">
|
</div>
|
||||||
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
|
<div class="suggestion-actions">
|
||||||
on:change={(e) => s.suggestedMin = parseInt(e.target.value)} />
|
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
|
||||||
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
|
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
|
||||||
<button class="btn btn-sm btn-primary" on:click={() => acceptJournalSuggestion(s)}>{t('suggest.apply')}</button>
|
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
|
||||||
|
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptJournalSuggestion(s)}>{t('suggest.apply')}</button>
|
||||||
|
</div>
|
||||||
</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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1831,21 +1868,52 @@
|
||||||
<th>{t('journal.path')}</th>
|
<th>{t('journal.path')}</th>
|
||||||
<th>{t('worklog.minutes')}</th>
|
<th>{t('worklog.minutes')}</th>
|
||||||
<th>{t('journal.billable')}</th>
|
<th>{t('journal.billable')}</th>
|
||||||
<th>{t('journal.approximate')}</th>
|
<th>{t('journal.approx')}</th>
|
||||||
<th>{t('common.date')}</th>
|
<th>{t('common.date')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each journalRows as r}
|
{#each journalRows as r}
|
||||||
<tr>
|
<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>{r.summary}</td>
|
<td class="journal-summary-cell">{r.summary}</td>
|
||||||
<td><button class="link-btn" on:click={() => openNodeById(r.nodeId)}>{r.nodeTitle}</button></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-path-cell">{r.nodePath}</td>
|
||||||
<td class="journal-min-cell">{r.minutes}</td>
|
<td class="journal-min-cell">{r.minutes}</td>
|
||||||
<td>{#if r.billable}✓{/if}</td>
|
<td class="journal-bool-cell">{#if r.billable}✓{/if}</td>
|
||||||
<td>{#if r.approximate}~{/if}</td>
|
<td class="journal-bool-cell">{#if r.approximate}~{/if}</td>
|
||||||
<td class="journal-date-cell">{r.date}</td>
|
<td class="journal-date-cell">{r.date}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{#if r._expanded}
|
||||||
|
<tr class="journal-row-detail">
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="journal-detail-body">
|
||||||
|
{#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>
|
||||||
|
<p>{#if r._hasEvents}{t('worklog.sourceSuggestion')}{:else}{t('worklog.sourceManual')}{/if}</p>
|
||||||
|
</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">{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>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -1871,18 +1939,33 @@
|
||||||
<div class="today-suggestions">
|
<div class="today-suggestions">
|
||||||
<div class="suggestions-title">{t('suggest.title')}</div>
|
<div class="suggestions-title">{t('suggest.title')}</div>
|
||||||
{#each suggestions as s}
|
{#each suggestions as s}
|
||||||
<div class="suggestion-card">
|
<div class="suggestion-card" class:expanded={s._expanded}>
|
||||||
<div class="suggestion-info">
|
<div class="suggestion-main" on:click={() => s._expanded = !s._expanded} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (s._expanded = !s._expanded)}>
|
||||||
<button class="suggestion-node link-btn" on:click={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button>
|
<div class="suggestion-info">
|
||||||
<span class="suggestion-summary">{s.summary}</span>
|
<button class="suggestion-node link-btn" on:click|stopPropagation={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button>
|
||||||
<span class="suggestion-confidence" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'}>{t('suggest.confidence.' + s.confidence)}</span>
|
<span class="suggestion-summary">{s.summary}</span>
|
||||||
</div>
|
<span class="suggestion-confidence" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'}>{t('suggest.confidence.' + s.confidence)}</span>
|
||||||
<div class="suggestion-actions">
|
</div>
|
||||||
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
|
<div class="suggestion-actions">
|
||||||
on:change={(e) => s.suggestedMin = parseInt(e.target.value)} />
|
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
|
||||||
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
|
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
|
||||||
<button class="btn btn-sm btn-primary" on:click={() => acceptTodaySuggestion(s)}>{t('suggest.apply')}</button>
|
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
|
||||||
|
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>{t('suggest.apply')}</button>
|
||||||
|
</div>
|
||||||
</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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2336,6 +2419,14 @@
|
||||||
.suggestions-title { font-size: 13px; font-weight: 600; color: #a5b4fc; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
.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; }
|
.suggestion-summary { font-size: 14px; color: #e4e4ef; }
|
||||||
.suggestion-meta { font-size: 12px; color: #8888a0; margin-top: 2px; }
|
.suggestion-meta { font-size: 12px; color: #8888a0; margin-top: 2px; }
|
||||||
|
.suggestion-main { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; cursor: pointer; gap: 12px; }
|
||||||
|
.suggestion-card.expanded { border-color: #3a3a5c; }
|
||||||
|
.suggestion-detail { padding: 0 12px 10px; border-top: 1px solid #2a2a3c; }
|
||||||
|
.suggestion-detail-title { font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; padding: 8px 0 4px; }
|
||||||
|
.suggestion-detail-event { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 13px; color: #b0b0c0; }
|
||||||
|
.suggestion-event-time { color: #a0a0b8; font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 48px; }
|
||||||
|
.suggestion-event-type { color: #8888a0; font-size: 11px; background: #1a1a2e; padding: 1px 6px; border-radius: 3px; }
|
||||||
|
.suggestion-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
/* Suggestion cards */
|
/* Suggestion cards */
|
||||||
.suggestion-card { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: #1e1e32; border-radius: 6px; margin-bottom: 8px; gap: 12px; }
|
.suggestion-card { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: #1e1e32; border-radius: 6px; margin-bottom: 8px; gap: 12px; }
|
||||||
|
|
@ -2369,12 +2460,27 @@
|
||||||
.summary-count { color: #8888a0; }
|
.summary-count { color: #8888a0; }
|
||||||
.journal-table-wrap { overflow-x: auto; }
|
.journal-table-wrap { overflow-x: auto; }
|
||||||
.journal-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
.journal-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
.journal-table th { text-align: left; padding: 8px 12px; border-bottom: 2px solid #2a2a3c; color: #8888a0; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; }
|
.journal-table th { text-align: left; padding: 8px 12px; border-bottom: 2px solid #2a2a3c; color: #b0b0c8; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; }
|
||||||
.journal-table td { padding: 8px 12px; border-bottom: 1px solid #1a1a28; color: #e4e4ef; }
|
.journal-table td { padding: 8px 12px; border-bottom: 1px solid #1a1a28; color: #e4e4ef; }
|
||||||
|
.journal-table tr:hover td { background: #1e1e32; }
|
||||||
.journal-table .link-btn { color: #a5b4fc; }
|
.journal-table .link-btn { color: #a5b4fc; }
|
||||||
.journal-path-cell { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #8888a0; font-size: 12px; }
|
.journal-path-cell { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #a0a0b8; font-size: 12px; }
|
||||||
.journal-min-cell { text-align: right; font-variant-numeric: tabular-nums; }
|
.journal-min-cell { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
.journal-date-cell { color: #8888a0; white-space: nowrap; }
|
.journal-date-cell { color: #b0b0c0; white-space: nowrap; }
|
||||||
|
.journal-bool-cell { text-align: center; color: #a0a0b8; }
|
||||||
|
.journal-row { cursor: pointer; }
|
||||||
|
.journal-row:hover td { background: #1e1e32; }
|
||||||
|
.journal-row.expanded td { background: #1a1a30; border-bottom: none; }
|
||||||
|
.journal-summary-cell { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.journal-row-detail td { padding: 0 12px 12px; background: #16162a; }
|
||||||
|
.journal-detail-body { display: flex; flex-direction: column; gap: 12px; padding: 8px 0; }
|
||||||
|
.journal-detail-section { font-size: 13px; }
|
||||||
|
.journal-detail-label { font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; display: block; margin-bottom: 4px; }
|
||||||
|
.journal-detail-section p { margin: 0; color: #c0c0d0; }
|
||||||
|
.journal-event-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; color: #b0b0c0; }
|
||||||
|
.journal-event-time { color: #a0a0b8; font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 48px; }
|
||||||
|
.journal-event-type { color: #8888a0; font-size: 11px; background: #1a1a2e; padding: 1px 6px; border-radius: 3px; }
|
||||||
|
.journal-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.journal-node-picker input[type="text"] { padding: 6px 10px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 13px; font-family: inherit; width: 240px; }
|
.journal-node-picker input[type="text"] { padding: 6px 10px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 13px; font-family: inherit; width: 240px; }
|
||||||
.journal-search-dropdown { position: absolute; top: 100%; left: 0; right: 0; z-index: 50; background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 6px; margin-top: 4px; max-height: 240px; overflow-y: auto; min-width: 260px; }
|
.journal-search-dropdown { position: absolute; top: 100%; left: 0; right: 0; z-index: 50; background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 6px; margin-top: 4px; max-height: 240px; overflow-y: auto; min-width: 260px; }
|
||||||
.journal-search-item { display: block; width: 100%; padding: 8px 12px; border: none; background: transparent; color: #e4e4ef; cursor: pointer; font-family: inherit; font-size: 13px; text-align: left; }
|
.journal-search-item { display: block; width: 100%; padding: 8px 12px; border: none; background: transparent; color: #e4e4ef; cursor: pointer; font-family: inherit; font-size: 13px; text-align: left; }
|
||||||
|
|
@ -2384,6 +2490,8 @@
|
||||||
.journal-selected-node { cursor: pointer; display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; background: #1e1e3a; border: 1px solid #3a3a5c; border-radius: 4px; font-size: 13px; color: #a5b4fc; white-space: nowrap; font-family: inherit; }
|
.journal-selected-node { cursor: pointer; display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; background: #1e1e3a; border: 1px solid #3a3a5c; border-radius: 4px; font-size: 13px; color: #a5b4fc; white-space: nowrap; font-family: inherit; }
|
||||||
.journal-selected-node:hover { background: #2a2a4a; }
|
.journal-selected-node:hover { background: #2a2a4a; }
|
||||||
.journal-node-clear { color: #8888a0; font-size: 14px; margin-left: 4px; }
|
.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; }
|
||||||
|
|
||||||
/* Today suggestions */
|
/* Today suggestions */
|
||||||
.today-suggestions { margin-bottom: 24px; }
|
.today-suggestions { margin-bottom: 24px; }
|
||||||
|
|
|
||||||
|
|
@ -123,9 +123,20 @@ export default {
|
||||||
|
|
||||||
'worklog.suggestions': 'Suggestions for today',
|
'worklog.suggestions': 'Suggestions for today',
|
||||||
'worklog.apply': 'Apply',
|
'worklog.apply': 'Apply',
|
||||||
|
'worklog.title': 'Work Log',
|
||||||
|
'worklog.whatDone': 'What was done',
|
||||||
|
'worklog.minutes': 'Min',
|
||||||
|
'worklog.min': 'min',
|
||||||
|
'worklog.log': 'Log',
|
||||||
|
'worklog.empty': 'No work logged yet',
|
||||||
|
'worklog.details': 'Details',
|
||||||
|
'worklog.source': 'Source',
|
||||||
|
'worklog.sourceSuggestion': 'Activity suggestion',
|
||||||
|
'worklog.sourceManual': 'Manual entry',
|
||||||
|
|
||||||
'common.all': 'All',
|
'common.all': 'All',
|
||||||
'common.no': 'No',
|
'common.no': 'No',
|
||||||
|
'common.open': 'Open',
|
||||||
'common.date': 'Date',
|
'common.date': 'Date',
|
||||||
'common.search': 'Search',
|
'common.search': 'Search',
|
||||||
|
|
||||||
|
|
@ -140,13 +151,21 @@ export default {
|
||||||
'journal.exportCSV': 'CSV',
|
'journal.exportCSV': 'CSV',
|
||||||
'journal.exportMarkdown': 'Markdown',
|
'journal.exportMarkdown': 'Markdown',
|
||||||
'journal.billable': 'Billable',
|
'journal.billable': 'Billable',
|
||||||
'journal.approximate': 'Approx',
|
'journal.billableYes': 'Billable',
|
||||||
|
'journal.billableNo': 'Not billable',
|
||||||
|
'journal.billableHint': 'Billable — can be included in client invoice',
|
||||||
|
'journal.approx': 'Time type',
|
||||||
|
'journal.approxExact': 'Exact',
|
||||||
|
'journal.approxEstimated': 'Estimated',
|
||||||
|
'journal.approxHint': 'Estimated — time suggested by the system or entered as approximate',
|
||||||
|
'journal.includeChildrenDisabledHint': 'Only works when a case/client/project is selected',
|
||||||
'journal.node': 'Case',
|
'journal.node': 'Case',
|
||||||
'journal.path': 'Path',
|
'journal.path': 'Path',
|
||||||
'journal.byDay': 'By day',
|
'journal.byDay': 'By day',
|
||||||
'journal.byNode': 'By case',
|
'journal.byNode': 'By case',
|
||||||
'journal.includeChildren': 'Include subtasks',
|
'journal.includeChildren': 'Include subtasks',
|
||||||
'journal.nodeSearch': 'Search case...',
|
'journal.nodeSearch': 'Search case...',
|
||||||
|
'journal.relatedEvents': 'Related events',
|
||||||
|
|
||||||
'suggest.title': 'Suggestions',
|
'suggest.title': 'Suggestions',
|
||||||
'suggest.apply': 'Log',
|
'suggest.apply': 'Log',
|
||||||
|
|
@ -158,4 +177,5 @@ export default {
|
||||||
'suggest.minutes': 'min',
|
'suggest.minutes': 'min',
|
||||||
'suggest.edit': 'Edit',
|
'suggest.edit': 'Edit',
|
||||||
'suggest.noSuggestions': 'No suggestions',
|
'suggest.noSuggestions': 'No suggestions',
|
||||||
|
'suggest.detectedEvents': 'What was detected',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ export default {
|
||||||
'common.run': 'Запустить',
|
'common.run': 'Запустить',
|
||||||
'common.test': 'Test',
|
'common.test': 'Test',
|
||||||
'common.all': 'Все',
|
'common.all': 'Все',
|
||||||
|
'common.open': 'Открыть',
|
||||||
'common.no': 'Нет',
|
'common.no': 'Нет',
|
||||||
'common.date': 'Дата',
|
'common.date': 'Дата',
|
||||||
'common.search': 'Найти',
|
'common.search': 'Найти',
|
||||||
|
|
@ -156,6 +157,12 @@ export default {
|
||||||
'worklog.min': 'мин',
|
'worklog.min': 'мин',
|
||||||
'worklog.log': 'Записать',
|
'worklog.log': 'Записать',
|
||||||
'worklog.empty': 'Записей работы пока нет',
|
'worklog.empty': 'Записей работы пока нет',
|
||||||
|
'worklog.details': 'Детали',
|
||||||
|
'worklog.source': 'Источник',
|
||||||
|
'worklog.sourceSuggestion': 'Предложение activity',
|
||||||
|
'worklog.sourceManual': 'Ручная запись',
|
||||||
|
'worklog.suggestions': 'Предложения',
|
||||||
|
'worklog.apply': 'Применить',
|
||||||
'worklog.suggestions': 'Предложения на сегодня',
|
'worklog.suggestions': 'Предложения на сегодня',
|
||||||
'worklog.apply': 'Применить',
|
'worklog.apply': 'Применить',
|
||||||
|
|
||||||
|
|
@ -211,14 +218,22 @@ export default {
|
||||||
'journal.total': 'Всего',
|
'journal.total': 'Всего',
|
||||||
'journal.exportCSV': 'CSV',
|
'journal.exportCSV': 'CSV',
|
||||||
'journal.exportMarkdown': 'Markdown',
|
'journal.exportMarkdown': 'Markdown',
|
||||||
'journal.billable': 'Оплачиваемое',
|
'journal.billable': 'К оплате',
|
||||||
'journal.approximate': 'Примерно',
|
'journal.billableYes': 'К оплате',
|
||||||
|
'journal.billableNo': 'Не к оплате',
|
||||||
|
'journal.billableHint': 'К оплате — можно включать в счёт клиенту',
|
||||||
|
'journal.approx': 'Тип времени',
|
||||||
|
'journal.approxExact': 'Точное',
|
||||||
|
'journal.approxEstimated': 'Оценочное',
|
||||||
|
'journal.approxHint': 'Оценочное — время предложено системой или введено как примерное',
|
||||||
|
'journal.includeChildrenDisabledHint': 'Работает только при выбранном деле/клиенте/проекте',
|
||||||
'journal.node': 'Дело',
|
'journal.node': 'Дело',
|
||||||
'journal.path': 'Путь',
|
'journal.path': 'Путь',
|
||||||
'journal.byDay': 'По дням',
|
'journal.byDay': 'По дням',
|
||||||
'journal.byNode': 'По делам',
|
'journal.byNode': 'По делам',
|
||||||
'journal.includeChildren': 'С подзадачами',
|
'journal.includeChildren': 'С подзадачами',
|
||||||
'journal.nodeSearch': 'Поиск дела...',
|
'journal.nodeSearch': 'Поиск дела...',
|
||||||
|
'journal.relatedEvents': 'Связанные события',
|
||||||
|
|
||||||
'suggest.title': 'Предложения на сегодня',
|
'suggest.title': 'Предложения на сегодня',
|
||||||
'suggest.apply': 'Записать',
|
'suggest.apply': 'Записать',
|
||||||
|
|
@ -230,6 +245,7 @@ export default {
|
||||||
'suggest.minutes': 'мин',
|
'suggest.minutes': 'мин',
|
||||||
'suggest.edit': 'Изменить',
|
'suggest.edit': 'Изменить',
|
||||||
'suggest.noSuggestions': 'Нет предложений для журнала',
|
'suggest.noSuggestions': 'Нет предложений для журнала',
|
||||||
|
'suggest.detectedEvents': 'Что обнаружено',
|
||||||
|
|
||||||
'activity.title': 'Активность',
|
'activity.title': 'Активность',
|
||||||
'activity.empty': 'Активность пока не зафиксирована',
|
'activity.empty': 'Активность пока не зафиксирована',
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,29 @@ const (
|
||||||
ConfidenceHigh = "high"
|
ConfidenceHigh = "high"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SuggestionDetail is a lightweight event summary for suggestion display.
|
||||||
|
type SuggestionDetail struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
EventType string `json:"eventType"`
|
||||||
|
TargetType string `json:"targetType"`
|
||||||
|
TargetID string `json:"targetId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
NodeID string `json:"nodeId"`
|
||||||
|
}
|
||||||
|
|
||||||
// Suggestion represents a suggested worklog entry derived from today's activity.
|
// Suggestion represents a suggested worklog entry derived from today's activity.
|
||||||
type Suggestion struct {
|
type Suggestion struct {
|
||||||
NodeID string `json:"nodeId"`
|
NodeID string `json:"nodeId"`
|
||||||
NodeTitle string `json:"nodeTitle"`
|
NodeTitle string `json:"nodeTitle"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
SuggestedMin int `json:"suggestedMin"`
|
SuggestedMin int `json:"suggestedMin"`
|
||||||
EventCount int `json:"eventCount"`
|
EventCount int `json:"eventCount"`
|
||||||
NodeKind string `json:"nodeKind"`
|
NodeKind string `json:"nodeKind"`
|
||||||
Confidence string `json:"confidence"`
|
Confidence string `json:"confidence"`
|
||||||
ConfidenceReason string `json:"confidenceReason"`
|
ConfidenceReason string `json:"confidenceReason"`
|
||||||
Hidden bool `json:"hidden"`
|
Hidden bool `json:"hidden"`
|
||||||
TimeSpreadMin int `json:"timeSpreadMin"` // minutes between first and last event
|
TimeSpreadMin int `json:"timeSpreadMin"` // minutes between first and last event
|
||||||
|
EventIDs []string `json:"eventIds"`
|
||||||
|
Events []SuggestionDetail `json:"events,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
// migration013 — worklog_entry_events table linking entries to activity events.
|
||||||
|
const migration013 = `
|
||||||
|
CREATE TABLE IF NOT EXISTS worklog_entry_events (
|
||||||
|
entry_id TEXT NOT NULL REFERENCES worklog_entries(id) ON DELETE CASCADE,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (entry_id, event_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wle_events ON worklog_entry_events(event_id);
|
||||||
|
`
|
||||||
|
|
@ -69,6 +69,7 @@ var migrationFiles = map[int]string{
|
||||||
10: migration010,
|
10: migration010,
|
||||||
11: migration011,
|
11: migration011,
|
||||||
12: migration012,
|
12: migration012,
|
||||||
|
13: migration013,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) runInitialSchema() error {
|
func (db *DB) runInitialSchema() error {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,17 @@
|
||||||
"nav.recipes": "Recipes",
|
"nav.recipes": "Recipes",
|
||||||
"nav.documents": "Documents",
|
"nav.documents": "Documents",
|
||||||
"nav.archive": "Archive",
|
"nav.archive": "Archive",
|
||||||
|
"nav.journal": "Work Log",
|
||||||
|
"nav.sections": "Sections",
|
||||||
|
"nav.cases": "Cases",
|
||||||
|
"nav.noCases": "No cases",
|
||||||
|
"nav.sync": "Sync",
|
||||||
|
"nav.syncSettings": "Sync settings",
|
||||||
|
"nav.syncNow": "Sync now",
|
||||||
|
"nav.selectPrompt": "Select a section or case",
|
||||||
|
"nav.brand": "Verstak",
|
||||||
|
"nav.createNode": "Create node",
|
||||||
|
"nav.moveToRoot": "Move to root",
|
||||||
|
|
||||||
"tab.overview": "Overview",
|
"tab.overview": "Overview",
|
||||||
"tab.notes": "Notes",
|
"tab.notes": "Notes",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"nav.sections": "Разделы",
|
"nav.sections": "Разделы",
|
||||||
"nav.cases": "Дела",
|
"nav.cases": "Дела",
|
||||||
"nav.noCases": "Нет дел",
|
"nav.noCases": "Нет дел",
|
||||||
|
"nav.journal": "Журнал",
|
||||||
"nav.sync": "Синхронизация",
|
"nav.sync": "Синхронизация",
|
||||||
"nav.syncSettings": "Настройки синхронизации",
|
"nav.syncSettings": "Настройки синхронизации",
|
||||||
"nav.syncNow": "Синхронизировать",
|
"nav.syncNow": "Синхронизировать",
|
||||||
|
|
@ -391,6 +392,8 @@
|
||||||
"nav.noNodes": "Нет узлов",
|
"nav.noNodes": "Нет узлов",
|
||||||
"nav.openFolder": "Открыть папку",
|
"nav.openFolder": "Открыть папку",
|
||||||
"nav.createInside": "Создать внутри",
|
"nav.createInside": "Создать внутри",
|
||||||
|
"nav.createNode": "Создать элемент",
|
||||||
|
"nav.moveToRoot": "Переместить в корень",
|
||||||
|
|
||||||
"template.folder": "Папка",
|
"template.folder": "Папка",
|
||||||
"template.project": "Проект",
|
"template.project": "Проект",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue