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:
+157
-152
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||||
|
|||||||
@@ -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)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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