feat: expand kanban assignment and filtering
Test and publish / verify (push) Successful in 2m22s

This commit is contained in:
2026-07-29 13:53:31 +03:30
parent e1475d0df0
commit 5a3ca4eb40
7 changed files with 432 additions and 91 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ Hamkar is a small team presence tracker built for startups that work with the Pe
- Team day detail showing who is present, remote, absent, or expected at the office - Team day detail showing who is present, remote, absent, or expected at the office
- SaturdayFriday team week schedule as the default team view, with calendar-style presence records - SaturdayFriday team week schedule as the default team view, with calendar-style presence records
- Period-based teammate work updates for completed, blocked, and pending tasks - Period-based teammate work updates for completed, blocked, and pending tasks
- Shared Trello-style team board with reusable colored tags, assignment, Jalali due dates, and drag-and-drop columns - Shared Trello-style team board with reusable colored tags, multiple assignees, importance levels, card archiving, Jalali due dates, and drag-and-drop columns
- Time-off and remote-day requests with a dependency-free Jalali date picker and typed Persian-date fallback - Time-off and remote-day requests with a dependency-free Jalali date picker and typed Persian-date fallback
- Admin approval/rejection with review notes - Admin approval/rejection with review notes
- Excel-compatible UTF-8 CSV reports for attendance and teammate work updates - Excel-compatible UTF-8 CSV reports for attendance and teammate work updates
+79 -11
View File
@@ -97,10 +97,18 @@ type PageData struct {
Board []BoardColumn Board []BoardColumn
BoardTags []BoardTag BoardTags []BoardTag
ArchivedTasks []BoardTask ArchivedTasks []BoardTask
BoardFilter BoardFilter
} }
type Stats struct{ Present, Remote, Leave int } type Stats struct{ Present, Remote, Leave int }
type BoardFilter struct {
TagID int64
AssigneeID int64
Priority string
Active bool
}
type ReportTotals struct { type ReportTotals struct {
Present int Present int
Remote int Remote int
@@ -217,6 +225,14 @@ func New(cfg Config) (*Server, error) {
} }
return false return false
}, },
"hasUser": func(users []User, id int64) bool {
for _, user := range users {
if user.ID == id {
return true
}
}
return false
},
} }
tmpl, err := template.New("root").Funcs(funcs).ParseFS(assets, "templates/*.html") tmpl, err := template.New("root").Funcs(funcs).ParseFS(assets, "templates/*.html")
if err != nil { if err != nil {
@@ -275,6 +291,7 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /board/tasks/{id}/restore", s.requireAuth(s.csrf(s.restoreBoardTask))) mux.HandleFunc("POST /board/tasks/{id}/restore", s.requireAuth(s.csrf(s.restoreBoardTask)))
mux.HandleFunc("POST /board/tasks/{id}/delete", s.requireAuth(s.csrf(s.deleteBoardTask))) 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/tasks/{id}/tags", s.requireAuth(s.csrf(s.setBoardTaskTags)))
mux.HandleFunc("POST /board/tasks/{id}/details", s.requireAuth(s.csrf(s.setBoardTaskDetails)))
mux.HandleFunc("POST /board/tags", s.requireAuth(s.csrf(s.createBoardTag))) 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("POST /board/tags/{id}/delete", s.requireAuth(s.csrf(s.deleteBoardTag)))
mux.HandleFunc("GET /admin/requests", s.requireAdmin(s.adminPage)) mux.HandleFunc("GET /admin/requests", s.requireAdmin(s.adminPage))
@@ -763,6 +780,22 @@ func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not load board tags", http.StatusInternalServerError) http.Error(w, "could not load board tags", http.StatusInternalServerError)
return return
} }
filter := BoardFilter{Priority: r.URL.Query().Get("priority")}
filter.TagID, _ = strconv.ParseInt(r.URL.Query().Get("tag"), 10, 64)
filter.AssigneeID, _ = strconv.ParseInt(r.URL.Query().Get("assignee"), 10, 64)
if !validBoardPriority(filter.Priority) {
filter.Priority = ""
}
filter.Active = filter.TagID > 0 || filter.AssigneeID > 0 || filter.Priority != ""
if filter.Active {
filtered := make([]BoardTask, 0, len(tasks))
for _, task := range tasks {
if boardTaskMatchesFilter(task, filter) {
filtered = append(filtered, task)
}
}
tasks = filtered
}
columns := []BoardColumn{ columns := []BoardColumn{
{Status: "backlog", Title: "Backlog", Hint: "Ready to pick up"}, {Status: "backlog", Title: "Backlog", Hint: "Ready to pick up"},
{Status: "in_progress", Title: "In progress", Hint: "Actively being worked"}, {Status: "in_progress", Title: "In progress", Hint: "Actively being worked"},
@@ -779,26 +812,52 @@ func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) {
} }
s.render(w, "board.html", PageData{ s.render(w, "board.html", PageData{
Title: "Team board", User: currentUser(r), CSRF: csrfToken(r), Users: users, Board: columns, BoardTags: tags, Title: "Team board", User: currentUser(r), CSRF: csrfToken(r), Users: users, Board: columns, BoardTags: tags,
ArchivedTasks: archivedTasks, ArchivedTasks: archivedTasks, BoardFilter: filter,
TodayJalali: jalali.FromTime(time.Now()).String(), Error: r.URL.Query().Get("error"), TodayJalali: jalali.FromTime(time.Now()).String(), Error: r.URL.Query().Get("error"),
Flash: r.URL.Query().Get("flash"), SelectedSection: "board", Flash: r.URL.Query().Get("flash"), SelectedSection: "board",
}) })
} }
func validBoardPriority(priority string) bool {
return priority == "" || priority == "low" || priority == "normal" || priority == "high" || priority == "urgent"
}
func boardTaskMatchesFilter(task BoardTask, filter BoardFilter) bool {
if filter.Priority != "" && task.Importance != filter.Priority {
return false
}
if filter.TagID > 0 {
found := false
for _, tag := range task.Tags {
if tag.ID == filter.TagID {
found = true
break
}
}
if !found {
return false
}
}
if filter.AssigneeID > 0 {
found := false
for _, assignee := range task.Assignees {
if assignee.ID == filter.AssigneeID {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) { func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(r.FormValue("title")) == "" { if strings.TrimSpace(r.FormValue("title")) == "" {
http.Redirect(w, r, "/board?error=Task+title+is+required", http.StatusSeeOther) http.Redirect(w, r, "/board?error=Task+title+is+required", http.StatusSeeOther)
return return
} }
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 := "" dueDate := ""
if rawDue := strings.TrimSpace(r.FormValue("due_date")); rawDue != "" { if rawDue := strings.TrimSpace(r.FormValue("due_date")); rawDue != "" {
parsed, ok := parseUserDate(rawDue) parsed, ok := parseUserDate(rawDue)
@@ -819,7 +878,7 @@ func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) {
} }
taskID, err := s.store.CreateBoardTask( taskID, err := s.store.CreateBoardTask(
currentUser(r).ID, r.FormValue("title"), r.FormValue("description"), currentUser(r).ID, r.FormValue("title"), r.FormValue("description"),
r.FormValue("status"), assigneeID, dueDate, r.FormValue("status"), r.FormValue("importance"), parseIDList(r.Form["assignee_ids"]), dueDate,
) )
if err != nil { if err != nil {
http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
@@ -892,6 +951,15 @@ func (s *Server) setBoardTaskTags(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/board?flash=Task+labels+updated", http.StatusSeeOther) http.Redirect(w, r, "/board?flash=Task+labels+updated", http.StatusSeeOther)
} }
func (s *Server) setBoardTaskDetails(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := s.store.SetBoardTaskDetails(id, parseIDList(r.Form["assignee_ids"]), r.FormValue("importance")); err != nil {
http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/board?flash=Card+details+updated", http.StatusSeeOther)
}
func (s *Server) createBoardTag(w http.ResponseWriter, r *http.Request) { func (s *Server) createBoardTag(w http.ResponseWriter, r *http.Request) {
if err := s.store.CreateBoardTag(r.FormValue("name"), r.FormValue("color")); err != nil { 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) http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
+78 -4
View File
@@ -40,7 +40,7 @@ func TestExistingDatabaseAddsNewColumns(t *testing.T) {
due_date TEXT, due_date TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
INSERT INTO board_tasks(title,creator_id) VALUES('Legacy card',1)`) INSERT INTO board_tasks(title,assignee_id,creator_id) VALUES('Legacy card',1,1)`)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -67,6 +67,41 @@ func TestExistingDatabaseAddsNewColumns(t *testing.T) {
if archivedAt.Valid { if archivedAt.Valid {
t.Fatalf("legacy card was unexpectedly archived: %q", archivedAt.String) t.Fatalf("legacy card was unexpectedly archived: %q", archivedAt.String)
} }
var importance string
if err := store.db.QueryRow(`SELECT importance FROM board_tasks WHERE title='Legacy card'`).Scan(&importance); err != nil || importance != "normal" {
t.Fatalf("legacy card importance: %q, %v", importance, err)
}
var migratedAssignees int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM board_task_assignees WHERE task_id=1 AND user_id=1`).Scan(&migratedAssignees); err != nil || migratedAssignees != 1 {
t.Fatalf("legacy card assignees: %d, %v", migratedAssignees, err)
}
}
func TestBoardTasksSortByCreationDate(t *testing.T) {
store, err := OpenStore(filepath.Join(t.TempDir(), "sort.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
olderID, err := store.CreateBoardTask(1, "Created later", "", "backlog", "normal", nil, "")
if err != nil {
t.Fatal(err)
}
newerID, err := store.CreateBoardTask(1, "Created earlier", "", "backlog", "normal", nil, "")
if err != nil {
t.Fatal(err)
}
if _, err := store.db.Exec(`UPDATE board_tasks SET created_at=CASE id WHEN ? THEN ? ELSE ? END`,
olderID, "2026-07-29 12:00:00", "2026-07-29 11:00:00"); err != nil {
t.Fatal(err)
}
tasks, err := store.BoardTasks()
if err != nil {
t.Fatal(err)
}
if len(tasks) != 2 || tasks[0].ID != olderID || tasks[1].ID != newerID {
t.Fatalf("cards were not sorted newest-created-first: %#v", tasks)
}
} }
func TestLoginAttendanceAndReportFlow(t *testing.T) { func TestLoginAttendanceAndReportFlow(t *testing.T) {
@@ -642,7 +677,11 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) {
"title": {"Ship the onboarding flow"}, "title": {"Ship the onboarding flow"},
"description": {"Finish QA and publish the release."}, "description": {"Finish QA and publish the release."},
"status": {"backlog"}, "status": {"backlog"},
"assignee_id": {strconv.FormatInt(memberID, 10)}, "assignee_ids": {
"1",
strconv.FormatInt(memberID, 10),
},
"importance": {"high"},
"due_date": {"1405-05-06"}, "due_date": {"1405-05-06"},
"tag_ids": { "tag_ids": {
strconv.FormatInt(tags[0].ID, 10), strconv.FormatInt(tags[0].ID, 10),
@@ -658,7 +697,7 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) {
if err != nil || len(tasks) != 1 { if err != nil || len(tasks) != 1 {
t.Fatalf("board tasks after create: %#v, %v", tasks, err) t.Fatalf("board tasks after create: %#v, %v", tasks, err)
} }
if tasks[0].AssigneeName != "Board Member" || tasks[0].DueDate != "2026-07-28" { if len(tasks[0].Assignees) != 2 || tasks[0].Importance != "high" || tasks[0].DueDate != "2026-07-28" {
t.Fatalf("unexpected assigned board task: %#v", tasks[0]) t.Fatalf("unexpected assigned board task: %#v", tasks[0])
} }
if len(tasks[0].Tags) != 3 { if len(tasks[0].Tags) != 3 {
@@ -677,7 +716,7 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) {
if boardResponse.Code != http.StatusOK { if boardResponse.Code != http.StatusOK {
t.Fatalf("board page status: %d body %s", boardResponse.Code, boardResponse.Body.String()) 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", "Manage tags", "Tags", "select multiple", "Create a new tag", "Backend", "Frontend", "Urgent", "/static/board.js", `data-board-column="backlog"`} { for _, expected := range []string{"Ship the onboarding flow", "Board Member", "Workspace Admin", "1405-05-06", "High", "Manage tags", "Tags", "select multiple", "Create a new tag", "People &amp; importance", "Card controls", "Backend", "Frontend", "Urgent", "/static/board.js", `data-board-column="backlog"`} {
if !strings.Contains(boardResponse.Body.String(), expected) { if !strings.Contains(boardResponse.Body.String(), expected) {
t.Fatalf("board page does not include %q", expected) t.Fatalf("board page does not include %q", expected)
} }
@@ -704,6 +743,41 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) {
if len(tasks[0].Tags) != 1 || tasks[0].Tags[0].ID != tags[0].ID { 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) t.Fatalf("task tags were not updated: %#v", tasks[0].Tags)
} }
detailsPath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/details"
setDetails := formRequest(t, s.http.Handler, detailsPath, url.Values{
"csrf": {memberCSRF},
"assignee_ids": {strconv.FormatInt(memberID, 10)},
"importance": {"urgent"},
}, memberCookie)
if setDetails.Code != http.StatusSeeOther || setDetails.Header().Get("Location") != "/board?flash=Card+details+updated" {
t.Fatalf("set task details: got %d location %q", setDetails.Code, setDetails.Header().Get("Location"))
}
tasks, _ = s.store.BoardTasks()
if len(tasks[0].Assignees) != 1 || tasks[0].Assignees[0].ID != memberID || tasks[0].Importance != "urgent" {
t.Fatalf("task people or importance were not updated: %#v", tasks[0])
}
matchingFilterPath := "/board?tag=" + strconv.FormatInt(tags[0].ID, 10) +
"&assignee=" + strconv.FormatInt(memberID, 10) + "&priority=urgent"
matchingFilterRequest := httptest.NewRequest(http.MethodGet, matchingFilterPath, nil)
matchingFilterRequest.AddCookie(memberCookie)
matchingFilter := httptest.NewRecorder()
s.http.Handler.ServeHTTP(matchingFilter, matchingFilterRequest)
if matchingFilter.Code != http.StatusOK || !strings.Contains(matchingFilter.Body.String(), "Ship the onboarding flow") || !strings.Contains(matchingFilter.Body.String(), "Clear") {
t.Fatalf("matching board filter: %d %s", matchingFilter.Code, matchingFilter.Body.String())
}
for _, path := range []string{
"/board?priority=high",
"/board?assignee=1",
"/board?tag=999999",
} {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.AddCookie(memberCookie)
response := httptest.NewRecorder()
s.http.Handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "Ship the onboarding flow") || !strings.Contains(response.Body.String(), "No matching cards") {
t.Fatalf("non-matching board filter %q: %d %s", path, response.Code, response.Body.String())
}
}
deleteTagPath := "/board/tags/" + strconv.FormatInt(tags[1].ID, 10) + "/delete" deleteTagPath := "/board/tags/" + strconv.FormatInt(tags[1].ID, 10) + "/delete"
deleteTag := formRequest(t, s.http.Handler, deleteTagPath, url.Values{"csrf": {adminCSRF}}, adminCookie) deleteTag := formRequest(t, s.http.Handler, deleteTagPath, url.Values{"csrf": {adminCSRF}}, adminCookie)
+4
View File
@@ -12,6 +12,10 @@
board.querySelectorAll("[data-task-id]").forEach(function (card) { board.querySelectorAll("[data-task-id]").forEach(function (card) {
card.addEventListener("dragstart", function (event) { card.addEventListener("dragstart", function (event) {
if (event.target.closest("button, input, select, textarea, label, summary, a")) {
event.preventDefault();
return;
}
dragged = card; dragged = card;
card.classList.add("dragging"); card.classList.add("dragging");
event.dataTransfer.effectAllowed = "move"; event.dataTransfer.effectAllowed = "move";
+55 -9
View File
@@ -262,7 +262,7 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb;
.new-task-panel > summary { list-style: none; user-select: none; } .new-task-panel > summary { list-style: none; user-select: none; }
.new-task-panel > summary::-webkit-details-marker { display: none; } .new-task-panel > summary::-webkit-details-marker { display: none; }
.new-task-panel[open] > summary { background: var(--ink); } .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 { position: absolute; z-index: 60; top: calc(100% + 9px); right: 0; display: grid; gap: 14px; width: min(430px, calc(100vw - 40px)); max-height: calc(100vh - 110px); padding: 22px; overflow-y: auto; 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; } .new-task-form-head h2 { margin-bottom: 0; }
.tag-manager-panel { position: relative; } .tag-manager-panel { position: relative; }
.tag-manager-panel > summary { list-style: none; user-select: none; } .tag-manager-panel > summary { list-style: none; user-select: none; }
@@ -304,6 +304,26 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb;
.inline-tag-fields { display: grid; grid-template-columns: 1fr 105px; gap: 8px; margin-top: 10px; } .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-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; } .inline-tag-creator > small { display: block; margin-top: 7px; color: var(--muted); font-size: .57rem; }
.assignee-picker, .compact-assignee-picker { margin: 0; padding: 0; border: 0; }
.assignee-picker legend, .compact-assignee-picker legend { width: 100%; margin-bottom: 8px; color: #49564f; font-size: .72rem; font-weight: 700; }
.assignee-picker legend small { float: right; color: #9aa39e; font-weight: 500; }
.assignee-picker > div, .compact-assignee-picker > div { display: flex; flex-wrap: wrap; gap: 6px; }
.assignee-picker label, .compact-assignee-picker label { position: relative; cursor: pointer; }
.assignee-picker input, .compact-assignee-picker input { position: absolute; opacity: 0; pointer-events: none; }
.assignee-picker label > span, .compact-assignee-picker label > span { display: inline-flex; gap: 6px; align-items: center; min-height: 31px; padding: 4px 9px 4px 5px; border: 1px solid var(--line); border-radius: 20px; background: var(--paper); color: var(--muted); font-size: .59rem; font-weight: 700; }
.assignee-picker label > span .avatar, .compact-assignee-picker label > span .avatar { width: 21px; height: 21px; font-size: .5rem; }
.assignee-picker input:checked + span, .compact-assignee-picker input:checked + span { border-color: #5f8f78; background: #edf6f1; color: var(--green-2); box-shadow: inset 0 0 0 1px #5f8f78; }
.assignee-picker input:focus-visible + span, .compact-assignee-picker input:focus-visible + span { outline: 2px solid var(--ink); outline-offset: 2px; }
.board-filter-bar { display: flex; gap: 16px; align-items: center; max-width: 1380px; margin: 0 auto 14px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 13px; background: var(--white); box-shadow: var(--shadow); }
.board-filter-title { display: flex; gap: 8px; align-items: center; flex: 0 0 auto; padding-right: 15px; border-right: 1px solid var(--line); color: var(--ink); font-size: .66rem; }
.board-filter-title span { display: grid; width: 31px; height: 31px; place-items: center; border-radius: 8px; background: var(--mint); color: var(--green); font-size: .9rem; }
.board-filter-bar form { display: grid; grid-template-columns: repeat(3, minmax(135px, 180px)) auto; gap: 8px; align-items: end; }
.board-filter-bar label { min-width: 0; color: var(--muted); font-size: .52rem; }
.board-filter-bar label > span { display: block; padding-left: 2px; }
.board-filter-bar select { min-height: 33px; margin-top: 3px; padding: 6px 27px 6px 9px; border-radius: 8px; background-color: var(--paper); font-size: .59rem; }
.board-filter-actions { display: flex; gap: 7px; align-items: center; min-height: 33px; }
.board-filter-actions .button { min-height: 33px; padding: 6px 13px; font-size: .59rem; }
.board-filter-actions .text-button { padding: 6px 2px; 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-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 { 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 { display: flex; justify-content: space-between; align-items: start; min-height: 51px; padding: 4px 5px 12px; }
@@ -322,24 +342,39 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb;
.kanban-card.dragging { opacity: .45; transform: rotate(1.5deg); } .kanban-card.dragging { opacity: .45; transform: rotate(1.5deg); }
.kanban-card.moving { opacity: .35; pointer-events: none; } .kanban-card.moving { opacity: .35; pointer-events: none; }
.card-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 9px; } .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; } .card-topline { display: flex; justify-content: space-between; gap: 8px; align-items: center; margin-bottom: 9px; }
.card-signals { display: flex; flex-wrap: wrap; gap: 5px; justify-content: flex-end; }
.task-id { color: #9aa39e; font-size: .58rem; font-weight: 700; } .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; } .task-due { padding: 4px 6px; border-radius: 6px; background: var(--sand); color: #786039; font-size: .55rem; font-weight: 700; }
.importance-badge { padding: 4px 6px; border-radius: 6px; background: var(--paper); color: var(--muted); font-size: .53rem; font-weight: 800; text-transform: uppercase; }
.importance-badge.low { background: #edf0ee; color: #68736e; }
.importance-badge.high { background: #f6eddc; color: #87662c; }
.importance-badge.urgent { background: #f5e6e4; color: #9d4a44; }
.kanban-card h3 { margin-bottom: 7px; font-size: .84rem; line-height: 1.35; } .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; } .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-meta { display: flex; justify-content: space-between; gap: 8px; align-items: center; padding-top: 10px; border-top: 1px solid var(--line); }
.task-assignees { display: flex; min-width: 0; flex-wrap: wrap; gap: 7px; }
.task-assignee { display: flex; min-width: 0; gap: 6px; align-items: center; color: #53605a; font-size: .61rem; font-weight: 700; } .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 { 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 i.avatar { width: 24px; height: 24px; }
.task-assignee span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .task-assignee span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-assignee.unassigned { color: var(--muted); font-weight: 500; } .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-creator { overflow: hidden; color: #9aa39e; font-size: .54rem; text-overflow: ellipsis; white-space: nowrap; }
.card-tag-editor { position: relative; margin-top: 9px; } .card-controls { margin: 10px -3px -5px; }
.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-controls > summary { display: flex; justify-content: space-between; align-items: center; padding: 5px 3px; color: var(--muted); font-size: .57rem; font-weight: 700; cursor: pointer; list-style: none; }
.card-tag-editor > summary::-webkit-details-marker { display: none; } .card-controls > 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-controls > summary i { font-size: .8rem; font-style: normal; transition: transform .15s ease; }
.card-tag-editor form .button { min-height: 31px; margin-top: 10px; font-size: .62rem; } .card-controls[open] > summary i { transform: rotate(180deg); }
.task-actions { display: flex; justify-content: space-between; align-items: center; margin: 11px -3px -5px; } .card-controls-body { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; padding-top: 8px; border-top: 1px dashed var(--line); }
.card-tag-editor, .card-detail-editor { position: relative; }
.card-tag-editor > summary, .card-detail-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, .card-detail-editor > summary::-webkit-details-marker { display: none; }
.card-tag-editor form, .card-detail-editor form { position: absolute; z-index: 8; top: calc(100% + 5px); left: -7px; width: 250px; 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, .card-detail-editor form .button { min-height: 31px; margin-top: 10px; font-size: .62rem; }
.card-detail-editor form { display: grid; gap: 10px; width: 270px; }
.compact-assignee-picker > div { max-height: 120px; overflow-y: auto; }
.card-detail-editor select { margin-top: 5px; padding-top: 7px; padding-bottom: 7px; }
.task-actions { display: flex; width: 100%; justify-content: space-between; align-items: center; }
.task-actions > form:first-child { display: flex; gap: 4px; align-items: center; } .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 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-actions .text-button { margin: 0; }
@@ -586,12 +621,17 @@ html[data-theme="dark"] .metric.pending,
html[data-theme="dark"] .pending-icon { background: #453820; color: #e3bd72; } 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"] .new-task-form { box-shadow: 0 22px 65px rgba(0, 0, 0, .52); }
html[data-theme="dark"] .tag-manager, 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"] .card-tag-editor form,
html[data-theme="dark"] .card-detail-editor form { box-shadow: 0 22px 65px rgba(0, 0, 0, .52); }
html[data-theme="dark"] .assignee-picker input:checked + span,
html[data-theme="dark"] .compact-assignee-picker input:checked + span { border-color: #5c9b7b; background: #1b3027; color: #9ed4b9; }
html[data-theme="dark"] .kanban-column { background: #141d18; } 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 { 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"] .kanban-card > p { color: #aab6b0; }
html[data-theme="dark"] .task-assignee { color: #bdc8c2; } html[data-theme="dark"] .task-assignee { color: #bdc8c2; }
html[data-theme="dark"] .task-due { background: #3b3222; color: #dec18b; } html[data-theme="dark"] .task-due { background: #3b3222; color: #dec18b; }
html[data-theme="dark"] .importance-badge.high { background: #453820; color: #e3bd72; }
html[data-theme="dark"] .importance-badge.urgent { background: #432725; color: #efa09a; }
html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142, .1); } html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142, .1); }
@media (max-width: 980px) { @media (max-width: 980px) {
@@ -678,6 +718,12 @@ html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142
.tag-manager { position: fixed; top: 76px; right: 15px; left: 15px; width: auto; max-height: calc(100vh - 100px); overflow-y: auto; } .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 { grid-template-columns: 1fr 1fr; }
.tag-create-form .button { grid-column: 1 / -1; } .tag-create-form .button { grid-column: 1 / -1; }
.board-filter-bar { display: block; padding: 10px; }
.board-filter-title { margin-bottom: 9px; padding: 0 0 8px; border-right: 0; border-bottom: 1px solid var(--line); }
.board-filter-title span { width: 27px; height: 27px; }
.board-filter-bar form { grid-template-columns: 1fr 1fr; }
.board-filter-bar form > label:nth-child(3) { grid-column: 1 / -1; }
.board-filter-actions { justify-content: flex-end; }
.kanban-board { grid-template-columns: repeat(4, 82vw); margin-right: -15px; padding-right: 0; padding-left: 15px; scroll-snap-type: x mandatory; } .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; } .kanban-column { scroll-snap-align: start; }
.archived-card { align-items: flex-start; flex-direction: column; gap: 11px; } .archived-card { align-items: flex-start; flex-direction: column; gap: 11px; }
+119 -17
View File
@@ -97,14 +97,13 @@ type BoardTask struct {
Title string Title string
Description string Description string
Status string Status string
AssigneeID sql.NullInt64 Importance string
AssigneeName string
AssigneeUser string
CreatorID int64 CreatorID int64
CreatorName string CreatorName string
DueDate string DueDate string
CreatedAt string CreatedAt string
ArchivedAt string ArchivedAt string
Assignees []User
Tags []BoardTag Tags []BoardTag
} }
@@ -232,6 +231,7 @@ CREATE TABLE IF NOT EXISTS board_tasks (
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'backlog' CHECK(status IN ('backlog','in_progress','blocked','done')), status TEXT NOT NULL DEFAULT 'backlog' CHECK(status IN ('backlog','in_progress','blocked','done')),
importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent')),
assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL, assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
due_date TEXT, due_date TEXT,
@@ -239,6 +239,12 @@ CREATE TABLE IF NOT EXISTS board_tasks (
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_board_tasks_status ON board_tasks(status,created_at); CREATE INDEX IF NOT EXISTS idx_board_tasks_status ON board_tasks(status,created_at);
CREATE TABLE IF NOT EXISTS board_task_assignees (
task_id INTEGER NOT NULL REFERENCES board_tasks(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY(task_id,user_id)
);
CREATE INDEX IF NOT EXISTS idx_board_task_assignees_user ON board_task_assignees(user_id,task_id);
CREATE TABLE IF NOT EXISTS board_tags ( CREATE TABLE IF NOT EXISTS board_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE, name TEXT NOT NULL UNIQUE COLLATE NOCASE,
@@ -261,6 +267,16 @@ CREATE INDEX IF NOT EXISTS idx_board_task_tags_tag ON board_task_tags(tag_id,tas
if err := s.ensureColumn("board_tasks", "archived_at", "archived_at DATETIME"); err != nil { if err := s.ensureColumn("board_tasks", "archived_at", "archived_at DATETIME"); err != nil {
return err return err
} }
if err := s.ensureColumn("board_tasks", "importance", "importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent'))"); err != nil {
return err
}
if _, err := s.db.Exec(`INSERT OR IGNORE INTO board_task_assignees(task_id,user_id)
SELECT id,assignee_id FROM board_tasks WHERE assignee_id IS NOT NULL`); err != nil {
return err
}
if _, err := s.db.Exec(`UPDATE board_tasks SET assignee_id=NULL WHERE assignee_id IS NOT NULL`); err != nil {
return err
}
var count int var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil { if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return err return err
@@ -914,7 +930,11 @@ func validBoardStatus(status string) bool {
return status == "backlog" || status == "in_progress" || status == "blocked" || status == "done" return status == "backlog" || status == "in_progress" || status == "blocked" || status == "done"
} }
func (s *Store) CreateBoardTask(creatorID int64, title, description, status string, assigneeID *int64, dueDate string) (int64, error) { func validBoardImportance(importance string) bool {
return importance == "low" || importance == "normal" || importance == "high" || importance == "urgent"
}
func (s *Store) CreateBoardTask(creatorID int64, title, description, status, importance string, assigneeIDs []int64, dueDate string) (int64, error) {
title = strings.TrimSpace(title) title = strings.TrimSpace(title)
description = strings.TrimSpace(description) description = strings.TrimSpace(description)
if title == "" { if title == "" {
@@ -926,20 +946,31 @@ func (s *Store) CreateBoardTask(creatorID int64, title, description, status stri
if !validBoardStatus(status) { if !validBoardStatus(status) {
status = "backlog" status = "backlog"
} }
var assignee any if !validBoardImportance(importance) {
if assigneeID != nil && *assigneeID > 0 { importance = "normal"
assignee = *assigneeID
} }
var due any var due any
if dueDate != "" { if dueDate != "" {
due = dueDate due = dueDate
} }
result, err := s.db.Exec(`INSERT INTO board_tasks(title,description,status,assignee_id,creator_id,due_date) tx, err := s.db.Begin()
VALUES(?,?,?,?,?,?)`, title, description, status, assignee, creatorID, due)
if err != nil { if err != nil {
return 0, err return 0, err
} }
return result.LastInsertId() defer tx.Rollback()
result, err := tx.Exec(`INSERT INTO board_tasks(title,description,status,importance,creator_id,due_date,created_at)
VALUES(?,?,?,?,?,?,?)`, title, description, status, importance, creatorID, due, time.Now().UTC())
if err != nil {
return 0, err
}
taskID, err := result.LastInsertId()
if err != nil {
return 0, err
}
if err := setBoardTaskAssignees(tx, taskID, assigneeIDs); err != nil {
return 0, err
}
return taskID, tx.Commit()
} }
func (s *Store) BoardTasks() ([]BoardTask, error) { func (s *Store) BoardTasks() ([]BoardTask, error) {
@@ -952,13 +983,11 @@ func (s *Store) ArchivedBoardTasks() ([]BoardTask, error) {
func (s *Store) boardTasks(archived bool) ([]BoardTask, error) { func (s *Store) boardTasks(archived bool) ([]BoardTask, error) {
query := `SELECT query := `SELECT
t.id,t.title,t.description,t.status,t.assignee_id, t.id,t.title,t.description,t.status,t.importance,
COALESCE(a.display_name,''),COALESCE(a.username,''),
t.creator_id,c.display_name,COALESCE(t.due_date,''),CAST(t.created_at AS TEXT), t.creator_id,c.display_name,COALESCE(t.due_date,''),CAST(t.created_at AS TEXT),
COALESCE(CAST(t.archived_at AS TEXT),'') COALESCE(CAST(t.archived_at AS TEXT),'')
FROM board_tasks t FROM board_tasks t
JOIN users c ON c.id=t.creator_id JOIN users c ON c.id=t.creator_id`
LEFT JOIN users a ON a.id=t.assignee_id`
if archived { if archived {
query += ` WHERE t.archived_at IS NOT NULL query += ` WHERE t.archived_at IS NOT NULL
ORDER BY t.archived_at DESC,t.id DESC` ORDER BY t.archived_at DESC,t.id DESC`
@@ -966,7 +995,7 @@ func (s *Store) boardTasks(archived bool) ([]BoardTask, error) {
query += ` WHERE t.archived_at IS NULL query += ` WHERE t.archived_at IS NULL
ORDER BY CASE t.status ORDER BY CASE t.status
WHEN 'backlog' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'blocked' THEN 2 ELSE 3 END, WHEN 'backlog' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'blocked' THEN 2 ELSE 3 END,
t.created_at,t.id` t.created_at DESC`
} }
rows, err := s.db.Query(query) rows, err := s.db.Query(query)
if err != nil { if err != nil {
@@ -976,8 +1005,8 @@ func (s *Store) boardTasks(archived bool) ([]BoardTask, error) {
for rows.Next() { for rows.Next() {
var task BoardTask var task BoardTask
if err := rows.Scan( if err := rows.Scan(
&task.ID, &task.Title, &task.Description, &task.Status, &task.AssigneeID, &task.ID, &task.Title, &task.Description, &task.Status, &task.Importance,
&task.AssigneeName, &task.AssigneeUser, &task.CreatorID, &task.CreatorName, &task.CreatorID, &task.CreatorName,
&task.DueDate, &task.CreatedAt, &task.ArchivedAt, &task.DueDate, &task.CreatedAt, &task.ArchivedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -995,6 +1024,34 @@ func (s *Store) boardTasks(archived bool) ([]BoardTask, error) {
for index := range tasks { for index := range tasks {
taskIndex[tasks[index].ID] = index taskIndex[tasks[index].ID] = index
} }
assigneeRows, err := s.db.Query(`SELECT a.task_id,u.id,u.username,u.display_name,
COALESCE(u.email,''),u.role,u.avatar_url,u.active
FROM board_task_assignees a JOIN users u ON u.id=a.user_id
ORDER BY u.display_name`)
if err != nil {
return nil, err
}
for assigneeRows.Next() {
var taskID int64
var user User
if err := assigneeRows.Scan(
&taskID, &user.ID, &user.Username, &user.DisplayName,
&user.Email, &user.Role, &user.AvatarURL, &user.Active,
); err != nil {
assigneeRows.Close()
return nil, err
}
if index, ok := taskIndex[taskID]; ok {
tasks[index].Assignees = append(tasks[index].Assignees, user)
}
}
if err := assigneeRows.Err(); err != nil {
assigneeRows.Close()
return nil, err
}
if err := assigneeRows.Close(); err != nil {
return nil, err
}
tagRows, err := s.db.Query(`SELECT tt.task_id,t.id,t.name,t.color 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 FROM board_task_tags tt JOIN board_tags t ON t.id=tt.tag_id
ORDER BY t.name`) ORDER BY t.name`)
@@ -1015,6 +1072,51 @@ func (s *Store) boardTasks(archived bool) ([]BoardTask, error) {
return tasks, tagRows.Err() return tasks, tagRows.Err()
} }
type sqlExecutor interface {
Exec(query string, args ...any) (sql.Result, error)
}
func setBoardTaskAssignees(executor sqlExecutor, taskID int64, userIDs []int64) error {
seen := map[int64]bool{}
for _, userID := range userIDs {
if userID < 1 || seen[userID] {
continue
}
seen[userID] = true
if _, err := executor.Exec(`INSERT INTO board_task_assignees(task_id,user_id)
SELECT ?,id FROM users WHERE id=? AND active=1`, taskID, userID); err != nil {
return err
}
}
return nil
}
func (s *Store) SetBoardTaskDetails(taskID int64, assigneeIDs []int64, importance string) error {
if !validBoardImportance(importance) {
return errors.New("choose a valid importance")
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.Exec(`UPDATE board_tasks SET importance=? WHERE id=?`, importance, taskID)
if err != nil {
return err
}
count, _ := result.RowsAffected()
if count == 0 {
return errors.New("task was not found")
}
if _, err := tx.Exec(`DELETE FROM board_task_assignees WHERE task_id=?`, taskID); err != nil {
return err
}
if err := setBoardTaskAssignees(tx, taskID, assigneeIDs); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) MoveBoardTask(id int64, status string) error { func (s *Store) MoveBoardTask(id int64, status string) error {
if !validBoardStatus(status) { if !validBoardStatus(status) {
return errors.New("invalid board column") return errors.New("invalid board column")
+51 -4
View File
@@ -42,8 +42,12 @@
<small>The new tag will be created and added to this card.</small> <small>The new tag will be created and added to this card.</small>
</details> </details>
</fieldset> </fieldset>
<fieldset class="assignee-picker">
<legend>Assignees <small>Optional · select multiple</small></legend>
<div>{{range .Users}}<label><input type="checkbox" name="assignee_ids" value="{{.ID}}"><span><i class="avatar">{{initial .DisplayName}}</i>{{.DisplayName}}</span></label>{{end}}</div>
</fieldset>
<div class="form-row"> <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>Importance<select name="importance"><option value="normal">Normal</option><option value="low">Low</option><option value="high">High</option><option value="urgent">Urgent</option></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> <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> </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> <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>
@@ -54,6 +58,32 @@
</header> </header>
{{template "notice" .}} {{template "notice" .}}
{{$filter := .BoardFilter}}
<section class="board-filter-bar">
<div class="board-filter-title"><span></span><strong>Filter cards</strong></div>
<form method="get" action="/board">
<label><span>Tag</span><select name="tag" aria-label="Filter by tag">
<option value="">All tags</option>
{{range .BoardTags}}<option value="{{.ID}}" {{if eq $filter.TagID .ID}}selected{{end}}>{{.Name}}</option>{{end}}
</select></label>
<label><span>Assignee</span><select name="assignee" aria-label="Filter by assignee">
<option value="">Everyone</option>
{{range .Users}}<option value="{{.ID}}" {{if eq $filter.AssigneeID .ID}}selected{{end}}>{{.DisplayName}}</option>{{end}}
</select></label>
<label><span>Priority</span><select name="priority" aria-label="Filter by priority">
<option value="">All priorities</option>
<option value="urgent" {{if eq $filter.Priority "urgent"}}selected{{end}}>Urgent</option>
<option value="high" {{if eq $filter.Priority "high"}}selected{{end}}>High</option>
<option value="normal" {{if eq $filter.Priority "normal"}}selected{{end}}>Normal</option>
<option value="low" {{if eq $filter.Priority "low"}}selected{{end}}>Low</option>
</select></label>
<div class="board-filter-actions">
{{if $filter.Active}}<a href="/board" class="text-button">Clear</a>{{end}}
<button class="button secondary" type="submit">Apply</button>
</div>
</form>
</section>
<div class="kanban-board" data-board data-csrf="{{.CSRF}}"> <div class="kanban-board" data-board data-csrf="{{.CSRF}}">
{{range .Board}} {{range .Board}}
<section class="kanban-column {{.Status}}" data-board-column="{{.Status}}"> <section class="kanban-column {{.Status}}" data-board-column="{{.Status}}">
@@ -66,13 +96,28 @@
{{$task := .}} {{$task := .}}
<article class="kanban-card" draggable="true" data-task-id="{{.ID}}"> <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}} {{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> <div class="card-topline"><span class="task-id">#{{.ID}}</span><span class="card-signals"><span class="importance-badge {{.Importance}}">{{if eq .Importance "urgent"}}Urgent{{else if eq .Importance "high"}}High{{else if eq .Importance "low"}}Low{{else}}Normal{{end}}</span>{{if .DueDate}}<span class="task-due">Due {{dateFA .DueDate}}</span>{{end}}</span></div>
<h3>{{.Title}}</h3> <h3>{{.Title}}</h3>
{{if .Description}}<p>{{.Description}}</p>{{end}} {{if .Description}}<p>{{.Description}}</p>{{end}}
<div class="task-meta"> <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}} {{if .Assignees}}<span class="task-assignees">{{range .Assignees}}<span class="task-assignee"><i class="avatar">{{initial .DisplayName}}</i><span>{{.DisplayName}}</span></span>{{end}}</span>{{else}}<span class="task-assignee unassigned"><i>?</i><span>Unassigned</span></span>{{end}}
<span class="task-creator">by {{.CreatorName}}</span> <span class="task-creator">by {{.CreatorName}}</span>
</div> </div>
<details class="card-controls">
<summary><span>Card controls</span><i></i></summary>
<div class="card-controls-body">
<details class="card-detail-editor">
<summary>People &amp; importance</summary>
<form method="post" action="/board/tasks/{{.ID}}/details">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<fieldset class="compact-assignee-picker">
<legend>Assignees</legend>
<div>{{range $.Users}}<label><input type="checkbox" name="assignee_ids" value="{{.ID}}" {{if hasUser $task.Assignees .ID}}checked{{end}}><span><i class="avatar">{{initial .DisplayName}}</i>{{.DisplayName}}</span></label>{{end}}</div>
</fieldset>
<label>Importance<select name="importance"><option value="low" {{if eq $task.Importance "low"}}selected{{end}}>Low</option><option value="normal" {{if eq $task.Importance "normal"}}selected{{end}}>Normal</option><option value="high" {{if eq $task.Importance "high"}}selected{{end}}>High</option><option value="urgent" {{if eq $task.Importance "urgent"}}selected{{end}}>Urgent</option></select></label>
<button class="button primary full" type="submit">Save details</button>
</form>
</details>
{{if $.BoardTags}} {{if $.BoardTags}}
<details class="card-tag-editor"> <details class="card-tag-editor">
<summary>Labels</summary> <summary>Labels</summary>
@@ -110,9 +155,11 @@
</div> </div>
{{end}} {{end}}
</div> </div>
</div>
</details>
</article> </article>
{{end}} {{end}}
{{if not .Tasks}}<div class="kanban-empty"><span>Drop tasks here</span></div>{{end}} {{if not .Tasks}}<div class="kanban-empty"><span>{{if $filter.Active}}No matching cards{{else}}Drop tasks here{{end}}</span></div>{{end}}
</div> </div>
</section> </section>
{{end}} {{end}}