diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index 7801b16f..be26fd44 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -26,6 +26,7 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin func (u *RecordingController) GetAll(c *fiber.Ctx) error { projectFlockID := c.QueryInt("project_flock_kandang_id", 0) + exportType := strings.TrimSpace(c.Query("export")) page := c.QueryInt("page", 1) limit := c.QueryInt("limit", 10) @@ -46,6 +47,11 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error { return err } + listDTO := dto.ToRecordingListDTOs(result) + if strings.EqualFold(exportType, "excel") { + return exportRecordingListExcel(c, listDTO) + } + return c.Status(fiber.StatusOK). JSON(response.SuccessWithPaginate[dto.RecordingListDTO]{ Code: fiber.StatusOK, @@ -57,7 +63,7 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error { TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), TotalResults: totalResults, }, - Data: dto.ToRecordingListDTOs(result), + Data: listDTO, }) } diff --git a/internal/modules/production/recordings/controllers/recording.export.go b/internal/modules/production/recordings/controllers/recording.export.go new file mode 100644 index 00000000..3f5294ab --- /dev/null +++ b/internal/modules/production/recordings/controllers/recording.export.go @@ -0,0 +1,517 @@ +package controller + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" +) + +func exportRecordingListExcel(c *fiber.Ctx, items []dto.RecordingListDTO) error { + file := excelize.NewFile() + defer file.Close() + + const sheetName = "Recordings" + defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) + if defaultSheet != sheetName { + if err := file.SetSheetName(defaultSheet, sheetName); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare excel sheet") + } + } + + if err := setRecordingExportColumns(file, sheetName); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare excel columns") + } + if err := setRecordingExportHeaders(file, sheetName); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare excel headers") + } + if err := setRecordingExportRows(file, sheetName, items); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare excel rows") + } + + buffer, err := file.WriteToBuffer() + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") + } + + filename := fmt.Sprintf("recordings_%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(buffer.Bytes()) +} + +func setRecordingExportColumns(file *excelize.File, sheet string) error { + columnWidths := map[string]float64{ + "A": 6, + "B": 18, + "C": 24, + "D": 18, + "E": 10, + "F": 12, + "G": 20, + "H": 18, + "I": 16, + "J": 12, + "K": 12, + "L": 16, + "M": 16, + "N": 18, + "O": 18, + "P": 16, + "Q": 16, + "R": 16, + "S": 16, + "T": 16, + "U": 16, + "V": 16, + "W": 18, + "X": 18, + "Y": 18, + "Z": 22, + "AA": 16, + "AB": 18, + } + + for col, width := range columnWidths { + if err := file.SetColWidth(sheet, col, col, width); err != nil { + return err + } + } + + if err := file.SetRowHeight(sheet, 1, 30); err != nil { + return err + } + if err := file.SetRowHeight(sheet, 2, 30); err != nil { + return err + } + + return nil +} + +func setRecordingExportHeaders(file *excelize.File, sheet string) error { + verticalHeaderCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "Y", "Z", "AA", "AB"} + for _, col := range verticalHeaderCols { + if err := file.MergeCell(sheet, col+"1", col+"2"); err != nil { + return err + } + } + + headerValues := map[string]string{ + "A1": "No", + "B1": "Lokasi", + "C1": "Flock", + "D1": "Kandang", + "E1": "Periode", + "F1": "Kategori", + "G1": "Umur (hari)", + "H1": "Waktu Recording", + "I1": "Populasi Akhir", + "Y1": "Status Approval", + "Z1": "Catatan Approval", + "AA1": "Dibuat Oleh", + "AB1": "Tanggal Submit", + } + for cell, value := range headerValues { + if err := file.SetCellValue(sheet, cell, value); err != nil { + return err + } + } + + if err := file.MergeCell(sheet, "J1", "K1"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "J1", "FCR"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "J2", "Actual"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "K2", "Standard"); err != nil { + return err + } + + if err := file.MergeCell(sheet, "L1", "M1"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "L1", "Feed Intake (KG)"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "L2", "Actual"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "M2", "Standard"); err != nil { + return err + } + + if err := file.MergeCell(sheet, "N1", "P1"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "N1", "Mortality"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "N2", "Cum Depletion Rate"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "O2", "Max Depletion Std"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "P2", "Total Depletion"); err != nil { + return err + } + + if err := file.MergeCell(sheet, "Q1", "T1"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "Q1", "Egg Production"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "Q2", "Egg Mass Actual"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "R2", "Egg Mass Standar"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "S2", "Egg Weight Actual"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "T2", "Egg Weight Standar"); err != nil { + return err + } + + if err := file.MergeCell(sheet, "U1", "X1"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "U1", "Hen Performance"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "U2", "Hen Day Actual"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "V2", "Hen Day Standar"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "W2", "Hen House Actual"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "X2", "Hen House Standar"); err != nil { + return err + } + + headerStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{ + Bold: true, + Color: "7A7A7A", + }, + Fill: excelize.Fill{ + Type: "pattern", + Pattern: 1, + Color: []string{"F5F5F5"}, + }, + Alignment: &excelize.Alignment{ + Horizontal: "center", + Vertical: "center", + WrapText: true, + }, + Border: []excelize.Border{ + {Type: "left", Color: "DDDDDD", Style: 1}, + {Type: "top", Color: "DDDDDD", Style: 1}, + {Type: "bottom", Color: "DDDDDD", Style: 1}, + {Type: "right", Color: "DDDDDD", Style: 1}, + }, + }) + if err != nil { + return err + } + + return file.SetCellStyle(sheet, "A1", "AB2", headerStyle) +} + +func setRecordingExportRows(file *excelize.File, sheet string, items []dto.RecordingListDTO) error { + if len(items) == 0 { + return nil + } + + columns := []string{ + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", + "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB", + } + + for i, item := range items { + rowNumber := i + 3 + + fcrStd := 0.0 + if item.ProjectFlock.Fcr != nil { + fcrStd = item.ProjectFlock.Fcr.FcrStd + } + + maxDepletionStd := 0.0 + eggMassStd := 0.0 + eggWeightStd := 0.0 + henDayStd := 0.0 + henHouseStd := 0.0 + feedIntakeStd := 0.0 + if item.ProjectFlock.ProductionStandart != nil { + maxDepletionStd = item.ProjectFlock.ProductionStandart.MaxDepletionStd + eggMassStd = item.ProjectFlock.ProductionStandart.EggMassStd + eggWeightStd = item.ProjectFlock.ProductionStandart.EggWeightStd + henDayStd = item.ProjectFlock.ProductionStandart.HenDayStd + henHouseStd = item.ProjectFlock.ProductionStandart.HenHouseStd + feedIntakeStd = item.ProjectFlock.ProductionStandart.FeedIntakeStd + } + + locationName := "-" + if item.Location != nil { + locationName = safeExportText(item.Location.Name) + } + + kandangName := "-" + if item.Kandang != nil { + kandangName = safeExportText(item.Kandang.Name) + } + + createdBy := "-" + if item.CreatedUser != nil { + createdBy = safeExportText(item.CreatedUser.Name) + } else if strings.TrimSpace(item.Approval.ActionBy.Name) != "" { + createdBy = safeExportText(item.Approval.ActionBy.Name) + } + + rowValues := []interface{}{ + i + 1, + locationName, + safeExportText(item.ProjectFlock.FlockName), + kandangName, + item.ProjectFlock.Period, + formatCategoryLabel(item.ProjectFlock.ProjectFlockCategory), + formatAgeLabel(item), + formatDateIndonesian(item.RecordDatetime), + formatNumberID(item.ProjectFlock.TotalChickQty, 0, false), + formatNumberID(item.FcrValue, 2, true), + formatNumberID(fcrStd, 2, true), + formatNumberID(item.FeedIntake, 2, true), + formatNumberID(feedIntakeStd, 2, true), + formatPercentID(item.CumDepletionRate, 2), + formatPercentID(maxDepletionStd, 2), + formatNumberID(item.TotalDepletionQty, 2, true), + formatNumberID(item.EggMass, 2, true), + formatNumberID(eggMassStd, 2, true), + formatNumberID(item.EggWeight, 2, true), + formatNumberID(eggWeightStd, 2, true), + formatPercentID(item.HenDay, 2), + formatPercentID(henDayStd, 2), + formatPercentID(item.HenHouse, 2), + formatPercentID(henHouseStd, 2), + formatApprovalStatus(item), + safeExportText(pointerString(item.Approval.Notes)), + createdBy, + formatDateIndonesian(item.CreatedAt), + } + + for idx, col := range columns { + cell := fmt.Sprintf("%s%d", col, rowNumber) + if err := file.SetCellValue(sheet, cell, rowValues[idx]); err != nil { + return err + } + } + } + + lastRow := len(items) + 2 + dataCenterStyle, err := file.NewStyle(&excelize.Style{ + Alignment: &excelize.Alignment{ + Horizontal: "center", + Vertical: "center", + WrapText: true, + }, + Border: []excelize.Border{ + {Type: "left", Color: "E6E6E6", Style: 1}, + {Type: "top", Color: "E6E6E6", Style: 1}, + {Type: "bottom", Color: "E6E6E6", Style: 1}, + {Type: "right", Color: "E6E6E6", Style: 1}, + }, + }) + if err != nil { + return err + } + if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AB%d", lastRow), dataCenterStyle); err != nil { + return err + } + + dataLeftStyle, err := file.NewStyle(&excelize.Style{ + Alignment: &excelize.Alignment{ + Horizontal: "left", + Vertical: "center", + WrapText: true, + }, + Border: []excelize.Border{ + {Type: "left", Color: "E6E6E6", Style: 1}, + {Type: "top", Color: "E6E6E6", Style: 1}, + {Type: "bottom", Color: "E6E6E6", Style: 1}, + {Type: "right", Color: "E6E6E6", Style: 1}, + }, + }) + if err != nil { + return err + } + + leftColumns := []string{"B", "C", "D", "F", "G", "H", "Y", "Z", "AA", "AB"} + for _, col := range leftColumns { + if err := file.SetCellStyle(sheet, col+"3", fmt.Sprintf("%s%d", col, lastRow), dataLeftStyle); err != nil { + return err + } + } + + return nil +} + +func formatAgeLabel(item dto.RecordingListDTO) string { + if item.Day <= 0 { + return "-" + } + + week := 0 + if item.ProjectFlock.ProductionStandart != nil && item.ProjectFlock.ProductionStandart.Week > 0 { + week = item.ProjectFlock.ProductionStandart.Week + } else { + week = ((item.Day - 1) / 7) + 1 + } + + return fmt.Sprintf("%d (Minggu ke-%d)", item.Day, week) +} + +func formatDateIndonesian(t time.Time) string { + if t.IsZero() { + return "-" + } + + loc, err := time.LoadLocation("Asia/Jakarta") + if err == nil { + t = t.In(loc) + } + + monthNames := []string{ + "", + "Januari", + "Februari", + "Maret", + "April", + "Mei", + "Juni", + "Juli", + "Agustus", + "September", + "Oktober", + "November", + "Desember", + } + + month := int(t.Month()) + monthLabel := strconv.Itoa(month) + if month > 0 && month < len(monthNames) { + monthLabel = monthNames[month] + } + + return fmt.Sprintf("%02d %s %d", t.Day(), monthLabel, t.Year()) +} + +func formatCategoryLabel(value string) string { + normalized := strings.TrimSpace(strings.ReplaceAll(value, "_", " ")) + if normalized == "" { + return "-" + } + + parts := strings.Fields(strings.ToLower(normalized)) + for i, part := range parts { + if len(part) == 0 { + continue + } + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + + return strings.Join(parts, " ") +} + +func formatPercentID(value float64, decimals int) string { + return fmt.Sprintf("%s%%", formatNumberID(value, decimals, false)) +} + +func formatNumberID(value float64, decimals int, trim bool) string { + if math.IsNaN(value) || math.IsInf(value, 0) { + return "0" + } + if decimals < 0 { + decimals = 0 + } + + raw := strconv.FormatFloat(value, 'f', decimals, 64) + if trim && strings.Contains(raw, ".") { + raw = strings.TrimRight(raw, "0") + raw = strings.TrimRight(raw, ".") + } + + parts := strings.SplitN(raw, ".", 2) + intPart := parts[0] + sign := "" + if strings.HasPrefix(intPart, "-") { + sign = "-" + intPart = strings.TrimPrefix(intPart, "-") + } + if intPart == "" { + intPart = "0" + } + + var grouped strings.Builder + rem := len(intPart) % 3 + if rem > 0 { + grouped.WriteString(intPart[:rem]) + if len(intPart) > rem { + grouped.WriteString(".") + } + } + for i := rem; i < len(intPart); i += 3 { + grouped.WriteString(intPart[i : i+3]) + if i+3 < len(intPart) { + grouped.WriteString(".") + } + } + + result := sign + grouped.String() + if len(parts) == 2 && parts[1] != "" { + result += "," + parts[1] + } + + return result +} + +func safeExportText(value string) string { + normalized := strings.TrimSpace(value) + if normalized == "" { + return "-" + } + return normalized +} + +func pointerString(value *string) string { + if value == nil { + return "" + } + return *value +} + +func formatApprovalStatus(item dto.RecordingListDTO) string { + action := strings.ToUpper(strings.TrimSpace(pointerString(item.Approval.Action))) + switch action { + case "UPDATED": + return "Diperbarui" + case "CREATED": + return safeExportText(item.Approval.StepName) + default: + return safeExportText(item.Approval.StepName) + } +}