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
+229 -71
View File
@@ -2,25 +2,32 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ActionIcon,
Avatar,
Badge,
Group,
Menu,
Paper,
Popover,
Select,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import {
IconClock,
IconDotsVertical,
IconEdit,
IconGripVertical,
IconHistory,
IconLock,
IconLockOpen,
IconPalette,
IconTrash,
IconUser,
IconUserCircle,
} from "@tabler/icons-react";
import { memo, useState } from "react";
import type { Card, CardColor } from "../types";
import type { Card, CardColor, User } from "../types";
import { CARD_COLORS, colorBg, colorBorder, colorSwatch } from "./colors";
import { formatDuration } from "./format";
@@ -31,14 +38,36 @@ interface Props {
onEdit: (card: Card) => void;
onChangeColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void;
onToggleLock: (id: string, locked: boolean) => void;
onAssign: (id: string, assignee_id: string | null) => void;
users: User[];
assignee?: User;
inDoneColumn?: boolean;
isOverlay?: boolean;
}
function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHistory, isOverlay }: Props) {
const [popOpen, setPopOpen] = useState(false);
function KanbanCardImpl({
card,
now,
onDelete,
onEdit,
onChangeColor,
onShowHistory,
onToggleLock,
onAssign,
users,
assignee,
inDoneColumn,
isOverlay,
}: Props) {
const isDone = inDoneColumn || !!card.completed_at;
const [colorPopOpen, setColorPopOpen] = useState(false);
const [assigneePopOpen, setAssigneePopOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: card.id,
data: { type: "card", columnId: card.column_id },
disabled: card.locked,
});
const style: React.CSSProperties = {
@@ -46,84 +75,205 @@ function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHist
transition,
opacity: isDragging ? 0.4 : 1,
background: colorBg(card.color),
borderColor: colorBorder(card.color),
borderColor: card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
borderWidth: card.locked ? 2 : 1,
};
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
const liveMs = Math.max(0, now - enteredAt);
const onContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setMenuOpen(true);
};
const menuItems = (
<>
<Menu.Label>Acciones</Menu.Label>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={() => {
setMenuOpen(false);
onEdit(card);
}}
>
Editar
</Menu.Item>
<Popover
opened={colorPopOpen}
onChange={setColorPopOpen}
position="right-start"
withArrow
shadow="md"
>
<Popover.Target>
<Menu.Item
leftSection={<IconPalette size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setColorPopOpen((v) => !v);
}}
closeMenuOnClick={false}
>
Color
</Menu.Item>
</Popover.Target>
<Popover.Dropdown p="xs">
<Group gap={4} maw={200}>
{CARD_COLORS.map((c) => (
<Tooltip key={c.value} label={c.label} withArrow>
<ActionIcon
variant={card.color === c.value ? "filled" : "default"}
size="md"
radius="xl"
style={{
background: colorSwatch(c.value),
borderColor: colorBorder(c.value),
}}
onClick={() => {
onChangeColor(card.id, c.value);
setColorPopOpen(false);
setMenuOpen(false);
}}
aria-label={c.label}
/>
</Tooltip>
))}
</Group>
</Popover.Dropdown>
</Popover>
<Popover
opened={assigneePopOpen}
onChange={setAssigneePopOpen}
position="right-start"
withArrow
shadow="md"
>
<Popover.Target>
<Menu.Item
leftSection={<IconUserCircle size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAssigneePopOpen((v) => !v);
}}
closeMenuOnClick={false}
>
Asignar a {assignee ? `(${assignee.display_name || assignee.username})` : "..."}
</Menu.Item>
</Popover.Target>
<Popover.Dropdown p="xs">
<Select
placeholder="Sin asignar"
value={card.assignee_id ?? null}
onChange={(v) => {
onAssign(card.id, v);
setAssigneePopOpen(false);
setMenuOpen(false);
}}
data={users.map((u) => ({ value: u.id, label: u.display_name || u.username }))}
clearable
searchable
autoFocus
/>
</Popover.Dropdown>
</Popover>
<Menu.Item
leftSection={card.locked ? <IconLockOpen size={14} /> : <IconLock size={14} />}
color={card.locked ? "yellow" : undefined}
onClick={() => {
setMenuOpen(false);
onToggleLock(card.id, !card.locked);
}}
>
{card.locked ? "Desbloquear" : "Bloquear"}
</Menu.Item>
<Menu.Item
leftSection={<IconHistory size={14} />}
onClick={() => {
setMenuOpen(false);
onShowHistory(card);
}}
>
Historial
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={() => {
setMenuOpen(false);
onDelete(card.id);
}}
>
Borrar
</Menu.Item>
</>
);
return (
<Paper ref={setNodeRef} style={style} withBorder p="xs" shadow={isOverlay ? "lg" : "xs"} radius="md">
<Paper
ref={setNodeRef}
style={{ ...style, cursor: card.locked ? "default" : "grab", touchAction: "none" }}
withBorder
p="xs"
shadow={isOverlay ? "lg" : "xs"}
radius="md"
onContextMenu={onContextMenu}
onDoubleClick={(e) => {
e.stopPropagation();
onEdit(card);
}}
{...attributes}
{...(card.locked ? {} : listeners)}
>
<Stack gap={6}>
<Group justify="space-between" gap={4} wrap="nowrap">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<ActionIcon
variant="subtle"
color="gray"
<Group justify="space-between" gap={4} wrap="nowrap" align="flex-start">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }} align="flex-start">
<IconGripVertical
size={14}
color="var(--mantine-color-dark-2)"
style={{ flexShrink: 0, marginTop: 4 }}
/>
{card.locked && (
<Tooltip label="Bloqueada" withArrow>
<IconLock
size={14}
color="var(--mantine-color-yellow-6)"
style={{ flexShrink: 0, marginTop: 4 }}
/>
</Tooltip>
)}
<Text
size="sm"
{...attributes}
{...listeners}
style={{ cursor: "grab" }}
aria-label="Drag"
fw={500}
style={{
flex: 1,
wordBreak: "break-word",
whiteSpace: "normal",
textDecoration: isDone ? "line-through" : "none",
opacity: isDone ? 0.7 : 1,
}}
>
<IconGripVertical size={14} />
</ActionIcon>
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>
{card.title}
</Text>
</Group>
<Group gap={2} wrap="nowrap">
<Popover opened={popOpen} onChange={setPopOpen} withArrow shadow="md" position="bottom-end">
<Popover.Target>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => setPopOpen((v) => !v)}
aria-label="Color"
>
<IconPalette size={14} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown p="xs">
<Group gap={4} maw={200}>
{CARD_COLORS.map((c) => (
<Tooltip key={c.value} label={c.label} withArrow>
<ActionIcon
variant={card.color === c.value ? "filled" : "default"}
size="md"
radius="xl"
style={{
background: colorSwatch(c.value),
borderColor: colorBorder(c.value),
}}
onClick={() => {
onChangeColor(card.id, c.value);
setPopOpen(false);
}}
aria-label={c.label}
/>
</Tooltip>
))}
</Group>
</Popover.Dropdown>
</Popover>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={() => onEdit(card)} aria-label="Edit">
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => onShowHistory(card)}
aria-label="History"
>
<IconHistory size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => onDelete(card.id)} aria-label="Delete">
<IconTrash size={14} />
</ActionIcon>
</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>{menuItems}</Menu.Dropdown>
</Menu>
</Group>
{card.requester && (
<Group gap={4}>
@@ -133,6 +283,16 @@ function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHist
</Text>
</Group>
)}
{assignee && (
<Group gap={6} wrap="nowrap">
<Avatar size={18} radius="xl" color="blue">
{(assignee.display_name || assignee.username).slice(0, 2).toUpperCase()}
</Avatar>
<Text size="xs" c="dimmed">
{assignee.display_name || assignee.username}
</Text>
</Group>
)}
{card.description && (
<Text size="xs" c="dimmed" lineClamp={3}>
{card.description}
@@ -148,6 +308,4 @@ function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHist
);
}
// memo: re-render solo cuando cambian props relevantes (card, now). Evita rerenders
// en cascada cuando otra columna cambia durante drag-over.
export const KanbanCard = memo(KanbanCardImpl);