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:
mirivlad 2026-06-03 10:56:13 +08:00
parent 5732264fc5
commit d34100e2ed
17 changed files with 512 additions and 32 deletions

View File

@ -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 {

View File

@ -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

View File

@ -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>

View File

@ -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; }

View File

@ -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',

View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -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.

View File

@ -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 {

View File

@ -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 ""
}

View File

@ -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 {