Files
task-master/web/src/App.svelte
T
skrueckenandCursor 6e6b13270a Add ticket file attachments with simple in-browser Excel editing.
Store uploads on disk under DataDir and expose them in the web ticket view so .xlsx files can be previewed and saved as cell values without YouTrack or TUI sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:53:17 +01:00

1263 lines
43 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'
import ExcelEditor from './lib/ExcelEditor.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
let attachments = $state([])
let excelAtt = $state(null)
let attachBusy = $state(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
excelAtt = null
cancelEditTime()
revisions = await api.listRevisions(projectId, t.id)
const all = await api.listTime(projectId)
ticketTime = all.filter((e) => e.ticketId === t.id)
attachments = await api.listAttachments(projectId, t.id)
await refreshTimer()
topTab = 'projects'
projectView = 'ticket'
}
function formatBytes(n) {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}
function isExcelName(name) {
return /\.xlsx$/i.test(name || '')
}
async function onAttachFile(e) {
const file = e.target?.files?.[0]
e.target.value = ''
if (!file || !selected) return
attachBusy = true
error = ''
try {
await api.uploadAttachment(projectId, selected.id, file)
attachments = await api.listAttachments(projectId, selected.id)
msg = 'Uploaded'
} catch (err) {
error = err.message
} finally {
attachBusy = false
}
}
async function removeAttachment(a) {
if (!selected || !confirm(`Delete ${a.filename}?`)) return
await api.deleteAttachment(projectId, selected.id, a.id)
if (excelAtt?.id === a.id) excelAtt = null
attachments = await api.listAttachments(projectId, selected.id)
msg = 'Attachment deleted'
}
async function refreshAttachments() {
if (!selected) return
attachments = await api.listAttachments(projectId, selected.id)
}
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>Attachments</h3>
<div class="row" style="flex-wrap: wrap; gap: 0.5rem; align-items: center;">
<label class="attach-upload">
<span class="buttonish">{attachBusy ? 'Uploading…' : 'Upload file'}</span>
<input type="file" disabled={attachBusy} onchange={onAttachFile} />
</label>
<span class="muted">Max 10 MiB</span>
</div>
{#if excelAtt}
<ExcelEditor
projectId={projectId}
ticketId={selected.id}
attachment={excelAtt}
onclose={() => (excelAtt = null)}
onsaved={refreshAttachments}
/>
{/if}
<ul class="attach-list">
{#each attachments as a}
<li class="attach-item">
<div class="attach-meta">
<span class="attach-name">{a.filename}</span>
<span class="muted">{formatBytes(a.sizeBytes)}</span>
</div>
<div class="row" style="width: auto; justify-content: flex-end;">
{#if isExcelName(a.filename)}
<button class="primary" onclick={() => (excelAtt = a)}>Edit</button>
{/if}
<a
class="buttonish"
href={api.downloadAttachmentUrl(projectId, selected.id, a.id)}
download={a.filename}
>Download</a>
<button class="danger" onclick={() => removeAttachment(a)}>Delete</button>
</div>
</li>
{:else}
<li class="muted">No attachments yet.</li>
{/each}
</ul>
<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}