feat(jira): indicator per-card + import view desde Jira board 33

Backend:
- migration 018: cards.jira_last_status / sync_at / error (estado persistido del ultimo
  sync para render UI sin polling Jira).
- Dispatcher: sync.Map inflight para 'yellow' realtime + persistencia de exito/fallo
  en cards tras cada dispatch attempt.
- GET /api/cards/{id}/jira-sync: devuelve {jira_key, last_status, last_sync_at,
  last_error, inflight, issue_url} para el tooltip del indicador.
- GET /api/jira/issues: lista issues del board 33 con flag already_imported +
  mapped_column_id (reverse status_map). Filtros include_imported, limit.
- POST /api/jira/import: multi-key. Cada issue -> CreateCard + setCardJiraKey +
  seed jira_last_status. Cae en columna mapeada por status, o en fallback_column_id.
  ADF de description extraido a texto plano.

Frontend:
- JiraSyncIndicator: dot gris/amarillo/verde/rojo bajo IconDotsVertical de cada card.
  Mantine HoverCard con jira_key, status, last_sync, last_error, link 'Abrir en Jira'.
  Poll cada 10s, refresh-tick opcional.
- KanbanCard: agrupa menu + indicator en Stack vertical (indicator debajo de los 3 dots).
- ImportJiraModal: modal admin con tabla de issues. Checkbox por fila, filtro por texto,
  toggle 'mostrar ya importadas', Select de columna fallback. Tras import recarga board.
- App.tsx: nueva entrada de menu 'Importar de Jira' (admin) y ImportJiraModal mounted.

