chore: auto-commit (28 archivos)

- app.md
- auth.go
- chat.go
- chat.log
- db.go
- frontend/package.json
- frontend/pnpm-lock.yaml
- frontend/src/App.tsx
- frontend/src/Root.tsx
- frontend/src/api.ts
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 00:27:18 +02:00
parent c915e721af
commit bee688e574
28 changed files with 3601 additions and 300 deletions
+257 -65
View File
@@ -24,11 +24,16 @@ import {
import {
ActionIcon,
AppShell,
Avatar,
Badge,
Box,
Button,
Group,
Loader,
Menu,
Paper,
Stack,
Tabs,
Text,
TextInput,
Title,
@@ -37,65 +42,36 @@ import {
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import {
IconColumnInsertRight,
IconArrowBackUp,
IconCalendar,
IconChartBar,
IconChevronDown,
IconChevronRight,
IconLayoutKanban,
IconLogout,
IconMenu2,
IconMessageChatbot,
IconPlus,
IconRefresh,
IconTrash,
IconTrashX,
IconX,
} from "@tabler/icons-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as api from "./api";
import { useAuth } from "./auth";
import { CardForm } from "./components/CardForm";
import { ChatPanel } from "./components/ChatPanel";
import { CalendarView } from "./components/CalendarView";
import { Dashboard } from "./components/Dashboard";
import { HistoryModal } from "./components/HistoryModal";
import { KanbanCard } from "./components/KanbanCard";
import { KanbanColumn } from "./components/KanbanColumn";
import { colorBg, colorBorder } from "./components/colors";
import type { Board, Card, CardColor, Column, ColumnLocation } from "./types";
import type { Board, Card, CardColor, Column, ColumnLocation, User } from "./types";
const COL_PREFIX = "column-";
function AddColumnDialog({
onSubmit,
onCancel,
}: {
onSubmit: (name: string) => Promise<void> | void;
onCancel: () => void;
}) {
const [name, setName] = useState("");
const submit = () => {
const n = name.trim();
if (n) onSubmit(n);
};
return (
<Stack gap="sm">
<TextInput
label="Nombre"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
data-autofocus
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
}
}}
/>
<Group justify="flex-end" gap="xs">
<Button variant="subtle" color="gray" onClick={onCancel}>
Cancelar
</Button>
<Button onClick={submit} disabled={!name.trim()}>
Crear
</Button>
</Group>
</Stack>
);
}
// Custom collision detection: prefiere otras columnas como destino al arrastrar
// columnas; al arrastrar cards prefiere cards/columnas via closestCorners.
function makeCollisionDetection(activeType: string | undefined): CollisionDetection {
@@ -118,7 +94,9 @@ function makeCollisionDetection(activeType: string | undefined): CollisionDetect
}
export function App() {
const auth = useAuth();
const [board, setBoard] = useState<Board | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [activeCard, setActiveCard] = useState<Card | null>(null);
const [activeColumnId, setActiveColumnId] = useState<string | null>(null);
const [activeType, setActiveType] = useState<string | undefined>(undefined);
@@ -126,6 +104,9 @@ export function App() {
const [colName, setColName] = useState("");
const [now, setNow] = useState(Date.now());
const [chatOpen, setChatOpen] = useState(false);
const [activeTab, setActiveTab] = useState<string>("board");
const [trash, setTrash] = useState<Card[]>([]);
const [trashOpen, setTrashOpen] = useState(false);
const [navOpen, setNavOpen] = useState(false);
const [navWidth, setNavWidth] = useState<number>(() => {
const stored = localStorage.getItem("kanban_nav_width");
@@ -177,11 +158,43 @@ export function App() {
reload();
}, [reload]);
const reloadUsers = useCallback(async () => {
try {
const us = await api.listUsers();
setUsers(us);
} catch (e) {
console.warn("listUsers failed", e);
}
}, []);
const reloadTrash = useCallback(async () => {
try {
const t = await api.listTrash();
setTrash(t);
} catch (e) {
console.warn("listTrash failed", e);
}
}, []);
useEffect(() => {
reloadUsers();
}, [reloadUsers]);
useEffect(() => {
reloadTrash();
}, [reloadTrash]);
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
const usersById = useMemo(() => {
const m = new Map<string, User>();
for (const u of users) m.set(u.id, u);
return m;
}, [users]);
const sortedColumns = useMemo(() => {
if (!board) return [];
return [...board.columns].sort((a, b) => a.position - b.position);
@@ -360,22 +373,6 @@ export function App() {
}
};
const openAddColumnModal = useCallback(() => {
const id = modals.open({
title: "Nueva columna",
size: "sm",
children: <AddColumnDialog onSubmit={async (name) => {
try {
await api.createColumn(name);
modals.close(id);
reload();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}} onCancel={() => modals.close(id)} />,
});
}, [reload]);
const handleRenameColumn = useCallback(async (id: string, name: string) => {
try {
await api.updateColumn(id, { name });
@@ -426,6 +423,8 @@ export function App() {
size: "md",
children: (
<CardForm
users={users}
initial={{ requester: auth.user?.display_name || auth.user?.username || "" }}
submitLabel="Crear"
onCancel={() => modals.close(id)}
onSubmit={async (v) => {
@@ -435,6 +434,7 @@ export function App() {
requester: v.requester,
title: v.title,
description: v.description,
assignee_id: v.assignee_id,
});
modals.close(id);
reload();
@@ -445,7 +445,7 @@ export function App() {
/>
),
});
}, [reload]);
}, [reload, users, auth.user]);
const openEditCard = useCallback((card: Card) => {
const id = modals.open({
@@ -453,7 +453,13 @@ export function App() {
size: "md",
children: (
<CardForm
initial={{ requester: card.requester, title: card.title, description: card.description }}
users={users}
initial={{
requester: card.requester,
title: card.title,
description: card.description,
assignee_id: card.assignee_id,
}}
submitLabel="Guardar"
onCancel={() => modals.close(id)}
onSubmit={async (v) => {
@@ -462,6 +468,7 @@ export function App() {
requester: v.requester,
title: v.title,
description: v.description,
assignee_id: v.assignee_id,
});
modals.close(id);
reload();
@@ -472,16 +479,57 @@ export function App() {
/>
),
});
}, [reload, users]);
const handleAssignCard = useCallback(async (id: string, assignee_id: string | null) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, assignee_id } : c)) };
});
try {
await api.updateCard(id, { assignee_id });
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
reload();
}
}, [reload]);
const handleDeleteCard = useCallback(async (id: string) => {
try {
await api.deleteCard(id);
reload();
reloadTrash();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}, [reload]);
}, [reload, reloadTrash]);
const handleRestoreCard = useCallback(async (id: string) => {
try {
await api.restoreCard(id);
reload();
reloadTrash();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}, [reload, reloadTrash]);
const handlePurgeCard = useCallback(async (id: string) => {
modals.openConfirmModal({
title: "Borrar permanentemente",
children: <Text size="sm">Esta accion no se puede deshacer.</Text>,
labels: { confirm: "Borrar", cancel: "Cancelar" },
confirmProps: { color: "red" },
onConfirm: async () => {
try {
await api.purgeCard(id);
reloadTrash();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
},
});
}, [reloadTrash]);
const handleChangeCardColor = useCallback(async (id: string, color: CardColor) => {
setBoard((prev) => {
@@ -504,6 +552,46 @@ export function App() {
});
}, []);
const handleToggleCardLock = useCallback(async (id: string, locked: boolean) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, locked } : c)) };
});
try {
await api.updateCard(id, { locked });
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
reload();
}
}, [reload]);
const handleSetWIPLimit = useCallback(async (id: string, wip_limit: number) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, wip_limit } : c)) };
});
try {
await api.updateColumn(id, { wip_limit });
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
reload();
}
}, [reload]);
const handleToggleDone = useCallback(async (id: string, is_done: boolean) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, is_done } : c)) };
});
try {
await api.updateColumn(id, { is_done });
reload();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
reload();
}
}, [reload]);
const headerConfig = useMemo(() => ({ height: 50 }), []);
const navbarConfig = useMemo(
() => ({
@@ -564,13 +652,21 @@ export function App() {
</ActionIcon>
<IconLayoutKanban size={22} />
<Title order={4}>Kanban</Title>
<Tabs value={activeTab} onChange={(v) => v && setActiveTab(v)} variant="pills" ml="md">
<Tabs.List>
<Tabs.Tab value="board" leftSection={<IconLayoutKanban size={14} />}>
Tablero
</Tabs.Tab>
<Tabs.Tab value="dashboard" leftSection={<IconChartBar size={14} />}>
Dashboard
</Tabs.Tab>
<Tabs.Tab value="calendar" leftSection={<IconCalendar size={14} />}>
Calendario
</Tabs.Tab>
</Tabs.List>
</Tabs>
</Group>
<Group gap={4}>
<Tooltip label="Nueva columna" withArrow>
<ActionIcon variant="subtle" onClick={openAddColumnModal} aria-label="Add column">
<IconColumnInsertRight size={16} />
</ActionIcon>
</Tooltip>
<ActionIcon variant="subtle" onClick={reload} aria-label="Refresh">
<IconRefresh size={16} />
</ActionIcon>
@@ -581,6 +677,27 @@ export function App() {
>
<IconMessageChatbot size={16} />
</ActionIcon>
{auth.user && (
<Menu position="bottom-end" shadow="md" withArrow>
<Menu.Target>
<ActionIcon variant="subtle" aria-label="Usuario">
<Avatar size={26} radius="xl" color="blue">
{(auth.user.display_name || auth.user.username).slice(0, 2).toUpperCase()}
</Avatar>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{auth.user.display_name || auth.user.username}</Menu.Label>
<Menu.Item
leftSection={<IconLogout size={14} />}
color="red"
onClick={() => auth.logout()}
>
Cerrar sesion
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Group>
</Group>
</AppShell.Header>
@@ -624,15 +741,70 @@ export function App() {
onResizeColumn={handleResizeColumn}
onMoveColumnLocation={handleMoveColumnLocation}
onDeleteColumn={handleDeleteColumn}
onSetWIPLimit={handleSetWIPLimit}
onToggleDone={handleToggleDone}
onEditCard={openEditCard}
onDeleteCard={handleDeleteCard}
onChangeCardColor={handleChangeCardColor}
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
onAssignCard={handleAssignCard}
users={users}
usersById={usersById}
/>
))}
</Stack>
</SortableContext>
</Box>
<Box style={{ borderTop: "1px solid var(--mantine-color-dark-5)", paddingTop: 8 }}>
<Button
variant="subtle"
color="gray"
size="xs"
fullWidth
justify="space-between"
leftSection={<IconTrash size={14} />}
rightSection={
<Group gap={4}>
<Badge size="xs" variant="light" color={trash.length > 0 ? "red" : "gray"}>
{trash.length}
</Badge>
{trashOpen ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}
</Group>
}
onClick={() => setTrashOpen((v) => !v)}
>
Papelera
</Button>
{trashOpen && (
<Stack gap={4} mt={4} style={{ maxHeight: 220, overflowY: "auto" }}>
{trash.length === 0 && (
<Text size="xs" c="dimmed" px="xs">
Vacia.
</Text>
)}
{trash.map((c) => (
<Paper key={c.id} p={6} withBorder radius="sm" bg="dark.7">
<Group justify="space-between" gap={4} wrap="nowrap">
<Text size="xs" truncate style={{ flex: 1 }} title={c.title}>
{c.title}
</Text>
<Tooltip label="Restaurar" withArrow>
<ActionIcon size="xs" variant="subtle" color="green" onClick={() => handleRestoreCard(c.id)}>
<IconArrowBackUp size={12} />
</ActionIcon>
</Tooltip>
<Tooltip label="Borrar permanentemente" withArrow>
<ActionIcon size="xs" variant="subtle" color="red" onClick={() => handlePurgeCard(c.id)}>
<IconTrashX size={12} />
</ActionIcon>
</Tooltip>
</Group>
</Paper>
))}
</Stack>
)}
</Box>
</Stack>
</AppShell.Navbar>
@@ -641,6 +813,15 @@ export function App() {
</AppShell.Aside>
<AppShell.Main>
{activeTab === "dashboard" ? (
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
<Dashboard users={users} />
</Box>
) : activeTab === "calendar" ? (
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
<CalendarView users={users} />
</Box>
) : (
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden" }}>
<SortableContext items={boardSortableIds} strategy={horizontalListSortingStrategy}>
<Group
@@ -661,10 +842,16 @@ export function App() {
onResizeColumn={handleResizeColumn}
onMoveColumnLocation={handleMoveColumnLocation}
onDeleteColumn={handleDeleteColumn}
onSetWIPLimit={handleSetWIPLimit}
onToggleDone={handleToggleDone}
onEditCard={openEditCard}
onDeleteCard={handleDeleteCard}
onChangeCardColor={handleChangeCardColor}
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
onAssignCard={handleAssignCard}
users={users}
usersById={usersById}
/>
))}
@@ -708,6 +895,7 @@ export function App() {
</Group>
</SortableContext>
</Box>
)}
</AppShell.Main>
</AppShell>
@@ -721,6 +909,10 @@ export function App() {
onEdit={() => {}}
onChangeColor={() => {}}
onShowHistory={() => {}}
onToggleLock={() => {}}
onAssign={() => {}}
users={users}
assignee={dragOverlayCard.assignee_id ? usersById.get(dragOverlayCard.assignee_id) : undefined}
isOverlay
/>
) : dragOverlayColumn ? (