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:
-1181
File diff suppressed because one or more lines are too long
+1181
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user