Phase 20 work was executed against go-backend/ (prototype) instead of backend/ (production). This commit ports the Figma-aligned restyle to the correct codebase. Changes: - tablos.templ: replace project-card-top header with tablo-detail-header, tablo-detail-avatar, tablo-detail-title; update tab bar to tablo-tab-bar; localise tab labels to French (Vue d'ensemble, Tâches, Fichiers, Discussion, Événements) - tasks.templ: update KanbanBoard to tablo-kanban-board; KanbanColumn to tablo-kanban-column with new header/count/empty classes; TaskCard to card-style layout (task-card-top-row, task-card-title, task-card-delete) - app.css: add sections 25–27 — tablo detail page, kanban board/columns, and task card (card appearance, hover states) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
468 lines
16 KiB
Text
468 lines
16 KiB
Text
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) {
|
|
<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, etapes []sqlc.Etape) {
|
|
{{ grouped := groupTasksByStatus(tasks) }}
|
|
<div id="kanban-board" class="tablo-kanban-board">
|
|
<form
|
|
id="reorder-form"
|
|
method="POST"
|
|
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks/reorder" + filter.QueryParam()) }
|
|
hx-post={ "/tablos/" + tabloID.String() + "/tasks/reorder" + filter.QueryParam() }
|
|
hx-target="#kanban-board"
|
|
hx-swap="outerHTML"
|
|
class="hidden"
|
|
>
|
|
@ui.CSRFField(csrfToken)
|
|
</form>
|
|
for _, status := range TaskColumns {
|
|
@KanbanColumn(tabloID, status, grouped[status], csrfToken, filter, etapes)
|
|
}
|
|
<script>
|
|
(function() {
|
|
function initSortable(root) {
|
|
var cols = root.querySelectorAll ? root.querySelectorAll(".sortable-column") : [];
|
|
if (cols.length === 0) {
|
|
cols = document.querySelectorAll(".sortable-column");
|
|
}
|
|
cols.forEach(function(col) {
|
|
if (col._sortableInit) { return; }
|
|
col._sortableInit = true;
|
|
new Sortable(col, {
|
|
group: "kanban",
|
|
animation: 150,
|
|
handle: ".task-drag-handle",
|
|
draggable: ".task-card-zone",
|
|
ghostClass: "bg-slate-100",
|
|
chosenClass: "opacity-50",
|
|
onEnd: function() {
|
|
var form = document.getElementById("reorder-form");
|
|
form.querySelectorAll("input[name=task_id],input[name=task_col]").forEach(function(el) { el.remove(); });
|
|
document.querySelectorAll(".sortable-column").forEach(function(c) {
|
|
c.querySelectorAll(".task-card").forEach(function(card) {
|
|
var inp = document.createElement("input");
|
|
inp.type = "hidden"; inp.name = "task_id"; inp.value = card.dataset.taskId;
|
|
form.appendChild(inp);
|
|
var colInp = document.createElement("input");
|
|
colInp.type = "hidden"; colInp.name = "task_col"; colInp.value = c.dataset.status;
|
|
form.appendChild(colInp);
|
|
});
|
|
});
|
|
htmx.trigger(form, "submit");
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function updateBadges() {
|
|
document.querySelectorAll(".sortable-column").forEach(function(col) {
|
|
var status = col.dataset.status;
|
|
var count = col.querySelectorAll(".task-card").length;
|
|
var wrapper = document.getElementById("task-count-badge-" + status);
|
|
if (wrapper) {
|
|
var inner = wrapper.querySelector("span") || wrapper;
|
|
inner.textContent = String(count);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initial page load — runs after all defer scripts (htmx + sortable) have executed.
|
|
document.addEventListener("DOMContentLoaded", function() {
|
|
initSortable(document.body);
|
|
updateBadges();
|
|
});
|
|
|
|
// After any HTMX swap — re-init new columns and refresh badge counts.
|
|
document.addEventListener("htmx:afterSettle", function() {
|
|
initSortable(document.body);
|
|
updateBadges();
|
|
});
|
|
})();
|
|
</script>
|
|
</div>
|
|
}
|
|
|
|
// 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) {
|
|
<div class="tablo-kanban-column" data-status={ string(status) }>
|
|
<div class="tablo-kanban-column-header">
|
|
<span class="tablo-kanban-column-title">{ TaskColumnLabels[status] }</span>
|
|
<span id={ "task-count-badge-" + string(status) } class="tablo-kanban-task-count">{ strconv.Itoa(len(tasks)) }</span>
|
|
<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="tablo-kanban-empty">Aucune tâche</p>
|
|
} else {
|
|
{{ groups := groupTasksByEtape(tasks, etapes) }}
|
|
for _, group := range groups {
|
|
@EtapeGroupHeader(group)
|
|
for _, task := range group.Tasks {
|
|
@TaskCard(tabloID, task, csrfToken)
|
|
}
|
|
}
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
// 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) {
|
|
<div class="task-card-zone" id={ "task-" + task.ID.String() }>
|
|
<div
|
|
class="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="task-card-top-row">
|
|
<span class="task-drag-handle" aria-hidden="true">⠿</span>
|
|
<span class="task-card-title">{ task.Title }</span>
|
|
@ui.IconButton(ui.IconButtonProps{
|
|
Label: "Delete task: " + task.Title,
|
|
Icon: "trash",
|
|
Variant: ui.IconButtonVariantDanger,
|
|
Tone: ui.IconButtonToneGhost,
|
|
Type: "button",
|
|
Attrs: templ.Attributes{
|
|
"class": "task-card-delete",
|
|
"hx-get": "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/delete-confirm",
|
|
"hx-target": "closest .task-card-zone",
|
|
"hx-swap": "outerHTML",
|
|
},
|
|
})
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
// 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) {
|
|
<div class="task-card-zone" id={ "task-" + task.ID.String() }>
|
|
{{ selectedEtapeID := taskEtapeIDString(task.EtapeID) }}
|
|
<form
|
|
method="POST"
|
|
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks/" + task.ID.String()) }
|
|
hx-post={ "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() }
|
|
hx-target="closest .task-card-zone"
|
|
hx-swap="outerHTML"
|
|
class="bg-white rounded border border-slate-200 p-3 shadow-sm space-y-2"
|
|
>
|
|
@ui.CSRFField(csrfToken)
|
|
<div>
|
|
<input
|
|
type="text"
|
|
name="title"
|
|
value={ form.Title }
|
|
maxlength="255"
|
|
required
|
|
placeholder="Task title"
|
|
class="block w-full rounded border border-slate-300 px-2 py-1 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
|
|
/>
|
|
@FieldError(errs.Title)
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-slate-600 mb-1">Etape</label>
|
|
<select
|
|
name="etape_id"
|
|
class="block w-full rounded border border-slate-300 px-2 py-1 text-sm focus:border-slate-500 focus:outline-none"
|
|
>
|
|
if selectedEtapeID == "" {
|
|
<option value="" selected>No etape</option>
|
|
} else {
|
|
<option value="">No etape</option>
|
|
}
|
|
for _, etape := range etapes {
|
|
if selectedEtapeID == etape.ID.String() {
|
|
<option value={ etape.ID.String() } selected>{ etape.Title }</option>
|
|
} else {
|
|
<option value={ etape.ID.String() }>{ etape.Title }</option>
|
|
}
|
|
}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<textarea
|
|
name="description"
|
|
rows="3"
|
|
placeholder="Description (optional)"
|
|
class="block w-full rounded border border-slate-300 px-2 py-1 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
|
|
>{ form.Description }</textarea>
|
|
</div>
|
|
if errs.General != "" {
|
|
@FieldError(errs.General)
|
|
}
|
|
<div class="flex items-center gap-2">
|
|
@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",
|
|
},
|
|
})
|
|
</div>
|
|
</form>
|
|
</div>
|
|
}
|
|
|
|
// 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) {
|
|
<form
|
|
method="POST"
|
|
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks") }
|
|
hx-post={ "/tablos/" + tabloID.String() + "/tasks" }
|
|
hx-target={ "#column-" + string(status) }
|
|
hx-swap="beforeend"
|
|
class="bg-white rounded border border-slate-200 p-3 shadow-sm space-y-2"
|
|
>
|
|
<input type="hidden" name="status" value={ string(status) }/>
|
|
@ui.CSRFField(csrfToken)
|
|
<div>
|
|
<input
|
|
type="text"
|
|
name="title"
|
|
value={ form.Title }
|
|
maxlength="255"
|
|
required
|
|
placeholder="Task title"
|
|
class="block w-full rounded border border-slate-300 px-2 py-1 text-sm placeholder-slate-400 focus:border-slate-500 focus:outline-none"
|
|
/>
|
|
@FieldError(errs.Title)
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-slate-600 mb-1">Etape</label>
|
|
<select
|
|
name="etape_id"
|
|
class="block w-full rounded border border-slate-300 px-2 py-1 text-sm focus:border-slate-500 focus:outline-none"
|
|
>
|
|
if form.EtapeID == "" {
|
|
<option value="" selected>No etape</option>
|
|
} else {
|
|
<option value="">No etape</option>
|
|
}
|
|
for _, etape := range etapes {
|
|
if form.EtapeID == etape.ID.String() {
|
|
<option value={ etape.ID.String() } selected>{ etape.Title }</option>
|
|
} else {
|
|
<option value={ etape.ID.String() }>{ etape.Title }</option>
|
|
}
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
@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",
|
|
},
|
|
})
|
|
</div>
|
|
</form>
|
|
}
|
|
|
|
// 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) {
|
|
<div class="task-card-zone" id={ "task-" + task.ID.String() }>
|
|
<div class="bg-white rounded border border-slate-200 p-3 shadow-sm space-y-2">
|
|
<p class="text-sm font-semibold text-slate-800">Delete task?</p>
|
|
<p class="text-xs text-slate-600">This cannot be undone.</p>
|
|
<div class="flex items-center gap-2">
|
|
<form
|
|
method="POST"
|
|
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/delete") }
|
|
hx-post={ "/tablos/" + tabloID.String() + "/tasks/" + task.ID.String() + "/delete" }
|
|
hx-target="closest .task-card-zone"
|
|
hx-swap="outerHTML"
|
|
>
|
|
@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",
|
|
},
|
|
})
|
|
</form>
|
|
@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",
|
|
},
|
|
})
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
// 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) {
|
|
<button
|
|
type="button"
|
|
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"
|
|
>+ Add task</button>
|
|
}
|
|
|
|
// 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>
|
|
}
|
|
|
|
// 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>
|
|
}
|