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:
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { MonthPickerInput } from "@mantine/dates";
|
||||
import { IconCheckbox, 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";
|
||||
|
||||
interface Props {
|
||||
users: User[];
|
||||
}
|
||||
|
||||
const DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
|
||||
|
||||
export function CalendarView({ users }: Props) {
|
||||
const [month, setMonth] = useState<Date>(new Date());
|
||||
const [assigneeId, setAssigneeId] = useState<string | null>(null);
|
||||
const [data, setData] = useState<Metrics | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
const start = dayjs(month).startOf("month").format("YYYY-MM-DD");
|
||||
const end = dayjs(month).endOf("month").format("YYYY-MM-DD");
|
||||
api
|
||||
.getMetrics({ from: start, to: end, assignee_id: assigneeId || undefined })
|
||||
.then((m) => {
|
||||
if (!cancelled) setData(m);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [month, assigneeId]);
|
||||
|
||||
const userOptions = useMemo(
|
||||
() => users.map((u) => ({ value: u.id, label: u.display_name || u.username })),
|
||||
[users]
|
||||
);
|
||||
|
||||
const dayMap = useMemo(() => {
|
||||
const m = new Map<string, { created: number; done: number }>();
|
||||
if (!data) return m;
|
||||
for (const d of data.created_daily) {
|
||||
const cur = m.get(d.date) ?? { created: 0, done: 0 };
|
||||
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 };
|
||||
cur.done = d.count;
|
||||
m.set(d.date, cur);
|
||||
}
|
||||
return m;
|
||||
}, [data]);
|
||||
|
||||
// Build month grid (Mon-first).
|
||||
const grid = useMemo(() => {
|
||||
const start = dayjs(month).startOf("month");
|
||||
const end = dayjs(month).endOf("month");
|
||||
// Day-of-week, ISO Mon=1..Sun=7. We want first cell to be Mon.
|
||||
const firstDow = (start.day() + 6) % 7; // 0=Mon
|
||||
const cells: { date: string | null; inMonth: boolean }[] = [];
|
||||
for (let i = 0; i < firstDow; i++) cells.push({ date: null, inMonth: false });
|
||||
for (let d = start; !d.isAfter(end); d = d.add(1, "day")) {
|
||||
cells.push({ date: d.format("YYYY-MM-DD"), inMonth: true });
|
||||
}
|
||||
while (cells.length % 7 !== 0) cells.push({ date: null, inMonth: false });
|
||||
return cells;
|
||||
}, [month]);
|
||||
|
||||
const totalCreated = useMemo(
|
||||
() => Array.from(dayMap.values()).reduce((s, v) => s + v.created, 0),
|
||||
[dayMap]
|
||||
);
|
||||
const totalDone = useMemo(
|
||||
() => Array.from(dayMap.values()).reduce((s, v) => s + v.done, 0),
|
||||
[dayMap]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>Calendario</Title>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<MonthPickerInput
|
||||
label="Mes"
|
||||
size="xs"
|
||||
value={month}
|
||||
onChange={(v) => v && setMonth(typeof v === "string" ? new Date(v) : v)}
|
||||
style={{ minWidth: 160 }}
|
||||
clearable={false}
|
||||
/>
|
||||
<Select
|
||||
label="Asignado"
|
||||
size="xs"
|
||||
placeholder="Todos"
|
||||
value={assigneeId}
|
||||
onChange={setAssigneeId}
|
||||
data={userOptions}
|
||||
clearable
|
||||
searchable
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="md">
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Group gap={6}>
|
||||
<IconPlus size={14} color="var(--mantine-color-blue-5)" />
|
||||
<Text size="sm" fw={600}>
|
||||
{totalCreated}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
creadas
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Group gap={6}>
|
||||
<IconCheckbox size={14} color="var(--mantine-color-green-5)" />
|
||||
<Text size="sm" fw={600}>
|
||||
{totalDone}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
hechas
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Group>
|
||||
|
||||
{loading && !data ? (
|
||||
<Center p="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<SimpleGrid cols={7} spacing={4} mb={4}>
|
||||
{DAY_LABELS.map((d) => (
|
||||
<Text key={d} size="xs" c="dimmed" ta="center" fw={600}>
|
||||
{d}
|
||||
</Text>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={7} spacing={4}>
|
||||
{grid.map((cell, i) => {
|
||||
if (!cell.date) {
|
||||
return <Box key={i} style={{ minHeight: 72 }} />;
|
||||
}
|
||||
const stats = dayMap.get(cell.date) ?? { created: 0, done: 0 };
|
||||
const dayNum = parseInt(cell.date.slice(8, 10), 10);
|
||||
const isToday = cell.date === dayjs().format("YYYY-MM-DD");
|
||||
return (
|
||||
<Paper
|
||||
key={i}
|
||||
p={6}
|
||||
withBorder
|
||||
radius="sm"
|
||||
style={{
|
||||
minHeight: 72,
|
||||
borderColor: isToday ? "var(--mantine-color-blue-5)" : undefined,
|
||||
background:
|
||||
stats.done > 0
|
||||
? "rgba(81, 207, 102, 0.08)"
|
||||
: stats.created > 0
|
||||
? "rgba(34, 139, 230, 0.06)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={isToday ? 700 : 500} c={isToday ? "blue" : undefined}>
|
||||
{dayNum}
|
||||
</Text>
|
||||
{stats.created > 0 && (
|
||||
<Group gap={3} wrap="nowrap">
|
||||
<IconPlus size={10} color="var(--mantine-color-blue-5)" />
|
||||
<Text size="xs" c="blue">
|
||||
{stats.created}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{stats.done > 0 && (
|
||||
<Group gap={3} wrap="nowrap">
|
||||
<IconCheckbox size={10} color="var(--mantine-color-green-5)" />
|
||||
<Text size="xs" c="green">
|
||||
{stats.done}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,40 @@
|
||||
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import { Button, Group, Select, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import { FormEvent, KeyboardEvent, useState } from "react";
|
||||
import type { User } from "../types";
|
||||
|
||||
export interface CardFormValues {
|
||||
requester: string;
|
||||
title: string;
|
||||
description: string;
|
||||
assignee_id: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initial?: Partial<CardFormValues>;
|
||||
submitLabel?: string;
|
||||
users?: User[];
|
||||
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function CardForm({ initial, submitLabel = "Guardar", onSubmit, onCancel }: Props) {
|
||||
export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmit, onCancel }: Props) {
|
||||
const [requester, setRequester] = useState(initial?.requester ?? "");
|
||||
const [title, setTitle] = useState(initial?.title ?? "");
|
||||
const [description, setDescription] = useState(initial?.description ?? "");
|
||||
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
|
||||
|
||||
const submit = async (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const t = title.trim();
|
||||
if (!t) return;
|
||||
await onSubmit({ requester: requester.trim(), title: t, description });
|
||||
await onSubmit({
|
||||
requester: requester.trim(),
|
||||
title: t,
|
||||
description,
|
||||
assignee_id: assigneeId,
|
||||
});
|
||||
};
|
||||
|
||||
// Enter en TextInput envia el form. Enter en Textarea inserta newline; Ctrl/Cmd+Enter envia.
|
||||
const enterSubmit = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -44,20 +52,20 @@ export function CardForm({ initial, submitLabel = "Guardar", onSubmit, onCancel
|
||||
<form onSubmit={submit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Solicitante"
|
||||
value={requester}
|
||||
onChange={(e) => setRequester(e.currentTarget.value)}
|
||||
label="Tarea"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
tabIndex={1}
|
||||
required
|
||||
autoComplete="off"
|
||||
data-autofocus
|
||||
onKeyDown={enterSubmit}
|
||||
/>
|
||||
<TextInput
|
||||
label="Tarea"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
label="Solicitante"
|
||||
value={requester}
|
||||
onChange={(e) => setRequester(e.currentTarget.value)}
|
||||
tabIndex={2}
|
||||
required
|
||||
autoComplete="off"
|
||||
onKeyDown={enterSubmit}
|
||||
/>
|
||||
@@ -72,11 +80,24 @@ export function CardForm({ initial, submitLabel = "Guardar", onSubmit, onCancel
|
||||
onKeyDown={textareaEnter}
|
||||
description="Ctrl+Enter para guardar"
|
||||
/>
|
||||
<Select
|
||||
label="Asignar a"
|
||||
placeholder="Sin asignar"
|
||||
value={assigneeId}
|
||||
onChange={(v) => setAssigneeId(v)}
|
||||
data={users.map((u) => ({
|
||||
value: u.id,
|
||||
label: u.display_name || u.username,
|
||||
}))}
|
||||
clearable
|
||||
searchable
|
||||
tabIndex={4}
|
||||
/>
|
||||
<Group justify="flex-end" gap="xs" mt="xs">
|
||||
<Button variant="subtle" color="gray" tabIndex={5} type="button" onClick={onCancel}>
|
||||
<Button variant="subtle" color="gray" tabIndex={6} type="button" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button tabIndex={4} type="submit" disabled={!title.trim()}>
|
||||
<Button tabIndex={5} type="submit" disabled={!title.trim()}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import { AreaChart, BarChart, LineChart } from "@mantine/charts";
|
||||
import "@mantine/charts/styles.css";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import "@mantine/dates/styles.css";
|
||||
import {
|
||||
IconCheckbox,
|
||||
IconClipboardList,
|
||||
IconClockHour4,
|
||||
IconLock,
|
||||
IconTrendingUp,
|
||||
} 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 { formatDuration } from "./format";
|
||||
|
||||
interface Props {
|
||||
users: User[];
|
||||
}
|
||||
|
||||
function fmtDate(d: Date | null): string | undefined {
|
||||
if (!d) return undefined;
|
||||
return dayjs(d).format("YYYY-MM-DD");
|
||||
}
|
||||
|
||||
function KPI({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
color,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
hint?: string;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap={4}>
|
||||
<Group gap={6} c="dimmed">
|
||||
{icon}
|
||||
<Text size="xs" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xl" fw={700} c={color}>
|
||||
{value}
|
||||
</Text>
|
||||
{hint && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{hint}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dashboard({ users }: Props) {
|
||||
const [from, setFrom] = useState<Date | null>(() => dayjs().subtract(30, "day").toDate());
|
||||
const [to, setTo] = useState<Date | null>(() => new Date());
|
||||
const [assigneeId, setAssigneeId] = useState<string | null>(null);
|
||||
const [requester, setRequester] = useState<string | null>(null);
|
||||
const [data, setData] = useState<Metrics | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
api
|
||||
.getMetrics({
|
||||
from: fmtDate(from),
|
||||
to: fmtDate(to),
|
||||
assignee_id: assigneeId || undefined,
|
||||
requester: requester || undefined,
|
||||
})
|
||||
.then((m) => {
|
||||
if (cancelled) return;
|
||||
setData(m);
|
||||
setRequesterOptions((prev) => {
|
||||
const set = new Set(prev);
|
||||
for (const r of m.top_requesters) set.add(r.requester);
|
||||
return Array.from(set).sort();
|
||||
});
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [from, to, assigneeId, requester]);
|
||||
|
||||
const userOptions = useMemo(
|
||||
() => users.map((u) => ({ value: u.id, label: u.display_name || u.username })),
|
||||
[users]
|
||||
);
|
||||
|
||||
const cumulativeFlow = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const arr = data.cumulative_flow;
|
||||
const firstIdx = arr.findIndex((p) => p.total > 0 || p.done > 0);
|
||||
if (firstIdx <= 0) return arr;
|
||||
return arr.slice(Math.max(0, firstIdx - 1));
|
||||
}, [data]);
|
||||
|
||||
const throughputSeries = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const map = new Map<string, { date: string; completed: number; created: number }>();
|
||||
for (const d of data.throughput_daily) {
|
||||
map.set(d.date, { date: d.date, completed: d.count, created: 0 });
|
||||
}
|
||||
for (const d of data.created_daily) {
|
||||
const cur = map.get(d.date) ?? { date: d.date, completed: 0, created: 0 };
|
||||
cur.created = d.count;
|
||||
map.set(d.date, cur);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date));
|
||||
}, [data]);
|
||||
|
||||
const byColumnSeries = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.by_column.map((c) => ({
|
||||
column: c.name + (c.is_done ? " ✓" : ""),
|
||||
tarjetas: c.count,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
const topAssigneeSeries = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.top_assignees
|
||||
.slice()
|
||||
.sort((a, b) => b.completed_in_range + b.active - (a.completed_in_range + a.active))
|
||||
.slice(0, 8)
|
||||
.map((a) => ({
|
||||
usuario: a.display_name || a.username,
|
||||
completadas: a.completed_in_range,
|
||||
activas: a.active,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
const topRequesterSeries = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.top_requesters.map((r) => ({
|
||||
solicitante: r.requester,
|
||||
tarjetas: r.total,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
const movementsSeries = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return data.movements_by_user
|
||||
.filter((m) => m.moves > 0)
|
||||
.slice(0, 8)
|
||||
.map((m) => ({
|
||||
usuario: m.display_name || m.username,
|
||||
movimientos: m.moves,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>Dashboard</Title>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<DatePickerInput
|
||||
label="Desde"
|
||||
value={from}
|
||||
onChange={(v) => setFrom(v as Date | null)}
|
||||
size="xs"
|
||||
clearable={false}
|
||||
valueFormat="YYYY-MM-DD"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<DatePickerInput
|
||||
label="Hasta"
|
||||
value={to}
|
||||
onChange={(v) => setTo(v as Date | null)}
|
||||
size="xs"
|
||||
clearable={false}
|
||||
valueFormat="YYYY-MM-DD"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<Select
|
||||
label="Asignado"
|
||||
size="xs"
|
||||
placeholder="Todos"
|
||||
value={assigneeId}
|
||||
onChange={setAssigneeId}
|
||||
data={userOptions}
|
||||
clearable
|
||||
searchable
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
<Select
|
||||
label="Solicitante"
|
||||
size="xs"
|
||||
placeholder="Todos"
|
||||
value={requester}
|
||||
onChange={setRequester}
|
||||
data={requesterOptions.map((r) => ({ value: r, label: r }))}
|
||||
clearable
|
||||
searchable
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{loading && !data && (
|
||||
<Center p="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
|
||||
<KPI
|
||||
icon={<IconClipboardList size={14} />}
|
||||
label="Tarjetas totales"
|
||||
value={data.totals.cards}
|
||||
hint={`${data.totals.columns} columnas, ${data.totals.users} usuarios`}
|
||||
/>
|
||||
<KPI
|
||||
icon={<IconCheckbox size={14} />}
|
||||
label="Completadas (rango)"
|
||||
value={data.totals.cards_completed_in_range}
|
||||
hint={`${data.totals.cards_created_in_range} creadas en rango`}
|
||||
color="green"
|
||||
/>
|
||||
<KPI
|
||||
icon={<IconClockHour4 size={14} />}
|
||||
label="Lead time p50"
|
||||
value={formatDuration(data.lead_time.p50_ms)}
|
||||
hint={`p90 ${formatDuration(data.lead_time.p90_ms)} · n=${data.lead_time.n}`}
|
||||
/>
|
||||
<KPI
|
||||
icon={<IconLock size={14} />}
|
||||
label="Bloqueos activos"
|
||||
value={data.totals.active_locks}
|
||||
hint={`Total bloqueado: ${formatDuration(data.lock_total_ms)}`}
|
||||
color={data.totals.active_locks > 0 ? "yellow" : undefined}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group gap={6} mb="sm">
|
||||
<IconTrendingUp size={16} />
|
||||
<Text fw={600}>Cumulative Flow Diagram</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
total vs hechas (acumulado)
|
||||
</Text>
|
||||
</Group>
|
||||
{cumulativeFlow.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
Sin datos.
|
||||
</Text>
|
||||
) : (
|
||||
<AreaChart
|
||||
h={260}
|
||||
data={cumulativeFlow}
|
||||
dataKey="date"
|
||||
withLegend
|
||||
withDots
|
||||
withGradient
|
||||
fillOpacity={0.5}
|
||||
strokeWidth={2.5}
|
||||
curveType="monotone"
|
||||
gridAxis="xy"
|
||||
series={[
|
||||
{ name: "total", label: "Total", color: "blue.6" },
|
||||
{ name: "done", label: "Hechas", color: "green.6" },
|
||||
]}
|
||||
yAxisProps={{ allowDecimals: false }}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 8 }}>
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group gap={6} mb="sm">
|
||||
<IconTrendingUp size={16} />
|
||||
<Text fw={600}>Throughput diario</Text>
|
||||
</Group>
|
||||
{throughputSeries.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
Sin datos en el rango.
|
||||
</Text>
|
||||
) : (
|
||||
<LineChart
|
||||
h={240}
|
||||
data={throughputSeries}
|
||||
dataKey="date"
|
||||
curveType="monotone"
|
||||
withLegend
|
||||
series={[
|
||||
{ name: "completed", label: "Completadas", color: "green.6" },
|
||||
{ name: "created", label: "Creadas", color: "blue.6" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text fw={600} mb="sm">
|
||||
Tarjetas por columna
|
||||
</Text>
|
||||
{byColumnSeries.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
Sin columnas.
|
||||
</Text>
|
||||
) : (
|
||||
<BarChart
|
||||
h={240}
|
||||
data={byColumnSeries}
|
||||
dataKey="column"
|
||||
orientation="vertical"
|
||||
yAxisProps={{ width: 100 }}
|
||||
series={[{ name: "tarjetas", label: "Tarjetas", color: "blue.6" }]}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text fw={600} mb="sm">
|
||||
Top asignados
|
||||
</Text>
|
||||
{topAssigneeSeries.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
Sin asignaciones.
|
||||
</Text>
|
||||
) : (
|
||||
<BarChart
|
||||
h={240}
|
||||
data={topAssigneeSeries}
|
||||
dataKey="usuario"
|
||||
orientation="vertical"
|
||||
yAxisProps={{ width: 120 }}
|
||||
withLegend
|
||||
series={[
|
||||
{ name: "completadas", label: "Completadas", color: "green.6" },
|
||||
{ name: "activas", label: "Activas", color: "blue.6" },
|
||||
]}
|
||||
type="stacked"
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text fw={600} mb="sm">
|
||||
Top solicitantes
|
||||
</Text>
|
||||
{topRequesterSeries.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
Sin solicitantes en el rango.
|
||||
</Text>
|
||||
) : (
|
||||
<BarChart
|
||||
h={240}
|
||||
data={topRequesterSeries}
|
||||
dataKey="solicitante"
|
||||
orientation="vertical"
|
||||
yAxisProps={{ width: 120 }}
|
||||
series={[{ name: "tarjetas", label: "Tarjetas", color: "violet.6" }]}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text fw={600} mb="sm">
|
||||
Movimientos por usuario (rango)
|
||||
</Text>
|
||||
{movementsSeries.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
Sin movimientos registrados.
|
||||
</Text>
|
||||
) : (
|
||||
<BarChart
|
||||
h={240}
|
||||
data={movementsSeries}
|
||||
dataKey="usuario"
|
||||
orientation="vertical"
|
||||
yAxisProps={{ width: 120 }}
|
||||
series={[{ name: "movimientos", label: "Movimientos", color: "orange.6" }]}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text fw={600} mb="sm">
|
||||
Tiempo en columna (cycle time)
|
||||
</Text>
|
||||
<Table striped highlightOnHover withTableBorder withColumnBorders fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Columna</Table.Th>
|
||||
<Table.Th>n</Table.Th>
|
||||
<Table.Th>p50</Table.Th>
|
||||
<Table.Th>p90</Table.Th>
|
||||
<Table.Th>avg</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.cycle_time_per_column.map((c) => (
|
||||
<Table.Tr key={c.column_id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="xs" fw={500}>
|
||||
{c.name}
|
||||
</Text>
|
||||
{c.is_done && (
|
||||
<Badge size="xs" color="green" variant="light">
|
||||
done
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{c.stats.n}</Table.Td>
|
||||
<Table.Td>{c.stats.n > 0 ? formatDuration(c.stats.p50_ms) : "—"}</Table.Td>
|
||||
<Table.Td>{c.stats.n > 0 ? formatDuration(c.stats.p90_ms) : "—"}</Table.Td>
|
||||
<Table.Td>{c.stats.n > 0 ? formatDuration(c.stats.avg_ms) : "—"}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
|
||||
import { IconColumns3 } from "@tabler/icons-react";
|
||||
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, HistoryEntry } from "../types";
|
||||
import type { Card, CardHistoryResponse } from "../types";
|
||||
import { formatDuration } from "./format";
|
||||
|
||||
interface Props {
|
||||
@@ -10,13 +10,17 @@ interface Props {
|
||||
}
|
||||
|
||||
export function HistoryModal({ card }: Props) {
|
||||
const [entries, setEntries] = useState<HistoryEntry[] | null>(null);
|
||||
const [data, setData] = useState<CardHistoryResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cardHistory(card.id).then(setEntries).catch(() => setEntries([]));
|
||||
cardHistory(card.id)
|
||||
.then(setData)
|
||||
.catch(() =>
|
||||
setData({ column_history: [], lock_periods: [], total_locked_ms: 0, currently_locked: false })
|
||||
);
|
||||
}, [card.id]);
|
||||
|
||||
if (!entries) {
|
||||
if (!data) {
|
||||
return (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" />
|
||||
@@ -24,7 +28,9 @@ export function HistoryModal({ card }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
const { column_history, lock_periods, total_locked_ms, currently_locked } = data;
|
||||
|
||||
if (column_history.length === 0 && lock_periods.length === 0) {
|
||||
return <Text c="dimmed">Sin historial.</Text>;
|
||||
}
|
||||
|
||||
@@ -33,8 +39,8 @@ export function HistoryModal({ card }: Props) {
|
||||
<Text size="sm" c="dimmed">
|
||||
Tiempo total en cada columna desde que se creo la tarjeta.
|
||||
</Text>
|
||||
<Timeline active={entries.length} bulletSize={22} lineWidth={2}>
|
||||
{entries.map((e) => (
|
||||
<Timeline active={column_history.length} bulletSize={22} lineWidth={2}>
|
||||
{column_history.map((e) => (
|
||||
<Timeline.Item
|
||||
key={e.id}
|
||||
bullet={<IconColumns3 size={12} />}
|
||||
@@ -61,6 +67,59 @@ export function HistoryModal({ card }: Props) {
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group gap={6} align="center">
|
||||
<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>
|
||||
)}
|
||||
</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,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);
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Menu,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -17,7 +20,12 @@ import {
|
||||
import {
|
||||
IconArchive,
|
||||
IconArchiveOff,
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconCheckbox,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconDotsVertical,
|
||||
IconGripVertical,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
@@ -25,7 +33,7 @@ import {
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { memo, MouseEvent, useEffect, useRef, useState } from "react";
|
||||
import type { Card, CardColor, Column } from "../types";
|
||||
import type { Card, CardColor, Column, User } from "../types";
|
||||
import { KanbanCard } from "./KanbanCard";
|
||||
|
||||
interface Props {
|
||||
@@ -38,10 +46,16 @@ interface Props {
|
||||
onResizeColumn: (id: string, width: number) => void;
|
||||
onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void;
|
||||
onDeleteColumn: (id: string) => void;
|
||||
onSetWIPLimit: (id: string, limit: number) => void;
|
||||
onToggleDone: (id: string, is_done: boolean) => void;
|
||||
onEditCard: (card: Card) => void;
|
||||
onDeleteCard: (id: string) => void;
|
||||
onChangeCardColor: (id: string, color: CardColor) => void;
|
||||
onShowHistory: (card: Card) => void;
|
||||
onToggleCardLock: (id: string, locked: boolean) => void;
|
||||
onAssignCard: (id: string, assignee_id: string | null) => void;
|
||||
users: User[];
|
||||
usersById: Map<string, User>;
|
||||
}
|
||||
|
||||
function KanbanColumnImpl({
|
||||
@@ -54,14 +68,34 @@ function KanbanColumnImpl({
|
||||
onResizeColumn,
|
||||
onMoveColumnLocation,
|
||||
onDeleteColumn,
|
||||
onSetWIPLimit,
|
||||
onToggleDone,
|
||||
onEditCard,
|
||||
onDeleteCard,
|
||||
onChangeCardColor,
|
||||
onShowHistory,
|
||||
onToggleCardLock,
|
||||
onAssignCard,
|
||||
users,
|
||||
usersById,
|
||||
}: Props) {
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [name, setName] = useState(column.name);
|
||||
const [localWidth, setLocalWidth] = useState<number | null>(null);
|
||||
const [wipPopOpen, setWipPopOpen] = useState(false);
|
||||
const [wipDraft, setWipDraft] = useState<number | string>(column.wip_limit);
|
||||
const [bodyHidden, setBodyHidden] = useState(() => {
|
||||
if (!collapsed) return false;
|
||||
return localStorage.getItem(`kanban_col_body_${column.id}`) === "1";
|
||||
});
|
||||
useEffect(() => {
|
||||
if (collapsed) {
|
||||
localStorage.setItem(`kanban_col_body_${column.id}`, bodyHidden ? "1" : "0");
|
||||
}
|
||||
}, [bodyHidden, collapsed, column.id]);
|
||||
|
||||
const wipLimit = column.wip_limit;
|
||||
const overLimit = wipLimit > 0 && cards.length > wipLimit;
|
||||
|
||||
// sync local width when column.width changes from outside (other clients).
|
||||
useEffect(() => {
|
||||
@@ -73,20 +107,32 @@ function KanbanColumnImpl({
|
||||
data: { type: "column", columnId: column.id, location: column.location },
|
||||
});
|
||||
|
||||
const effectiveWidth = collapsed ? 220 : localWidth ?? column.width;
|
||||
const effectiveWidth = collapsed ? "100%" : localWidth ?? column.width;
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
width: effectiveWidth,
|
||||
minWidth: effectiveWidth,
|
||||
maxWidth: effectiveWidth,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
};
|
||||
const style: React.CSSProperties = collapsed
|
||||
? {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
flex: bodyHidden ? "0 0 auto" : "1 1 auto",
|
||||
minHeight: 0,
|
||||
}
|
||||
: {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
width: effectiveWidth,
|
||||
minWidth: effectiveWidth,
|
||||
maxWidth: effectiveWidth,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
};
|
||||
|
||||
const cardIds = cards.map((c) => c.id);
|
||||
|
||||
@@ -135,8 +181,24 @@ function KanbanColumnImpl({
|
||||
const archiveLabel = isInSidebar ? "Restaurar al board" : "Mover al sidebar";
|
||||
const ArchiveIcon = isInSidebar ? IconArchiveOff : IconArchive;
|
||||
|
||||
const submitWIP = () => {
|
||||
const n = typeof wipDraft === "number" ? wipDraft : parseInt(String(wipDraft), 10);
|
||||
const safe = Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
|
||||
if (safe !== column.wip_limit) onSetWIPLimit(column.id, safe);
|
||||
setWipPopOpen(false);
|
||||
};
|
||||
|
||||
const paperBg = overLimit ? "var(--mantine-color-red-9)" : "var(--mantine-color-dark-7)";
|
||||
const paperBorderColor = overLimit ? "var(--mantine-color-red-6)" : undefined;
|
||||
|
||||
return (
|
||||
<Paper ref={setNodeRef} style={style} withBorder radius="md" p="sm" bg="dark.7">
|
||||
<Paper
|
||||
ref={setNodeRef}
|
||||
style={{ ...style, background: paperBg, borderColor: paperBorderColor, borderWidth: overLimit ? 2 : 1 }}
|
||||
withBorder
|
||||
radius="md"
|
||||
p="sm"
|
||||
>
|
||||
<Group justify="space-between" mb="xs" wrap="nowrap">
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<ActionIcon
|
||||
@@ -181,9 +243,65 @@ function KanbanColumnImpl({
|
||||
{column.name}
|
||||
</Text>
|
||||
)}
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{cards.length}
|
||||
</Badge>
|
||||
<Popover
|
||||
opened={wipPopOpen}
|
||||
onChange={(o) => {
|
||||
setWipPopOpen(o);
|
||||
if (o) setWipDraft(column.wip_limit);
|
||||
}}
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="md"
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip
|
||||
label={
|
||||
wipLimit > 0
|
||||
? `WIP ${cards.length}/${wipLimit}${overLimit ? " (excedido)" : ""}`
|
||||
: "Click para limitar WIP"
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant={overLimit ? "filled" : "light"}
|
||||
color={overLimit ? "red" : wipLimit > 0 ? "yellow" : "gray"}
|
||||
leftSection={overLimit ? <IconAlertTriangle size={10} /> : null}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => setWipPopOpen((v) => !v)}
|
||||
>
|
||||
{wipLimit > 0 ? `${cards.length}/${wipLimit}` : cards.length}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
Maximo de tarjetas (0 = sin limite)
|
||||
</Text>
|
||||
<NumberInput
|
||||
size="xs"
|
||||
value={wipDraft}
|
||||
onChange={setWipDraft}
|
||||
min={0}
|
||||
max={999}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submitWIP();
|
||||
if (e.key === "Escape") setWipPopOpen(false);
|
||||
}}
|
||||
/>
|
||||
<Group justify="flex-end" gap={4}>
|
||||
<Button size="xs" variant="subtle" onClick={() => setWipPopOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button size="xs" onClick={submitWIP}>
|
||||
Guardar
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
<Group gap={2} wrap="nowrap">
|
||||
{renaming ? (
|
||||
@@ -206,72 +324,109 @@ function KanbanColumnImpl({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setName(column.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
aria-label="Rename"
|
||||
>
|
||||
<IconPencil size={14} />
|
||||
</ActionIcon>
|
||||
<Tooltip label={archiveLabel} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
|
||||
aria-label={archiveLabel}
|
||||
>
|
||||
<ArchiveIcon size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => onDeleteColumn(column.id)}
|
||||
aria-label="Delete column"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
{collapsed && (
|
||||
<Tooltip label={bodyHidden ? "Expandir" : "Colapsar"} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => setBodyHidden((v) => !v)}
|
||||
aria-label={bodyHidden ? "Expandir columna" : "Colapsar columna"}
|
||||
>
|
||||
{bodyHidden ? <IconChevronRight size={14} /> : <IconChevronDown size={14} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{column.is_done && (
|
||||
<Tooltip label="Columna Done" withArrow>
|
||||
<Badge size="xs" color="green" variant="filled" leftSection={<IconCheckbox size={10} />}>
|
||||
done
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Menu position="bottom-end" shadow="md" withArrow>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label="Acciones columna">
|
||||
<IconDotsVertical size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Columna</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size={14} />}
|
||||
onClick={() => {
|
||||
setName(column.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
>
|
||||
Renombrar
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconCheckbox size={14} />}
|
||||
color={column.is_done ? "yellow" : "green"}
|
||||
onClick={() => onToggleDone(column.id, !column.is_done)}
|
||||
>
|
||||
{column.is_done ? "Quitar marca Done" : "Marcar como Done"}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<ArchiveIcon size={14} />}
|
||||
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
|
||||
>
|
||||
{archiveLabel}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={14} />}
|
||||
color="red"
|
||||
onClick={() => onDeleteColumn(column.id)}
|
||||
>
|
||||
Borrar columna
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} type="auto">
|
||||
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
|
||||
<Stack gap="xs" pb="xs" style={{ minHeight: 40 }}>
|
||||
{cards.map((c) => (
|
||||
<KanbanCard
|
||||
key={c.id}
|
||||
card={c}
|
||||
now={now}
|
||||
onDelete={onDeleteCard}
|
||||
onEdit={onEditCard}
|
||||
onChangeColor={onChangeCardColor}
|
||||
onShowHistory={onShowHistory}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SortableContext>
|
||||
</ScrollArea>
|
||||
{!(collapsed && bodyHidden) && (
|
||||
<>
|
||||
<ScrollArea style={{ flex: 1 }} type="auto">
|
||||
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
|
||||
<Stack gap="xs" pb="xs" style={{ minHeight: 40 }}>
|
||||
{cards.map((c) => (
|
||||
<KanbanCard
|
||||
key={c.id}
|
||||
card={c}
|
||||
now={now}
|
||||
onDelete={onDeleteCard}
|
||||
onEdit={onEditCard}
|
||||
onChangeColor={onChangeCardColor}
|
||||
onShowHistory={onShowHistory}
|
||||
onToggleLock={onToggleCardLock}
|
||||
onAssign={onAssignCard}
|
||||
users={users}
|
||||
assignee={c.assignee_id ? usersById.get(c.assignee_id) : undefined}
|
||||
inDoneColumn={column.is_done}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SortableContext>
|
||||
</ScrollArea>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => onAddCard(column.id)}
|
||||
mt="xs"
|
||||
fullWidth
|
||||
>
|
||||
Anadir tarjeta
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => onAddCard(column.id)}
|
||||
mt="xs"
|
||||
fullWidth
|
||||
>
|
||||
Anadir tarjeta
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Resize handle (only on board, not sidebar) */}
|
||||
{!isInSidebar && (
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Center,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconLayoutKanban } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
type Mode = "login" | "register";
|
||||
|
||||
export function LoginPage() {
|
||||
const auth = useAuth();
|
||||
const [mode, setMode] = useState<Mode>("login");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (mode === "login") {
|
||||
await auth.login(username.trim(), password);
|
||||
} else {
|
||||
await auth.register(username.trim(), password, displayName.trim() || username.trim());
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Center style={{ minHeight: "100vh" }} p="md">
|
||||
<Paper p="xl" withBorder radius="md" shadow="md" style={{ width: 360, maxWidth: "100%" }}>
|
||||
<form onSubmit={submit}>
|
||||
<Stack gap="md">
|
||||
<Stack gap={4} align="center">
|
||||
<IconLayoutKanban size={36} />
|
||||
<Title order={3}>Kanban</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{mode === "login" ? "Inicia sesion" : "Crea una cuenta"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<TextInput
|
||||
label="Usuario"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
/>
|
||||
{mode === "register" && (
|
||||
<TextInput
|
||||
label="Nombre (opcional)"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.currentTarget.value)}
|
||||
autoComplete="name"
|
||||
/>
|
||||
)}
|
||||
<PasswordInput
|
||||
label="Contrasena"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
/>
|
||||
{error && (
|
||||
<Text size="sm" c="red">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<Button type="submit" loading={submitting} fullWidth>
|
||||
{mode === "login" ? "Entrar" : "Registrar"}
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{mode === "login" ? "No tienes cuenta?" : "Ya tienes cuenta?"}{" "}
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setMode(mode === "login" ? "register" : "login");
|
||||
}}
|
||||
>
|
||||
{mode === "login" ? "Registrate" : "Inicia sesion"}
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user