chore: auto-commit (23 archivos)
- app.md - backend/dist/assets/index-CFDWXN9Z.js - backend/dist/index.html - backend/handlers.go - backend/main.go - backend/users.go - e2e/smoke_live.sh - frontend/src/App.tsx - frontend/src/api.ts - frontend/src/components/CardChatPanel.tsx - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,9 @@ interface Props {
|
||||
users: User[];
|
||||
currentUserId?: string;
|
||||
onMessagesChange?: (messages: CardMessage[]) => void;
|
||||
// When set, the panel scrolls the matching message into view and flashes a
|
||||
// brief highlight (~2s). Used by notification click → open card.
|
||||
highlightMessageId?: string;
|
||||
}
|
||||
|
||||
// Window for considering a peer "actively typing" after its last event.
|
||||
@@ -98,7 +101,7 @@ function renderBody(body: string, knownUsers: Map<string, User>): ReactNode {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }: Props) {
|
||||
export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange, highlightMessageId }: Props) {
|
||||
const [messages, setMessages] = useState<CardMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [body, setBody] = useState("");
|
||||
@@ -185,6 +188,22 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
// Scroll to + briefly pulse the message that triggered an incoming
|
||||
// notification. Runs whenever the highlight id changes AND the message
|
||||
// is present in the list (it may arrive asynchronously after WS sync).
|
||||
const [pulse, setPulse] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!highlightMessageId) return;
|
||||
if (!messages.some((m) => m.id === highlightMessageId)) return;
|
||||
const el = document.querySelector(`[data-msg-id="${highlightMessageId}"]`);
|
||||
if (el && el instanceof HTMLElement) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
setPulse(highlightMessageId);
|
||||
const t = setTimeout(() => setPulse(null), 2200);
|
||||
return () => clearTimeout(t);
|
||||
}, [highlightMessageId, messages]);
|
||||
|
||||
const sendTypingPing = () => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
@@ -319,13 +338,25 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
|
||||
const author = m.author_id ? usersById.get(m.author_id) : null;
|
||||
const isMe = m.author_id && m.author_id === currentUserId;
|
||||
const label = author ? author.display_name || author.username : "Anonimo";
|
||||
const highlighted = pulse === m.id;
|
||||
return (
|
||||
<Paper
|
||||
key={m.id}
|
||||
withBorder
|
||||
p="xs"
|
||||
radius="sm"
|
||||
bg={isMe ? "var(--mantine-color-blue-light)" : undefined}
|
||||
data-msg-id={m.id}
|
||||
bg={
|
||||
highlighted
|
||||
? "var(--mantine-color-yellow-light)"
|
||||
: isMe
|
||||
? "var(--mantine-color-blue-light)"
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
transition: "background-color 600ms ease",
|
||||
boxShadow: highlighted ? "0 0 0 2px var(--mantine-color-yellow-5)" : undefined,
|
||||
}}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" align="flex-start">
|
||||
<Avatar size={22} radius="xl" color={author?.color || tagColor(label)}>
|
||||
|
||||
@@ -14,6 +14,9 @@ interface Props {
|
||||
tagOptions: string[];
|
||||
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
// When set, the chat panel auto-scrolls to this message id and pulses
|
||||
// it briefly. Used when opening a card from a notification click.
|
||||
highlightMessageId?: string;
|
||||
}
|
||||
|
||||
export function CardEditPanel({
|
||||
@@ -24,6 +27,7 @@ export function CardEditPanel({
|
||||
tagOptions,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
highlightMessageId,
|
||||
}: Props) {
|
||||
const [messages, setMessages] = useState<CardMessage[]>([]);
|
||||
const [liveCard, setLiveCard] = useState(card);
|
||||
@@ -68,6 +72,7 @@ export function CardEditPanel({
|
||||
users={users}
|
||||
currentUserId={currentUserId}
|
||||
onMessagesChange={setMessages}
|
||||
highlightMessageId={highlightMessageId}
|
||||
/>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Code,
|
||||
Divider,
|
||||
Group,
|
||||
JsonInput,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconPlug, IconPlugConnected, IconRefresh, IconTestPipe, IconTrash } from "@tabler/icons-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import * as api from "../api";
|
||||
import type { KanbanModule, ModuleLog } from "../types";
|
||||
import { formatDateTimeShort } from "./format";
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const KANBAN_EVENTS = [
|
||||
"card.created",
|
||||
"card.updated",
|
||||
"card.moved",
|
||||
"card.deleted",
|
||||
"message.created",
|
||||
"board.invalidated",
|
||||
];
|
||||
|
||||
const DEFAULT_JIRA_CONFIG = {
|
||||
base_url: "",
|
||||
email: "",
|
||||
api_token: "",
|
||||
project_key: "",
|
||||
status_map: {
|
||||
"Por hacer": "To Do",
|
||||
"Doing": "In Progress",
|
||||
"Done": "Done",
|
||||
},
|
||||
};
|
||||
|
||||
export function ModulesModal({ opened, onClose }: Props) {
|
||||
const [modules, setModules] = useState<KanbanModule[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<KanbanModule | null>(null);
|
||||
const [logs, setLogs] = useState<ModuleLog[]>([]);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<string | null>("form");
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await api.listModules();
|
||||
setModules(list);
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) reload();
|
||||
}, [opened, reload]);
|
||||
|
||||
const reloadLogs = useCallback(async (id: string) => {
|
||||
setLogsLoading(true);
|
||||
try {
|
||||
const out = await api.listModuleLogs(id);
|
||||
setLogs(out);
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const select = (m: KanbanModule | null) => {
|
||||
setEditing(m ? { ...m, config: { ...m.config } } : null);
|
||||
setSelectedId(m?.id ?? null);
|
||||
setActiveTab("form");
|
||||
setLogs([]);
|
||||
if (m) reloadLogs(m.id);
|
||||
};
|
||||
|
||||
const startNew = () => {
|
||||
const blank: KanbanModule = {
|
||||
id: "",
|
||||
name: "Nuevo modulo",
|
||||
kind: "jira",
|
||||
enabled: false,
|
||||
event_filter: ["card.created", "card.updated", "card.moved", "message.created"],
|
||||
config: { ...DEFAULT_JIRA_CONFIG, status_map: { ...DEFAULT_JIRA_CONFIG.status_map } },
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
setEditing(blank);
|
||||
setSelectedId(null);
|
||||
setActiveTab("form");
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!editing) return;
|
||||
try {
|
||||
const payload = {
|
||||
name: editing.name,
|
||||
kind: editing.kind,
|
||||
enabled: editing.enabled,
|
||||
event_filter: editing.event_filter,
|
||||
config: editing.config,
|
||||
};
|
||||
const saved = editing.id
|
||||
? await api.updateModule(editing.id, payload)
|
||||
: await api.createModule(payload);
|
||||
notifications.show({ color: "green", message: "Modulo guardado" });
|
||||
await reload();
|
||||
select(saved);
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!selectedId) return;
|
||||
if (!confirm("Borrar modulo?")) return;
|
||||
try {
|
||||
await api.deleteModule(selectedId);
|
||||
notifications.show({ color: "green", message: "Modulo borrado" });
|
||||
setEditing(null);
|
||||
setSelectedId(null);
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
if (!editing) return;
|
||||
try {
|
||||
const result = editing.id
|
||||
? await api.testModule(editing.id)
|
||||
: await api.testModule("draft", {
|
||||
name: editing.name,
|
||||
kind: editing.kind,
|
||||
enabled: editing.enabled,
|
||||
event_filter: editing.event_filter,
|
||||
config: editing.config,
|
||||
});
|
||||
if (result.ok) {
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: `Test OK (${result.status})`,
|
||||
message: `Conexion verificada en ${result.duration_ms}ms`,
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: `Test fallo (${result.status})`,
|
||||
message: result.error || "sin detalle",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<IconPlug size={18} />
|
||||
<Text fw={600}>Modulos / Integraciones</Text>
|
||||
</Group>
|
||||
}
|
||||
size="xl"
|
||||
centered
|
||||
>
|
||||
<Group align="flex-start" gap="md" wrap="nowrap">
|
||||
<Box style={{ width: 220, minWidth: 220 }}>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="xs" c="dimmed">Configurados</Text>
|
||||
<Tooltip label="Refrescar" withArrow>
|
||||
<ActionIcon size="sm" variant="subtle" onClick={reload}>
|
||||
<IconRefresh size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<ScrollArea h={400} type="auto">
|
||||
<Stack gap={4}>
|
||||
{loading && <Loader size="xs" />}
|
||||
{modules.map((m) => (
|
||||
<Box
|
||||
key={m.id}
|
||||
p="xs"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: 4,
|
||||
background:
|
||||
selectedId === m.id ? "var(--mantine-color-blue-light)" : undefined,
|
||||
}}
|
||||
onClick={() => select(m)}
|
||||
>
|
||||
<Group justify="space-between" gap={4} wrap="nowrap">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{m.name}
|
||||
</Text>
|
||||
<Badge size="xs" color={m.enabled ? "green" : "gray"}>
|
||||
{m.enabled ? "on" : "off"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{m.kind}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Button size="xs" variant="light" onClick={startNew} mt="xs">
|
||||
+ Nuevo
|
||||
</Button>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
|
||||
<Divider orientation="vertical" />
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
{!editing ? (
|
||||
<Alert color="gray">Selecciona un modulo o pulsa "Nuevo".</Alert>
|
||||
) : (
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="form">Configuracion</Tabs.Tab>
|
||||
<Tabs.Tab value="logs">Logs</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="form" pt="xs">
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<TextInput
|
||||
label="Nombre"
|
||||
value={editing.name}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.currentTarget.value })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
label="Kind"
|
||||
value={editing.kind}
|
||||
onChange={(v) => setEditing({ ...editing, kind: v || "jira" })}
|
||||
data={[{ value: "jira", label: "Jira" }]}
|
||||
w={140}
|
||||
/>
|
||||
</Group>
|
||||
<Checkbox
|
||||
label="Activo"
|
||||
checked={editing.enabled}
|
||||
onChange={(e) => setEditing({ ...editing, enabled: e.currentTarget.checked })}
|
||||
/>
|
||||
<Box>
|
||||
<Text size="xs" fw={600} mb={4}>Eventos</Text>
|
||||
<Group gap="xs">
|
||||
{KANBAN_EVENTS.map((ev) => (
|
||||
<Checkbox
|
||||
key={ev}
|
||||
label={<Code>{ev}</Code>}
|
||||
checked={editing.event_filter.includes(ev)}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.checked
|
||||
? [...editing.event_filter, ev]
|
||||
: editing.event_filter.filter((x) => x !== ev);
|
||||
setEditing({ ...editing, event_filter: next });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
<JiraConfigEditor editing={editing} setEditing={setEditing} />
|
||||
<Group gap="xs">
|
||||
<Button onClick={save} leftSection={<IconPlugConnected size={14} />}>
|
||||
Guardar
|
||||
</Button>
|
||||
<Button variant="default" onClick={test} leftSection={<IconTestPipe size={14} />}>
|
||||
Probar conexion
|
||||
</Button>
|
||||
{selectedId && (
|
||||
<Button
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onClick={remove}
|
||||
leftSection={<IconTrash size={14} />}
|
||||
ml="auto"
|
||||
>
|
||||
Borrar
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="logs" pt="xs">
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="xs" c="dimmed">Ultimas 100 entradas</Text>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => selectedId && reloadLogs(selectedId)}
|
||||
>
|
||||
<IconRefresh size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
{logsLoading ? (
|
||||
<Loader size="sm" />
|
||||
) : logs.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">Sin entradas.</Text>
|
||||
) : (
|
||||
<ScrollArea h={400}>
|
||||
<Table withTableBorder striped highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Hora</Table.Th>
|
||||
<Table.Th>Evento</Table.Th>
|
||||
<Table.Th>HTTP</Table.Th>
|
||||
<Table.Th>ms</Table.Th>
|
||||
<Table.Th>Error</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{logs.map((l) => (
|
||||
<Table.Tr key={l.id}>
|
||||
<Table.Td>{formatDateTimeShort(l.created_at)}</Table.Td>
|
||||
<Table.Td><Code>{l.event_type}</Code></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={l.status >= 400 || l.error ? "red" : "green"} size="sm">
|
||||
{l.status || "-"}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{l.duration_ms}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="red" lineClamp={2}>{l.error}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface JiraConfigEditorProps {
|
||||
editing: KanbanModule;
|
||||
setEditing: (m: KanbanModule) => void;
|
||||
}
|
||||
|
||||
function JiraConfigEditor({ editing, setEditing }: JiraConfigEditorProps) {
|
||||
const cfg = editing.config as Record<string, unknown>;
|
||||
const set = (key: string, value: unknown) =>
|
||||
setEditing({ ...editing, config: { ...cfg, [key]: value } });
|
||||
|
||||
const statusMapText = useMemo(() => {
|
||||
return JSON.stringify(cfg.status_map ?? {}, null, 2);
|
||||
}, [cfg.status_map]);
|
||||
|
||||
if (editing.kind !== "jira") {
|
||||
return (
|
||||
<Alert color="yellow" mt="xs">
|
||||
Editor especifico para esta kind aun no implementado.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
label="Base URL"
|
||||
placeholder="https://acme.atlassian.net"
|
||||
value={(cfg.base_url as string) || ""}
|
||||
onChange={(e) => set("base_url", e.currentTarget.value)}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<TextInput
|
||||
label="Email"
|
||||
value={(cfg.email as string) || ""}
|
||||
onChange={(e) => set("email", e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="API token"
|
||||
placeholder={editing.id ? "*** (deja vacio para conservar)" : ""}
|
||||
value={(cfg.api_token as string) || ""}
|
||||
onChange={(e) => set("api_token", e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Project key"
|
||||
placeholder="KAN"
|
||||
value={(cfg.project_key as string) || ""}
|
||||
onChange={(e) => set("project_key", e.currentTarget.value)}
|
||||
/>
|
||||
<JsonInput
|
||||
label="Status map (columna kanban → transicion Jira)"
|
||||
description='{"Doing":"In Progress","Done":"Done"}'
|
||||
value={statusMapText}
|
||||
autosize
|
||||
minRows={3}
|
||||
validationError="JSON invalido"
|
||||
onChange={(v) => {
|
||||
try {
|
||||
const parsed = JSON.parse(v);
|
||||
set("status_map", parsed);
|
||||
} catch {
|
||||
// Hold invalid input in textarea via raw state; final save will
|
||||
// reuse last valid parse.
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,9 @@ interface Props {
|
||||
unreadCount?: number;
|
||||
notifications?: Notification[];
|
||||
// Called when the user clicks a notification → open the relevant card.
|
||||
onOpenCard?: (cardId: string) => void;
|
||||
// messageId points to the chat message that triggered the notification so
|
||||
// the parent can scroll to it.
|
||||
onOpenCard?: (cardId: string, messageId: string) => void;
|
||||
// Called whenever the bell mutates state (mark read / mark all) so the
|
||||
// parent can refresh its cached lists.
|
||||
onChanged?: () => void;
|
||||
@@ -101,7 +103,7 @@ export function NotificationsBell({ unreadCount: extCount, notifications: extLis
|
||||
}
|
||||
}
|
||||
setOpened(false);
|
||||
onOpenCard?.(n.card_id);
|
||||
onOpenCard?.(n.card_id, n.message_id);
|
||||
};
|
||||
|
||||
const handleMarkAll = async () => {
|
||||
|
||||
Reference in New Issue
Block a user