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
@@ -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",
};
}