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" ) func isCustomerPaymentExcelExportRequest(c *fiber.Ctx) bool { return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") } func isCustomerPaymentExcelAllExportRequest(c *fiber.Ctx) bool { return strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel-all") } func exportCustomerPaymentExcel(c *fiber.Ctx, items []dto.CustomerPaymentReportItem) error { content, err := buildCustomerPaymentWorkbook(items) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") } filename := fmt.Sprintf("laporan-kontrol-pembayaran-customer-%s.xlsx", time.Now().Format("2006-01-02-1504")) 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 exportCustomerPaymentExcelAll(c *fiber.Ctx, items []dto.CustomerPaymentReportItem) error { content, err := buildCustomerPaymentAllWorkbook(items) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") } filename := fmt.Sprintf("laporan-kontrol-pembayaran-customer-all-%s.xlsx", time.Now().Format("2006-01-02-1504")) 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 buildCustomerPaymentWorkbook(items []dto.CustomerPaymentReportItem) ([]byte, error) { file := excelize.NewFile() defer file.Close() defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) if len(items) == 0 { if err := writeCustomerPaymentSheet(file, defaultSheet, dto.CustomerPaymentReportItem{}); err != nil { return nil, err } buf, err := file.WriteToBuffer() if err != nil { return nil, err } return buf.Bytes(), nil } for idx, item := range items { sheetName := sanitizeCustomerPaymentSheetName(customerPaymentName(item)) if sheetName == "" { sheetName = fmt.Sprintf("Customer %d", idx+1) } if idx == 0 { if defaultSheet != sheetName { if err := file.SetSheetName(defaultSheet, sheetName); err != nil { return nil, err } } } else { if _, err := file.NewSheet(sheetName); err != nil { return nil, err } } if err := writeCustomerPaymentSheet(file, sheetName, item); err != nil { return nil, err } } buf, err := file.WriteToBuffer() if err != nil { return nil, err } return buf.Bytes(), nil } func buildCustomerPaymentAllWorkbook(items []dto.CustomerPaymentReportItem) ([]byte, error) { file := excelize.NewFile() defer file.Close() const sheet = "Kontrol Pembayaran Customer" defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) if defaultSheet != sheet { if err := file.SetSheetName(defaultSheet, sheet); err != nil { return nil, err } } if err := setCustomerPaymentAllColumns(file, sheet); err != nil { return nil, err } if err := setCustomerPaymentAllHeaders(file, sheet); err != nil { return nil, err } if err := writeCustomerPaymentAllRows(file, sheet, items); err != nil { return nil, err } if err := file.SetPanes(sheet, &excelize.Panes{ Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft", }); err != nil { return nil, err } buf, err := file.WriteToBuffer() if err != nil { return nil, err } return buf.Bytes(), nil } var cpSheetHeaders = []string{ "No", "Tanggal DO/Bayar", "Tanggal Realisasi", "Aging", "Referensi", "Nomor Polisi", "Ekor/Qty", "Berat (Kg)", "AVG", "Harga/Unit (Rp)", "Harga Akhir (Rp)", "Total (Rp)", "Pembayaran (Rp)", "Saldo Piutang (Rp)", "Keterangan", "Pengambilan", "Sales/Marketing", } var cpAllSheetHeaders = append([]string{"Customer"}, cpSheetHeaders...) var cpSheetColumnWidths = map[string]float64{ "A": 5, "B": 15, "C": 12, "D": 8, "E": 12, "F": 15, "G": 10, "H": 12, "I": 10, "J": 15, "K": 15, "L": 15, "M": 15, "N": 15, "O": 20, "P": 15, "Q": 20, } var cpAllSheetColumnWidths = map[string]float64{ "A": 22, "B": 6, "C": 15, "D": 15, "E": 8, "F": 12, "G": 15, "H": 10, "I": 12, "J": 10, "K": 15, "L": 15, "M": 15, "N": 15, "O": 15, "P": 20, "Q": 15, "R": 20, } func writeCustomerPaymentSheet(file *excelize.File, sheet string, item dto.CustomerPaymentReportItem) error { for col, width := range cpSheetColumnWidths { if err := file.SetColWidth(sheet, col, col, width); err != nil { return err } } // Row 1: headers for i, h := range cpSheetHeaders { col, _ := excelize.ColumnNumberToName(i + 1) if err := file.SetCellValue(sheet, col+"1", h); err != nil { return err } } redStyle, err := file.NewStyle(&excelize.Style{ Font: &excelize.Font{Color: "FF0000"}, }) if err != nil { return err } // Row 2: saldo awal initialFormatted := formatCPRupiah(item.InitialBalance) if err := file.SetCellValue(sheet, "N2", initialFormatted); err != nil { return err } if item.InitialBalance < 0 { if err := file.SetCellStyle(sheet, "N2", "N2", redStyle); err != nil { return err } } // Rows 3+: data rows for i, row := range item.Rows { rowNum := i + 3 rowStr := fmt.Sprintf("%d", rowNum) cells := customerPaymentRowCells(row, i+1) for colIdx, val := range cells { col, _ := excelize.ColumnNumberToName(colIdx + 1) if err := file.SetCellValue(sheet, col+rowStr, val); err != nil { return err } } if row.AccountsReceivable < 0 { if err := file.SetCellStyle(sheet, "N"+rowStr, "N"+rowStr, redStyle); err != nil { return err } } } // Total row totalRowNum := len(item.Rows) + 3 totalRowStr := fmt.Sprintf("%d", totalRowNum) totalCells := map[string]string{ "A": "Total", "G": formatCPIDInteger(item.Summary.TotalQty), "H": formatCPIDInteger(item.Summary.TotalWeight), "K": formatCPRupiah(item.Summary.TotalFinalAmount), "L": formatCPRupiah(item.Summary.TotalGrandAmount), "M": formatCPRupiah(item.Summary.TotalPayment), "N": formatCPRupiah(item.Summary.TotalAccountsReceivable), } for col, val := range totalCells { if err := file.SetCellValue(sheet, col+totalRowStr, val); err != nil { return err } } if item.Summary.TotalAccountsReceivable < 0 { if err := file.SetCellStyle(sheet, "N"+totalRowStr, "N"+totalRowStr, redStyle); err != nil { return err } } return nil } func setCustomerPaymentAllColumns(file *excelize.File, sheet string) error { for col, width := range cpAllSheetColumnWidths { if err := file.SetColWidth(sheet, col, col, width); err != nil { return err } } return file.SetRowHeight(sheet, 1, 24) } func setCustomerPaymentAllHeaders(file *excelize.File, sheet string) error { borderStyle := []excelize.Border{ {Type: "left", Color: "000000", Style: 1}, {Type: "top", Color: "000000", Style: 1}, {Type: "bottom", Color: "000000", Style: 1}, {Type: "right", Color: "000000", Style: 1}, } headerStyle, err := file.NewStyle(&excelize.Style{ Font: &excelize.Font{Bold: true, Color: "FFFFFF", Family: "Arial", Size: 10}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}}, Alignment: &excelize.Alignment{ Horizontal: "center", Vertical: "center", WrapText: true, }, Border: borderStyle, }) if err != nil { return err } for i, h := range cpAllSheetHeaders { col, _ := excelize.ColumnNumberToName(i + 1) if err := file.SetCellValue(sheet, col+"1", h); err != nil { return err } } lastCol, _ := excelize.ColumnNumberToName(len(cpAllSheetHeaders)) return file.SetCellStyle(sheet, "A1", lastCol+"1", headerStyle) } func writeCustomerPaymentAllRows(file *excelize.File, sheet string, items []dto.CustomerPaymentReportItem) error { borderStyle := []excelize.Border{ {Type: "left", Color: "000000", Style: 1}, {Type: "top", Color: "000000", Style: 1}, {Type: "bottom", Color: "000000", Style: 1}, {Type: "right", Color: "000000", Style: 1}, } dataStyle, err := file.NewStyle(&excelize.Style{ Font: &excelize.Font{Color: "000000", Family: "Arial", Size: 10}, Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, Border: borderStyle, }) if err != nil { return err } totalStyle, err := file.NewStyle(&excelize.Style{ Font: &excelize.Font{Bold: true, Color: "000000", Family: "Arial", Size: 10}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}}, Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, Border: borderStyle, }) if err != nil { return err } redDataStyle, err := file.NewStyle(&excelize.Style{ Font: &excelize.Font{Color: "FF0000", Family: "Arial", Size: 10}, Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, Border: borderStyle, }) if err != nil { return err } redTotalStyle, err := file.NewStyle(&excelize.Style{ Font: &excelize.Font{Bold: true, Color: "FF0000", Family: "Arial", Size: 10}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E2EFDA"}}, Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}, Border: borderStyle, }) if err != nil { return err } lastHeaderCol, _ := excelize.ColumnNumberToName(len(cpAllSheetHeaders)) currentRow := 2 for _, item := range items { name := customerPaymentName(item) // Saldo awal row saldoStr := fmt.Sprintf("%d", currentRow) if err := file.SetCellValue(sheet, "A"+saldoStr, name); err != nil { return err } initialFormatted := formatCPRupiah(item.InitialBalance) if err := file.SetCellValue(sheet, "O"+saldoStr, initialFormatted); err != nil { return err } if err := file.SetCellStyle(sheet, "A"+saldoStr, lastHeaderCol+saldoStr, dataStyle); err != nil { return err } if item.InitialBalance < 0 { if err := file.SetCellStyle(sheet, "O"+saldoStr, "O"+saldoStr, redDataStyle); err != nil { return err } } currentRow++ // Data rows for seq, row := range item.Rows { rowStr := fmt.Sprintf("%d", currentRow) if err := file.SetCellValue(sheet, "A"+rowStr, name); err != nil { return err } cells := customerPaymentRowCells(row, seq+1) for colIdx, val := range cells { col, _ := excelize.ColumnNumberToName(colIdx + 2) if err := file.SetCellValue(sheet, col+rowStr, val); err != nil { return err } } if err := file.SetCellStyle(sheet, "A"+rowStr, lastHeaderCol+rowStr, dataStyle); err != nil { return err } if row.AccountsReceivable < 0 { if err := file.SetCellStyle(sheet, "O"+rowStr, "O"+rowStr, redDataStyle); err != nil { return err } } currentRow++ } // Total row totalStr := fmt.Sprintf("%d", currentRow) totalCells := map[string]string{ "A": name, "B": "Total", "H": formatCPIDInteger(item.Summary.TotalQty), "I": formatCPIDInteger(item.Summary.TotalWeight), "L": formatCPRupiah(item.Summary.TotalFinalAmount), "M": formatCPRupiah(item.Summary.TotalGrandAmount), "N": formatCPRupiah(item.Summary.TotalPayment), "O": formatCPRupiah(item.Summary.TotalAccountsReceivable), } for col, val := range totalCells { if err := file.SetCellValue(sheet, col+totalStr, val); err != nil { return err } } if err := file.SetCellStyle(sheet, "A"+totalStr, lastHeaderCol+totalStr, totalStyle); err != nil { return err } if item.Summary.TotalAccountsReceivable < 0 { if err := file.SetCellStyle(sheet, "O"+totalStr, "O"+totalStr, redTotalStyle); err != nil { return err } } currentRow++ // Empty separator row currentRow++ } return nil } // customerPaymentRowCells returns 17 cell values for cols A..Q. func customerPaymentRowCells(row dto.CustomerPaymentReportRow, seq int) []interface{} { return []interface{}{ seq, formatCPDate(row.TransDate), formatCPOptionalDate(row.DeliveryDate), formatCPAging(row.AgingDay), safeCPText(row.Reference), joinCPStrings(row.VehicleNumbers), formatCPIDInteger(row.Qty), formatCPIDInteger(row.Weight), formatCPAvg(row.AverageWeight), formatCPRupiah(row.UnitPrice), formatCPRupiah(row.FinalPrice), formatCPRupiah(row.TotalPrice), formatCPRupiah(row.PaymentAmount), formatCPRupiah(row.AccountsReceivable), safeCPText(row.Status), joinCPStrings(row.PickupInfo), safeCPText(row.SalesPerson), } } func customerPaymentName(item dto.CustomerPaymentReportItem) string { name := strings.TrimSpace(item.Customer.Name) if name == "" { return "Customer" } return name } func sanitizeCustomerPaymentSheetName(name string) string { replacer := strings.NewReplacer( ":", " ", "\\", " ", "/", " ", "?", " ", "*", " ", "[", " ", "]", " ", ) sanitized := strings.TrimSpace(replacer.Replace(name)) if sanitized == "" { return "Sheet" } runes := []rune(sanitized) if len(runes) > 31 { return string(runes[:31]) } return sanitized } var cpIndonesianMonths = [12]string{ "Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des", } func formatCPDate(t time.Time) string { if t.IsZero() { return "-" } loc, err := time.LoadLocation("Asia/Jakarta") if err == nil { t = t.In(loc) } return fmt.Sprintf("%02d %s %d", t.Day(), cpIndonesianMonths[t.Month()-1], t.Year()) } func formatCPOptionalDate(t *time.Time) string { if t == nil || t.IsZero() { return "-" } return formatCPDate(*t) } func formatCPAging(v *int) string { if v == nil { return "-" } return strconv.Itoa(*v) } func formatCPIDInteger(v float64) string { n := int64(math.Round(v)) if n == 0 { return "0" } negative := n < 0 abs := n if negative { abs = -n } s := strconv.FormatInt(abs, 10) // insert dots as thousand separators var b strings.Builder start := len(s) % 3 if start == 0 { start = 3 } b.WriteString(s[:start]) for i := start; i < len(s); i += 3 { b.WriteByte('.') b.WriteString(s[i : i+3]) } if negative { return "-" + b.String() } return b.String() } func formatCPRupiah(v float64) string { const nbsp = " " if v < 0 { return "-Rp" + nbsp + formatCPIDInteger(-v) } return "Rp" + nbsp + formatCPIDInteger(v) } func formatCPAvg(v float64) string { if v == 0 { return "0" } s := strconv.FormatFloat(v, 'f', 2, 64) return strings.ReplaceAll(s, ".", ",") } func safeCPText(s string) string { t := strings.TrimSpace(s) if t == "" { return "-" } return t } func joinCPStrings(ss []string) string { var parts []string for _, s := range ss { s = strings.TrimSpace(s) if s != "" { parts = append(parts, s) } } if len(parts) == 0 { return "-" } return strings.Join(parts, "\n") }