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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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">
|
||||
</head>
|
||||
<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.
|
||||
{Method: "GET", Path: "/api/jira/issues", Handler: handleListJiraIssues(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"`
|
||||
}
|
||||
|
||||
// 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,
|
||||
// or an error if no module is configured. The handlers below need both the
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -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