Replace the plain textarea with a dual-mode editor so ticket content can be written as WYSIWYG or Markdown while still storing Markdown. Co-authored-by: Cursor <cursoragent@cursor.com>
1176 lines
40 KiB
Svelte
1176 lines
40 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte'
|
|
import { api } from './lib/api.js'
|
|
import { parseRoute, buildHash, currentHash } from './lib/route.js'
|
|
import MarkdownEditor from './lib/MarkdownEditor.svelte'
|
|
|
|
let user = $state(null)
|
|
let loading = $state(true)
|
|
let error = $state('')
|
|
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([])
|
|
let selected = $state(null)
|
|
let revisions = $state([])
|
|
let ticketTime = $state([])
|
|
let users = $state([])
|
|
let members = $state([])
|
|
let timer = $state(null)
|
|
let msg = $state('')
|
|
let report = $state(null)
|
|
let reportGroupBy = $state('ticket')
|
|
let reportFrom = $state('')
|
|
let reportTo = $state('')
|
|
let reportTicketId = $state('')
|
|
let dash = $state({ projects: [], openTickets: [] })
|
|
let tokens = $state([])
|
|
let newTokenSecret = $state('')
|
|
let renameName = $state('')
|
|
let editGroupId = $state('')
|
|
let applyingRoute = false
|
|
|
|
let loginUser = $state('')
|
|
let loginPass = $state('')
|
|
let newProjectName = $state('')
|
|
let newProjectGroupId = $state('')
|
|
let newGroupName = $state('')
|
|
let newTicketTitle = $state('')
|
|
let editTitle = $state('')
|
|
let editDesc = $state('')
|
|
let editStatus = $state('todo')
|
|
let newUserName = $state('')
|
|
let newUserPass = $state('')
|
|
let newUserAdmin = $state(false)
|
|
let memberUserId = $state('')
|
|
let memberRole = $state('member')
|
|
let timeMinutes = $state(30)
|
|
let editingTimeId = $state('')
|
|
let editTimeStart = $state('')
|
|
let editTimeEnd = $state('')
|
|
let tokenName = $state('tui')
|
|
let tokenScope = $state('write')
|
|
let dragTicketId = $state('')
|
|
let dragOverStatus = $state('')
|
|
let suppressTicketClick = false
|
|
|
|
const boardStatuses = [
|
|
['todo', 'OPEN'],
|
|
['in_progress', 'WIP'],
|
|
['done', 'DONE'],
|
|
]
|
|
|
|
function desiredHash() {
|
|
return buildHash({
|
|
topTab,
|
|
projectId,
|
|
projectView,
|
|
ticketId: selected?.id || '',
|
|
})
|
|
}
|
|
|
|
function routeMatches(r) {
|
|
if (r.topTab !== topTab) return false
|
|
if (r.topTab !== 'projects') return true
|
|
if (r.projectId !== projectId) return false
|
|
if (r.projectView === 'ticket') {
|
|
return projectView === 'ticket' && selected?.id === r.ticketId
|
|
}
|
|
return projectView === r.projectView
|
|
}
|
|
|
|
function syncRoute() {
|
|
if (applyingRoute || !user) return
|
|
const next = desiredHash()
|
|
if (currentHash() === next) return
|
|
location.hash = next
|
|
}
|
|
|
|
async function applyRoute() {
|
|
if (!user || applyingRoute) return
|
|
const r = parseRoute()
|
|
if (routeMatches(r) && !loading) return
|
|
applyingRoute = true
|
|
error = ''
|
|
try {
|
|
if (r.topTab === 'dashboard') {
|
|
await showDashboard()
|
|
} else if (r.topTab === 'users') {
|
|
if (user.isAdmin) await showAdmin()
|
|
else await showDashboard()
|
|
} else if (r.topTab === 'settings') {
|
|
showGlobalSettings()
|
|
} else if (r.topTab === 'projects') {
|
|
if (!r.projectId) {
|
|
await showProjects()
|
|
} else if (r.projectView === 'ticket' && r.ticketId) {
|
|
await showProject(r.projectId)
|
|
const t = tickets.find((x) => x.id === r.ticketId)
|
|
if (t) await showTicket(t)
|
|
else await showProject(r.projectId)
|
|
} else if (r.projectView === 'report') {
|
|
await showProject(r.projectId)
|
|
await showReport()
|
|
} else if (r.projectView === 'members') {
|
|
await showProject(r.projectId)
|
|
await showMembers()
|
|
} else if (r.projectView === 'settings') {
|
|
await showProject(r.projectId)
|
|
await showProjectSettings()
|
|
} else {
|
|
await showProject(r.projectId)
|
|
}
|
|
} else {
|
|
await showDashboard()
|
|
}
|
|
} catch (e) {
|
|
error = e.message || String(e)
|
|
await showDashboard()
|
|
} finally {
|
|
const next = desiredHash()
|
|
if (currentHash() !== next) history.replaceState(null, '', next)
|
|
applyingRoute = false
|
|
}
|
|
}
|
|
|
|
onMount(async () => {
|
|
const onHash = () => {
|
|
if (!user) return
|
|
applyRoute()
|
|
}
|
|
window.addEventListener('hashchange', onHash)
|
|
try {
|
|
user = await api.me()
|
|
await applyRoute()
|
|
await refreshTimer()
|
|
} catch {
|
|
user = null
|
|
} finally {
|
|
loading = false
|
|
}
|
|
return () => window.removeEventListener('hashchange', onHash)
|
|
})
|
|
|
|
function formatDuration(ns) {
|
|
const totalMin = Math.max(0, Math.round(Number(ns) / 6e10))
|
|
const h = Math.floor(totalMin / 60)
|
|
const m = totalMin % 60
|
|
if (h > 0 && m > 0) return `${h}h ${m}m`
|
|
if (h > 0) return `${h}h`
|
|
return `${m}m`
|
|
}
|
|
|
|
function toLocalInput(iso) {
|
|
if (!iso) return ''
|
|
const d = new Date(iso)
|
|
if (Number.isNaN(d.getTime())) return ''
|
|
const pad = (n) => String(n).padStart(2, '0')
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
}
|
|
|
|
async function refreshTicketTime() {
|
|
if (!selected) return
|
|
const all = await api.listTime(projectId)
|
|
ticketTime = all.filter((e) => e.ticketId === selected.id)
|
|
}
|
|
|
|
function beginEditTime(e) {
|
|
editingTimeId = e.id
|
|
editTimeStart = toLocalInput(e.start)
|
|
editTimeEnd = toLocalInput(e.end || (e.start && e.duration
|
|
? new Date(new Date(e.start).getTime() + Number(e.duration)).toISOString()
|
|
: ''))
|
|
}
|
|
|
|
function cancelEditTime() {
|
|
editingTimeId = ''
|
|
editTimeStart = ''
|
|
editTimeEnd = ''
|
|
}
|
|
|
|
async function saveTimeEntry() {
|
|
if (!projectId || !editingTimeId) return
|
|
if (!editTimeStart || !editTimeEnd) {
|
|
error = 'Start and end are required'
|
|
return
|
|
}
|
|
const start = new Date(editTimeStart)
|
|
const end = new Date(editTimeEnd)
|
|
if (!(end > start)) {
|
|
error = 'End must be after start'
|
|
return
|
|
}
|
|
error = ''
|
|
await api.patchTime(projectId, editingTimeId, {
|
|
start: start.toISOString(),
|
|
end: end.toISOString(),
|
|
})
|
|
cancelEditTime()
|
|
if (projectView === 'report') await loadReport()
|
|
else await refreshTicketTime()
|
|
msg = 'Time updated'
|
|
}
|
|
|
|
async function removeTimeEntry(eid) {
|
|
if (!confirm('Delete time entry?')) return
|
|
await api.deleteTime(projectId, eid)
|
|
if (editingTimeId === eid) cancelEditTime()
|
|
if (projectView === 'report') await loadReport()
|
|
else await refreshTicketTime()
|
|
msg = 'Time deleted'
|
|
}
|
|
|
|
function ticketTitle(tid) {
|
|
const t = tickets.find((x) => x.id === tid)
|
|
return t?.title || tid || '—'
|
|
}
|
|
|
|
function reportQuery() {
|
|
return {
|
|
groupBy: reportGroupBy,
|
|
from: reportFrom,
|
|
to: reportTo,
|
|
ticketId: reportTicketId,
|
|
}
|
|
}
|
|
|
|
async function doLogin() {
|
|
error = ''
|
|
try {
|
|
user = await api.login(loginUser, loginPass)
|
|
await applyRoute()
|
|
await refreshTimer()
|
|
} catch (e) {
|
|
error = e.message
|
|
}
|
|
}
|
|
|
|
async function doLogout() {
|
|
await api.logout()
|
|
user = null
|
|
projects = []
|
|
groups = []
|
|
selected = null
|
|
projectId = ''
|
|
projectView = ''
|
|
topTab = 'dashboard'
|
|
}
|
|
|
|
async function refreshProjects() {
|
|
;[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() {
|
|
const data = await api.getTimer()
|
|
timer = data.timer
|
|
}
|
|
|
|
async function showDashboard() {
|
|
topTab = 'dashboard'
|
|
projectId = ''
|
|
projectView = ''
|
|
selected = null
|
|
dash = await api.dashboard()
|
|
await refreshProjects()
|
|
}
|
|
|
|
async function openDashboard() {
|
|
await showDashboard()
|
|
syncRoute()
|
|
}
|
|
|
|
async function showProjects() {
|
|
topTab = 'projects'
|
|
projectId = ''
|
|
projectView = ''
|
|
selected = null
|
|
error = ''
|
|
msg = ''
|
|
await refreshProjects()
|
|
}
|
|
|
|
async function openProjects() {
|
|
await showProjects()
|
|
syncRoute()
|
|
}
|
|
|
|
async function showProject(id, name = '') {
|
|
topTab = 'projects'
|
|
projectId = id
|
|
if (!projects.length) await refreshProjects()
|
|
projectName = name || projects.find((p) => p.id === id)?.name || id
|
|
projectView = 'board'
|
|
selected = null
|
|
error = ''
|
|
tickets = await api.listTickets(id)
|
|
}
|
|
|
|
async function openProject(id, name = '') {
|
|
await showProject(id, name)
|
|
syncRoute()
|
|
}
|
|
|
|
async function createProject() {
|
|
error = ''
|
|
const name = newProjectName.trim()
|
|
if (!name) {
|
|
error = 'Name required'
|
|
return
|
|
}
|
|
try {
|
|
const p = await api.createProject(name, newProjectGroupId)
|
|
newProjectName = ''
|
|
await refreshProjects()
|
|
await openProject(p.id, p.name)
|
|
} catch (e) {
|
|
error = e.message
|
|
}
|
|
}
|
|
|
|
async function createTicket() {
|
|
if (!newTicketTitle.trim()) return
|
|
await api.createTicket(projectId, { title: newTicketTitle, status: 'todo' })
|
|
newTicketTitle = ''
|
|
tickets = await api.listTickets(projectId)
|
|
}
|
|
|
|
async function showTicket(t) {
|
|
selected = t
|
|
editTitle = t.title
|
|
editDesc = t.description || ''
|
|
editStatus = t.status
|
|
cancelEditTime()
|
|
revisions = await api.listRevisions(projectId, t.id)
|
|
const all = await api.listTime(projectId)
|
|
ticketTime = all.filter((e) => e.ticketId === t.id)
|
|
await refreshTimer()
|
|
topTab = 'projects'
|
|
projectView = 'ticket'
|
|
}
|
|
|
|
async function openTicket(t) {
|
|
await showTicket(t)
|
|
syncRoute()
|
|
}
|
|
|
|
async function saveTicket() {
|
|
selected = await api.patchTicket(projectId, selected.id, {
|
|
title: editTitle,
|
|
description: editDesc,
|
|
status: editStatus,
|
|
})
|
|
revisions = await api.listRevisions(projectId, selected.id)
|
|
tickets = await api.listTickets(projectId)
|
|
msg = 'Saved'
|
|
}
|
|
|
|
async function removeTicket() {
|
|
if (!confirm('Delete ticket?')) return
|
|
await api.deleteTicket(projectId, selected.id)
|
|
selected = null
|
|
projectView = 'board'
|
|
tickets = await api.listTickets(projectId)
|
|
syncRoute()
|
|
}
|
|
|
|
async function restoreRev(rid) {
|
|
selected = await api.restoreRevision(projectId, selected.id, rid)
|
|
editTitle = selected.title
|
|
editDesc = selected.description || ''
|
|
editStatus = selected.status
|
|
revisions = await api.listRevisions(projectId, selected.id)
|
|
tickets = await api.listTickets(projectId)
|
|
msg = 'Restored'
|
|
}
|
|
|
|
async function bookTime() {
|
|
if (!selected) return
|
|
await api.createTime(projectId, {
|
|
ticketId: selected.id,
|
|
durationNs: timeMinutes * 60 * 1e9,
|
|
source: 'manual',
|
|
})
|
|
await refreshTicketTime()
|
|
msg = `Booked ${timeMinutes}m`
|
|
}
|
|
|
|
async function toggleTimer() {
|
|
if (!selected) return
|
|
if (timer && timer.ticketId === selected.id) {
|
|
await api.stopTimer()
|
|
} else {
|
|
await api.startTimer(selected.id, projectId)
|
|
}
|
|
await refreshTimer()
|
|
await refreshTicketTime()
|
|
}
|
|
|
|
async function loadReport() {
|
|
report = await api.report(projectId, reportQuery())
|
|
}
|
|
|
|
async function showReport() {
|
|
topTab = 'projects'
|
|
projectView = 'report'
|
|
error = ''
|
|
await loadReport()
|
|
}
|
|
|
|
async function openReport() {
|
|
await showReport()
|
|
syncRoute()
|
|
}
|
|
|
|
async function changeReportGroup(g) {
|
|
reportGroupBy = g
|
|
await loadReport()
|
|
}
|
|
|
|
async function applyReportFilters() {
|
|
error = ''
|
|
try {
|
|
await loadReport()
|
|
} catch (e) {
|
|
error = e.message || String(e)
|
|
}
|
|
}
|
|
|
|
function clearReportFilters() {
|
|
reportFrom = ''
|
|
reportTo = ''
|
|
reportTicketId = ''
|
|
}
|
|
|
|
async function resetReportFilters() {
|
|
clearReportFilters()
|
|
await applyReportFilters()
|
|
}
|
|
|
|
async function showProjectSettings() {
|
|
topTab = 'projects'
|
|
projectView = 'settings'
|
|
renameName = projectName
|
|
editGroupId = projects.find((p) => p.id === projectId)?.groupId || ''
|
|
newTokenSecret = ''
|
|
tokens = await api.listTokens(projectId)
|
|
await refreshProjects()
|
|
}
|
|
|
|
async function openProjectSettings() {
|
|
await showProjectSettings()
|
|
syncRoute()
|
|
}
|
|
|
|
async function saveProjectGroup() {
|
|
const p = await api.patchProject(projectId, { groupId: editGroupId })
|
|
msg = 'Gruppe gespeichert'
|
|
await refreshProjects()
|
|
if (p.groupId !== undefined) editGroupId = p.groupId || ''
|
|
}
|
|
|
|
function showGlobalSettings() {
|
|
topTab = 'settings'
|
|
projectId = ''
|
|
projectView = ''
|
|
selected = null
|
|
error = ''
|
|
msg = ''
|
|
}
|
|
|
|
function openGlobalSettings() {
|
|
showGlobalSettings()
|
|
syncRoute()
|
|
}
|
|
|
|
async function saveRename() {
|
|
const name = renameName.trim()
|
|
if (!name) return
|
|
const p = await api.patchProject(projectId, { name })
|
|
projectName = p.name
|
|
msg = 'Renamed'
|
|
await refreshProjects()
|
|
}
|
|
|
|
async function createToken() {
|
|
const t = await api.createToken(projectId, { name: tokenName || 'token', scope: tokenScope })
|
|
newTokenSecret = t.token
|
|
tokens = await api.listTokens(projectId)
|
|
msg = 'Token created — copy it now'
|
|
}
|
|
|
|
async function revokeToken(tid) {
|
|
await api.revokeToken(projectId, tid)
|
|
tokens = await api.listTokens(projectId)
|
|
}
|
|
|
|
async function showAdmin() {
|
|
topTab = 'users'
|
|
projectId = ''
|
|
projectView = ''
|
|
selected = null
|
|
error = ''
|
|
msg = ''
|
|
users = await api.listUsers()
|
|
}
|
|
|
|
async function openAdmin() {
|
|
await showAdmin()
|
|
syncRoute()
|
|
}
|
|
|
|
async function createUser() {
|
|
await api.createUser({ username: newUserName, password: newUserPass, isAdmin: newUserAdmin })
|
|
newUserName = ''
|
|
newUserPass = ''
|
|
newUserAdmin = false
|
|
users = await api.listUsers()
|
|
}
|
|
|
|
async function toggleAdmin(u) {
|
|
if (u.id === user.id) {
|
|
error = 'Cannot change your own admin flag here'
|
|
return
|
|
}
|
|
await api.patchUser(u.id, { isAdmin: !u.isAdmin })
|
|
users = await api.listUsers()
|
|
}
|
|
|
|
async function deleteUser(u) {
|
|
if (u.id === user.id) return
|
|
if (!confirm(`Delete user ${u.username}?`)) return
|
|
await api.deleteUser(u.id)
|
|
users = await api.listUsers()
|
|
}
|
|
|
|
async function showMembers() {
|
|
topTab = 'projects'
|
|
projectView = 'members'
|
|
members = await api.listMembers(projectId)
|
|
users = await api.listUsers()
|
|
}
|
|
|
|
async function openMembers() {
|
|
await showMembers()
|
|
syncRoute()
|
|
}
|
|
|
|
async function addMember() {
|
|
await api.setMember(projectId, memberUserId, memberRole)
|
|
members = await api.listMembers(projectId)
|
|
}
|
|
|
|
function byStatus(status) {
|
|
return tickets.filter((t) => t.status === status)
|
|
}
|
|
|
|
async function setTicketStatus(ticket, status) {
|
|
if (!ticket || ticket.status === status) return
|
|
error = ''
|
|
const prev = ticket.status
|
|
tickets = tickets.map((t) => (t.id === ticket.id ? { ...t, status } : t))
|
|
try {
|
|
await api.patchTicket(projectId, ticket.id, { status })
|
|
if (selected?.id === ticket.id) {
|
|
selected = { ...selected, status }
|
|
editStatus = status
|
|
}
|
|
} catch (e) {
|
|
tickets = tickets.map((t) => (t.id === ticket.id ? { ...t, status: prev } : t))
|
|
error = e.message
|
|
}
|
|
}
|
|
|
|
function onTicketDragStart(e, ticket) {
|
|
dragTicketId = ticket.id
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
e.dataTransfer.setData('text/plain', ticket.id)
|
|
}
|
|
|
|
function onTicketDragEnd() {
|
|
dragTicketId = ''
|
|
dragOverStatus = ''
|
|
// Click fires after dragend on some browsers; suppress open-once.
|
|
suppressTicketClick = true
|
|
setTimeout(() => {
|
|
suppressTicketClick = false
|
|
}, 0)
|
|
}
|
|
|
|
function onColumnDragOver(e, status) {
|
|
e.preventDefault()
|
|
e.dataTransfer.dropEffect = 'move'
|
|
dragOverStatus = status
|
|
}
|
|
|
|
function onColumnDragLeave(e, status) {
|
|
if (dragOverStatus === status) dragOverStatus = ''
|
|
}
|
|
|
|
async function onColumnDrop(e, status) {
|
|
e.preventDefault()
|
|
const id = e.dataTransfer.getData('text/plain') || dragTicketId
|
|
dragTicketId = ''
|
|
dragOverStatus = ''
|
|
const ticket = tickets.find((t) => t.id === id)
|
|
if (ticket) await setTicketStatus(ticket, status)
|
|
}
|
|
|
|
function onBoardTicketClick(t) {
|
|
if (suppressTicketClick) return
|
|
openTicket(t)
|
|
}
|
|
|
|
async function copyText(text) {
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
msg = 'Copied'
|
|
} catch {
|
|
msg = text
|
|
}
|
|
}
|
|
|
|
function sidebarActive(v) {
|
|
if (v === 'board') return projectView === 'board' || projectView === 'ticket'
|
|
return projectView === v
|
|
}
|
|
</script>
|
|
|
|
{#if loading}
|
|
<div class="login-wrap"><p class="muted">Loading…</p></div>
|
|
{:else if !user}
|
|
<div class="login-wrap">
|
|
<div class="panel login-card stack">
|
|
<h1>task-master</h1>
|
|
<p class="muted">Sign in to manage projects and tickets.</p>
|
|
{#if error}<p class="error">{error}</p>{/if}
|
|
<form
|
|
class="stack"
|
|
onsubmit={(e) => {
|
|
e.preventDefault()
|
|
doLogin()
|
|
}}
|
|
>
|
|
<label>Username<input bind:value={loginUser} autocomplete="username" /></label>
|
|
<label>Password<input type="password" bind:value={loginPass} autocomplete="current-password" /></label>
|
|
<button class="primary" type="submit">Login</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="app-shell">
|
|
<header class="topbar">
|
|
<div class="topbar-start">
|
|
<button type="button" class="brand" onclick={openDashboard}>task-master</button>
|
|
<nav class="nav">
|
|
<button type="button" class:active={topTab === 'dashboard'} onclick={openDashboard}>Dashboard</button>
|
|
<button type="button" class:active={topTab === 'projects'} onclick={openProjects}>Projekte</button>
|
|
{#if user.isAdmin}
|
|
<button type="button" class:active={topTab === 'users'} onclick={openAdmin}>User</button>
|
|
{/if}
|
|
<button type="button" class:active={topTab === 'settings'} onclick={openGlobalSettings}>Settings</button>
|
|
</nav>
|
|
</div>
|
|
<div class="topbar-meta row">
|
|
{#if timer}
|
|
<span class="muted">⏱ {timer.ticketId.slice(0, 8)}…</span>
|
|
{/if}
|
|
<span class="muted">{user.username}{user.isAdmin ? ' (admin)' : ''}</span>
|
|
<button type="button" onclick={doLogout}>Logout</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="app-body" class:with-sidebar={topTab === 'projects'}>
|
|
{#if topTab === 'projects'}
|
|
<aside class="sidebar">
|
|
<div class="sidebar-section sidebar-projects">
|
|
<p class="sidebar-label">Projekte</p>
|
|
<div class="sidebar-project-list">
|
|
{#each groups as g}
|
|
<button type="button" class="sidebar-group" onclick={() => toggleGroup(g.id)}>
|
|
<span class="sidebar-twist">{collapsedGroups[g.id] ? '▸' : '▾'}</span>
|
|
<span class="sidebar-group-name">{g.name}</span>
|
|
<span class="muted">{projectsInGroup(g.id).length}</span>
|
|
</button>
|
|
{#if !collapsedGroups[g.id]}
|
|
{#each projectsInGroup(g.id) as p}
|
|
<button
|
|
class="sidebar-item sidebar-child"
|
|
class:active={projectId === p.id}
|
|
onclick={() => openProject(p.id, p.name)}
|
|
>{p.name}</button>
|
|
{:else}
|
|
<p class="muted sidebar-child">leer</p>
|
|
{/each}
|
|
{/if}
|
|
{/each}
|
|
{#if ungroupedProjects().length}
|
|
{#if groups.length}
|
|
<p class="sidebar-label sidebar-ungrouped-label">Ohne Gruppe</p>
|
|
{/if}
|
|
{#each ungroupedProjects() as p}
|
|
<button
|
|
class="sidebar-item"
|
|
class:active={projectId === p.id}
|
|
onclick={() => openProject(p.id, p.name)}
|
|
>{p.name}</button>
|
|
{/each}
|
|
{:else if !groups.length && !projects.length}
|
|
<p class="muted">Noch keine Projekte.</p>
|
|
{/if}
|
|
</div>
|
|
<div class="sidebar-create stack">
|
|
<input placeholder="Neues Projekt" bind:value={newProjectName} />
|
|
{#if groups.length}
|
|
<select bind:value={newProjectGroupId}>
|
|
<option value="">Ohne Gruppe</option>
|
|
{#each groups as g}<option value={g.id}>{g.name}</option>{/each}
|
|
</select>
|
|
{/if}
|
|
<button class="primary" onclick={createProject}>Anlegen</button>
|
|
<div class="row" style="margin-top: 0.35rem;">
|
|
<input placeholder="Neue Gruppe" bind:value={newGroupName} />
|
|
<button onclick={createGroup}>+</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{#if projectId}
|
|
<div class="sidebar-section sidebar-subnav">
|
|
<p class="sidebar-label">{projectName}</p>
|
|
<button class="sidebar-item" class:active={sidebarActive('board')} onclick={() => openProject(projectId, projectName)}>Board</button>
|
|
<button class="sidebar-item" class:active={sidebarActive('report')} onclick={openReport}>Report</button>
|
|
<button class="sidebar-item" class:active={sidebarActive('members')} onclick={openMembers}>Members</button>
|
|
<button class="sidebar-item" class:active={sidebarActive('settings')} onclick={openProjectSettings}>Settings</button>
|
|
</div>
|
|
{/if}
|
|
</aside>
|
|
{/if}
|
|
|
|
<main class="content">
|
|
{#if error}<p class="error">{error}</p>{/if}
|
|
{#if msg}<p class="muted">{msg}</p>{/if}
|
|
|
|
{#if topTab === 'dashboard'}
|
|
<div class="grid" style="grid-template-columns: 1.2fr 1fr; gap: 1rem;">
|
|
<section class="panel stack">
|
|
<h2>Projects</h2>
|
|
{#each dash.projects as p}
|
|
<button class="ticket" onclick={() => openProject(p.id, p.name)}>
|
|
<strong>{p.name}</strong>
|
|
<div class="muted">{p.openCount} open · {formatDuration(p.durationNs)}</div>
|
|
</button>
|
|
{:else}
|
|
<p class="muted">No projects yet.</p>
|
|
{/each}
|
|
<button onclick={openProjects}>Alle Projekte →</button>
|
|
</section>
|
|
<div class="stack">
|
|
<section class="panel stack">
|
|
<h2>Open tickets</h2>
|
|
{#each dash.openTickets as t}
|
|
<button class="ticket" onclick={async () => { await openProject(t.projectId, t.projectName); const full = (await api.listTickets(t.projectId)).find(x => x.id === t.id); if (full) openTicket(full) }}>
|
|
<span class="badge {t.status}">{t.status}</span>
|
|
<div>{t.title}</div>
|
|
<div class="muted">{t.projectName}</div>
|
|
</button>
|
|
{:else}
|
|
<p class="muted">Nothing open.</p>
|
|
{/each}
|
|
</section>
|
|
<section class="panel stack">
|
|
<h2>Time by project</h2>
|
|
<table>
|
|
<thead><tr><th>Project</th><th>Time</th></tr></thead>
|
|
<tbody>
|
|
{#each dash.projects as p}
|
|
<tr><td>{p.name}</td><td>{formatDuration(p.durationNs)}</td></tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
{:else if topTab === 'projects' && !projectId}
|
|
<section class="panel stack">
|
|
<h2>Projekte</h2>
|
|
<p class="muted">Wähle links ein Projekt oder lege ein neues an.</p>
|
|
</section>
|
|
{:else if topTab === 'projects' && projectView === 'board'}
|
|
<section class="panel stack">
|
|
<div class="row" style="justify-content: space-between;">
|
|
<h2>{projectName}</h2>
|
|
<div class="row">
|
|
<input placeholder="New ticket title" bind:value={newTicketTitle} style="width: 240px;" />
|
|
<button class="primary" onclick={createTicket}>Add</button>
|
|
</div>
|
|
</div>
|
|
<div class="grid cols-3">
|
|
{#each boardStatuses as [st, label]}
|
|
<div
|
|
class="board-col stack"
|
|
class:drag-over={dragOverStatus === st}
|
|
ondragover={(e) => onColumnDragOver(e, st)}
|
|
ondragleave={(e) => onColumnDragLeave(e, st)}
|
|
ondrop={(e) => onColumnDrop(e, st)}
|
|
role="list"
|
|
aria-label={label}
|
|
>
|
|
<h3>{label}</h3>
|
|
{#each byStatus(st) as t}
|
|
<div
|
|
class="ticket"
|
|
class:dragging={dragTicketId === t.id}
|
|
draggable="true"
|
|
role="listitem"
|
|
ondragstart={(e) => onTicketDragStart(e, t)}
|
|
ondragend={onTicketDragEnd}
|
|
>
|
|
<button type="button" class="ticket-open" onclick={() => onBoardTicketClick(t)}>
|
|
<div>{t.title}</div>
|
|
{#if t.externalId}<div class="muted">{t.externalId}</div>{/if}
|
|
</button>
|
|
<span class="drag-handle" title="Ziehen" aria-hidden="true">⠿</span>
|
|
</div>
|
|
{:else}
|
|
<div class="board-empty muted">Drop hier</div>
|
|
{/each}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{:else if topTab === 'projects' && projectView === 'ticket' && selected}
|
|
<section class="panel stack">
|
|
<div class="row">
|
|
<button onclick={() => openProject(projectId, projectName)}>← Board</button>
|
|
<button class="danger" onclick={removeTicket}>Delete</button>
|
|
</div>
|
|
<label>Title<input bind:value={editTitle} /></label>
|
|
<label>Status
|
|
<select bind:value={editStatus}>
|
|
<option value="todo">OPEN</option>
|
|
<option value="in_progress">WIP</option>
|
|
<option value="done">DONE</option>
|
|
</select>
|
|
</label>
|
|
<MarkdownEditor bind:value={editDesc} label="Description" />
|
|
<button class="primary" onclick={saveTicket}>Save</button>
|
|
|
|
<h3>Time</h3>
|
|
<div class="row">
|
|
<button class="primary" onclick={toggleTimer}>
|
|
{timer && timer.ticketId === selected.id ? 'Stop timer' : 'Start timer'}
|
|
</button>
|
|
<label class="row" style="width: auto;">Minutes
|
|
<input type="number" min="1" bind:value={timeMinutes} style="width: 5rem;" />
|
|
</label>
|
|
<button onclick={bookTime}>Book</button>
|
|
</div>
|
|
<table>
|
|
<thead><tr><th>When</th><th>Duration</th><th>Source</th><th></th></tr></thead>
|
|
<tbody>
|
|
{#each ticketTime as e}
|
|
{#if editingTimeId === e.id}
|
|
<tr>
|
|
<td colspan="4">
|
|
<div class="row" style="flex-wrap: wrap; gap: 0.5rem;">
|
|
<label class="row" style="width: auto;">Start
|
|
<input type="datetime-local" bind:value={editTimeStart} style="width: auto;" />
|
|
</label>
|
|
<label class="row" style="width: auto;">End
|
|
<input type="datetime-local" bind:value={editTimeEnd} style="width: auto;" />
|
|
</label>
|
|
<button class="primary" onclick={saveTimeEntry}>Save</button>
|
|
<button onclick={cancelEditTime}>Cancel</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{:else}
|
|
<tr>
|
|
<td class="muted">{e.start ? new Date(e.start).toLocaleString() : '—'}</td>
|
|
<td>{formatDuration(e.duration)}</td>
|
|
<td>{e.source}</td>
|
|
<td class="row" style="width: auto; justify-content: flex-end;">
|
|
<button onclick={() => beginEditTime(e)}>Edit</button>
|
|
<button class="danger" onclick={() => removeTimeEntry(e.id)}>Delete</button>
|
|
</td>
|
|
</tr>
|
|
{/if}
|
|
{:else}
|
|
<tr><td colspan="4" class="muted">No time yet.</td></tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
|
|
<h3>History</h3>
|
|
<table>
|
|
<thead><tr><th>Rev</th><th>Op</th><th>At</th><th></th></tr></thead>
|
|
<tbody>
|
|
{#each revisions as rev}
|
|
<tr>
|
|
<td>{rev.rev}</td>
|
|
<td>{rev.op}</td>
|
|
<td class="muted">{new Date(rev.at).toLocaleString()}</td>
|
|
<td><button onclick={() => restoreRev(rev.id)}>Restore</button></td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
{:else if topTab === 'projects' && projectView === 'report'}
|
|
<section class="panel stack">
|
|
<div class="row" style="justify-content: space-between; flex-wrap: wrap; gap: 0.75rem;">
|
|
<h2>Report — {projectName}</h2>
|
|
<div class="row">
|
|
{#each [['ticket', 'Ticket'], ['status', 'Status'], ['day', 'Day']] as [g, label]}
|
|
<button class:primary={reportGroupBy === g} onclick={() => changeReportGroup(g)}>{label}</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
<div class="row" style="flex-wrap: wrap; gap: 0.75rem; align-items: flex-end;">
|
|
<label style="width: auto;">From
|
|
<input type="date" bind:value={reportFrom} style="width: auto;" />
|
|
</label>
|
|
<label style="width: auto;">To
|
|
<input type="date" bind:value={reportTo} style="width: auto;" />
|
|
</label>
|
|
<label style="width: auto; flex: 1; min-width: 12rem;">Ticket
|
|
<select bind:value={reportTicketId}>
|
|
<option value="">All tickets</option>
|
|
{#each tickets as t}
|
|
<option value={t.id}>{t.title}</option>
|
|
{/each}
|
|
</select>
|
|
</label>
|
|
<button class="primary" onclick={applyReportFilters}>Filter</button>
|
|
<button onclick={resetReportFilters}>Reset</button>
|
|
</div>
|
|
{#if report}
|
|
<p class="muted">{report.entryCount} entries · total {formatDuration(report.totalDurationNs)}</p>
|
|
<h3>Summary</h3>
|
|
<table>
|
|
<thead><tr><th>Group</th><th>Duration</th></tr></thead>
|
|
<tbody>
|
|
{#each report.rows as row}
|
|
<tr><td>{row.label}</td><td>{formatDuration(row.durationNs)}</td></tr>
|
|
{:else}
|
|
<tr><td colspan="2" class="muted">No time logged.</td></tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
<h3>Entries</h3>
|
|
<table>
|
|
<thead><tr><th>When</th><th>Ticket</th><th>Duration</th><th>Source</th><th>Note</th><th></th></tr></thead>
|
|
<tbody>
|
|
{#each report.entries || [] as e}
|
|
{#if editingTimeId === e.id}
|
|
<tr>
|
|
<td colspan="6">
|
|
<div class="row" style="flex-wrap: wrap; gap: 0.5rem;">
|
|
<label class="row" style="width: auto;">Start
|
|
<input type="datetime-local" bind:value={editTimeStart} style="width: auto;" />
|
|
</label>
|
|
<label class="row" style="width: auto;">End
|
|
<input type="datetime-local" bind:value={editTimeEnd} style="width: auto;" />
|
|
</label>
|
|
<button class="primary" onclick={saveTimeEntry}>Save</button>
|
|
<button onclick={cancelEditTime}>Cancel</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{:else}
|
|
<tr>
|
|
<td class="muted">{e.start ? new Date(e.start).toLocaleString() : '—'}</td>
|
|
<td>{ticketTitle(e.ticketId)}</td>
|
|
<td>{formatDuration(e.duration)}</td>
|
|
<td>{e.source}</td>
|
|
<td class="muted">{e.note || ''}</td>
|
|
<td class="row" style="width: auto; justify-content: flex-end;">
|
|
<button onclick={() => beginEditTime(e)}>Edit</button>
|
|
<button class="danger" onclick={() => removeTimeEntry(e.id)}>Delete</button>
|
|
</td>
|
|
</tr>
|
|
{/if}
|
|
{:else}
|
|
<tr><td colspan="6" class="muted">No entries for this filter.</td></tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{/if}
|
|
</section>
|
|
{:else if topTab === 'projects' && projectView === 'settings'}
|
|
<section class="panel stack">
|
|
<h2>Settings — {projectName}</h2>
|
|
<label>Project ID
|
|
<div class="row">
|
|
<code style="flex:1">{projectId}</code>
|
|
<button onclick={() => copyText(projectId)}>Copy</button>
|
|
</div>
|
|
</label>
|
|
<label>Name
|
|
<div class="row">
|
|
<input bind:value={renameName} />
|
|
<button class="primary" onclick={saveRename}>Rename</button>
|
|
</div>
|
|
</label>
|
|
<label>Gruppe
|
|
<div class="row">
|
|
<select bind:value={editGroupId} style="width: auto; flex: 1;">
|
|
<option value="">Ohne Gruppe</option>
|
|
{#each groups as g}<option value={g.id}>{g.name}</option>{/each}
|
|
</select>
|
|
<button class="primary" onclick={saveProjectGroup}>Speichern</button>
|
|
</div>
|
|
</label>
|
|
{#if groups.length}
|
|
<div class="stack">
|
|
<h3>Gruppen verwalten</h3>
|
|
{#each groups as g}
|
|
<div class="row">
|
|
<span style="flex:1">{g.name}</span>
|
|
<button class="danger" onclick={() => removeGroup(g.id)}>Löschen</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<h3>API tokens (for TUI)</h3>
|
|
<p class="muted">Create a write token, then in `tm`: project edit → backend taskmaster → URL, token, this project id.</p>
|
|
{#if newTokenSecret}
|
|
<div class="panel stack" style="border-color: var(--accent);">
|
|
<strong>New token (shown once)</strong>
|
|
<code>{newTokenSecret}</code>
|
|
<button onclick={() => copyText(newTokenSecret)}>Copy token</button>
|
|
</div>
|
|
{/if}
|
|
<div class="row">
|
|
<input placeholder="name" bind:value={tokenName} style="width: 8rem;" />
|
|
<select bind:value={tokenScope} style="width: auto;">
|
|
<option value="read">read</option>
|
|
<option value="write">write</option>
|
|
</select>
|
|
<button class="primary" onclick={createToken}>Create token</button>
|
|
</div>
|
|
<table>
|
|
<thead><tr><th>Name</th><th>Scope</th><th>Created</th><th></th></tr></thead>
|
|
<tbody>
|
|
{#each tokens as t}
|
|
<tr>
|
|
<td>{t.name}{#if t.revokedAt} <span class="muted">(revoked)</span>{/if}</td>
|
|
<td>{t.scope}</td>
|
|
<td class="muted">{new Date(t.createdAt).toLocaleString()}</td>
|
|
<td>
|
|
{#if !t.revokedAt}
|
|
<button class="danger" onclick={() => revokeToken(t.id)}>Revoke</button>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
{:else if topTab === 'projects' && projectView === 'members'}
|
|
<section class="panel stack">
|
|
<h2>Members — {projectName}</h2>
|
|
<table>
|
|
<thead><tr><th>User</th><th>Role</th><th></th></tr></thead>
|
|
<tbody>
|
|
{#each members as m}
|
|
<tr>
|
|
<td>{m.username}</td>
|
|
<td>{m.role}</td>
|
|
<td><button class="danger" onclick={async () => { await api.removeMember(projectId, m.userId); members = await api.listMembers(projectId) }}>Remove</button></td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
<div class="row">
|
|
<select bind:value={memberUserId} style="width: auto;">
|
|
<option value="">user…</option>
|
|
{#each users as u}<option value={u.id}>{u.username}</option>{/each}
|
|
</select>
|
|
<select bind:value={memberRole} style="width: auto;">
|
|
<option value="viewer">viewer</option>
|
|
<option value="member">member</option>
|
|
<option value="admin">admin</option>
|
|
<option value="owner">owner</option>
|
|
</select>
|
|
<button class="primary" onclick={addMember}>Add</button>
|
|
</div>
|
|
</section>
|
|
{:else if topTab === 'users'}
|
|
<section class="panel stack">
|
|
<h2>Users</h2>
|
|
<table>
|
|
<thead><tr><th>Username</th><th>Admin</th><th></th></tr></thead>
|
|
<tbody>
|
|
{#each users as u}
|
|
<tr>
|
|
<td>{u.username}</td>
|
|
<td>
|
|
<button onclick={() => toggleAdmin(u)} disabled={u.id === user.id}>
|
|
{u.isAdmin ? 'yes' : 'no'}
|
|
</button>
|
|
</td>
|
|
<td>
|
|
{#if u.id !== user.id}
|
|
<button class="danger" onclick={() => deleteUser(u)}>Delete</button>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
<h3>Create user</h3>
|
|
<input placeholder="username" bind:value={newUserName} />
|
|
<input type="password" placeholder="password" bind:value={newUserPass} />
|
|
<label class="row"><input type="checkbox" bind:checked={newUserAdmin} /> Admin</label>
|
|
<button class="primary" onclick={createUser}>Create</button>
|
|
</section>
|
|
{:else if topTab === 'settings'}
|
|
<section class="panel stack">
|
|
<h2>Settings</h2>
|
|
<p class="muted">Globale Einstellungen folgen hier später.</p>
|
|
</section>
|
|
{/if}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
{/if}
|