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:
2026-05-08 00:27:18 +02:00
parent c915e721af
commit bee688e574
28 changed files with 3601 additions and 300 deletions
+216
View File
@@ -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>
);
}
+34 -13
View File
@@ -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>
+467
View File
@@ -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>
);
}
+68 -9
View File
@@ -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>
);
}
+229 -71
View File
@@ -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);
+233 -78
View File
@@ -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 && (
+106
View File
@@ -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>
);
}