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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user