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
|
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">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<h3>{ TaskColumnLabels[status] }</h3>
|
||||||
<span id={ "task-count-badge-" + string(status) }>
|
<span id={ "task-count-badge-" + string(status) }>
|
||||||
@ui.Badge(ui.BadgeProps{Label: strconv.Itoa(len(tasks)), Variant: ui.BadgeVariantInfo})
|
@ui.Badge(ui.BadgeProps{Label: strconv.Itoa(len(tasks)), Variant: ui.BadgeVariantInfo})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id={ "add-task-slot-" + string(status) }>
|
||||||
|
@AddTaskTrigger(tabloID, status, csrfToken, filter)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="sortable-column min-h-16 space-y-2"
|
class="task-list sortable-column"
|
||||||
data-status={ string(status) }
|
data-status={ string(status) }
|
||||||
id={ "column-" + string(status) }
|
id={ "column-" + string(status) }
|
||||||
aria-label={ TaskColumnLabels[status] + " column" }
|
aria-label={ TaskColumnLabels[status] + " column" }
|
||||||
>
|
>
|
||||||
if len(tasks) == 0 {
|
if len(tasks) == 0 {
|
||||||
<p class="text-sm text-slate-400 italic px-2 py-1">No tasks yet</p>
|
<p class="task-list-empty">No tasks yet</p>
|
||||||
}
|
} else {
|
||||||
for _, task := range tasks {
|
{{ groups := groupTasksByEtape(tasks, etapes) }}
|
||||||
|
for _, group := range groups {
|
||||||
|
@EtapeGroupHeader(group)
|
||||||
|
for _, task := range group.Tasks {
|
||||||
@TaskCard(tabloID, task, csrfToken)
|
@TaskCard(tabloID, task, csrfToken)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div id={ "add-task-slot-" + string(status) }>
|
|
||||||
@AddTaskTrigger(tabloID, status, csrfToken, filter)
|
|
||||||
</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() }
|
||||||
>
|
|
||||||
<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-get={ "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/edit" }
|
||||||
hx-target="closest .task-card-zone"
|
hx-target="closest .task-card-zone"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
role="button"
|
role="button"
|
||||||
aria-label={ "Edit task: " + task.Title }
|
aria-label={ "Edit task: " + task.Title }
|
||||||
>
|
>
|
||||||
<p class="text-sm text-slate-800 break-words">{ task.Title }</p>
|
<div class="task-check" role="checkbox" aria-checked="false"></div>
|
||||||
</div>
|
<div class="task-body">
|
||||||
</div>
|
<p>{ task.Title }</p>
|
||||||
<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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue