LPM-140 The Sprinting Update Adds sprint support across the web UI (board + issue detail), TUI kanban, CLI/REPL, and the Beads-backed storage layer. Changes: Introduces sprint entities in storage and exposes sprint operations via IssueService. Updates web board to support backlog vs. sprint columns, sprint selection, and sprint creation routes. Updates TUI kanban to show backlog + sprint columns and adds sprint selection/creation actions.
273 lines
9.1 KiB
Plaintext
273 lines
9.1 KiB
Plaintext
package routes
|
|
|
|
import (
|
|
"fmt"
|
|
"github.com/LazyBachelor/LazyPM/internal/models"
|
|
"github.com/LazyBachelor/LazyPM/pkg/web/components"
|
|
)
|
|
|
|
templ IssueDetailPage(issue *models.Issue, comments []*models.Comment) {
|
|
{{
|
|
issueJSON, _ := templ.JSONString(issue)
|
|
xData := fmt.Sprintf(`{
|
|
issue: %s,
|
|
editing: null,
|
|
newComment: '',
|
|
get statusClass() {
|
|
const classes = {
|
|
'open': 'badge-info',
|
|
'in_progress': 'badge-warning',
|
|
'blocked': 'badge-error',
|
|
'closed': 'badge-success'
|
|
}
|
|
return classes[this.issue.status] || 'badge-ghost'
|
|
},
|
|
get priorityLabel() {
|
|
const labels = ['Irrelevant', 'Low', 'Normal', 'High', 'Critical']
|
|
return labels[this.issue.priority] || 'P' + this.issue.priority
|
|
},
|
|
async saveField(field, value) {
|
|
const formData = new URLSearchParams()
|
|
formData.append(field, value || '')
|
|
const response = await fetch('/issues/' + this.issue.id, {
|
|
method: 'PATCH',
|
|
body: formData,
|
|
headers: { 'HX-Request': 'true' }
|
|
})
|
|
this.editing = null
|
|
// Check if server wants to redirect back to list
|
|
const redirectUrl = response.headers.get('HX-Redirect')
|
|
if (redirectUrl) {
|
|
window.location.href = redirectUrl
|
|
}
|
|
},
|
|
async addComment() {
|
|
if (!this.newComment.trim()) return
|
|
const author = this.issue.created_by || 'Anonymous'
|
|
const formData = new URLSearchParams()
|
|
formData.append('text', this.newComment)
|
|
formData.append('author', author)
|
|
await fetch('/issues/' + this.issue.id + '/comments', {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: { 'HX-Request': 'true' }
|
|
})
|
|
this.newComment = ''
|
|
window.location.reload()
|
|
}
|
|
}`, issueJSON)
|
|
}}
|
|
@BaseLayout() {
|
|
<div class="max-w-4xl mx-auto p-4" x-data={ xData }>
|
|
<div class="flex justify-between items-center mb-6">
|
|
<a href="/" class="btn btn-ghost btn-sm">
|
|
@components.IconBack()
|
|
Back to Issues
|
|
</a>
|
|
<div class="flex gap-2">
|
|
<button
|
|
class="btn btn-error btn-sm"
|
|
@click="if(confirm('Delete this issue?')) { fetch('/issues/' + issue.id, { method: 'DELETE' }).then(() => window.location.href = '/') }"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card bg-base-100 shadow-xl">
|
|
<div class="card-body">
|
|
<div class="mb-4">
|
|
<template x-if="editing !== 'title'">
|
|
<h1
|
|
class="card-title text-3xl cursor-pointer hover:text-primary"
|
|
@click="editing = 'title'"
|
|
x-text="issue.title"
|
|
></h1>
|
|
</template>
|
|
<template x-if="editing === 'title'">
|
|
<div class="flex gap-2">
|
|
<input
|
|
type="text"
|
|
name="title"
|
|
x-model="issue.title"
|
|
class="input input-bordered text-xl flex-1"
|
|
@keydown.enter="saveField('title', issue.title)"
|
|
@keydown.escape="editing = null"
|
|
x-init="$el.focus()"
|
|
/>
|
|
<button class="btn btn-primary" @click="saveField('title', issue.title)">Save</button>
|
|
<button class="btn" @click="editing = null">Cancel</button>
|
|
</div>
|
|
</template>
|
|
<div class="text-sm opacity-60 mt-1" x-text="issue.id"></div>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2 mb-4">
|
|
<template x-if="editing !== 'status'">
|
|
<span
|
|
:class="'badge ' + statusClass + ' cursor-pointer hover:opacity-80 whitespace-nowrap'"
|
|
@click="editing = 'status'"
|
|
x-text="issue.status"
|
|
></span>
|
|
</template>
|
|
<template x-if="editing === 'status'">
|
|
<select
|
|
name="status"
|
|
x-model="issue.status"
|
|
class="select select-sm select-bordered"
|
|
@change="saveField('status', issue.status)"
|
|
>
|
|
<option value="open">Open</option>
|
|
<option value="in_progress">In Progress</option>
|
|
<option value="blocked">Blocked</option>
|
|
<option value="closed">Closed</option>
|
|
</select>
|
|
</template>
|
|
<template x-if="editing !== 'type'">
|
|
<span
|
|
class="badge badge-outline cursor-pointer hover:opacity-80 whitespace-nowrap"
|
|
@click="editing = 'type'"
|
|
x-text="issue.issue_type"
|
|
></span>
|
|
</template>
|
|
<template x-if="editing === 'type'">
|
|
<select
|
|
name="issue_type"
|
|
x-model="issue.issue_type"
|
|
class="select select-sm select-bordered"
|
|
@change="saveField('issue_type', issue.issue_type)"
|
|
>
|
|
<option value="task">Task</option>
|
|
<option value="bug">Bug</option>
|
|
<option value="feature">Feature</option>
|
|
<option value="chore">Chore</option>
|
|
</select>
|
|
</template>
|
|
<template x-if="editing !== 'priority'">
|
|
<span
|
|
class="badge badge-ghost cursor-pointer hover:opacity-80 whitespace-nowrap"
|
|
@click="editing = 'priority'"
|
|
x-text="'P' + issue.priority + ' - ' + priorityLabel"
|
|
></span>
|
|
</template>
|
|
<template x-if="editing === 'priority'">
|
|
<select
|
|
name="priority"
|
|
x-model="issue.priority"
|
|
class="select select-sm select-bordered"
|
|
@change="saveField('priority', issue.priority)"
|
|
>
|
|
<option value="0">P0 - Irrelevant</option>
|
|
<option value="1">P1 - Low</option>
|
|
<option value="2">P2 - Normal</option>
|
|
<option value="3">P3 - High</option>
|
|
<option value="4">P4 - Critical</option>
|
|
</select>
|
|
</template>
|
|
</div>
|
|
<div class="mb-6">
|
|
<h3 class="text-sm font-semibold opacity-70 mb-2">Description</h3>
|
|
<template x-if="editing !== 'description'">
|
|
<div
|
|
class="min-h-25 p-4 bg-base-200 rounded-lg cursor-pointer hover:bg-base-300"
|
|
@click="editing = 'description'"
|
|
>
|
|
<p class="whitespace-pre-wrap" x-show="issue.description" x-text="issue.description"></p>
|
|
<p class="text-base-content/50 italic" x-show="!issue.description">Click to add description...</p>
|
|
</div>
|
|
</template>
|
|
<template x-if="editing === 'description'">
|
|
<div class="flex flex-col gap-2">
|
|
<textarea
|
|
name="description"
|
|
x-model="issue.description"
|
|
class="textarea textarea-bordered min-h-37.5"
|
|
@keydown.escape="editing = null"
|
|
x-init="$el.focus()"
|
|
></textarea>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-primary" @click="saveField('description', issue.description)">Save</button>
|
|
<button class="btn" @click="editing = null">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
|
<div class="bg-base-200 rounded-lg p-3">
|
|
<span class="text-xs opacity-60 block">Created</span>
|
|
<p x-text="new Date(issue.created_at).toLocaleDateString()"></p>
|
|
<p class="text-xs opacity-50" x-text="issue.created_by"></p>
|
|
</div>
|
|
<div class="bg-base-200 rounded-lg p-3">
|
|
<span class="text-xs opacity-60 block">Updated</span>
|
|
<p x-text="new Date(issue.updated_at).toLocaleDateString()"></p>
|
|
</div>
|
|
<div
|
|
class="bg-base-200 rounded-lg p-3 cursor-pointer hover:bg-base-300"
|
|
@click="editing = 'assignee'"
|
|
>
|
|
<template x-if="editing !== 'assignee'">
|
|
<div>
|
|
<span class="text-xs opacity-60 block">Assignee</span>
|
|
<p x-text="issue.assignee || 'Unassigned'"></p>
|
|
</div>
|
|
</template>
|
|
<template x-if="editing === 'assignee'">
|
|
<div>
|
|
<span class="text-xs opacity-60 block">Assignee</span>
|
|
<input
|
|
type="text"
|
|
name="assignee"
|
|
x-model="issue.assignee"
|
|
class="input input-sm input-bordered w-full"
|
|
@keydown.enter="saveField('assignee', issue.assignee)"
|
|
@keydown.escape="editing = null"
|
|
x-init="$el.focus()"
|
|
/>
|
|
<div class="flex gap-1 mt-1">
|
|
<button class="btn btn-xs btn-primary" @click="saveField('assignee', issue.assignee)">Save</button>
|
|
<button class="btn btn-xs" @click="editing = null">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-6">
|
|
<h3 class="text-lg font-semibold mb-4">Comments</h3>
|
|
<div class="card bg-base-100 shadow mb-4">
|
|
<div class="card-body">
|
|
<textarea
|
|
x-model="newComment"
|
|
class="textarea textarea-bordered w-full"
|
|
placeholder="Add a comment..."
|
|
@keydown.enter.prevent="addComment()"
|
|
></textarea>
|
|
<div class="card-actions justify-end mt-2">
|
|
<button
|
|
class="btn btn-primary btn-sm"
|
|
@click="addComment()"
|
|
:disabled="!newComment.trim()"
|
|
>
|
|
Add Comment
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="space-y-4">
|
|
for _, comment := range comments {
|
|
<div class="card bg-base-200">
|
|
<div class="card-body py-3">
|
|
<div class="flex justify-between items-start mb-2">
|
|
<span class="font-semibold text-sm">{ comment.Author }</span>
|
|
<span class="text-xs opacity-50">{ comment.CreatedAt.Format("Jan 2, 2006 15:04") }</span>
|
|
</div>
|
|
<p class="whitespace-pre-wrap text-sm">{ comment.Text }</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|