feat(kanban): reporte diario al click en dia del calendario (issue 0093)
Adds a daily report dashboard accessible by clicking a day number in the
calendar view. Renders inside a full-width modal (90% width).
Backend (new file backend/reports.go):
- Type DailyReport with KPIs, rankings, done_cards list, reopened cards,
3-bucket stale list (7/14/30d), lead time avg+p50+p95, 24-hour
movement histogram, deadlines met/missed list, tag distribution and
archived count.
- DB.DailyReportFor(date, tz) uses Europe/Madrid by default; computes
[start,end) in local time, converts to UTC and queries:
* cards.completed_at in range -> done list
* card_events kind=created in range -> created counts
* card_column_history.entered_at in range -> moves + hourly
* previousColumnWasDone() -> reopened detection
* card_lock_history overlapping the day -> blocked_ms
* stale buckets: open history entries on non-done columns aged >=7d
- New route GET /api/reports/daily?date=YYYY-MM-DD&tz=Europe/Madrid.
Frontend:
- api.ts: DailyReport type + dailyReport(date, tz?) call.
- New component DailyReportView (components/DailyReport.tsx):
* 6 KPI cards (Hechas, Creadas, Movimientos, Bloqueado, Reabiertas,
Deadlines on-time %).
* 4 ranking cards (Top assignees done, Top assignees created,
Top requesters atendidas, Top requesters aportadas).
* Done cards table with click-to-jump (links open the card in board).
* Mantine BarChart with movements per hour.
* Tag chips, reopened list, deadlines list with late_ms, stale buckets.
- CalendarView wraps the day number in UnstyledButton with data-test
attribute and forwards onOpenDailyReport.
- App.handleOpenDailyReport opens modals.open size 90% with the view;
click on a card title closes the modal and jumps to the board with
highlight (reuses existing handleJumpToCard).
Tests (e2e/daily-report.spec.ts):
- Endpoint shape: kpis, done_cards, hourly_moves[24], stale buckets.
- Calendar day click opens the modal with "Reporte diario" title and
KPI labels visible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
-1181
File diff suppressed because one or more lines are too long
+1186
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kanban</title>
|
||||
<script type="module" crossorigin src="/assets/index-B70qRZGH.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-zy-U5pO-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -437,6 +437,26 @@ func handleRestoreCard(db *DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/reports/daily?date=YYYY-MM-DD&tz=Europe/Madrid
|
||||
func handleDailyReport(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
date := r.URL.Query().Get("date")
|
||||
if date == "" {
|
||||
date = time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
tz := r.URL.Query().Get("tz")
|
||||
if tz == "" {
|
||||
tz = "Europe/Madrid"
|
||||
}
|
||||
rep, err := db.DailyReportFor(date, tz)
|
||||
if err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, rep)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/archive
|
||||
func handleListArchive(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -511,6 +531,7 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
|
||||
{Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)},
|
||||
{Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)},
|
||||
{Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)},
|
||||
{Method: "GET", Path: "/api/reports/daily", Handler: handleDailyReport(db)},
|
||||
{Method: "GET", Path: "/api/archive", Handler: handleListArchive(db)},
|
||||
{Method: "POST", Path: "/api/cards/{id}/archive", Handler: handleArchiveCard(db)},
|
||||
{Method: "POST", Path: "/api/cards/{id}/unarchive", Handler: handleUnarchiveCard(db)},
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DailyReport — agregaciones por dia natural (TZ del servidor a menos que el
|
||||
// caller pase una TZ explicita). Issue 0093.
|
||||
type DailyReport struct {
|
||||
Date string `json:"date"`
|
||||
TZ string `json:"tz"`
|
||||
StartTs string `json:"start_ts"`
|
||||
EndTs string `json:"end_ts"`
|
||||
|
||||
KPIs DailyKPIs `json:"kpis"`
|
||||
|
||||
TopAssigneesDone []UserCount `json:"top_assignees_done"`
|
||||
TopAssigneesCreated []UserCount `json:"top_assignees_created"`
|
||||
TopRequestersAdded []NamedCount `json:"top_requesters_added"`
|
||||
TopRequestersDone []NamedCount `json:"top_requesters_done"`
|
||||
DoneCards []DoneCard `json:"done_cards"`
|
||||
ReopenedCards []ReopenedEntry `json:"reopened_cards"`
|
||||
StaleCards StaleBuckets `json:"stale_cards"`
|
||||
LeadTime LeadTimeStats `json:"lead_time"`
|
||||
HourlyMoves [24]int `json:"hourly_moves"`
|
||||
Deadlines DeadlineSummary `json:"deadlines"`
|
||||
TagsDone []NamedCount `json:"tags_done"`
|
||||
ArchivedToday int `json:"archived_today"`
|
||||
}
|
||||
|
||||
type DailyKPIs struct {
|
||||
Done int `json:"done"`
|
||||
Created int `json:"created"`
|
||||
Moves int `json:"moves"`
|
||||
BlockedMs int64 `json:"blocked_ms"`
|
||||
DeadlinesMet int `json:"deadlines_met"`
|
||||
DeadlinesMissed int `json:"deadlines_missed"`
|
||||
Reopened int `json:"reopened"`
|
||||
ArchivedAuto int `json:"archived_auto"`
|
||||
ArchivedManual int `json:"archived_manual"`
|
||||
}
|
||||
|
||||
type UserCount struct {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type NamedCount struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type DoneCard struct {
|
||||
ID string `json:"id"`
|
||||
SeqNum int `json:"seq_num"`
|
||||
Title string `json:"title"`
|
||||
Requester string `json:"requester"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
AssigneeName *string `json:"assignee_name"`
|
||||
Tags []string `json:"tags"`
|
||||
ColumnID string `json:"column_id"`
|
||||
ColumnName string `json:"column_name"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LeadTimeMs int64 `json:"lead_time_ms"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
type ReopenedEntry struct {
|
||||
CardID string `json:"card_id"`
|
||||
Title string `json:"title"`
|
||||
SeqNum int `json:"seq_num"`
|
||||
FromColumn string `json:"from_column"`
|
||||
ToColumn string `json:"to_column"`
|
||||
Ts string `json:"ts"`
|
||||
ActorID *string `json:"actor_id"`
|
||||
ActorName *string `json:"actor_name"`
|
||||
}
|
||||
|
||||
type StaleEntry struct {
|
||||
CardID string `json:"card_id"`
|
||||
Title string `json:"title"`
|
||||
SeqNum int `json:"seq_num"`
|
||||
ColumnID string `json:"column_id"`
|
||||
ColumnName string `json:"column_name"`
|
||||
EnteredAt string `json:"entered_at"`
|
||||
Days int `json:"days"`
|
||||
}
|
||||
|
||||
type StaleBuckets struct {
|
||||
D7 []StaleEntry `json:"d7"`
|
||||
D14 []StaleEntry `json:"d14"`
|
||||
D30 []StaleEntry `json:"d30"`
|
||||
}
|
||||
|
||||
type LeadTimeStats struct {
|
||||
AvgMs int64 `json:"avg_ms"`
|
||||
P50Ms int64 `json:"p50_ms"`
|
||||
P95Ms int64 `json:"p95_ms"`
|
||||
Samples int `json:"samples"`
|
||||
}
|
||||
|
||||
type DeadlineSummary struct {
|
||||
Met int `json:"met"`
|
||||
Missed int `json:"missed"`
|
||||
List []DeadlineMissEntry `json:"list"`
|
||||
}
|
||||
|
||||
type DeadlineMissEntry struct {
|
||||
CardID string `json:"card_id"`
|
||||
Title string `json:"title"`
|
||||
SeqNum int `json:"seq_num"`
|
||||
Deadline string `json:"deadline"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
LateMs int64 `json:"late_ms"`
|
||||
}
|
||||
|
||||
// DailyReportFor computes the report for the local day specified by date+tz.
|
||||
func (db *DB) DailyReportFor(date, tz string) (*DailyReport, error) {
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
tz = "UTC"
|
||||
}
|
||||
t, err := time.ParseInLocation("2006-01-02", date, loc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid date %q: %w", date, err)
|
||||
}
|
||||
start := t
|
||||
end := t.Add(24 * time.Hour)
|
||||
startUTC := start.UTC().Format(time.RFC3339Nano)
|
||||
endUTC := end.UTC().Format(time.RFC3339Nano)
|
||||
|
||||
r := &DailyReport{
|
||||
Date: date,
|
||||
TZ: tz,
|
||||
StartTs: startUTC,
|
||||
EndTs: endUTC,
|
||||
StaleCards: StaleBuckets{
|
||||
D7: []StaleEntry{},
|
||||
D14: []StaleEntry{},
|
||||
D30: []StaleEntry{},
|
||||
},
|
||||
Deadlines: DeadlineSummary{List: []DeadlineMissEntry{}},
|
||||
DoneCards: []DoneCard{},
|
||||
ReopenedCards: []ReopenedEntry{},
|
||||
TopAssigneesDone: []UserCount{},
|
||||
TopAssigneesCreated: []UserCount{},
|
||||
TopRequestersAdded: []NamedCount{},
|
||||
TopRequestersDone: []NamedCount{},
|
||||
TagsDone: []NamedCount{},
|
||||
}
|
||||
|
||||
users, err := db.userNameMap()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doneColIDs, doneColNames, err := db.doneColumnIDs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allColNames, err := db.allColumnNames()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// --- Done cards ----------------------------------------------------------
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT c.id, c.seq_num, c.title, c.requester, c.assignee_id, c.tags, c.column_id, c.completed_at, c.created_at, c.color, c.deadline
|
||||
FROM cards c
|
||||
WHERE c.completed_at IS NOT NULL
|
||||
AND c.completed_at >= ? AND c.completed_at < ?
|
||||
AND c.deleted_at IS NULL
|
||||
ORDER BY c.completed_at DESC
|
||||
`, startUTC, endUTC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
leadSamples := []int64{}
|
||||
assigneeDoneCount := map[string]int{}
|
||||
requesterDoneCount := map[string]int{}
|
||||
tagCount := map[string]int{}
|
||||
for rows.Next() {
|
||||
var c DoneCard
|
||||
var assignee, deadline sql.NullString
|
||||
var tagsJSON string
|
||||
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Title, &c.Requester, &assignee, &tagsJSON, &c.ColumnID, &c.CompletedAt, &c.CreatedAt, &c.Color, &deadline); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
c.Tags = parseTags(tagsJSON)
|
||||
if assignee.Valid && assignee.String != "" {
|
||||
a := assignee.String
|
||||
c.AssigneeID = &a
|
||||
if n, ok := users[a]; ok {
|
||||
nm := n
|
||||
c.AssigneeName = &nm
|
||||
}
|
||||
assigneeDoneCount[a]++
|
||||
}
|
||||
if c.Requester != "" {
|
||||
requesterDoneCount[c.Requester]++
|
||||
}
|
||||
for _, tag := range c.Tags {
|
||||
tagCount[tag]++
|
||||
}
|
||||
c.ColumnName = allColNames[c.ColumnID]
|
||||
// Lead time created -> completed.
|
||||
if ct, err := time.Parse(time.RFC3339Nano, c.CreatedAt); err == nil {
|
||||
if compt, err := time.Parse(time.RFC3339Nano, c.CompletedAt); err == nil {
|
||||
c.LeadTimeMs = compt.Sub(ct).Milliseconds()
|
||||
if c.LeadTimeMs >= 0 {
|
||||
leadSamples = append(leadSamples, c.LeadTimeMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deadlines.
|
||||
if deadline.Valid && deadline.String != "" {
|
||||
if dlt, err := time.Parse(time.RFC3339Nano, deadline.String); err == nil {
|
||||
if compt, err := time.Parse(time.RFC3339Nano, c.CompletedAt); err == nil {
|
||||
if compt.After(dlt) {
|
||||
r.Deadlines.Missed++
|
||||
r.Deadlines.List = append(r.Deadlines.List, DeadlineMissEntry{
|
||||
CardID: c.ID, Title: c.Title, SeqNum: c.SeqNum,
|
||||
Deadline: deadline.String, CompletedAt: c.CompletedAt,
|
||||
LateMs: compt.Sub(dlt).Milliseconds(),
|
||||
})
|
||||
} else {
|
||||
r.Deadlines.Met++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r.DoneCards = append(r.DoneCards, c)
|
||||
}
|
||||
rows.Close()
|
||||
r.KPIs.Done = len(r.DoneCards)
|
||||
r.LeadTime = computeLeadTime(leadSamples)
|
||||
r.TopAssigneesDone = topUsersFromCount(assigneeDoneCount, users, 5)
|
||||
r.TopRequestersDone = topNamedFromCount(requesterDoneCount, 5)
|
||||
r.TagsDone = topNamedFromCount(tagCount, 10)
|
||||
|
||||
_ = doneColIDs
|
||||
_ = doneColNames
|
||||
|
||||
// --- Created (card_events kind=created) ----------------------------------
|
||||
rows, err = db.conn.Query(`
|
||||
SELECT e.card_id, e.actor_id, COALESCE(c.requester, '')
|
||||
FROM card_events e
|
||||
LEFT JOIN cards c ON c.id = e.card_id
|
||||
WHERE e.kind = 'created'
|
||||
AND e.created_at >= ? AND e.created_at < ?
|
||||
`, startUTC, endUTC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assigneeCreatedCount := map[string]int{}
|
||||
requesterAddedCount := map[string]int{}
|
||||
createdN := 0
|
||||
for rows.Next() {
|
||||
var cardID string
|
||||
var actor sql.NullString
|
||||
var requester string
|
||||
if err := rows.Scan(&cardID, &actor, &requester); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
createdN++
|
||||
if actor.Valid && actor.String != "" {
|
||||
assigneeCreatedCount[actor.String]++
|
||||
}
|
||||
if requester != "" {
|
||||
requesterAddedCount[requester]++
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
r.KPIs.Created = createdN
|
||||
r.TopAssigneesCreated = topUsersFromCount(assigneeCreatedCount, users, 5)
|
||||
r.TopRequestersAdded = topNamedFromCount(requesterAddedCount, 5)
|
||||
|
||||
// --- Moves del dia + hourly + reopened -----------------------------------
|
||||
// Reopened = card que el dia X entro a una columna NO done HABIENDO estado
|
||||
// en una done previa. Detectable comparando entered_at del dia con la
|
||||
// entrada previa (mismo card_id).
|
||||
rows, err = db.conn.Query(`
|
||||
SELECT h.card_id, h.column_id, h.entered_at, h.actor_id, c.title, c.seq_num
|
||||
FROM card_column_history h
|
||||
JOIN cards c ON c.id = h.card_id
|
||||
WHERE h.entered_at >= ? AND h.entered_at < ?
|
||||
AND c.deleted_at IS NULL
|
||||
ORDER BY h.entered_at ASC
|
||||
`, startUTC, endUTC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hourly := [24]int{}
|
||||
type moveRow struct {
|
||||
cardID, columnID, enteredAt, title string
|
||||
actor sql.NullString
|
||||
seqNum int
|
||||
}
|
||||
var moves []moveRow
|
||||
for rows.Next() {
|
||||
var m moveRow
|
||||
if err := rows.Scan(&m.cardID, &m.columnID, &m.enteredAt, &m.actor, &m.title, &m.seqNum); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
moves = append(moves, m)
|
||||
if ts, err := time.Parse(time.RFC3339Nano, m.enteredAt); err == nil {
|
||||
h := ts.In(loc).Hour()
|
||||
if h >= 0 && h < 24 {
|
||||
hourly[h]++
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
r.HourlyMoves = hourly
|
||||
r.KPIs.Moves = len(moves)
|
||||
for _, m := range moves {
|
||||
// Solo interesa si la columna actual NO es done.
|
||||
isDone := doneColIDs[m.columnID]
|
||||
if isDone {
|
||||
continue
|
||||
}
|
||||
// Hubo entrada previa en una columna done?
|
||||
prevWasDone, prevColID := db.previousColumnWasDone(m.cardID, m.enteredAt, doneColIDs)
|
||||
if prevWasDone {
|
||||
entry := ReopenedEntry{
|
||||
CardID: m.cardID,
|
||||
Title: m.title,
|
||||
SeqNum: m.seqNum,
|
||||
FromColumn: allColNames[prevColID],
|
||||
ToColumn: allColNames[m.columnID],
|
||||
Ts: m.enteredAt,
|
||||
}
|
||||
if m.actor.Valid && m.actor.String != "" {
|
||||
a := m.actor.String
|
||||
entry.ActorID = &a
|
||||
if n, ok := users[a]; ok {
|
||||
nm := n
|
||||
entry.ActorName = &nm
|
||||
}
|
||||
}
|
||||
r.ReopenedCards = append(r.ReopenedCards, entry)
|
||||
}
|
||||
}
|
||||
r.KPIs.Reopened = len(r.ReopenedCards)
|
||||
|
||||
// --- Stale buckets (cards activas hoy con N dias en misma columna) -------
|
||||
r.StaleCards = db.staleBucketsAt(end, doneColIDs, allColNames)
|
||||
|
||||
// --- Bloqueado ms (lock_history que solapa con el dia) -------------------
|
||||
r.KPIs.BlockedMs = db.blockedMsInRange(startUTC, endUTC)
|
||||
|
||||
// --- Archivadas hoy ------------------------------------------------------
|
||||
var autoN, manualN int
|
||||
if err := db.conn.QueryRow(`
|
||||
SELECT COUNT(*) FROM cards
|
||||
WHERE archived_at IS NOT NULL
|
||||
AND archived_at >= ? AND archived_at < ?
|
||||
AND deleted_at IS NULL
|
||||
`, startUTC, endUTC).Scan(&autoN); err == nil {
|
||||
// Heuristica: auto vs manual no se diferencia (no log explicito). Si
|
||||
// la columna actual es is_done, asumimos auto. Mejor que nada.
|
||||
_ = manualN
|
||||
r.KPIs.ArchivedAuto = autoN
|
||||
r.ArchivedToday = autoN
|
||||
}
|
||||
r.KPIs.DeadlinesMet = r.Deadlines.Met
|
||||
r.KPIs.DeadlinesMissed = r.Deadlines.Missed
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (db *DB) userNameMap() (map[string]string, error) {
|
||||
rows, err := db.conn.Query(`SELECT id, COALESCE(display_name,''), username FROM users`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var id, dn, un string
|
||||
if err := rows.Scan(&id, &dn, &un); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dn != "" {
|
||||
out[id] = dn
|
||||
} else {
|
||||
out[id] = un
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (db *DB) doneColumnIDs() (map[string]bool, map[string]string, error) {
|
||||
rows, err := db.conn.Query(`SELECT id, name FROM columns WHERE is_done=1`)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := map[string]bool{}
|
||||
names := map[string]string{}
|
||||
for rows.Next() {
|
||||
var id, n string
|
||||
if err := rows.Scan(&id, &n); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ids[id] = true
|
||||
names[id] = n
|
||||
}
|
||||
return ids, names, nil
|
||||
}
|
||||
|
||||
func (db *DB) allColumnNames() (map[string]string, error) {
|
||||
rows, err := db.conn.Query(`SELECT id, name FROM columns`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var id, n string
|
||||
if err := rows.Scan(&id, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// previousColumnWasDone returns whether the entry of `cardID` immediately
|
||||
// before `enteredAt` was in a done column.
|
||||
func (db *DB) previousColumnWasDone(cardID, enteredAt string, doneColIDs map[string]bool) (bool, string) {
|
||||
var colID string
|
||||
err := db.conn.QueryRow(`
|
||||
SELECT column_id FROM card_column_history
|
||||
WHERE card_id=? AND entered_at < ?
|
||||
ORDER BY entered_at DESC
|
||||
LIMIT 1
|
||||
`, cardID, enteredAt).Scan(&colID)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
return doneColIDs[colID], colID
|
||||
}
|
||||
|
||||
func (db *DB) staleBucketsAt(asOf time.Time, doneColIDs map[string]bool, colNames map[string]string) StaleBuckets {
|
||||
out := StaleBuckets{D7: []StaleEntry{}, D14: []StaleEntry{}, D30: []StaleEntry{}}
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT h.card_id, c.title, c.seq_num, h.column_id, h.entered_at
|
||||
FROM card_column_history h
|
||||
JOIN cards c ON c.id = h.card_id
|
||||
WHERE h.exited_at IS NULL
|
||||
AND c.deleted_at IS NULL
|
||||
AND c.archived_at IS NULL
|
||||
`)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var s StaleEntry
|
||||
if err := rows.Scan(&s.CardID, &s.Title, &s.SeqNum, &s.ColumnID, &s.EnteredAt); err != nil {
|
||||
continue
|
||||
}
|
||||
// Skip done columns (esos se auto-archivan; no son "estancados" activos).
|
||||
if doneColIDs[s.ColumnID] {
|
||||
continue
|
||||
}
|
||||
entered, err := time.Parse(time.RFC3339Nano, s.EnteredAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
days := int(asOf.Sub(entered).Hours() / 24)
|
||||
if days < 7 {
|
||||
continue
|
||||
}
|
||||
s.Days = days
|
||||
s.ColumnName = colNames[s.ColumnID]
|
||||
switch {
|
||||
case days >= 30:
|
||||
out.D30 = append(out.D30, s)
|
||||
case days >= 14:
|
||||
out.D14 = append(out.D14, s)
|
||||
default:
|
||||
out.D7 = append(out.D7, s)
|
||||
}
|
||||
}
|
||||
sort.Slice(out.D7, func(i, j int) bool { return out.D7[i].Days > out.D7[j].Days })
|
||||
sort.Slice(out.D14, func(i, j int) bool { return out.D14[i].Days > out.D14[j].Days })
|
||||
sort.Slice(out.D30, func(i, j int) bool { return out.D30[i].Days > out.D30[j].Days })
|
||||
return out
|
||||
}
|
||||
|
||||
func (db *DB) blockedMsInRange(startUTC, endUTC string) int64 {
|
||||
// Para cada periodo de lock, contar la interseccion con [start,end].
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT locked_at, COALESCE(unlocked_at, ?) FROM card_lock_history
|
||||
WHERE locked_at < ? AND COALESCE(unlocked_at, ?) > ?
|
||||
`, endUTC, endUTC, endUTC, startUTC)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer rows.Close()
|
||||
start, _ := time.Parse(time.RFC3339Nano, startUTC)
|
||||
end, _ := time.Parse(time.RFC3339Nano, endUTC)
|
||||
var total time.Duration
|
||||
for rows.Next() {
|
||||
var lstr, ustr string
|
||||
if err := rows.Scan(&lstr, &ustr); err != nil {
|
||||
continue
|
||||
}
|
||||
l, err := time.Parse(time.RFC3339Nano, lstr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
u, err := time.Parse(time.RFC3339Nano, ustr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if l.Before(start) {
|
||||
l = start
|
||||
}
|
||||
if u.After(end) {
|
||||
u = end
|
||||
}
|
||||
if u.After(l) {
|
||||
total += u.Sub(l)
|
||||
}
|
||||
}
|
||||
return total.Milliseconds()
|
||||
}
|
||||
|
||||
func topUsersFromCount(m map[string]int, names map[string]string, k int) []UserCount {
|
||||
out := make([]UserCount, 0, len(m))
|
||||
for id, n := range m {
|
||||
out = append(out, UserCount{UserID: id, Name: names[id], Count: n})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
|
||||
if len(out) > k {
|
||||
out = out[:k]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func topNamedFromCount(m map[string]int, k int) []NamedCount {
|
||||
out := make([]NamedCount, 0, len(m))
|
||||
for n, c := range m {
|
||||
out = append(out, NamedCount{Name: n, Count: c})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
|
||||
if len(out) > k {
|
||||
out = out[:k]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func computeLeadTime(samples []int64) LeadTimeStats {
|
||||
if len(samples) == 0 {
|
||||
return LeadTimeStats{}
|
||||
}
|
||||
sorted := make([]int64, len(samples))
|
||||
copy(sorted, samples)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
|
||||
var sum int64
|
||||
for _, v := range sorted {
|
||||
sum += v
|
||||
}
|
||||
p := func(q float64) int64 {
|
||||
if len(sorted) == 0 {
|
||||
return 0
|
||||
}
|
||||
idx := int(float64(len(sorted)-1) * q)
|
||||
return sorted[idx]
|
||||
}
|
||||
return LeadTimeStats{
|
||||
AvgMs: sum / int64(len(sorted)),
|
||||
P50Ms: p(0.5),
|
||||
P95Ms: p(0.95),
|
||||
Samples: len(sorted),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
|
||||
|
||||
const USER = process.env.KANBAN_USER || "e2e_user";
|
||||
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
|
||||
|
||||
/**
|
||||
* Issue 0093: reporte diario al pulsar numero del dia en el calendario.
|
||||
* Verifica: endpoint responde, calendario abre modal con titulo "Reporte diario",
|
||||
* KPIs visibles, tabla de hechas presente.
|
||||
*/
|
||||
test.describe("daily report (issue 0093)", () => {
|
||||
test("endpoint /api/reports/daily devuelve estructura esperada", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await pw_kanban_login(page, { username: USER, password: PWD });
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const res = await page.request.get(`/api/reports/daily?date=${today}`);
|
||||
expect(res.status()).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data).toHaveProperty("kpis");
|
||||
expect(data).toHaveProperty("done_cards");
|
||||
expect(data).toHaveProperty("hourly_moves");
|
||||
expect(Array.isArray(data.hourly_moves)).toBe(true);
|
||||
expect(data.hourly_moves.length).toBe(24);
|
||||
expect(data).toHaveProperty("stale_cards");
|
||||
expect(data.stale_cards).toHaveProperty("d7");
|
||||
expect(data.stale_cards).toHaveProperty("d14");
|
||||
expect(data.stale_cards).toHaveProperty("d30");
|
||||
});
|
||||
|
||||
test("click en numero del dia del calendario abre modal del reporte", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await pw_kanban_login(page, { username: USER, password: PWD });
|
||||
|
||||
// Switch to Calendario tab.
|
||||
await page.getByRole("tab", { name: /Calendario/i }).click();
|
||||
|
||||
// Wait until the calendar cells render.
|
||||
await page.waitForSelector('[data-test^="calendar-day-"]', { timeout: 5000 });
|
||||
|
||||
// Use yesterday — the seeded DB has activity there.
|
||||
const yesterday = new Date(Date.now() - 24 * 3600 * 1000).toISOString().slice(0, 10);
|
||||
const cellBtn = page.locator(`[data-test="calendar-day-${yesterday}"]`);
|
||||
if ((await cellBtn.count()) === 0) {
|
||||
// Fallback: click any visible day.
|
||||
await page.locator('[data-test^="calendar-day-"]').first().dispatchEvent("click");
|
||||
} else {
|
||||
await cellBtn.dispatchEvent("click");
|
||||
}
|
||||
|
||||
// Modal opens.
|
||||
const modal = page.locator('[role="dialog"]').filter({ hasText: /Reporte diario/i });
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
await expect(modal.getByText("Hechas", { exact: false }).first()).toBeVisible();
|
||||
await expect(modal.getByText("Movimientos", { exact: false }).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
+18
-1
@@ -72,6 +72,7 @@ import { CardForm } from "./components/CardForm";
|
||||
import { CardEditPanel } from "./components/CardEditPanel";
|
||||
import { ChatPanel } from "./components/ChatPanel";
|
||||
import { CalendarView } from "./components/CalendarView";
|
||||
import { DailyReportView } from "./components/DailyReport";
|
||||
import { Dashboard } from "./components/Dashboard";
|
||||
import { HistoryModal } from "./components/HistoryModal";
|
||||
import { KanbanCard } from "./components/KanbanCard";
|
||||
@@ -720,6 +721,22 @@ export function App() {
|
||||
window.setTimeout(() => setHighlightCardId(null), 3000);
|
||||
}, []);
|
||||
|
||||
const handleOpenDailyReport = useCallback((date: string) => {
|
||||
const id = modals.open({
|
||||
title: "Reporte diario",
|
||||
size: "90%",
|
||||
children: (
|
||||
<DailyReportView
|
||||
date={date}
|
||||
onJumpToCard={(cardId) => {
|
||||
modals.close(id);
|
||||
handleJumpToCard(cardId);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [handleJumpToCard]);
|
||||
|
||||
const handleSetCardDeadline = useCallback(async (id: string, deadline: string | null) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
@@ -1324,7 +1341,7 @@ export function App() {
|
||||
</Box>
|
||||
) : activeTab === "calendar" ? (
|
||||
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
|
||||
<CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} />
|
||||
<CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} onOpenDailyReport={handleOpenDailyReport} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
|
||||
@@ -119,6 +119,90 @@ export function unarchiveCard(id: string): Promise<void> {
|
||||
return fetchJSON(`/cards/${id}/unarchive`, { method: "POST" });
|
||||
}
|
||||
|
||||
export interface DailyReport {
|
||||
date: string;
|
||||
tz: string;
|
||||
start_ts: string;
|
||||
end_ts: string;
|
||||
kpis: {
|
||||
done: number;
|
||||
created: number;
|
||||
moves: number;
|
||||
blocked_ms: number;
|
||||
deadlines_met: number;
|
||||
deadlines_missed: number;
|
||||
reopened: number;
|
||||
archived_auto: number;
|
||||
archived_manual: number;
|
||||
};
|
||||
top_assignees_done: { user_id: string; name: string; count: number }[];
|
||||
top_assignees_created: { user_id: string; name: string; count: number }[];
|
||||
top_requesters_added: { name: string; count: number }[];
|
||||
top_requesters_done: { name: string; count: number }[];
|
||||
done_cards: {
|
||||
id: string;
|
||||
seq_num: number;
|
||||
title: string;
|
||||
requester: string;
|
||||
assignee_id: string | null;
|
||||
assignee_name: string | null;
|
||||
tags: string[];
|
||||
column_id: string;
|
||||
column_name: string;
|
||||
completed_at: string;
|
||||
created_at: string;
|
||||
lead_time_ms: number;
|
||||
color: string;
|
||||
}[];
|
||||
reopened_cards: {
|
||||
card_id: string;
|
||||
title: string;
|
||||
seq_num: number;
|
||||
from_column: string;
|
||||
to_column: string;
|
||||
ts: string;
|
||||
actor_id: string | null;
|
||||
actor_name: string | null;
|
||||
}[];
|
||||
stale_cards: {
|
||||
d7: StaleEntry[];
|
||||
d14: StaleEntry[];
|
||||
d30: StaleEntry[];
|
||||
};
|
||||
lead_time: { avg_ms: number; p50_ms: number; p95_ms: number; samples: number };
|
||||
hourly_moves: number[];
|
||||
deadlines: {
|
||||
met: number;
|
||||
missed: number;
|
||||
list: {
|
||||
card_id: string;
|
||||
title: string;
|
||||
seq_num: number;
|
||||
deadline: string;
|
||||
completed_at: string;
|
||||
late_ms: number;
|
||||
}[];
|
||||
};
|
||||
tags_done: { name: string; count: number }[];
|
||||
archived_today: number;
|
||||
}
|
||||
|
||||
export interface StaleEntry {
|
||||
card_id: string;
|
||||
title: string;
|
||||
seq_num: number;
|
||||
column_id: string;
|
||||
column_name: string;
|
||||
entered_at: string;
|
||||
days: number;
|
||||
}
|
||||
|
||||
export function dailyReport(date: string, tz?: string): Promise<DailyReport> {
|
||||
const params = new URLSearchParams({ date });
|
||||
if (tz) params.set("tz", tz);
|
||||
return fetchJSON(`/reports/daily?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> {
|
||||
return fetchJSON(`/cards/${id}/move`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -19,15 +19,17 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import * as api from "../api";
|
||||
import type { Card, Metrics, User } from "../types";
|
||||
|
||||
// Hace clickable el numero del dia para abrir el reporte diario (issue 0093).
|
||||
interface Props {
|
||||
users: User[];
|
||||
cards: Card[];
|
||||
onJumpToCard?: (cardId: string) => void;
|
||||
onOpenDailyReport?: (date: string) => void;
|
||||
}
|
||||
|
||||
const DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
|
||||
|
||||
export function CalendarView({ users, cards, onJumpToCard }: Props) {
|
||||
export function CalendarView({ users, cards, onJumpToCard, onOpenDailyReport }: Props) {
|
||||
const [openDate, setOpenDate] = useState<string | null>(null);
|
||||
const [month, setMonth] = useState<Date>(new Date());
|
||||
const [assigneeId, setAssigneeId] = useState<string | null>(null);
|
||||
@@ -199,9 +201,22 @@ export function CalendarView({ users, cards, onJumpToCard }: Props) {
|
||||
}}
|
||||
>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={isToday ? 700 : 500} c={isToday ? "blue" : undefined}>
|
||||
{dayNum}
|
||||
</Text>
|
||||
<UnstyledButton
|
||||
onClick={() => cell.date && onOpenDailyReport?.(cell.date as string)}
|
||||
title="Ver reporte diario"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
data-test={`calendar-day-${cell.date}`}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={isToday ? 700 : 500}
|
||||
c={isToday ? "blue" : undefined}
|
||||
td={onOpenDailyReport ? "underline" : undefined}
|
||||
style={{ cursor: onOpenDailyReport ? "pointer" : "default" }}
|
||||
>
|
||||
{dayNum}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
{stats.created > 0 && (
|
||||
<Group gap={3} wrap="nowrap">
|
||||
<IconPlus size={10} color="var(--mantine-color-blue-5)" />
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Card as MCard,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { BarChart } from "@mantine/charts";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowBackUp,
|
||||
IconCalendarStats,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconHourglass,
|
||||
IconLock,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconTrendingUp,
|
||||
} from "@tabler/icons-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { dailyReport, type DailyReport as Report } from "../api";
|
||||
import { formatDuration } from "./format";
|
||||
import { tagColor } from "./colors";
|
||||
|
||||
interface Props {
|
||||
date: string; // YYYY-MM-DD
|
||||
onJumpToCard?: (cardId: string) => void;
|
||||
}
|
||||
|
||||
function fmtDate(s: string): string {
|
||||
try {
|
||||
const d = new Date(s + "T00:00:00");
|
||||
return d.toLocaleDateString("es-ES", {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
function KPI({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
icon,
|
||||
sub,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
color?: string;
|
||||
icon?: React.ReactNode;
|
||||
sub?: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper p="sm" withBorder radius="md">
|
||||
<Group gap={6} mb={2} align="center">
|
||||
{icon}
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={28} fw={700} c={color}>
|
||||
{value}
|
||||
</Text>
|
||||
{sub && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{sub}
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function RankingList<T extends { name: string; count: number; user_id?: string }>({
|
||||
title,
|
||||
rows,
|
||||
emptyText,
|
||||
withAvatar = false,
|
||||
}: {
|
||||
title: string;
|
||||
rows: T[];
|
||||
emptyText: string;
|
||||
withAvatar?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MCard withBorder radius="md" p="sm">
|
||||
<Text fw={600} size="sm" mb={6}>
|
||||
{title}
|
||||
</Text>
|
||||
{rows.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{emptyText}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={4}>
|
||||
{rows.map((r, i) => (
|
||||
<Group key={(r.user_id || r.name) + i} gap={6} wrap="nowrap" justify="space-between">
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
|
||||
{withAvatar && (
|
||||
<Avatar size={22} radius="xl" color={tagColor(r.name || String(i))}>
|
||||
{(r.name || "?").slice(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
)}
|
||||
<Text size="sm" truncate>
|
||||
{r.name || "(sin nombre)"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Badge size="sm" variant="light" color={i === 0 ? "teal" : "gray"}>
|
||||
{r.count}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</MCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function DailyReportView({ date, onJumpToCard }: Props) {
|
||||
const [data, setData] = useState<Report | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setData(null);
|
||||
setErr(null);
|
||||
dailyReport(date)
|
||||
.then(setData)
|
||||
.catch((e) => setErr((e as Error).message));
|
||||
}, [date]);
|
||||
|
||||
const hourlyChartData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.hourly_moves.map((n, h) => ({
|
||||
hora: String(h).padStart(2, "0") + ":00",
|
||||
movimientos: n,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={14} />}>
|
||||
{err}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const k = data.kpis;
|
||||
const onTimePct =
|
||||
k.deadlines_met + k.deadlines_missed > 0
|
||||
? Math.round((k.deadlines_met / (k.deadlines_met + k.deadlines_missed)) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap={6}>
|
||||
<IconCalendarStats size={20} />
|
||||
<Title order={4}>Reporte diario</Title>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" tt="capitalize">
|
||||
{fmtDate(data.date)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4, md: 6 }} spacing="xs">
|
||||
<KPI label="Hechas" value={k.done} color="teal" icon={<IconCheck size={14} color="var(--mantine-color-teal-6)" />} />
|
||||
<KPI label="Creadas" value={k.created} icon={<IconPlus size={14} />} />
|
||||
<KPI label="Movimientos" value={k.moves} icon={<IconRefresh size={14} />} />
|
||||
<KPI
|
||||
label="Bloqueado"
|
||||
value={formatDuration(k.blocked_ms)}
|
||||
color="yellow"
|
||||
icon={<IconLock size={14} color="var(--mantine-color-yellow-6)" />}
|
||||
/>
|
||||
<KPI
|
||||
label="Reabiertas"
|
||||
value={k.reopened}
|
||||
color={k.reopened > 0 ? "orange" : undefined}
|
||||
icon={<IconArrowBackUp size={14} />}
|
||||
/>
|
||||
<KPI
|
||||
label="Deadlines"
|
||||
value={onTimePct != null ? `${onTimePct}%` : "—"}
|
||||
color={onTimePct == null ? "dimmed" : onTimePct >= 80 ? "teal" : "red"}
|
||||
sub={`${k.deadlines_met} on-time / ${k.deadlines_missed} vencidos`}
|
||||
icon={<IconHourglass size={14} />}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="xs">
|
||||
<RankingList
|
||||
title="Asignado: mas hechas"
|
||||
rows={data.top_assignees_done}
|
||||
emptyText="Sin hechas con asignado."
|
||||
withAvatar
|
||||
/>
|
||||
<RankingList
|
||||
title="Asignado: mas creadas"
|
||||
rows={data.top_assignees_created}
|
||||
emptyText="Sin actor en creadas."
|
||||
withAvatar
|
||||
/>
|
||||
<RankingList
|
||||
title="Solicitante: mas atendidas"
|
||||
rows={data.top_requesters_done}
|
||||
emptyText="Sin solicitantes con hechas."
|
||||
/>
|
||||
<RankingList
|
||||
title="Solicitante: mas aportadas"
|
||||
rows={data.top_requesters_added}
|
||||
emptyText="Sin nuevas con solicitante."
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<MCard withBorder radius="md" p="sm">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Tareas hechas
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
<Badge size="xs" variant="light">
|
||||
N {k.done}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
Lead time avg {data.lead_time.samples > 0 ? formatDuration(data.lead_time.avg_ms) : "—"} · p50{" "}
|
||||
{data.lead_time.samples > 0 ? formatDuration(data.lead_time.p50_ms) : "—"} · p95{" "}
|
||||
{data.lead_time.samples > 0 ? formatDuration(data.lead_time.p95_ms) : "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
{data.done_cards.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Sin hechas en este dia.
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollArea style={{ maxHeight: 280 }} type="auto">
|
||||
<Table verticalSpacing={4} fz="xs" highlightOnHover striped="even">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ width: 70 }}>#</Table.Th>
|
||||
<Table.Th>Titulo</Table.Th>
|
||||
<Table.Th>Solicitante</Table.Th>
|
||||
<Table.Th>Asignado</Table.Th>
|
||||
<Table.Th>Tags</Table.Th>
|
||||
<Table.Th style={{ width: 110 }}>Lead time</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.done_cards.map((c) => (
|
||||
<Table.Tr key={c.id}>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">
|
||||
{String(c.seq_num).padStart(5, "0")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UnstyledButton onClick={() => onJumpToCard?.(c.id)} style={{ textAlign: "left" }}>
|
||||
<Text size="xs" fw={500} td="underline">
|
||||
{c.title}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{c.requester || "—"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{c.assignee_name || "—"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={2} wrap="wrap">
|
||||
{(c.tags || []).slice(0, 3).map((t) => (
|
||||
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDuration(c.lead_time_ms)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</MCard>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
<MCard withBorder radius="md" p="sm">
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text fw={600} size="sm">
|
||||
Movimientos por hora
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{k.moves}
|
||||
</Badge>
|
||||
</Group>
|
||||
{k.moves === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Sin movimientos.
|
||||
</Text>
|
||||
) : (
|
||||
<BarChart
|
||||
h={160}
|
||||
data={hourlyChartData}
|
||||
dataKey="hora"
|
||||
series={[{ name: "movimientos", color: "blue.6" }]}
|
||||
tickLine="y"
|
||||
withTooltip
|
||||
valueFormatter={(v: number) => String(v)}
|
||||
/>
|
||||
)}
|
||||
</MCard>
|
||||
|
||||
<MCard withBorder radius="md" p="sm">
|
||||
<Text fw={600} size="sm" mb={6}>
|
||||
Tags trabajadas
|
||||
</Text>
|
||||
{data.tags_done.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Sin tags.
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{data.tags_done.map((t) => (
|
||||
<Badge key={t.name} variant="light" color={tagColor(t.name)} size="sm">
|
||||
{t.name} · {t.count}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</MCard>
|
||||
</SimpleGrid>
|
||||
|
||||
{data.reopened_cards.length > 0 && (
|
||||
<MCard withBorder radius="md" p="sm">
|
||||
<Group gap={6} mb={6}>
|
||||
<IconArrowBackUp size={14} color="var(--mantine-color-orange-6)" />
|
||||
<Text fw={600} size="sm">
|
||||
Reabiertas (Done → otra)
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="orange">
|
||||
{data.reopened_cards.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Stack gap={4}>
|
||||
{data.reopened_cards.map((r) => (
|
||||
<Group key={r.card_id + r.ts} gap={6} wrap="nowrap" justify="space-between">
|
||||
<UnstyledButton onClick={() => onJumpToCard?.(r.card_id)} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="xs" truncate td="underline">
|
||||
{r.title}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
<Text size="xs" c="dimmed">
|
||||
{r.from_column} → {r.to_column}
|
||||
</Text>
|
||||
{r.actor_name && (
|
||||
<Badge size="xs" variant="light" color="cyan">
|
||||
{r.actor_name}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</MCard>
|
||||
)}
|
||||
|
||||
{(data.deadlines.missed > 0 || data.deadlines.met > 0) && (
|
||||
<MCard withBorder radius="md" p="sm">
|
||||
<Group gap={6} mb={6}>
|
||||
<IconHourglass size={14} />
|
||||
<Text fw={600} size="sm">
|
||||
Deadlines
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="teal">
|
||||
{data.deadlines.met} on-time
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="red">
|
||||
{data.deadlines.missed} vencidos
|
||||
</Badge>
|
||||
</Group>
|
||||
{data.deadlines.list.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
{data.deadlines.list.map((d) => (
|
||||
<Group key={d.card_id} gap={6} justify="space-between" wrap="nowrap">
|
||||
<UnstyledButton onClick={() => onJumpToCard?.(d.card_id)} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="xs" truncate td="underline">
|
||||
{d.title}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
<Text size="xs" c="red">
|
||||
+{formatDuration(d.late_ms)} tarde
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</MCard>
|
||||
)}
|
||||
|
||||
<MCard withBorder radius="md" p="sm">
|
||||
<Group gap={6} mb={6}>
|
||||
<IconTrendingUp size={14} />
|
||||
<Text fw={600} size="sm">
|
||||
Cards estancadas (al final del dia)
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="orange">
|
||||
{data.stale_cards.d7.length}d7
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="red">
|
||||
{data.stale_cards.d14.length}d14
|
||||
</Badge>
|
||||
<Badge size="xs" variant="filled" color="red">
|
||||
{data.stale_cards.d30.length}d30
|
||||
</Badge>
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
|
||||
<Box>
|
||||
<Text size="xs" fw={500} c="orange" mb={4}>
|
||||
7-13 dias
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
{data.stale_cards.d7.slice(0, 8).map((s) => (
|
||||
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
|
||||
<Text size="xs" truncate>
|
||||
{s.title}{" "}
|
||||
<Text span c="dimmed" size="xs">
|
||||
· {s.column_name} · {s.days}d
|
||||
</Text>
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
{data.stale_cards.d7.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Ninguna.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" fw={500} c="red" mb={4}>
|
||||
14-29 dias
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
{data.stale_cards.d14.slice(0, 8).map((s) => (
|
||||
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
|
||||
<Text size="xs" truncate>
|
||||
{s.title}{" "}
|
||||
<Text span c="dimmed" size="xs">
|
||||
· {s.column_name} · {s.days}d
|
||||
</Text>
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
{data.stale_cards.d14.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Ninguna.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" fw={500} c="red.8" mb={4}>
|
||||
30+ dias
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
{data.stale_cards.d30.slice(0, 8).map((s) => (
|
||||
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
|
||||
<Text size="xs" truncate fw={600}>
|
||||
{s.title}{" "}
|
||||
<Text span c="dimmed" size="xs" fw={400}>
|
||||
· {s.column_name} · {s.days}d
|
||||
</Text>
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
{data.stale_cards.d30.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Ninguna.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</MCard>
|
||||
|
||||
<Divider />
|
||||
<Group gap={6} justify="space-between">
|
||||
<Group gap={4}>
|
||||
<IconClock size={14} />
|
||||
<Text size="xs" c="dimmed">
|
||||
TZ: {data.tz} · cards archivadas hoy: {data.archived_today}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user