All checks were successful
build-and-push / build (push) Successful in 35s
- 직급에 인턴 추가(기본 할당량 15), 직책(position)은 UI에서 제거(컬럼은 유지) - 초과근무: 유저 신청 제거 → 관리자 근무관리에서 실제 출퇴근 기록 기반 자동 집계 - 로그아웃: infra 공통 LOGOUT_URL(/me로 전달) 사용 → oauth2-proxy 종료 + Keycloak end-session - (이전 커밋 포함) Device 등록 + FCM HTTP v1 sender + notify 연동 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
370 lines
10 KiB
Go
370 lines
10 KiB
Go
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))
|
|
}
|
|
|
|
// Overtime is OBSERVED from real attendance (worked beyond the daily standard),
|
|
// not user-applied — only admins see it (via the 근무 관리 timesheet).
|
|
otMin := 0
|
|
for _, a := range att {
|
|
if extra := a.WorkMinutes - pol.DailyStandardMin; extra > 0 {
|
|
otMin += extra
|
|
}
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
var leaveLabels = map[string]string{
|
|
models.LeaveAnnual: "연차", models.LeaveHalfAM: "오전 반차", models.LeaveHalfPM: "오후 반차",
|
|
models.LeavePublic: "공가", models.LeaveSick: "병가", models.LeaveFamily: "경조사", models.LeaveUnpaid: "무급",
|
|
}
|
|
|
|
func leaveTypeLabel(t string) string {
|
|
if l, ok := leaveLabels[t]; ok {
|
|
return l
|
|
}
|
|
return t
|
|
}
|
|
|
|
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)
|
|
verdict := "반려되었습니다"
|
|
if d.Approve {
|
|
verdict = "승인되었습니다"
|
|
}
|
|
s.notify(lv.MemberEmail, "leave", "휴가 신청이 "+verdict,
|
|
"["+leaveTypeLabel(lv.Type)+"] "+lv.StartDate+" 신청이 "+verdict+".", "/attendance")
|
|
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)
|
|
verdict := "반려되었습니다"
|
|
if d.Approve {
|
|
verdict = "승인되었습니다"
|
|
}
|
|
s.notify(o.MemberEmail, "overtime", "초과근무 신청이 "+verdict, o.Date+" 초과근무 신청이 "+verdict+".", "/attendance")
|
|
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)
|
|
}
|