516 lines
16 KiB
Go
516 lines
16 KiB
Go
package bot
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
bot "github.com/go-telegram/bot"
|
|
"github.com/go-telegram/bot/models"
|
|
"github.com/xuri/excelize/v2"
|
|
|
|
"worktimeBot/internal/db"
|
|
)
|
|
|
|
type themeColors struct {
|
|
headerFill string
|
|
border string
|
|
totalFill string
|
|
}
|
|
|
|
var themes = map[string]themeColors{
|
|
"ocean": {headerFill: "4472C4", border: "D9D9D9", totalFill: "D9E2F3"},
|
|
"beach": {headerFill: "C87D3C", border: "E8D5B7", totalFill: "FFF0E0"},
|
|
"rose": {headerFill: "B86C80", border: "F0D0E0", totalFill: "FCE8F0"},
|
|
"catppuccin": {headerFill: "B48DED", border: "D9D0E8", totalFill: "F0E8FC"},
|
|
}
|
|
|
|
func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, calYear, calMonth int, start, end time.Time) ([]byte, error) {
|
|
theme, ok := themes[accent]
|
|
if !ok {
|
|
theme = themes["ocean"]
|
|
}
|
|
|
|
f := excelize.NewFile()
|
|
defer f.Close()
|
|
|
|
sheet := "Report"
|
|
index, err := f.NewSheet(sheet)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f.SetActiveSheet(index)
|
|
f.DeleteSheet("Sheet1")
|
|
|
|
titleStyle, _ := f.NewStyle(&excelize.Style{
|
|
Font: &excelize.Font{Bold: true, Size: 14, Color: "FFFFFF"},
|
|
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}},
|
|
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
|
})
|
|
headerStyle, _ := f.NewStyle(&excelize.Style{
|
|
Font: &excelize.Font{Bold: true, Color: "FFFFFF"},
|
|
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}},
|
|
Border: []excelize.Border{
|
|
{Type: "left", Color: "FFFFFF", Style: 1},
|
|
{Type: "right", Color: "FFFFFF", Style: 1},
|
|
{Type: "top", Color: "FFFFFF", Style: 1},
|
|
{Type: "bottom", Color: "FFFFFF", Style: 1},
|
|
},
|
|
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
|
})
|
|
dataStyle, _ := f.NewStyle(&excelize.Style{
|
|
Border: []excelize.Border{
|
|
{Type: "left", Color: theme.border, Style: 1},
|
|
{Type: "right", Color: theme.border, Style: 1},
|
|
{Type: "top", Color: theme.border, Style: 1},
|
|
{Type: "bottom", Color: theme.border, Style: 1},
|
|
},
|
|
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
|
})
|
|
totalStyle, _ := f.NewStyle(&excelize.Style{
|
|
Font: &excelize.Font{Bold: true, Size: 11},
|
|
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}},
|
|
Border: []excelize.Border{
|
|
{Type: "left", Color: theme.border, Style: 1},
|
|
{Type: "right", Color: theme.border, Style: 1},
|
|
{Type: "top", Color: theme.border, Style: 1},
|
|
{Type: "bottom", Color: theme.border, Style: 2},
|
|
},
|
|
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
|
})
|
|
dayOffStyle, _ := f.NewStyle(&excelize.Style{
|
|
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}},
|
|
Border: []excelize.Border{
|
|
{Type: "left", Color: theme.border, Style: 1},
|
|
{Type: "right", Color: theme.border, Style: 1},
|
|
{Type: "top", Color: theme.border, Style: 1},
|
|
{Type: "bottom", Color: theme.border, Style: 1},
|
|
},
|
|
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
|
})
|
|
|
|
monthName := formatMonthTitle(calendar, calYear, calMonth)
|
|
f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName))
|
|
f.MergeCell(sheet, "A1", "F1")
|
|
f.SetCellStyle(sheet, "A1", "F1", titleStyle)
|
|
f.SetRowHeight(sheet, 1, 30)
|
|
|
|
headers := []string{"Date", "Clock In", "Clock Out", "Type", "Work", "Break"}
|
|
for i, h := range headers {
|
|
cell, _ := excelize.CoordinatesToCellName(i+1, 2)
|
|
f.SetCellValue(sheet, cell, h)
|
|
f.SetCellStyle(sheet, cell, cell, headerStyle)
|
|
}
|
|
f.SetRowHeight(sheet, 2, 22)
|
|
|
|
f.SetColWidth(sheet, "A", "A", 14)
|
|
f.SetColWidth(sheet, "B", "B", 10)
|
|
f.SetColWidth(sheet, "C", "C", 10)
|
|
f.SetColWidth(sheet, "D", "D", 12)
|
|
f.SetColWidth(sheet, "E", "E", 10)
|
|
f.SetColWidth(sheet, "F", "F", 10)
|
|
|
|
loc := loadLocation(timezone)
|
|
|
|
row := 3
|
|
|
|
userDays, err := store.GetUserDaysInRange(userID, start.Format("2006-01-02"), end.Format("2006-01-02"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dayMap := make(map[string]*db.Day)
|
|
for i := range userDays {
|
|
dayMap[userDays[i].Date] = &userDays[i]
|
|
}
|
|
|
|
var totalWork, totalBreak int64
|
|
|
|
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
|
dateStr := d.Format("2006-01-02")
|
|
displayDate := formatDateForCalendar(dateStr, calendar)
|
|
|
|
// Determine if this day exists, its events, and day-off status.
|
|
var (
|
|
day *db.Day
|
|
events []db.Event
|
|
hasDayOff bool
|
|
)
|
|
if existing, ok := dayMap[dateStr]; ok {
|
|
day = existing
|
|
events, err = store.EventsForDayByDayID(day.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hasDayOff = day.IsDayOff
|
|
}
|
|
|
|
// Write the date cell for every day.
|
|
cellDate, _ := excelize.CoordinatesToCellName(1, row)
|
|
f.SetCellValue(sheet, cellDate, displayDate)
|
|
f.SetCellStyle(sheet, cellDate, cellDate, dataStyle)
|
|
|
|
if len(events) > 0 {
|
|
cellIn, _ := excelize.CoordinatesToCellName(2, row)
|
|
f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).In(loc).Format("15:04"))
|
|
f.SetCellStyle(sheet, cellIn, cellIn, dataStyle)
|
|
}
|
|
if len(events) > 0 {
|
|
lastEvent := events[len(events)-1]
|
|
if lastEvent.EventType == "out" {
|
|
cellOut, _ := excelize.CoordinatesToCellName(3, row)
|
|
f.SetCellValue(sheet, cellOut, time.Unix(lastEvent.OccurredAt, 0).In(loc).Format("15:04"))
|
|
f.SetCellStyle(sheet, cellOut, cellOut, dataStyle)
|
|
}
|
|
}
|
|
|
|
// Type column: day-off, work-type label, or empty.
|
|
var typeLabel string
|
|
if hasDayOff {
|
|
if day.DayOffReason != "" {
|
|
typeLabel = "Day Off (" + day.DayOffReason + ")"
|
|
} else {
|
|
typeLabel = "Day Off"
|
|
}
|
|
} else if len(events) > 0 {
|
|
typeLabel = workTypeLabel(store, day, events)
|
|
}
|
|
cellType, _ := excelize.CoordinatesToCellName(4, row)
|
|
f.SetCellValue(sheet, cellType, typeLabel)
|
|
f.SetCellStyle(sheet, cellType, cellType, dataStyle)
|
|
|
|
// Work and Break columns — compute totals only when there are events.
|
|
var workSec, breakSec int64
|
|
if len(events) > 0 && day != nil {
|
|
dayStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc).Unix()
|
|
dayEnd := time.Date(d.Year(), d.Month(), d.Day(), 23, 59, 59, 0, loc).Unix()
|
|
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
|
|
workSec = totals.TotalSeconds
|
|
breakSec = totals.BreakSeconds
|
|
totalWork += workSec
|
|
totalBreak += breakSec
|
|
}
|
|
|
|
cellWork, _ := excelize.CoordinatesToCellName(5, row)
|
|
f.SetCellValue(sheet, cellWork, fmtHHMM(workSec))
|
|
f.SetCellStyle(sheet, cellWork, cellWork, dataStyle)
|
|
|
|
cellBreak, _ := excelize.CoordinatesToCellName(6, row)
|
|
f.SetCellValue(sheet, cellBreak, fmtHHMM(breakSec))
|
|
f.SetCellStyle(sheet, cellBreak, cellBreak, dataStyle)
|
|
|
|
if hasDayOff {
|
|
cellA, _ := excelize.CoordinatesToCellName(1, row)
|
|
cellF, _ := excelize.CoordinatesToCellName(6, row)
|
|
f.SetCellStyle(sheet, cellA, cellF, dayOffStyle)
|
|
}
|
|
|
|
row++
|
|
}
|
|
|
|
row++
|
|
|
|
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total")
|
|
f.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), totalStyle)
|
|
f.SetCellValue(sheet, fmt.Sprintf("E%d", row), fmtDDHHMM(totalWork))
|
|
f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("E%d", row), totalStyle)
|
|
f.SetCellValue(sheet, fmt.Sprintf("F%d", row), fmtDDHHMM(totalBreak))
|
|
f.SetCellStyle(sheet, fmt.Sprintf("F%d", row), fmt.Sprintf("F%d", row), totalStyle)
|
|
|
|
buf, err := f.WriteToBuffer()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string {
|
|
wtSeen := make(map[int64]bool)
|
|
for _, e := range events {
|
|
if e.WorkTypeID != nil {
|
|
wtSeen[*e.WorkTypeID] = true
|
|
}
|
|
}
|
|
if len(wtSeen) == 0 {
|
|
wt, err := store.GetWorkType(day.CurrentWorkTypeID)
|
|
if err == nil {
|
|
return wt.Name
|
|
}
|
|
return ""
|
|
}
|
|
if len(wtSeen) == 1 {
|
|
for id := range wtSeen {
|
|
wt, err := store.GetWorkType(id)
|
|
if err == nil {
|
|
return wt.Name
|
|
}
|
|
}
|
|
}
|
|
return "mixed"
|
|
}
|
|
|
|
func fmtHHMM(seconds int64) string {
|
|
hours := seconds / 3600
|
|
mins := (seconds % 3600) / 60
|
|
return fmt.Sprintf("%d:%02d", hours, mins)
|
|
}
|
|
|
|
func fmtDDHHMM(seconds int64) string {
|
|
days := seconds / 86400
|
|
rem := seconds % 86400
|
|
hours := rem / 3600
|
|
mins := (rem % 3600) / 60
|
|
return fmt.Sprintf("%d:%02d:%02d", days, hours, mins)
|
|
}
|
|
|
|
// handleExport processes the /export command, showing the month picker or exporting directly.
|
|
func (h *Handler) handleExport(ctx context.Context, msg *models.Message) {
|
|
user, err := h.getOrCreateUser(msg.Chat.ID)
|
|
if err != nil {
|
|
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
|
|
return
|
|
}
|
|
loc := loadLocation(user.Timezone)
|
|
now := time.Now().In(loc)
|
|
|
|
cy, cm := now.Year(), int(now.Month())
|
|
switch user.Calendar {
|
|
case "jalali":
|
|
cy, cm, _ = gregorianToJalali(cy, cm, now.Day())
|
|
case "hijri":
|
|
cy, cm, _ = gregorianToHijri(cy, cm, now.Day())
|
|
}
|
|
|
|
args := strings.Fields(msg.Text)
|
|
if len(args) > 0 && args[0][0] == '/' {
|
|
args = args[1:]
|
|
}
|
|
if len(args) >= 1 {
|
|
if n, _ := fmt.Sscanf(args[0], "%d-%d", &cy, &cm); n == 2 {
|
|
if cy < 0 || cm < 1 || cm > 12 {
|
|
h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /export YYYY-MM")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
h.sendExportDirect(ctx, msg.Chat.ID, user, cy, cm)
|
|
}
|
|
|
|
// sendExportDirect generates and sends an Excel report for the given calendar month.
|
|
func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *db.User, calYear, calMonth int) {
|
|
last, err := h.DB.GetLastEvent(user.ID)
|
|
if err == nil && last != nil && last.EventType == "in" {
|
|
h.sendText(ctx, chatID, "You are currently clocked in. Please clock out first, then try again.")
|
|
return
|
|
}
|
|
|
|
loc := loadLocation(user.Timezone)
|
|
startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth)
|
|
start, err := time.Parse("2006-01-02", startStr)
|
|
if err != nil {
|
|
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
|
}
|
|
end, err := time.Parse("2006-01-02", endStr)
|
|
if err != nil {
|
|
startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
|
end = startOfMonth.AddDate(0, 1, -1)
|
|
}
|
|
|
|
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", calYear, "cal_m", calMonth, "from", startStr, "to", endStr)
|
|
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, calYear, calMonth, start, end)
|
|
if err != nil {
|
|
slog.Error("generate report", "error", err)
|
|
h.sendText(ctx, chatID, "Error generating report")
|
|
return
|
|
}
|
|
slog.Info("report generated", "bytes", len(data))
|
|
if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{
|
|
ChatID: chatID,
|
|
Document: &models.InputFileUpload{
|
|
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)),
|
|
Data: bytes.NewReader(data),
|
|
},
|
|
}); err != nil {
|
|
slog.Error("send document", "error", err)
|
|
}
|
|
}
|
|
|
|
// exportCallback opens the export month picker.
|
|
func (h *Handler) exportCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
|
|
defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
|
|
user, err := h.getOrCreateUser(chatID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
loc := loadLocation(user.Timezone)
|
|
now := time.Now().In(loc)
|
|
cy, cm := now.Year(), int(now.Month())
|
|
switch user.Calendar {
|
|
case "jalali":
|
|
cy, cm, _ = gregorianToJalali(cy, cm, now.Day())
|
|
case "hijri":
|
|
cy, cm, _ = gregorianToHijri(cy, cm, now.Day())
|
|
}
|
|
h.editExportMonthPicker(ctx, chatID, msgID, cy, cm, user)
|
|
}
|
|
|
|
// sendExportMonthPicker sends the export month picker as a new message.
|
|
func (h *Handler) sendExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, month int, user *db.User) {
|
|
h.editExportMonthPicker(ctx, chatID, msgID, year, month, user)
|
|
}
|
|
|
|
// editExportMonthPicker renders a year-based month picker with 12 month buttons.
|
|
func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, _ int, user *db.User) {
|
|
months := gregMonthNames
|
|
switch user.Calendar {
|
|
case "jalali":
|
|
months = jalaliMonthNames
|
|
case "hijri":
|
|
months = hijriMonthNames
|
|
}
|
|
|
|
kbRows := [][]models.InlineKeyboardButton{
|
|
// Year navigation
|
|
{
|
|
{Text: "<", CallbackData: fmt.Sprintf("exp_year_prev_%d", year)},
|
|
{Text: fmt.Sprintf("%d", year), CallbackData: "noop"},
|
|
{Text: ">", CallbackData: fmt.Sprintf("exp_year_next_%d", year)},
|
|
},
|
|
}
|
|
|
|
// 4 columns x 3 rows of month buttons
|
|
for i := 0; i < 12; i += 4 {
|
|
row := []models.InlineKeyboardButton{}
|
|
for j := i; j < i+4 && j < 12; j++ {
|
|
row = append(row, models.InlineKeyboardButton{
|
|
Text: months[j], CallbackData: fmt.Sprintf("exp_do_%d_%d", year, j+1),
|
|
})
|
|
}
|
|
kbRows = append(kbRows, row)
|
|
}
|
|
|
|
kbRows = append(kbRows, []models.InlineKeyboardButton{
|
|
{Text: "Back to Menu", CallbackData: "back_menu"},
|
|
})
|
|
|
|
kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
|
|
if msgID == 0 {
|
|
h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: "Select month to export:", ReplyMarkup: &kb})
|
|
} else {
|
|
h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: "Select month to export:", ReplyMarkup: &kb})
|
|
}
|
|
}
|
|
|
|
// formatMonthTitle returns the localized month name and year for a given calendar.
|
|
func formatMonthTitle(cal string, year, month int) string {
|
|
switch cal {
|
|
case "jalali":
|
|
return fmt.Sprintf("%s %d", jalaliMonthNames[month-1], year)
|
|
case "hijri":
|
|
return fmt.Sprintf("%s %d", hijriMonthNames[month-1], year)
|
|
default:
|
|
return fmt.Sprintf("%s %d", gregMonthNames[month-1], year)
|
|
}
|
|
}
|
|
|
|
// handleExportCallback routes export inline button presses (year nav, month select).
|
|
func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID int, callbackID, data string) {
|
|
defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
|
|
user, err := h.getOrCreateUser(chatID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
var y, m int
|
|
|
|
// Navigate to previous year
|
|
if n, _ := fmt.Sscanf(data, "exp_year_prev_%d", &y); n == 1 {
|
|
h.editExportMonthPicker(ctx, chatID, msgID, y-1, 0, user)
|
|
return
|
|
}
|
|
// Navigate to next year
|
|
if n, _ := fmt.Sscanf(data, "exp_year_next_%d", &y); n == 1 {
|
|
h.editExportMonthPicker(ctx, chatID, msgID, y+1, 0, user)
|
|
return
|
|
}
|
|
|
|
// Export a specific month
|
|
if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n == 2 {
|
|
last, err := h.DB.GetLastEvent(user.ID)
|
|
if err == nil && last != nil && last.EventType == "in" {
|
|
h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
|
|
ChatID: chatID,
|
|
MessageID: msgID,
|
|
Text: "You are currently clocked in. Please clock out first, then try again.",
|
|
ReplyMarkup: &models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
|
{
|
|
{Text: "Back to Menu", CallbackData: "back_menu"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
startStr, endStr := monthGregorianRange(user.Calendar, y, m)
|
|
start, err := time.Parse("2006-01-02", startStr)
|
|
if err != nil {
|
|
h.sendText(ctx, chatID, "Error processing date")
|
|
return
|
|
}
|
|
end, err := time.Parse("2006-01-02", endStr)
|
|
if err != nil {
|
|
h.sendText(ctx, chatID, "Error processing date")
|
|
return
|
|
}
|
|
|
|
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr)
|
|
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, y, m, start, end)
|
|
if err != nil {
|
|
slog.Error("generate report", "error", err)
|
|
h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
|
|
ChatID: chatID,
|
|
MessageID: msgID,
|
|
Text: "Error generating report",
|
|
ReplyMarkup: &models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
|
{
|
|
{Text: "Back to Menu", CallbackData: "back_menu"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
slog.Info("report generated", "bytes", len(data))
|
|
h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
|
|
ChatID: chatID,
|
|
MessageID: msgID,
|
|
Text: "Report ready:",
|
|
ReplyMarkup: &models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
|
{
|
|
{Text: "Back to Menu", CallbackData: "back_menu"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{
|
|
ChatID: chatID,
|
|
Document: &models.InputFileUpload{
|
|
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)),
|
|
Data: bytes.NewReader(data),
|
|
},
|
|
}); err != nil {
|
|
slog.Error("send document", "error", err)
|
|
}
|
|
}
|
|
}
|