Add date and ticket filters plus entry list to the project time report.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"task/internal/sqlite"
|
"task/internal/sqlite"
|
||||||
"task/internal/ticket"
|
"task/internal/ticket"
|
||||||
@@ -29,11 +30,46 @@ func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from, err := ticket.ParseReportDay(r.URL.Query().Get("from"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toDay, err := ticket.ParseReportDay(r.URL.Query().Get("to"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var toBound = toDay
|
||||||
|
if !toDay.IsZero() {
|
||||||
|
toBound = ticket.EndOfReportDay(toDay)
|
||||||
|
}
|
||||||
|
filter := ticket.TimeFilter{
|
||||||
|
From: from,
|
||||||
|
To: toBound,
|
||||||
|
TicketID: r.URL.Query().Get("ticketId"),
|
||||||
|
}
|
||||||
|
|
||||||
entries, err := s.Times.ListByProject(id)
|
entries, err := s.Times.ListByProject(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
entries = ticket.FilterEntries(entries, filter)
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
ai, aj := entries[i].Start, entries[j].Start
|
||||||
|
if ai.IsZero() {
|
||||||
|
ai = entries[i].End
|
||||||
|
}
|
||||||
|
if aj.IsZero() {
|
||||||
|
aj = entries[j].End
|
||||||
|
}
|
||||||
|
if !ai.Equal(aj) {
|
||||||
|
return ai.After(aj)
|
||||||
|
}
|
||||||
|
return entries[i].ID > entries[j].ID
|
||||||
|
})
|
||||||
|
|
||||||
tickets, err := s.Tickets.List(id)
|
tickets, err := s.Tickets.List(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
@@ -64,5 +100,9 @@ func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
"totalDurationNs": int64(total),
|
"totalDurationNs": int64(total),
|
||||||
"rows": out,
|
"rows": out,
|
||||||
"entryCount": len(entries),
|
"entryCount": len(entries),
|
||||||
|
"entries": entries,
|
||||||
|
"from": r.URL.Query().Get("from"),
|
||||||
|
"to": r.URL.Query().Get("to"),
|
||||||
|
"ticketId": filter.TicketID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"task/internal/sqlite"
|
||||||
|
"task/internal/ticket"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectReportFilters(t *testing.T) {
|
||||||
|
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if _, err := db.CreateUser("admin", "pass", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := New(db)
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
cookie := rec.Result().Cookies()[0]
|
||||||
|
|
||||||
|
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
var proj map[string]any
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
|
||||||
|
pid, _ := proj["id"].(string)
|
||||||
|
|
||||||
|
ticketBody, _ := json.Marshal(map[string]string{"title": "Work"})
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
var tk map[string]any
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &tk)
|
||||||
|
tid, _ := tk["id"].(string)
|
||||||
|
|
||||||
|
day := time.Date(2026, 10, 18, 14, 0, 0, 0, time.Local)
|
||||||
|
_, err = srv.Times.Create(ticket.TimeEntry{
|
||||||
|
TicketID: tid,
|
||||||
|
ProjectID: pid,
|
||||||
|
Start: day,
|
||||||
|
End: day.Add(45 * time.Minute),
|
||||||
|
Duration: 45 * time.Minute,
|
||||||
|
Source: ticket.SourceManual,
|
||||||
|
Note: "filtered",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
other := time.Date(2026, 9, 1, 10, 0, 0, 0, time.Local)
|
||||||
|
_, err = srv.Times.Create(ticket.TimeEntry{
|
||||||
|
TicketID: tid,
|
||||||
|
ProjectID: pid,
|
||||||
|
Start: other,
|
||||||
|
End: other.Add(time.Hour),
|
||||||
|
Duration: time.Hour,
|
||||||
|
Source: ticket.SourceManual,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := "/api/v1/projects/" + pid + "/report?groupBy=ticket&from=2026-10-18&to=2026-10-18&ticketId=" + tid
|
||||||
|
req = httptest.NewRequest(http.MethodGet, url, nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("report %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
EntryCount int `json:"entryCount"`
|
||||||
|
TotalDurationNs int64 `json:"totalDurationNs"`
|
||||||
|
Entries []ticket.TimeEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.EntryCount != 1 || len(out.Entries) != 1 {
|
||||||
|
t.Fatalf("want 1 entry, got count=%d entries=%d", out.EntryCount, len(out.Entries))
|
||||||
|
}
|
||||||
|
if out.TotalDurationNs != int64(45*time.Minute) {
|
||||||
|
t.Fatalf("total=%d", out.TotalDurationNs)
|
||||||
|
}
|
||||||
|
if out.Entries[0].Note != "filtered" {
|
||||||
|
t.Fatalf("note=%q", out.Entries[0].Note)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package ticket
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -155,3 +156,62 @@ func TotalDuration(entries []TimeEntry) time.Duration {
|
|||||||
}
|
}
|
||||||
return sum
|
return sum
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TimeFilter selects which time entries belong in a report.
|
||||||
|
// From/To are inclusive bounds on the entry's work instant (Start, else End).
|
||||||
|
// Zero times mean no bound on that side.
|
||||||
|
type TimeFilter struct {
|
||||||
|
From time.Time
|
||||||
|
To time.Time
|
||||||
|
TicketID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterEntries returns entries matching f. The input slice is not modified.
|
||||||
|
func FilterEntries(entries []TimeEntry, f TimeFilter) []TimeEntry {
|
||||||
|
if f.From.IsZero() && f.To.IsZero() && f.TicketID == "" {
|
||||||
|
out := make([]TimeEntry, len(entries))
|
||||||
|
copy(out, entries)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out := make([]TimeEntry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if f.TicketID != "" && e.TicketID != f.TicketID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
when := e.Start
|
||||||
|
if when.IsZero() {
|
||||||
|
when = e.End
|
||||||
|
}
|
||||||
|
if !f.From.IsZero() && (when.IsZero() || when.Before(f.From)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !f.To.IsZero() && (when.IsZero() || when.After(f.To)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseReportDay parses YYYY-MM-DD as local midnight. Empty returns zero time.
|
||||||
|
func ParseReportDay(s string) (time.Time, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("invalid date %q", s)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndOfReportDay returns the last instant of the calendar day of t in local time.
|
||||||
|
func EndOfReportDay(t time.Time) time.Time {
|
||||||
|
if t.IsZero() {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
local := t.In(time.Local)
|
||||||
|
y, m, d := local.Date()
|
||||||
|
return time.Date(y, m, d, 23, 59, 59, 999999999, time.Local)
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,3 +70,48 @@ func TestAggregateByDay(t *testing.T) {
|
|||||||
t.Fatalf("older day dur=%v", rows[1].Duration)
|
t.Fatalf("older day dur=%v", rows[1].Duration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFilterEntries(t *testing.T) {
|
||||||
|
d1 := time.Date(2026, 10, 18, 10, 0, 0, 0, time.Local)
|
||||||
|
d2 := time.Date(2026, 10, 20, 11, 0, 0, 0, time.Local)
|
||||||
|
entries := []ticket.TimeEntry{
|
||||||
|
{ID: "1", TicketID: "a", Start: d1, Duration: time.Hour},
|
||||||
|
{ID: "2", TicketID: "b", Start: d2, Duration: 30 * time.Minute},
|
||||||
|
{ID: "3", TicketID: "a", Start: d2, Duration: 15 * time.Minute},
|
||||||
|
}
|
||||||
|
|
||||||
|
byTicket := ticket.FilterEntries(entries, ticket.TimeFilter{TicketID: "a"})
|
||||||
|
if len(byTicket) != 2 {
|
||||||
|
t.Fatalf("ticket filter=%d", len(byTicket))
|
||||||
|
}
|
||||||
|
|
||||||
|
from := time.Date(2026, 10, 19, 0, 0, 0, 0, time.Local)
|
||||||
|
to := ticket.EndOfReportDay(time.Date(2026, 10, 20, 0, 0, 0, 0, time.Local))
|
||||||
|
byRange := ticket.FilterEntries(entries, ticket.TimeFilter{From: from, To: to})
|
||||||
|
if len(byRange) != 2 {
|
||||||
|
t.Fatalf("range filter=%d", len(byRange))
|
||||||
|
}
|
||||||
|
|
||||||
|
both := ticket.FilterEntries(entries, ticket.TimeFilter{From: from, To: to, TicketID: "a"})
|
||||||
|
if len(both) != 1 || both[0].ID != "3" {
|
||||||
|
t.Fatalf("combined=%+v", both)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseReportDay(t *testing.T) {
|
||||||
|
got, err := ticket.ParseReportDay("2026-10-18")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
y, m, d := got.Date()
|
||||||
|
if y != 2026 || m != 10 || d != 18 || got.Hour() != 0 {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
empty, err := ticket.ParseReportDay("")
|
||||||
|
if err != nil || !empty.IsZero() {
|
||||||
|
t.Fatalf("empty: %v %v", empty, err)
|
||||||
|
}
|
||||||
|
if _, err := ticket.ParseReportDay("nope"); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+106
-6
@@ -23,6 +23,9 @@
|
|||||||
let msg = $state('')
|
let msg = $state('')
|
||||||
let report = $state(null)
|
let report = $state(null)
|
||||||
let reportGroupBy = $state('ticket')
|
let reportGroupBy = $state('ticket')
|
||||||
|
let reportFrom = $state('')
|
||||||
|
let reportTo = $state('')
|
||||||
|
let reportTicketId = $state('')
|
||||||
let dash = $state({ projects: [], openTickets: [] })
|
let dash = $state({ projects: [], openTickets: [] })
|
||||||
let tokens = $state([])
|
let tokens = $state([])
|
||||||
let newTokenSecret = $state('')
|
let newTokenSecret = $state('')
|
||||||
@@ -180,7 +183,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveTimeEntry() {
|
async function saveTimeEntry() {
|
||||||
if (!selected || !editingTimeId) return
|
if (!projectId || !editingTimeId) return
|
||||||
if (!editTimeStart || !editTimeEnd) {
|
if (!editTimeStart || !editTimeEnd) {
|
||||||
error = 'Start and end are required'
|
error = 'Start and end are required'
|
||||||
return
|
return
|
||||||
@@ -197,7 +200,8 @@
|
|||||||
end: end.toISOString(),
|
end: end.toISOString(),
|
||||||
})
|
})
|
||||||
cancelEditTime()
|
cancelEditTime()
|
||||||
await refreshTicketTime()
|
if (projectView === 'report') await loadReport()
|
||||||
|
else await refreshTicketTime()
|
||||||
msg = 'Time updated'
|
msg = 'Time updated'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,10 +209,25 @@
|
|||||||
if (!confirm('Delete time entry?')) return
|
if (!confirm('Delete time entry?')) return
|
||||||
await api.deleteTime(projectId, eid)
|
await api.deleteTime(projectId, eid)
|
||||||
if (editingTimeId === eid) cancelEditTime()
|
if (editingTimeId === eid) cancelEditTime()
|
||||||
await refreshTicketTime()
|
if (projectView === 'report') await loadReport()
|
||||||
|
else await refreshTicketTime()
|
||||||
msg = 'Time deleted'
|
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() {
|
async function doLogin() {
|
||||||
error = ''
|
error = ''
|
||||||
try {
|
try {
|
||||||
@@ -414,11 +433,15 @@
|
|||||||
await refreshTicketTime()
|
await refreshTicketTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadReport() {
|
||||||
|
report = await api.report(projectId, reportQuery())
|
||||||
|
}
|
||||||
|
|
||||||
async function showReport() {
|
async function showReport() {
|
||||||
topTab = 'projects'
|
topTab = 'projects'
|
||||||
projectView = 'report'
|
projectView = 'report'
|
||||||
error = ''
|
error = ''
|
||||||
report = await api.report(projectId, reportGroupBy)
|
await loadReport()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openReport() {
|
async function openReport() {
|
||||||
@@ -428,7 +451,27 @@
|
|||||||
|
|
||||||
async function changeReportGroup(g) {
|
async function changeReportGroup(g) {
|
||||||
reportGroupBy = g
|
reportGroupBy = g
|
||||||
report = await api.report(projectId, reportGroupBy)
|
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() {
|
async function showProjectSettings() {
|
||||||
@@ -826,7 +869,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{:else if topTab === 'projects' && projectView === 'report'}
|
{:else if topTab === 'projects' && projectView === 'report'}
|
||||||
<section class="panel stack">
|
<section class="panel stack">
|
||||||
<div class="row" style="justify-content: space-between;">
|
<div class="row" style="justify-content: space-between; flex-wrap: wrap; gap: 0.75rem;">
|
||||||
<h2>Report — {projectName}</h2>
|
<h2>Report — {projectName}</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{#each [['ticket', 'Ticket'], ['status', 'Status'], ['day', 'Day']] as [g, label]}
|
{#each [['ticket', 'Ticket'], ['status', 'Status'], ['day', 'Day']] as [g, label]}
|
||||||
@@ -834,8 +877,27 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{#if report}
|
||||||
<p class="muted">{report.entryCount} entries · total {formatDuration(report.totalDurationNs)}</p>
|
<p class="muted">{report.entryCount} entries · total {formatDuration(report.totalDurationNs)}</p>
|
||||||
|
<h3>Summary</h3>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Group</th><th>Duration</th></tr></thead>
|
<thead><tr><th>Group</th><th>Duration</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -846,6 +908,44 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{:else if topTab === 'projects' && projectView === 'settings'}
|
{:else if topTab === 'projects' && projectView === 'settings'}
|
||||||
|
|||||||
+8
-2
@@ -71,8 +71,14 @@ export const api = {
|
|||||||
body: '{}',
|
body: '{}',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
report: (projectId, groupBy = 'ticket') =>
|
report: (projectId, { groupBy = 'ticket', from = '', to = '', ticketId = '' } = {}) => {
|
||||||
request(`/projects/${projectId}/report?groupBy=${encodeURIComponent(groupBy)}`),
|
const q = new URLSearchParams()
|
||||||
|
q.set('groupBy', groupBy)
|
||||||
|
if (from) q.set('from', from)
|
||||||
|
if (to) q.set('to', to)
|
||||||
|
if (ticketId) q.set('ticketId', ticketId)
|
||||||
|
return request(`/projects/${projectId}/report?${q}`)
|
||||||
|
},
|
||||||
listTime: (projectId) => request(`/projects/${projectId}/time`),
|
listTime: (projectId) => request(`/projects/${projectId}/time`),
|
||||||
createTime: (projectId, body) =>
|
createTime: (projectId, body) =>
|
||||||
request(`/projects/${projectId}/time`, { method: 'POST', body: JSON.stringify(body) }),
|
request(`/projects/${projectId}/time`, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
|||||||
Reference in New Issue
Block a user