feat(16-03): restyle kanban board with etape grouping (tasks.templ)
- Add EtapeGroup type and groupTasksByEtape helper (etape declaration order, unassigned last) - Add EtapeGroupHeader templ component with color dot and muted label for unassigned - Update KanbanBoard and KanbanColumn signatures to accept etapes []sqlc.Etape (5th param) - Restyle KanbanColumn: kanban-column > tasks-section > tasks-section-header/task-list layout - Restyle TaskCard: task-row task-card with task-check + task-body + @ui.IconButton(trash) - Restyle AddTaskTrigger: tasks-add-button class (replaces ui-button compound classes) - Remove @EtapeStrip OOB calls from TaskCardGone and TaskCardOOB; keep params with TODO
This commit is contained in:
parent
f39971bd0a
commit
084fc0ebba
1 changed files with 125 additions and 51 deletions
|
|
@ -17,10 +17,76 @@ func groupTasksByStatus(tasks []sqlc.Task) map[sqlc.TaskStatus][]sqlc.Task {
|
|||
return result
|
||||
}
|
||||
|
||||
// EtapeGroup holds tasks for one etape within a kanban column.
|
||||
type EtapeGroup struct {
|
||||
EtapeID string // "" for unassigned
|
||||
EtapeTitle string // "No etape" for unassigned
|
||||
EtapeColor string // "" for unassigned
|
||||
Tasks []sqlc.Task
|
||||
}
|
||||
|
||||
// groupTasksByEtape groups tasks by etape in declaration order; unassigned last.
|
||||
// Model: groupTasksByStatus above. Uses uuid.UUID(id.Bytes).String() same as taskEtapeIDString.
|
||||
func groupTasksByEtape(tasks []sqlc.Task, etapes []sqlc.Etape) []EtapeGroup {
|
||||
// Collect tasks per etape ID string
|
||||
tasksByEtapeID := make(map[string][]sqlc.Task)
|
||||
var unassigned []sqlc.Task
|
||||
|
||||
for _, t := range tasks {
|
||||
if !t.EtapeID.Valid {
|
||||
unassigned = append(unassigned, t)
|
||||
} else {
|
||||
keyStr := uuid.UUID(t.EtapeID.Bytes).String()
|
||||
tasksByEtapeID[keyStr] = append(tasksByEtapeID[keyStr], t)
|
||||
}
|
||||
}
|
||||
|
||||
var groups []EtapeGroup
|
||||
for _, etape := range etapes {
|
||||
id := etape.ID.String()
|
||||
ts := tasksByEtapeID[id]
|
||||
if len(ts) == 0 {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, EtapeGroup{
|
||||
EtapeID: id,
|
||||
EtapeTitle: etape.Title,
|
||||
EtapeColor: "", // Etape struct has no Color field in this schema
|
||||
Tasks: ts,
|
||||
})
|
||||
}
|
||||
|
||||
if len(unassigned) > 0 {
|
||||
groups = append(groups, EtapeGroup{
|
||||
EtapeID: "",
|
||||
EtapeTitle: "No etape",
|
||||
EtapeColor: "",
|
||||
Tasks: unassigned,
|
||||
})
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// EtapeGroupHeader renders the sub-heading row for an etape group within a kanban column.
|
||||
// "No etape" / unassigned group omits the color dot and uses muted label style.
|
||||
templ EtapeGroupHeader(group EtapeGroup) {
|
||||
<div class="etape-group-header">
|
||||
if group.EtapeColor != "" {
|
||||
<span class="etape-group-color-dot" style={ "background-color: " + group.EtapeColor }></span>
|
||||
}
|
||||
if group.EtapeID == "" {
|
||||
<span class="etape-group-label is-unassigned">{ group.EtapeTitle }</span>
|
||||
} else {
|
||||
<span class="etape-group-label">{ group.EtapeTitle }</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// KanbanBoard renders the outer board container with 4 columns and a hidden
|
||||
// reorder form. Used by TabloDetailPage below the tablo header section.
|
||||
// UI-SPEC §1 and D-08.
|
||||
templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task, filter EtapeFilter) {
|
||||
templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task, filter EtapeFilter, etapes []sqlc.Etape) {
|
||||
{{ grouped := groupTasksByStatus(tasks) }}
|
||||
<div id="kanban-board" class="flex gap-4 overflow-x-auto pb-4">
|
||||
<form
|
||||
|
|
@ -35,7 +101,7 @@ templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task, filter
|
|||
@ui.CSRFField(csrfToken)
|
||||
</form>
|
||||
for _, status := range TaskColumns {
|
||||
@KanbanColumn(tabloID, status, grouped[status], csrfToken, filter)
|
||||
@KanbanColumn(tabloID, status, grouped[status], csrfToken, filter, etapes)
|
||||
}
|
||||
<script>
|
||||
(function() {
|
||||
|
|
@ -102,30 +168,40 @@ templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task, filter
|
|||
}
|
||||
|
||||
// KanbanColumn renders a single kanban column: header, task list, and add-task slot.
|
||||
// Tasks are grouped by etape in declaration order; unassigned tasks appear last.
|
||||
// UI-SPEC §1 and §2.
|
||||
templ KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task, csrfToken string, filter EtapeFilter) {
|
||||
<div class="flex-shrink-0 w-72">
|
||||
<div class="bg-slate-100 rounded px-3 py-2 mb-2 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-slate-700">{ TaskColumnLabels[status] }</h3>
|
||||
<span id={ "task-count-badge-" + string(status) }>
|
||||
@ui.Badge(ui.BadgeProps{Label: strconv.Itoa(len(tasks)), Variant: ui.BadgeVariantInfo})
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="sortable-column min-h-16 space-y-2"
|
||||
data-status={ string(status) }
|
||||
id={ "column-" + string(status) }
|
||||
aria-label={ TaskColumnLabels[status] + " column" }
|
||||
>
|
||||
if len(tasks) == 0 {
|
||||
<p class="text-sm text-slate-400 italic px-2 py-1">No tasks yet</p>
|
||||
}
|
||||
for _, task := range tasks {
|
||||
@TaskCard(tabloID, task, csrfToken)
|
||||
}
|
||||
</div>
|
||||
<div id={ "add-task-slot-" + string(status) }>
|
||||
@AddTaskTrigger(tabloID, status, csrfToken, filter)
|
||||
templ KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task, csrfToken string, filter EtapeFilter, etapes []sqlc.Etape) {
|
||||
<div class="kanban-column">
|
||||
<div class="tasks-section">
|
||||
<div class="tasks-section-header">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<h3>{ TaskColumnLabels[status] }</h3>
|
||||
<span id={ "task-count-badge-" + string(status) }>
|
||||
@ui.Badge(ui.BadgeProps{Label: strconv.Itoa(len(tasks)), Variant: ui.BadgeVariantInfo})
|
||||
</span>
|
||||
</div>
|
||||
<div id={ "add-task-slot-" + string(status) }>
|
||||
@AddTaskTrigger(tabloID, status, csrfToken, filter)
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="task-list sortable-column"
|
||||
data-status={ string(status) }
|
||||
id={ "column-" + string(status) }
|
||||
aria-label={ TaskColumnLabels[status] + " column" }
|
||||
>
|
||||
if len(tasks) == 0 {
|
||||
<p class="task-list-empty">No tasks yet</p>
|
||||
} else {
|
||||
{{ groups := groupTasksByEtape(tasks, etapes) }}
|
||||
for _, group := range groups {
|
||||
@EtapeGroupHeader(group)
|
||||
for _, task := range group.Tasks {
|
||||
@TaskCard(tabloID, task, csrfToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -136,32 +212,30 @@ templ KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task,
|
|||
templ TaskCard(tabloID uuid.UUID, task sqlc.Task, csrfToken string) {
|
||||
<div class="task-card-zone" id={ "task-" + task.ID.String() }>
|
||||
<div
|
||||
class="task-card bg-white rounded border border-slate-200 px-3 py-2 shadow-sm"
|
||||
class="task-row task-card"
|
||||
data-task-id={ task.ID.String() }
|
||||
hx-get={ "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/edit" }
|
||||
hx-target="closest .task-card-zone"
|
||||
hx-swap="outerHTML"
|
||||
role="button"
|
||||
aria-label={ "Edit task: " + task.Title }
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-start gap-2 flex-1 min-w-0">
|
||||
<div class="task-drag-handle cursor-grab text-slate-400 select-none mt-0.5" aria-hidden="true">⠿</div>
|
||||
<div
|
||||
class="flex-1 min-w-0 cursor-pointer"
|
||||
hx-get={ "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/edit" }
|
||||
hx-target="closest .task-card-zone"
|
||||
hx-swap="outerHTML"
|
||||
role="button"
|
||||
aria-label={ "Edit task: " + task.Title }
|
||||
>
|
||||
<p class="text-sm text-slate-800 break-words">{ task.Title }</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ui-button ui-button-soft ui-button-danger ui-button-md flex-shrink-0 text-xs"
|
||||
hx-get={ "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/delete-confirm" }
|
||||
hx-target="closest .task-card-zone"
|
||||
hx-swap="outerHTML"
|
||||
aria-label={ "Delete task: " + task.Title }
|
||||
>Delete</button>
|
||||
<div class="task-check" role="checkbox" aria-checked="false"></div>
|
||||
<div class="task-body">
|
||||
<p>{ task.Title }</p>
|
||||
</div>
|
||||
@ui.IconButton(ui.IconButtonProps{
|
||||
Label: "Delete task: " + task.Title,
|
||||
Icon: "trash",
|
||||
Variant: ui.IconButtonVariantDanger,
|
||||
Tone: ui.IconButtonToneGhost,
|
||||
Type: "button",
|
||||
Attrs: templ.Attributes{
|
||||
"hx-get": "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/delete-confirm",
|
||||
"hx-target": "closest .task-card-zone",
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -372,7 +446,7 @@ templ TaskDeleteConfirmFragment(tabloID uuid.UUID, task sqlc.Task, csrfToken str
|
|||
templ AddTaskTrigger(tabloID uuid.UUID, status sqlc.TaskStatus, csrfToken string, filter EtapeFilter) {
|
||||
<button
|
||||
type="button"
|
||||
class="ui-button ui-button-soft ui-button-neutral ui-button-md w-full text-left text-sm mt-2"
|
||||
class="tasks-add-button"
|
||||
hx-get={ "/tablos/" + tabloID.String() + "/tasks/new?status=" + string(status) + filter.QuerySuffix() }
|
||||
hx-target={ "#add-task-slot-" + string(status) }
|
||||
hx-swap="innerHTML"
|
||||
|
|
@ -381,19 +455,19 @@ templ AddTaskTrigger(tabloID uuid.UUID, status sqlc.TaskStatus, csrfToken string
|
|||
|
||||
// TaskCardGone renders an empty zone div with the task's id so HTMX outerHTML
|
||||
// swap removes the card from the DOM after a successful delete (TASK-06).
|
||||
// TODO: remove etapes and counts params after Phase 16 cleanup
|
||||
templ TaskCardGone(taskID uuid.UUID, tabloID uuid.UUID, csrfToken string, filter EtapeFilter, etapes []sqlc.Etape, counts EtapeTaskCounts) {
|
||||
<div id={ "task-" + taskID.String() } class="task-card-zone"></div>
|
||||
@EtapeStrip(tabloID, etapes, counts, filter, csrfToken, true)
|
||||
}
|
||||
|
||||
// TaskCardOOB renders a new TaskCard AND an OOB swap that resets the add-task
|
||||
// slot to AddTaskTrigger. Used by TaskCreateHandler to perform both operations
|
||||
// in a single HTMX response.
|
||||
// D-08/UI-SPEC §2: OOB swap resets #add-task-slot-{status} after create.
|
||||
// TODO: remove etapes and counts params after Phase 16 cleanup
|
||||
templ TaskCardOOB(status sqlc.TaskStatus, task sqlc.Task, tabloID uuid.UUID, csrfToken string, filter EtapeFilter, etapes []sqlc.Etape, counts EtapeTaskCounts) {
|
||||
@TaskCard(tabloID, task, csrfToken)
|
||||
<div hx-swap-oob={ "innerHTML:#add-task-slot-" + string(status) }>
|
||||
@AddTaskTrigger(tabloID, status, csrfToken, filter)
|
||||
</div>
|
||||
@EtapeStrip(tabloID, etapes, counts, filter, csrfToken, true)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue