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>
1263 lines
43 KiB
Svelte
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}
|