diff --git a/Makefile b/Makefile
index 8bb79f2..d4c7216 100644
--- a/Makefile
+++ b/Makefile
@@ -39,7 +39,7 @@ run-dev: build
TASK_MASTER_HOME=$(DEV_HOME) ./bin/$(BINARY)
# Web test environment: SQLite demo against tm_server only (no YouTrack).
-# Seeds project "Test" + samples; login admin/admin (alice/alice as member).
+# Seeds 50 demo projects (Test + samples); login admin/admin (alice/alice as member).
run-server-dev: build-server-go
@rm -f $(DEV_HOME)/.local/share/task-master/task-master.db \
$(DEV_HOME)/.local/share/task-master/task-master.db-wal \
diff --git a/internal/project/project.go b/internal/project/project.go
index 9982a74..e6b0f89 100644
--- a/internal/project/project.go
+++ b/internal/project/project.go
@@ -2,6 +2,7 @@ package project
// Project is a lightweight workspace identified by a directory name.
type Project struct {
- ID string `json:"id"`
- Name string `json:"name"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ GroupID string `json:"groupId,omitempty"`
}
diff --git a/internal/server/group_handlers.go b/internal/server/group_handlers.go
new file mode 100644
index 0000000..28563cc
--- /dev/null
+++ b/internal/server/group_handlers.go
@@ -0,0 +1,60 @@
+package server
+
+import (
+ "net/http"
+)
+
+func (s *Server) handleListGroups(w http.ResponseWriter, r *http.Request) {
+ groups, err := s.DB.ListProjectGroups()
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, groups)
+}
+
+func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Name string `json:"name"`
+ }
+ if err := readJSON(r, &body); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid json")
+ return
+ }
+ g, err := s.DB.CreateProjectGroup(body.Name)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusCreated, g)
+}
+
+func (s *Server) handlePatchGroup(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ var body struct {
+ Name string `json:"name"`
+ }
+ if err := readJSON(r, &body); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid json")
+ return
+ }
+ if err := s.DB.RenameProjectGroup(id, body.Name); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ g, err := s.DB.GetProjectGroup(id)
+ if err != nil {
+ writeError(w, http.StatusNotFound, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, g)
+}
+
+func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if err := s.DB.DeleteProjectGroup(id); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}
diff --git a/internal/server/project_handlers.go b/internal/server/project_handlers.go
index 96d86ea..bd9d442 100644
--- a/internal/server/project_handlers.go
+++ b/internal/server/project_handlers.go
@@ -20,7 +20,8 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
var body struct {
- Name string `json:"name"`
+ Name string `json:"name"`
+ GroupID string `json:"groupId"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
@@ -37,6 +38,13 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
return
}
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner)
+ if gid := strings.TrimSpace(body.GroupID); gid != "" {
+ if err := s.Projects.SetGroup(p.ID, gid); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ p.GroupID = gid
+ }
writeJSON(w, http.StatusCreated, p)
}
@@ -61,17 +69,30 @@ func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) {
return
}
var body struct {
- Name string `json:"name"`
+ Name *string `json:"name"`
+ GroupID *string `json:"groupId"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
- if err := s.Projects.Rename(id, body.Name); err != nil {
- writeError(w, http.StatusBadRequest, err.Error())
+ if body.Name != nil {
+ if err := s.Projects.Rename(id, *body.Name); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ }
+ if body.GroupID != nil {
+ if err := s.Projects.SetGroup(id, strings.TrimSpace(*body.GroupID)); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ }
+ p, err := s.Projects.Get(id)
+ if err != nil {
+ writeError(w, http.StatusNotFound, err.Error())
return
}
- p, _ := s.Projects.Get(id)
writeJSON(w, http.StatusOK, p)
}
diff --git a/internal/server/seed.go b/internal/server/seed.go
index feed80f..fb80f0c 100644
--- a/internal/server/seed.go
+++ b/internal/server/seed.go
@@ -3,6 +3,7 @@ package server
import (
"fmt"
"os"
+ "strings"
"time"
"task/internal/sqlite"
@@ -78,13 +79,26 @@ func (s *Server) SeedDemo() error {
},
},
}
+ for i := len(demos) + 1; i <= 50; i++ {
+ demos = append(demos, struct {
+ Name string
+ Tickets []seedTicket
+ }{
+ Name: fmt.Sprintf("Sample %02d", i),
+ Tickets: []seedTicket{
+ {Title: "Starter ticket", Desc: "Seed-Platzhalter.", Status: ticket.StatusTodo},
+ },
+ })
+ }
now := time.Now().UTC()
+ created := make([]struct{ ID, Name string }, 0, len(demos))
for _, d := range demos {
p, err := s.Projects.Create("", d.Name)
if err != nil {
return err
}
+ created = append(created, struct{ ID, Name string }{p.ID, p.Name})
_ = s.DB.SetMember(p.ID, admin.ID, sqlite.RoleOwner)
_ = s.DB.SetMember(p.ID, member.ID, sqlite.RoleMember)
// Explicit local-only sync (no remote backend in the test environment).
@@ -125,6 +139,23 @@ func (s *Server) SeedDemo() error {
}
}
- fmt.Fprintf(os.Stderr, "seeded demo: Test + sample projects (users %s + alice/alice), sync=none\n", admin.Username)
+ kunden, err := s.DB.CreateProjectGroup("Kunden")
+ if err != nil {
+ return fmt.Errorf("seed group Kunden: %w", err)
+ }
+ intern, err := s.DB.CreateProjectGroup("Intern")
+ if err != nil {
+ return fmt.Errorf("seed group Intern: %w", err)
+ }
+ for _, c := range created {
+ switch {
+ case c.Name == "Website Relaunch" || c.Name == "API Platform" || strings.HasPrefix(c.Name, "Sample 0"):
+ _ = s.Projects.SetGroup(c.ID, kunden.ID)
+ case c.Name == "Test" || strings.HasPrefix(c.Name, "Sample 1") || strings.HasPrefix(c.Name, "Sample 2"):
+ _ = s.Projects.SetGroup(c.ID, intern.ID)
+ }
+ }
+
+ fmt.Fprintf(os.Stderr, "seeded demo: %d projects, 2 groups (users %s + alice/alice), sync=none\n", len(demos), admin.Username)
return nil
}
diff --git a/internal/server/seed_test.go b/internal/server/seed_test.go
index fce4edb..b6c9940 100644
--- a/internal/server/seed_test.go
+++ b/internal/server/seed_test.go
@@ -21,8 +21,8 @@ func TestSeedDemo(t *testing.T) {
t.Fatal(err)
}
projects, err := srv.Projects.List()
- if err != nil || len(projects) < 3 {
- t.Fatalf("projects=%v err=%v", projects, err)
+ if err != nil || len(projects) != 50 {
+ t.Fatalf("want 50 projects, got %d err=%v", len(projects), err)
}
var testID string
for _, p := range projects {
@@ -34,6 +34,14 @@ func TestSeedDemo(t *testing.T) {
if testID == "" {
t.Fatal("missing Test project")
}
+ groups, err := db.ListProjectGroups()
+ if err != nil || len(groups) != 2 {
+ t.Fatalf("want 2 groups, got %d err=%v", len(groups), err)
+ }
+ p, err := srv.Projects.Get(testID)
+ if err != nil || p.GroupID == "" {
+ t.Fatalf("Test project should be grouped, got %+v err=%v", p, err)
+ }
sync, err := srv.Projects.GetSyncBackend(testID)
if err != nil || sync.Kind != "none" {
t.Fatalf("sync=%+v err=%v", sync, err)
diff --git a/internal/server/server.go b/internal/server/server.go
index d6aaf9a..48f02b5 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -58,6 +58,11 @@ func (s *Server) routes(mux *http.ServeMux) {
api.HandleFunc("GET /dashboard", s.requireAuth(s.handleDashboard))
+ api.HandleFunc("GET /project-groups", s.requireAuth(s.handleListGroups))
+ api.HandleFunc("POST /project-groups", s.requireAuth(s.handleCreateGroup))
+ api.HandleFunc("PATCH /project-groups/{id}", s.requireAuth(s.handlePatchGroup))
+ api.HandleFunc("DELETE /project-groups/{id}", s.requireAuth(s.handleDeleteGroup))
+
api.HandleFunc("GET /projects", s.requireAuth(s.handleListProjects))
api.HandleFunc("POST /projects", s.requireAuth(s.handleCreateProject))
api.HandleFunc("GET /projects/{id}", s.requireAuth(s.handleGetProject))
diff --git a/internal/sqlite/groups.go b/internal/sqlite/groups.go
new file mode 100644
index 0000000..c1e9468
--- /dev/null
+++ b/internal/sqlite/groups.go
@@ -0,0 +1,117 @@
+package sqlite
+
+import (
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// ProjectGroup is a folder for organizing projects in the UI tree.
+type ProjectGroup struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ SortOrder int `json:"sortOrder"`
+}
+
+// ListProjectGroups returns all groups sorted by sort_order then name.
+func (db *DB) ListProjectGroups() ([]ProjectGroup, error) {
+ rows, err := db.SQL.Query(`
+ SELECT id, name, sort_order FROM project_groups
+ ORDER BY sort_order ASC, name COLLATE NOCASE`)
+ if err != nil {
+ return nil, fmt.Errorf("list project groups: %w", err)
+ }
+ defer rows.Close()
+ var out []ProjectGroup
+ for rows.Next() {
+ var g ProjectGroup
+ if err := rows.Scan(&g.ID, &g.Name, &g.SortOrder); err != nil {
+ return nil, err
+ }
+ out = append(out, g)
+ }
+ if out == nil {
+ out = []ProjectGroup{}
+ }
+ return out, rows.Err()
+}
+
+// CreateProjectGroup inserts a new group.
+func (db *DB) CreateProjectGroup(name string) (ProjectGroup, error) {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return ProjectGroup{}, fmt.Errorf("name is required")
+ }
+ id := uuid.NewString()
+ var maxOrd sql.NullInt64
+ _ = db.SQL.QueryRow(`SELECT MAX(sort_order) FROM project_groups`).Scan(&maxOrd)
+ ord := 0
+ if maxOrd.Valid {
+ ord = int(maxOrd.Int64) + 1
+ }
+ now := formatTime(time.Now().UTC())
+ _, err := db.SQL.Exec(`INSERT INTO project_groups(id, name, sort_order, created_at) VALUES(?, ?, ?, ?)`,
+ id, name, ord, now)
+ if err != nil {
+ return ProjectGroup{}, fmt.Errorf("create project group: %w", err)
+ }
+ return ProjectGroup{ID: id, Name: name, SortOrder: ord}, nil
+}
+
+// RenameProjectGroup updates the group name.
+func (db *DB) RenameProjectGroup(id, name string) error {
+ name = strings.TrimSpace(name)
+ if id == "" || name == "" {
+ return fmt.Errorf("id and name are required")
+ }
+ res, err := db.SQL.Exec(`UPDATE project_groups SET name = ? WHERE id = ?`, name, id)
+ if err != nil {
+ return err
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ return fmt.Errorf("group %s: not found", id)
+ }
+ return nil
+}
+
+// DeleteProjectGroup removes a group; projects in it become ungrouped.
+func (db *DB) DeleteProjectGroup(id string) error {
+ if id == "" {
+ return fmt.Errorf("id is required")
+ }
+ tx, err := db.SQL.Begin()
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback() }()
+ if _, err := tx.Exec(`UPDATE projects SET group_id = NULL WHERE group_id = ?`, id); err != nil {
+ return fmt.Errorf("ungroup projects: %w", err)
+ }
+ res, err := tx.Exec(`DELETE FROM project_groups WHERE id = ?`, id)
+ if err != nil {
+ return fmt.Errorf("delete group %s: %w", id, err)
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ return fmt.Errorf("group %s: not found", id)
+ }
+ return tx.Commit()
+}
+
+// GetProjectGroup returns one group.
+func (db *DB) GetProjectGroup(id string) (ProjectGroup, error) {
+ var g ProjectGroup
+ err := db.SQL.QueryRow(`SELECT id, name, sort_order FROM project_groups WHERE id = ?`, id).
+ Scan(&g.ID, &g.Name, &g.SortOrder)
+ if err == sql.ErrNoRows {
+ return ProjectGroup{}, fmt.Errorf("group %s: not found", id)
+ }
+ if err != nil {
+ return ProjectGroup{}, err
+ }
+ return g, nil
+}
diff --git a/internal/sqlite/migrations/003_project_groups.sql b/internal/sqlite/migrations/003_project_groups.sql
new file mode 100644
index 0000000..9facea8
--- /dev/null
+++ b/internal/sqlite/migrations/003_project_groups.sql
@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS project_groups (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_project_groups_sort ON project_groups(sort_order, name COLLATE NOCASE);
+
+ALTER TABLE projects ADD COLUMN group_id TEXT REFERENCES project_groups(id) ON DELETE SET NULL;
+
+CREATE INDEX IF NOT EXISTS idx_projects_group ON projects(group_id);
diff --git a/internal/sqlite/projects.go b/internal/sqlite/projects.go
index f6b4138..dd75e00 100644
--- a/internal/sqlite/projects.go
+++ b/internal/sqlite/projects.go
@@ -22,17 +22,29 @@ func NewProjectStore(db *DB) *ProjectStore {
return &ProjectStore{db: db}
}
+func scanProject(scanner interface{ Scan(dest ...any) error }) (project.Project, error) {
+ var p project.Project
+ var groupID sql.NullString
+ if err := scanner.Scan(&p.ID, &p.Name, &groupID); err != nil {
+ return project.Project{}, err
+ }
+ if groupID.Valid {
+ p.GroupID = groupID.String
+ }
+ return p, nil
+}
+
// List returns all projects sorted by name.
func (s *ProjectStore) List() ([]project.Project, error) {
- rows, err := s.db.SQL.Query(`SELECT id, name FROM projects ORDER BY name COLLATE NOCASE`)
+ rows, err := s.db.SQL.Query(`SELECT id, name, group_id FROM projects ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, fmt.Errorf("list projects: %w", err)
}
defer rows.Close()
var out []project.Project
for rows.Next() {
- var p project.Project
- if err := rows.Scan(&p.ID, &p.Name); err != nil {
+ p, err := scanProject(rows)
+ if err != nil {
return nil, err
}
out = append(out, p)
@@ -70,7 +82,7 @@ func (s *ProjectStore) Create(id, name string) (project.Project, error) {
id = uuid.NewString()
}
now := formatTime(time.Now().UTC())
- _, err := s.db.SQL.Exec(`INSERT INTO projects(id, name, created_at, updated_at) VALUES(?, ?, ?, ?)`,
+ _, err := s.db.SQL.Exec(`INSERT INTO projects(id, name, created_at, updated_at, group_id) VALUES(?, ?, ?, ?, NULL)`,
id, name, now, now)
if err != nil {
return project.Project{}, fmt.Errorf("create project: %w", err)
@@ -78,6 +90,31 @@ func (s *ProjectStore) Create(id, name string) (project.Project, error) {
return project.Project{ID: id, Name: name}, nil
}
+// SetGroup assigns a project to a group. Empty groupID clears the assignment.
+func (s *ProjectStore) SetGroup(id, groupID string) error {
+ id = strings.TrimSpace(id)
+ groupID = strings.TrimSpace(groupID)
+ if id == "" {
+ return fmt.Errorf("project id is required")
+ }
+ var arg any
+ if groupID == "" {
+ arg = nil
+ } else {
+ arg = groupID
+ }
+ res, err := s.db.SQL.Exec(`UPDATE projects SET group_id = ?, updated_at = ? WHERE id = ?`,
+ arg, formatTime(time.Now().UTC()), id)
+ if err != nil {
+ return fmt.Errorf("set project group: %w", err)
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ return fmt.Errorf("project %s: not found", id)
+ }
+ return nil
+}
+
// Rename updates the display name.
func (s *ProjectStore) Rename(id, name string) error {
name = strings.TrimSpace(name)
@@ -114,8 +151,7 @@ func (s *ProjectStore) Delete(id string) error {
// Get returns one project.
func (s *ProjectStore) Get(id string) (project.Project, error) {
- var p project.Project
- err := s.db.SQL.QueryRow(`SELECT id, name FROM projects WHERE id = ?`, id).Scan(&p.ID, &p.Name)
+ p, err := scanProject(s.db.SQL.QueryRow(`SELECT id, name, group_id FROM projects WHERE id = ?`, id))
if err == sql.ErrNoRows {
return project.Project{}, fmt.Errorf("project %s: not found", id)
}
@@ -255,7 +291,7 @@ func (s *ProjectStore) ListIDsForUser(userID string, isAdmin bool) ([]project.Pr
return s.List()
}
rows, err := s.db.SQL.Query(`
- SELECT p.id, p.name FROM projects p
+ SELECT p.id, p.name, p.group_id FROM projects p
INNER JOIN project_members m ON m.project_id = p.id
WHERE m.user_id = ?
ORDER BY p.name COLLATE NOCASE`, userID)
@@ -265,8 +301,8 @@ func (s *ProjectStore) ListIDsForUser(userID string, isAdmin bool) ([]project.Pr
defer rows.Close()
var out []project.Project
for rows.Next() {
- var p project.Project
- if err := rows.Scan(&p.ID, &p.Name); err != nil {
+ p, err := scanProject(rows)
+ if err != nil {
return nil, err
}
out = append(out, p)
diff --git a/web/src/App.svelte b/web/src/App.svelte
index 1e7cfd3..2c709c5 100644
--- a/web/src/App.svelte
+++ b/web/src/App.svelte
@@ -5,8 +5,11 @@
let user = $state(null)
let loading = $state(true)
let error = $state('')
- let view = $state('dashboard')
+ let topTab = $state('dashboard') // dashboard | projects | users | settings
+ let projectView = $state('') // '' | board | ticket | report | members | settings
let projects = $state([])
+ let groups = $state([])
+ let collapsedGroups = $state({})
let projectId = $state('')
let projectName = $state('')
let tickets = $state([])
@@ -23,10 +26,13 @@
let tokens = $state([])
let newTokenSecret = $state('')
let renameName = $state('')
+ let editGroupId = $state('')
let loginUser = $state('')
let loginPass = $state('')
let newProjectName = $state('')
+ let newProjectGroupId = $state('')
+ let newGroupName = $state('')
let newTicketTitle = $state('')
let editTitle = $state('')
let editDesc = $state('')
@@ -75,10 +81,45 @@
await api.logout()
user = null
projects = []
+ groups = []
}
async function refreshProjects() {
- projects = await api.listProjects()
+ ;[projects, groups] = await Promise.all([api.listProjects(), api.listGroups()])
+ }
+
+ function projectsInGroup(groupId) {
+ return projects.filter((p) => (p.groupId || '') === groupId)
+ }
+
+ function ungroupedProjects() {
+ const ids = new Set(groups.map((g) => g.id))
+ return projects.filter((p) => !p.groupId || !ids.has(p.groupId))
+ }
+
+ function toggleGroup(id) {
+ collapsedGroups = { ...collapsedGroups, [id]: !collapsedGroups[id] }
+ }
+
+ async function createGroup() {
+ error = ''
+ const name = newGroupName.trim()
+ if (!name) return
+ try {
+ await api.createGroup(name)
+ newGroupName = ''
+ await refreshProjects()
+ } catch (e) {
+ error = e.message
+ }
+ }
+
+ async function removeGroup(id) {
+ if (!confirm('Gruppe löschen? Projekte werden ungeordnet.')) return
+ await api.deleteGroup(id)
+ if (newProjectGroupId === id) newProjectGroupId = ''
+ if (editGroupId === id) editGroupId = ''
+ await refreshProjects()
}
async function refreshTimer() {
@@ -87,16 +128,27 @@
}
async function openDashboard() {
- view = 'dashboard'
+ topTab = 'dashboard'
projectId = ''
+ projectView = ''
+ selected = null
dash = await api.dashboard()
await refreshProjects()
}
+ async function openProjects() {
+ topTab = 'projects'
+ error = ''
+ msg = ''
+ await refreshProjects()
+ }
+
async function openProject(id, name = '') {
+ topTab = 'projects'
projectId = id
projectName = name || projects.find((p) => p.id === id)?.name || id
- view = 'board'
+ projectView = 'board'
+ selected = null
error = ''
tickets = await api.listTickets(id)
}
@@ -109,9 +161,10 @@
return
}
try {
- await api.createProject(name)
+ const p = await api.createProject(name, newProjectGroupId)
newProjectName = ''
- await openDashboard()
+ await refreshProjects()
+ await openProject(p.id, p.name)
} catch (e) {
error = e.message
}
@@ -133,7 +186,8 @@
const all = await api.listTime(projectId)
ticketTime = all.filter((e) => e.ticketId === t.id)
await refreshTimer()
- view = 'ticket'
+ topTab = 'projects'
+ projectView = 'ticket'
}
async function saveTicket() {
@@ -151,7 +205,7 @@
if (!confirm('Delete ticket?')) return
await api.deleteTicket(projectId, selected.id)
selected = null
- view = 'board'
+ projectView = 'board'
tickets = await api.listTickets(projectId)
}
@@ -190,7 +244,8 @@
}
async function openReport() {
- view = 'report'
+ topTab = 'projects'
+ projectView = 'report'
error = ''
report = await api.report(projectId, reportGroupBy)
}
@@ -200,11 +255,27 @@
report = await api.report(projectId, reportGroupBy)
}
- async function openSettings() {
- view = 'settings'
+ async function openProjectSettings() {
+ topTab = 'projects'
+ projectView = 'settings'
renameName = projectName
+ editGroupId = projects.find((p) => p.id === projectId)?.groupId || ''
newTokenSecret = ''
tokens = await api.listTokens(projectId)
+ await refreshProjects()
+ }
+
+ async function saveProjectGroup() {
+ const p = await api.patchProject(projectId, { groupId: editGroupId })
+ msg = 'Gruppe gespeichert'
+ await refreshProjects()
+ if (p.groupId !== undefined) editGroupId = p.groupId || ''
+ }
+
+ function openGlobalSettings() {
+ topTab = 'settings'
+ error = ''
+ msg = ''
}
async function saveRename() {
@@ -229,7 +300,9 @@
}
async function openAdmin() {
- view = 'admin'
+ topTab = 'users'
+ error = ''
+ msg = ''
users = await api.listUsers()
}
@@ -258,7 +331,8 @@
}
async function openMembers() {
- view = 'members'
+ topTab = 'projects'
+ projectView = 'members'
members = await api.listMembers(projectId)
users = await api.listUsers()
}
@@ -280,6 +354,11 @@
msg = text
}
}
+
+ function sidebarActive(v) {
+ if (v === 'board') return projectView === 'board' || projectView === 'ticket'
+ return projectView === v
+ }
{#if loading}
@@ -308,16 +387,12 @@
- {#if error}
{error}
{/if}
- {#if msg}{msg}
{/if}
-
- {#if view === 'dashboard'}
-
-
- Projects
- {#each dash.projects as p}
-
- {:else}
- No projects yet.
- {/each}
- New project
-
-
-
+
+ {#if topTab === 'projects'}
+
- {:else if view === 'board'}
-
-
-
{projectName}
-
-
-
-
-
-
- {#each [['todo', 'OPEN'], ['in_progress', 'WIP'], ['done', 'DONE']] as [st, label]}
-
-
{label}
- {#each byStatus(st) as t}
-
- {/each}
+ {:else if topTab === 'projects' && projectView === 'report'}
+
+
+
Report — {projectName}
+
+ {#each [['ticket', 'Ticket'], ['status', 'Status'], ['day', 'Day']] as [g, label]}
+
+ {/each}
+
- {/each}
-
-
- {:else if view === 'ticket' && selected}
-
+ {:else if topTab === 'projects' && projectView === 'settings'}
+
+ Settings — {projectName}
+
+
+
+ {#if groups.length}
+
+
Gruppen verwalten
+ {#each groups as g}
+
+ {g.name}
+
+
+ {/each}
+
+ {/if}
- Time
-
-
-
-
-
-
- | When | Duration | Source |
-
- {#each ticketTime as e}
-
- | {e.start ? new Date(e.start).toLocaleString() : '—'} |
- {formatDuration(e.duration)} |
- {e.source} |
-
- {:else}
- | No time yet. |
- {/each}
-
-
-
- History
-
- | Rev | Op | At | |
-
- {#each revisions as rev}
-
- | {rev.rev} |
- {rev.op} |
- {new Date(rev.at).toLocaleString()} |
- |
-
- {/each}
-
-
-
- {:else if view === 'report'}
-
-
-
Report — {projectName}
-
- {#each [['ticket', 'Ticket'], ['status', 'Status'], ['day', 'Day']] as [g, label]}
-
- {/each}
-
-
- {#if report}
- {report.entryCount} entries · total {formatDuration(report.totalDurationNs)}
-
- | Group | Duration |
-
- {#each report.rows as row}
- | {row.label} | {formatDuration(row.durationNs)} |
- {:else}
- | No time logged. |
- {/each}
-
-
+ API tokens (for TUI)
+ Create a write token, then in `tm`: project edit → backend taskmaster → URL, token, this project id.
+ {#if newTokenSecret}
+
+ New token (shown once)
+ {newTokenSecret}
+
+
+ {/if}
+
+
+
+
+
+
+ | Name | Scope | Created | |
+
+ {#each tokens as t}
+
+ | {t.name}{#if t.revokedAt} (revoked){/if} |
+ {t.scope} |
+ {new Date(t.createdAt).toLocaleString()} |
+
+ {#if !t.revokedAt}
+
+ {/if}
+ |
+
+ {/each}
+
+
+
+ {:else if topTab === 'projects' && projectView === 'members'}
+
+ Members — {projectName}
+
+ | User | Role | |
+
+ {#each members as m}
+
+ | {m.username} |
+ {m.role} |
+ |
+
+ {/each}
+
+
+
+
+
+
+
+
+ {:else if topTab === 'users'}
+
+ {:else if topTab === 'settings'}
+
+ Settings
+ Globale Einstellungen folgen hier später.
+
{/if}
-
- {:else if view === 'settings'}
-
- {:else if view === 'admin'}
-
- {:else if view === 'members'}
-
- Members — {projectName}
-
- | User | Role | |
-
- {#each members as m}
-
- | {m.username} |
- {m.role} |
- |
-
- {/each}
-
-
-
-
-
-
-
-
- {/if}
+
+
{/if}
diff --git a/web/src/app.css b/web/src/app.css
index b662e83..b4db48b 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -69,18 +69,23 @@ code {
}
.app-shell {
- max-width: 1100px;
+ max-width: 1200px;
margin: 0 auto;
- padding: 1.25rem;
+ padding: 0 1.25rem 1.25rem;
}
.topbar {
+ position: sticky;
+ top: 0;
+ z-index: 100;
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
- margin-bottom: 1.25rem;
- padding-bottom: 0.85rem;
+ margin: 0 -1.25rem 1.25rem;
+ padding: 0.85rem 1.25rem;
border-bottom: 1px solid var(--line);
+ background: color-mix(in srgb, var(--bg) 92%, transparent);
+ backdrop-filter: blur(8px);
}
.brand {
font-weight: 700;
@@ -93,6 +98,132 @@ code {
color: var(--accent-ink);
border-color: var(--accent);
}
+.app-body {
+ display: grid;
+ gap: 1rem;
+ min-width: 0;
+}
+.app-body.with-sidebar {
+ grid-template-columns: 220px minmax(0, 1fr);
+}
+.content { min-width: 0; }
+.sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ align-self: start;
+ max-height: calc(100vh - 5.5rem);
+ border-right: 1px solid var(--line);
+ padding-right: 0.85rem;
+ min-height: 0;
+}
+.sidebar-section {
+ display: grid;
+ gap: 0.35rem;
+ align-content: start;
+}
+.sidebar-projects {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ min-height: 0;
+ flex: 1 1 auto;
+}
+.sidebar-project-list {
+ --sidebar-row: 2.05rem;
+ display: grid;
+ gap: 0.2rem;
+ align-content: start;
+ overflow-y: auto;
+ min-height: 0;
+ flex: 0 1 auto;
+ max-height: calc(12 * var(--sidebar-row));
+ padding-right: 0.15rem;
+}
+.sidebar-group {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ width: 100%;
+ text-align: left;
+ background: transparent;
+ border-color: transparent;
+ padding: 0.3rem 0.5rem;
+ color: var(--muted);
+ font-weight: 600;
+ font-size: 0.86rem;
+}
+.sidebar-group:hover {
+ background: var(--panel-2);
+ border-color: var(--line);
+ color: var(--ink);
+}
+.sidebar-twist {
+ display: inline-block;
+ width: 0.9rem;
+ flex: 0 0 auto;
+}
+.sidebar-group-name {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: inherit;
+}
+.sidebar-child {
+ padding-left: 1.35rem;
+}
+.sidebar-ungrouped-label {
+ margin-top: 0.45rem;
+}
+.sidebar-subnav {
+ flex: 0 0 auto;
+ padding-top: 0.85rem;
+ border-top: 1px solid var(--line);
+}
+.sidebar-label {
+ margin: 0.25rem 0 0.35rem;
+ font-size: 0.78rem;
+ font-weight: 650;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--muted);
+ flex: 0 0 auto;
+}
+.sidebar-item {
+ width: 100%;
+ text-align: left;
+ background: transparent;
+ border-color: transparent;
+}
+.sidebar-item:hover {
+ background: var(--panel-2);
+ border-color: var(--line);
+}
+.sidebar-item.active {
+ background: var(--accent);
+ color: var(--accent-ink);
+ border-color: var(--accent);
+ font-weight: 600;
+}
+.sidebar-create {
+ flex: 0 0 auto;
+ margin-top: 0.25rem;
+ padding-top: 0.65rem;
+ border-top: 1px solid var(--line);
+}
+@media (max-width: 800px) {
+ .app-body.with-sidebar {
+ grid-template-columns: 1fr;
+ }
+ .sidebar {
+ border-right: none;
+ border-bottom: 1px solid var(--line);
+ padding-right: 0;
+ padding-bottom: 0.85rem;
+ }
+}
.panel {
background: linear-gradient(180deg, var(--panel) 0%, #14191f 100%);
border: 1px solid var(--line);
diff --git a/web/src/lib/api.js b/web/src/lib/api.js
index eb905fd..d252402 100644
--- a/web/src/lib/api.js
+++ b/web/src/lib/api.js
@@ -29,11 +29,21 @@ export const api = {
dashboard: () => request('/dashboard'),
listProjects: () => request('/projects'),
- createProject: (name) =>
- request('/projects', { method: 'POST', body: JSON.stringify({ name }) }),
+ createProject: (name, groupId = '') =>
+ request('/projects', {
+ method: 'POST',
+ body: JSON.stringify(groupId ? { name, groupId } : { name }),
+ }),
patchProject: (id, body) =>
request(`/projects/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
deleteProject: (id) => request(`/projects/${id}`, { method: 'DELETE' }),
+
+ listGroups: () => request('/project-groups'),
+ createGroup: (name) =>
+ request('/project-groups', { method: 'POST', body: JSON.stringify({ name }) }),
+ patchGroup: (id, body) =>
+ request(`/project-groups/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
+ deleteGroup: (id) => request(`/project-groups/${id}`, { method: 'DELETE' }),
listMembers: (id) => request(`/projects/${id}/members`),
setMember: (id, userId, role) =>
request(`/projects/${id}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }) }),