package templates import ( "backend/internal/db/sqlc" "backend/internal/web/ui" "github.com/google/uuid" "strconv" ) // groupTasksByStatus groups a flat task slice into a map keyed by TaskStatus. // Returns a map; missing statuses return nil (empty) slices. func groupTasksByStatus(tasks []sqlc.Task) map[sqlc.TaskStatus][]sqlc.Task { result := make(map[sqlc.TaskStatus][]sqlc.Task, len(TaskColumns)) for _, t := range tasks { result[t.Status] = append(result[t.Status], t) } 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) {
if group.EtapeColor != "" { } if group.EtapeID == "" { { group.EtapeTitle } } else { { group.EtapeTitle } }
} // 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, etapes []sqlc.Etape) { {{ grouped := groupTasksByStatus(tasks) }}
for _, status := range TaskColumns { @KanbanColumn(tabloID, status, grouped[status], csrfToken, filter, etapes) }
} // 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, etapes []sqlc.Etape) {

{ TaskColumnLabels[status] }

@ui.Badge(ui.BadgeProps{Label: strconv.Itoa(len(tasks)), Variant: ui.BadgeVariantInfo})
@AddTaskTrigger(tabloID, status, csrfToken, filter)
if len(tasks) == 0 {

No tasks yet

} else { {{ groups := groupTasksByEtape(tasks, etapes) }} for _, group := range groups { @EtapeGroupHeader(group) for _, task := range group.Tasks { @TaskCard(tabloID, task, csrfToken) } } }
} // TaskCard renders a single task card. The outer wrapper carries class "task-card-zone" // and id="task-{task.ID}" so HTMX outerHTML swaps round-trip cleanly. // UI-SPEC §4 and D-08. templ TaskCard(tabloID uuid.UUID, task sqlc.Task, csrfToken string) {

{ task.Title }

@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", }, })
} // TaskEditFragment renders the inline edit form for an existing task. // The outer wrapper carries class="task-card-zone" id="task-{task.ID}" so // HTMX outerHTML swaps round-trip cleanly with TaskCard (TASK-03). // UI-SPEC §3. templ TaskEditFragment(tabloID uuid.UUID, task sqlc.Task, etapes []sqlc.Etape, form TaskUpdateForm, errs TaskUpdateErrors, csrfToken string) {
{{ selectedEtapeID := taskEtapeIDString(task.EtapeID) }}
@ui.CSRFField(csrfToken)
@FieldError(errs.Title)
if errs.General != "" { @FieldError(errs.General) }
@ui.Button(ui.ButtonProps{ Label: "Save changes", Variant: ui.ButtonVariantDefault, Tone: ui.ButtonToneSolid, Size: ui.SizeMD, Type: "submit", }) @ui.Button(ui.ButtonProps{ Label: "Discard changes", Variant: ui.ButtonVariantNeutral, Tone: ui.ButtonToneSoft, Size: ui.SizeMD, Type: "button", Attrs: templ.Attributes{ "hx-get": "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/show", "hx-target": "closest .task-card-zone", "hx-swap": "outerHTML", }, })
} // TaskCreateFormFragment renders the inline create form shown when a user clicks // "+ Add task". Targets #column-{status} for HTMX beforeend swap on submit. // UI-SPEC §2. templ TaskCreateFormFragment(tabloID uuid.UUID, status sqlc.TaskStatus, form TaskCreateForm, errs TaskCreateErrors, csrfToken string, filter EtapeFilter, etapes []sqlc.Etape) {
@ui.CSRFField(csrfToken)
@FieldError(errs.Title)
@ui.Button(ui.ButtonProps{ Label: "Save", Variant: ui.ButtonVariantDefault, Tone: ui.ButtonToneSolid, Size: ui.SizeMD, Type: "submit", }) @ui.Button(ui.ButtonProps{ Label: "Discard", Variant: ui.ButtonVariantNeutral, Tone: ui.ButtonToneSoft, Size: ui.SizeMD, Type: "button", Attrs: templ.Attributes{ "hx-get": "/tablos/" + tabloID.String() + "/tasks/cancel-new?status=" + string(status) + filter.QuerySuffix(), "hx-target": "#add-task-slot-" + string(status), "hx-swap": "innerHTML", }, })
} // TaskDeleteConfirmFragment renders the delete confirmation dialog for a task. // Carries class "task-card-zone" so outerHTML round-trips work correctly. // UI-SPEC §5. templ TaskDeleteConfirmFragment(tabloID uuid.UUID, task sqlc.Task, csrfToken string) {

Delete task?

This cannot be undone.

@ui.CSRFField(csrfToken) @ui.Button(ui.ButtonProps{ Label: "Yes, delete", Variant: ui.ButtonVariantDanger, Tone: ui.ButtonToneSolid, Size: ui.SizeMD, Type: "submit", Attrs: templ.Attributes{ "aria-label": "Confirm delete task", }, })
@ui.Button(ui.ButtonProps{ Label: "Keep task", Variant: ui.ButtonVariantNeutral, Tone: ui.ButtonToneSoft, Size: ui.SizeMD, Type: "button", Attrs: templ.Attributes{ "hx-get": "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/show", "hx-target": "closest .task-card-zone", "hx-swap": "outerHTML", "aria-label": "Keep task", }, })
} // AddTaskTrigger renders the "+ Add task" button that expands to TaskCreateFormFragment. // Targets #add-task-slot-{status} for innerHTML replacement. // UI-SPEC §2. templ AddTaskTrigger(tabloID uuid.UUID, status sqlc.TaskStatus, csrfToken string, filter EtapeFilter) { } // 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) {
} // 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)
@AddTaskTrigger(tabloID, status, csrfToken, filter)
}