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:
@@ -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.
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
|
||||
Reference in New Issue
Block a user