added kanban and reporting
This commit is contained in:
+231
-1
@@ -85,10 +85,23 @@ type PageData struct {
|
||||
SelectedSection string
|
||||
Users []User
|
||||
Day DayDetail
|
||||
WorkUpdates []WorkUpdate
|
||||
ReportSummary []PersonReportSummary
|
||||
ReportTotals ReportTotals
|
||||
Board []BoardColumn
|
||||
}
|
||||
|
||||
type Stats struct{ Present, Remote, Leave int }
|
||||
|
||||
type ReportTotals struct {
|
||||
Present int
|
||||
Remote int
|
||||
Leave int
|
||||
Done int
|
||||
Blocked int
|
||||
Pending int
|
||||
}
|
||||
|
||||
type DayDetail struct {
|
||||
Gregorian string
|
||||
Jalali string
|
||||
@@ -111,6 +124,13 @@ type RosterMember struct {
|
||||
CheckOut string
|
||||
}
|
||||
|
||||
type BoardColumn struct {
|
||||
Status string
|
||||
Title string
|
||||
Hint string
|
||||
Tasks []BoardTask
|
||||
}
|
||||
|
||||
type Calendar struct {
|
||||
Year, Month int
|
||||
MonthName string
|
||||
@@ -213,12 +233,21 @@ func (s *Server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /requests", s.requireAuth(s.requestsPage))
|
||||
mux.HandleFunc("POST /requests", s.requireAuth(s.csrf(s.createRequest)))
|
||||
mux.HandleFunc("POST /requests/{id}/cancel", s.requireAuth(s.csrf(s.cancelRequest)))
|
||||
mux.HandleFunc("GET /updates", s.requireAuth(s.updatesPage))
|
||||
mux.HandleFunc("POST /updates", s.requireAuth(s.csrf(s.createWorkUpdate)))
|
||||
mux.HandleFunc("POST /updates/{id}/delete", s.requireAuth(s.csrf(s.deleteWorkUpdate)))
|
||||
mux.HandleFunc("GET /board", s.requireAuth(s.boardPage))
|
||||
mux.HandleFunc("POST /board/tasks", s.requireAuth(s.csrf(s.createBoardTask)))
|
||||
mux.HandleFunc("POST /board/tasks/{id}/move", s.requireAuth(s.csrf(s.moveBoardTask)))
|
||||
mux.HandleFunc("POST /board/tasks/{id}/delete", s.requireAuth(s.csrf(s.deleteBoardTask)))
|
||||
mux.HandleFunc("GET /admin/requests", s.requireAdmin(s.adminPage))
|
||||
mux.HandleFunc("POST /admin/requests/{id}/review", s.requireAdmin(s.csrf(s.reviewRequest)))
|
||||
mux.HandleFunc("GET /admin/users", s.requireAdmin(s.usersPage))
|
||||
mux.HandleFunc("POST /admin/users", s.requireAdmin(s.csrf(s.createUser)))
|
||||
mux.HandleFunc("GET /reports", s.requireAdmin(s.reportPage))
|
||||
mux.HandleFunc("GET /reports/attendance.csv", s.requireAdmin(s.reportCSV))
|
||||
mux.HandleFunc("GET /reports/work-updates.csv", s.requireAdmin(s.workUpdatesCSV))
|
||||
mux.HandleFunc("GET /reports/summary.csv", s.requireAdmin(s.reportSummaryCSV))
|
||||
}
|
||||
|
||||
func (s *Server) dayPage(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -552,6 +581,131 @@ func (s *Server) cancelRequest(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/requests?flash=Request+cancelled", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) updatesPage(w http.ResponseWriter, r *http.Request) {
|
||||
updates, err := s.store.WorkUpdates("", "", 100)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load work updates", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, "updates.html", PageData{
|
||||
Title: "Work updates", User: currentUser(r), CSRF: csrfToken(r), WorkUpdates: updates,
|
||||
TodayJalali: jalali.FromTime(time.Now()).String(), Error: r.URL.Query().Get("error"),
|
||||
Flash: r.URL.Query().Get("flash"), SelectedSection: "updates",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createWorkUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
start, startOK := parseUserDate(r.FormValue("period_start"))
|
||||
end, endOK := parseUserDate(r.FormValue("period_end"))
|
||||
if !startOK || !endOK || end < start {
|
||||
http.Redirect(w, r, "/updates?error=Choose+a+valid+reporting+period", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
startTime, _ := time.Parse("2006-01-02", start)
|
||||
endTime, _ := time.Parse("2006-01-02", end)
|
||||
if endTime.Sub(startTime) > 366*24*time.Hour {
|
||||
http.Redirect(w, r, "/updates?error=Reporting+period+cannot+exceed+one+year", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := s.store.CreateWorkUpdate(
|
||||
currentUser(r).ID, start, end, r.FormValue("status"), r.FormValue("note"),
|
||||
); err != nil {
|
||||
http.Redirect(w, r, "/updates?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/updates?flash=Work+update+shared", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) deleteWorkUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err := s.store.DeleteWorkUpdate(id, currentUser(r).ID); err != nil {
|
||||
http.Redirect(w, r, "/updates?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/updates?flash=Work+update+removed", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) {
|
||||
tasks, err := s.store.BoardTasks()
|
||||
if err != nil {
|
||||
http.Error(w, "could not load team board", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
users, err := s.store.Users()
|
||||
if err != nil {
|
||||
http.Error(w, "could not load teammates", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
columns := []BoardColumn{
|
||||
{Status: "backlog", Title: "Backlog", Hint: "Ready to pick up"},
|
||||
{Status: "in_progress", Title: "In progress", Hint: "Actively being worked"},
|
||||
{Status: "blocked", Title: "Blocked", Hint: "Needs help"},
|
||||
{Status: "done", Title: "Done", Hint: "Completed work"},
|
||||
}
|
||||
for _, task := range tasks {
|
||||
for index := range columns {
|
||||
if columns[index].Status == task.Status {
|
||||
columns[index].Tasks = append(columns[index].Tasks, task)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
s.render(w, "board.html", PageData{
|
||||
Title: "Team board", User: currentUser(r), CSRF: csrfToken(r), Users: users, Board: columns,
|
||||
TodayJalali: jalali.FromTime(time.Now()).String(), Error: r.URL.Query().Get("error"),
|
||||
Flash: r.URL.Query().Get("flash"), SelectedSection: "board",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) {
|
||||
var assigneeID *int64
|
||||
if raw := r.FormValue("assignee_id"); raw != "" {
|
||||
id, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || id < 1 {
|
||||
http.Redirect(w, r, "/board?error=Choose+a+valid+assignee", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
assigneeID = &id
|
||||
}
|
||||
dueDate := ""
|
||||
if rawDue := strings.TrimSpace(r.FormValue("due_date")); rawDue != "" {
|
||||
parsed, ok := parseUserDate(rawDue)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/board?error=Choose+a+valid+Jalali+due+date", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
dueDate = parsed
|
||||
}
|
||||
err := s.store.CreateBoardTask(
|
||||
currentUser(r).ID, r.FormValue("title"), r.FormValue("description"),
|
||||
r.FormValue("status"), assigneeID, dueDate,
|
||||
)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/board?flash=Task+added+to+the+team+board", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) moveBoardTask(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err := s.store.MoveBoardTask(id, r.FormValue("status")); err != nil {
|
||||
http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/board?flash=Task+moved", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) deleteBoardTask(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
user := currentUser(r)
|
||||
if err := s.store.DeleteBoardTask(id, user.ID, user.Role == "admin"); err != nil {
|
||||
http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/board?flash=Task+removed", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) adminPage(w http.ResponseWriter, r *http.Request) {
|
||||
requests, err := s.store.Requests(currentUser(r).ID, true, r.URL.Query().Get("status"))
|
||||
if err != nil {
|
||||
@@ -598,9 +752,27 @@ func (s *Server) createUser(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) reportPage(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now()
|
||||
start := now.AddDate(0, -1, 0).Format("2006-01-02")
|
||||
end := now.Format("2006-01-02")
|
||||
if requestedStart, requestedEnd := r.URL.Query().Get("start"), r.URL.Query().Get("end"); validDate(requestedStart) && validDate(requestedEnd) && requestedEnd >= requestedStart {
|
||||
start, end = requestedStart, requestedEnd
|
||||
}
|
||||
summaries, err := s.store.ReportSummary(start, end)
|
||||
if err != nil {
|
||||
http.Error(w, "could not build report summary", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var totals ReportTotals
|
||||
for _, summary := range summaries {
|
||||
totals.Present += summary.PresentDays
|
||||
totals.Remote += summary.RemoteDays
|
||||
totals.Leave += summary.LeaveDays
|
||||
totals.Done += summary.DoneUpdates
|
||||
totals.Blocked += summary.Blocked
|
||||
totals.Pending += summary.Pending
|
||||
}
|
||||
s.render(w, "reports.html", PageData{
|
||||
Title: "Reports", User: currentUser(r), CSRF: csrfToken(r), ReportStart: start,
|
||||
ReportEnd: now.Format("2006-01-02"), SelectedSection: "reports",
|
||||
ReportEnd: end, ReportSummary: summaries, ReportTotals: totals, SelectedSection: "reports",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -632,6 +804,64 @@ func (s *Server) reportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
cw.Flush()
|
||||
}
|
||||
|
||||
func (s *Server) workUpdatesCSV(w http.ResponseWriter, r *http.Request) {
|
||||
start, end := r.URL.Query().Get("start"), r.URL.Query().Get("end")
|
||||
if !validDate(start) || !validDate(end) || end < start {
|
||||
http.Error(w, "invalid report date range", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
updates, err := s.store.WorkUpdates(start, end, 0)
|
||||
if err != nil {
|
||||
http.Error(w, "could not generate work-update report", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="work-updates-%s-to-%s.csv"`, start, end))
|
||||
_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
cw := csv.NewWriter(w)
|
||||
_ = cw.Write([]string{"Name", "Username", "Period start", "Persian start", "Period end", "Persian end", "Status", "Update", "Submitted at"})
|
||||
for _, update := range updates {
|
||||
startTime, _ := time.Parse("2006-01-02", update.PeriodStart)
|
||||
endTime, _ := time.Parse("2006-01-02", update.PeriodEnd)
|
||||
_ = cw.Write([]string{
|
||||
update.UserName, update.Username, update.PeriodStart, jalali.FromTime(startTime).String(),
|
||||
update.PeriodEnd, jalali.FromTime(endTime).String(), update.Status, update.Note, update.CreatedAt,
|
||||
})
|
||||
}
|
||||
cw.Flush()
|
||||
}
|
||||
|
||||
func (s *Server) reportSummaryCSV(w http.ResponseWriter, r *http.Request) {
|
||||
start, end := r.URL.Query().Get("start"), r.URL.Query().Get("end")
|
||||
if !validDate(start) || !validDate(end) || end < start {
|
||||
http.Error(w, "invalid report date range", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
summaries, err := s.store.ReportSummary(start, end)
|
||||
if err != nil {
|
||||
http.Error(w, "could not generate accumulated report", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="team-summary-%s-to-%s.csv"`, start, end))
|
||||
_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
cw := csv.NewWriter(w)
|
||||
_ = cw.Write([]string{
|
||||
"Name", "Username", "Office presence days", "Remote days", "Approved time-off days",
|
||||
"Done updates", "Blocked updates", "Pending updates", "Total updates",
|
||||
})
|
||||
for _, summary := range summaries {
|
||||
totalUpdates := summary.DoneUpdates + summary.Blocked + summary.Pending
|
||||
_ = cw.Write([]string{
|
||||
summary.User.DisplayName, summary.User.Username,
|
||||
strconv.Itoa(summary.PresentDays), strconv.Itoa(summary.RemoteDays), strconv.Itoa(summary.LeaveDays),
|
||||
strconv.Itoa(summary.DoneUpdates), strconv.Itoa(summary.Blocked), strconv.Itoa(summary.Pending),
|
||||
strconv.Itoa(totalUpdates),
|
||||
})
|
||||
}
|
||||
cw.Flush()
|
||||
}
|
||||
|
||||
func (s *Server) redirectError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
http.Redirect(w, r, "/?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user