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:
2026-06-03 10:56:13 +08:00
parent 5732264fc5
commit d34100e2ed
17 changed files with 512 additions and 32 deletions
+40
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.
+23 -13
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 {
+266
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 ""
}
+49
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 {