feat(kanban): mejoras historial card — DONE check + tiempo por columna
HistoryModal now receives the board's columns via prop to enrich history events with column metadata. Timeline: - Entries for column moves into a column with is_done=true now render with a green IconCheck bullet and the kind label "Hecho en columna" instead of the generic blue arrows / "Mueve a columna". Makes the card's "done" moments scannable at a glance. Footer (below the timeline): - Replaces the single Group-of-badges with a structured table showing one row per visited column: name, visits (entry count) and total time in column. DONE columns are flagged with a green check + bold. - Total locked time keeps the same source (total_locked_ms) but moved to a header line above the table to declutter. - Currently-active entry (exited_at=null) contributes now - entered_at to its row, so the table reflects live time. App.tsx passes columns from board state when opening the history modal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -781,9 +781,9 @@ export function App() {
|
||||
modals.open({
|
||||
title: card.title,
|
||||
size: "md",
|
||||
children: <HistoryModal card={card} />,
|
||||
children: <HistoryModal card={card} columns={board?.columns ?? []} />,
|
||||
});
|
||||
}, []);
|
||||
}, [board?.columns]);
|
||||
|
||||
const handleToggleCardLock = useCallback(async (id: string, locked: boolean) => {
|
||||
setBoard((prev) => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Badge, Divider, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
|
||||
import { Badge, Divider, Group, Loader, Stack, Table, Text, Timeline } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsHorizontal,
|
||||
IconCalendarDue,
|
||||
IconCalendarOff,
|
||||
IconCheck,
|
||||
IconColumns3,
|
||||
IconEdit,
|
||||
IconLock,
|
||||
@@ -16,11 +17,12 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { cardHistory, listUsers } from "../api";
|
||||
import type { Card, CardEvent, CardHistoryResponse, User } from "../types";
|
||||
import type { Card, CardEvent, CardHistoryResponse, Column, User } from "../types";
|
||||
import { formatDuration } from "./format";
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
columns?: Column[];
|
||||
}
|
||||
|
||||
interface UnifiedEvent {
|
||||
@@ -31,6 +33,7 @@ interface UnifiedEvent {
|
||||
detail: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
doneColumn?: boolean;
|
||||
}
|
||||
|
||||
function parsePayload(p: string): Record<string, unknown> {
|
||||
@@ -67,10 +70,18 @@ function eventToUnified(e: CardEvent): UnifiedEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export function HistoryModal({ card }: Props) {
|
||||
export function HistoryModal({ card, columns = [] }: Props) {
|
||||
const [data, setData] = useState<CardHistoryResponse | null>(null);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
|
||||
const columnById = useMemo(() => {
|
||||
const m = new Map<string, Column>();
|
||||
for (const c of columns) m.set(c.id, c);
|
||||
return m;
|
||||
}, [columns]);
|
||||
|
||||
const isDoneColumn = (columnId: string) => columnById.get(columnId)?.is_done === true;
|
||||
|
||||
useEffect(() => {
|
||||
cardHistory(card.id)
|
||||
.then(setData)
|
||||
@@ -91,14 +102,16 @@ export function HistoryModal({ card }: Props) {
|
||||
const out: UnifiedEvent[] = [];
|
||||
for (const e of data.events || []) out.push(eventToUnified(e));
|
||||
for (const h of data.column_history || []) {
|
||||
const done = isDoneColumn(h.column_id);
|
||||
out.push({
|
||||
id: "h_in_" + h.id,
|
||||
ts: h.entered_at,
|
||||
kind: "Mueve a columna",
|
||||
kind: done ? "Hecho en columna" : "Mueve a columna",
|
||||
actorID: h.actor_id,
|
||||
detail: h.column_name || h.column_id,
|
||||
icon: <IconArrowsHorizontal size={12} />,
|
||||
color: "blue",
|
||||
icon: done ? <IconCheck size={12} /> : <IconArrowsHorizontal size={12} />,
|
||||
color: done ? "green" : "blue",
|
||||
doneColumn: done,
|
||||
});
|
||||
}
|
||||
for (const p of data.lock_periods || []) {
|
||||
@@ -108,7 +121,7 @@ export function HistoryModal({ card }: Props) {
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.ts.localeCompare(b.ts));
|
||||
}, [data]);
|
||||
}, [data, columnById]);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
@@ -124,6 +137,26 @@ export function HistoryModal({ card }: Props) {
|
||||
return <Text c="dimmed">Sin historial.</Text>;
|
||||
}
|
||||
|
||||
// Per-column time stats: sum duration_ms by column_id from column_history.
|
||||
// Currently-active entry (exited_at=null) gets duration_ms = now - entered_at.
|
||||
const nowMs = Date.now();
|
||||
const perColumnMs = new Map<string, { name: string; isDone: boolean; ms: number; visits: number }>();
|
||||
for (const h of column_history) {
|
||||
const dur = h.exited_at ? h.duration_ms : Math.max(0, nowMs - new Date(h.entered_at).getTime());
|
||||
const key = h.column_id;
|
||||
const prev = perColumnMs.get(key);
|
||||
const meta = columnById.get(key);
|
||||
perColumnMs.set(key, {
|
||||
name: h.column_name || meta?.name || key,
|
||||
isDone: meta?.is_done ?? false,
|
||||
ms: (prev?.ms ?? 0) + dur,
|
||||
visits: (prev?.visits ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
const perColumnRows = Array.from(perColumnMs.entries())
|
||||
.map(([id, v]) => ({ id, ...v }))
|
||||
.sort((a, b) => b.ms - a.ms);
|
||||
|
||||
const userLabel = (id: string | null): string => {
|
||||
if (!id) return "";
|
||||
const u = userById.get(id);
|
||||
@@ -140,6 +173,7 @@ export function HistoryModal({ card }: Props) {
|
||||
key={e.id}
|
||||
bullet={e.icon}
|
||||
color={e.color}
|
||||
lineVariant={e.doneColumn ? "solid" : undefined}
|
||||
title={
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text fw={500} size="sm">{e.kind}</Text>
|
||||
@@ -163,16 +197,47 @@ export function HistoryModal({ card }: Props) {
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group gap={6} align="center">
|
||||
<IconColumns3 size={14} />
|
||||
<Text fw={500} size="sm">Columnas visitadas</Text>
|
||||
<Badge size="xs" variant="light" color="gray">{column_history.length}</Badge>
|
||||
<IconLock size={14} color="var(--mantine-color-yellow-6)" />
|
||||
<Badge size="xs" variant="light" color={total_locked_ms > 0 ? "yellow" : "gray"}>
|
||||
{formatDuration(total_locked_ms)}
|
||||
</Badge>
|
||||
{currently_locked && <Badge size="xs" variant="filled" color="yellow">bloqueada</Badge>}
|
||||
</Group>
|
||||
<Stack gap={6}>
|
||||
<Group gap={6} align="center" wrap="wrap">
|
||||
<IconColumns3 size={14} />
|
||||
<Text fw={500} size="sm">Tiempo por columna</Text>
|
||||
<Badge size="xs" variant="light" color="gray">{column_history.length} entradas</Badge>
|
||||
<Text size="xs" c="dimmed" ml="auto">
|
||||
<IconLock size={11} style={{ verticalAlign: "middle" }} />{" "}
|
||||
<Text span size="xs" fw={500} c={total_locked_ms > 0 ? "yellow" : "dimmed"}>
|
||||
{formatDuration(total_locked_ms)}
|
||||
</Text>{" "}
|
||||
bloqueada{currently_locked ? " (en curso)" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
{perColumnRows.length > 0 ? (
|
||||
<Table withTableBorder withColumnBorders striped="even" verticalSpacing={4} fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Columna</Table.Th>
|
||||
<Table.Th style={{ width: 60 }}>Visitas</Table.Th>
|
||||
<Table.Th style={{ width: 130 }}>Tiempo total</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{perColumnRows.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{r.isDone && <IconCheck size={12} color="var(--mantine-color-green-6)" />}
|
||||
<Text size="xs" fw={r.isDone ? 600 : 400}>{r.name}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.visits}</Table.Td>
|
||||
<Table.Td>{formatDuration(r.ms)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">Sin movimientos entre columnas.</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user