This commit is contained in:
+74
-4
@@ -95,6 +95,7 @@ type PageData struct {
|
||||
ReportSummary []PersonReportSummary
|
||||
ReportTotals ReportTotals
|
||||
Board []BoardColumn
|
||||
BoardTags []BoardTag
|
||||
}
|
||||
|
||||
type Stats struct{ Present, Remote, Leave int }
|
||||
@@ -207,6 +208,14 @@ func New(cfg Config) (*Server, error) {
|
||||
r, _ := utf8.DecodeRuneInString(v)
|
||||
return string(r)
|
||||
},
|
||||
"hasTag": func(tags []BoardTag, id int64) bool {
|
||||
for _, tag := range tags {
|
||||
if tag.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
tmpl, err := template.New("root").Funcs(funcs).ParseFS(assets, "templates/*.html")
|
||||
if err != nil {
|
||||
@@ -262,6 +271,9 @@ func (s *Server) routes(mux *http.ServeMux) {
|
||||
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("POST /board/tasks/{id}/tags", s.requireAuth(s.csrf(s.setBoardTaskTags)))
|
||||
mux.HandleFunc("POST /board/tags", s.requireAuth(s.csrf(s.createBoardTag)))
|
||||
mux.HandleFunc("POST /board/tags/{id}/delete", s.requireAuth(s.csrf(s.deleteBoardTag)))
|
||||
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))
|
||||
@@ -285,8 +297,8 @@ func (s *Server) dayPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
selected, _ := time.Parse("2006-01-02", day)
|
||||
view := r.URL.Query().Get("view")
|
||||
if view != "week" {
|
||||
view = "day"
|
||||
if view != "day" {
|
||||
view = "week"
|
||||
}
|
||||
var detail DayDetail
|
||||
var week WeekDetail
|
||||
@@ -737,6 +749,11 @@ func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "could not load teammates", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tags, err := s.store.BoardTags()
|
||||
if err != nil {
|
||||
http.Error(w, "could not load board tags", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
columns := []BoardColumn{
|
||||
{Status: "backlog", Title: "Backlog", Hint: "Ready to pick up"},
|
||||
{Status: "in_progress", Title: "In progress", Hint: "Actively being worked"},
|
||||
@@ -752,13 +769,17 @@ func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
s.render(w, "board.html", PageData{
|
||||
Title: "Team board", User: currentUser(r), CSRF: csrfToken(r), Users: users, Board: columns,
|
||||
Title: "Team board", User: currentUser(r), CSRF: csrfToken(r), Users: users, Board: columns, BoardTags: tags,
|
||||
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) {
|
||||
if strings.TrimSpace(r.FormValue("title")) == "" {
|
||||
http.Redirect(w, r, "/board?error=Task+title+is+required", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
var assigneeID *int64
|
||||
if raw := r.FormValue("assignee_id"); raw != "" {
|
||||
id, err := strconv.ParseInt(raw, 10, 64)
|
||||
@@ -777,7 +798,16 @@ func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
dueDate = parsed
|
||||
}
|
||||
err := s.store.CreateBoardTask(
|
||||
tagIDs := parseIDList(r.Form["tag_ids"])
|
||||
if newTagName := strings.TrimSpace(r.FormValue("new_tag_name")); newTagName != "" {
|
||||
tag, err := s.store.FindOrCreateBoardTag(newTagName, r.FormValue("new_tag_color"))
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
tagIDs = append(tagIDs, tag.ID)
|
||||
}
|
||||
taskID, err := s.store.CreateBoardTask(
|
||||
currentUser(r).ID, r.FormValue("title"), r.FormValue("description"),
|
||||
r.FormValue("status"), assigneeID, dueDate,
|
||||
)
|
||||
@@ -785,9 +815,23 @@ func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetBoardTaskTags(taskID, tagIDs); 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 parseIDList(values []string) []int64 {
|
||||
ids := make([]int64, 0, len(values))
|
||||
for _, raw := range values {
|
||||
if id, err := strconv.ParseInt(raw, 10, 64); err == nil && id > 0 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -807,6 +851,32 @@ func (s *Server) deleteBoardTask(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/board?flash=Task+removed", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) setBoardTaskTags(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err := s.store.SetBoardTaskTags(id, parseIDList(r.Form["tag_ids"])); err != nil {
|
||||
http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/board?flash=Task+labels+updated", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) createBoardTag(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.CreateBoardTag(r.FormValue("name"), r.FormValue("color")); err != nil {
|
||||
http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/board?flash=Tag+created", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) deleteBoardTag(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err := s.store.DeleteBoardTag(id); err != nil {
|
||||
http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/board?flash=Tag+deleted", 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 {
|
||||
|
||||
@@ -331,7 +331,7 @@ func TestTeamDayShowsPresentRemoteAndAbsent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/day?date="+selectedDay, nil)
|
||||
request := httptest.NewRequest(http.MethodGet, "/day?date="+selectedDay+"&view=day", nil)
|
||||
request.AddCookie(&http.Cookie{Name: "teammate_session", Value: token})
|
||||
response := httptest.NewRecorder()
|
||||
s.http.Handler.ServeHTTP(response, request)
|
||||
@@ -372,6 +372,14 @@ func TestTeamDayShowsPresentRemoteAndAbsent(t *testing.T) {
|
||||
t.Fatalf("week page does not include %q", expected)
|
||||
}
|
||||
}
|
||||
|
||||
defaultRequest := httptest.NewRequest(http.MethodGet, "/day?date="+selectedDay, nil)
|
||||
defaultRequest.AddCookie(&http.Cookie{Name: "teammate_session", Value: token})
|
||||
defaultResponse := httptest.NewRecorder()
|
||||
s.http.Handler.ServeHTTP(defaultResponse, defaultRequest)
|
||||
if defaultResponse.Code != http.StatusOK || !strings.Contains(defaultResponse.Body.String(), "TEAM WEEK") {
|
||||
t.Fatalf("default team view is not weekly: %d %s", defaultResponse.Code, defaultResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkUpdateSubmissionFeedAndCSV(t *testing.T) {
|
||||
@@ -487,6 +495,30 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminCookie := &http.Cookie{Name: "teammate_session", Value: adminToken}
|
||||
for _, tag := range []struct {
|
||||
name string
|
||||
color string
|
||||
}{
|
||||
{"Backend", "green"},
|
||||
{"Urgent", "red"},
|
||||
} {
|
||||
response := formRequest(t, s.http.Handler, "/board/tags", url.Values{
|
||||
"csrf": {adminCSRF},
|
||||
"name": {tag.name},
|
||||
"color": {tag.color},
|
||||
}, adminCookie)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create board tag %s: %d %s", tag.name, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
tags, err := s.store.BoardTags()
|
||||
if err != nil || len(tags) != 2 {
|
||||
t.Fatalf("board tags after create: %#v, %v", tags, err)
|
||||
}
|
||||
reusedTag, err := s.store.FindOrCreateBoardTag("backend", "red")
|
||||
if err != nil || reusedTag.ID != tags[0].ID || reusedTag.Color != "green" {
|
||||
t.Fatalf("existing inline tag was not reused: %#v, %v", reusedTag, err)
|
||||
}
|
||||
create := formRequest(t, s.http.Handler, "/board/tasks", url.Values{
|
||||
"csrf": {adminCSRF},
|
||||
"title": {"Ship the onboarding flow"},
|
||||
@@ -494,6 +526,12 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) {
|
||||
"status": {"backlog"},
|
||||
"assignee_id": {strconv.FormatInt(memberID, 10)},
|
||||
"due_date": {"1405-05-06"},
|
||||
"tag_ids": {
|
||||
strconv.FormatInt(tags[0].ID, 10),
|
||||
strconv.FormatInt(tags[1].ID, 10),
|
||||
},
|
||||
"new_tag_name": {"Frontend"},
|
||||
"new_tag_color": {"blue"},
|
||||
}, 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())
|
||||
@@ -505,6 +543,9 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) {
|
||||
if tasks[0].AssigneeName != "Board Member" || tasks[0].DueDate != "2026-07-28" {
|
||||
t.Fatalf("unexpected assigned board task: %#v", tasks[0])
|
||||
}
|
||||
if len(tasks[0].Tags) != 3 {
|
||||
t.Fatalf("task tags after create: %#v", tasks[0].Tags)
|
||||
}
|
||||
|
||||
memberToken, memberCSRF, err := s.store.CreateSession(memberID)
|
||||
if err != nil {
|
||||
@@ -518,12 +559,34 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) {
|
||||
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"`} {
|
||||
for _, expected := range []string{"Ship the onboarding flow", "Board Member", "1405-05-06", "Manage tags", "Tags", "select multiple", "Create a new tag", "Backend", "Frontend", "Urgent", "/static/board.js", `data-board-column="backlog"`} {
|
||||
if !strings.Contains(boardResponse.Body.String(), expected) {
|
||||
t.Fatalf("board page does not include %q", expected)
|
||||
}
|
||||
}
|
||||
|
||||
tagPath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/tags"
|
||||
setTags := formRequest(t, s.http.Handler, tagPath, url.Values{
|
||||
"csrf": {memberCSRF},
|
||||
"tag_ids": {strconv.FormatInt(tags[0].ID, 10)},
|
||||
}, memberCookie)
|
||||
if setTags.Code != http.StatusSeeOther {
|
||||
t.Fatalf("set task tags status: %d", setTags.Code)
|
||||
}
|
||||
tasks, _ = s.store.BoardTasks()
|
||||
if len(tasks[0].Tags) != 1 || tasks[0].Tags[0].ID != tags[0].ID {
|
||||
t.Fatalf("task tags were not updated: %#v", tasks[0].Tags)
|
||||
}
|
||||
|
||||
deleteTagPath := "/board/tags/" + strconv.FormatInt(tags[1].ID, 10) + "/delete"
|
||||
deleteTag := formRequest(t, s.http.Handler, deleteTagPath, url.Values{"csrf": {adminCSRF}}, adminCookie)
|
||||
if deleteTag.Code != http.StatusSeeOther {
|
||||
t.Fatalf("delete tag status: %d", deleteTag.Code)
|
||||
}
|
||||
if remainingTags, _ := s.store.BoardTags(); len(remainingTags) != 2 {
|
||||
t.Fatalf("tag manager did not delete tag: %#v", remainingTags)
|
||||
}
|
||||
|
||||
movePath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/move"
|
||||
move := formRequest(t, s.http.Handler, movePath, url.Values{
|
||||
"csrf": {memberCSRF},
|
||||
|
||||
@@ -248,12 +248,53 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb;
|
||||
.report-export-grid .report-form .button { grid-column: 1 / -1; }
|
||||
|
||||
.board-page-head { position: relative; align-items: center; }
|
||||
.board-head-actions { display: flex; gap: 8px; 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; }
|
||||
.tag-manager-panel { position: relative; }
|
||||
.tag-manager-panel > summary { list-style: none; user-select: none; }
|
||||
.tag-manager-panel > summary::-webkit-details-marker { display: none; }
|
||||
.tag-manager { position: absolute; z-index: 61; top: calc(100% + 9px); right: 0; width: min(440px, 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); }
|
||||
.tag-manager h2 { margin-bottom: 3px; }
|
||||
.tag-manager > div:first-child > p:last-child { color: var(--muted); font-size: .7rem; }
|
||||
.tag-create-form { display: grid; grid-template-columns: 1fr 110px auto; gap: 8px; align-items: end; margin-top: 18px; }
|
||||
.tag-create-form input, .tag-create-form select { margin-top: 5px; padding-top: 9px; padding-bottom: 9px; }
|
||||
.tag-create-form .button { min-height: 39px; }
|
||||
.tag-manager-list { display: grid; gap: 6px; max-height: 240px; margin-top: 18px; overflow-y: auto; }
|
||||
.tag-manager-list > div { display: flex; justify-content: space-between; align-items: center; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; background: var(--paper); }
|
||||
.tag-manager-list > p { margin: 0; padding: 18px; color: var(--muted); font-size: .7rem; text-align: center; }
|
||||
.tag-delete { position: relative; color: var(--muted); font-size: .6rem; }
|
||||
.tag-delete > summary { cursor: pointer; list-style: none; text-decoration: underline; }
|
||||
.tag-delete > summary::-webkit-details-marker { display: none; }
|
||||
.tag-delete[open] > summary { visibility: hidden; }
|
||||
.tag-delete form { position: absolute; right: 0; bottom: -1px; }
|
||||
.tag-delete .text-button { color: var(--red); font-weight: 700; }
|
||||
.board-tag { display: inline-flex; align-items: center; max-width: 100%; min-height: 21px; padding: 3px 8px; border-radius: 6px; color: #fff; font-size: .55rem; font-weight: 700; line-height: 1.2; overflow-wrap: anywhere; }
|
||||
.board-tag.green { background: #3f8966; }
|
||||
.board-tag.blue { background: #4e7eae; }
|
||||
.board-tag.red { background: #b65b53; }
|
||||
.board-tag.amber { background: #a77931; }
|
||||
.board-tag.purple { background: #7a62a4; }
|
||||
.board-tag.slate { background: #65736c; }
|
||||
.card-tag-picker { margin: 0; padding: 0; border: 0; }
|
||||
.card-tag-picker legend { width: 100%; margin-bottom: 8px; color: #49564f; font-size: .72rem; font-weight: 700; }
|
||||
.card-tag-picker legend small { float: right; color: #9aa39e; font-weight: 500; }
|
||||
.card-tag-picker > .tag-options, .card-tag-editor form > div { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.card-tag-picker label, .card-tag-editor label { position: relative; cursor: pointer; }
|
||||
.card-tag-picker .tag-options input, .card-tag-editor input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.card-tag-picker .tag-options input:not(:checked) + .board-tag, .card-tag-editor input:not(:checked) + .board-tag { opacity: .38; filter: saturate(.45); }
|
||||
.card-tag-picker .tag-options input:focus-visible + .board-tag, .card-tag-editor input:focus-visible + .board-tag { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||
.tag-picker-empty { margin: 0; padding: 10px 12px; border: 1px dashed #bdc6c1; border-radius: 9px; background: var(--paper); color: var(--muted); font-size: .66rem; line-height: 1.5; }
|
||||
.inline-tag-creator { margin-top: 9px; padding: 9px 11px; border: 1px dashed var(--line); border-radius: 9px; background: var(--paper); }
|
||||
.inline-tag-creator > summary { color: var(--green); font-size: .66rem; font-weight: 700; cursor: pointer; list-style: none; }
|
||||
.inline-tag-creator > summary::-webkit-details-marker { display: none; }
|
||||
.inline-tag-fields { display: grid; grid-template-columns: 1fr 105px; gap: 8px; margin-top: 10px; }
|
||||
.inline-tag-fields input, .inline-tag-fields select { margin-top: 5px; padding-top: 9px; padding-bottom: 9px; background: var(--white); }
|
||||
.inline-tag-creator > small { display: block; margin-top: 7px; color: var(--muted); font-size: .57rem; }
|
||||
.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; }
|
||||
@@ -271,6 +312,7 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb;
|
||||
.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-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 9px; }
|
||||
.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; }
|
||||
@@ -283,6 +325,11 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb;
|
||||
.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; }
|
||||
.card-tag-editor { position: relative; margin-top: 9px; }
|
||||
.card-tag-editor > summary { width: max-content; color: var(--muted); font-size: .56rem; font-weight: 700; cursor: pointer; list-style: none; text-decoration: underline; }
|
||||
.card-tag-editor > summary::-webkit-details-marker { display: none; }
|
||||
.card-tag-editor form { position: absolute; z-index: 8; top: calc(100% + 5px); left: -7px; width: 230px; padding: 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--white); box-shadow: 0 12px 28px rgba(24, 36, 31, .2); }
|
||||
.card-tag-editor form .button { min-height: 31px; margin-top: 10px; font-size: .62rem; }
|
||||
.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; }
|
||||
@@ -506,6 +553,8 @@ 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"] .tag-manager,
|
||||
html[data-theme="dark"] .card-tag-editor 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; }
|
||||
@@ -592,6 +641,11 @@ html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142
|
||||
.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; }
|
||||
.board-head-actions { align-items: stretch; }
|
||||
.board-head-actions .button { min-width: 0; padding-right: 12px; padding-left: 12px; }
|
||||
.tag-manager { position: fixed; top: 76px; right: 15px; left: 15px; width: auto; max-height: calc(100vh - 100px); overflow-y: auto; }
|
||||
.tag-create-form { grid-template-columns: 1fr 1fr; }
|
||||
.tag-create-form .button { grid-column: 1 / -1; }
|
||||
.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; }
|
||||
|
||||
+159
-7
@@ -103,6 +103,13 @@ type BoardTask struct {
|
||||
CreatorName string
|
||||
DueDate string
|
||||
CreatedAt string
|
||||
Tags []BoardTag
|
||||
}
|
||||
|
||||
type BoardTag struct {
|
||||
ID int64
|
||||
Name string
|
||||
Color string
|
||||
}
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
@@ -196,6 +203,18 @@ CREATE TABLE IF NOT EXISTS board_tasks (
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_board_tasks_status ON board_tasks(status,created_at);
|
||||
CREATE TABLE IF NOT EXISTS board_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
color TEXT NOT NULL DEFAULT 'green',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS board_task_tags (
|
||||
task_id INTEGER NOT NULL REFERENCES board_tasks(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES board_tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY(task_id,tag_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_board_task_tags_tag ON board_task_tags(tag_id,task_id);
|
||||
`
|
||||
if _, err := s.db.Exec(schema); err != nil {
|
||||
return err
|
||||
@@ -772,14 +791,14 @@ 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 {
|
||||
func (s *Store) CreateBoardTask(creatorID int64, title, description, status string, assigneeID *int64, dueDate string) (int64, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
description = strings.TrimSpace(description)
|
||||
if title == "" {
|
||||
return errors.New("task title is required")
|
||||
return 0, 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")
|
||||
return 0, errors.New("task title or description is too long")
|
||||
}
|
||||
if !validBoardStatus(status) {
|
||||
status = "backlog"
|
||||
@@ -792,9 +811,12 @@ func (s *Store) CreateBoardTask(creatorID int64, title, description, status stri
|
||||
if dueDate != "" {
|
||||
due = dueDate
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO board_tasks(title,description,status,assignee_id,creator_id,due_date)
|
||||
result, 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
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func (s *Store) BoardTasks() ([]BoardTask, error) {
|
||||
@@ -811,7 +833,6 @@ func (s *Store) BoardTasks() ([]BoardTask, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var tasks []BoardTask
|
||||
for rows.Next() {
|
||||
var task BoardTask
|
||||
@@ -824,7 +845,35 @@ func (s *Store) BoardTasks() ([]BoardTask, error) {
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
return tasks, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskIndex := make(map[int64]int, len(tasks))
|
||||
for index := range tasks {
|
||||
taskIndex[tasks[index].ID] = index
|
||||
}
|
||||
tagRows, err := s.db.Query(`SELECT tt.task_id,t.id,t.name,t.color
|
||||
FROM board_task_tags tt JOIN board_tags t ON t.id=tt.tag_id
|
||||
ORDER BY t.name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tagRows.Close()
|
||||
for tagRows.Next() {
|
||||
var taskID int64
|
||||
var tag BoardTag
|
||||
if err := tagRows.Scan(&taskID, &tag.ID, &tag.Name, &tag.Color); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if index, ok := taskIndex[taskID]; ok {
|
||||
tasks[index].Tags = append(tasks[index].Tags, tag)
|
||||
}
|
||||
}
|
||||
return tasks, tagRows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) MoveBoardTask(id int64, status string) error {
|
||||
@@ -859,3 +908,106 @@ func (s *Store) DeleteBoardTask(id, userID int64, admin bool) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validTagColor(color string) bool {
|
||||
switch color {
|
||||
case "green", "blue", "red", "amber", "purple", "slate":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) CreateBoardTag(name, color string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
color = strings.TrimSpace(color)
|
||||
if name == "" || len([]rune(name)) > 40 {
|
||||
return errors.New("tag name must be between 1 and 40 characters")
|
||||
}
|
||||
if !validTagColor(color) {
|
||||
return errors.New("choose a valid tag color")
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO board_tags(name,color) VALUES(?,?)`, name, color)
|
||||
if err != nil && strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
return errors.New("a tag with that name already exists")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) FindOrCreateBoardTag(name, color string) (BoardTag, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
color = strings.TrimSpace(color)
|
||||
if name == "" || len([]rune(name)) > 40 {
|
||||
return BoardTag{}, errors.New("tag name must be between 1 and 40 characters")
|
||||
}
|
||||
if !validTagColor(color) {
|
||||
return BoardTag{}, errors.New("choose a valid tag color")
|
||||
}
|
||||
if _, err := s.db.Exec(`INSERT INTO board_tags(name,color) VALUES(?,?)
|
||||
ON CONFLICT(name) DO NOTHING`, name, color); err != nil {
|
||||
return BoardTag{}, err
|
||||
}
|
||||
var tag BoardTag
|
||||
err := s.db.QueryRow(`SELECT id,name,color FROM board_tags
|
||||
WHERE name = ? COLLATE NOCASE`, name).Scan(&tag.ID, &tag.Name, &tag.Color)
|
||||
return tag, err
|
||||
}
|
||||
|
||||
func (s *Store) BoardTags() ([]BoardTag, error) {
|
||||
rows, err := s.db.Query(`SELECT id,name,color FROM board_tags ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var tags []BoardTag
|
||||
for rows.Next() {
|
||||
var tag BoardTag
|
||||
if err := rows.Scan(&tag.ID, &tag.Name, &tag.Color); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
return tags, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SetBoardTaskTags(taskID int64, tagIDs []int64) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var exists int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM board_tasks WHERE id=?`, taskID).Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
if exists == 0 {
|
||||
return errors.New("task was not found")
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM board_task_tags WHERE task_id=?`, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[int64]bool{}
|
||||
for _, tagID := range tagIDs {
|
||||
if tagID < 1 || seen[tagID] {
|
||||
continue
|
||||
}
|
||||
seen[tagID] = true
|
||||
if _, err := tx.Exec(`INSERT INTO board_task_tags(task_id,tag_id)
|
||||
SELECT ?,id FROM board_tags WHERE id=?`, taskID, tagID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteBoardTag(id int64) error {
|
||||
result, err := s.db.Exec(`DELETE FROM board_tags WHERE id=?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, _ := result.RowsAffected()
|
||||
if count == 0 {
|
||||
return errors.New("tag was not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
{{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>
|
||||
<div class="board-head-actions">
|
||||
<details class="tag-manager-panel">
|
||||
<summary class="button secondary">◉ Manage tags</summary>
|
||||
<div class="tag-manager">
|
||||
<div><p class="eyebrow">BOARD LABELS</p><h2>Tag manager</h2><p>Create reusable labels for every card.</p></div>
|
||||
<form method="post" action="/board/tags" class="tag-create-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Tag name<input name="name" required maxlength="40" placeholder="e.g. Backend"></label>
|
||||
<label>Color<select name="color"><option value="green">Green</option><option value="blue">Blue</option><option value="red">Red</option><option value="amber">Amber</option><option value="purple">Purple</option><option value="slate">Slate</option></select></label>
|
||||
<button class="button primary" type="submit">Add tag</button>
|
||||
</form>
|
||||
<div class="tag-manager-list">
|
||||
{{range .BoardTags}}
|
||||
<div><span class="board-tag {{.Color}}">{{.Name}}</span><details class="tag-delete"><summary>Delete</summary><form method="post" action="/board/tags/{{.ID}}/delete"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="text-button" type="submit">Confirm</button></form></details></div>
|
||||
{{else}}<p>No tags yet. Create the first one above.</p>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<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">
|
||||
@@ -9,6 +27,21 @@
|
||||
<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>
|
||||
<fieldset class="card-tag-picker">
|
||||
<legend>Tags <small>Optional · select multiple</small></legend>
|
||||
<div class="tag-options">
|
||||
{{range .BoardTags}}<label><input type="checkbox" name="tag_ids" value="{{.ID}}"><span class="board-tag {{.Color}}">{{.Name}}</span></label>
|
||||
{{else}}<p class="tag-picker-empty">No tags yet. Create the first one below.</p>{{end}}
|
||||
</div>
|
||||
<details class="inline-tag-creator">
|
||||
<summary>+ Create a new tag</summary>
|
||||
<div class="inline-tag-fields">
|
||||
<label>Tag name<input name="new_tag_name" maxlength="40" placeholder="e.g. Backend"></label>
|
||||
<label>Color<select name="new_tag_color"><option value="green">Green</option><option value="blue">Blue</option><option value="red">Red</option><option value="amber">Amber</option><option value="purple">Purple</option><option value="slate">Slate</option></select></label>
|
||||
</div>
|
||||
<small>The new tag will be created and added to this card.</small>
|
||||
</details>
|
||||
</fieldset>
|
||||
<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>
|
||||
@@ -17,6 +50,7 @@
|
||||
<button class="button dark full" type="submit">Create task</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</header>
|
||||
{{template "notice" .}}
|
||||
|
||||
@@ -29,7 +63,9 @@
|
||||
</header>
|
||||
<div class="kanban-cards" data-drop-zone="{{.Status}}">
|
||||
{{range .Tasks}}
|
||||
{{$task := .}}
|
||||
<article class="kanban-card" draggable="true" data-task-id="{{.ID}}">
|
||||
{{if .Tags}}<div class="card-tags">{{range .Tags}}<span class="board-tag {{.Color}}">{{.Name}}</span>{{end}}</div>{{end}}
|
||||
<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}}
|
||||
@@ -37,6 +73,16 @@
|
||||
{{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>
|
||||
{{if $.BoardTags}}
|
||||
<details class="card-tag-editor">
|
||||
<summary>Labels</summary>
|
||||
<form method="post" action="/board/tasks/{{.ID}}/tags">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<div>{{range $.BoardTags}}<label><input type="checkbox" name="tag_ids" value="{{.ID}}" {{if hasTag $task.Tags .ID}}checked{{end}}><span class="board-tag {{.Color}}">{{.Name}}</span></label>{{end}}</div>
|
||||
<button class="button primary full" type="submit">Save labels</button>
|
||||
</form>
|
||||
</details>
|
||||
{{end}}
|
||||
<div class="task-actions">
|
||||
<form method="post" action="/board/tasks/{{.ID}}/move">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="calendar-grid days">
|
||||
{{range .Calendar.Cells}}
|
||||
{{if .InMonth}}
|
||||
<a href="/day?date={{.Gregorian}}" class="day {{if .IsToday}}today{{end}} {{if .IsFriday}}friday{{end}} {{if .Holiday}}holiday{{end}}" title="View team details for this day">
|
||||
<a href="/day?date={{.Gregorian}}&view=day" class="day {{if .IsToday}}today{{end}} {{if .IsFriday}}friday{{end}} {{if .Holiday}}holiday{{end}}" title="View team details for this day">
|
||||
<span class="day-number">{{.Day}}</span>
|
||||
{{if .Status}}<i class="status-dot {{.Status}}" title="{{.Status}}"></i>{{end}}
|
||||
{{if .Holiday}}<small title="{{.Holiday}}">{{.Holiday}}</small>{{end}}
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
</div>
|
||||
<div class="day-view-controls">
|
||||
<nav class="view-switch" aria-label="Schedule view">
|
||||
<a href="/day?date={{.Day.Jalali}}" class="{{if not .Week.Enabled}}active{{end}}">Day</a>
|
||||
<a href="/day?date={{.Day.Jalali}}&view=day" class="{{if not .Week.Enabled}}active{{end}}">Day</a>
|
||||
<a href="/day?date={{.Day.Jalali}}&view=week" class="{{if .Week.Enabled}}active{{end}}">Week</a>
|
||||
</nav>
|
||||
<form method="get" action="/day" class="day-date-form">
|
||||
{{if .Week.Enabled}}<input type="hidden" name="view" value="week">{{end}}
|
||||
<input type="hidden" name="view" value="{{if .Week.Enabled}}week{{else}}day{{end}}">
|
||||
<label>Persian date
|
||||
<span class="jalali-field">
|
||||
<input name="date" value="{{.Day.Jalali}}" data-jalali-picker required autocomplete="off" inputmode="numeric" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="week-grid">
|
||||
{{range .Week.Days}}
|
||||
<section class="week-day-column {{if .IsToday}}today{{end}} {{if eq .Weekday "Friday"}}friday{{end}}">
|
||||
<a class="week-day-head" href="/day?date={{.Jalali}}">
|
||||
<a class="week-day-head" href="/day?date={{.Jalali}}&view=day">
|
||||
<span>{{.Weekday}}</span>
|
||||
<strong>{{.Jalali}}</strong>
|
||||
<small>{{.Gregorian}}{{if .DayNote}} · {{.DayNote}}{{end}}</small>
|
||||
@@ -71,13 +71,13 @@
|
||||
<div class="day-layout">
|
||||
<section class="card day-summary-card">
|
||||
<div class="day-date-nav">
|
||||
<a href="/day?date={{.Day.Prev}}" aria-label="Previous day">‹</a>
|
||||
<a href="/day?date={{.Day.Prev}}&view=day" aria-label="Previous day">‹</a>
|
||||
<div>
|
||||
<p class="eyebrow">PERSIAN CALENDAR</p>
|
||||
<h2>{{.Day.Jalali}}</h2>
|
||||
<span>{{.Day.Weekday}} · {{.Day.Gregorian}}{{if .Day.DayNote}} · {{.Day.DayNote}}{{end}}</span>
|
||||
</div>
|
||||
<a href="/day?date={{.Day.Next}}" aria-label="Next day">›</a>
|
||||
<a href="/day?date={{.Day.Next}}&view=day" aria-label="Next day">›</a>
|
||||
</div>
|
||||
<div class="day-stats">
|
||||
<div><i class="presence-mark present"></i><span><strong>{{.Day.Present}}</strong><small>Present</small></span></div>
|
||||
|
||||
Reference in New Issue
Block a user