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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Kanban</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-S1AyDjRq.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Popover,
|
Popover,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -38,6 +39,22 @@ import { memo, MouseEvent, useEffect, useRef, useState } from "react";
|
|||||||
import type { Card, CardColor, Column, User } from "../types";
|
import type { Card, CardColor, Column, User } from "../types";
|
||||||
import { KanbanCard } from "./KanbanCard";
|
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 {
|
interface Props {
|
||||||
column: Column;
|
column: Column;
|
||||||
cards: Card[];
|
cards: Card[];
|
||||||
@@ -112,6 +129,24 @@ function KanbanColumnImpl({
|
|||||||
const [localWidth, setLocalWidth] = useState<number | null>(null);
|
const [localWidth, setLocalWidth] = useState<number | null>(null);
|
||||||
const [wipPopOpen, setWipPopOpen] = useState(false);
|
const [wipPopOpen, setWipPopOpen] = useState(false);
|
||||||
const [wipDraft, setWipDraft] = useState<number | string>(column.wip_limit);
|
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(() => {
|
const [bodyHidden, setBodyHidden] = useState(() => {
|
||||||
if (!collapsed) return false;
|
if (!collapsed) return false;
|
||||||
return localStorage.getItem(`kanban_col_body_${column.id}`) === "1";
|
return localStorage.getItem(`kanban_col_body_${column.id}`) === "1";
|
||||||
@@ -396,24 +431,113 @@ function KanbanColumnImpl({
|
|||||||
>
|
>
|
||||||
{column.is_done ? "Quitar marca Done" : "Marcar como Done"}
|
{column.is_done ? "Quitar marca Done" : "Marcar como Done"}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Popover
|
||||||
leftSection={<IconClock size={14} />}
|
opened={maxTimePopOpen}
|
||||||
data-test="column-max-time"
|
onChange={(o) => {
|
||||||
onClick={() => {
|
setMaxTimePopOpen(o);
|
||||||
const current = column.max_time_minutes || 0;
|
if (o) {
|
||||||
const raw = window.prompt(
|
const u = pickInitialUnit(column.max_time_minutes || 0);
|
||||||
"Tiempo maximo en minutos (0 = sin limite). Cards que pasen este tiempo en la columna mostraran borde rojo. Columnas Done no aplican.",
|
setMaxTimeUnit(u);
|
||||||
String(current)
|
setMaxTimeDraft(minutesToUnit(column.max_time_minutes || 0, u));
|
||||||
);
|
}
|
||||||
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);
|
|
||||||
}}
|
}}
|
||||||
|
position="right-start"
|
||||||
|
withArrow
|
||||||
|
shadow="md"
|
||||||
|
withinPortal={false}
|
||||||
>
|
>
|
||||||
Tiempo maximo
|
<Popover.Target>
|
||||||
{column.max_time_minutes > 0 ? ` (${column.max_time_minutes} min)` : ""}
|
<Menu.Item
|
||||||
</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
|
<Menu.Item
|
||||||
leftSection={<IconDice5 size={14} />}
|
leftSection={<IconDice5 size={14} />}
|
||||||
data-test="column-random-pick"
|
data-test="column-random-pick"
|
||||||
|
|||||||
Reference in New Issue
Block a user