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 @@
task-master
{#if timer} @@ -328,267 +403,362 @@
- {#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'} +
-
+ {#if projectId} + + {/if} + + {/if} + +
+ {#if error}

{error}

{/if} + {#if msg}

{msg}

{/if} + + {#if topTab === 'dashboard'} +
+
+

Projects

+ {#each dash.projects as p} + + {:else} +

No projects yet.

+ {/each} + +
+
+
+

Open tickets

+ {#each dash.openTickets as t} + + {:else} +

Nothing open.

+ {/each} +
+
+

Time by project

+ + + + {#each dash.projects as p} + + {/each} + +
ProjectTime
{p.name}{formatDuration(p.durationNs)}
+
+
+
+ {:else if topTab === 'projects' && !projectId}
-

Open tickets

- {#each dash.openTickets as t} - - {:else} -

Nothing open.

- {/each} +

Projekte

+

Wähle links ein Projekt oder lege ein neues an.

+ {:else if topTab === 'projects' && projectView === 'board'}
-

Time by project

+
+

{projectName}

+
+ + +
+
+
+ {#each [['todo', 'OPEN'], ['in_progress', 'WIP'], ['done', 'DONE']] as [st, label]} +
+

{label}

+ {#each byStatus(st) as t} + + {/each} +
+ {/each} +
+
+ {:else if topTab === 'projects' && projectView === 'ticket' && selected} +
+
+ + +
+ + + + + +

Time

+
+ + + +
- + - {#each dash.projects as p} - + {#each ticketTime as e} + + + + + + {:else} + + {/each} + +
ProjectTime
WhenDurationSource
{p.name}{formatDuration(p.durationNs)}
{e.start ? new Date(e.start).toLocaleString() : '—'}{formatDuration(e.duration)}{e.source}
No time yet.
+ +

History

+ + + + {#each revisions as rev} + + + + + + {/each}
RevOpAt
{rev.rev}{rev.op}{new Date(rev.at).toLocaleString()}
-
-
- {: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} -
-
- - -
- - - - + {#if report} +

{report.entryCount} entries · total {formatDuration(report.totalDurationNs)}

+ + + + {#each report.rows as row} + + {:else} + + {/each} + +
GroupDuration
{row.label}{formatDuration(row.durationNs)}
No time logged.
+ {/if} +
+ {:else if topTab === 'projects' && projectView === 'settings'} +
+

Settings — {projectName}

+ + + + {#if groups.length} +
+

Gruppen verwalten

+ {#each groups as g} +
+ {g.name} + +
+ {/each} +
+ {/if} -

Time

-
- - - -
- - - - {#each ticketTime as e} - - - - - - {:else} - - {/each} - -
WhenDurationSource
{e.start ? new Date(e.start).toLocaleString() : '—'}{formatDuration(e.duration)}{e.source}
No time yet.
- -

History

- - - - {#each revisions as rev} - - - - - - - {/each} - -
RevOpAt
{rev.rev}{rev.op}{new Date(rev.at).toLocaleString()}
-
- {: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)}

- - - - {#each report.rows as row} - - {:else} - - {/each} - -
GroupDuration
{row.label}{formatDuration(row.durationNs)}
No time logged.
+

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} +
+ + + +
+ + + + {#each tokens as t} + + + + + + + {/each} + +
NameScopeCreated
{t.name}{#if t.revokedAt} (revoked){/if}{t.scope}{new Date(t.createdAt).toLocaleString()} + {#if !t.revokedAt} + + {/if} +
+
+ {:else if topTab === 'projects' && projectView === 'members'} +
+

Members — {projectName}

+ + + + {#each members as m} + + + + + + {/each} + +
UserRole
{m.username}{m.role}
+
+ + + +
+
+ {:else if topTab === 'users'} +
+

Users

+ + + + {#each users as u} + + + + + + {/each} + +
UsernameAdmin
{u.username} + + + {#if u.id !== user.id} + + {/if} +
+

Create user

+ + + + +
+ {:else if topTab === 'settings'} +
+

Settings

+

Globale Einstellungen folgen hier später.

+
{/if} - - {:else if view === 'settings'} -
-

Settings — {projectName}

- - - -

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} -
- - - -
- - - - {#each tokens as t} - - - - - - - {/each} - -
NameScopeCreated
{t.name}{#if t.revokedAt} (revoked){/if}{t.scope}{new Date(t.createdAt).toLocaleString()} - {#if !t.revokedAt} - - {/if} -
-
- {:else if view === 'admin'} -
-

Users

- - - - {#each users as u} - - - - - - {/each} - -
UsernameAdmin
{u.username} - - - {#if u.id !== user.id} - - {/if} -
-

Create user

- - - - -
- {:else if view === 'members'} -
-

Members — {projectName}

- - - - {#each members as m} - - - - - - {/each} - -
UserRole
{m.username}{m.role}
-
- - - -
-
- {/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 }) }),