package httpapi import ( "net/http" "strconv" "time" "spin/internal/models" "spin/internal/worktime" "github.com/go-chi/chi/v5" ) // scopeEmail resolves which member's data a request targets. Members are always // scoped to themselves; admins may pass ?email= to inspect anyone (or "" = all). func (s *Server) scopeEmail(r *http.Request) (email string, all bool) { if !s.isAdmin(r) { return s.email(r), false } q := lc(r.URL.Query().Get("email")) if q == "" { return "", true } return q, false } // ---- attendance ----------------------------------------------------------- func (s *Server) handleListAttendance(w http.ResponseWriter, r *http.Request) { email, all := s.scopeEmail(r) q := s.db.Order("date desc") if !all { q = q.Where("lower(member_email) = ?", email) } if month := r.URL.Query().Get("month"); month != "" { // YYYY-MM q = q.Where("date LIKE ?", month+"%") } var out []models.Attendance q.Find(&out) writeJSON(w, http.StatusOK, out) } // handlePunch records a clock-in (first call of the day) or clock-out. func (s *Server) handlePunch(w http.ResponseWriter, r *http.Request) { email := s.email(r) now := time.Now() date := now.Format("2006-01-02") pol := s.activeWorkPolicy() var a models.Attendance err := s.db.Where("lower(member_email) = ? AND date = ?", email, date).First(&a).Error if err != nil { a = models.Attendance{MemberEmail: email, Date: date, ClockIn: &now, Source: "web"} s.db.Create(&a) writeJSON(w, http.StatusOK, a) return } // already has a record → set clock-out and compute worked minutes a.ClockOut = &now a.WorkMinutes = worktime.NetWorkMinutes(a.ClockIn, a.ClockOut, pol.LunchMinutes) s.db.Save(&a) writeJSON(w, http.StatusOK, a) } // handleTimesheet returns the monthly roll-up for the scoped member. func (s *Server) handleTimesheet(w http.ResponseWriter, r *http.Request) { email := s.email(r) if s.isAdmin(r) { if q := lc(r.URL.Query().Get("email")); q != "" { email = q } } now := time.Now() year, _ := strconv.Atoi(r.URL.Query().Get("year")) month, _ := strconv.Atoi(r.URL.Query().Get("month")) if year == 0 { year = now.Year() } if month == 0 { month = int(now.Month()) } prefix := strconv.Itoa(year) + "-" + pad2(month) pol := s.activeWorkPolicy() var att []models.Attendance s.db.Where("lower(member_email) = ? AND date LIKE ?", email, prefix+"%").Find(&att) worked, days := 0, 0 for _, a := range att { worked += a.WorkMinutes if a.WorkMinutes > 0 { days++ } } // recognized leave minutes (approved annual/public/etc within month) var leaves []models.LeaveRequest s.db.Where("lower(member_email) = ? AND status = ? AND start_date LIKE ?", email, models.StatusApproved, prefix+"%").Find(&leaves) leaveMin := 0 for _, lv := range leaves { if lv.Type == models.LeaveUnpaid { continue } leaveMin += int(lv.Days * float64(pol.DailyStandardMin)) } var ots []models.OvertimeRequest s.db.Where("lower(member_email) = ? AND status = ? AND date LIKE ?", email, models.StatusApproved, prefix+"%").Find(&ots) otMin := 0 for _, o := range ots { otMin += o.Minutes } writeJSON(w, http.StatusOK, worktime.Compute(year, month, pol.DailyStandardMin, worked, leaveMin, otMin, days)) } func pad2(n int) string { if n < 10 { return "0" + strconv.Itoa(n) } return strconv.Itoa(n) } // ---- leave ---------------------------------------------------------------- func (s *Server) handleListLeave(w http.ResponseWriter, r *http.Request) { email, all := s.scopeEmail(r) q := s.db.Order("created_at desc") if !all { q = q.Where("lower(member_email) = ?", email) } if st := r.URL.Query().Get("status"); st != "" { q = q.Where("status = ?", st) } var out []models.LeaveRequest q.Find(&out) writeJSON(w, http.StatusOK, out) } func (s *Server) handleCreateLeave(w http.ResponseWriter, r *http.Request) { var lv models.LeaveRequest if err := decodeJSON(r, &lv); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } lv.MemberEmail = s.email(r) // members can only file for themselves lv.Status = models.StatusPending lv.Approver = "" if lv.Days == 0 { lv.Days = leaveDays(lv) } if err := s.db.Create(&lv).Error; err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusCreated, lv) } // leaveDays estimates day count from the date span (half-day types = 0.5). func leaveDays(lv models.LeaveRequest) float64 { if lv.Type == models.LeaveHalfAM || lv.Type == models.LeaveHalfPM { return 0.5 } s, e1 := lv.StartDate, lv.EndDate if e1 == "" { e1 = s } t1, err1 := time.Parse("2006-01-02", s) t2, err2 := time.Parse("2006-01-02", e1) if err1 != nil || err2 != nil { return 1 } return t2.Sub(t1).Hours()/24 + 1 } type decision struct { Approve bool `json:"approve"` Memo string `json:"memo"` } func (s *Server) handleDecideLeave(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var lv models.LeaveRequest if err := s.db.First(&lv, "id = ?", chi.URLParam(r, "id")).Error; err != nil { writeError(w, http.StatusNotFound, "신청을 찾을 수 없습니다") return } var d decision decodeJSON(r, &d) now := time.Now() lv.Status = models.StatusRejected if d.Approve { lv.Status = models.StatusApproved s.applyLeaveBalance(lv) } lv.Approver = currentUser(r.Context()).Email lv.DecidedAt = &now lv.DecisionMemo = d.Memo s.db.Save(&lv) s.audit(r, "decide", "leave", lv.ID, lv.Status) writeJSON(w, http.StatusOK, lv) } // applyLeaveBalance draws down the annual leave balance for 연차 types. func (s *Server) applyLeaveBalance(lv models.LeaveRequest) { if lv.Type != models.LeaveAnnual && lv.Type != models.LeaveHalfAM && lv.Type != models.LeaveHalfPM { return } year := time.Now().Year() if t, err := time.Parse("2006-01-02", lv.StartDate); err == nil { year = t.Year() } var bal models.LeaveBalance if err := s.db.Where("lower(member_email) = ? AND year = ?", lc(lv.MemberEmail), year).First(&bal).Error; err != nil { bal = models.LeaveBalance{MemberEmail: lv.MemberEmail, Year: year, Granted: 15} s.db.Create(&bal) } bal.Used += lv.Days s.db.Save(&bal) } func (s *Server) handleCancelLeave(w http.ResponseWriter, r *http.Request) { var lv models.LeaveRequest if err := s.db.First(&lv, "id = ?", chi.URLParam(r, "id")).Error; err != nil { writeError(w, http.StatusNotFound, "신청을 찾을 수 없습니다") return } if !s.isAdmin(r) && !s.owns(r, lv.MemberEmail) { writeError(w, http.StatusForbidden, "본인 신청만 취소할 수 있습니다") return } lv.Status = models.StatusCanceled s.db.Save(&lv) writeJSON(w, http.StatusOK, lv) } // ---- overtime ------------------------------------------------------------- func (s *Server) handleListOvertime(w http.ResponseWriter, r *http.Request) { email, all := s.scopeEmail(r) q := s.db.Order("created_at desc") if !all { q = q.Where("lower(member_email) = ?", email) } var out []models.OvertimeRequest q.Find(&out) writeJSON(w, http.StatusOK, out) } func (s *Server) handleCreateOvertime(w http.ResponseWriter, r *http.Request) { var o models.OvertimeRequest if err := decodeJSON(r, &o); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } o.MemberEmail = s.email(r) o.Status = models.StatusPending if err := s.db.Create(&o).Error; err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusCreated, o) } func (s *Server) handleDecideOvertime(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var o models.OvertimeRequest if err := s.db.First(&o, "id = ?", chi.URLParam(r, "id")).Error; err != nil { writeError(w, http.StatusNotFound, "신청을 찾을 수 없습니다") return } var d decision decodeJSON(r, &d) now := time.Now() o.Status = models.StatusRejected if d.Approve { o.Status = models.StatusApproved } o.Approver = currentUser(r.Context()).Email o.DecidedAt = &now o.DecisionMemo = d.Memo s.db.Save(&o) s.audit(r, "decide", "overtime", o.ID, o.Status) writeJSON(w, http.StatusOK, o) } // ---- work policy ---------------------------------------------------------- // activeWorkPolicy returns the active policy or a sane default. func (s *Server) activeWorkPolicy() models.WorkPolicy { var p models.WorkPolicy if err := s.db.Where("active = ?", true).First(&p).Error; err != nil { return models.WorkPolicy{WeeklyHours: 40, DailyStandardMin: 480, LunchMinutes: 60, CoreStart: "09:00", CoreEnd: "18:00", AnnualLeaveBase: 15, Active: true} } return p } func (s *Server) handleGetWorkPolicy(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.activeWorkPolicy()) } func (s *Server) handlePutWorkPolicy(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var in models.WorkPolicy if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } var p models.WorkPolicy if err := s.db.Where("active = ?", true).First(&p).Error; err != nil { in.Active = true s.db.Create(&in) writeJSON(w, http.StatusOK, in) return } in.ID = p.ID in.Active = true s.db.Save(&in) writeJSON(w, http.StatusOK, in) } // ---- approval queue (admin) ---------------------------------------------- type approvalQueue struct { Leave []models.LeaveRequest `json:"leave"` Overtime []models.OvertimeRequest `json:"overtime"` } func (s *Server) handleApprovalQueue(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var q approvalQueue s.db.Where("status = ?", models.StatusPending).Order("created_at asc").Find(&q.Leave) s.db.Where("status = ?", models.StatusPending).Order("created_at asc").Find(&q.Overtime) writeJSON(w, http.StatusOK, q) }