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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Kanban</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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
|
// GET /api/archive
|
||||||
func handleListArchive(db *DB) http.HandlerFunc {
|
func handleListArchive(db *DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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/cards/{id}/history", Handler: handleCardHistory(db)},
|
||||||
{Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)},
|
{Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)},
|
||||||
{Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(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: "GET", Path: "/api/archive", Handler: handleListArchive(db)},
|
||||||
{Method: "POST", Path: "/api/cards/{id}/archive", Handler: handleArchiveCard(db)},
|
{Method: "POST", Path: "/api/cards/{id}/archive", Handler: handleArchiveCard(db)},
|
||||||
{Method: "POST", Path: "/api/cards/{id}/unarchive", Handler: handleUnarchiveCard(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 { CardEditPanel } from "./components/CardEditPanel";
|
||||||
import { ChatPanel } from "./components/ChatPanel";
|
import { ChatPanel } from "./components/ChatPanel";
|
||||||
import { CalendarView } from "./components/CalendarView";
|
import { CalendarView } from "./components/CalendarView";
|
||||||
|
import { DailyReportView } from "./components/DailyReport";
|
||||||
import { Dashboard } from "./components/Dashboard";
|
import { Dashboard } from "./components/Dashboard";
|
||||||
import { HistoryModal } from "./components/HistoryModal";
|
import { HistoryModal } from "./components/HistoryModal";
|
||||||
import { KanbanCard } from "./components/KanbanCard";
|
import { KanbanCard } from "./components/KanbanCard";
|
||||||
@@ -720,6 +721,22 @@ export function App() {
|
|||||||
window.setTimeout(() => setHighlightCardId(null), 3000);
|
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) => {
|
const handleSetCardDeadline = useCallback(async (id: string, deadline: string | null) => {
|
||||||
setBoard((prev) => {
|
setBoard((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
@@ -1324,7 +1341,7 @@ export function App() {
|
|||||||
</Box>
|
</Box>
|
||||||
) : activeTab === "calendar" ? (
|
) : activeTab === "calendar" ? (
|
||||||
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
<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" });
|
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> {
|
export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> {
|
||||||
return fetchJSON(`/cards/${id}/move`, {
|
return fetchJSON(`/cards/${id}/move`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -19,15 +19,17 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
import type { Card, Metrics, User } from "../types";
|
import type { Card, Metrics, User } from "../types";
|
||||||
|
|
||||||
|
// Hace clickable el numero del dia para abrir el reporte diario (issue 0093).
|
||||||
interface Props {
|
interface Props {
|
||||||
users: User[];
|
users: User[];
|
||||||
cards: Card[];
|
cards: Card[];
|
||||||
onJumpToCard?: (cardId: string) => void;
|
onJumpToCard?: (cardId: string) => void;
|
||||||
|
onOpenDailyReport?: (date: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
|
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 [openDate, setOpenDate] = useState<string | null>(null);
|
||||||
const [month, setMonth] = useState<Date>(new Date());
|
const [month, setMonth] = useState<Date>(new Date());
|
||||||
const [assigneeId, setAssigneeId] = useState<string | null>(null);
|
const [assigneeId, setAssigneeId] = useState<string | null>(null);
|
||||||
@@ -199,9 +201,22 @@ export function CalendarView({ users, cards, onJumpToCard }: Props) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Text size="xs" fw={isToday ? 700 : 500} c={isToday ? "blue" : undefined}>
|
<UnstyledButton
|
||||||
{dayNum}
|
onClick={() => cell.date && onOpenDailyReport?.(cell.date as string)}
|
||||||
</Text>
|
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 && (
|
{stats.created > 0 && (
|
||||||
<Group gap={3} wrap="nowrap">
|
<Group gap={3} wrap="nowrap">
|
||||||
<IconPlus size={10} color="var(--mantine-color-blue-5)" />
|
<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