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:
@@ -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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user