From b129d871c34c851cbadd103bd797f2ee03720646 Mon Sep 17 00:00:00 2001 From: nfel Date: Tue, 28 Jul 2026 02:20:13 +0330 Subject: [PATCH] added kanban and reporting --- README.md | 5 +- internal/app/server.go | 232 ++++++++++++++++++++++++- internal/app/server_test.go | 182 +++++++++++++++++++ internal/app/static/board.js | 68 ++++++++ internal/app/static/style.css | 152 ++++++++++++++++ internal/app/store.go | 261 ++++++++++++++++++++++++++++ internal/app/templates/base.html | 6 + internal/app/templates/board.html | 70 ++++++++ internal/app/templates/reports.html | 53 +++++- internal/app/templates/updates.html | 54 ++++++ 10 files changed, 1080 insertions(+), 3 deletions(-) create mode 100644 internal/app/static/board.js create mode 100644 internal/app/templates/board.html create mode 100644 internal/app/templates/updates.html diff --git a/README.md b/README.md index 328be23..ab48abe 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,12 @@ Hamkar is a small team presence tracker built for startups that work with the Pe - Office and remote check-in/check-out - Persian month calendar with attendance, approved leave, remote days, Fridays, and fixed Solar Hijri public holidays - Team day detail showing who is present, remote, absent, or expected at the office +- Period-based teammate work updates for completed, blocked, and pending tasks +- Shared Trello-style team board with assignment, Jalali due dates, and drag-and-drop columns - Time-off and remote-day requests with a dependency-free Jalali date picker and typed Persian-date fallback - Admin approval/rejection with review notes -- Excel-compatible UTF-8 CSV reports with both Persian and Gregorian dates +- Excel-compatible UTF-8 CSV reports for attendance and teammate work updates +- Accumulated per-teammate summaries combining office, remote, leave, and work-update results - CSRF protection, secure session cookies, security headers, and role checks - Responsive UI with no JavaScript build step diff --git a/internal/app/server.go b/internal/app/server.go index 3769a09..c60343d 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -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) } diff --git a/internal/app/server_test.go b/internal/app/server_test.go index 37887dd..85d3548 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -6,6 +6,7 @@ import ( "net/url" "path/filepath" "regexp" + "strconv" "strings" "testing" ) @@ -295,6 +296,187 @@ func TestTeamDayShowsPresentRemoteAndAbsent(t *testing.T) { } } +func TestWorkUpdateSubmissionFeedAndCSV(t *testing.T) { + s, err := New(Config{ + Addr: ":0", + BaseURL: "http://example.test", + DatabasePath: filepath.Join(t.TempDir(), "updates.db"), + }) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + login := formRequest(t, s.http.Handler, "/login", url.Values{ + "identifier": {"admin"}, + "password": {"admin123"}, + }, nil) + session := login.Result().Cookies()[0] + dashboardRequest := httptest.NewRequest(http.MethodGet, "/", nil) + dashboardRequest.AddCookie(session) + dashboardResponse := httptest.NewRecorder() + s.http.Handler.ServeHTTP(dashboardResponse, dashboardRequest) + csrf := extractCSRF(t, dashboardResponse.Body.String()) + + submission := formRequest(t, s.http.Handler, "/updates", url.Values{ + "csrf": {csrf}, + "period_start": {"1405-05-06"}, + "period_end": {"1405-05-09"}, + "status": {"blocked"}, + "note": {"Completed task ABC.\nBlocked on staging access; task XYZ is pending."}, + }, session) + if submission.Code != http.StatusSeeOther || submission.Header().Get("Location") != "/updates?flash=Work+update+shared" { + t.Fatalf("work update submission: got %d location %q body %s", submission.Code, submission.Header().Get("Location"), submission.Body.String()) + } + if err := s.store.CheckIn(1, "2026-07-28", "office"); err != nil { + t.Fatal(err) + } + if err := s.store.CheckIn(1, "2026-07-29", "remote"); err != nil { + t.Fatal(err) + } + if err := s.store.CreateRequest(1, "leave", "2026-07-30", "2026-07-31", "Time off"); err != nil { + t.Fatal(err) + } + leaveRequests, err := s.store.Requests(1, false, "pending") + if err != nil || len(leaveRequests) != 1 { + t.Fatalf("leave request: %#v, %v", leaveRequests, err) + } + if err := s.store.ReviewRequest(t.Context(), leaveRequests[0].ID, 1, "approved", "Approved"); err != nil { + t.Fatal(err) + } + + feedRequest := httptest.NewRequest(http.MethodGet, "/updates", nil) + feedRequest.AddCookie(session) + feedResponse := httptest.NewRecorder() + s.http.Handler.ServeHTTP(feedResponse, feedRequest) + if feedResponse.Code != http.StatusOK { + t.Fatalf("work update feed status: %d", feedResponse.Code) + } + for _, expected := range []string{"Completed task ABC.", "Blocked on staging access", "1405-05-06", "1405-05-09", "Blocked"} { + if !strings.Contains(feedResponse.Body.String(), expected) { + t.Fatalf("work update feed does not include %q", expected) + } + } + + exportRequest := httptest.NewRequest(http.MethodGet, "/reports/work-updates.csv?start=2026-07-01&end=2026-08-01", nil) + exportRequest.AddCookie(session) + exportResponse := httptest.NewRecorder() + s.http.Handler.ServeHTTP(exportResponse, exportRequest) + if exportResponse.Code != http.StatusOK || !strings.Contains(exportResponse.Body.String(), "Completed task ABC.") { + t.Fatalf("work update CSV: got %d body %s", exportResponse.Code, exportResponse.Body.String()) + } + if !strings.Contains(exportResponse.Header().Get("Content-Disposition"), "work-updates-") { + t.Fatalf("unexpected work update CSV filename: %s", exportResponse.Header().Get("Content-Disposition")) + } + + reportRequest := httptest.NewRequest(http.MethodGet, "/reports?start=2026-07-01&end=2026-08-01", nil) + reportRequest.AddCookie(session) + reportResponse := httptest.NewRecorder() + s.http.Handler.ServeHTTP(reportResponse, reportRequest) + if reportResponse.Code != http.StatusOK || !strings.Contains(reportResponse.Body.String(), "ACCUMULATED RESULT") { + t.Fatalf("accumulated report page: got %d body %s", reportResponse.Code, reportResponse.Body.String()) + } + + summaryRequest := httptest.NewRequest(http.MethodGet, "/reports/summary.csv?start=2026-07-01&end=2026-08-01", nil) + summaryRequest.AddCookie(session) + summaryResponse := httptest.NewRecorder() + s.http.Handler.ServeHTTP(summaryResponse, summaryRequest) + if summaryResponse.Code != http.StatusOK { + t.Fatalf("summary CSV status: %d body %s", summaryResponse.Code, summaryResponse.Body.String()) + } + if !strings.Contains(summaryResponse.Body.String(), "Workspace Admin,admin,1,1,2,0,1,0,1") { + t.Fatalf("summary CSV did not contain accumulated values: %s", summaryResponse.Body.String()) + } +} + +func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) { + s, err := New(Config{ + Addr: ":0", + BaseURL: "http://example.test", + DatabasePath: filepath.Join(t.TempDir(), "board.db"), + }) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + memberID, err := s.store.CreateUser("board-member", "secure-password", "Board Member", "board@example.test", "member") + if err != nil { + t.Fatal(err) + } + adminToken, adminCSRF, err := s.store.CreateSession(1) + if err != nil { + t.Fatal(err) + } + adminCookie := &http.Cookie{Name: "teammate_session", Value: adminToken} + create := formRequest(t, s.http.Handler, "/board/tasks", url.Values{ + "csrf": {adminCSRF}, + "title": {"Ship the onboarding flow"}, + "description": {"Finish QA and publish the release."}, + "status": {"backlog"}, + "assignee_id": {strconv.FormatInt(memberID, 10)}, + "due_date": {"1405-05-06"}, + }, adminCookie) + if create.Code != http.StatusSeeOther || create.Header().Get("Location") != "/board?flash=Task+added+to+the+team+board" { + t.Fatalf("create board task: got %d location %q body %s", create.Code, create.Header().Get("Location"), create.Body.String()) + } + tasks, err := s.store.BoardTasks() + if err != nil || len(tasks) != 1 { + t.Fatalf("board tasks after create: %#v, %v", tasks, err) + } + if tasks[0].AssigneeName != "Board Member" || tasks[0].DueDate != "2026-07-28" { + t.Fatalf("unexpected assigned board task: %#v", tasks[0]) + } + + memberToken, memberCSRF, err := s.store.CreateSession(memberID) + if err != nil { + t.Fatal(err) + } + memberCookie := &http.Cookie{Name: "teammate_session", Value: memberToken} + boardRequest := httptest.NewRequest(http.MethodGet, "/board", nil) + boardRequest.AddCookie(memberCookie) + boardResponse := httptest.NewRecorder() + s.http.Handler.ServeHTTP(boardResponse, boardRequest) + if boardResponse.Code != http.StatusOK { + t.Fatalf("board page status: %d body %s", boardResponse.Code, boardResponse.Body.String()) + } + for _, expected := range []string{"Ship the onboarding flow", "Board Member", "1405-05-06", "/static/board.js", `data-board-column="backlog"`} { + if !strings.Contains(boardResponse.Body.String(), expected) { + t.Fatalf("board page does not include %q", expected) + } + } + + movePath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/move" + move := formRequest(t, s.http.Handler, movePath, url.Values{ + "csrf": {memberCSRF}, + "status": {"in_progress"}, + }, memberCookie) + if move.Code != http.StatusSeeOther { + t.Fatalf("move board task status: %d", move.Code) + } + tasks, _ = s.store.BoardTasks() + if tasks[0].Status != "in_progress" { + t.Fatalf("task did not move: %#v", tasks[0]) + } + + deletePath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/delete" + deniedDelete := formRequest(t, s.http.Handler, deletePath, url.Values{"csrf": {memberCSRF}}, memberCookie) + if deniedDelete.Code != http.StatusSeeOther || !strings.Contains(deniedDelete.Header().Get("Location"), "error=") { + t.Fatalf("non-creator delete was not rejected: %d %q", deniedDelete.Code, deniedDelete.Header().Get("Location")) + } + if remaining, _ := s.store.BoardTasks(); len(remaining) != 1 { + t.Fatal("non-creator removed a board task") + } + + adminDelete := formRequest(t, s.http.Handler, deletePath, url.Values{"csrf": {adminCSRF}}, adminCookie) + if adminDelete.Code != http.StatusSeeOther { + t.Fatalf("admin delete status: %d", adminDelete.Code) + } + if remaining, _ := s.store.BoardTasks(); len(remaining) != 0 { + t.Fatal("admin could not remove the board task") + } +} + func formRequest(t *testing.T, handler http.Handler, path string, values url.Values, cookie *http.Cookie) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode())) diff --git a/internal/app/static/board.js b/internal/app/static/board.js new file mode 100644 index 0000000..f8590d3 --- /dev/null +++ b/internal/app/static/board.js @@ -0,0 +1,68 @@ +(function () { + "use strict"; + + document.addEventListener("DOMContentLoaded", function () { + var board = document.querySelector("[data-board]"); + if (!board) { + return; + } + + var dragged = null; + var csrf = board.dataset.csrf; + + board.querySelectorAll("[data-task-id]").forEach(function (card) { + card.addEventListener("dragstart", function (event) { + dragged = card; + card.classList.add("dragging"); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", card.dataset.taskId); + }); + card.addEventListener("dragend", function () { + card.classList.remove("dragging"); + board.querySelectorAll(".drag-over").forEach(function (zone) { + zone.classList.remove("drag-over"); + }); + dragged = null; + }); + }); + + board.querySelectorAll("[data-drop-zone]").forEach(function (zone) { + zone.addEventListener("dragover", function (event) { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + zone.classList.add("drag-over"); + }); + zone.addEventListener("dragleave", function (event) { + if (!zone.contains(event.relatedTarget)) { + zone.classList.remove("drag-over"); + } + }); + zone.addEventListener("drop", function (event) { + event.preventDefault(); + zone.classList.remove("drag-over"); + var taskID = event.dataTransfer.getData("text/plain"); + var status = zone.dataset.dropZone; + if (!taskID || !dragged || dragged.closest("[data-board-column]").dataset.boardColumn === status) { + return; + } + + var body = new URLSearchParams({ csrf: csrf, status: status }); + dragged.classList.add("moving"); + fetch("/board/tasks/" + encodeURIComponent(taskID) + "/move", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + credentials: "same-origin" + }).then(function (response) { + if (!response.ok) { + throw new Error("Could not move task"); + } + window.location.assign("/board?flash=Task+moved"); + }).catch(function () { + dragged.classList.remove("moving"); + window.alert("The task could not be moved. Please reload and try again."); + }); + }); + }); + }); +})(); diff --git a/internal/app/static/style.css b/internal/app/static/style.css index e95ce87..97b7f25 100644 --- a/internal/app/static/style.css +++ b/internal/app/static/style.css @@ -137,6 +137,8 @@ h2 { margin-bottom: 8px; font-size: 1.25rem; letter-spacing: -.025em; } .badge.approved { background: #e3f0e8; color: #337052; } .badge.rejected, .badge.cancelled { background: #f5e6e4; color: #9d4a44; } .badge.kind { background: #edf0ee; color: #68736e; } +.badge.done { background: #e3f0e8; color: #337052; } +.badge.blocked { background: #f5e6e4; color: #9d4a44; } .empty { padding: 22px; color: var(--muted); text-align: center; font-size: .76rem; } .empty > span { color: #9ca9a2; font-size: 1.5rem; } .empty a { color: var(--green); font-weight: 700; } @@ -207,6 +209,115 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; .report-card { display: grid; grid-template-columns: 75px 1fr; gap: 20px; max-width: 850px; margin: auto; padding: 32px; } .report-illustration { display: grid; width: 66px; height: 66px; place-items: center; border-radius: 18px; background: var(--mint); color: var(--green); font-size: 1.7rem; } .report-form { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; align-items: end; padding-top: 20px; border-top: 1px solid var(--line); } +.secondary-report { margin-top: 20px; } +.report-page-head { align-items: center; } +.report-range-filter { display: grid; grid-template-columns: 145px 145px auto; gap: 9px; align-items: end; padding: 10px; border: 1px solid var(--line); border-radius: 12px; background: var(--white); box-shadow: var(--shadow); } +.report-range-filter input { margin-top: 4px; padding-top: 8px; padding-bottom: 8px; } +.report-range-filter .button { min-height: 39px; } +.accumulated-report { max-width: 1180px; margin: 0 auto 20px; overflow: hidden; } +.accumulated-head { display: flex; justify-content: space-between; align-items: center; padding: 25px 28px 20px; } +.accumulated-head h2 { margin-bottom: 3px; } +.accumulated-head p:last-child { margin: 0; color: var(--muted); font-size: .7rem; } +.accumulated-totals { display: grid; grid-template-columns: repeat(6, 1fr); margin: 0 28px 24px; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); } +.accumulated-totals > div { display: grid; grid-template-columns: 31px 1fr; column-gap: 8px; align-items: center; min-width: 0; padding: 13px 12px; border-right: 1px solid var(--line); } +.accumulated-totals > div:last-child { border-right: 0; } +.accumulated-totals .stat-icon { grid-row: span 2; margin: 0; } +.accumulated-totals small { overflow: hidden; color: var(--muted); font-size: .53rem; font-weight: 700; letter-spacing: .06em; text-overflow: ellipsis; white-space: nowrap; } +.accumulated-totals strong { font-size: 1.25rem; line-height: 1; } +.blocked-icon { background: #f5e6e4; color: #a34d47; } +.pending-icon { background: #f6eddc; color: #87662c; } +.summary-table-wrap { overflow-x: auto; border-top: 1px solid var(--line); } +.summary-table { width: 100%; border-collapse: collapse; font-size: .73rem; } +.summary-table th { padding: 11px 15px; background: #f8faf8; color: var(--muted); font-size: .56rem; font-weight: 700; letter-spacing: .07em; text-align: center; text-transform: uppercase; white-space: nowrap; } +.summary-table th:first-child, .summary-table td:first-child { padding-left: 28px; text-align: left; } +.summary-table td { padding: 13px 15px; border-top: 1px solid var(--line); text-align: center; } +.summary-person { display: inline-flex; align-items: center; gap: 10px; min-width: 185px; text-align: left; } +.summary-person .avatar { width: 34px; height: 34px; flex: 0 0 34px; } +.summary-person img.avatar { display: block; object-fit: cover; } +.summary-person strong { display: block; font-size: .74rem; } +.summary-person small { display: block; margin-top: 2px; color: var(--muted); font-size: .6rem; } +.metric { display: inline-grid; min-width: 25px; height: 25px; place-items: center; border-radius: 7px; font-weight: 700; } +.metric.done { background: #e3f0e8; color: #337052; } +.metric.blocked { background: #f5e6e4; color: #9d4a44; } +.metric.pending { background: #f6eddc; color: #87662c; } +.report-export-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; max-width: 1180px; margin: auto; } +.report-export-grid .report-card { grid-template-columns: 60px 1fr; max-width: none; margin: 0; padding: 25px; } +.report-export-grid .secondary-report { margin-top: 0; } +.report-export-grid .report-illustration { width: 55px; height: 55px; border-radius: 15px; } +.report-export-grid .report-form { grid-template-columns: 1fr 1fr; } +.report-export-grid .report-form .button { grid-column: 1 / -1; } + +.board-page-head { position: relative; align-items: center; } +.new-task-panel { position: relative; } +.new-task-panel > summary { list-style: none; user-select: none; } +.new-task-panel > summary::-webkit-details-marker { display: none; } +.new-task-panel[open] > summary { background: var(--ink); } +.new-task-form { position: absolute; z-index: 60; top: calc(100% + 9px); right: 0; display: grid; gap: 14px; width: min(430px, calc(100vw - 40px)); padding: 22px; border: 1px solid var(--line); border-radius: 15px; background: var(--white); box-shadow: 0 22px 60px rgba(24, 36, 31, .22); } +.new-task-form-head h2 { margin-bottom: 0; } +.kanban-board { display: grid; grid-template-columns: repeat(4, minmax(270px, 1fr)); gap: 14px; align-items: start; max-width: 1380px; margin: 0 auto; overflow-x: auto; padding-bottom: 18px; } +.kanban-column { min-width: 270px; padding: 13px; border: 1px solid var(--line); border-radius: 15px; background: #eef2ef; } +.kanban-column-head { display: flex; justify-content: space-between; align-items: start; min-height: 51px; padding: 4px 5px 12px; } +.kanban-column-head > div { display: grid; grid-template-columns: 9px 1fr; column-gap: 8px; align-items: center; } +.column-dot { width: 8px; height: 8px; border-radius: 50%; background: #87938d; } +.kanban-column.in_progress .column-dot { background: #6689b2; } +.kanban-column.blocked .column-dot { background: #c2655c; } +.kanban-column.done .column-dot { background: #4d9870; } +.kanban-column-head strong { font-size: .79rem; } +.kanban-column-head small { grid-column: 2; margin-top: 2px; color: var(--muted); font-size: .58rem; } +.kanban-column-head > b { display: grid; min-width: 23px; height: 23px; place-items: center; border-radius: 20px; background: var(--white); color: var(--muted); font-size: .62rem; } +.kanban-cards { display: grid; gap: 9px; min-height: 100px; border-radius: 11px; transition: background .16s ease, box-shadow .16s ease; } +.kanban-cards.drag-over { background: rgba(70, 126, 100, .1); box-shadow: inset 0 0 0 2px #6c9c85; } +.kanban-card { padding: 14px; border: 1px solid #dfe4e1; border-radius: 11px; background: var(--white); box-shadow: 0 3px 10px rgba(24, 36, 31, .045); cursor: grab; transition: opacity .15s ease, transform .15s ease, box-shadow .15s ease; } +.kanban-card:hover { transform: translateY(-1px); box-shadow: 0 7px 18px rgba(24, 36, 31, .09); } +.kanban-card.dragging { opacity: .45; transform: rotate(1.5deg); } +.kanban-card.moving { opacity: .35; pointer-events: none; } +.card-topline { display: flex; justify-content: space-between; align-items: center; margin-bottom: 9px; } +.task-id { color: #9aa39e; font-size: .58rem; font-weight: 700; } +.task-due { padding: 4px 6px; border-radius: 6px; background: var(--sand); color: #786039; font-size: .55rem; font-weight: 700; } +.kanban-card h3 { margin-bottom: 7px; font-size: .84rem; line-height: 1.35; } +.kanban-card > p { display: -webkit-box; overflow: hidden; margin-bottom: 13px; color: #64706a; font-size: .68rem; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 4; } +.task-meta { display: flex; justify-content: space-between; gap: 8px; align-items: center; padding-top: 10px; border-top: 1px solid var(--line); } +.task-assignee { display: flex; min-width: 0; gap: 6px; align-items: center; color: #53605a; font-size: .61rem; font-weight: 700; } +.task-assignee i { display: grid; width: 24px; height: 24px; flex: 0 0 24px; place-items: center; border-radius: 50%; background: var(--blue); color: #4d6e94; font-size: .59rem; font-style: normal; } +.task-assignee i.avatar { width: 24px; height: 24px; } +.task-assignee span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.task-assignee.unassigned { color: var(--muted); font-weight: 500; } +.task-creator { overflow: hidden; color: #9aa39e; font-size: .54rem; text-overflow: ellipsis; white-space: nowrap; } +.task-actions { display: flex; justify-content: space-between; align-items: center; margin: 11px -3px -5px; } +.task-actions > form:first-child { display: flex; gap: 4px; align-items: center; } +.task-actions select { width: auto; min-width: 104px; margin: 0; padding: 5px 25px 5px 7px; border: 0; background-color: var(--paper); font-size: .58rem; font-weight: 700; } +.task-actions .text-button { margin: 0; } +.task-move-button { display: none; } +.task-actions form:focus-within .task-move-button { display: inline-block; } +.task-remove { position: relative; color: var(--muted); font-size: .58rem; } +.task-remove > summary { cursor: pointer; list-style: none; text-decoration: underline; } +.task-remove > summary::-webkit-details-marker { display: none; } +.task-remove[open] > summary { visibility: hidden; } +.task-remove form { position: absolute; right: 0; bottom: -1px; } +.task-remove form .text-button { color: var(--red); font-weight: 700; } +.kanban-empty { display: grid; min-height: 74px; place-items: center; border: 1px dashed #b8c2bd; border-radius: 10px; color: var(--muted); font-size: .62rem; } + +.updates-layout { grid-template-columns: minmax(390px, .8fr) minmax(460px, 1.2fr); } +.update-form-card { align-self: start; } +.update-status-choices { grid-template-columns: repeat(3, 1fr); } +.update-status-choices span { grid-template-columns: 24px 1fr; padding: 11px 9px; } +.update-status-choices b { font-size: .86rem; } +.update-status-choices small { line-height: 1.3; } +.update-status-choices label:nth-child(1) input:checked + span { border-color: #5c8d77; background: #f2f8f5; box-shadow: inset 0 0 0 1px #5c8d77; } +.update-status-choices label:nth-child(2) input:checked + span { border-color: #b66b64; background: #fff5f3; box-shadow: inset 0 0 0 1px #b66b64; } +.update-status-choices label:nth-child(3) input:checked + span { border-color: #b08a4d; background: #fffaf0; box-shadow: inset 0 0 0 1px #b08a4d; } +.update-feed-card { align-self: start; padding: 26px; } +.feed-count { color: var(--muted); font-size: .68rem; } +.update-feed { margin: 0 -26px -12px; } +.update-entry { position: relative; padding: 20px 26px; border-top: 1px solid var(--line); } +.update-author { display: grid; grid-template-columns: 38px 1fr auto; gap: 11px; align-items: center; } +.update-author .avatar { width: 38px; height: 38px; } +.update-author img.avatar { display: block; object-fit: cover; } +.update-author strong { display: block; font-size: .79rem; } +.update-author small { display: block; margin-top: 2px; color: var(--muted); font-size: .63rem; } +.update-period { margin: 13px 0 7px; color: var(--green-2); font-size: .68rem; font-weight: 700; } +.update-note { margin: 0; color: #46534d; font-size: .78rem; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; } +.update-delete { text-align: right; } .day-page-head { align-items: center; } .day-date-form { display: grid; grid-template-columns: 190px auto; gap: 9px; align-items: end; padding: 11px; border: 1px solid var(--line); border-radius: 13px; background: var(--white); box-shadow: var(--shadow); } @@ -331,6 +442,25 @@ html[data-theme="dark"] .badge.approved { background: #1d3b2c; color: #8fd0ae; } html[data-theme="dark"] .badge.rejected, html[data-theme="dark"] .badge.cancelled { background: #432725; color: #efa09a; } html[data-theme="dark"] .badge.kind { background: #28332e; color: #b2bdb7; } +html[data-theme="dark"] .badge.done { background: #1d3b2c; color: #8fd0ae; } +html[data-theme="dark"] .badge.blocked { background: #432725; color: #efa09a; } +html[data-theme="dark"] .update-note { color: #bdc8c2; } +html[data-theme="dark"] .update-status-choices label:nth-child(1) input:checked + span { border-color: #5c9b7b; background: #1b3027; } +html[data-theme="dark"] .update-status-choices label:nth-child(2) input:checked + span { border-color: #b66b64; background: #372321; } +html[data-theme="dark"] .update-status-choices label:nth-child(3) input:checked + span { border-color: #a88951; background: #352f21; } +html[data-theme="dark"] .summary-table th { background: #141d18; } +html[data-theme="dark"] .metric.done { background: #1d3b2c; color: #8fd0ae; } +html[data-theme="dark"] .metric.blocked, +html[data-theme="dark"] .blocked-icon { background: #432725; color: #efa09a; } +html[data-theme="dark"] .metric.pending, +html[data-theme="dark"] .pending-icon { background: #453820; color: #e3bd72; } +html[data-theme="dark"] .new-task-form { box-shadow: 0 22px 65px rgba(0, 0, 0, .52); } +html[data-theme="dark"] .kanban-column { background: #141d18; } +html[data-theme="dark"] .kanban-card { border-color: #35443d; box-shadow: 0 4px 13px rgba(0, 0, 0, .22); } +html[data-theme="dark"] .kanban-card > p { color: #aab6b0; } +html[data-theme="dark"] .task-assignee { color: #bdc8c2; } +html[data-theme="dark"] .task-due { background: #3b3222; color: #dec18b; } +html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142, .1); } @media (max-width: 980px) { .app-shell { grid-template-columns: 74px 1fr; } @@ -348,8 +478,16 @@ html[data-theme="dark"] .badge.kind { background: #28332e; color: #b2bdb7; } .login-page { grid-template-columns: 1fr 1fr; } .story-calendar { display: none; } .two-column { grid-template-columns: 1fr; } + .updates-layout { grid-template-columns: 1fr; } .day-summary-card { grid-template-columns: 1fr; gap: 20px; } .day-stats { padding-top: 18px; border-top: 1px solid var(--line); border-left: 0; } + .report-page-head { display: block; } + .report-range-filter { width: max-content; max-width: 100%; margin-top: 18px; } + .accumulated-totals { grid-template-columns: repeat(3, 1fr); } + .accumulated-totals > div:nth-child(3) { border-right: 0; } + .accumulated-totals > div:nth-child(-n+3) { border-bottom: 1px solid var(--line); } + .report-export-grid { grid-template-columns: 1fr; } + .kanban-board { grid-template-columns: repeat(4, 285px); margin-right: calc(-1 * clamp(28px, 5vw, 68px)); padding-right: clamp(28px, 5vw, 68px); } } @media (max-width: 700px) { @@ -385,6 +523,20 @@ html[data-theme="dark"] .badge.kind { background: #28332e; color: #b2bdb7; } .review-form, .report-form { grid-template-columns: 1fr; } .report-card { grid-template-columns: 1fr; } .report-form { grid-column: auto; } + .report-range-filter { grid-template-columns: 1fr 1fr; width: 100%; } + .report-range-filter .button { grid-column: 1 / -1; } + .accumulated-head { display: block; padding: 20px 18px; } + .accumulated-head .button { margin-top: 14px; } + .accumulated-totals { grid-template-columns: repeat(2, 1fr); margin: 0 18px 20px; } + .accumulated-totals > div:nth-child(3) { border-right: 1px solid var(--line); } + .accumulated-totals > div:nth-child(even) { border-right: 0; } + .accumulated-totals > div:nth-child(-n+4) { border-bottom: 1px solid var(--line); } + .summary-table th:first-child, .summary-table td:first-child { padding-left: 18px; } + .report-export-grid .report-card { grid-template-columns: 1fr; } + .board-page-head { align-items: center; } + .new-task-form { position: fixed; top: 76px; right: 15px; left: 15px; width: auto; max-height: calc(100vh - 100px); overflow-y: auto; } + .kanban-board { grid-template-columns: repeat(4, 82vw); margin-right: -15px; padding-right: 0; padding-left: 15px; scroll-snap-type: x mandatory; } + .kanban-column { scroll-snap-align: start; } .filter-tabs { display: none; } .day-summary-card, .roster-card { padding: 18px; } .day-date-nav { grid-template-columns: 33px 1fr 33px; gap: 9px; } diff --git a/internal/app/store.go b/internal/app/store.go index b10aac3..70a82ba 100644 --- a/internal/app/store.go +++ b/internal/app/store.go @@ -64,6 +64,47 @@ type DayRosterRow struct { RequestKind string } +type WorkUpdate struct { + ID int64 + UserID int64 + UserName string + Username string + AvatarURL string + PeriodStart string + PeriodEnd string + Status string + Note string + CreatedAt string +} + +type PersonReportSummary struct { + User User + PresentDays int + RemoteDays int + LeaveDays int + DoneUpdates int + Blocked int + Pending int +} + +func (s PersonReportSummary) TotalUpdates() int { + return s.DoneUpdates + s.Blocked + s.Pending +} + +type BoardTask struct { + ID int64 + Title string + Description string + Status string + AssigneeID sql.NullInt64 + AssigneeName string + AssigneeUser string + CreatorID int64 + CreatorName string + DueDate string + CreatedAt string +} + type Store struct{ db *sql.DB } func OpenStore(path string) (*Store, error) { @@ -133,6 +174,28 @@ CREATE TABLE IF NOT EXISTS requests ( ); CREATE INDEX IF NOT EXISTS idx_attendance_day ON attendance(day); CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status, start_date); +CREATE TABLE IF NOT EXISTS work_updates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('done','blocked','pending')), + note TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK(period_end >= period_start) +); +CREATE INDEX IF NOT EXISTS idx_work_updates_period ON work_updates(period_start,period_end); +CREATE TABLE IF NOT EXISTS board_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'backlog' CHECK(status IN ('backlog','in_progress','blocked','done')), + assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + due_date TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_board_tasks_status ON board_tasks(status,created_at); ` if _, err := s.db.Exec(schema); err != nil { return err @@ -595,3 +658,201 @@ func (s *Store) DayRoster(day string) ([]DayRosterRow, error) { } return roster, rows.Err() } + +func (s *Store) CreateWorkUpdate(userID int64, start, end, status, note string) error { + note = strings.TrimSpace(note) + if status != "done" && status != "blocked" && status != "pending" { + return errors.New("choose a valid update status") + } + if note == "" { + return errors.New("describe the work, outcome, or blocker") + } + if len([]rune(note)) > 4000 { + return errors.New("work update must be 4,000 characters or fewer") + } + _, err := s.db.Exec(`INSERT INTO work_updates(user_id,period_start,period_end,status,note) VALUES(?,?,?,?,?)`, + userID, start, end, status, note) + return err +} + +func (s *Store) WorkUpdates(start, end string, limit int) ([]WorkUpdate, error) { + query := `SELECT w.id,w.user_id,u.display_name,u.username,u.avatar_url, + w.period_start,w.period_end,w.status,w.note,CAST(w.created_at AS TEXT) + FROM work_updates w JOIN users u ON u.id=w.user_id` + args := []any{} + if start != "" && end != "" { + query += ` WHERE w.period_start<=? AND w.period_end>=?` + args = append(args, end, start) + } + query += ` ORDER BY w.created_at DESC,w.id DESC` + if limit > 0 { + query += ` LIMIT ?` + args = append(args, limit) + } + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var updates []WorkUpdate + for rows.Next() { + var update WorkUpdate + if err := rows.Scan( + &update.ID, &update.UserID, &update.UserName, &update.Username, &update.AvatarURL, + &update.PeriodStart, &update.PeriodEnd, &update.Status, &update.Note, &update.CreatedAt, + ); err != nil { + return nil, err + } + updates = append(updates, update) + } + return updates, rows.Err() +} + +func (s *Store) DeleteWorkUpdate(id, userID int64) error { + result, err := s.db.Exec(`DELETE FROM work_updates WHERE id=? AND user_id=?`, id, userID) + if err != nil { + return err + } + count, _ := result.RowsAffected() + if count == 0 { + return errors.New("work update was not found") + } + return nil +} + +func (s *Store) ReportSummary(start, end string) ([]PersonReportSummary, error) { + rows, err := s.db.Query(`SELECT + u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url, + (SELECT COUNT(*) FROM attendance a + WHERE a.user_id=u.id AND a.day BETWEEN ? AND ? AND a.mode='office'), + (SELECT COUNT(*) FROM attendance a + WHERE a.user_id=u.id AND a.day BETWEEN ? AND ? AND a.mode='remote'), + COALESCE((SELECT CAST(SUM( + julianday(MIN(r.end_date,?))-julianday(MAX(r.start_date,?))+1 + ) AS INTEGER) FROM requests r + WHERE r.user_id=u.id AND r.kind='leave' AND r.status='approved' + AND r.start_date<=? AND r.end_date>=?),0), + (SELECT COUNT(*) FROM work_updates w + WHERE w.user_id=u.id AND w.status='done' AND w.period_start<=? AND w.period_end>=?), + (SELECT COUNT(*) FROM work_updates w + WHERE w.user_id=u.id AND w.status='blocked' AND w.period_start<=? AND w.period_end>=?), + (SELECT COUNT(*) FROM work_updates w + WHERE w.user_id=u.id AND w.status='pending' AND w.period_start<=? AND w.period_end>=?) + FROM users u ORDER BY u.display_name`, + start, end, + start, end, + end, start, end, start, + end, start, + end, start, + end, start, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var summaries []PersonReportSummary + for rows.Next() { + var summary PersonReportSummary + if err := rows.Scan( + &summary.User.ID, &summary.User.Username, &summary.User.DisplayName, &summary.User.Email, + &summary.User.Role, &summary.User.AvatarURL, &summary.PresentDays, &summary.RemoteDays, + &summary.LeaveDays, &summary.DoneUpdates, &summary.Blocked, &summary.Pending, + ); err != nil { + return nil, err + } + summaries = append(summaries, summary) + } + return summaries, rows.Err() +} + +func validBoardStatus(status string) bool { + return status == "backlog" || status == "in_progress" || status == "blocked" || status == "done" +} + +func (s *Store) CreateBoardTask(creatorID int64, title, description, status string, assigneeID *int64, dueDate string) error { + title = strings.TrimSpace(title) + description = strings.TrimSpace(description) + if title == "" { + return errors.New("task title is required") + } + if len([]rune(title)) > 160 || len([]rune(description)) > 4000 { + return errors.New("task title or description is too long") + } + if !validBoardStatus(status) { + status = "backlog" + } + var assignee any + if assigneeID != nil && *assigneeID > 0 { + assignee = *assigneeID + } + var due any + if dueDate != "" { + due = dueDate + } + _, err := s.db.Exec(`INSERT INTO board_tasks(title,description,status,assignee_id,creator_id,due_date) + VALUES(?,?,?,?,?,?)`, title, description, status, assignee, creatorID, due) + return err +} + +func (s *Store) BoardTasks() ([]BoardTask, error) { + rows, err := s.db.Query(`SELECT + t.id,t.title,t.description,t.status,t.assignee_id, + COALESCE(a.display_name,''),COALESCE(a.username,''), + t.creator_id,c.display_name,COALESCE(t.due_date,''),CAST(t.created_at AS TEXT) + FROM board_tasks t + JOIN users c ON c.id=t.creator_id + LEFT JOIN users a ON a.id=t.assignee_id + ORDER BY CASE t.status + WHEN 'backlog' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'blocked' THEN 2 ELSE 3 END, + t.created_at,t.id`) + if err != nil { + return nil, err + } + defer rows.Close() + var tasks []BoardTask + for rows.Next() { + var task BoardTask + if err := rows.Scan( + &task.ID, &task.Title, &task.Description, &task.Status, &task.AssigneeID, + &task.AssigneeName, &task.AssigneeUser, &task.CreatorID, &task.CreatorName, + &task.DueDate, &task.CreatedAt, + ); err != nil { + return nil, err + } + tasks = append(tasks, task) + } + return tasks, rows.Err() +} + +func (s *Store) MoveBoardTask(id int64, status string) error { + if !validBoardStatus(status) { + return errors.New("invalid board column") + } + result, err := s.db.Exec(`UPDATE board_tasks SET status=? WHERE id=?`, status, id) + if err != nil { + return err + } + count, _ := result.RowsAffected() + if count == 0 { + return errors.New("task was not found") + } + return nil +} + +func (s *Store) DeleteBoardTask(id, userID int64, admin bool) error { + query := `DELETE FROM board_tasks WHERE id=?` + args := []any{id} + if !admin { + query += ` AND creator_id=?` + args = append(args, userID) + } + result, err := s.db.Exec(query, args...) + if err != nil { + return err + } + count, _ := result.RowsAffected() + if count == 0 { + return errors.New("only the creator or an admin can remove this task") + } + return nil +} diff --git a/internal/app/templates/base.html b/internal/app/templates/base.html index 5865356..7334465 100644 --- a/internal/app/templates/base.html +++ b/internal/app/templates/base.html @@ -38,6 +38,12 @@ My requests + + Work updates + + + Team board + {{if eq .User.Role "admin"}} diff --git a/internal/app/templates/board.html b/internal/app/templates/board.html new file mode 100644 index 0000000..8222f74 --- /dev/null +++ b/internal/app/templates/board.html @@ -0,0 +1,70 @@ +{{define "board.html"}} +{{template "shell-start" .}} +
+

SHARED WORKSPACE

Team board

Plan, assign, and move work forward together.

+
+ + Add task +
+ +

NEW CARD

Add work to the board

+ + +
+ + +
+ + +
+
+
+{{template "notice" .}} + +
+ {{range .Board}} +
+
+
{{.Title}}{{.Hint}}
+ {{len .Tasks}} +
+
+ {{range .Tasks}} +
+
#{{.ID}}{{if .DueDate}}Due {{dateFA .DueDate}}{{end}}
+

{{.Title}}

+ {{if .Description}}

{{.Description}}

{{end}} +
+ {{if .AssigneeID.Valid}}{{initial .AssigneeName}}{{.AssigneeName}}{{else}}?Unassigned{{end}} + by {{.CreatorName}} +
+
+
+ + + +
+ {{if or (eq .CreatorID $.User.ID) (eq $.User.Role "admin")}} +
+ Remove +
+ + +
+
+ {{end}} +
+
+ {{end}} + {{if not .Tasks}}
Drop tasks here
{{end}} +
+
+ {{end}} +
+ +{{template "shell-end" .}} +{{end}} diff --git a/internal/app/templates/reports.html b/internal/app/templates/reports.html index 8062ab2..1cc9d46 100644 --- a/internal/app/templates/reports.html +++ b/internal/app/templates/reports.html @@ -1,6 +1,47 @@ {{define "reports.html"}} {{template "shell-start" .}} -

ADMIN

Reports

Export team presence data for payroll or analysis.

+
+

ADMIN

Reports

Review accumulated attendance and work updates for a selected period.

+
+ + + +
+
+ +
+
+

ACCUMULATED RESULT

Team summary

{{dateFA .ReportStart}} — {{dateFA .ReportEnd}} · {{len .ReportSummary}} teammates

+
Download combined CSV +
+
+
OFFICE DAYS{{.ReportTotals.Present}}
+
REMOTE DAYS{{.ReportTotals.Remote}}
+
TIME OFF{{.ReportTotals.Leave}}
+
DONE UPDATES{{.ReportTotals.Done}}
+
!BLOCKED{{.ReportTotals.Blocked}}
+
PENDING{{.ReportTotals.Pending}}
+
+
+ + + + {{range .ReportSummary}} + + + + + + + + + {{end}} + +
TeammateOfficeRemoteTime offDoneBlockedPendingUpdates
{{if .User.AvatarURL}}{{else}}{{initial .User.DisplayName}}{{end}}{{.User.DisplayName}}@{{.User.Username}}{{.PresentDays}}{{.RemoteDays}}{{.LeaveDays}}{{.DoneUpdates}}{{.Blocked}}{{.Pending}}{{.TotalUpdates}}
+
+
+ +

ATTENDANCE EXPORT

Download presence records

The UTF-8 CSV includes Gregorian and Persian dates, check-in/out time, location, and approved requests. It opens directly in Excel.

@@ -10,5 +51,15 @@
+
+
+

WORK UPDATE EXPORT

Download teammate reports

Includes each teammate’s period, status, and free-form notes about completed, blocked, or pending work.

+
+ + + +
+
+
{{template "shell-end" .}} {{end}} diff --git a/internal/app/templates/updates.html b/internal/app/templates/updates.html new file mode 100644 index 0000000..3252eeb --- /dev/null +++ b/internal/app/templates/updates.html @@ -0,0 +1,54 @@ +{{define "updates.html"}} +{{template "shell-start" .}} +
+

TEAM REPORTING

Work updates

Share completed tasks, pending work, and anything blocking progress.

+
+{{template "notice" .}} +
+
+

NEW UPDATE

What did you work on?

+
+ +
+ + + +
+
+ + +
+ + +
+
+ +
+

TEAM FEED

Latest updates

{{len .WorkUpdates}} reports
+ {{if .WorkUpdates}} +
+ {{range .WorkUpdates}} +
+
+ {{if .AvatarURL}}{{else}}{{initial .UserName}}{{end}} +
{{.UserName}}@{{.Username}}
+ {{statusLabel .Status}} +
+

{{dateFA .PeriodStart}}{{if ne .PeriodStart .PeriodEnd}} — {{dateFA .PeriodEnd}}{{end}}

+

{{.Note}}

+ {{if eq .UserID $.User.ID}} +
+ + +
+ {{end}} +
+ {{end}} +
+ {{else}} +

No updates yet

Share the first update with your team.

+ {{end}} +
+
+{{template "shell-end" .}} +{{end}}