feat: node search picker, ByNode grouping fix, PDF export
- node picker: Search/Path on Repository, SearchNodes binding, debounced search dropdown showing title + full path - ByNode summary groups by nodeID with NodePath as label (not NodeTitle) - PDF export for worklog reports with embedded DejaVuSans fonts - ExportWorklogPDF binding + button on Journal screen - Removed unused Section field from ReportFilter - ListReport now calls BuildReportPaths so nodePath is available - go.sum: +github.com/signintech/gopdf dependency
This commit is contained in:
parent
5732264fc5
commit
d34100e2ed
|
|
@ -907,6 +907,34 @@ func (a *App) rollbackFileMoves(moves []fileMoveInfo) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SearchNodeResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SearchNodes(query string) ([]SearchNodeResult, error) {
|
||||||
|
nodes, err := a.nodes.Search(query, 20)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results := make([]SearchNodeResult, 0, len(nodes))
|
||||||
|
for i := range nodes {
|
||||||
|
if nodes[i].IsDeleted() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := a.nodes.Path(nodes[i].ID)
|
||||||
|
results = append(results, SearchNodeResult{
|
||||||
|
ID: nodes[i].ID,
|
||||||
|
Title: nodes[i].Title,
|
||||||
|
Path: path,
|
||||||
|
Type: nodes[i].Type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) OpenNodeFolder(nodeID string) (string, error) {
|
func (a *App) OpenNodeFolder(nodeID string) (string, error) {
|
||||||
n, err := a.nodes.GetActive(nodeID)
|
n, err := a.nodes.GetActive(nodeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,11 @@ func (a *App) ExportWorklogMarkdown(dateFrom, dateTo, nodeID string, includeChil
|
||||||
return a.worklog.ExportMarkdown(f)
|
return a.worklog.ExportMarkdown(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ExportWorklogPDF(dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) ([]byte, error) {
|
||||||
|
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
|
||||||
|
return a.worklog.ExportPDF(f)
|
||||||
|
}
|
||||||
|
|
||||||
func boolPtr(s string) *bool {
|
func boolPtr(s string) *bool {
|
||||||
switch s {
|
switch s {
|
||||||
case "yes":
|
case "yes":
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -16,8 +16,8 @@
|
||||||
background: #13131f;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-DU1CFPIY.js"></script>
|
<script type="module" crossorigin src="/assets/main-BYxU8Qbt.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-B4G76NhT.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-C7UPiZtQ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@
|
||||||
let journalBillableFilter = 'all'
|
let journalBillableFilter = 'all'
|
||||||
let journalApproxFilter = 'all'
|
let journalApproxFilter = 'all'
|
||||||
let journalFilteredNodeTitle = ''
|
let journalFilteredNodeTitle = ''
|
||||||
|
let journalSearchQuery = ''
|
||||||
|
let journalSearchResults = []
|
||||||
|
let journalShowResults = false
|
||||||
let activityLoading = false
|
let activityLoading = false
|
||||||
let caseActivity = []
|
let caseActivity = []
|
||||||
let version = ''
|
let version = ''
|
||||||
|
|
@ -969,11 +972,13 @@
|
||||||
journalSummary = summary || null
|
journalSummary = summary || null
|
||||||
suggestions = sugs || []
|
suggestions = sugs || []
|
||||||
suggestionCount = suggestions.length
|
suggestionCount = suggestions.length
|
||||||
// Resolve node title if filtered by node.
|
// Resolve node title/path if filtered by node.
|
||||||
if (journalNodeID && rows && rows.length > 0) {
|
if (journalNodeID && !journalFilteredNodeTitle) {
|
||||||
journalFilteredNodeTitle = rows[0].nodeTitle
|
if (rows && rows.length > 0 && rows[0].nodePath) {
|
||||||
} else if (journalNodeID) {
|
journalFilteredNodeTitle = rows[0].nodePath
|
||||||
try { const n = await wailsCall('GetNodeTitle', journalNodeID); journalFilteredNodeTitle = n } catch(e) { journalFilteredNodeTitle = '' }
|
} else {
|
||||||
|
try { journalFilteredNodeTitle = await wailsCall('GetNodeTitle', journalNodeID) } catch(e) { journalFilteredNodeTitle = '' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
journalRows = []
|
journalRows = []
|
||||||
|
|
@ -997,20 +1002,58 @@
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickJournalNode() {
|
async function exportJournalPDF() {
|
||||||
// Simple prompt — in future make a proper tree picker.
|
try {
|
||||||
const id = prompt('Введите ID дела (nodeId):')
|
const data = await wailsCall('ExportWorklogPDF', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter)
|
||||||
if (id && id.trim()) {
|
let pdfBytes = data
|
||||||
journalNodeID = id.trim()
|
if (typeof data === 'string') {
|
||||||
journalIncludeChildren = true
|
const bin = atob(data)
|
||||||
await loadJournal()
|
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
|
||||||
|
|
||||||
|
async function searchJournalNodes() {
|
||||||
|
const q = journalSearchQuery.trim()
|
||||||
|
if (!q || q.length < 2) {
|
||||||
|
journalSearchResults = []
|
||||||
|
journalShowResults = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
journalSearchResults = await wailsCall('SearchNodes', q) || []
|
||||||
|
journalShowResults = journalSearchResults.length > 0
|
||||||
|
} catch (e) { journalSearchResults = []; journalShowResults = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onJournalSearchInput() {
|
||||||
|
clearTimeout(journalSearchTimer)
|
||||||
|
journalSearchTimer = setTimeout(searchJournalNodes, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectJournalNode(result) {
|
||||||
|
journalNodeID = result.id
|
||||||
|
journalFilteredNodeTitle = result.path || result.title
|
||||||
|
journalIncludeChildren = true
|
||||||
|
journalSearchQuery = ''
|
||||||
|
journalSearchResults = []
|
||||||
|
journalShowResults = false
|
||||||
|
loadJournal()
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearJournalNode() {
|
function clearJournalNode() {
|
||||||
journalNodeID = ''
|
journalNodeID = ''
|
||||||
journalIncludeChildren = false
|
journalIncludeChildren = false
|
||||||
journalFilteredNodeTitle = ''
|
journalFilteredNodeTitle = ''
|
||||||
|
journalSearchQuery = ''
|
||||||
|
journalSearchResults = []
|
||||||
|
journalShowResults = false
|
||||||
loadJournal()
|
loadJournal()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1687,11 +1730,24 @@
|
||||||
<input type="date" bind:value={journalDateTo} />
|
<input type="date" bind:value={journalDateTo} />
|
||||||
</label>
|
</label>
|
||||||
<label><span class="label-text">{t('journal.node')}</span>
|
<label><span class="label-text">{t('journal.node')}</span>
|
||||||
<div class="journal-node-picker">
|
<div class="journal-node-picker" style="position:relative">
|
||||||
<input type="text" placeholder="nodeId" bind:value={journalNodeID} />
|
{#if journalFilteredNodeTitle}
|
||||||
<button class="btn btn-sm" on:click={pickJournalNode}>{t('common.search')}</button>
|
<button class="journal-selected-node" on:click={() => { journalSearchQuery = ''; journalFilteredNodeTitle = ''; clearJournalNode() }}>
|
||||||
{#if journalNodeID}
|
{journalFilteredNodeTitle} <span class="journal-node-clear">✕</span>
|
||||||
<button class="btn btn-sm" on:click={clearJournalNode}>✕</button>
|
</button>
|
||||||
|
{:else}
|
||||||
|
<input type="text" placeholder={t('journal.nodeSearch')} bind:value={journalSearchQuery}
|
||||||
|
on:input={onJournalSearchInput} on:blur={() => setTimeout(() => journalShowResults = false, 200)} />
|
||||||
|
{#if journalShowResults}
|
||||||
|
<div class="journal-search-dropdown">
|
||||||
|
{#each journalSearchResults as r}
|
||||||
|
<button class="journal-search-item" on:click={() => selectJournalNode(r)}>
|
||||||
|
<span class="journal-search-title">{r.title}</span>
|
||||||
|
<span class="journal-search-path">{r.path}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -1716,6 +1772,7 @@
|
||||||
<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={exportJournalCSV}>{t('journal.exportCSV')}</button>
|
||||||
<button class="btn btn-sm" on:click={exportJournalMarkdown}>{t('journal.exportMarkdown')}</button>
|
<button class="btn btn-sm" on:click={exportJournalMarkdown}>{t('journal.exportMarkdown')}</button>
|
||||||
|
<button class="btn btn-sm" on:click={exportJournalPDF}>PDF</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -2318,6 +2375,15 @@
|
||||||
.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: #8888a0; 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: #8888a0; 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-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:hover { background: #2a2a4a; }
|
||||||
|
.journal-search-title { display: block; font-weight: 500; }
|
||||||
|
.journal-search-path { display: block; font-size: 11px; color: #8888a0; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.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-node-clear { color: #8888a0; font-size: 14px; margin-left: 4px; }
|
||||||
|
|
||||||
/* Today suggestions */
|
/* Today suggestions */
|
||||||
.today-suggestions { margin-bottom: 24px; }
|
.today-suggestions { margin-bottom: 24px; }
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,7 @@ export default {
|
||||||
'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...',
|
||||||
|
|
||||||
'suggest.title': 'Suggestions',
|
'suggest.title': 'Suggestions',
|
||||||
'suggest.apply': 'Log',
|
'suggest.apply': 'Log',
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,7 @@ export default {
|
||||||
'journal.byDay': 'По дням',
|
'journal.byDay': 'По дням',
|
||||||
'journal.byNode': 'По делам',
|
'journal.byNode': 'По делам',
|
||||||
'journal.includeChildren': 'С подзадачами',
|
'journal.includeChildren': 'С подзадачами',
|
||||||
|
'journal.nodeSearch': 'Поиск дела...',
|
||||||
|
|
||||||
'suggest.title': 'Предложения на сегодня',
|
'suggest.title': 'Предложения на сегодня',
|
||||||
'suggest.apply': 'Записать',
|
'suggest.apply': 'Записать',
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -25,10 +25,12 @@ require (
|
||||||
github.com/leaanthony/u v1.1.1 // indirect
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 // indirect
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/samber/lo v1.49.1 // indirect
|
github.com/samber/lo v1.49.1 // indirect
|
||||||
|
github.com/signintech/gopdf v0.36.1 // indirect
|
||||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
|
|
||||||
5
go.sum
5
go.sum
|
|
@ -42,8 +42,11 @@ github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCK
|
||||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
|
github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 h1:zyWXQ6vu27ETMpYsEMAsisQ+GqJ4e1TPvSNfdOPF0no=
|
||||||
|
github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
|
@ -53,6 +56,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||||
|
github.com/signintech/gopdf v0.36.1 h1:cGpvEKvvqCV+ZXB9R2SQoWgouW91JpwsgoQEhLxIdp0=
|
||||||
|
github.com/signintech/gopdf v0.36.1/go.mod h1:d23eO35GpEliSrF22eJ4bsM3wVeQJTjXTHq5x5qGKjA=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,46 @@ func (r *Repository) CountChildren(parentID string, types ...string) (int, error
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 ?`
|
||||||
|
rows, err := r.db.Query(q, "%"+query+"%", limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanNodes(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path builds the full path from root to the given node by walking parent_id chain.
|
||||||
|
// Returns "title1 > title2 > ... > nodetitle". Returns empty string on error.
|
||||||
|
func (r *Repository) Path(nodeID string) string {
|
||||||
|
var segments []string
|
||||||
|
currentID := nodeID
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
if currentID == "" || seen[currentID] {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
seen[currentID] = true
|
||||||
|
var title string
|
||||||
|
var parent sql.NullString
|
||||||
|
err := r.db.QueryRow(
|
||||||
|
`SELECT title, parent_id FROM nodes WHERE id = ?`, currentID,
|
||||||
|
).Scan(&title, &parent)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
segments = append([]string{title}, segments...)
|
||||||
|
if !parent.Valid {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
currentID = parent.String
|
||||||
|
}
|
||||||
|
return strings.Join(segments, " > ")
|
||||||
|
}
|
||||||
|
|
||||||
// ListByParent returns children as *Node pointers. parentID must not be empty.
|
// ListByParent returns children as *Node pointers. parentID must not be empty.
|
||||||
func (r *Repository) ListByParent(parentID string) ([]*Node, error) {
|
func (r *Repository) ListByParent(parentID string) ([]*Node, error) {
|
||||||
rows, err := r.db.Query(
|
rows, err := r.db.Query(
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -19,7 +19,6 @@ type ReportFilter struct {
|
||||||
IncludeChildren bool // include descendants of NodeID
|
IncludeChildren bool // include descendants of NodeID
|
||||||
Billable *bool // nil = all, true/false to filter
|
Billable *bool // nil = all, true/false to filter
|
||||||
Approximate *bool // nil = all
|
Approximate *bool // nil = all
|
||||||
Section string // filter by node section (requires JOIN)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReportRow is a single worklog entry with node info.
|
// ReportRow is a single worklog entry with node info.
|
||||||
|
|
@ -188,7 +187,11 @@ func (s *Service) ListReport(f ReportFilter) ([]ReportRow, error) {
|
||||||
|
|
||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.BuildReportPaths(out)
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildReportPaths enriches report rows with node paths.
|
// BuildReportPaths enriches report rows with node paths.
|
||||||
|
|
@ -214,20 +217,27 @@ func (s *Service) Summary(f ReportFilter) (*ReportSummary, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
s.BuildReportPaths(rows)
|
||||||
|
|
||||||
sm := &ReportSummary{}
|
sm := &ReportSummary{}
|
||||||
dayMap := make(map[string]int)
|
dayMap := make(map[string]int)
|
||||||
dayCount := make(map[string]int)
|
dayCount := make(map[string]int)
|
||||||
nodeMap := make(map[string]int)
|
nodeMap := make(map[string]int)
|
||||||
nodeCount := make(map[string]int)
|
nodeCount := make(map[string]int)
|
||||||
|
nodeLabel := make(map[string]string) // nodeID → NodePath
|
||||||
|
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
sm.TotalMinutes += r.Minutes
|
sm.TotalMinutes += r.Minutes
|
||||||
sm.TotalEntries++
|
sm.TotalEntries++
|
||||||
dayMap[r.Date] += r.Minutes
|
dayMap[r.Date] += r.Minutes
|
||||||
dayCount[r.Date]++
|
dayCount[r.Date]++
|
||||||
nodeMap[r.NodeTitle] += r.Minutes
|
nodeMap[r.NodeID] += r.Minutes
|
||||||
nodeCount[r.NodeTitle]++
|
nodeCount[r.NodeID]++
|
||||||
|
label := r.NodePath
|
||||||
|
if label == "" {
|
||||||
|
label = r.NodeTitle
|
||||||
|
}
|
||||||
|
nodeLabel[r.NodeID] = label
|
||||||
}
|
}
|
||||||
|
|
||||||
for day, min := range dayMap {
|
for day, min := range dayMap {
|
||||||
|
|
@ -236,8 +246,8 @@ func (s *Service) Summary(f ReportFilter) (*ReportSummary, error) {
|
||||||
sort.Slice(sm.ByDay, func(i, j int) bool {
|
sort.Slice(sm.ByDay, func(i, j int) bool {
|
||||||
return sm.ByDay[i].Label > sm.ByDay[j].Label // descending date
|
return sm.ByDay[i].Label > sm.ByDay[j].Label // descending date
|
||||||
})
|
})
|
||||||
for node, min := range nodeMap {
|
for nodeID, min := range nodeMap {
|
||||||
sm.ByNode = append(sm.ByNode, SummaryGroup{Label: node, Minutes: min, Count: nodeCount[node]})
|
sm.ByNode = append(sm.ByNode, SummaryGroup{Label: nodeLabel[nodeID], Minutes: min, Count: nodeCount[nodeID]})
|
||||||
}
|
}
|
||||||
sort.Slice(sm.ByNode, func(i, j int) bool {
|
sort.Slice(sm.ByNode, func(i, j int) bool {
|
||||||
if sm.ByNode[i].Minutes != sm.ByNode[j].Minutes {
|
if sm.ByNode[i].Minutes != sm.ByNode[j].Minutes {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
package worklog
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/signintech/gopdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed fonts/DejaVuSans.ttf
|
||||||
|
var dejaVuSansTTF []byte
|
||||||
|
|
||||||
|
//go:embed fonts/DejaVuSans-Bold.ttf
|
||||||
|
var dejaVuSansBoldTTF []byte
|
||||||
|
|
||||||
|
const (
|
||||||
|
pageW = 210.0
|
||||||
|
pageH = 297.0
|
||||||
|
leftMar = 20.0
|
||||||
|
rightMar = 20.0
|
||||||
|
topMar = 22.0
|
||||||
|
bottomMar = 18.0
|
||||||
|
colW = pageW - leftMar - rightMar
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExportPDF returns a PDF report as bytes.
|
||||||
|
func (s *Service) ExportPDF(f ReportFilter) ([]byte, error) {
|
||||||
|
rows, err := s.ListReport(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.BuildReportPaths(rows)
|
||||||
|
sm, _ := s.Summary(f)
|
||||||
|
|
||||||
|
pdf := &gopdf.GoPdf{}
|
||||||
|
pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
|
||||||
|
|
||||||
|
if err := pdf.AddTTFFontByReader("DejaVu", bytes.NewReader(dejaVuSansTTF)); err != nil {
|
||||||
|
return nil, fmt.Errorf("load font: %w", err)
|
||||||
|
}
|
||||||
|
if err := pdf.AddTTFFontByReader("DejaVuBold", bytes.NewReader(dejaVuSansBoldTTF)); err != nil {
|
||||||
|
return nil, fmt.Errorf("load bold font: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.AddPage()
|
||||||
|
y := topMar
|
||||||
|
|
||||||
|
y = writeTitle(pdf, f, y)
|
||||||
|
y = writeSummary(pdf, sm, y)
|
||||||
|
y = writeTable(pdf, rows, y)
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
pdf.SetFont("DejaVu", "", 8)
|
||||||
|
pdf.SetTextColor(120, 120, 140)
|
||||||
|
now := time.Now()
|
||||||
|
dateStr := fmt.Sprintf("%d-%02d-%02d %02d:%02d", now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute())
|
||||||
|
pdf.SetXY(leftMar, pageH-bottomMar)
|
||||||
|
textWidth(pdf, "Сгенерировано: "+dateStr)
|
||||||
|
pdf.SetTextColor(0, 0, 0)
|
||||||
|
|
||||||
|
return pdf.GetBytesPdfReturnErr()
|
||||||
|
}
|
||||||
|
|
||||||
|
func textWidth(pdf *gopdf.GoPdf, text string) {
|
||||||
|
_ = pdf.Cell(nil, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTitle(pdf *gopdf.GoPdf, f ReportFilter, y float64) float64 {
|
||||||
|
pdf.SetFont("DejaVuBold", "", 16)
|
||||||
|
pdf.SetXY(leftMar, y)
|
||||||
|
textWidth(pdf, "Отчёт по времени")
|
||||||
|
y += 9
|
||||||
|
|
||||||
|
pdf.SetFont("DejaVu", "", 10)
|
||||||
|
pdf.SetXY(leftMar, y)
|
||||||
|
period := "Период: весь"
|
||||||
|
if f.DateFrom != "" || f.DateTo != "" {
|
||||||
|
period = fmt.Sprintf("Период: %s — %s", f.DateFrom, f.DateTo)
|
||||||
|
}
|
||||||
|
textWidth(pdf, period)
|
||||||
|
y += 6
|
||||||
|
|
||||||
|
if f.NodeID != "" {
|
||||||
|
pdf.SetXY(leftMar, y)
|
||||||
|
textWidth(pdf, "Фильтр по узлу: "+f.NodeID)
|
||||||
|
y += 6
|
||||||
|
}
|
||||||
|
y += 3
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSummary(pdf *gopdf.GoPdf, sm *ReportSummary, y float64) float64 {
|
||||||
|
if sm == nil {
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.SetFont("DejaVuBold", "", 12)
|
||||||
|
pdf.SetXY(leftMar, y)
|
||||||
|
textWidth(pdf, fmt.Sprintf("Итого: %d ч %d мин (%d записей)",
|
||||||
|
sm.TotalMinutes/60, sm.TotalMinutes%60, sm.TotalEntries))
|
||||||
|
y += 8
|
||||||
|
|
||||||
|
if len(sm.ByDay) > 0 {
|
||||||
|
y = checkPageBreak(pdf, y, 4.5*float64(len(sm.ByDay))+8)
|
||||||
|
pdf.SetFont("DejaVuBold", "", 10)
|
||||||
|
pdf.SetXY(leftMar, y)
|
||||||
|
textWidth(pdf, "По дням:")
|
||||||
|
y += 5
|
||||||
|
pdf.SetFont("DejaVu", "", 9)
|
||||||
|
for _, d := range sm.ByDay {
|
||||||
|
pdf.SetXY(leftMar+5, y)
|
||||||
|
textWidth(pdf, fmt.Sprintf("%s — %d ч %d мин (%d зап.)",
|
||||||
|
d.Label, d.Minutes/60, d.Minutes%60, d.Count))
|
||||||
|
y += 4.5
|
||||||
|
}
|
||||||
|
y += 3
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sm.ByNode) > 0 {
|
||||||
|
y = checkPageBreak(pdf, y, 4.5*float64(len(sm.ByNode))+8)
|
||||||
|
pdf.SetFont("DejaVuBold", "", 10)
|
||||||
|
pdf.SetXY(leftMar, y)
|
||||||
|
textWidth(pdf, "По делам:")
|
||||||
|
y += 5
|
||||||
|
pdf.SetFont("DejaVu", "", 8)
|
||||||
|
for _, n := range sm.ByNode {
|
||||||
|
pdf.SetXY(leftMar+5, y)
|
||||||
|
label := truncate(n.Label, 90)
|
||||||
|
textWidth(pdf, fmt.Sprintf("%s — %d ч %d мин (%d зап.)",
|
||||||
|
label, n.Minutes/60, n.Minutes%60, n.Count))
|
||||||
|
y += 4.5
|
||||||
|
}
|
||||||
|
y += 5
|
||||||
|
}
|
||||||
|
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTable(pdf *gopdf.GoPdf, rows []ReportRow, y float64) float64 {
|
||||||
|
type colDef struct {
|
||||||
|
name string
|
||||||
|
width float64
|
||||||
|
header string
|
||||||
|
}
|
||||||
|
cols := []colDef{
|
||||||
|
{"date", 18, "Дата"},
|
||||||
|
{"node", 28, "Дело"},
|
||||||
|
{"path", 28, "Путь"},
|
||||||
|
{"summary", 60, "Описание"},
|
||||||
|
{"min", 12, "Мин"},
|
||||||
|
{"bill", 10, "Опл"},
|
||||||
|
{"approx", 10, "~"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale columns to fit
|
||||||
|
total := 0.0
|
||||||
|
for _, c := range cols {
|
||||||
|
total += c.width
|
||||||
|
}
|
||||||
|
if total != colW {
|
||||||
|
scale := colW / total
|
||||||
|
for i := range cols {
|
||||||
|
cols[i].width *= scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
y = checkPageBreak(pdf, y, 10)
|
||||||
|
pdf.SetFont("DejaVu", "", 11)
|
||||||
|
pdf.SetXY(leftMar, y)
|
||||||
|
textWidth(pdf, "Записей за период нет")
|
||||||
|
return y + 6
|
||||||
|
}
|
||||||
|
|
||||||
|
y = checkPageBreak(pdf, y, 20)
|
||||||
|
|
||||||
|
// Header
|
||||||
|
pdf.SetFont("DejaVuBold", "", 7.5)
|
||||||
|
pdf.SetFillColor(55, 55, 90)
|
||||||
|
pdf.SetTextColor(230, 230, 245)
|
||||||
|
x := leftMar
|
||||||
|
for _, c := range cols {
|
||||||
|
pdf.RectFromUpperLeftWithStyle(x, y, c.width, 6, "F")
|
||||||
|
pdf.SetXY(x+0.5, y+0.5)
|
||||||
|
textWidth(pdf, c.header)
|
||||||
|
x += c.width
|
||||||
|
}
|
||||||
|
y += 6
|
||||||
|
pdf.SetTextColor(0, 0, 0)
|
||||||
|
|
||||||
|
// Data
|
||||||
|
pdf.SetFont("DejaVu", "", 7)
|
||||||
|
rowH := 5.0
|
||||||
|
for i, r := range rows {
|
||||||
|
if y+rowH > pageH-bottomMar-5 {
|
||||||
|
pdf.AddPage()
|
||||||
|
y = topMar
|
||||||
|
// Repeat header on new page
|
||||||
|
pdf.SetFont("DejaVuBold", "", 7.5)
|
||||||
|
pdf.SetFillColor(55, 55, 90)
|
||||||
|
pdf.SetTextColor(230, 230, 245)
|
||||||
|
x = leftMar
|
||||||
|
for _, c := range cols {
|
||||||
|
pdf.RectFromUpperLeftWithStyle(x, y, c.width, 6, "F")
|
||||||
|
pdf.SetXY(x+0.5, y+0.5)
|
||||||
|
textWidth(pdf, c.header)
|
||||||
|
x += c.width
|
||||||
|
}
|
||||||
|
y += 6
|
||||||
|
pdf.SetTextColor(0, 0, 0)
|
||||||
|
pdf.SetFont("DejaVu", "", 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
x = leftMar
|
||||||
|
vals := []string{
|
||||||
|
r.Date,
|
||||||
|
truncate(r.NodeTitle, 28),
|
||||||
|
truncate(r.NodePath, 35),
|
||||||
|
truncate(r.Summary, 48),
|
||||||
|
fmt.Sprintf("%d", r.Minutes),
|
||||||
|
boolMark(r.Billable),
|
||||||
|
boolMark(r.Approximate),
|
||||||
|
}
|
||||||
|
|
||||||
|
if i%2 == 0 {
|
||||||
|
pdf.SetFillColor(248, 248, 252)
|
||||||
|
} else {
|
||||||
|
pdf.SetFillColor(255, 255, 255)
|
||||||
|
}
|
||||||
|
pdf.RectFromUpperLeftWithStyle(x, y, colW, rowH, "F")
|
||||||
|
|
||||||
|
for ci, c := range cols {
|
||||||
|
pdf.SetXY(x+0.5, y+0.5)
|
||||||
|
textWidth(pdf, vals[ci])
|
||||||
|
x += c.width
|
||||||
|
}
|
||||||
|
y += rowH
|
||||||
|
}
|
||||||
|
|
||||||
|
return y + 3
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPageBreak(pdf *gopdf.GoPdf, y, needed float64) float64 {
|
||||||
|
if y+needed > pageH-bottomMar-5 {
|
||||||
|
pdf.AddPage()
|
||||||
|
return topMar
|
||||||
|
}
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, max int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:max]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolMark(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "✓"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
@ -154,6 +154,55 @@ func TestReport(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExportPDF(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
svc := NewService(db)
|
||||||
|
|
||||||
|
// Insert a node so the JOIN in the report query works.
|
||||||
|
_, err := db.Exec(`INSERT INTO nodes(id,type,title,slug,created_at,updated_at) VALUES(?,?,?,?,?,?)`,
|
||||||
|
"node-pdf-1", "case", "Тестовое дело (PDF)", "test-pdf-case",
|
||||||
|
time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.Add("node-pdf-1", "Работа над отчётом", "Подготовка PDF экспорта", 90, true, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
_, err = svc.Add("node-pdf-1", "Правки и доработки", "Длинное описание с кириллицей: тестирование генерации PDF отчёта с длинными строками и специальными символами | pipe | ещё", 45, false, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := svc.ExportPDF(ReportFilter{
|
||||||
|
DateFrom: "",
|
||||||
|
DateTo: "",
|
||||||
|
NodeID: "node-pdf-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportPDF: %v", err)
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
t.Fatal("ExportPDF returned empty bytes")
|
||||||
|
}
|
||||||
|
if string(data[:4]) != "%PDF" {
|
||||||
|
t.Errorf("ExportPDF missing PDF magic header, got %q", string(data[:4]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test empty filter returns valid PDF
|
||||||
|
emptyData, err := svc.ExportPDF(ReportFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExportPDF empty: %v", err)
|
||||||
|
}
|
||||||
|
if len(emptyData) == 0 {
|
||||||
|
t.Fatal("ExportPDF empty returned empty bytes")
|
||||||
|
}
|
||||||
|
if string(emptyData[:4]) != "%PDF" {
|
||||||
|
t.Errorf("ExportPDF empty missing PDF magic header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func contains(s, sub string) bool {
|
func contains(s, sub string) bool {
|
||||||
for i := 0; i+len(sub) <= len(s); i++ {
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
if s[i:i+len(sub)] == sub {
|
if s[i:i+len(sub)] == sub {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue