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({
|
modals.open({
|
||||||
title: card.title,
|
title: card.title,
|
||||||
size: "md",
|
size: "md",
|
||||||
children: <HistoryModal card={card} />,
|
children: <HistoryModal card={card} columns={board?.columns ?? []} />,
|
||||||
});
|
});
|
||||||
}, []);
|
}, [board?.columns]);
|
||||||
|
|
||||||
const handleToggleCardLock = useCallback(async (id: string, locked: boolean) => {
|
const handleToggleCardLock = useCallback(async (id: string, locked: boolean) => {
|
||||||
setBoard((prev) => {
|
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 {
|
import {
|
||||||
IconArrowsHorizontal,
|
IconArrowsHorizontal,
|
||||||
IconCalendarDue,
|
IconCalendarDue,
|
||||||
IconCalendarOff,
|
IconCalendarOff,
|
||||||
|
IconCheck,
|
||||||
IconColumns3,
|
IconColumns3,
|
||||||
IconEdit,
|
IconEdit,
|
||||||
IconLock,
|
IconLock,
|
||||||
@@ -16,11 +17,12 @@ import {
|
|||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { cardHistory, listUsers } from "../api";
|
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";
|
import { formatDuration } from "./format";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
card: Card;
|
card: Card;
|
||||||
|
columns?: Column[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UnifiedEvent {
|
interface UnifiedEvent {
|
||||||
@@ -31,6 +33,7 @@ interface UnifiedEvent {
|
|||||||
detail: string;
|
detail: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
color: string;
|
color: string;
|
||||||
|
doneColumn?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePayload(p: string): Record<string, unknown> {
|
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 [data, setData] = useState<CardHistoryResponse | null>(null);
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
cardHistory(card.id)
|
cardHistory(card.id)
|
||||||
.then(setData)
|
.then(setData)
|
||||||
@@ -91,14 +102,16 @@ export function HistoryModal({ card }: Props) {
|
|||||||
const out: UnifiedEvent[] = [];
|
const out: UnifiedEvent[] = [];
|
||||||
for (const e of data.events || []) out.push(eventToUnified(e));
|
for (const e of data.events || []) out.push(eventToUnified(e));
|
||||||
for (const h of data.column_history || []) {
|
for (const h of data.column_history || []) {
|
||||||
|
const done = isDoneColumn(h.column_id);
|
||||||
out.push({
|
out.push({
|
||||||
id: "h_in_" + h.id,
|
id: "h_in_" + h.id,
|
||||||
ts: h.entered_at,
|
ts: h.entered_at,
|
||||||
kind: "Mueve a columna",
|
kind: done ? "Hecho en columna" : "Mueve a columna",
|
||||||
actorID: h.actor_id,
|
actorID: h.actor_id,
|
||||||
detail: h.column_name || h.column_id,
|
detail: h.column_name || h.column_id,
|
||||||
icon: <IconArrowsHorizontal size={12} />,
|
icon: done ? <IconCheck size={12} /> : <IconArrowsHorizontal size={12} />,
|
||||||
color: "blue",
|
color: done ? "green" : "blue",
|
||||||
|
doneColumn: done,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const p of data.lock_periods || []) {
|
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));
|
return out.sort((a, b) => a.ts.localeCompare(b.ts));
|
||||||
}, [data]);
|
}, [data, columnById]);
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
@@ -124,6 +137,26 @@ export function HistoryModal({ card }: Props) {
|
|||||||
return <Text c="dimmed">Sin historial.</Text>;
|
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 => {
|
const userLabel = (id: string | null): string => {
|
||||||
if (!id) return "";
|
if (!id) return "";
|
||||||
const u = userById.get(id);
|
const u = userById.get(id);
|
||||||
@@ -140,6 +173,7 @@ export function HistoryModal({ card }: Props) {
|
|||||||
key={e.id}
|
key={e.id}
|
||||||
bullet={e.icon}
|
bullet={e.icon}
|
||||||
color={e.color}
|
color={e.color}
|
||||||
|
lineVariant={e.doneColumn ? "solid" : undefined}
|
||||||
title={
|
title={
|
||||||
<Group gap={6} wrap="wrap">
|
<Group gap={6} wrap="wrap">
|
||||||
<Text fw={500} size="sm">{e.kind}</Text>
|
<Text fw={500} size="sm">{e.kind}</Text>
|
||||||
@@ -163,16 +197,47 @@ export function HistoryModal({ card }: Props) {
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Group gap={6} align="center">
|
<Stack gap={6}>
|
||||||
<IconColumns3 size={14} />
|
<Group gap={6} align="center" wrap="wrap">
|
||||||
<Text fw={500} size="sm">Columnas visitadas</Text>
|
<IconColumns3 size={14} />
|
||||||
<Badge size="xs" variant="light" color="gray">{column_history.length}</Badge>
|
<Text fw={500} size="sm">Tiempo por columna</Text>
|
||||||
<IconLock size={14} color="var(--mantine-color-yellow-6)" />
|
<Badge size="xs" variant="light" color="gray">{column_history.length} entradas</Badge>
|
||||||
<Badge size="xs" variant="light" color={total_locked_ms > 0 ? "yellow" : "gray"}>
|
<Text size="xs" c="dimmed" ml="auto">
|
||||||
{formatDuration(total_locked_ms)}
|
<IconLock size={11} style={{ verticalAlign: "middle" }} />{" "}
|
||||||
</Badge>
|
<Text span size="xs" fw={500} c={total_locked_ms > 0 ? "yellow" : "dimmed"}>
|
||||||
{currently_locked && <Badge size="xs" variant="filled" color="yellow">bloqueada</Badge>}
|
{formatDuration(total_locked_ms)}
|
||||||
</Group>
|
</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>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user