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:
Arthur Belleville 2026-05-16 23:42:40 +02:00
parent f39971bd0a
commit 084fc0ebba
No known key found for this signature in database

View file

@ -17,10 +17,76 @@ func groupTasksByStatus(tasks []sqlc.Task) map[sqlc.TaskStatus][]sqlc.Task {
return result 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 // KanbanBoard renders the outer board container with 4 columns and a hidden
// reorder form. Used by TabloDetailPage below the tablo header section. // reorder form. Used by TabloDetailPage below the tablo header section.
// UI-SPEC §1 and D-08. // 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) }} {{ grouped := groupTasksByStatus(tasks) }}
<div id="kanban-board" class="flex gap-4 overflow-x-auto pb-4"> <div id="kanban-board" class="flex gap-4 overflow-x-auto pb-4">
<form <form
@ -35,7 +101,7 @@ templ KanbanBoard(tabloID uuid.UUID, csrfToken string, tasks []sqlc.Task, filter
@ui.CSRFField(csrfToken) @ui.CSRFField(csrfToken)
</form> </form>
for _, status := range TaskColumns { for _, status := range TaskColumns {
@KanbanColumn(tabloID, status, grouped[status], csrfToken, filter) @KanbanColumn(tabloID, status, grouped[status], csrfToken, filter, etapes)
} }
<script> <script>
(function() { (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. // 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. // UI-SPEC §1 and §2.
templ KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task, csrfToken string, filter EtapeFilter) { templ KanbanColumn(tabloID uuid.UUID, status sqlc.TaskStatus, tasks []sqlc.Task, csrfToken string, filter EtapeFilter, etapes []sqlc.Etape) {
<div class="flex-shrink-0 w-72"> <div class="kanban-column">
<div class="bg-slate-100 rounded px-3 py-2 mb-2 flex items-center justify-between"> <div class="tasks-section">
<h3 class="text-sm font-semibold text-slate-700">{ TaskColumnLabels[status] }</h3> <div class="tasks-section-header">
<span id={ "task-count-badge-" + string(status) }> <div style="display: flex; align-items: center; gap: 0.5rem;">
@ui.Badge(ui.BadgeProps{Label: strconv.Itoa(len(tasks)), Variant: ui.BadgeVariantInfo}) <h3>{ TaskColumnLabels[status] }</h3>
</span> <span id={ "task-count-badge-" + string(status) }>
</div> @ui.Badge(ui.BadgeProps{Label: strconv.Itoa(len(tasks)), Variant: ui.BadgeVariantInfo})
<div </span>
class="sortable-column min-h-16 space-y-2" </div>
data-status={ string(status) } <div id={ "add-task-slot-" + string(status) }>
id={ "column-" + string(status) } @AddTaskTrigger(tabloID, status, csrfToken, filter)
aria-label={ TaskColumnLabels[status] + " column" } </div>
> </div>
if len(tasks) == 0 { <div
<p class="text-sm text-slate-400 italic px-2 py-1">No tasks yet</p> class="task-list sortable-column"
} data-status={ string(status) }
for _, task := range tasks { id={ "column-" + string(status) }
@TaskCard(tabloID, task, csrfToken) aria-label={ TaskColumnLabels[status] + " column" }
} >
</div> if len(tasks) == 0 {
<div id={ "add-task-slot-" + string(status) }> <p class="task-list-empty">No tasks yet</p>
@AddTaskTrigger(tabloID, status, csrfToken, filter) } else {
{{ groups := groupTasksByEtape(tasks, etapes) }}
for _, group := range groups {
@EtapeGroupHeader(group)
for _, task := range group.Tasks {
@TaskCard(tabloID, task, csrfToken)
}
}
}
</div>
</div> </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) { templ TaskCard(tabloID uuid.UUID, task sqlc.Task, csrfToken string) {
<div class="task-card-zone" id={ "task-" + task.ID.String() }> <div class="task-card-zone" id={ "task-" + task.ID.String() }>
<div <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() } 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="task-check" role="checkbox" aria-checked="false"></div>
<div class="flex items-start gap-2 flex-1 min-w-0"> <div class="task-body">
<div class="task-drag-handle cursor-grab text-slate-400 select-none mt-0.5" aria-hidden="true">⠿</div> <p>{ task.Title }</p>
<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> </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>
</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) { templ AddTaskTrigger(tabloID uuid.UUID, status sqlc.TaskStatus, csrfToken string, filter EtapeFilter) {
<button <button
type="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-get={ "/tablos/" + tabloID.String() + "/tasks/new?status=" + string(status) + filter.QuerySuffix() }
hx-target={ "#add-task-slot-" + string(status) } hx-target={ "#add-task-slot-" + string(status) }
hx-swap="innerHTML" 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 // 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). // 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) { 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> <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 // TaskCardOOB renders a new TaskCard AND an OOB swap that resets the add-task
// slot to AddTaskTrigger. Used by TaskCreateHandler to perform both operations // slot to AddTaskTrigger. Used by TaskCreateHandler to perform both operations
// in a single HTMX response. // in a single HTMX response.
// D-08/UI-SPEC §2: OOB swap resets #add-task-slot-{status} after create. // 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) { templ TaskCardOOB(status sqlc.TaskStatus, task sqlc.Task, tabloID uuid.UUID, csrfToken string, filter EtapeFilter, etapes []sqlc.Etape, counts EtapeTaskCounts) {
@TaskCard(tabloID, task, csrfToken) @TaskCard(tabloID, task, csrfToken)
<div hx-swap-oob={ "innerHTML:#add-task-slot-" + string(status) }> <div hx-swap-oob={ "innerHTML:#add-task-slot-" + string(status) }>
@AddTaskTrigger(tabloID, status, csrfToken, filter) @AddTaskTrigger(tabloID, status, csrfToken, filter)
</div> </div>
@EtapeStrip(tabloID, etapes, counts, filter, csrfToken, true)
} }