Ekran — Todo Lista
Opis
Todo Lista pozwala rodzicowi/nauczycielowi przypisywac zadania dzieciom i sledzic ich realizacje. Zawiera taby statusow, tabele z paginacja HTMX, oraz modal dodawania nowego todo (Alpine.js slide-in).
ASCII Wireframe
+--------+-----------------------------------------------------------+
| | Top Bar |
| LOGO | Todo [Ania v] |
| +-----------------------------------------------------------+
| SIDE | |
| BAR | [ Wszystkie ] [ Oczekujace ] [ Ukonczone ] [ Przetermino]|
| | =================================================== |
| Dash. | [ + Dodaj todo ] |
| Content| |
| Worlds | +-----------------------------------------------------+ |
| *Todo* | | Zadanie | Przyp. do | Termin | Status |A| |
| ---- | |------------------|-----------|----------|---------|--| |
| Settng | | Przeczytaj roz.3 | Ania | Jutro | Oczek. |v| |
| | | Test z przyrody | Ania | Za 3 dni | Oczek. |v| |
| | | Projekt plast. | Tomek | Za 5 dni | Oczek. |v| |
| | | Slowka angielski | Ania | Wczoraj | Wykon. | | |
| | | Cwiczenia mat. | Tomek | 2 dni t. | Przeter.|!| |
| | |------------------|-----------|----------|---------|--| |
| | | Wyniki 1-20 z 34 | [<] [1] [2] [>] | |
| | +-----------------------------------------------------+ |
| | |
+--------+-----------------------------------------------------------+
+---+ Slide-in modal (Alpine.js):
| |
| + | +---------------------------+
| | | Nowe todo [x] |
| | | |
| | | Tytul: |
| | | [ _____________________ ]|
| | | |
| | | Opis: |
| | | [ _____________________ ]|
| | | [ _____________________ ]|
| | | |
| | | Termin: |
| | | [ 2026-03-15 ] |
| | | |
| | | Przypisz do: |
| | | [ Ania v ] |
| | | |
| | | [Anuluj] [Dodaj todo] |
| | +---------------------------+
+---+Status tabs (HTMX)
html
<div class="flex items-center gap-1 mb-6 border-b border-gray-200"
x-data="{ activeTab: '{{ .ActiveTab }}' }">
<button hx-get="/partials/todo/list?status=all"
hx-target="#todo-table-body"
hx-swap="innerHTML transition:true"
@click="activeTab = 'all'"
:class="activeTab === 'all'
? 'border-indigo-600 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="px-4 py-3 text-sm font-medium border-b-2
transition-colors duration-150 -mb-px">
Wszystkie
<span class="ml-1.5 px-2 py-0.5 rounded-full text-xs bg-gray-100 text-gray-600">
{{ .Counts.All }}
</span>
</button>
<button hx-get="/partials/todo/list?status=pending"
hx-target="#todo-table-body"
hx-swap="innerHTML transition:true"
@click="activeTab = 'pending'"
:class="activeTab === 'pending'
? 'border-indigo-600 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="px-4 py-3 text-sm font-medium border-b-2
transition-colors duration-150 -mb-px">
Oczekujace
<span class="ml-1.5 px-2 py-0.5 rounded-full text-xs bg-amber-50 text-amber-700">
{{ .Counts.Pending }}
</span>
</button>
<button hx-get="/partials/todo/list?status=completed"
hx-target="#todo-table-body"
hx-swap="innerHTML transition:true"
@click="activeTab = 'completed'"
:class="activeTab === 'completed'
? 'border-indigo-600 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="px-4 py-3 text-sm font-medium border-b-2
transition-colors duration-150 -mb-px">
Ukonczone
<span class="ml-1.5 px-2 py-0.5 rounded-full text-xs bg-green-50 text-green-700">
{{ .Counts.Completed }}
</span>
</button>
<button hx-get="/partials/todo/list?status=overdue"
hx-target="#todo-table-body"
hx-swap="innerHTML transition:true"
@click="activeTab = 'overdue'"
:class="activeTab === 'overdue'
? 'border-indigo-600 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="px-4 py-3 text-sm font-medium border-b-2
transition-colors duration-150 -mb-px">
Przeterminowane
<span class="ml-1.5 px-2 py-0.5 rounded-full text-xs bg-red-50 text-red-700">
{{ .Counts.Overdue }}
</span>
</button>
</div>Tabela Todo
html
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-gray-100">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Zadanie
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Przypisane do
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Termin
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Akcje
</th>
</tr>
</thead>
<tbody id="todo-table-body"
hx-get="/partials/todo/list?status=all&page=1&per_page=20"
hx-trigger="load"
hx-swap="innerHTML transition:true"
class="divide-y divide-gray-50">
</tbody>
</table>
<div id="todo-pagination"></div>
</div>Partial: wiersze tabeli
html
{{/* partials/todo/list.html */}}
{{ range .Todos }}
<tr id="todo-row-{{ .ID }}"
class="hover:bg-gray-50 transition-colors duration-150">
<!-- Zadanie -->
<td class="px-6 py-4">
<div class="text-sm font-medium text-gray-900">{{ .Title }}</div>
{{ if .Description }}
<div class="text-xs text-gray-500 mt-0.5">{{ .Description | truncate 50 }}</div>
{{ end }}
</td>
<!-- Przypisane do -->
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center">
<span class="text-xs font-semibold text-indigo-600">{{ .Child.Initials }}</span>
</div>
<span class="text-sm text-gray-700">{{ .Child.Name }}</span>
</div>
</td>
<!-- Termin -->
<td class="px-6 py-4">
<span class="text-sm {{ if .IsOverdue }}text-red-600 font-medium{{ else }}text-gray-700{{ end }}">
{{ .DueDateLabel }}
</span>
</td>
<!-- Status -->
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{{ if eq .Status "pending" }}bg-amber-50 text-amber-700{{ end }}
{{ if eq .Status "completed" }}bg-green-50 text-green-700{{ end }}
{{ if eq .Status "overdue" }}bg-red-50 text-red-700{{ end }}">
{{ .StatusLabel }}
</span>
</td>
<!-- Akcje -->
<td class="px-6 py-4 text-right">
{{ if eq .Status "pending" }}
<button hx-post="/todo/{{ .ID }}/approve"
hx-target="#todo-row-{{ .ID }}"
hx-swap="outerHTML transition:true"
class="inline-flex items-center gap-1.5 px-3 py-1.5
text-sm font-medium text-green-700
bg-green-50 rounded-lg
hover:bg-green-100
transition-colors duration-150">
<i data-lucide="check" class="w-3.5 h-3.5"></i>
Zatwierdz
</button>
{{ end }}
{{ if eq .Status "overdue" }}
<span class="inline-flex items-center gap-1 text-xs text-red-500">
<i data-lucide="alert-circle" class="w-3.5 h-3.5"></i>
Przeterminowane
</span>
{{ end }}
</td>
</tr>
{{ end }}
<!-- Pagination OOB -->
<div id="todo-pagination"
hx-swap-oob="innerHTML"
class="px-6 py-4 border-t border-gray-100 flex items-center justify-between">
<div class="text-sm text-gray-500">
Wyniki {{ .From }}-{{ .To }} z {{ .Total }}
</div>
<nav class="flex items-center gap-1">
{{ range .Pages }}
<button hx-get="/partials/todo/list?status={{ $.Status }}&page={{ .Number }}&per_page={{ $.PerPage }}"
hx-target="#todo-table-body"
hx-swap="innerHTML transition:true"
class="px-3 py-2 text-sm rounded-lg transition-colors duration-150
{{ if .IsCurrent }}bg-indigo-50 text-indigo-700 font-medium
{{ else }}text-gray-600 hover:bg-gray-100{{ end }}">
{{ .Number }}
</button>
{{ end }}
</nav>
</div>Approve inline (HTMX)
Po kliknieciu "Zatwierdz" serwer zwraca zaktualizowany wiersz:
go
func TodoApproveHandler(w http.ResponseWriter, r *http.Request) {
todoID := r.PathValue("id")
todo, err := todoService.Approve(r.Context(), todoID)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
// Zwroc zaktualizowany wiersz HTML
tmpl := template.Must(template.ParseFiles("templates/partials/todo/row.html"))
w.Header().Set("Content-Type", "text/html")
tmpl.Execute(w, todo)
}Zwrocony HTML zastepuje wiersz (hx-swap="outerHTML"):
html
<!-- Wiersz po zatwierdzeniu -->
<tr id="todo-row-{{ .ID }}"
class="hover:bg-gray-50 transition-colors duration-150 bg-green-50/30">
<td class="px-6 py-4">
<div class="text-sm font-medium text-gray-900 line-through">{{ .Title }}</div>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center">
<span class="text-xs font-semibold text-indigo-600">{{ .Child.Initials }}</span>
</div>
<span class="text-sm text-gray-700">{{ .Child.Name }}</span>
</div>
</td>
<td class="px-6 py-4">
<span class="text-sm text-gray-500">{{ .DueDateLabel }}</span>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
bg-green-50 text-green-700">
Ukonczone
</span>
</td>
<td class="px-6 py-4 text-right">
<span class="text-xs text-gray-400">Zatwierdzone</span>
</td>
</tr>Modal — Dodaj Todo (Alpine.js slide-in)
html
<div x-data="{ showModal: false }">
<!-- Trigger button -->
<button @click="showModal = true"
class="inline-flex items-center gap-2 px-4 py-2.5
bg-indigo-600 text-white text-sm font-medium
rounded-lg shadow-sm hover:bg-indigo-700
transition-colors duration-150">
<i data-lucide="plus" class="w-4 h-4"></i>
Dodaj todo
</button>
<!-- Backdrop -->
<div x-show="showModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="showModal = false"
class="fixed inset-0 bg-black/30 backdrop-blur-sm z-40"
x-cloak>
</div>
<!-- Slide-in panel -->
<div x-show="showModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
class="fixed right-0 top-0 bottom-0 w-full max-w-md
bg-white shadow-2xl z-50 flex flex-col"
x-cloak>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<h2 class="text-lg font-display font-semibold text-gray-800">
Nowe todo
</h2>
<button @click="showModal = false"
class="text-gray-400 hover:text-gray-600 transition-colors duration-150">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<!-- Form -->
<form hx-post="/todo"
hx-target="#todo-table-body"
hx-swap="innerHTML transition:true"
@htmx:after-request="showModal = false"
class="flex-1 flex flex-col">
<div class="flex-1 overflow-y-auto p-6 space-y-5">
<!-- Tytul -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">
Tytul
</label>
<input type="text" name="title" required
placeholder="Co ma zrobic dziecko?"
class="w-full px-3.5 py-2.5 text-sm text-gray-900
border border-gray-300 rounded-lg
placeholder:text-gray-400
focus:outline-none focus:ring-2 focus:ring-indigo-500
transition-colors duration-150">
</div>
<!-- Opis -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">
Opis (opcjonalny)
</label>
<textarea name="description" rows="3"
placeholder="Dodatkowe informacje..."
class="w-full px-3.5 py-2.5 text-sm text-gray-900
border border-gray-300 rounded-lg resize-none
placeholder:text-gray-400
focus:outline-none focus:ring-2 focus:ring-indigo-500
transition-colors duration-150"></textarea>
</div>
<!-- Termin -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">
Termin
</label>
<input type="date" name="due_date" required
class="w-full px-3.5 py-2.5 text-sm text-gray-900
border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-indigo-500
transition-colors duration-150">
</div>
<!-- Przypisz do dziecka -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">
Przypisz do
</label>
<select name="child_id" required
class="w-full px-3.5 py-2.5 text-sm text-gray-900
border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-indigo-500
transition-colors duration-150">
<option value="">Wybierz dziecko...</option>
{{ range .Children }}
<option value="{{ .ID }}">{{ .Name }}</option>
{{ end }}
</select>
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t border-gray-100 flex items-center justify-end gap-3">
<button type="button" @click="showModal = false"
class="px-4 py-2.5 text-sm font-medium text-gray-700
rounded-lg hover:bg-gray-100
transition-colors duration-150">
Anuluj
</button>
<button type="submit"
class="px-4 py-2.5 bg-indigo-600 text-white text-sm font-medium
rounded-lg shadow-sm hover:bg-indigo-700
transition-colors duration-150">
Dodaj todo
</button>
</div>
</form>
</div>
</div>Endpointy
| Metoda | URL | Opis |
|---|---|---|
| GET | /todo | Strona todo (pelna strona SSR) |
| GET | /partials/todo/list | Partial: wiersze tabeli + paginacja |
| POST | /todo | Utworzenie nowego todo |
| POST | /todo/:id/approve | Zatwierdzenie todo (inline HTMX) |
| DELETE | /todo/:id | Usuniecie todo |
Query params dla /partials/todo/list
| Param | Typ | Domyslna | Opcje |
|---|---|---|---|
| status | string | all | all, pending, completed, overdue |
| page | int | 1 | |
| per_page | int | 20 |
Go Handler
go
func TodoListPartialHandler(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r.Context())
status := r.URL.Query().Get("status")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
if page < 1 { page = 1 }
if perPage < 1 { perPage = 20 }
offset := (page - 1) * perPage
todos, total := todoService.FindFiltered(r.Context(), user.ID, status, offset, perPage)
data := TodoListData{
Todos: todos,
Status: status,
Page: page,
PerPage: perPage,
Total: total,
// ...pagination fields...
}
tmpl := template.Must(template.ParseFiles("templates/partials/todo/list.html"))
w.Header().Set("Content-Type", "text/html")
tmpl.Execute(w, data)
}