added kanban and reporting

This commit is contained in:
2026-07-28 02:20:13 +03:30
parent a5aed05b98
commit b129d871c3
10 changed files with 1080 additions and 3 deletions
+231 -1
View File
@@ -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)
}