Backend tests siguen verdes (test mock cubre transitions endpoints).
Frontend pnpm build OK.
This commit is contained in:
egutierrez
2026-05-29 12:00:26 +02:00
parent 5744b82f58
commit c3cc42b350
13 changed files with 2450 additions and 1330 deletions
+19
View File
@@ -57,6 +57,7 @@ import {
IconLogout,
IconPlug,
IconKey,
IconBrandJira,
IconMenu2,
IconMessageChatbot,
IconMoodSmile,
@@ -86,6 +87,7 @@ import { colorBg, colorBorder } from "./components/colors";
import { NotificationsBell } from "./components/NotificationsBell";
import { ModulesModal } from "./components/ModulesModal";
import { MCPTokensModal } from "./components/MCPTokensModal";
import { ImportJiraModal } from "./components/ImportJiraModal";
import { useEventStream } from "./hooks/useEventStream";
import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types";
@@ -364,6 +366,7 @@ export function App() {
const [modulesOpen, setModulesOpen] = useState(false);
const [mcpTokensOpen, setMcpTokensOpen] = useState(false);
const [jiraImportOpen, setJiraImportOpen] = useState(false);
const reloadNotifs = useCallback(async () => {
try {
@@ -1292,6 +1295,14 @@ export function App() {
Modulos
</Menu.Item>
)}
{auth.user.is_admin && (
<Menu.Item
leftSection={<IconBrandJira size={14} />}
onClick={() => setJiraImportOpen(true)}
>
Importar de Jira
</Menu.Item>
)}
<Menu.Item
leftSection={<IconKey size={14} />}
onClick={() => setMcpTokensOpen(true)}
@@ -1311,6 +1322,14 @@ export function App() {
{auth.user?.is_admin && (
<ModulesModal opened={modulesOpen} onClose={() => setModulesOpen(false)} />
)}
{auth.user?.is_admin && board && (
<ImportJiraModal
opened={jiraImportOpen}
onClose={() => setJiraImportOpen(false)}
columns={board.columns}
onImported={() => reload()}
/>
)}
<MCPTokensModal opened={mcpTokensOpen} onClose={() => setMcpTokensOpen(false)} />
</Group>
</Group>
+60
View File
@@ -505,6 +505,66 @@ export function revokeMCPToken(id: string): Promise<void> {
return fetchJSON(`/mcp-tokens/${id}`, { method: "DELETE" });
}
// --- Jira sync state + import ----------------------------------------------
export interface CardJiraSyncState {
card_id: string;
jira_key: string;
last_status: string;
last_sync_at: string;
last_error: string;
inflight: boolean;
issue_url?: string;
}
export function getCardJiraSync(cardId: string): Promise<CardJiraSyncState> {
return fetchJSON(`/cards/${cardId}/jira-sync`);
}
export interface JiraIssue {
key: string;
summary: string;
status_name: string;
issue_type: string;
assignee: string;
updated: string;
url: string;
already_imported: boolean;
mapped_column_id?: string;
issue_type_icon?: string;
}
export interface ListJiraIssuesResponse {
issues: JiraIssue[];
board_id: number;
project_key: string;
status_to_column: Record<string, string>;
include_imported: boolean;
}
export function listJiraIssues(opts?: { includeImported?: boolean; limit?: number }): Promise<ListJiraIssuesResponse> {
const qs = new URLSearchParams();
if (opts?.includeImported) qs.set("include_imported", "true");
if (opts?.limit) qs.set("limit", String(opts.limit));
const q = qs.toString();
return fetchJSON(`/jira/issues${q ? `?${q}` : ""}`);
}
export interface JiraImportResult {
key: string;
status: "imported" | "skipped" | "error";
card_id?: string;
column_id?: string;
error?: string;
}
export function importJiraIssues(issueKeys: string[], fallbackColumnId?: string): Promise<{ results: JiraImportResult[] }> {
return fetchJSON("/jira/import", {
method: "POST",
body: JSON.stringify({ issue_keys: issueKeys, fallback_column_id: fallbackColumnId || "" }),
});
}
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
const qs = new URLSearchParams();
if (f.from) qs.set("from", f.from);
+279
View File
@@ -0,0 +1,279 @@
import {
Anchor,
Badge,
Box,
Button,
Checkbox,
Group,
Image,
Loader,
Modal,
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconBrandJira, IconRefresh, IconSearch } from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import * as api from "../api";
import type { JiraIssue } from "../api";
import type { Column } from "../types";
interface Props {
opened: boolean;
onClose: () => void;
columns: Column[];
onImported?: () => void;
}
// ImportJiraModal lists every issue on the configured Jira board and lets the
// admin pick which ones to materialise as kanban cards. Issues already linked
// to a card are hidden by default and re-shown by toggling "Mostrar
// importadas". Each new card lands in the column whose status_map matches the
// issue's current status; the fallback column picker covers statuses without
// a mapping (e.g. Jira Backlog → kanban "HACIENDO").
export function ImportJiraModal({ opened, onClose, columns, onImported }: Props) {
const [issues, setIssues] = useState<JiraIssue[] | null>(null);
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [showImported, setShowImported] = useState(false);
const [query, setQuery] = useState("");
const [fallbackColumn, setFallbackColumn] = useState<string>("");
const [boardId, setBoardId] = useState<number | null>(null);
const [projectKey, setProjectKey] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const reload = async () => {
setLoading(true);
setError(null);
try {
const r = await api.listJiraIssues({ includeImported: showImported, limit: 200 });
setIssues(r.issues);
setBoardId(r.board_id);
setProjectKey(r.project_key);
// Clear selection of any keys that disappeared from the new list.
setSelected((prev) => {
const validKeys = new Set(r.issues.map((i) => i.key));
const next = new Set<string>();
for (const k of prev) if (validKeys.has(k)) next.add(k);
return next;
});
} catch (e) {
setError((e as Error).message);
setIssues([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (opened) reload();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, showImported]);
// Default the fallback column to the first board column (operator can change
// it via the Select). We only set it once columns are available.
useEffect(() => {
if (!fallbackColumn && columns.length > 0) {
const boardCol = columns.find((c) => c.location !== "sidebar") || columns[0];
setFallbackColumn(boardCol.id);
}
}, [columns, fallbackColumn]);
const visible = useMemo(() => {
if (!issues) return [];
const q = query.trim().toLowerCase();
if (!q) return issues;
return issues.filter(
(i) =>
i.key.toLowerCase().includes(q) ||
i.summary.toLowerCase().includes(q) ||
i.assignee.toLowerCase().includes(q) ||
i.status_name.toLowerCase().includes(q),
);
}, [issues, query]);
const toggle = (key: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleAll = () => {
const importable = visible.filter((i) => !i.already_imported);
if (selected.size === importable.length && importable.length > 0) {
setSelected(new Set());
} else {
setSelected(new Set(importable.map((i) => i.key)));
}
};
const doImport = async () => {
if (selected.size === 0) return;
setImporting(true);
try {
const res = await api.importJiraIssues(Array.from(selected), fallbackColumn || undefined);
const ok = res.results.filter((r) => r.status === "imported").length;
const skip = res.results.filter((r) => r.status === "skipped").length;
const err = res.results.filter((r) => r.status === "error");
const msg = `${ok} importadas` + (skip > 0 ? ` · ${skip} omitidas` : "") + (err.length > 0 ? ` · ${err.length} con error` : "");
notifications.show({
color: err.length > 0 ? "yellow" : "green",
message: msg,
autoClose: 5000,
});
if (err.length > 0) {
console.warn("import errors", err);
}
setSelected(new Set());
await reload();
onImported?.();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setImporting(false);
}
};
const columnOptions = columns.map((c) => ({ value: c.id, label: c.name }));
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Group gap={8}>
<IconBrandJira size={18} />
<Text fw={600}>
Importar tareas de Jira{boardId ? ` · board ${boardId}` : ""}{projectKey ? ` (${projectKey})` : ""}
</Text>
</Group>
}
size="xl"
>
<Stack gap="sm">
<Group gap="xs" wrap="nowrap">
<TextInput
placeholder="Filtrar por key, titulo, asignado o status..."
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
leftSection={<IconSearch size={14} />}
style={{ flex: 1 }}
/>
<Tooltip label="Refrescar" withArrow>
<Button variant="default" onClick={reload} loading={loading} leftSection={<IconRefresh size={14} />}>
Refrescar
</Button>
</Tooltip>
</Group>
<Group justify="space-between" gap="xs">
<Checkbox
label="Mostrar issues ya importadas"
checked={showImported}
onChange={(e) => setShowImported(e.currentTarget.checked)}
/>
<Select
label="Columna fallback"
description="Para issues cuyo status no tiene mapping"
data={columnOptions}
value={fallbackColumn}
onChange={(v) => setFallbackColumn(v || "")}
allowDeselect={false}
w={260}
size="xs"
/>
</Group>
{error && (
<Text size="sm" c="red">{error}</Text>
)}
<Box style={{ maxHeight: 480, overflowY: "auto", border: "1px solid var(--mantine-color-default-border)", borderRadius: 4 }}>
{loading && !issues ? (
<Group justify="center" p="md"><Loader size="sm" /></Group>
) : visible.length === 0 ? (
<Text size="sm" c="dimmed" p="md" ta="center">
{issues?.length === 0
? "No hay issues sin importar en el board."
: "Ninguna issue coincide con el filtro."}
</Text>
) : (
<Stack gap={0}>
<Group
p="xs"
gap="xs"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)", background: "var(--mantine-color-default-hover)" }}
>
<Checkbox
checked={selected.size > 0 && selected.size === visible.filter((i) => !i.already_imported).length}
indeterminate={selected.size > 0 && selected.size < visible.filter((i) => !i.already_imported).length}
onChange={toggleAll}
/>
<Text size="xs" fw={600} c="dimmed">
{visible.length} issues · {selected.size} seleccionadas
</Text>
</Group>
{visible.map((iss) => (
<Group
key={iss.key}
p="xs"
gap="xs"
wrap="nowrap"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)", opacity: iss.already_imported ? 0.5 : 1 }}
>
<Checkbox
checked={selected.has(iss.key)}
disabled={iss.already_imported}
onChange={() => toggle(iss.key)}
/>
{iss.issue_type_icon && (
<Image src={iss.issue_type_icon} w={16} h={16} alt={iss.issue_type} />
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Anchor href={iss.url} target="_blank" rel="noopener noreferrer" size="sm" fw={600}>
{iss.key}
</Anchor>
<Text size="sm" truncate style={{ flex: 1 }}>{iss.summary}</Text>
</Group>
<Group gap={4} wrap="nowrap">
<Badge size="xs" variant="light" color={badgeColor(iss.status_name)}>{iss.status_name}</Badge>
<Badge size="xs" variant="outline">{iss.issue_type}</Badge>
{iss.assignee && <Text size="xs" c="dimmed">· {iss.assignee}</Text>}
{iss.already_imported && <Badge size="xs" color="gray">ya en kanban</Badge>}
{!iss.already_imported && !iss.mapped_column_id && (
<Badge size="xs" color="orange" variant="light">sin mapping (usa fallback)</Badge>
)}
</Group>
</Stack>
</Group>
))}
</Stack>
)}
</Box>
<Group justify="flex-end" gap="xs">
<Button variant="default" onClick={onClose} disabled={importing}>Cerrar</Button>
<Button onClick={doImport} disabled={selected.size === 0 || importing} loading={importing}>
Importar {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</Group>
</Stack>
</Modal>
);
}
function badgeColor(status: string): string {
const s = status.toLowerCase();
if (s.includes("done") || s.includes("hecho") || s.includes("closed")) return "green";
if (s.includes("progress") || s.includes("doing")) return "blue";
if (s.includes("implementado") || s.includes("review")) return "violet";
if (s.includes("creado") || s.includes("backlog") || s.includes("nuevo")) return "gray";
return "yellow";
}
@@ -0,0 +1,157 @@
import { Anchor, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
import { IconBrandJira, IconAlertCircle } from "@tabler/icons-react";
import { CSSProperties, useEffect, useState } from "react";
import * as api from "../api";
import type { CardJiraSyncState } from "../api";
import { formatDateTimeShort } from "./format";
// Pull state every POLL_MS so the indicator catches up with async dispatcher
// pushes without needing SSE. 10s is a reasonable balance: short enough that a
// drag-to-Jira shows green within one tick, long enough that the polling is
// not a noticeable load.
const POLL_MS = 10000;
type Tone = "gray" | "yellow" | "green" | "red";
function tone(state: CardJiraSyncState): Tone {
if (state.inflight) return "yellow";
if (state.last_error) return "red";
if (state.jira_key) return "green";
return "gray";
}
const TONE_COLOR: Record<Tone, string> = {
gray: "var(--mantine-color-gray-5)",
yellow: "var(--mantine-color-yellow-5)",
green: "var(--mantine-color-green-5)",
red: "var(--mantine-color-red-6)",
};
const TONE_LABEL: Record<Tone, string> = {
gray: "Sin sincronizar con Jira",
yellow: "Sincronizando...",
green: "Sincronizada con Jira",
red: "Error de sincronizacion",
};
interface Props {
cardId: string;
// Pollen-down so the parent can refresh when needed (e.g. after a move
// animation finishes) without waiting for the next tick.
refreshTick?: number;
}
export function JiraSyncIndicator({ cardId, refreshTick }: Props) {
const [state, setState] = useState<CardJiraSyncState | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const s = await api.getCardJiraSync(cardId);
if (!cancelled) {
setState(s);
setErr(null);
}
} catch (e) {
if (!cancelled) setErr((e as Error).message);
}
};
load();
const t = setInterval(load, POLL_MS);
return () => {
cancelled = true;
clearInterval(t);
};
}, [cardId, refreshTick]);
if (err && !state) {
return (
<Box title={err} style={dotStyle("var(--mantine-color-gray-3)")} aria-label="Jira sync state unavailable" />
);
}
if (!state) {
// Initial render — fade in a placeholder dot so the layout does not shift
// when the fetch resolves.
return <Box style={dotStyle("var(--mantine-color-gray-2)")} aria-label="Cargando estado Jira" />;
}
const t = tone(state);
return (
<HoverCard width={300} shadow="md" openDelay={150} closeDelay={120} withinPortal>
<HoverCard.Target>
<Box
role="status"
aria-label={TONE_LABEL[t]}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
style={dotStyle(TONE_COLOR[t])}
/>
</HoverCard.Target>
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
<Stack gap={6}>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap">
<IconBrandJira size={14} />
<Text size="sm" fw={600}>{TONE_LABEL[t]}</Text>
</Group>
{state.issue_url && (
<Anchor
size="xs"
href={state.issue_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
Abrir en Jira
</Anchor>
)}
</Group>
{state.jira_key && (
<Text size="xs">
<Text component="span" c="dimmed">Issue:</Text>{" "}
<Text component="span" fw={600}>{state.jira_key}</Text>
</Text>
)}
{state.last_status && (
<Text size="xs">
<Text component="span" c="dimmed">Status:</Text>{" "}
<Text component="span">{state.last_status}</Text>
</Text>
)}
{state.last_sync_at && (
<Text size="xs">
<Text component="span" c="dimmed">Ultimo sync:</Text>{" "}
<Text component="span">{formatDateTimeShort(state.last_sync_at)}</Text>
</Text>
)}
{state.last_error && (
<Group gap={6} wrap="nowrap" align="flex-start">
<IconAlertCircle size={14} color="var(--mantine-color-red-6)" />
<Text size="xs" c="red" style={{ wordBreak: "break-word" }}>{state.last_error}</Text>
</Group>
)}
{!state.jira_key && (
<Text size="xs" c="dimmed">
La card todavia no se ha empujado a Jira. Editala o muevela para
disparar el sync, o usa la opcion "Importar de Jira" si ya existe
alli.
</Text>
)}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
}
function dotStyle(color: string): CSSProperties {
return {
width: 10,
height: 10,
borderRadius: "50%",
background: color,
cursor: "default",
boxShadow: "0 0 0 2px var(--mantine-color-body)",
transition: "background 120ms ease",
};
}
+14 -10
View File
@@ -38,6 +38,7 @@ import type { Card, CardColor, User } from "../types";
import { colorBg, colorBorder, tagColor } from "./colors";
import { ColorPickerGrid } from "./ColorPickerGrid";
import { formatDateTimeShort, formatDuration } from "./format";
import { JiraSyncIndicator } from "./JiraSyncIndicator";
interface Props {
card: Card;
@@ -358,16 +359,19 @@ const KanbanCardBody = memo(function KanbanCardBody({
{card.title}
</Text>
</Group>
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones" style={{ flexShrink: 0 }} onPointerDown={(e) => e.stopPropagation()}>
<IconDotsVertical size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
{menuItems}
</Menu.Dropdown>
</Menu>
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones" onPointerDown={(e) => e.stopPropagation()}>
<IconDotsVertical size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
{menuItems}
</Menu.Dropdown>
</Menu>
<JiraSyncIndicator cardId={card.id} />
</Stack>
</Group>
{(card.requester || assignee) && (
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>