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:
+215
-5
@@ -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
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface CreateCardInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
assignee_id?: string | null;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export function createCard(input: CreateCardInput): Promise<Card> {
|
||||
@@ -81,6 +82,7 @@ export interface UpdateCardInput {
|
||||
color?: string;
|
||||
locked?: boolean;
|
||||
assignee_id?: string | null;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export function updateCard(id: string, patch: UpdateCardInput): Promise<void> {
|
||||
@@ -162,6 +164,14 @@ export function listUsers(): Promise<User[]> {
|
||||
return fetchJSON("/users");
|
||||
}
|
||||
|
||||
export function listTags(): Promise<string[]> {
|
||||
return fetchJSON("/tags");
|
||||
}
|
||||
|
||||
export function listRequesters(): Promise<string[]> {
|
||||
return fetchJSON("/requesters");
|
||||
}
|
||||
|
||||
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
|
||||
const qs = new URLSearchParams();
|
||||
if (f.from) qs.set("from", f.from);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Group, Select, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import { Autocomplete, Button, Group, Select, Stack, TagsInput, Textarea } from "@mantine/core";
|
||||
import { FormEvent, KeyboardEvent, useState } from "react";
|
||||
import type { User } from "../types";
|
||||
|
||||
@@ -7,21 +7,33 @@ export interface CardFormValues {
|
||||
title: string;
|
||||
description: string;
|
||||
assignee_id: string | null;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initial?: Partial<CardFormValues>;
|
||||
submitLabel?: string;
|
||||
users?: User[];
|
||||
requesterOptions?: string[];
|
||||
tagOptions?: string[];
|
||||
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmit, onCancel }: Props) {
|
||||
export function CardForm({
|
||||
initial,
|
||||
submitLabel = "Guardar",
|
||||
users = [],
|
||||
requesterOptions = [],
|
||||
tagOptions = [],
|
||||
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 [tags, setTags] = useState<string[]>(initial?.tags ?? []);
|
||||
|
||||
const submit = async (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
@@ -32,6 +44,7 @@ export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmi
|
||||
title: t,
|
||||
description,
|
||||
assignee_id: assigneeId,
|
||||
tags,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -51,7 +64,7 @@ export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmi
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
<Textarea
|
||||
label="Tarea"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
@@ -59,15 +72,26 @@ export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmi
|
||||
required
|
||||
autoComplete="off"
|
||||
data-autofocus
|
||||
onKeyDown={enterSubmit}
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
<Autocomplete
|
||||
label="Solicitante"
|
||||
value={requester}
|
||||
onChange={(e) => setRequester(e.currentTarget.value)}
|
||||
onChange={setRequester}
|
||||
data={requesterOptions}
|
||||
tabIndex={2}
|
||||
autoComplete="off"
|
||||
onKeyDown={enterSubmit}
|
||||
placeholder="Empieza a escribir y elige uno existente"
|
||||
limit={10}
|
||||
/>
|
||||
<Textarea
|
||||
label="Descripcion"
|
||||
@@ -93,11 +117,21 @@ export function CardForm({ initial, submitLabel = "Guardar", users = [], onSubmi
|
||||
searchable
|
||||
tabIndex={4}
|
||||
/>
|
||||
<TagsInput
|
||||
label="Tags"
|
||||
value={tags}
|
||||
onChange={setTags}
|
||||
data={tagOptions}
|
||||
clearable
|
||||
tabIndex={5}
|
||||
placeholder="Enter para añadir; sugiere existentes"
|
||||
splitChars={[",", " "]}
|
||||
/>
|
||||
<Group justify="flex-end" gap="xs" mt="xs">
|
||||
<Button variant="subtle" color="gray" tabIndex={6} type="button" onClick={onCancel}>
|
||||
<Button variant="subtle" color="gray" tabIndex={7} type="button" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button tabIndex={5} type="submit" disabled={!title.trim()}>
|
||||
<Button tabIndex={6} type="submit" disabled={!title.trim()}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -178,7 +178,8 @@ export function Dashboard({ users }: Props) {
|
||||
if (!data) return [];
|
||||
return data.top_requesters.map((r) => ({
|
||||
solicitante: r.requester,
|
||||
tarjetas: r.total,
|
||||
activas: r.active,
|
||||
completadas: r.completed_in_range,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
@@ -250,18 +251,25 @@ export function Dashboard({ users }: Props) {
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
|
||||
<SimpleGrid cols={{ base: 2, md: 5 }} spacing="md">
|
||||
<KPI
|
||||
icon={<IconClipboardList size={14} />}
|
||||
label="Tarjetas totales"
|
||||
label="Totales"
|
||||
value={data.totals.cards}
|
||||
hint={`${data.totals.columns} columnas, ${data.totals.users} usuarios`}
|
||||
/>
|
||||
<KPI
|
||||
icon={<IconClipboardList size={14} />}
|
||||
label="Activas"
|
||||
value={data.totals.cards_active}
|
||||
hint={`Sin completar`}
|
||||
color="blue"
|
||||
/>
|
||||
<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`}
|
||||
hint={`${data.totals.cards_done} completadas total · ${data.totals.cards_created_in_range} creadas rango`}
|
||||
color="green"
|
||||
/>
|
||||
<KPI
|
||||
@@ -426,12 +434,17 @@ export function Dashboard({ users }: Props) {
|
||||
</Text>
|
||||
) : (
|
||||
<BarChart
|
||||
h={240}
|
||||
h={Math.max(240, topRequesterSeries.length * 32)}
|
||||
data={topRequesterSeries}
|
||||
dataKey="solicitante"
|
||||
orientation="vertical"
|
||||
yAxisProps={{ width: 120 }}
|
||||
series={[{ name: "tarjetas", label: "Tarjetas", color: "violet.6" }]}
|
||||
yAxisProps={{ width: 160, interval: 0 }}
|
||||
withLegend
|
||||
series={[
|
||||
{ name: "completadas", label: "Completadas", color: "green.6" },
|
||||
{ name: "activas", label: "Activas", color: "violet.6" },
|
||||
]}
|
||||
type="stacked"
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
@@ -149,6 +149,7 @@ function KanbanCardImpl({
|
||||
position="right-start"
|
||||
withArrow
|
||||
shadow="md"
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Menu.Item
|
||||
@@ -176,6 +177,7 @@ function KanbanCardImpl({
|
||||
clearable
|
||||
searchable
|
||||
autoFocus
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
@@ -298,6 +300,15 @@ function KanbanCardImpl({
|
||||
{card.description}
|
||||
</Text>
|
||||
)}
|
||||
{card.tags && card.tags.length > 0 && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{card.tags.map((t) => (
|
||||
<Badge key={t} size="xs" variant="outline" color="violet" radius="sm">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
<Group gap={4}>
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
|
||||
{formatDuration(liveMs)}
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface Card {
|
||||
assignee_id: string | null;
|
||||
completed_at: string | null;
|
||||
deleted_at: string | null;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
entered_at: string;
|
||||
@@ -47,6 +48,8 @@ export interface MetricsTotals {
|
||||
cards: number;
|
||||
cards_completed_in_range: number;
|
||||
cards_created_in_range: number;
|
||||
cards_active: number;
|
||||
cards_done: number;
|
||||
columns: number;
|
||||
users: number;
|
||||
active_locks: number;
|
||||
@@ -90,6 +93,8 @@ export interface MetricsAssignee {
|
||||
export interface MetricsRequester {
|
||||
requester: string;
|
||||
total: number;
|
||||
active: number;
|
||||
completed_in_range: number;
|
||||
}
|
||||
|
||||
export interface MetricsMovement {
|
||||
|
||||
Reference in New Issue
Block a user