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
}
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) {
n, err := a.nodes.GetActive(nodeID)
if err != nil {

View File

@ -61,6 +61,11 @@ func (a *App) ExportWorklogMarkdown(dateFrom, dateTo, nodeID string, includeChil
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 {
switch s {
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;
}
</style>
<script type="module" crossorigin src="/assets/main-DU1CFPIY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-B4G76NhT.css">
<script type="module" crossorigin src="/assets/main-BYxU8Qbt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-C7UPiZtQ.css">
</head>
<body>
<div id="app"></div>

View File

@ -43,6 +43,9 @@
let journalBillableFilter = 'all'
let journalApproxFilter = 'all'
let journalFilteredNodeTitle = ''
let journalSearchQuery = ''
let journalSearchResults = []
let journalShowResults = false
let activityLoading = false
let caseActivity = []
let version = ''
@ -969,11 +972,13 @@
journalSummary = summary || null
suggestions = sugs || []
suggestionCount = suggestions.length
// Resolve node title if filtered by node.
if (journalNodeID && rows && rows.length > 0) {
journalFilteredNodeTitle = rows[0].nodeTitle
} else if (journalNodeID) {
try { const n = await wailsCall('GetNodeTitle', journalNodeID); journalFilteredNodeTitle = n } catch(e) { journalFilteredNodeTitle = '' }
// Resolve node title/path if filtered by node.
if (journalNodeID && !journalFilteredNodeTitle) {
if (rows && rows.length > 0 && rows[0].nodePath) {
journalFilteredNodeTitle = rows[0].nodePath
} else {
try { journalFilteredNodeTitle = await wailsCall('GetNodeTitle', journalNodeID) } catch(e) { journalFilteredNodeTitle = '' }
}
}
} catch (e) {
journalRows = []
@ -997,20 +1002,58 @@
} catch (e) { console.error(e) }
}
async function pickJournalNode() {
// Simple prompt — in future make a proper tree picker.
const id = prompt('Введите ID дела (nodeId):')
if (id && id.trim()) {
journalNodeID = id.trim()
journalIncludeChildren = true
await loadJournal()
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
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() {
journalNodeID = ''
journalIncludeChildren = false
journalFilteredNodeTitle = ''
journalSearchQuery = ''
journalSearchResults = []
journalShowResults = false
loadJournal()
}
@ -1687,11 +1730,24 @@
<input type="date" bind:value={journalDateTo} />
</label>
<label><span class="label-text">{t('journal.node')}</span>
<div class="journal-node-picker">
<input type="text" placeholder="nodeId" bind:value={journalNodeID} />
<button class="btn btn-sm" on:click={pickJournalNode}>{t('common.search')}</button>
{#if journalNodeID}
<button class="btn btn-sm" on:click={clearJournalNode}>✕</button>
<div class="journal-node-picker" style="position:relative">
{#if journalFilteredNodeTitle}
<button class="journal-selected-node" on:click={() => { journalSearchQuery = ''; journalFilteredNodeTitle = ''; clearJournalNode() }}>
{journalFilteredNodeTitle} <span class="journal-node-clear"></span>
</button>
{:else}
<input type="text" placeholder={t('journal.nodeSearch')} bind:value={journalSearchQuery}
on:input={onJournalSearchInput} on:blur={() => setTimeout(() => journalShowResults = false, 200)} />
{#if journalShowResults}
<div class="journal-search-dropdown">
{#each journalSearchResults as r}
<button class="journal-search-item" on:click={() => selectJournalNode(r)}>
<span class="journal-search-title">{r.title}</span>
<span class="journal-search-path">{r.path}</span>
</button>
{/each}
</div>
{/if}
{/if}
</div>
</label>
@ -1716,6 +1772,7 @@
<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={exportJournalMarkdown}>{t('journal.exportMarkdown')}</button>
<button class="btn btn-sm" on:click={exportJournalPDF}>PDF</button>
</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-min-cell { text-align: right; font-variant-numeric: tabular-nums; }
.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 { margin-bottom: 24px; }

View File

@ -146,6 +146,7 @@ export default {
'journal.byDay': 'By day',
'journal.byNode': 'By case',
'journal.includeChildren': 'Include subtasks',
'journal.nodeSearch': 'Search case...',
'suggest.title': 'Suggestions',
'suggest.apply': 'Log',

View File

@ -218,6 +218,7 @@ export default {
'journal.byDay': 'По дням',
'journal.byNode': 'По делам',
'journal.includeChildren': 'С подзадачами',
'journal.nodeSearch': 'Поиск дела...',
'suggest.title': 'Предложения на сегодня',
'suggest.apply': 'Записать',

2
go.mod
View File

@ -25,10 +25,12 @@ require (
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // 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/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // 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/valyala/bytebufferpool v1.0.0 // 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/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/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/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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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
}
// 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.
func (r *Repository) ListByParent(parentID string) ([]*Node, error) {
rows, err := r.db.Query(

Binary file not shown.

Binary file not shown.

View File

@ -13,13 +13,12 @@ import (
// ReportFilter specifies which worklog entries to include.
type ReportFilter struct {
DateFrom string // "2006-01-02" or "" for no lower bound
DateTo string // "2006-01-02" or "" for no upper bound
NodeID string // optional filter by node
IncludeChildren bool // include descendants of NodeID
Billable *bool // nil = all, true/false to filter
Approximate *bool // nil = all
Section string // filter by node section (requires JOIN)
DateFrom string // "2006-01-02" or "" for no lower bound
DateTo string // "2006-01-02" or "" for no upper bound
NodeID string // optional filter by node
IncludeChildren bool // include descendants of NodeID
Billable *bool // nil = all, true/false to filter
Approximate *bool // nil = all
}
// ReportRow is a single worklog entry with node info.
@ -186,9 +185,13 @@ func (s *Service) ListReport(f ReportFilter) ([]ReportRow, error) {
r.NodeTitle = title
}
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.
@ -214,20 +217,27 @@ func (s *Service) Summary(f ReportFilter) (*ReportSummary, error) {
if err != nil {
return nil, err
}
s.BuildReportPaths(rows)
sm := &ReportSummary{}
dayMap := make(map[string]int)
dayCount := make(map[string]int)
nodeMap := make(map[string]int)
nodeCount := make(map[string]int)
nodeLabel := make(map[string]string) // nodeID → NodePath
for _, r := range rows {
sm.TotalMinutes += r.Minutes
sm.TotalEntries++
dayMap[r.Date] += r.Minutes
dayCount[r.Date]++
nodeMap[r.NodeTitle] += r.Minutes
nodeCount[r.NodeTitle]++
nodeMap[r.NodeID] += r.Minutes
nodeCount[r.NodeID]++
label := r.NodePath
if label == "" {
label = r.NodeTitle
}
nodeLabel[r.NodeID] = label
}
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 {
return sm.ByDay[i].Label > sm.ByDay[j].Label // descending date
})
for node, min := range nodeMap {
sm.ByNode = append(sm.ByNode, SummaryGroup{Label: node, Minutes: min, Count: nodeCount[node]})
for nodeID, min := range nodeMap {
sm.ByNode = append(sm.ByNode, SummaryGroup{Label: nodeLabel[nodeID], Minutes: min, Count: nodeCount[nodeID]})
}
sort.Slice(sm.ByNode, func(i, j int) bool {
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 {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {