feat(jira): indicator per-card + import view desde Jira board 33
Backend:
- migration 018: cards.jira_last_status / sync_at / error (estado persistido del ultimo
sync para render UI sin polling Jira).
- Dispatcher: sync.Map inflight para 'yellow' realtime + persistencia de exito/fallo
en cards tras cada dispatch attempt.
- GET /api/cards/{id}/jira-sync: devuelve {jira_key, last_status, last_sync_at,
last_error, inflight, issue_url} para el tooltip del indicador.
- GET /api/jira/issues: lista issues del board 33 con flag already_imported +
mapped_column_id (reverse status_map). Filtros include_imported, limit.
- POST /api/jira/import: multi-key. Cada issue -> CreateCard + setCardJiraKey +
seed jira_last_status. Cae en columna mapeada por status, o en fallback_column_id.
ADF de description extraido a texto plano.
Frontend:
- JiraSyncIndicator: dot gris/amarillo/verde/rojo bajo IconDotsVertical de cada card.
Mantine HoverCard con jira_key, status, last_sync, last_error, link 'Abrir en Jira'.
Poll cada 10s, refresh-tick opcional.
- KanbanCard: agrupa menu + indicator en Stack vertical (indicator debajo de los 3 dots).
- ImportJiraModal: modal admin con tabla de issues. Checkbox por fila, filtro por texto,
toggle 'mostrar ya importadas', Select de columna fallback. Tras import recarga board.
- App.tsx: nueva entrada de menu 'Importar de Jira' (admin) y ImportJiraModal mounted.
Backend tests siguen verdes (test mock cubre transitions endpoints).
Frontend pnpm build OK.
This commit is contained in:
@@ -57,6 +57,7 @@ import {
|
||||
IconLogout,
|
||||
IconPlug,
|
||||
IconKey,
|
||||
IconBrandJira,
|
||||
IconMenu2,
|
||||
IconMessageChatbot,
|
||||
IconMoodSmile,
|
||||
@@ -86,6 +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 { useEventStream } from "./hooks/useEventStream";
|
||||
import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types";
|
||||
|
||||
@@ -364,6 +366,7 @@ export function App() {
|
||||
|
||||
const [modulesOpen, setModulesOpen] = useState(false);
|
||||
const [mcpTokensOpen, setMcpTokensOpen] = useState(false);
|
||||
const [jiraImportOpen, setJiraImportOpen] = useState(false);
|
||||
|
||||
const reloadNotifs = useCallback(async () => {
|
||||
try {
|
||||
@@ -1292,6 +1295,14 @@ export function App() {
|
||||
Modulos
|
||||
</Menu.Item>
|
||||
)}
|
||||
{auth.user.is_admin && (
|
||||
<Menu.Item
|
||||
leftSection={<IconBrandJira size={14} />}
|
||||
onClick={() => setJiraImportOpen(true)}
|
||||
>
|
||||
Importar de Jira
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<IconKey size={14} />}
|
||||
onClick={() => setMcpTokensOpen(true)}
|
||||
@@ -1311,6 +1322,14 @@ export function App() {
|
||||
{auth.user?.is_admin && (
|
||||
<ModulesModal opened={modulesOpen} onClose={() => setModulesOpen(false)} />
|
||||
)}
|
||||
{auth.user?.is_admin && board && (
|
||||
<ImportJiraModal
|
||||
opened={jiraImportOpen}
|
||||
onClose={() => setJiraImportOpen(false)}
|
||||
columns={board.columns}
|
||||
onImported={() => reload()}
|
||||
/>
|
||||
)}
|
||||
<MCPTokensModal opened={mcpTokensOpen} onClose={() => setMcpTokensOpen(false)} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -505,6 +505,66 @@ export function revokeMCPToken(id: string): Promise<void> {
|
||||
return fetchJSON(`/mcp-tokens/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// --- Jira sync state + import ----------------------------------------------
|
||||
|
||||
export interface CardJiraSyncState {
|
||||
card_id: string;
|
||||
jira_key: string;
|
||||
last_status: string;
|
||||
last_sync_at: string;
|
||||
last_error: string;
|
||||
inflight: boolean;
|
||||
issue_url?: string;
|
||||
}
|
||||
|
||||
export function getCardJiraSync(cardId: string): Promise<CardJiraSyncState> {
|
||||
return fetchJSON(`/cards/${cardId}/jira-sync`);
|
||||
}
|
||||
|
||||
export interface JiraIssue {
|
||||
key: string;
|
||||
summary: string;
|
||||
status_name: string;
|
||||
issue_type: string;
|
||||
assignee: string;
|
||||
updated: string;
|
||||
url: string;
|
||||
already_imported: boolean;
|
||||
mapped_column_id?: string;
|
||||
issue_type_icon?: string;
|
||||
}
|
||||
|
||||
export interface ListJiraIssuesResponse {
|
||||
issues: JiraIssue[];
|
||||
board_id: number;
|
||||
project_key: string;
|
||||
status_to_column: Record<string, string>;
|
||||
include_imported: boolean;
|
||||
}
|
||||
|
||||
export function listJiraIssues(opts?: { includeImported?: boolean; limit?: number }): Promise<ListJiraIssuesResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts?.includeImported) qs.set("include_imported", "true");
|
||||
if (opts?.limit) qs.set("limit", String(opts.limit));
|
||||
const q = qs.toString();
|
||||
return fetchJSON(`/jira/issues${q ? `?${q}` : ""}`);
|
||||
}
|
||||
|
||||
export interface JiraImportResult {
|
||||
key: string;
|
||||
status: "imported" | "skipped" | "error";
|
||||
card_id?: string;
|
||||
column_id?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function importJiraIssues(issueKeys: string[], fallbackColumnId?: string): Promise<{ results: JiraImportResult[] }> {
|
||||
return fetchJSON("/jira/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ issue_keys: issueKeys, fallback_column_id: fallbackColumnId || "" }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
|
||||
const qs = new URLSearchParams();
|
||||
if (f.from) qs.set("from", f.from);
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
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,157 @@
|
||||
import { Anchor, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
||||
import { IconBrandJira, IconAlertCircle } from "@tabler/icons-react";
|
||||
import { CSSProperties, useEffect, useState } from "react";
|
||||
import * as api from "../api";
|
||||
import type { CardJiraSyncState } from "../api";
|
||||
import { formatDateTimeShort } from "./format";
|
||||
|
||||
// Pull state every POLL_MS so the indicator catches up with async dispatcher
|
||||
// pushes without needing SSE. 10s is a reasonable balance: short enough that a
|
||||
// drag-to-Jira shows green within one tick, long enough that the polling is
|
||||
// not a noticeable load.
|
||||
const POLL_MS = 10000;
|
||||
|
||||
type Tone = "gray" | "yellow" | "green" | "red";
|
||||
|
||||
function tone(state: CardJiraSyncState): Tone {
|
||||
if (state.inflight) return "yellow";
|
||||
if (state.last_error) return "red";
|
||||
if (state.jira_key) return "green";
|
||||
return "gray";
|
||||
}
|
||||
|
||||
const TONE_COLOR: Record<Tone, string> = {
|
||||
gray: "var(--mantine-color-gray-5)",
|
||||
yellow: "var(--mantine-color-yellow-5)",
|
||||
green: "var(--mantine-color-green-5)",
|
||||
red: "var(--mantine-color-red-6)",
|
||||
};
|
||||
|
||||
const TONE_LABEL: Record<Tone, string> = {
|
||||
gray: "Sin sincronizar con Jira",
|
||||
yellow: "Sincronizando...",
|
||||
green: "Sincronizada con Jira",
|
||||
red: "Error de sincronizacion",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
cardId: string;
|
||||
// Pollen-down so the parent can refresh when needed (e.g. after a move
|
||||
// animation finishes) without waiting for the next tick.
|
||||
refreshTick?: number;
|
||||
}
|
||||
|
||||
export function JiraSyncIndicator({ cardId, refreshTick }: Props) {
|
||||
const [state, setState] = useState<CardJiraSyncState | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const s = await api.getCardJiraSync(cardId);
|
||||
if (!cancelled) {
|
||||
setState(s);
|
||||
setErr(null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setErr((e as Error).message);
|
||||
}
|
||||
};
|
||||
load();
|
||||
const t = setInterval(load, POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [cardId, refreshTick]);
|
||||
|
||||
if (err && !state) {
|
||||
return (
|
||||
<Box title={err} style={dotStyle("var(--mantine-color-gray-3)")} aria-label="Jira sync state unavailable" />
|
||||
);
|
||||
}
|
||||
if (!state) {
|
||||
// Initial render — fade in a placeholder dot so the layout does not shift
|
||||
// when the fetch resolves.
|
||||
return <Box style={dotStyle("var(--mantine-color-gray-2)")} aria-label="Cargando estado Jira" />;
|
||||
}
|
||||
const t = tone(state);
|
||||
return (
|
||||
<HoverCard width={300} shadow="md" openDelay={150} closeDelay={120} withinPortal>
|
||||
<HoverCard.Target>
|
||||
<Box
|
||||
role="status"
|
||||
aria-label={TONE_LABEL[t]}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={dotStyle(TONE_COLOR[t])}
|
||||
/>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown onClick={(e) => e.stopPropagation()}>
|
||||
<Stack gap={6}>
|
||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<IconBrandJira size={14} />
|
||||
<Text size="sm" fw={600}>{TONE_LABEL[t]}</Text>
|
||||
</Group>
|
||||
{state.issue_url && (
|
||||
<Anchor
|
||||
size="xs"
|
||||
href={state.issue_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Abrir en Jira
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
{state.jira_key && (
|
||||
<Text size="xs">
|
||||
<Text component="span" c="dimmed">Issue:</Text>{" "}
|
||||
<Text component="span" fw={600}>{state.jira_key}</Text>
|
||||
</Text>
|
||||
)}
|
||||
{state.last_status && (
|
||||
<Text size="xs">
|
||||
<Text component="span" c="dimmed">Status:</Text>{" "}
|
||||
<Text component="span">{state.last_status}</Text>
|
||||
</Text>
|
||||
)}
|
||||
{state.last_sync_at && (
|
||||
<Text size="xs">
|
||||
<Text component="span" c="dimmed">Ultimo sync:</Text>{" "}
|
||||
<Text component="span">{formatDateTimeShort(state.last_sync_at)}</Text>
|
||||
</Text>
|
||||
)}
|
||||
{state.last_error && (
|
||||
<Group gap={6} wrap="nowrap" align="flex-start">
|
||||
<IconAlertCircle size={14} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" c="red" style={{ wordBreak: "break-word" }}>{state.last_error}</Text>
|
||||
</Group>
|
||||
)}
|
||||
{!state.jira_key && (
|
||||
<Text size="xs" c="dimmed">
|
||||
La card todavia no se ha empujado a Jira. Editala o muevela para
|
||||
disparar el sync, o usa la opcion "Importar de Jira" si ya existe
|
||||
alli.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function dotStyle(color: string): CSSProperties {
|
||||
return {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
cursor: "default",
|
||||
boxShadow: "0 0 0 2px var(--mantine-color-body)",
|
||||
transition: "background 120ms ease",
|
||||
};
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import type { Card, CardColor, User } from "../types";
|
||||
import { colorBg, colorBorder, tagColor } from "./colors";
|
||||
import { ColorPickerGrid } from "./ColorPickerGrid";
|
||||
import { formatDateTimeShort, formatDuration } from "./format";
|
||||
import { JiraSyncIndicator } from "./JiraSyncIndicator";
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
@@ -358,16 +359,19 @@ const KanbanCardBody = memo(function KanbanCardBody({
|
||||
{card.title}
|
||||
</Text>
|
||||
</Group>
|
||||
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones" style={{ flexShrink: 0 }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<IconDotsVertical size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
|
||||
{menuItems}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
|
||||
<Menu opened={menuOpen} onChange={setMenuOpen} position="bottom-end" shadow="md" withArrow>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<IconDotsVertical size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
|
||||
{menuItems}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<JiraSyncIndicator cardId={card.id} />
|
||||
</Stack>
|
||||
</Group>
|
||||
{(card.requester || assignee) && (
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
|
||||
Reference in New Issue
Block a user