feat(kanban): deadlines en cards (context menu, badges, calendario, history)
- migration 009 + columna deadline TEXT en cards - backend: CardPatch.HasDeadline, eventos deadline_set/deadline_cleared - KanbanCard: menu derecho con DatePicker, badge countdown con colores por ratio (azul>=50%, amarillo<50%, rojo<10%, red.9 overdue) - App.tsx: filtro "Con deadline", handleSetCardDeadline optimista, jump-to-card + highlight - CalendarView: popover por dia con seq_num + titulo, click navega a card en tablero - HistoryModal: render eventos deadline_set/deadline_cleared - .gitignore: *.log Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,26 +4,31 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Popover,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { MonthPickerInput } from "@mantine/dates";
|
||||
import { IconCheckbox, IconPlus } from "@tabler/icons-react";
|
||||
import { IconCheckbox, IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import * as api from "../api";
|
||||
import type { Metrics, User } from "../types";
|
||||
import type { Card, Metrics, User } from "../types";
|
||||
|
||||
interface Props {
|
||||
users: User[];
|
||||
cards: Card[];
|
||||
onJumpToCard?: (cardId: string) => void;
|
||||
}
|
||||
|
||||
const DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
|
||||
|
||||
export function CalendarView({ users }: Props) {
|
||||
export function CalendarView({ users, cards, onJumpToCard }: Props) {
|
||||
const [openDate, setOpenDate] = useState<string | null>(null);
|
||||
const [month, setMonth] = useState<Date>(new Date());
|
||||
const [assigneeId, setAssigneeId] = useState<string | null>(null);
|
||||
const [data, setData] = useState<Metrics | null>(null);
|
||||
@@ -53,20 +58,27 @@ export function CalendarView({ users }: Props) {
|
||||
);
|
||||
|
||||
const dayMap = useMemo(() => {
|
||||
const m = new Map<string, { created: number; done: number }>();
|
||||
const m = new Map<string, { created: number; done: number; deadlines: Card[] }>();
|
||||
if (!data) return m;
|
||||
for (const d of data.created_daily) {
|
||||
const cur = m.get(d.date) ?? { created: 0, done: 0 };
|
||||
const cur = m.get(d.date) ?? { created: 0, done: 0, deadlines: [] };
|
||||
cur.created = d.count;
|
||||
m.set(d.date, cur);
|
||||
}
|
||||
for (const d of data.throughput_daily) {
|
||||
const cur = m.get(d.date) ?? { created: 0, done: 0 };
|
||||
const cur = m.get(d.date) ?? { created: 0, done: 0, deadlines: [] };
|
||||
cur.done = d.count;
|
||||
m.set(d.date, cur);
|
||||
}
|
||||
for (const c of cards) {
|
||||
if (!c.deadline || c.deleted_at) continue;
|
||||
const date = c.deadline.slice(0, 10);
|
||||
const cur = m.get(date) ?? { created: 0, done: 0, deadlines: [] };
|
||||
cur.deadlines.push(c);
|
||||
m.set(date, cur);
|
||||
}
|
||||
return m;
|
||||
}, [data]);
|
||||
}, [data, cards]);
|
||||
|
||||
// Build month grid (Mon-first).
|
||||
const grid = useMemo(() => {
|
||||
@@ -163,9 +175,12 @@ export function CalendarView({ users }: Props) {
|
||||
if (!cell.date) {
|
||||
return <Box key={i} style={{ minHeight: 72 }} />;
|
||||
}
|
||||
const stats = dayMap.get(cell.date) ?? { created: 0, done: 0 };
|
||||
const stats = dayMap.get(cell.date) ?? { created: 0, done: 0, deadlines: [] as Card[] };
|
||||
const dayNum = parseInt(cell.date.slice(8, 10), 10);
|
||||
const isToday = cell.date === dayjs().format("YYYY-MM-DD");
|
||||
const todayMs = dayjs().startOf("day").valueOf();
|
||||
const cellMs = dayjs(cell.date).startOf("day").valueOf();
|
||||
const overdueDay = cellMs < todayMs;
|
||||
return (
|
||||
<Paper
|
||||
key={i}
|
||||
@@ -203,6 +218,62 @@ export function CalendarView({ users }: Props) {
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{stats.deadlines.length > 0 && (
|
||||
<Popover
|
||||
opened={openDate === cell.date}
|
||||
onChange={(o) => setOpenDate(o ? cell.date : null)}
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="md"
|
||||
width={280}
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
onClick={() => setOpenDate(openDate === cell.date ? null : cell.date)}
|
||||
style={{ textAlign: "left" }}
|
||||
>
|
||||
<Stack gap={1}>
|
||||
<Group gap={3} wrap="nowrap">
|
||||
<IconHourglass size={10} color={overdueDay ? "var(--mantine-color-red-5)" : "var(--mantine-color-orange-5)"} />
|
||||
<Text size="xs" c={overdueDay ? "red" : "orange"} fw={700} td="underline">
|
||||
{stats.deadlines.length} deadline{stats.deadlines.length === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={6}>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" fw={600} mb={2}>
|
||||
Vencen el {dayjs(cell.date).format("DD/MM/YYYY")}
|
||||
</Text>
|
||||
{stats.deadlines.map((c) => (
|
||||
<UnstyledButton
|
||||
key={c.id}
|
||||
onClick={() => {
|
||||
setOpenDate(null);
|
||||
onJumpToCard?.(c.id);
|
||||
}}
|
||||
style={{
|
||||
padding: "4px 6px",
|
||||
borderRadius: 4,
|
||||
background: "var(--mantine-color-dark-6)",
|
||||
}}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
#{String(c.seq_num).padStart(5, "0")}
|
||||
</Text>
|
||||
<Text size="xs" lineClamp={1} title={c.title}>
|
||||
{c.title}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Box, Button, ColorPicker, Group, Modal, Stack, TextInput, Tooltip } from "@mantine/core";
|
||||
import { IconPalette } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CARD_COLORS, colorBorder, colorSwatch } from "./colors";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
options?: { value: string; label: string }[];
|
||||
// Si se da, el "+" delega en el padre (recomendado dentro de Menu/Popover).
|
||||
// Sin esto, ColorPickerGrid abre Modal interno (puede colisionar con cierres del padre).
|
||||
onOpenCustom?: () => void;
|
||||
}
|
||||
|
||||
const SWATCH = 26;
|
||||
|
||||
export function ColorPickerGrid({ value, onChange, options = CARD_COLORS, onOpenCustom }: Props) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [custom, setCustom] = useState(value && value.startsWith("#") ? value : "#888888");
|
||||
const isCustomActive = !!value && value.startsWith("#") && !options.some((o) => o.value === value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group gap={6} maw={280}>
|
||||
{options.map((c) => {
|
||||
const selected = value === c.value;
|
||||
return (
|
||||
<Tooltip key={c.value || "default"} label={c.label} withArrow>
|
||||
<Box
|
||||
role="button"
|
||||
onClick={(e) => { e.stopPropagation(); onChange(c.value); }}
|
||||
aria-label={c.label}
|
||||
style={{
|
||||
width: SWATCH,
|
||||
height: SWATCH,
|
||||
borderRadius: "50%",
|
||||
background: colorSwatch(c.value),
|
||||
border: `2px solid ${selected ? "var(--mantine-color-white)" : colorBorder(c.value)}`,
|
||||
boxShadow: selected ? "0 0 0 2px var(--mantine-color-blue-5)" : undefined,
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
transition: "transform .1s",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<Tooltip label="Color personalizado" withArrow>
|
||||
<Box
|
||||
role="button"
|
||||
onMouseDown={(e) => { e.stopPropagation(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onOpenCustom) onOpenCustom();
|
||||
else setPickerOpen(true);
|
||||
}}
|
||||
aria-label="Color personalizado"
|
||||
style={{
|
||||
width: SWATCH,
|
||||
height: SWATCH,
|
||||
borderRadius: "50%",
|
||||
background: isCustomActive ? custom : "transparent",
|
||||
border: `2px dashed ${isCustomActive ? custom : "var(--mantine-color-gray-5)"}`,
|
||||
boxShadow: isCustomActive ? "0 0 0 2px var(--mantine-color-blue-5)" : undefined,
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--mantine-color-gray-3)",
|
||||
}}
|
||||
>
|
||||
<IconPalette size={14} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{!onOpenCustom && (
|
||||
<CustomColorModal
|
||||
opened={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
value={custom}
|
||||
onAccept={(v) => { setCustom(v); onChange(v); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
value: string;
|
||||
// Disparado solo cuando el usuario pulsa "Aceptar". Mientras arrastra el picker
|
||||
// el cambio queda local — no fuga al resto de la app.
|
||||
onAccept: (v: string) => void;
|
||||
}
|
||||
|
||||
const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||
|
||||
export function CustomColorModal({ opened, onClose, value, onAccept }: ModalProps) {
|
||||
const [local, setLocal] = useState(value || "#888888");
|
||||
const [hexInput, setHexInput] = useState(value || "#888888");
|
||||
|
||||
// Reset state cuando abre con un value nuevo (cada vez que se abre).
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
const v = value && HEX_RE.test(value) ? value : "#888888";
|
||||
setLocal(v);
|
||||
setHexInput(v);
|
||||
}
|
||||
}, [opened, value]);
|
||||
|
||||
const onHexChange = (v: string) => {
|
||||
let s = v.trim();
|
||||
if (s && !s.startsWith("#")) s = "#" + s;
|
||||
setHexInput(s);
|
||||
if (HEX_RE.test(s)) setLocal(s);
|
||||
};
|
||||
const onPickerChange = (v: string) => {
|
||||
setLocal(v);
|
||||
setHexInput(v);
|
||||
};
|
||||
const accept = () => { onAccept(local); onClose(); };
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Color personalizado"
|
||||
size="auto"
|
||||
centered
|
||||
withinPortal
|
||||
zIndex={2000}
|
||||
closeOnClickOutside
|
||||
closeOnEscape={false}
|
||||
trapFocus={false}
|
||||
withCloseButton={false}
|
||||
>
|
||||
<Stack
|
||||
gap="sm"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ColorPicker
|
||||
value={local}
|
||||
onChange={onPickerChange}
|
||||
format="hex"
|
||||
swatches={["#1c7ed6", "#15aabf", "#12b886", "#37b24d", "#82c91e", "#fab005", "#fd7e14", "#fa5252", "#e64980", "#be4bdb", "#7950f2", "#4c6ef5", "#868e96", "#212529"]}
|
||||
fullWidth
|
||||
/>
|
||||
<Group align="end" gap="xs">
|
||||
<TextInput
|
||||
label="Hex"
|
||||
value={hexInput}
|
||||
onChange={(e) => onHexChange(e.currentTarget.value)}
|
||||
error={hexInput && !HEX_RE.test(hexInput) ? "Hex invalido" : undefined}
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
placeholder="#rrggbb"
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 4,
|
||||
background: HEX_RE.test(hexInput) ? hexInput : "transparent",
|
||||
border: "1px solid var(--mantine-color-dark-4)",
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="default" size="xs" onClick={onClose}>Cancelar</Button>
|
||||
<Button size="xs" onClick={accept} disabled={!HEX_RE.test(local)}>Aceptar</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
MultiSelect,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
@@ -89,10 +90,16 @@ export function Dashboard({ users }: Props) {
|
||||
const [to, setTo] = useState<Date | null>(() => new Date());
|
||||
const [assigneeId, setAssigneeId] = useState<string | null>(null);
|
||||
const [requester, setRequester] = useState<string | null>(null);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [tagOptions, setTagOptions] = useState<string[]>([]);
|
||||
const [data, setData] = useState<Metrics | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.listTags().then(setTagOptions).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
@@ -102,6 +109,7 @@ export function Dashboard({ users }: Props) {
|
||||
to: fmtDate(to),
|
||||
assignee_id: assigneeId || undefined,
|
||||
requester: requester || undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
})
|
||||
.then((m) => {
|
||||
if (cancelled) return;
|
||||
@@ -119,7 +127,7 @@ export function Dashboard({ users }: Props) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [from, to, assigneeId, requester]);
|
||||
}, [from, to, assigneeId, requester, tags]);
|
||||
|
||||
const userOptions = useMemo(
|
||||
() => users.map((u) => ({ value: u.id, label: u.display_name || u.username })),
|
||||
@@ -240,6 +248,17 @@ export function Dashboard({ users }: Props) {
|
||||
searchable
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Tags"
|
||||
size="xs"
|
||||
placeholder="Todas"
|
||||
value={tags}
|
||||
onChange={setTags}
|
||||
data={tagOptions}
|
||||
clearable
|
||||
searchable
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -1,25 +1,115 @@
|
||||
import { Badge, Divider, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
|
||||
import { IconColumns3, IconLock } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cardHistory } from "../api";
|
||||
import type { Card, CardHistoryResponse } from "../types";
|
||||
import {
|
||||
IconArrowsHorizontal,
|
||||
IconCalendarDue,
|
||||
IconCalendarOff,
|
||||
IconColumns3,
|
||||
IconEdit,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconPalette,
|
||||
IconPlus,
|
||||
IconTag,
|
||||
IconUser,
|
||||
IconUserMinus,
|
||||
IconUserPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { cardHistory, listUsers } from "../api";
|
||||
import type { Card, CardEvent, CardHistoryResponse, User } from "../types";
|
||||
import { formatDuration } from "./format";
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
}
|
||||
|
||||
interface UnifiedEvent {
|
||||
id: string;
|
||||
ts: string;
|
||||
kind: string;
|
||||
actorID: string | null;
|
||||
detail: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function parsePayload(p: string): Record<string, unknown> {
|
||||
try { return JSON.parse(p); } catch { return {}; }
|
||||
}
|
||||
|
||||
function eventToUnified(e: CardEvent): UnifiedEvent {
|
||||
const p = parsePayload(e.payload);
|
||||
switch (e.kind) {
|
||||
case "created":
|
||||
return { id: e.id, ts: e.created_at, kind: "Creada", actorID: e.actor_id, detail: String(p.title || ""), icon: <IconPlus size={12} />, color: "green" };
|
||||
case "title_changed":
|
||||
return { id: e.id, ts: e.created_at, kind: "Titulo", actorID: e.actor_id, detail: `"${p.old}" → "${p.new}"`, icon: <IconEdit size={12} />, color: "blue" };
|
||||
case "requester_changed":
|
||||
return { id: e.id, ts: e.created_at, kind: "Solicitante", actorID: e.actor_id, detail: `"${p.old || "(vacio)"}" → "${p.new || "(vacio)"}"`, icon: <IconEdit size={12} />, color: "orange" };
|
||||
case "description_changed":
|
||||
return { id: e.id, ts: e.created_at, kind: "Descripcion", actorID: e.actor_id, detail: "edicion", icon: <IconEdit size={12} />, color: "blue" };
|
||||
case "color_changed":
|
||||
return { id: e.id, ts: e.created_at, kind: "Color", actorID: e.actor_id, detail: String(p.color || ""), icon: <IconPalette size={12} />, color: "violet" };
|
||||
case "tags_changed":
|
||||
return { id: e.id, ts: e.created_at, kind: "Tags", actorID: e.actor_id, detail: Array.isArray(p.tags) ? (p.tags as string[]).join(", ") || "(sin tags)" : "", icon: <IconTag size={12} />, color: "grape" };
|
||||
case "assigned":
|
||||
return { id: e.id, ts: e.created_at, kind: "Asignada", actorID: e.actor_id, detail: String(p.assignee_id || ""), icon: <IconUserPlus size={12} />, color: "teal" };
|
||||
case "unassigned":
|
||||
return { id: e.id, ts: e.created_at, kind: "Sin asignar", actorID: e.actor_id, detail: "", icon: <IconUserMinus size={12} />, color: "gray" };
|
||||
case "deadline_set": {
|
||||
const d = String(p.deadline || "");
|
||||
return { id: e.id, ts: e.created_at, kind: "Deadline", actorID: e.actor_id, detail: d ? d.slice(0, 10) : "", icon: <IconCalendarDue size={12} />, color: "orange" };
|
||||
}
|
||||
case "deadline_cleared":
|
||||
return { id: e.id, ts: e.created_at, kind: "Deadline quitado", actorID: e.actor_id, detail: p.prev ? String(p.prev).slice(0, 10) : "", icon: <IconCalendarOff size={12} />, color: "gray" };
|
||||
default:
|
||||
return { id: e.id, ts: e.created_at, kind: e.kind, actorID: e.actor_id, detail: e.payload, icon: <IconEdit size={12} />, color: "gray" };
|
||||
}
|
||||
}
|
||||
|
||||
export function HistoryModal({ card }: Props) {
|
||||
const [data, setData] = useState<CardHistoryResponse | null>(null);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
cardHistory(card.id)
|
||||
.then(setData)
|
||||
.catch(() =>
|
||||
setData({ column_history: [], lock_periods: [], total_locked_ms: 0, currently_locked: false })
|
||||
setData({ column_history: [], lock_periods: [], events: [], total_locked_ms: 0, currently_locked: false })
|
||||
);
|
||||
listUsers().then(setUsers).catch(() => {});
|
||||
}, [card.id]);
|
||||
|
||||
const userById = useMemo(() => {
|
||||
const m = new Map<string, User>();
|
||||
for (const u of users) m.set(u.id, u);
|
||||
return m;
|
||||
}, [users]);
|
||||
|
||||
const unified = useMemo(() => {
|
||||
if (!data) return [] as UnifiedEvent[];
|
||||
const out: UnifiedEvent[] = [];
|
||||
for (const e of data.events || []) out.push(eventToUnified(e));
|
||||
for (const h of data.column_history || []) {
|
||||
out.push({
|
||||
id: "h_in_" + h.id,
|
||||
ts: h.entered_at,
|
||||
kind: "Mueve a columna",
|
||||
actorID: h.actor_id,
|
||||
detail: h.column_name || h.column_id,
|
||||
icon: <IconArrowsHorizontal size={12} />,
|
||||
color: "blue",
|
||||
});
|
||||
}
|
||||
for (const p of data.lock_periods || []) {
|
||||
out.push({ id: "lk_" + p.id, ts: p.locked_at, kind: "Bloqueada", actorID: p.actor_id, detail: "", icon: <IconLock size={12} />, color: "yellow" });
|
||||
if (p.unlocked_at) {
|
||||
out.push({ id: "lku_" + p.id, ts: p.unlocked_at, kind: "Desbloqueada", actorID: p.actor_id, detail: formatDuration(p.duration_ms), icon: <IconLockOpen size={12} />, color: "yellow" });
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.ts.localeCompare(b.ts));
|
||||
}, [data]);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Group justify="center" p="xl">
|
||||
@@ -28,42 +118,45 @@ export function HistoryModal({ card }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const { column_history, lock_periods, total_locked_ms, currently_locked } = data;
|
||||
const { column_history, total_locked_ms, currently_locked } = data;
|
||||
|
||||
if (column_history.length === 0 && lock_periods.length === 0) {
|
||||
if (unified.length === 0) {
|
||||
return <Text c="dimmed">Sin historial.</Text>;
|
||||
}
|
||||
|
||||
const userLabel = (id: string | null): string => {
|
||||
if (!id) return "";
|
||||
const u = userById.get(id);
|
||||
if (!u) return id;
|
||||
return u.display_name || u.username;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Tiempo total en cada columna desde que se creo la tarjeta.
|
||||
</Text>
|
||||
<Timeline active={column_history.length} bulletSize={22} lineWidth={2}>
|
||||
{column_history.map((e) => (
|
||||
<Text size="sm" c="dimmed">Linea de tiempo completa de la tarjeta.</Text>
|
||||
<Timeline active={unified.length} bulletSize={22} lineWidth={2}>
|
||||
{unified.map((e) => (
|
||||
<Timeline.Item
|
||||
key={e.id}
|
||||
bullet={<IconColumns3 size={12} />}
|
||||
bullet={e.icon}
|
||||
color={e.color}
|
||||
title={
|
||||
<Group gap={6}>
|
||||
<Text fw={500} size="sm">
|
||||
{e.column_name || e.column_id}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={e.exited_at ? "gray" : "blue"}>
|
||||
{formatDuration(e.duration_ms)}
|
||||
</Badge>
|
||||
{!e.exited_at && (
|
||||
<Badge size="xs" variant="filled" color="blue">
|
||||
actual
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Text fw={500} size="sm">{e.kind}</Text>
|
||||
{e.actorID && (
|
||||
<Badge size="xs" variant="light" color="cyan" leftSection={<IconUser size={10} />}>
|
||||
{userLabel(e.actorID)}
|
||||
</Badge>
|
||||
)}
|
||||
{e.detail && (
|
||||
<Badge size="xs" variant="outline" color={e.color}>
|
||||
{e.detail}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(e.entered_at).toLocaleString()}
|
||||
{e.exited_at && ` -> ${new Date(e.exited_at).toLocaleString()}`}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">{new Date(e.ts).toLocaleString()}</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
@@ -71,55 +164,15 @@ export function HistoryModal({ card }: Props) {
|
||||
<Divider />
|
||||
|
||||
<Group gap={6} align="center">
|
||||
<IconColumns3 size={14} />
|
||||
<Text fw={500} size="sm">Columnas visitadas</Text>
|
||||
<Badge size="xs" variant="light" color="gray">{column_history.length}</Badge>
|
||||
<IconLock size={14} color="var(--mantine-color-yellow-6)" />
|
||||
<Text fw={500} size="sm">
|
||||
Tiempo bloqueada
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={total_locked_ms > 0 ? "yellow" : "gray"}>
|
||||
{formatDuration(total_locked_ms)}
|
||||
</Badge>
|
||||
{currently_locked && (
|
||||
<Badge size="xs" variant="filled" color="yellow">
|
||||
actualmente bloqueada
|
||||
</Badge>
|
||||
)}
|
||||
{currently_locked && <Badge size="xs" variant="filled" color="yellow">bloqueada</Badge>}
|
||||
</Group>
|
||||
|
||||
{lock_periods.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Nunca ha sido bloqueada.
|
||||
</Text>
|
||||
) : (
|
||||
<Timeline active={lock_periods.length} bulletSize={22} lineWidth={2}>
|
||||
{lock_periods.map((p) => (
|
||||
<Timeline.Item
|
||||
key={p.id}
|
||||
bullet={<IconLock size={12} />}
|
||||
title={
|
||||
<Group gap={6}>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={p.unlocked_at ? "gray" : "yellow"}
|
||||
>
|
||||
{formatDuration(p.duration_ms)}
|
||||
</Badge>
|
||||
{!p.unlocked_at && (
|
||||
<Badge size="xs" variant="filled" color="yellow">
|
||||
en curso
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(p.locked_at).toLocaleString()}
|
||||
{p.unlocked_at && ` -> ${new Date(p.unlocked_at).toLocaleString()}`}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ActionIcon,
|
||||
Autocomplete,
|
||||
Avatar,
|
||||
Badge,
|
||||
Group,
|
||||
@@ -14,22 +15,26 @@ import {
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconCalendarDue,
|
||||
IconCheck,
|
||||
IconClock,
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconGripVertical,
|
||||
IconHistory,
|
||||
IconHourglass,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconPalette,
|
||||
IconUserSquare,
|
||||
IconTrash,
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Card, CardColor, User } from "../types";
|
||||
import { CARD_COLORS, colorBg, colorBorder, colorSwatch } from "./colors";
|
||||
import { colorBg, colorBorder, tagColor } from "./colors";
|
||||
import { ColorPickerGrid } from "./ColorPickerGrid";
|
||||
import { formatDateTimeShort, formatDuration } from "./format";
|
||||
|
||||
interface Props {
|
||||
@@ -41,6 +46,10 @@ interface Props {
|
||||
onShowHistory: (card: Card) => void;
|
||||
onToggleLock: (id: string, locked: boolean) => void;
|
||||
onAssign: (id: string, assignee_id: string | null) => void;
|
||||
onSetDeadline?: (id: string, deadline: string | null) => void;
|
||||
onSetRequester?: (id: string, requester: string) => void;
|
||||
requesterOptions?: string[];
|
||||
onOpenCustomColor?: (cardId: string, current: string) => void;
|
||||
activeSticker?: string | null;
|
||||
onAddSticker?: (cardId: string, x: number, y: number) => void;
|
||||
onRemoveSticker?: (cardId: string, index: number) => void;
|
||||
@@ -50,6 +59,7 @@ interface Props {
|
||||
assignee?: User;
|
||||
inDoneColumn?: boolean;
|
||||
isOverlay?: boolean;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
function KanbanCardImpl({
|
||||
@@ -61,6 +71,10 @@ function KanbanCardImpl({
|
||||
onShowHistory,
|
||||
onToggleLock,
|
||||
onAssign,
|
||||
onSetDeadline,
|
||||
onSetRequester,
|
||||
requesterOptions,
|
||||
onOpenCustomColor,
|
||||
activeSticker,
|
||||
onAddSticker,
|
||||
onRemoveSticker,
|
||||
@@ -70,10 +84,14 @@ function KanbanCardImpl({
|
||||
assignee,
|
||||
inDoneColumn,
|
||||
isOverlay,
|
||||
highlight,
|
||||
}: Props) {
|
||||
const isDone = inDoneColumn || !!card.completed_at;
|
||||
const [colorPopOpen, setColorPopOpen] = useState(false);
|
||||
const [assigneePopOpen, setAssigneePopOpen] = useState(false);
|
||||
const [requesterPopOpen, setRequesterPopOpen] = useState(false);
|
||||
const [deadlinePopOpen, setDeadlinePopOpen] = useState(false);
|
||||
const [requesterDraft, setRequesterDraft] = useState(card.requester || "");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const cardElRef = useRef<HTMLElement | null>(null);
|
||||
const draggingStickerRef = useRef<number | null>(null);
|
||||
@@ -89,6 +107,12 @@ function KanbanCardImpl({
|
||||
setNodeRef(el);
|
||||
}, [setNodeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlight && cardElRef.current) {
|
||||
cardElRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, [highlight]);
|
||||
|
||||
const onCardClickAddSticker = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!stickerMode || !onAddSticker || isOverlay) return;
|
||||
if ((e.target as HTMLElement).closest("[data-sticker-overlay]")) return;
|
||||
@@ -140,13 +164,25 @@ function KanbanCardImpl({
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
background: colorBg(card.color),
|
||||
borderColor: card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
|
||||
borderWidth: card.locked ? 2 : 1,
|
||||
borderColor: highlight ? "var(--mantine-color-blue-5)" : card.locked ? "var(--mantine-color-yellow-6)" : colorBorder(card.color),
|
||||
borderWidth: highlight || card.locked ? 2 : 1,
|
||||
boxShadow: highlight ? "0 0 0 3px var(--mantine-color-blue-4)" : undefined,
|
||||
filter: isDone ? "brightness(0.55) saturate(0.7)" : undefined,
|
||||
};
|
||||
|
||||
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
|
||||
const liveMs = Math.max(0, now - enteredAt);
|
||||
const deadlineAt = card.deadline ? new Date(card.deadline).getTime() : 0;
|
||||
const deadlineRemainingMs = deadlineAt ? deadlineAt - now : 0;
|
||||
const overdue = deadlineAt ? deadlineRemainingMs < 0 : false;
|
||||
const createdAtMs = card.created_at ? new Date(card.created_at).getTime() : 0;
|
||||
const deadlineTotalMs = deadlineAt && createdAtMs ? deadlineAt - createdAtMs : 0;
|
||||
const deadlineRatio = deadlineTotalMs > 0 ? deadlineRemainingMs / deadlineTotalMs : 0;
|
||||
let dlColor: string = "blue";
|
||||
let dlVariant: "light" | "filled" = "light";
|
||||
if (overdue) { dlColor = "red.9"; dlVariant = "filled"; }
|
||||
else if (deadlineRatio < 0.1) { dlColor = "red"; dlVariant = "filled"; }
|
||||
else if (deadlineRatio < 0.5) { dlColor = "yellow"; dlVariant = "light"; }
|
||||
const lockedAt = card.locked_at ? new Date(card.locked_at).getTime() : 0;
|
||||
const lockedMs = card.locked && lockedAt ? Math.max(0, now - lockedAt) : 0;
|
||||
const createdAt = card.created_at ? new Date(card.created_at).getTime() : 0;
|
||||
@@ -190,28 +226,12 @@ function KanbanCardImpl({
|
||||
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 p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<ColorPickerGrid
|
||||
value={card.color}
|
||||
onChange={(c) => onChangeColor(card.id, c as CardColor)}
|
||||
onOpenCustom={onOpenCustomColor ? () => onOpenCustomColor(card.id, card.color || "#888888") : undefined}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Popover
|
||||
@@ -235,7 +255,7 @@ function KanbanCardImpl({
|
||||
Asignar a {assignee ? `(${assignee.display_name || assignee.username})` : "..."}
|
||||
</Menu.Item>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<Popover.Dropdown p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<Select
|
||||
placeholder="Sin asignar"
|
||||
value={card.assignee_id ?? null}
|
||||
@@ -252,6 +272,55 @@ function KanbanCardImpl({
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Popover
|
||||
opened={requesterPopOpen}
|
||||
onChange={setRequesterPopOpen}
|
||||
position="right-start"
|
||||
withArrow
|
||||
shadow="md"
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Menu.Item
|
||||
leftSection={<IconUserSquare size={14} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setRequesterDraft(card.requester || "");
|
||||
setRequesterPopOpen((v) => !v);
|
||||
}}
|
||||
closeMenuOnClick={false}
|
||||
>
|
||||
Solicitante {card.requester ? `(${card.requester})` : "..."}
|
||||
</Menu.Item>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<Autocomplete
|
||||
placeholder="Sin solicitante"
|
||||
value={requesterDraft}
|
||||
onChange={setRequesterDraft}
|
||||
data={requesterOptions || []}
|
||||
autoFocus
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onSetRequester?.(card.id, requesterDraft.trim());
|
||||
setRequesterPopOpen(false);
|
||||
setMenuOpen(false);
|
||||
} else if (e.key === "Escape") {
|
||||
setRequesterPopOpen(false);
|
||||
}
|
||||
}}
|
||||
onOptionSubmit={(v) => {
|
||||
setRequesterDraft(v);
|
||||
onSetRequester?.(card.id, v);
|
||||
setRequesterPopOpen(false);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Menu.Item
|
||||
leftSection={card.locked ? <IconLockOpen size={14} /> : <IconLock size={14} />}
|
||||
color={card.locked ? "yellow" : undefined}
|
||||
@@ -271,6 +340,63 @@ function KanbanCardImpl({
|
||||
>
|
||||
Historial
|
||||
</Menu.Item>
|
||||
{onSetDeadline && (
|
||||
<Popover
|
||||
opened={deadlinePopOpen}
|
||||
onChange={setDeadlinePopOpen}
|
||||
position="right-start"
|
||||
withArrow
|
||||
shadow="md"
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Menu.Item
|
||||
leftSection={<IconCalendarDue size={14} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDeadlinePopOpen((v) => !v);
|
||||
}}
|
||||
closeMenuOnClick={false}
|
||||
>
|
||||
{card.deadline ? `Deadline (${card.deadline.slice(0, 10)})` : "Deadline..."}
|
||||
</Menu.Item>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs" onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<DatePickerInput
|
||||
value={card.deadline ? card.deadline.slice(0, 10) : null}
|
||||
onChange={(v) => {
|
||||
const s = v ? (typeof v === "string" ? v.slice(0, 10) : new Date(v as unknown as string).toISOString().slice(0, 10)) : null;
|
||||
onSetDeadline(card.id, s ? `${s}T23:59:59Z` : null);
|
||||
setDeadlinePopOpen(false);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
clearable
|
||||
valueFormat="DD/MM/YYYY"
|
||||
size="xs"
|
||||
placeholder="Elegir fecha"
|
||||
popoverProps={{ withinPortal: false }}
|
||||
/>
|
||||
{card.deadline && (
|
||||
<Tooltip label="Quitar deadline" withArrow>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
mt={6}
|
||||
onClick={() => {
|
||||
onSetDeadline(card.id, null);
|
||||
setDeadlinePopOpen(false);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<IconTrash size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={14} />}
|
||||
@@ -346,25 +472,39 @@ function KanbanCardImpl({
|
||||
<IconDotsVertical size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>{menuItems}</Menu.Dropdown>
|
||||
<Menu.Dropdown
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
{menuItems}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
{card.requester && (
|
||||
<Group gap={4}>
|
||||
<IconUser size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{card.requester}
|
||||
</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>
|
||||
{(card.requester || assignee) && (
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{card.requester && (
|
||||
<>
|
||||
<Avatar size={18} radius="xs" color={tagColor(card.requester)} style={{ flexShrink: 0 }}>
|
||||
{card.requester.slice(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
<Text size="xs" c="dimmed" truncate>{card.requester}</Text>
|
||||
</>
|
||||
)}
|
||||
{card.requester && assignee && (
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>-</Text>
|
||||
)}
|
||||
{assignee && (
|
||||
<>
|
||||
<Avatar size={18} radius="xl" color={assignee.color || "blue"} style={{ flexShrink: 0 }}>
|
||||
{(assignee.display_name || assignee.username).slice(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{assignee.display_name || assignee.username}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{card.description && (
|
||||
@@ -375,18 +515,19 @@ function KanbanCardImpl({
|
||||
{card.tags && card.tags.length > 0 && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{card.tags.map((t) => (
|
||||
<Badge key={t} size="xs" variant="outline" color="violet" radius="sm">
|
||||
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
<Group gap={4} wrap="wrap">
|
||||
{card.locked ? (
|
||||
{card.locked && (
|
||||
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>
|
||||
{formatDuration(lockedMs)}
|
||||
</Badge>
|
||||
) : isDone && card.completed_at ? (
|
||||
)}
|
||||
{!card.locked && isDone && card.completed_at ? (
|
||||
<>
|
||||
<Badge size="xs" variant="light" color="teal" leftSection={<IconCheck size={10} />}>
|
||||
{formatDateTimeShort(card.completed_at)}
|
||||
@@ -394,13 +535,36 @@ function KanbanCardImpl({
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
|
||||
Total: {formatDuration(totalDoneMs)}
|
||||
</Badge>
|
||||
{card.total_locked_ms > 0 && (
|
||||
<Badge size="xs" variant="light" color="yellow" leftSection={<IconLock size={10} />}>
|
||||
{formatDuration(card.total_locked_ms)}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
|
||||
{formatDuration(liveMs)}
|
||||
</Badge>
|
||||
)}
|
||||
) : !card.locked ? (
|
||||
card.deadline ? (
|
||||
<Tooltip label={`Vence: ${formatDateTimeShort(card.deadline)}`} withArrow>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant={dlVariant}
|
||||
color={dlColor}
|
||||
leftSection={<IconHourglass size={10} />}
|
||||
>
|
||||
{overdue ? `-${formatDuration(-deadlineRemainingMs)}` : formatDuration(deadlineRemainingMs)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
|
||||
{formatDuration(liveMs)}
|
||||
</Badge>
|
||||
)
|
||||
) : null}
|
||||
</Group>
|
||||
{card.seq_num > 0 && (
|
||||
<Text size="xs" c="dimmed" style={{ marginTop: -2 }}>
|
||||
#{String(card.seq_num).padStart(5, "0")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{card.stickers && card.stickers.length > 0 && (
|
||||
<div
|
||||
|
||||
@@ -54,6 +54,10 @@ interface Props {
|
||||
onShowHistory: (card: Card) => void;
|
||||
onToggleCardLock: (id: string, locked: boolean) => void;
|
||||
onAssignCard: (id: string, assignee_id: string | null) => void;
|
||||
onSetCardDeadline?: (id: string, deadline: string | null) => void;
|
||||
onSetRequester?: (id: string, requester: string) => void;
|
||||
requesterOptions?: string[];
|
||||
onOpenCustomCardColor?: (cardId: string, current: string) => void;
|
||||
activeSticker?: string | null;
|
||||
onAddSticker?: (cardId: string, x: number, y: number) => void;
|
||||
onRemoveSticker?: (cardId: string, index: number) => void;
|
||||
@@ -61,6 +65,7 @@ interface Props {
|
||||
onCommitSticker?: (cardId: string) => void;
|
||||
users: User[];
|
||||
usersById: Map<string, User>;
|
||||
highlightCardId?: string | null;
|
||||
}
|
||||
|
||||
function KanbanColumnImpl({
|
||||
@@ -81,6 +86,10 @@ function KanbanColumnImpl({
|
||||
onShowHistory,
|
||||
onToggleCardLock,
|
||||
onAssignCard,
|
||||
onSetCardDeadline,
|
||||
onSetRequester,
|
||||
requesterOptions,
|
||||
onOpenCustomCardColor,
|
||||
activeSticker,
|
||||
onAddSticker,
|
||||
onRemoveSticker,
|
||||
@@ -88,6 +97,7 @@ function KanbanColumnImpl({
|
||||
onCommitSticker,
|
||||
users,
|
||||
usersById,
|
||||
highlightCardId,
|
||||
}: Props) {
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [name, setName] = useState(column.name);
|
||||
@@ -415,9 +425,14 @@ function KanbanColumnImpl({
|
||||
onShowHistory={onShowHistory}
|
||||
onToggleLock={onToggleCardLock}
|
||||
onAssign={onAssignCard}
|
||||
onSetDeadline={onSetCardDeadline}
|
||||
onSetRequester={onSetRequester}
|
||||
requesterOptions={requesterOptions}
|
||||
onOpenCustomColor={onOpenCustomCardColor}
|
||||
users={users}
|
||||
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
|
||||
inDoneColumn={column.is_done}
|
||||
highlight={highlightCardId === c.id}
|
||||
activeSticker={activeSticker}
|
||||
onAddSticker={onAddSticker}
|
||||
onRemoveSticker={onRemoveSticker}
|
||||
|
||||
@@ -1,32 +1,46 @@
|
||||
import type { CardColor } from "../types";
|
||||
import { stringHashPalette } from "@fn_library/core/string_hash_palette";
|
||||
|
||||
export { colorBg } from "@fn_library/ui/color_bg";
|
||||
export { colorBorder } from "@fn_library/ui/color_border";
|
||||
export { colorSwatch } from "@fn_library/ui/color_swatch";
|
||||
|
||||
// 22 colores fijos (default + 21 distintos). El ColorPickerGrid añade un 23º circulo "+"
|
||||
// que abre el ColorPicker libre para hex personalizado.
|
||||
export const CARD_COLORS: { value: CardColor; label: string }[] = [
|
||||
{ value: "", label: "Default" },
|
||||
{ value: "blue", label: "Azul" },
|
||||
{ value: "cyan", label: "Cian" },
|
||||
{ value: "teal", label: "Teal" },
|
||||
{ value: "green", label: "Verde" },
|
||||
{ value: "lime", label: "Lima" },
|
||||
{ value: "yellow", label: "Amarillo" },
|
||||
{ value: "orange", label: "Naranja" },
|
||||
{ value: "red", label: "Rojo" },
|
||||
{ value: "pink", label: "Rosa" },
|
||||
{ value: "grape", label: "Uva" },
|
||||
{ value: "violet", label: "Violeta" },
|
||||
{ value: "indigo", label: "Indigo" },
|
||||
{ value: "gray", label: "Gris" },
|
||||
{ value: "#0ea5e9", label: "Sky" },
|
||||
{ value: "#14b8a6", label: "Esmeralda" },
|
||||
{ value: "#84cc16", label: "Lima fluor" },
|
||||
{ value: "#ec4899", label: "Magenta" },
|
||||
{ value: "#a855f7", label: "Lavanda" },
|
||||
{ value: "#f97316", label: "Mandarina" },
|
||||
{ value: "#dc2626", label: "Rubi" },
|
||||
{ value: "#0891b2", label: "Petroleo" },
|
||||
{ value: "#fde047", label: "Limon" },
|
||||
{ value: "#10b981", label: "Menta" },
|
||||
{ value: "#fb7185", label: "Coral" },
|
||||
{ value: "#6366f1", label: "Iris" },
|
||||
{ value: "#94a3b8", label: "Pizarra" },
|
||||
];
|
||||
|
||||
// color-mix mezcla 18% del tono base con dark.6 → suave en dark mode.
|
||||
// Border 30% del tono mas claro con dark.4 para definicion sutil.
|
||||
// Swatch (boton picker) usa tono pleno -7 para que sea visible.
|
||||
export function colorBg(color: CardColor): string {
|
||||
if (color === "") return "var(--mantine-color-dark-6)";
|
||||
return `color-mix(in srgb, var(--mantine-color-${color}-9) 18%, var(--mantine-color-dark-6))`;
|
||||
}
|
||||
export const AVATAR_COLORS = CARD_COLORS;
|
||||
|
||||
export function colorBorder(color: CardColor): string {
|
||||
if (color === "") return "var(--mantine-color-dark-4)";
|
||||
return `color-mix(in srgb, var(--mantine-color-${color}-7) 30%, var(--mantine-color-dark-4))`;
|
||||
}
|
||||
const TAG_PALETTE = ["blue", "cyan", "teal", "green", "lime", "yellow", "orange", "red", "pink", "grape", "violet", "indigo"];
|
||||
|
||||
export function colorSwatch(color: CardColor): string {
|
||||
if (color === "") return "var(--mantine-color-dark-3)";
|
||||
return `var(--mantine-color-${color}-7)`;
|
||||
export function tagColor(tag: string): string {
|
||||
return stringHashPalette(tag, TAG_PALETTE);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,2 @@
|
||||
// Escala unidades segun magnitud: m | h Xm | D Xh | S XD | M XS.
|
||||
// <1 minuto cae como "0m" para mantener la unidad mas pequena coherente.
|
||||
const MIN = 60_000;
|
||||
const HOUR = 60 * MIN;
|
||||
const DAY = 24 * HOUR;
|
||||
const WEEK = 7 * DAY;
|
||||
const MONTH = 30 * DAY;
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms < 0) return "0m";
|
||||
if (ms < HOUR) return `${Math.floor(ms / MIN)}m`;
|
||||
if (ms < DAY) {
|
||||
const h = Math.floor(ms / HOUR);
|
||||
const m = Math.floor((ms % HOUR) / MIN);
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||
}
|
||||
if (ms < WEEK) {
|
||||
const d = Math.floor(ms / DAY);
|
||||
const h = Math.floor((ms % DAY) / HOUR);
|
||||
return h === 0 ? `${d}D` : `${d}D ${h}h`;
|
||||
}
|
||||
if (ms < MONTH) {
|
||||
const w = Math.floor(ms / WEEK);
|
||||
const d = Math.floor((ms % WEEK) / DAY);
|
||||
return d === 0 ? `${w}S` : `${w}S ${d}D`;
|
||||
}
|
||||
const m = Math.floor(ms / MONTH);
|
||||
const w = Math.floor((ms % MONTH) / WEEK);
|
||||
return w === 0 ? `${m}M` : `${m}M ${w}S`;
|
||||
}
|
||||
|
||||
export function formatDateTimeShort(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const yy = String(d.getFullYear()).slice(-2);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${dd}/${mm}/${yy} ${hh}:${mi}`;
|
||||
}
|
||||
export { formatDuration } from "@fn_library/core/format_duration";
|
||||
export { formatDateTimeShort } from "@fn_library/core/format_datetime_short";
|
||||
|
||||
Reference in New Issue
Block a user