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:
2026-05-21 18:22:44 +02:00
parent 2524340759
commit c9e15513c7
22 changed files with 2380 additions and 179 deletions
+33 -2
View File
@@ -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>
+441
View File
@@ -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 () => {