feat(jira): menu 'Jira' (rename) + modal con tabs Importar/Comprobar columnas
UI:
- Menu avatar dropdown: 'Importar de Jira' -> 'Jira' (renombrado).
- ImportJiraModal.tsx eliminado. Sustituido por JiraModal.tsx con Mantine Tabs:
* 'Importar de Jira': UI heredada del modal anterior intacta.
* 'Comprobar columnas': nueva. Lista cards linked y muestra desincronizadas
(kanban col vs Jira status actual). Por cada row: kanban col + expected jira
status + jira status real. Checkbox multi-select + boton 'Sincronizar' que
empuja Jira al status correcto (kanban gana).
Backend:
- GET /api/jira/check-columns: walk cards.jira_key != ''. Por cada uno GET
/rest/api/3/issue/{key}?fields=status. Compara status real vs status_map.
Devuelve {rows[], total, mismatches, in_sync, status_map, reverse_map}.
- POST /api/jira/reconcile-columns {card_ids[], direction:'kanban-wins'}:
reusa jiraHandler.transitionToStatus para empujar cada issue al status del
status_map de su columna kanban actual + actualiza cards.jira_last_status.
- Helper listLinkedCardsForCheck en jira_import.go.
Direction='kanban-wins' default. Reverse direction (jira-wins) no soportado
por ahora: mover cards desde el server tiene efectos colaterales (eventos,
notificaciones, timers) que no quiero disparar masivos sin pensar.
This commit is contained in:
@@ -87,7 +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 { JiraModal } from "./components/JiraModal";
|
||||
import { useEventStream } from "./hooks/useEventStream";
|
||||
import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types";
|
||||
|
||||
@@ -1304,7 +1304,7 @@ export function App() {
|
||||
leftSection={<IconBrandJira size={14} />}
|
||||
onClick={() => setJiraImportOpen(true)}
|
||||
>
|
||||
Importar de Jira
|
||||
Jira
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
@@ -1327,11 +1327,11 @@ export function App() {
|
||||
<ModulesModal opened={modulesOpen} onClose={() => setModulesOpen(false)} />
|
||||
)}
|
||||
{auth.user?.is_admin && board && (
|
||||
<ImportJiraModal
|
||||
<JiraModal
|
||||
opened={jiraImportOpen}
|
||||
onClose={() => setJiraImportOpen(false)}
|
||||
columns={board.columns}
|
||||
onImported={() => reload()}
|
||||
onMutated={() => reload()}
|
||||
/>
|
||||
)}
|
||||
<MCPTokensModal opened={mcpTokensOpen} onClose={() => setMcpTokensOpen(false)} />
|
||||
|
||||
@@ -565,6 +565,48 @@ export function importJiraIssues(issueKeys: string[], fallbackColumnId?: string)
|
||||
});
|
||||
}
|
||||
|
||||
export interface JiraCheckRow {
|
||||
card_id: string;
|
||||
jira_key: string;
|
||||
title: string;
|
||||
kanban_column_id: string;
|
||||
kanban_column_name: string;
|
||||
jira_status_name: string;
|
||||
expected_kanban_col: string;
|
||||
expected_jira_status: string;
|
||||
mismatch: boolean;
|
||||
issue_url: string;
|
||||
}
|
||||
|
||||
export interface JiraCheckResponse {
|
||||
rows: JiraCheckRow[];
|
||||
total: number;
|
||||
mismatches: number;
|
||||
in_sync: number;
|
||||
status_map: Record<string, string>;
|
||||
reverse_map: Record<string, string>;
|
||||
}
|
||||
|
||||
export function checkJiraColumns(): Promise<JiraCheckResponse> {
|
||||
return fetchJSON("/jira/check-columns");
|
||||
}
|
||||
|
||||
export interface JiraReconcileResult {
|
||||
card_id: string;
|
||||
status: "fixed" | "skipped" | "error";
|
||||
jira_key?: string;
|
||||
jira_status?: string;
|
||||
error?: string;
|
||||
http?: number;
|
||||
}
|
||||
|
||||
export function reconcileJiraColumns(cardIds: string[]): Promise<{ results: JiraReconcileResult[] }> {
|
||||
return fetchJSON("/jira/reconcile-columns", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ card_ids: cardIds, direction: "kanban-wins" }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
|
||||
const qs = new URLSearchParams();
|
||||
if (f.from) qs.set("from", f.from);
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
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,455 @@
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrandJira,
|
||||
IconChecks,
|
||||
IconDownload,
|
||||
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[];
|
||||
// Called when imports or reconciles modified the board so the parent can
|
||||
// refetch /api/board.
|
||||
onMutated?: () => void;
|
||||
}
|
||||
|
||||
// JiraModal is the admin hub for everything Jira-related: importing issues
|
||||
// into kanban and verifying the kanban-column ↔ Jira-status mapping is in
|
||||
// sync. The previous standalone "Importar de Jira" modal moved here as the
|
||||
// "Importar" tab; the new "Comprobar columnas" tab surfaces drift detected
|
||||
// by /api/jira/check-columns and offers a one-click fix per row.
|
||||
export function JiraModal({ opened, onClose, columns, onMutated }: Props) {
|
||||
const [tab, setTab] = useState<string | null>("import");
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<IconBrandJira size={18} />
|
||||
<Text fw={600}>Jira</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Tabs value={tab} onChange={setTab} keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="import" leftSection={<IconDownload size={14} />}>
|
||||
Importar de Jira
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="check" leftSection={<IconChecks size={14} />}>
|
||||
Comprobar columnas
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="import" pt="sm">
|
||||
<ImportTab columns={columns} onImported={onMutated} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="check" pt="sm">
|
||||
<CheckTab onReconciled={onMutated} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Importar de Jira (kept verbatim from the old standalone modal, minus the
|
||||
// Modal frame which lives in the parent now).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ImportTab({ columns, onImported }: { columns: Column[]; onImported?: () => void }) {
|
||||
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);
|
||||
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(() => {
|
||||
reload();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showImported]);
|
||||
|
||||
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 (
|
||||
<Stack gap="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
{boardId ? `Board ${boardId} (${projectKey})` : "Cargando board..."}
|
||||
</Text>
|
||||
<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: 440, 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 onClick={doImport} disabled={selected.size === 0 || importing} loading={importing}>
|
||||
Importar {selected.size > 0 ? `(${selected.size})` : ""}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comprobar columnas: detects drift between kanban column ↔ Jira status.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CheckTab({ onReconciled }: { onReconciled?: () => void }) {
|
||||
const [data, setData] = useState<api.JiraCheckResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fixing, setFixing] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [onlyMismatch, setOnlyMismatch] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.checkJiraColumns();
|
||||
setData(r);
|
||||
// After a refresh, drop selections that no longer apply.
|
||||
setSelected((prev) => {
|
||||
const validIds = new Set(r.rows.filter((x) => x.mismatch).map((x) => x.card_id));
|
||||
const next = new Set<string>();
|
||||
for (const id of prev) if (validIds.has(id)) next.add(id);
|
||||
return next;
|
||||
});
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, []);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return onlyMismatch ? data.rows.filter((r) => r.mismatch) : data.rows;
|
||||
}, [data, onlyMismatch]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
const fixable = visible.filter((r) => r.mismatch).map((r) => r.card_id);
|
||||
if (selected.size === fixable.length && fixable.length > 0) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(fixable));
|
||||
}
|
||||
};
|
||||
|
||||
const doFix = async () => {
|
||||
if (selected.size === 0) return;
|
||||
setFixing(true);
|
||||
try {
|
||||
const res = await api.reconcileJiraColumns(Array.from(selected));
|
||||
const ok = res.results.filter((r) => r.status === "fixed").length;
|
||||
const skip = res.results.filter((r) => r.status === "skipped").length;
|
||||
const err = res.results.filter((r) => r.status === "error");
|
||||
const msg = `${ok} sincronizadas` + (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("reconcile errors", err);
|
||||
setSelected(new Set());
|
||||
await reload();
|
||||
onReconciled?.();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
} finally {
|
||||
setFixing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
Compara la columna kanban de cada card con el status actual de su Jira issue. Lo hace
|
||||
consultando Jira en vivo (1 request por card) — puede tardar varios segundos con
|
||||
muchas cards.
|
||||
</Text>
|
||||
{data && (
|
||||
<Group gap="xs">
|
||||
<Badge color="green" variant="light">{data.in_sync} en sync</Badge>
|
||||
{data.mismatches > 0 && (
|
||||
<Badge color="red" variant="filled" leftSection={<IconAlertTriangle size={12} />}>
|
||||
{data.mismatches} desincronizadas
|
||||
</Badge>
|
||||
)}
|
||||
<Badge color="gray" variant="outline">{data.total} totales</Badge>
|
||||
</Group>
|
||||
)}
|
||||
<Group gap="xs" justify="space-between">
|
||||
<Checkbox
|
||||
label="Solo mostrar desincronizadas"
|
||||
checked={onlyMismatch}
|
||||
onChange={(e) => setOnlyMismatch(e.currentTarget.checked)}
|
||||
/>
|
||||
<Button variant="default" onClick={reload} loading={loading} leftSection={<IconRefresh size={14} />}>
|
||||
Re-comprobar
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{error && <Text size="sm" c="red">{error}</Text>}
|
||||
|
||||
<Box style={{ maxHeight: 440, overflowY: "auto", border: "1px solid var(--mantine-color-default-border)", borderRadius: 4 }}>
|
||||
{loading && !data ? (
|
||||
<Group justify="center" p="md"><Loader size="sm" /></Group>
|
||||
) : visible.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" p="md" ta="center">
|
||||
{data && data.mismatches === 0
|
||||
? "Todas las cards estan en su columna correcta. ✅"
|
||||
: "No hay rows que mostrar con el filtro actual."}
|
||||
</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((r) => r.mismatch).length}
|
||||
indeterminate={selected.size > 0 && selected.size < visible.filter((r) => r.mismatch).length}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{visible.length} rows · {selected.size} seleccionadas para sync
|
||||
</Text>
|
||||
</Group>
|
||||
{visible.map((row) => (
|
||||
<Group key={row.card_id} p="xs" gap="xs" wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}>
|
||||
<Checkbox checked={selected.has(row.card_id)} disabled={!row.mismatch} onChange={() => toggle(row.card_id)} />
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Anchor href={row.issue_url} target="_blank" rel="noopener noreferrer" size="sm" fw={600}>{row.jira_key}</Anchor>
|
||||
<Text size="sm" truncate style={{ flex: 1 }}>{row.title}</Text>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" variant="light" color="blue">kanban: {row.kanban_column_name}</Badge>
|
||||
<Text size="xs" c="dimmed">→ esperado Jira: <b>{row.expected_jira_status || "(sin mapeo)"}</b></Text>
|
||||
<Badge size="xs" variant="light" color={row.mismatch ? "red" : "green"}>
|
||||
jira: {row.jira_status_name}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Group justify="space-between" gap="xs">
|
||||
<Text size="xs" c="dimmed">Fix = transicionar Jira al status que matchea la columna kanban (kanban gana)</Text>
|
||||
<Button color="orange" onClick={doFix} disabled={selected.size === 0 || fixing} loading={fixing}>
|
||||
Sincronizar {selected.size > 0 ? `(${selected.size})` : ""}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user