feat(kanban): tiempo maximo via popover con unidad (issue 0089 followup)

Replace the native window.prompt with a Popover that mirrors the deadline
picker pattern: NumberInput + unit Select (minutos/horas/dias/semanas/meses).
The selected unit converts to minutes at save time; the column's stored
unit on the backend stays unchanged (max_time_minutes).

On open the popover pre-selects the largest unit that yields a clean
integer for the current value (e.g. 1440 -> 1 dia, 60 -> 1 hora).

Includes a trash icon to clear the limit and a Guardar button.
data-test selectors added for future e2e:
- column-max-time, column-max-time-input, column-max-time-unit,
  column-max-time-save.

Menu label now shows "(N dias)" / "(N semanas)" / etc. instead of "(N min)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 13:35:14 +02:00
parent c93ac46c37
commit bc502df48a
4 changed files with 1322 additions and 1198 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban</title>
<script type="module" crossorigin src="/assets/index-Cph8eYBP.js"></script>
<script type="module" crossorigin src="/assets/index-CsQHDHWL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-S1AyDjRq.css">
</head>
<body>
+140 -16
View File
@@ -12,6 +12,7 @@ import {
Paper,
Popover,
ScrollArea,
Select,
Stack,
Text,
TextInput,
@@ -38,6 +39,22 @@ import { memo, MouseEvent, useEffect, useRef, useState } from "react";
import type { Card, CardColor, Column, User } from "../types";
import { KanbanCard } from "./KanbanCard";
type MaxTimeUnit = "minutes" | "hours" | "days" | "weeks" | "months";
const MAX_TIME_UNIT_MIN: Record<MaxTimeUnit, number> = {
minutes: 1,
hours: 60,
days: 60 * 24,
weeks: 60 * 24 * 7,
months: 60 * 24 * 30,
};
const MAX_TIME_UNIT_LABEL: Record<MaxTimeUnit, string> = {
minutes: "minutos",
hours: "horas",
days: "dias",
weeks: "semanas",
months: "meses",
};
interface Props {
column: Column;
cards: Card[];
@@ -112,6 +129,24 @@ function KanbanColumnImpl({
const [localWidth, setLocalWidth] = useState<number | null>(null);
const [wipPopOpen, setWipPopOpen] = useState(false);
const [wipDraft, setWipDraft] = useState<number | string>(column.wip_limit);
const [maxTimePopOpen, setMaxTimePopOpen] = useState(false);
// Initial unit picked from current value: largest unit that yields >=1
const pickInitialUnit = (mins: number): MaxTimeUnit => {
if (mins <= 0) return "minutes";
if (mins % 43200 === 0) return "months";
if (mins % 10080 === 0) return "weeks";
if (mins % 1440 === 0) return "days";
if (mins % 60 === 0) return "hours";
return "minutes";
};
const minutesToUnit = (mins: number, u: MaxTimeUnit): number => {
const div = MAX_TIME_UNIT_MIN[u];
return mins > 0 ? Math.max(1, Math.round(mins / div)) : 0;
};
const [maxTimeUnit, setMaxTimeUnit] = useState<MaxTimeUnit>(() => pickInitialUnit(column.max_time_minutes || 0));
const [maxTimeDraft, setMaxTimeDraft] = useState<number | string>(() =>
minutesToUnit(column.max_time_minutes || 0, pickInitialUnit(column.max_time_minutes || 0))
);
const [bodyHidden, setBodyHidden] = useState(() => {
if (!collapsed) return false;
return localStorage.getItem(`kanban_col_body_${column.id}`) === "1";
@@ -396,24 +431,113 @@ function KanbanColumnImpl({
>
{column.is_done ? "Quitar marca Done" : "Marcar como Done"}
</Menu.Item>
<Menu.Item
leftSection={<IconClock size={14} />}
data-test="column-max-time"
onClick={() => {
const current = column.max_time_minutes || 0;
const raw = window.prompt(
"Tiempo maximo en minutos (0 = sin limite). Cards que pasen este tiempo en la columna mostraran borde rojo. Columnas Done no aplican.",
String(current)
);
if (raw === null) return;
const v = parseInt(raw.trim(), 10);
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
if (safe !== current) onSetMaxTimeMinutes(column.id, safe);
<Popover
opened={maxTimePopOpen}
onChange={(o) => {
setMaxTimePopOpen(o);
if (o) {
const u = pickInitialUnit(column.max_time_minutes || 0);
setMaxTimeUnit(u);
setMaxTimeDraft(minutesToUnit(column.max_time_minutes || 0, u));
}
}}
position="right-start"
withArrow
shadow="md"
withinPortal={false}
>
Tiempo maximo
{column.max_time_minutes > 0 ? ` (${column.max_time_minutes} min)` : ""}
</Menu.Item>
<Popover.Target>
<Menu.Item
leftSection={<IconClock size={14} />}
data-test="column-max-time"
closeMenuOnClick={false}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMaxTimePopOpen((v) => !v);
}}
>
Tiempo maximo
{column.max_time_minutes > 0
? ` (${(() => {
const u = pickInitialUnit(column.max_time_minutes);
return `${minutesToUnit(column.max_time_minutes, u)} ${MAX_TIME_UNIT_LABEL[u]}`;
})()})`
: ""}
</Menu.Item>
</Popover.Target>
<Popover.Dropdown
p="xs"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
<Stack gap={6} style={{ minWidth: 240 }}>
<Text size="xs" c="dimmed">
Cards que pasen este tiempo se pintaran con borde rojo. 0 = sin limite. Columnas Done no aplican.
</Text>
<Group gap={6} wrap="nowrap">
<NumberInput
size="xs"
min={0}
max={999}
value={maxTimeDraft}
onChange={setMaxTimeDraft}
placeholder="0"
style={{ width: 90 }}
data-test="column-max-time-input"
/>
<Select
size="xs"
value={maxTimeUnit}
onChange={(v) => v && setMaxTimeUnit(v as MaxTimeUnit)}
data={(Object.keys(MAX_TIME_UNIT_LABEL) as MaxTimeUnit[]).map((u) => ({
value: u,
label: MAX_TIME_UNIT_LABEL[u],
}))}
style={{ width: 130 }}
allowDeselect={false}
data-test="column-max-time-unit"
/>
</Group>
<Group justify="space-between" gap={6}>
<Tooltip label="Quitar limite" withArrow disabled={!column.max_time_minutes}>
<ActionIcon
size="sm"
variant="subtle"
color="red"
disabled={!column.max_time_minutes}
onClick={() => {
onSetMaxTimeMinutes(column.id, 0);
setMaxTimeDraft(0);
setMaxTimePopOpen(false);
}}
>
<IconTrash size={12} />
</ActionIcon>
</Tooltip>
<Button
size="xs"
data-test="column-max-time-save"
onClick={() => {
const raw =
typeof maxTimeDraft === "number"
? maxTimeDraft
: parseInt(String(maxTimeDraft), 10);
const n = Number.isFinite(raw) && raw >= 0 ? raw : 0;
const mins = n * MAX_TIME_UNIT_MIN[maxTimeUnit];
if (mins !== column.max_time_minutes) {
onSetMaxTimeMinutes(column.id, mins);
}
setMaxTimePopOpen(false);
}}
>
Guardar
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
<Menu.Item
leftSection={<IconDice5 size={14} />}
data-test="column-random-pick"