chore: auto-commit (10 archivos)

- chat.log
- db.go
- frontend/src/App.tsx
- frontend/src/api.ts
- frontend/src/components/CardForm.tsx
- frontend/src/components/Dashboard.tsx
- frontend/src/components/KanbanCard.tsx
- frontend/src/types.ts
- handlers.go
- metrics.go

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 15:55:35 +02:00
parent 9290a0b2d0
commit 2a727eb7c1
10 changed files with 583 additions and 52 deletions
+215 -5
View File
@@ -28,10 +28,13 @@ import {
Badge,
Box,
Button,
Checkbox,
Group,
Loader,
Menu,
MultiSelect,
Paper,
Select,
Stack,
Tabs,
Text,
@@ -39,6 +42,8 @@ import {
Title,
Tooltip,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import "@mantine/dates/styles.css";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import {
@@ -53,6 +58,7 @@ import {
IconMessageChatbot,
IconPlus,
IconRefresh,
IconSearch,
IconTrash,
IconTrashX,
IconX,
@@ -107,6 +113,15 @@ export function App() {
const [activeTab, setActiveTab] = useState<string>("board");
const [trash, setTrash] = useState<Card[]>([]);
const [trashOpen, setTrashOpen] = useState(false);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [filterAssigneeId, setFilterAssigneeId] = useState<string | null>(null);
const [filterRequester, setFilterRequester] = useState<string | null>(null);
const [filterTags, setFilterTags] = useState<string[]>([]);
const [filterUnassigned, setFilterUnassigned] = useState(false);
const [filterDateFrom, setFilterDateFrom] = useState<Date | null>(null);
const [filterDateTo, setFilterDateTo] = useState<Date | null>(null);
const [navOpen, setNavOpen] = useState(false);
const [navWidth, setNavWidth] = useState<number>(() => {
const stored = localStorage.getItem("kanban_nav_width");
@@ -176,6 +191,24 @@ export function App() {
}
}, []);
const reloadTags = useCallback(async () => {
try {
const t = await api.listTags();
setTagOptions(t);
} catch (e) {
console.warn("listTags failed", e);
}
}, []);
const reloadRequesters = useCallback(async () => {
try {
const r = await api.listRequesters();
setRequesterOptions(r);
} catch (e) {
console.warn("listRequesters failed", e);
}
}, []);
useEffect(() => {
reloadUsers();
}, [reloadUsers]);
@@ -184,6 +217,11 @@ export function App() {
reloadTrash();
}, [reloadTrash]);
useEffect(() => {
reloadTags();
reloadRequesters();
}, [reloadTags, reloadRequesters]);
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
@@ -206,16 +244,61 @@ export function App() {
const boardSortableIds = useMemo(() => boardColumns.map((c) => `${COL_PREFIX}${c.id}`), [boardColumns]);
const sidebarSortableIds = useMemo(() => sidebarColumns.map((c) => `${COL_PREFIX}${c.id}`), [sidebarColumns]);
const cardMatches = useCallback(
(c: Card): boolean => {
const term = searchTerm.trim().toLowerCase();
if (term) {
const hay = [
c.title,
c.description,
c.requester,
...(c.tags || []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (!hay.includes(term)) return false;
}
if (filterAssigneeId && c.assignee_id !== filterAssigneeId) return false;
if (filterUnassigned && c.assignee_id) return false;
if (filterRequester && c.requester !== filterRequester) return false;
if (filterTags.length > 0) {
const cardTags = new Set(c.tags || []);
for (const t of filterTags) if (!cardTags.has(t)) return false;
}
if (filterDateFrom || filterDateTo) {
const fromMs = filterDateFrom ? new Date(filterDateFrom).setHours(0, 0, 0, 0) : -Infinity;
const toMs = filterDateTo ? new Date(filterDateTo).setHours(23, 59, 59, 999) : Infinity;
const created = c.created_at ? new Date(c.created_at).getTime() : NaN;
const moved = c.entered_at ? new Date(c.entered_at).getTime() : NaN;
const inRange = (t: number) => !isNaN(t) && t >= fromMs && t <= toMs;
if (!inRange(created) && !inRange(moved)) return false;
}
return true;
},
[searchTerm, filterAssigneeId, filterUnassigned, filterRequester, filterTags, filterDateFrom, filterDateTo]
);
const cardsByColumn = useMemo(() => {
const map = new Map<string, Card[]>();
if (!board) return map;
for (const col of board.columns) map.set(col.id, []);
for (const c of [...board.cards].sort((a, b) => a.position - b.position)) {
if (!cardMatches(c)) continue;
const arr = map.get(c.column_id);
if (arr) arr.push(c);
}
return map;
}, [board]);
}, [board, cardMatches]);
const filtersActive =
!!searchTerm.trim() ||
!!filterAssigneeId ||
filterUnassigned ||
!!filterRequester ||
filterTags.length > 0 ||
!!filterDateFrom ||
!!filterDateTo;
const findCard = (id: string): Card | undefined => board?.cards.find((c) => c.id === id);
const findColumn = (id: string): Column | undefined => board?.columns.find((c) => c.id === id);
@@ -424,6 +507,8 @@ export function App() {
children: (
<CardForm
users={users}
requesterOptions={requesterOptions}
tagOptions={tagOptions}
initial={{ requester: auth.user?.display_name || auth.user?.username || "" }}
submitLabel="Crear"
onCancel={() => modals.close(id)}
@@ -435,9 +520,12 @@ export function App() {
title: v.title,
description: v.description,
assignee_id: v.assignee_id,
tags: v.tags,
});
modals.close(id);
reload();
reloadTags();
reloadRequesters();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
@@ -445,7 +533,7 @@ export function App() {
/>
),
});
}, [reload, users, auth.user]);
}, [reload, users, auth.user, requesterOptions, tagOptions]);
const openEditCard = useCallback((card: Card) => {
const id = modals.open({
@@ -454,11 +542,14 @@ export function App() {
children: (
<CardForm
users={users}
requesterOptions={requesterOptions}
tagOptions={tagOptions}
initial={{
requester: card.requester,
title: card.title,
description: card.description,
assignee_id: card.assignee_id,
tags: card.tags || [],
}}
submitLabel="Guardar"
onCancel={() => modals.close(id)}
@@ -469,9 +560,12 @@ export function App() {
title: v.title,
description: v.description,
assignee_id: v.assignee_id,
tags: v.tags,
});
modals.close(id);
reload();
reloadTags();
reloadRequesters();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
@@ -479,7 +573,7 @@ export function App() {
/>
),
});
}, [reload, users]);
}, [reload, users, requesterOptions, tagOptions]);
const handleAssignCard = useCallback(async (id: string, assignee_id: string | null) => {
setBoard((prev) => {
@@ -822,14 +916,130 @@ export function App() {
<CalendarView users={users} />
</Box>
) : (
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden" }}>
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
<Group gap="xs" p="xs" wrap="wrap" align="end" style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}>
<TextInput
leftSection={<IconSearch size={14} />}
placeholder="Buscar (titulo, descripcion, solicitante, tag)"
value={searchTerm}
onChange={(e) => setSearchTerm(e.currentTarget.value)}
rightSection={
searchTerm ? (
<ActionIcon size="sm" variant="subtle" color="gray" onClick={() => setSearchTerm("")} aria-label="Limpiar">
<IconX size={14} />
</ActionIcon>
) : null
}
style={{ minWidth: 280, flex: 1 }}
size="xs"
/>
<Select
placeholder="Asignado"
value={filterAssigneeId}
onChange={setFilterAssigneeId}
data={users.map((u) => ({ value: u.id, label: u.display_name || u.username }))}
clearable
searchable
size="xs"
style={{ minWidth: 160 }}
disabled={filterUnassigned}
/>
<Checkbox
size="xs"
label="Sin asignar"
checked={filterUnassigned}
onChange={(e) => {
const v = e.currentTarget.checked;
setFilterUnassigned(v);
if (v) setFilterAssigneeId(null);
}}
/>
<Select
placeholder="Solicitante"
value={filterRequester}
onChange={setFilterRequester}
data={requesterOptions}
clearable
searchable
size="xs"
style={{ minWidth: 160 }}
/>
<MultiSelect
placeholder="Tags"
value={filterTags}
onChange={setFilterTags}
data={tagOptions}
clearable
searchable
size="xs"
style={{ minWidth: 200 }}
/>
<DatePickerInput
placeholder="Desde"
value={filterDateFrom}
onChange={(v) => setFilterDateFrom(v ? new Date(v as unknown as string) : null)}
clearable
size="xs"
style={{ minWidth: 130 }}
valueFormat="DD/MM/YY"
/>
<DatePickerInput
placeholder="Hasta"
value={filterDateTo}
onChange={(v) => setFilterDateTo(v ? new Date(v as unknown as string) : null)}
clearable
size="xs"
style={{ minWidth: 130 }}
valueFormat="DD/MM/YY"
/>
<Group gap={4}>
<Button size="xs" variant="default" onClick={() => {
const t = new Date();
setFilterDateFrom(t);
setFilterDateTo(t);
}}>Hoy</Button>
<Button size="xs" variant="default" onClick={() => {
const t = new Date();
const f = new Date();
f.setDate(f.getDate() - 7);
setFilterDateFrom(f);
setFilterDateTo(t);
}}>7d</Button>
<Button size="xs" variant="default" onClick={() => {
const t = new Date();
const f = new Date();
f.setDate(f.getDate() - 30);
setFilterDateFrom(f);
setFilterDateTo(t);
}}>30d</Button>
</Group>
{filtersActive && (
<Button
size="xs"
variant="subtle"
color="gray"
leftSection={<IconX size={12} />}
onClick={() => {
setSearchTerm("");
setFilterAssigneeId(null);
setFilterUnassigned(false);
setFilterRequester(null);
setFilterTags([]);
setFilterDateFrom(null);
setFilterDateTo(null);
}}
>
Limpiar
</Button>
)}
</Group>
<SortableContext items={boardSortableIds} strategy={horizontalListSortingStrategy}>
<Group
align="stretch"
wrap="nowrap"
gap="md"
p="md"
style={{ height: "100%", overflowX: "auto" }}
style={{ flex: 1, overflowX: "auto", overflowY: "hidden" }}
>
{boardColumns.map((col) => (
<KanbanColumn