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:
+257
-65
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user