From 16ac54ff39876bcfbc1736d4ea1aa5e37bb45eba Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 29 Apr 2026 11:52:03 +0700 Subject: [PATCH] add export excel and pdf report penjualan --- go.mod | 1 + go.sum | 2 + .../controllers/repport.controller.go | 10 + .../controllers/repport.marketing.export.go | 271 ++++++++++ .../controllers/repport.marketing.pdf.go | 510 ++++++++++++++++++ 5 files changed, 794 insertions(+) create mode 100644 internal/modules/repports/controllers/repport.marketing.export.go create mode 100644 internal/modules/repports/controllers/repport.marketing.pdf.go diff --git a/go.mod b/go.mod index d0ffe677..d0c07314 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,7 @@ require ( github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-pdf/fpdf v0.9.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/go-cmp v0.6.0 // indirect diff --git a/go.sum b/go.sum index ab7d76b4..09c2a0df 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,8 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= +github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index a5de422f..b13260ea 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -246,6 +246,16 @@ func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error { if err != nil { return err } + + if isMarketingExcelExportRequest(ctx) { + return exportMarketingReportExcel(ctx, result) + } + + if isMarketingPdfExportRequest(ctx) { + meta := buildMarketingPdfMeta(query.StartDate, query.EndDate) + return exportMarketingReportPdf(ctx, result, meta) + } + total := dto.ToSummaryFromDTOItems(result) return ctx.Status(fiber.StatusOK). diff --git a/internal/modules/repports/controllers/repport.marketing.export.go b/internal/modules/repports/controllers/repport.marketing.export.go new file mode 100644 index 00000000..866590c4 --- /dev/null +++ b/internal/modules/repports/controllers/repport.marketing.export.go @@ -0,0 +1,271 @@ +package controller + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" + "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" +) + +const marketingReportExportSheetName = "Laporan Marketing Harian" + +func isMarketingExcelExportRequest(c *fiber.Ctx) bool { + return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") +} + +func exportMarketingReportExcel(c *fiber.Ctx, items []dto.RepportMarketingItemDTO) error { + content, err := buildMarketingReportWorkbook(items) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") + } + + filename := fmt.Sprintf("laporan_marketing_harian_%s.xlsx", time.Now().Format("20060102_150405")) + c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + return c.Status(fiber.StatusOK).Send(content) +} + +func buildMarketingReportWorkbook(items []dto.RepportMarketingItemDTO) ([]byte, error) { + file := excelize.NewFile() + defer file.Close() + + defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) + if defaultSheet != marketingReportExportSheetName { + if err := file.SetSheetName(defaultSheet, marketingReportExportSheetName); err != nil { + return nil, err + } + } + + if err := setMarketingReportColumns(file); err != nil { + return nil, err + } + if err := setMarketingReportHeaders(file); err != nil { + return nil, err + } + if err := setMarketingReportRows(file, items); err != nil { + return nil, err + } + + buffer, err := file.WriteToBuffer() + if err != nil { + return nil, err + } + + return buffer.Bytes(), nil +} + +func setMarketingReportColumns(file *excelize.File) error { + columnWidths := map[string]float64{ + "A": 10, + "B": 15, + "C": 18, + "D": 10, + "E": 25, + "F": 25, + "G": 15, + "H": 20, + "I": 15, + "J": 15, + "K": 20, + "L": 12, + "M": 20, + "N": 18, + "O": 18, + "P": 15, + "Q": 20, + "R": 20, + } + + sheet := marketingReportExportSheetName + for col, width := range columnWidths { + if err := file.SetColWidth(sheet, col, col, width); err != nil { + return err + } + } + + return nil +} + +func setMarketingReportHeaders(file *excelize.File) error { + sheet := marketingReportExportSheetName + headers := []string{ + "No", + "Tanggal Jual", + "Tanggal Realisasi", + "Aging", + "Gudang Fisik", + "Pelanggan", + "No. DO", + "Sales/Marketing", + "No. Polisi", + "Marketing Type", + "Produk", + "Kuantitas", + "Bobot Rata-Rata (Kg)", + "Bobot Total (Kg)", + "Harga Jual (Rp)", + "HPP (Rp)", + "HPP Amount (Rp)", + "Total (Rp)", + } + + for i, header := range headers { + colName, err := excelize.ColumnNumberToName(i + 1) + if err != nil { + return err + } + if err := file.SetCellValue(sheet, colName+"1", header); err != nil { + return err + } + } + + return nil +} + +func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingItemDTO) error { + sheet := marketingReportExportSheetName + summary := dto.ToSummaryFromDTOItems(items) + + for idx, item := range items { + row := strconv.Itoa(idx + 2) + + warehouseName := "-" + if item.Warehouse != nil { + warehouseName = safeMarketingExportText(item.Warehouse.Name) + } + + customerName := "-" + if item.Customer != nil { + customerName = safeMarketingExportText(item.Customer.Name) + } + + salesName := "-" + if item.Sales != nil { + salesName = safeMarketingExportText(item.Sales.Name) + } + + productName := "-" + if item.Product != nil { + productName = safeMarketingExportText(item.Product.Name) + } + + agingText := fmt.Sprintf("%d hari", item.AgingDays) + + values := []interface{}{ + idx + 1, + formatMarketingDate(item.SoDate), + formatMarketingDate(item.RealizationDate), + agingText, + warehouseName, + customerName, + safeMarketingExportText(item.DoNumber), + salesName, + safeMarketingExportText(item.VehicleNumber), + safeMarketingExportText(item.MarketingType), + productName, + item.Qty, + item.AverageWeightKg, + item.TotalWeightKg, + formatMarketingRupiah(item.SalesPricePerKg), + formatMarketingRupiah(item.HppPricePerKg), + formatMarketingRupiah(item.HppAmount), + formatMarketingRupiah(item.SalesAmount), + } + + for colIdx, val := range values { + colName, err := excelize.ColumnNumberToName(colIdx + 1) + if err != nil { + return err + } + if err := file.SetCellValue(sheet, colName+row, val); err != nil { + return err + } + } + } + + // Baris TOTAL + totalRow := strconv.Itoa(len(items) + 2) + if err := file.SetCellValue(sheet, "A"+totalRow, "TOTAL"); err != nil { + return err + } + + if summary != nil { + if err := file.SetCellValue(sheet, "L"+totalRow, summary.TotalQty); err != nil { + return err + } + if err := file.SetCellValue(sheet, "M"+totalRow, summary.AverageWeightKg); err != nil { + return err + } + if err := file.SetCellValue(sheet, "N"+totalRow, summary.TotalWeightKg); err != nil { + return err + } + if err := file.SetCellValue(sheet, "O"+totalRow, formatMarketingRupiah(summary.AverageSalesPrice)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "P"+totalRow, formatMarketingRupiah(summary.TotalHppPricePerKg)); err != nil { + return err + } + if err := file.SetCellValue(sheet, "Q"+totalRow, formatMarketingRupiah(float64(summary.TotalHppAmount))); err != nil { + return err + } + if err := file.SetCellValue(sheet, "R"+totalRow, formatMarketingRupiah(float64(summary.TotalSalesAmount))); err != nil { + return err + } + } + + return nil +} + +func formatMarketingDate(t time.Time) string { + if t.IsZero() { + return "-" + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err == nil { + t = t.In(location) + } + + return t.Format("02 Jan 2006") +} + +func safeMarketingExportText(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "-" + } + return trimmed +} + +// formatMarketingRupiah formats a float64 as Indonesian Rupiah string. +// e.g. 1000000 → "Rp 1.000.000" +func formatMarketingRupiah(value float64) string { + rounded := int64(math.Round(value)) + + negative := rounded < 0 + abs := rounded + if negative { + abs = -rounded + } + + numStr := strconv.FormatInt(abs, 10) + n := len(numStr) + + var b strings.Builder + for i, c := range numStr { + if i > 0 && (n-i)%3 == 0 { + b.WriteByte('.') + } + b.WriteRune(c) + } + + if negative { + return "Rp -" + b.String() + } + return "Rp " + b.String() +} diff --git a/internal/modules/repports/controllers/repport.marketing.pdf.go b/internal/modules/repports/controllers/repport.marketing.pdf.go new file mode 100644 index 00000000..8bac933f --- /dev/null +++ b/internal/modules/repports/controllers/repport.marketing.pdf.go @@ -0,0 +1,510 @@ +package controller + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/go-pdf/fpdf" + "github.com/gofiber/fiber/v2" + "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" +) + +// --------------------------------------------------------------------------- +// Trigger +// --------------------------------------------------------------------------- + +func isMarketingPdfExportRequest(c *fiber.Ctx) bool { + return strings.EqualFold(strings.TrimSpace(c.Query("export")), "pdf") +} + +// --------------------------------------------------------------------------- +// HTTP handler +// --------------------------------------------------------------------------- + +func exportMarketingReportPdf(c *fiber.Ctx, items []dto.RepportMarketingItemDTO, query marketingPdfQueryMeta) error { + content, err := buildMarketingReportPdf(items, query) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate pdf file") + } + + filename := fmt.Sprintf("laporan_marketing_harian_%s.pdf", time.Now().Format("20060102_150405")) + c.Set("Content-Type", "application/pdf") + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + return c.Status(fiber.StatusOK).Send(content) +} + +// marketingPdfQueryMeta holds display metadata for the PDF header. +type marketingPdfQueryMeta struct { + StartDate string // e.g. "01 April 2026" + EndDate string // e.g. "29 April 2026" + PrintedAt time.Time +} + +// --------------------------------------------------------------------------- +// Column definitions +// --------------------------------------------------------------------------- + +type pdfColumn struct { + header string + width float64 // mm + align string // "L", "C", "R" +} + +var marketingPdfColumns = []pdfColumn{ + {"No", 6, "C"}, + {"Tanggal Sales Order", 16, "C"}, + {"Tanggal Delivery Order", 16, "C"}, + {"Aging\n(Hari)", 9, "C"}, + {"Gudang Fisik", 20, "L"}, + {"Pelanggan", 20, "L"}, + {"Sales", 18, "L"}, + {"Produk", 16, "L"}, + {"Nomor DO", 14, "C"}, + {"Nomor Polisi", 14, "C"}, + {"Tipe\nMarketing", 14, "C"}, + {"Quantity", 13, "R"}, + {"Rata-Rata\n(Kg)", 13, "R"}, + {"Total Berat\n(Kg)", 14, "R"}, + {"Harga Jual\n(Rp)", 17, "R"}, + {"HPP\n(Rp)", 17, "R"}, + {"Total Jual\n(Rp)", 18, "R"}, + {"Total HPP\n(Rp)", 18, "R"}, +} + +// --------------------------------------------------------------------------- +// Colours +// --------------------------------------------------------------------------- + +const ( + headerR, headerG, headerB = 30, 64, 120 // dark blue header bg + headerTextR, headerTextG, headerTextB = 255, 255, 255 // white header text + rowAltR, rowAltG, rowAltB = 245, 247, 250 // alternating row bg + borderR, borderG, borderB = 200, 200, 200 // light border + + badgeTelurR, badgeTelurG, badgeTelurB = 59, 130, 246 // blue + badgeAyamR, badgeAyamG, badgeAyamB = 34, 197, 94 // green + badgeTradingR, badgeTradingG, badgeTradingB = 249, 115, 22 // orange + badgeDefaultR, badgeDefaultG, badgeDefaultB = 107, 114, 128 // gray +) + +// --------------------------------------------------------------------------- +// Workbook builder +// --------------------------------------------------------------------------- + +func buildMarketingReportPdf(items []dto.RepportMarketingItemDTO, meta marketingPdfQueryMeta) ([]byte, error) { + pdf := fpdf.New("L", "mm", "A4", "") + pdf.SetMargins(10, 12, 10) + pdf.SetAutoPageBreak(true, 12) + pdf.AddPage() + + // ---- title ---- + pdf.SetFont("Helvetica", "B", 14) + pdf.SetTextColor(30, 64, 120) + pdf.CellFormat(0, 8, "Laporan > Penjualan Harian", "", 1, "L", false, 0, "") + + // ---- subtitle ---- + pdf.SetFont("Helvetica", "", 8) + pdf.SetTextColor(80, 80, 80) + dateLabel := buildMarketingPdfDateLabel(meta) + printedAt := formatMarketingPdfDateTime(meta.PrintedAt) + pdf.CellFormat(0, 5, fmt.Sprintf("%s Dicetak: %s", dateLabel, printedAt), "", 1, "L", false, 0, "") + pdf.Ln(3) + + // ---- table ---- + writeMarketingPdfHeader(pdf) + writeMarketingPdfRows(pdf, items) + writeMarketingPdfTotal(pdf, items) + + return marshalMarketingPdf(pdf) +} + +func marshalMarketingPdf(pdf *fpdf.Fpdf) ([]byte, error) { + w := &pdfByteBuffer{} + err := pdf.Output(w) + if err != nil { + return nil, err + } + return w.Bytes(), nil +} + +// pdfByteBuffer implements io.Writer and accumulates bytes. +type pdfByteBuffer struct { + buf []byte +} + +func (b *pdfByteBuffer) Write(p []byte) (n int, err error) { + b.buf = append(b.buf, p...) + return len(p), nil +} + +func (b *pdfByteBuffer) Bytes() []byte { return b.buf } + +// --------------------------------------------------------------------------- +// Header row +// --------------------------------------------------------------------------- + +func writeMarketingPdfHeader(pdf *fpdf.Fpdf) { + pdf.SetFont("Helvetica", "B", 6.5) + pdf.SetFillColor(headerR, headerG, headerB) + pdf.SetTextColor(headerTextR, headerTextG, headerTextB) + pdf.SetDrawColor(borderR, borderG, borderB) + pdf.SetLineWidth(0.1) + + headerH := 9.0 // height of header row (mm) + + x0 := pdf.GetX() + y0 := pdf.GetY() + + for _, col := range marketingPdfColumns { + lines := strings.Split(col.header, "\n") + lineH := headerH / float64(len(lines)) + + x := pdf.GetX() + for li, line := range lines { + pdf.SetXY(x, y0+float64(li)*lineH) + pdf.CellFormat(col.width, lineH, line, "", 0, "C", true, 0, "") + } + pdf.SetXY(x+col.width, y0) + } + + _ = x0 + pdf.SetXY(10, y0+headerH) + pdf.Ln(0) +} + +// --------------------------------------------------------------------------- +// Data rows +// --------------------------------------------------------------------------- + +func writeMarketingPdfRows(pdf *fpdf.Fpdf, items []dto.RepportMarketingItemDTO) { + pdf.SetFont("Helvetica", "", 6) + pdf.SetDrawColor(borderR, borderG, borderB) + pdf.SetLineWidth(0.1) + + rowH := 6.0 + + for idx, item := range items { + // page break check + if pdf.GetY()+rowH > marketingPdfPageHeight(pdf)-12 { + pdf.AddPage() + writeMarketingPdfHeader(pdf) + pdf.SetFont("Helvetica", "", 6) + } + + // alternating bg + if idx%2 == 1 { + pdf.SetFillColor(rowAltR, rowAltG, rowAltB) + } else { + pdf.SetFillColor(255, 255, 255) + } + pdf.SetTextColor(40, 40, 40) + + y := pdf.GetY() + writeMarketingPdfRow(pdf, idx+1, item, rowH, y) + } +} + +func writeMarketingPdfRow(pdf *fpdf.Fpdf, no int, item dto.RepportMarketingItemDTO, h, y float64) { + fill := true // use the fill colour already set + + cols := marketingPdfColumns + x := 10.0 // left margin + + values := marketingPdfRowValues(no, item) + + for i, col := range cols { + pdf.SetXY(x, y) + + if i == 10 { // Tipe Marketing → badge + drawMarketingTypeBadge(pdf, x, y, col.width, h, item.MarketingType) + } else { + pdf.CellFormat(col.width, h, values[i], "1", 0, col.align, fill, 0, "") + } + + x += col.width + } + + pdf.SetXY(10, y+h) +} + +func marketingPdfRowValues(no int, item dto.RepportMarketingItemDTO) []string { + warehouse := "-" + if item.Warehouse != nil { + warehouse = safeMarketingExportText(item.Warehouse.Name) + } + customer := "-" + if item.Customer != nil { + customer = safeMarketingExportText(item.Customer.Name) + } + sales := "-" + if item.Sales != nil { + sales = safeMarketingExportText(item.Sales.Name) + } + product := "-" + if item.Product != nil { + product = safeMarketingExportText(item.Product.Name) + } + + return []string{ + strconv.Itoa(no), + formatMarketingDate(item.SoDate), + formatMarketingDate(item.RealizationDate), + strconv.Itoa(item.AgingDays), + warehouse, + customer, + sales, + product, + safeMarketingExportText(item.DoNumber), + safeMarketingExportText(item.VehicleNumber), + safeMarketingExportText(item.MarketingType), // index 10, overridden by badge + formatMarketingPdfNumber(item.Qty), + formatMarketingPdfDecimal(item.AverageWeightKg), + formatMarketingPdfDecimal(item.TotalWeightKg), + formatMarketingPdfRupiah(item.SalesPricePerKg), + formatMarketingPdfRupiah(item.HppPricePerKg), + formatMarketingPdfRupiah(item.SalesAmount), + formatMarketingPdfRupiah(item.HppAmount), + } +} + +// --------------------------------------------------------------------------- +// Total row +// --------------------------------------------------------------------------- + +func writeMarketingPdfTotal(pdf *fpdf.Fpdf, items []dto.RepportMarketingItemDTO) { + summary := dto.ToSummaryFromDTOItems(items) + if summary == nil { + return + } + + rowH := 6.5 + + if pdf.GetY()+rowH > marketingPdfPageHeight(pdf)-12 { + pdf.AddPage() + writeMarketingPdfHeader(pdf) + } + + pdf.SetFont("Helvetica", "B", 6) + pdf.SetFillColor(220, 230, 245) + pdf.SetTextColor(30, 64, 120) + pdf.SetDrawColor(borderR, borderG, borderB) + pdf.SetLineWidth(0.1) + + y := pdf.GetY() + x := 10.0 + + // merge first 11 cols (No … Tipe Marketing) into "TOTAL" label + mergedWidth := 0.0 + for i := 0; i < 11; i++ { + mergedWidth += marketingPdfColumns[i].width + } + pdf.SetXY(x, y) + pdf.CellFormat(mergedWidth, rowH, "TOTAL", "1", 0, "R", true, 0, "") + x += mergedWidth + + totals := []string{ + formatMarketingPdfNumber(float64(summary.TotalQty)), + formatMarketingPdfDecimal(summary.AverageWeightKg), + formatMarketingPdfDecimal(summary.TotalWeightKg), + formatMarketingPdfRupiah(summary.AverageSalesPrice), + formatMarketingPdfRupiah(summary.TotalHppPricePerKg), + formatMarketingPdfRupiah(float64(summary.TotalSalesAmount)), + formatMarketingPdfRupiah(float64(summary.TotalHppAmount)), + } + + for i, val := range totals { + col := marketingPdfColumns[11+i] + pdf.SetXY(x, y) + pdf.CellFormat(col.width, rowH, val, "1", 0, "R", true, 0, "") + x += col.width + } + + pdf.SetXY(10, y+rowH) +} + +// --------------------------------------------------------------------------- +// Badge drawer +// --------------------------------------------------------------------------- + +func drawMarketingTypeBadge(pdf *fpdf.Fpdf, x, y, w, h float64, marketingType string) { + lower := strings.ToLower(strings.TrimSpace(marketingType)) + + var r, g, b int + switch lower { + case "telur": + r, g, b = badgeTelurR, badgeTelurG, badgeTelurB + case "ayam": + r, g, b = badgeAyamR, badgeAyamG, badgeAyamB + case "trading": + r, g, b = badgeTradingR, badgeTradingG, badgeTradingB + default: + r, g, b = badgeDefaultR, badgeDefaultG, badgeDefaultB + } + + // badge background (slightly inset from the cell border) + padH := 1.2 + padV := 1.2 + bx := x + padH + by := y + padV + bw := w - padH*2 + bh := h - padV*2 + + pdf.SetFillColor(r, g, b) + pdf.SetDrawColor(r, g, b) + pdf.RoundedRect(bx, by, bw, bh, 1.5, "1234", "FD") + + // border of the cell itself (transparent bg, just border) + pdf.SetDrawColor(borderR, borderG, borderB) + pdf.SetFillColor(255, 255, 255) + pdf.SetXY(x, y) + pdf.CellFormat(w, h, "", "1", 0, "C", false, 0, "") + + // badge label + pdf.SetFont("Helvetica", "B", 5.5) + pdf.SetTextColor(255, 255, 255) + label := strings.Title(strings.ToLower(marketingType)) + if label == "" { + label = "-" + } + textW := pdf.GetStringWidth(label) + pdf.SetXY(bx+(bw-textW)/2, by+(bh-3)/2) + pdf.CellFormat(textW, 3, label, "", 0, "C", false, 0, "") + + // reset + pdf.SetFont("Helvetica", "", 6) + pdf.SetTextColor(40, 40, 40) + pdf.SetDrawColor(borderR, borderG, borderB) +} + +// --------------------------------------------------------------------------- +// Helpers — date/time +// --------------------------------------------------------------------------- + +// buildMarketingPdfMeta converts raw query string dates into display meta. +func buildMarketingPdfMeta(startDate, endDate string) marketingPdfQueryMeta { + loc, _ := time.LoadLocation("Asia/Jakarta") + + format := func(s string) string { + t, err := time.ParseInLocation("2006-01-02", s, loc) + if err != nil { + return s + } + return t.Format("02 January 2006") + } + + return marketingPdfQueryMeta{ + StartDate: format(startDate), + EndDate: format(endDate), + PrintedAt: time.Now(), + } +} + +func buildMarketingPdfDateLabel(meta marketingPdfQueryMeta) string { + if meta.StartDate != "" && meta.EndDate != "" { + return fmt.Sprintf("Tanggal: %s - %s", meta.StartDate, meta.EndDate) + } + if meta.StartDate != "" { + return fmt.Sprintf("Tanggal: %s", meta.StartDate) + } + if meta.EndDate != "" { + return fmt.Sprintf("Tanggal: %s", meta.EndDate) + } + return fmt.Sprintf("Tanggal: %s", time.Now().Format("02 January 2006")) +} + +func formatMarketingPdfDateTime(t time.Time) string { + if t.IsZero() { + t = time.Now() + } + loc, err := time.LoadLocation("Asia/Jakarta") + if err == nil { + t = t.In(loc) + } + return t.Format("02 Jan 2006 15:04") +} + +// --------------------------------------------------------------------------- +// Helpers — number formatting +// --------------------------------------------------------------------------- + +// formatMarketingPdfNumber formats a float64 as an integer with period-thousands separator. +// e.g. 7299 → "7.299" +func formatMarketingPdfNumber(v float64) string { + return formatMarketingPdfThousands(int64(math.Round(v))) +} + +// formatMarketingPdfDecimal formats a float64 with 2 decimal places (Indonesian locale). +// e.g. 452.54 → "452,54" +func formatMarketingPdfDecimal(v float64) string { + rounded := math.Round(v*100) / 100 + intPart := int64(rounded) + fracPart := int64(math.Round((rounded - float64(intPart)) * 100)) + if fracPart < 0 { + fracPart = -fracPart + } + return fmt.Sprintf("%s,%02d", formatMarketingPdfThousands(intPart), fracPart) +} + +// formatMarketingPdfRupiah formats a float64 as Rupiah with Indonesian locale. +// Drops trailing ",00" decimals for whole numbers. +// e.g. 25500 → "Rp 25.500" | 240896.94 → "Rp 240.896,94" +func formatMarketingPdfRupiah(v float64) string { + rounded := math.Round(v*100) / 100 + intPart := int64(rounded) + fracPart := int64(math.Round((rounded - float64(intPart)) * 100)) + if fracPart < 0 { + fracPart = -fracPart + } + + negative := intPart < 0 + absInt := intPart + if negative { + absInt = -intPart + } + + s := formatMarketingPdfThousands(absInt) + var result string + if fracPart == 0 { + result = "Rp " + s + } else { + result = fmt.Sprintf("Rp %s,%02d", s, fracPart) + } + if negative { + return "Rp -" + s + } + return result +} + +// marketingPdfPageHeight returns the page height in mm. +func marketingPdfPageHeight(pdf *fpdf.Fpdf) float64 { + _, h := pdf.GetPageSize() + return h +} + +// formatMarketingPdfThousands inserts period every 3 digits. +func formatMarketingPdfThousands(v int64) string { + negative := v < 0 + abs := v + if negative { + abs = -v + } + + numStr := strconv.FormatInt(abs, 10) + n := len(numStr) + + var b strings.Builder + for i, c := range numStr { + if i > 0 && (n-i)%3 == 0 { + b.WriteByte('.') + } + b.WriteRune(c) + } + + if negative { + return "-" + b.String() + } + return b.String() +}