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)
}
+182
View File
@@ -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()))
+68
View File
@@ -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.");
});
});
});
});
})();
+152
View File
@@ -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; }
+261
View File
@@ -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
}
+6
View File
@@ -38,6 +38,12 @@
<a href="/requests" class="{{if eq .SelectedSection "requests"}}active{{end}}">
<span class="nav-icon"></span> My requests
</a>
<a href="/updates" class="{{if eq .SelectedSection "updates"}}active{{end}}">
<span class="nav-icon"></span> Work updates
</a>
<a href="/board" class="{{if eq .SelectedSection "board"}}active{{end}}">
<span class="nav-icon"></span> Team board
</a>
{{if eq .User.Role "admin"}}
<p class="nav-label">ADMIN</p>
<a href="/admin/requests" class="{{if eq .SelectedSection "admin"}}active{{end}}">
+70
View File
@@ -0,0 +1,70 @@
{{define "board.html"}}
{{template "shell-start" .}}
<header class="page-head board-page-head">
<div><p class="eyebrow">SHARED WORKSPACE</p><h1>Team board</h1><p>Plan, assign, and move work forward together.</p></div>
<details class="new-task-panel" {{if .Error}}open{{end}}>
<summary class="button primary"> Add task</summary>
<form method="post" action="/board/tasks" class="new-task-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="new-task-form-head"><div><p class="eyebrow">NEW CARD</p><h2>Add work to the board</h2></div></div>
<label>Task title<input name="title" required maxlength="160" autofocus placeholder="What needs to be done?"></label>
<label>Description <small>Optional</small><textarea name="description" rows="4" maxlength="4000" placeholder="Add context, acceptance criteria, or links…"></textarea></label>
<div class="form-row">
<label>Assignee<select name="assignee_id"><option value="">Unassigned</option>{{range .Users}}<option value="{{.ID}}">{{.DisplayName}}</option>{{end}}</select></label>
<label>Column<select name="status"><option value="backlog">Backlog</option><option value="in_progress">In progress</option><option value="blocked">Blocked</option><option value="done">Done</option></select></label>
</div>
<label>Due date <small>Optional · Persian</small><span class="jalali-field"><input name="due_date" data-jalali-picker autocomplete="off" inputmode="numeric" placeholder="{{.TodayJalali}}" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"><button type="button" class="jalali-trigger" aria-label="Choose due date" title="Open Persian calendar"></button></span></label>
<button class="button dark full" type="submit">Create task</button>
</form>
</details>
</header>
{{template "notice" .}}
<div class="kanban-board" data-board data-csrf="{{.CSRF}}">
{{range .Board}}
<section class="kanban-column {{.Status}}" data-board-column="{{.Status}}">
<header class="kanban-column-head">
<div><span class="column-dot"></span><strong>{{.Title}}</strong><small>{{.Hint}}</small></div>
<b>{{len .Tasks}}</b>
</header>
<div class="kanban-cards" data-drop-zone="{{.Status}}">
{{range .Tasks}}
<article class="kanban-card" draggable="true" data-task-id="{{.ID}}">
<div class="card-topline"><span class="task-id">#{{.ID}}</span>{{if .DueDate}}<span class="task-due">Due {{dateFA .DueDate}}</span>{{end}}</div>
<h3>{{.Title}}</h3>
{{if .Description}}<p>{{.Description}}</p>{{end}}
<div class="task-meta">
{{if .AssigneeID.Valid}}<span class="task-assignee"><i class="avatar">{{initial .AssigneeName}}</i><span>{{.AssigneeName}}</span></span>{{else}}<span class="task-assignee unassigned"><i>?</i><span>Unassigned</span></span>{{end}}
<span class="task-creator">by {{.CreatorName}}</span>
</div>
<div class="task-actions">
<form method="post" action="/board/tasks/{{.ID}}/move">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<select name="status" aria-label="Move task">
<option value="backlog" {{if eq .Status "backlog"}}selected{{end}}>Backlog</option>
<option value="in_progress" {{if eq .Status "in_progress"}}selected{{end}}>In progress</option>
<option value="blocked" {{if eq .Status "blocked"}}selected{{end}}>Blocked</option>
<option value="done" {{if eq .Status "done"}}selected{{end}}>Done</option>
</select>
<button class="text-button task-move-button" type="submit">Move</button>
</form>
{{if or (eq .CreatorID $.User.ID) (eq $.User.Role "admin")}}
<details class="task-remove">
<summary>Remove</summary>
<form method="post" action="/board/tasks/{{.ID}}/delete">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="text-button" type="submit">Confirm</button>
</form>
</details>
{{end}}
</div>
</article>
{{end}}
{{if not .Tasks}}<div class="kanban-empty"><span>Drop tasks here</span></div>{{end}}
</div>
</section>
{{end}}
</div>
<script src="/static/board.js" defer></script>
{{template "shell-end" .}}
{{end}}
+52 -1
View File
@@ -1,6 +1,47 @@
{{define "reports.html"}}
{{template "shell-start" .}}
<header class="page-head"><div><p class="eyebrow">ADMIN</p><h1>Reports</h1><p>Export team presence data for payroll or analysis.</p></div></header>
<header class="page-head report-page-head">
<div><p class="eyebrow">ADMIN</p><h1>Reports</h1><p>Review accumulated attendance and work updates for a selected period.</p></div>
<form method="get" action="/reports" class="report-range-filter">
<label>From<input type="date" name="start" value="{{.ReportStart}}" required></label>
<label>To<input type="date" name="end" value="{{.ReportEnd}}" required></label>
<button class="button dark">Apply period</button>
</form>
</header>
<section class="card accumulated-report">
<header class="accumulated-head">
<div><p class="eyebrow">ACCUMULATED RESULT</p><h2>Team summary</h2><p>{{dateFA .ReportStart}} — {{dateFA .ReportEnd}} · {{len .ReportSummary}} teammates</p></div>
<a class="button secondary" href="/reports/summary.csv?start={{.ReportStart}}&end={{.ReportEnd}}">Download combined CSV</a>
</header>
<div class="accumulated-totals">
<div><span class="stat-icon mint"></span><small>OFFICE DAYS</small><strong>{{.ReportTotals.Present}}</strong></div>
<div><span class="stat-icon blue"></span><small>REMOTE DAYS</small><strong>{{.ReportTotals.Remote}}</strong></div>
<div><span class="stat-icon sand"></span><small>TIME OFF</small><strong>{{.ReportTotals.Leave}}</strong></div>
<div><span class="stat-icon mint"></span><small>DONE UPDATES</small><strong>{{.ReportTotals.Done}}</strong></div>
<div><span class="stat-icon blocked-icon">!</span><small>BLOCKED</small><strong>{{.ReportTotals.Blocked}}</strong></div>
<div><span class="stat-icon pending-icon"></span><small>PENDING</small><strong>{{.ReportTotals.Pending}}</strong></div>
</div>
<div class="summary-table-wrap">
<table class="summary-table">
<thead><tr><th>Teammate</th><th>Office</th><th>Remote</th><th>Time off</th><th>Done</th><th>Blocked</th><th>Pending</th><th>Updates</th></tr></thead>
<tbody>
{{range .ReportSummary}}
<tr>
<td><span class="summary-person">{{if .User.AvatarURL}}<img class="avatar" src="{{.User.AvatarURL}}" alt="">{{else}}<span class="avatar">{{initial .User.DisplayName}}</span>{{end}}<span><strong>{{.User.DisplayName}}</strong><small>@{{.User.Username}}</small></span></span></td>
<td>{{.PresentDays}}</td><td>{{.RemoteDays}}</td><td>{{.LeaveDays}}</td>
<td><span class="metric done">{{.DoneUpdates}}</span></td>
<td><span class="metric blocked">{{.Blocked}}</span></td>
<td><span class="metric pending">{{.Pending}}</span></td>
<td><strong>{{.TotalUpdates}}</strong></td>
</tr>
{{end}}
</tbody>
</table>
</div>
</section>
<div class="report-export-grid">
<section class="card report-card">
<div class="report-illustration"></div>
<div><p class="eyebrow">ATTENDANCE EXPORT</p><h2>Download presence records</h2><p class="muted">The UTF-8 CSV includes Gregorian and Persian dates, check-in/out time, location, and approved requests. It opens directly in Excel.</p></div>
@@ -10,5 +51,15 @@
<button class="button primary">Download CSV</button>
</form>
</section>
<section class="card report-card secondary-report">
<div class="report-illustration"></div>
<div><p class="eyebrow">WORK UPDATE EXPORT</p><h2>Download teammate reports</h2><p class="muted">Includes each teammates period, status, and free-form notes about completed, blocked, or pending work.</p></div>
<form method="get" action="/reports/work-updates.csv" class="report-form">
<label>From<input type="date" name="start" value="{{.ReportStart}}" required></label>
<label>To<input type="date" name="end" value="{{.ReportEnd}}" required></label>
<button class="button primary">Download CSV</button>
</form>
</section>
</div>
{{template "shell-end" .}}
{{end}}
+54
View File
@@ -0,0 +1,54 @@
{{define "updates.html"}}
{{template "shell-start" .}}
<header class="page-head">
<div><p class="eyebrow">TEAM REPORTING</p><h1>Work updates</h1><p>Share completed tasks, pending work, and anything blocking progress.</p></div>
</header>
{{template "notice" .}}
<div class="two-column updates-layout">
<section class="card form-card update-form-card">
<p class="eyebrow">NEW UPDATE</p><h2>What did you work on?</h2>
<form method="post" action="/updates" class="stack-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<fieldset class="choice-cards update-status-choices">
<label><input type="radio" name="status" value="done" checked><span><b></b><strong>Done</strong><small>Completed work</small></span></label>
<label><input type="radio" name="status" value="blocked"><span><b>!</b><strong>Blocked</strong><small>Needs attention</small></span></label>
<label><input type="radio" name="status" value="pending"><span><b></b><strong>Pending</strong><small>Still in progress</small></span></label>
</fieldset>
<div class="form-row">
<label>Period start <small>Persian date</small><span class="jalali-field"><input name="period_start" value="{{.TodayJalali}}" data-jalali-picker data-jalali-role="start" required autocomplete="off" inputmode="numeric" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"><button type="button" class="jalali-trigger" aria-label="Choose period start" title="Open Persian calendar"></button></span></label>
<label>Period end <small>Persian date</small><span class="jalali-field"><input name="period_end" value="{{.TodayJalali}}" data-jalali-picker data-jalali-role="end" required autocomplete="off" inputmode="numeric" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"><button type="button" class="jalali-trigger" aria-label="Choose period end" title="Open Persian calendar"></button></span></label>
</div>
<label>Update <small>Required</small><textarea name="note" rows="7" maxlength="4000" required placeholder="Example: Completed task ABC and deployed the API. Task XYZ is pending review. Blocked on access to the staging server."></textarea></label>
<button class="button primary full">Share update</button>
</form>
</section>
<section class="card update-feed-card">
<header class="section-head"><div><p class="eyebrow">TEAM FEED</p><h2>Latest updates</h2></div><span class="feed-count">{{len .WorkUpdates}} reports</span></header>
{{if .WorkUpdates}}
<div class="update-feed">
{{range .WorkUpdates}}
<article class="update-entry">
<div class="update-author">
{{if .AvatarURL}}<img class="avatar" src="{{.AvatarURL}}" alt="">{{else}}<span class="avatar">{{initial .UserName}}</span>{{end}}
<div><strong>{{.UserName}}</strong><small>@{{.Username}}</small></div>
<span class="badge {{.Status}}">{{statusLabel .Status}}</span>
</div>
<p class="update-period">{{dateFA .PeriodStart}}{{if ne .PeriodStart .PeriodEnd}} — {{dateFA .PeriodEnd}}{{end}}</p>
<p class="update-note">{{.Note}}</p>
{{if eq .UserID $.User.ID}}
<form method="post" action="/updates/{{.ID}}/delete" class="update-delete">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="text-button" type="submit">Remove my update</button>
</form>
{{end}}
</article>
{{end}}
</div>
{{else}}
<div class="empty roomy"><span></span><h3>No updates yet</h3><p>Share the first update with your team.</p></div>
{{end}}
</section>
</div>
{{template "shell-end" .}}
{{end}}