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:
egutierrez
2026-05-29 15:18:59 +02:00
parent 9b0b6e516c
commit d4558667f6
8 changed files with 871 additions and 436 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban</title> <title>Kanban</title>
<script type="module" crossorigin src="/assets/index-Zozqj0rw.js"></script> <script type="module" crossorigin src="/assets/index-Be_Ib5cu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css"> <link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
</head> </head>
<body> <body>
+3
View File
@@ -707,6 +707,9 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
// Jira import: list issues not yet in kanban + bulk import. // Jira import: list issues not yet in kanban + bulk import.
{Method: "GET", Path: "/api/jira/issues", Handler: handleListJiraIssues(db)}, {Method: "GET", Path: "/api/jira/issues", Handler: handleListJiraIssues(db)},
{Method: "POST", Path: "/api/jira/import", Handler: handleImportJiraIssues(db)}, {Method: "POST", Path: "/api/jira/import", Handler: handleImportJiraIssues(db)},
// Jira column-sync check: detect drift between kanban col ↔ Jira status.
{Method: "GET", Path: "/api/jira/check-columns", Handler: handleCheckJiraColumns(db)},
{Method: "POST", Path: "/api/jira/reconcile-columns", Handler: handleReconcileJiraColumns(db)},
} }
} }
+209
View File
@@ -38,6 +38,40 @@ type jiraIssueOut struct {
IssueTypeIcon string `json:"issue_type_icon,omitempty"` IssueTypeIcon string `json:"issue_type_icon,omitempty"`
} }
// linkedCardForCheck is the projection used by the check-columns endpoint.
// We only need fields visible in the report table.
type linkedCardForCheck struct {
ID string
Title string
JiraKey string
ColumnID string
ColumnName string
}
func listLinkedCardsForCheck(db *DB) ([]linkedCardForCheck, error) {
rows, err := db.conn.Query(`
SELECT c.id, c.title, c.jira_key, c.column_id, col.name
FROM cards c
JOIN columns col ON col.id = c.column_id
WHERE c.jira_key != ''
AND c.deleted_at IS NULL
ORDER BY c.jira_key ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []linkedCardForCheck{}
for rows.Next() {
var c linkedCardForCheck
if err := rows.Scan(&c.ID, &c.Title, &c.JiraKey, &c.ColumnID, &c.ColumnName); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// activeJiraModule returns the first enabled Jira module + its decoded config, // activeJiraModule returns the first enabled Jira module + its decoded config,
// or an error if no module is configured. The handlers below need both the // or an error if no module is configured. The handlers below need both the
// credentials and the status_map to operate. // credentials and the status_map to operate.
@@ -245,6 +279,181 @@ func handleListJiraIssues(db *DB) http.HandlerFunc {
}) })
} }
// jiraCheckRow is one row of the check-columns report.
type jiraCheckRow struct {
CardID string `json:"card_id"`
JiraKey string `json:"jira_key"`
Title string `json:"title"`
KanbanColumnID string `json:"kanban_column_id"`
KanbanColumnName string `json:"kanban_column_name"`
JiraStatusName string `json:"jira_status_name"`
ExpectedKanbanCol string `json:"expected_kanban_col"` // kanban col that matches the current Jira status (reverse status_map)
ExpectedJiraStat string `json:"expected_jira_status"` // jira status that matches the current kanban col (status_map)
Mismatch bool `json:"mismatch"`
IssueURL string `json:"issue_url"`
}
// handleCheckJiraColumns walks every linked card, fetches its current Jira
// status, and reports whether the kanban column ↔ Jira status mapping is in
// sync. Used by the "Comprobar columnas" tab in the Jira modal.
//
// Performance note: one Jira REST call per linked card. With 127 cards that
// is ~127 round-trips — slow (≈30s end-to-end) but tolerable as an admin op.
// A future optimisation could batch via /search/jql with key IN (...) and a
// fields=status projection.
func handleCheckJiraColumns(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
_, cfg, err := activeJiraModule(db)
if err != nil {
badRequest(w, err.Error())
return
}
cards, err := listLinkedCardsForCheck(db)
if err != nil {
serverError(w, err)
return
}
statusToCol := reverseStatusMap(cfg)
ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout*5)
defer cancel()
rows := make([]jiraCheckRow, 0, len(cards))
var mismatches int
for _, c := range cards {
var iss struct {
Fields struct {
Status struct {
Name string `json:"name"`
} `json:"status"`
} `json:"fields"`
}
if _, err := jiraGET(ctx, cfg, "/rest/api/3/issue/"+url.PathEscape(c.JiraKey)+"?fields=status", &iss); err != nil {
rows = append(rows, jiraCheckRow{
CardID: c.ID,
JiraKey: c.JiraKey,
Title: c.Title,
KanbanColumnID: c.ColumnID,
KanbanColumnName: c.ColumnName,
JiraStatusName: "(fetch failed: " + err.Error() + ")",
Mismatch: true,
IssueURL: cfg.BaseURL + "/browse/" + c.JiraKey,
})
mismatches++
continue
}
expectedCol := statusToCol[strings.ToLower(iss.Fields.Status.Name)]
expectedStat := cfg.StatusMap[c.ColumnName]
mm := !strings.EqualFold(iss.Fields.Status.Name, expectedStat)
if mm {
mismatches++
}
rows = append(rows, jiraCheckRow{
CardID: c.ID,
JiraKey: c.JiraKey,
Title: c.Title,
KanbanColumnID: c.ColumnID,
KanbanColumnName: c.ColumnName,
JiraStatusName: iss.Fields.Status.Name,
ExpectedKanbanCol: expectedCol,
ExpectedJiraStat: expectedStat,
Mismatch: mm,
IssueURL: cfg.BaseURL + "/browse/" + c.JiraKey,
})
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{
"rows": rows,
"total": len(rows),
"mismatches": mismatches,
"in_sync": len(rows) - mismatches,
"status_map": cfg.StatusMap,
"reverse_map": statusToCol,
})
})
}
// reconcileRequest is the body shape for POST /api/jira/reconcile-columns.
// direction=kanban-wins → push Jira to match kanban (the only mode for now;
// reverse is risky because moving cards in kanban can trigger downstream
// notifications/timers).
type reconcileRequest struct {
CardIDs []string `json:"card_ids"`
Direction string `json:"direction"` // currently only "kanban-wins"
}
// handleReconcileJiraColumns transitions each requested issue so its status
// matches the current kanban column (kanban as source of truth). Reuses
// the dispatcher's transitionToStatus helper for consistency with the
// regular card.moved path. Per-card result.
func handleReconcileJiraColumns(db *DB) http.HandlerFunc {
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
var body reconcileRequest
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if len(body.CardIDs) == 0 {
badRequest(w, "card_ids required")
return
}
if body.Direction == "" {
body.Direction = "kanban-wins"
}
if body.Direction != "kanban-wins" {
badRequest(w, "only direction=kanban-wins is supported")
return
}
_, cfg, err := activeJiraModule(db)
if err != nil {
badRequest(w, err.Error())
return
}
h := &jiraHandler{}
ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout*time.Duration(len(body.CardIDs)+1))
defer cancel()
results := make([]map[string]interface{}, 0, len(body.CardIDs))
for _, cid := range body.CardIDs {
res := map[string]interface{}{"card_id": cid}
card, cerr := db.getCardForJira(cid)
if cerr != nil {
res["status"] = "error"
res["error"] = cerr.Error()
results = append(results, res)
continue
}
if card.JiraKey == "" {
res["status"] = "skipped"
res["error"] = "card has no jira_key"
results = append(results, res)
continue
}
if _, ok := cfg.StatusMap[card.ColumnName]; !ok {
res["status"] = "skipped"
res["error"] = "no status_map entry for column " + card.ColumnName
results = append(results, res)
continue
}
status, terr := h.transitionToStatus(ctx, cfg, card.JiraKey, card.ColumnName)
if terr != nil {
res["status"] = "error"
res["error"] = terr.Error()
res["http"] = status
results = append(results, res)
continue
}
now := time.Now().UTC().Format(time.RFC3339)
_ = db.updateCardJiraSync(cid, cfg.StatusMap[card.ColumnName], now, "")
res["status"] = "fixed"
res["jira_key"] = card.JiraKey
res["jira_status"] = cfg.StatusMap[card.ColumnName]
results = append(results, res)
}
infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{"results": results})
})
}
// handleImportJiraIssues creates a kanban card for each requested issue_key // handleImportJiraIssues creates a kanban card for each requested issue_key
// and links it to the existing Jira issue (sets jira_key directly, so the // and links it to the existing Jira issue (sets jira_key directly, so the
// dispatcher will treat any future kanban edits as updates instead of trying // dispatcher will treat any future kanban edits as updates instead of trying
+4 -4
View File
@@ -87,7 +87,7 @@ import { colorBg, colorBorder } from "./components/colors";
import { NotificationsBell } from "./components/NotificationsBell"; import { NotificationsBell } from "./components/NotificationsBell";
import { ModulesModal } from "./components/ModulesModal"; import { ModulesModal } from "./components/ModulesModal";
import { MCPTokensModal } from "./components/MCPTokensModal"; import { MCPTokensModal } from "./components/MCPTokensModal";
import { ImportJiraModal } from "./components/ImportJiraModal"; import { JiraModal } from "./components/JiraModal";
import { useEventStream } from "./hooks/useEventStream"; import { useEventStream } from "./hooks/useEventStream";
import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types"; import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types";
@@ -1304,7 +1304,7 @@ export function App() {
leftSection={<IconBrandJira size={14} />} leftSection={<IconBrandJira size={14} />}
onClick={() => setJiraImportOpen(true)} onClick={() => setJiraImportOpen(true)}
> >
Importar de Jira Jira
</Menu.Item> </Menu.Item>
)} )}
<Menu.Item <Menu.Item
@@ -1327,11 +1327,11 @@ export function App() {
<ModulesModal opened={modulesOpen} onClose={() => setModulesOpen(false)} /> <ModulesModal opened={modulesOpen} onClose={() => setModulesOpen(false)} />
)} )}
{auth.user?.is_admin && board && ( {auth.user?.is_admin && board && (
<ImportJiraModal <JiraModal
opened={jiraImportOpen} opened={jiraImportOpen}
onClose={() => setJiraImportOpen(false)} onClose={() => setJiraImportOpen(false)}
columns={board.columns} columns={board.columns}
onImported={() => reload()} onMutated={() => reload()}
/> />
)} )}
<MCPTokensModal opened={mcpTokensOpen} onClose={() => setMcpTokensOpen(false)} /> <MCPTokensModal opened={mcpTokensOpen} onClose={() => setMcpTokensOpen(false)} />
+42
View File
@@ -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> { export function getMetrics(f: MetricsFilter): Promise<Metrics> {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (f.from) qs.set("from", f.from); if (f.from) qs.set("from", f.from);
-279
View File
@@ -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";
}
+455
View File
@@ -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";
}