9c5e76e03f
Anade tres capas sobre el reporte diario del issue 0093:
1) Bocadillo del agente: cuadro azul encima de "Tareas hechas" con un
resumen en lenguaje natural (max 4 frases) generado por claude -p
sobre el JSON del reporte. Botones Regenerar e icono Settings.
2) Settings del prompt: modal con textarea editable para el template
del agente (key=daily_report_prompt). Compartido por todos los
usuarios. Boton Restablecer por defecto.
3) PDF descargable: boton que abre ventana nueva con HTML imprimible
(estilo A4, KPIs filtrados, tabla con enlaces absolutos por card).
Permite compartir el listado de tareas hechas con los solicitantes.
Backend:
- Migration 013 anade tablas daily_summaries y settings; seed del
prompt por defecto en castellano.
- daily_summary.go con GetSetting/SetSetting, GetDailySummary/Upsert,
runClaudePrompt (envuelve claude -p) y GenerateDailySummary que
orquesta DailyReportFor + plantilla + claude + persist.
- Nuevos endpoints:
* GET /api/reports/daily/summary
* POST /api/reports/daily/summary
* GET /api/settings/{key}
* PUT /api/settings/{key}
Frontend:
- api.ts: getDailySummary, generateDailySummary, getSetting, setSetting.
- DailyReport.tsx: estado de summary, settingsOpen, promptDraft,
filterRequester, filterAssignee, filteredDoneCards, exportPDF.
- Bocadillo con IconSparkles + IconRefresh + IconSettings.
- Modal de prompt con Guardar/Cancelar/Reset.
- Filtros Select por solicitante y asignado encima de la tabla.
- exportPDF abre window.open con HTML self-contained que incluye
enlaces ${origin}/?card=${id} y window.print() automatico.
E2E nuevo (daily-summary-pdf.spec.ts): CRUD del setting, GET summary
shape, presencia del boton PDF/Settings/Regenerar en el modal. No
invoca claude real (binario externo, no disponible en CI).
Suite completa 11/11 pasa.
811 lines
27 KiB
TypeScript
811 lines
27 KiB
TypeScript
import {
|
|
ActionIcon,
|
|
Alert,
|
|
Avatar,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Card as MCard,
|
|
Divider,
|
|
Group,
|
|
Loader,
|
|
Modal,
|
|
Paper,
|
|
ScrollArea,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Table,
|
|
Text,
|
|
Textarea,
|
|
Title,
|
|
Tooltip,
|
|
UnstyledButton,
|
|
} from "@mantine/core";
|
|
import { BarChart } from "@mantine/charts";
|
|
import {
|
|
IconAlertTriangle,
|
|
IconArrowBackUp,
|
|
IconCalendarStats,
|
|
IconCheck,
|
|
IconClock,
|
|
IconDownload,
|
|
IconHourglass,
|
|
IconLock,
|
|
IconPlus,
|
|
IconRefresh,
|
|
IconSettings,
|
|
IconSparkles,
|
|
IconTrendingUp,
|
|
} from "@tabler/icons-react";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
dailyReport,
|
|
generateDailySummary,
|
|
getDailySummary,
|
|
getSetting,
|
|
setSetting,
|
|
type DailyReport as Report,
|
|
type DailySummary,
|
|
} from "../api";
|
|
import { formatDuration } from "./format";
|
|
import { tagColor } from "./colors";
|
|
|
|
interface Props {
|
|
date: string; // YYYY-MM-DD
|
|
onJumpToCard?: (cardId: string) => void;
|
|
}
|
|
|
|
const PROMPT_KEY = "daily_report_prompt";
|
|
const PROMPT_DEFAULT =
|
|
"Eres un coach de equipo. Resume el reporte diario en un MAXIMO de 4 frases cortas, mencionando: (1) total de tareas hechas y quien destaco, (2) cualquier card reabierta o deadline vencido que merezca atencion, (3) cards estancadas criticas (30+ dias) si las hay, (4) una frase corta de animo o aviso si toca. Tono natural, primera persona del plural, sin emojis. No inventes datos; usa solo los del JSON del reporte.";
|
|
|
|
function fmtDate(s: string): string {
|
|
try {
|
|
const d = new Date(s + "T00:00:00");
|
|
return d.toLocaleDateString("es-ES", {
|
|
weekday: "long",
|
|
day: "2-digit",
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
} catch {
|
|
return s;
|
|
}
|
|
}
|
|
|
|
function KPI({
|
|
label,
|
|
value,
|
|
color,
|
|
icon,
|
|
sub,
|
|
}: {
|
|
label: string;
|
|
value: string | number;
|
|
color?: string;
|
|
icon?: React.ReactNode;
|
|
sub?: string;
|
|
}) {
|
|
return (
|
|
<Paper p="sm" withBorder radius="md">
|
|
<Group gap={6} mb={2} align="center">
|
|
{icon}
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
|
{label}
|
|
</Text>
|
|
</Group>
|
|
<Text fz={28} fw={700} c={color}>
|
|
{value}
|
|
</Text>
|
|
{sub && (
|
|
<Text size="xs" c="dimmed">
|
|
{sub}
|
|
</Text>
|
|
)}
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function RankingList<T extends { name: string; count: number; user_id?: string }>({
|
|
title,
|
|
rows,
|
|
emptyText,
|
|
withAvatar = false,
|
|
}: {
|
|
title: string;
|
|
rows: T[];
|
|
emptyText: string;
|
|
withAvatar?: boolean;
|
|
}) {
|
|
return (
|
|
<MCard withBorder radius="md" p="sm">
|
|
<Text fw={600} size="sm" mb={6}>
|
|
{title}
|
|
</Text>
|
|
{rows.length === 0 ? (
|
|
<Text size="xs" c="dimmed">
|
|
{emptyText}
|
|
</Text>
|
|
) : (
|
|
<Stack gap={4}>
|
|
{rows.map((r, i) => (
|
|
<Group key={(r.user_id || r.name) + i} gap={6} wrap="nowrap" justify="space-between">
|
|
<Group gap={6} wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
|
|
{withAvatar && (
|
|
<Avatar size={22} radius="xl" color={tagColor(r.name || String(i))}>
|
|
{(r.name || "?").slice(0, 2).toUpperCase()}
|
|
</Avatar>
|
|
)}
|
|
<Text size="sm" truncate>
|
|
{r.name || "(sin nombre)"}
|
|
</Text>
|
|
</Group>
|
|
<Badge size="sm" variant="light" color={i === 0 ? "teal" : "gray"}>
|
|
{r.count}
|
|
</Badge>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</MCard>
|
|
);
|
|
}
|
|
|
|
export function DailyReportView({ date, onJumpToCard }: Props) {
|
|
const [data, setData] = useState<Report | null>(null);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
const [summary, setSummary] = useState<DailySummary | null>(null);
|
|
const [summaryLoading, setSummaryLoading] = useState(false);
|
|
const [summaryErr, setSummaryErr] = useState<string | null>(null);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [promptDraft, setPromptDraft] = useState("");
|
|
const [filterRequester, setFilterRequester] = useState<string | null>(null);
|
|
const [filterAssignee, setFilterAssignee] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
setData(null);
|
|
setErr(null);
|
|
dailyReport(date)
|
|
.then(setData)
|
|
.catch((e) => setErr((e as Error).message));
|
|
setSummary(null);
|
|
setSummaryErr(null);
|
|
getDailySummary(date)
|
|
.then((s) => setSummary(s.exists ? s : null))
|
|
.catch(() => {});
|
|
}, [date]);
|
|
|
|
const regenerateSummary = async () => {
|
|
setSummaryLoading(true);
|
|
setSummaryErr(null);
|
|
try {
|
|
const s = await generateDailySummary(date);
|
|
setSummary({ ...s, exists: true });
|
|
} catch (e) {
|
|
setSummaryErr((e as Error).message);
|
|
} finally {
|
|
setSummaryLoading(false);
|
|
}
|
|
};
|
|
|
|
const openSettings = async () => {
|
|
try {
|
|
const s = await getSetting(PROMPT_KEY);
|
|
setPromptDraft(s.value || PROMPT_DEFAULT);
|
|
} catch {
|
|
setPromptDraft(PROMPT_DEFAULT);
|
|
}
|
|
setSettingsOpen(true);
|
|
};
|
|
|
|
const saveSettings = async () => {
|
|
await setSetting(PROMPT_KEY, promptDraft);
|
|
setSettingsOpen(false);
|
|
};
|
|
|
|
const resetSettings = () => setPromptDraft(PROMPT_DEFAULT);
|
|
|
|
const hourlyChartData = useMemo(() => {
|
|
if (!data) return [];
|
|
return data.hourly_moves.map((n, h) => ({
|
|
hora: String(h).padStart(2, "0") + ":00",
|
|
movimientos: n,
|
|
}));
|
|
}, [data]);
|
|
|
|
const requesterOptions = useMemo(() => {
|
|
if (!data) return [];
|
|
const set = new Set<string>();
|
|
for (const c of data.done_cards) if (c.requester) set.add(c.requester);
|
|
return Array.from(set).sort();
|
|
}, [data]);
|
|
|
|
const assigneeOptions = useMemo(() => {
|
|
if (!data) return [];
|
|
const m = new Map<string, string>();
|
|
for (const c of data.done_cards) {
|
|
if (c.assignee_id) m.set(c.assignee_id, c.assignee_name || c.assignee_id);
|
|
}
|
|
return Array.from(m.entries()).map(([value, label]) => ({ value, label }));
|
|
}, [data]);
|
|
|
|
const filteredDoneCards = useMemo(() => {
|
|
if (!data) return [];
|
|
return data.done_cards.filter((c) => {
|
|
if (filterRequester && c.requester !== filterRequester) return false;
|
|
if (filterAssignee && c.assignee_id !== filterAssignee) return false;
|
|
return true;
|
|
});
|
|
}, [data, filterRequester, filterAssignee]);
|
|
|
|
const exportPDF = () => {
|
|
if (!data) return;
|
|
const win = window.open("", "_blank");
|
|
if (!win) return;
|
|
const origin = window.location.origin;
|
|
const dateLabel = (() => {
|
|
try {
|
|
return new Date(data.date + "T00:00:00").toLocaleDateString("es-ES", {
|
|
weekday: "long",
|
|
day: "2-digit",
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
} catch {
|
|
return data.date;
|
|
}
|
|
})();
|
|
const filterSub: string[] = [];
|
|
if (filterRequester) filterSub.push(`solicitante=${filterRequester}`);
|
|
if (filterAssignee) {
|
|
const a = assigneeOptions.find((o) => o.value === filterAssignee);
|
|
filterSub.push(`asignado=${a?.label || filterAssignee}`);
|
|
}
|
|
const escape = (s: string) =>
|
|
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
const rows = filteredDoneCards
|
|
.map((c) => {
|
|
const tags = (c.tags || []).map(escape).join(", ");
|
|
const link = `${origin}/?card=${c.id}`;
|
|
return `<tr>
|
|
<td class="num">${String(c.seq_num).padStart(5, "0")}</td>
|
|
<td><a href="${link}">${escape(c.title)}</a></td>
|
|
<td>${escape(c.requester || "")}</td>
|
|
<td>${escape(c.assignee_name || "")}</td>
|
|
<td>${escape(tags)}</td>
|
|
<td class="num">${formatDuration(c.lead_time_ms)}</td>
|
|
</tr>`;
|
|
})
|
|
.join("");
|
|
const html = `<!doctype html>
|
|
<html lang="es"><head><meta charset="utf-8" />
|
|
<title>Reporte ${data.date}</title>
|
|
<style>
|
|
@page { margin: 18mm 15mm; }
|
|
body { font-family: system-ui, sans-serif; color: #222; }
|
|
h1 { font-size: 18pt; margin-bottom: 4px; }
|
|
.sub { color: #666; font-size: 10pt; margin-bottom: 16px; }
|
|
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 18px; }
|
|
.kpi { border: 1px solid #ddd; border-radius: 6px; padding: 8px; }
|
|
.kpi .l { font-size: 8pt; color: #888; text-transform: uppercase; }
|
|
.kpi .v { font-size: 16pt; font-weight: 700; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 9pt; }
|
|
th, td { border-bottom: 1px solid #e5e5e5; padding: 6px 4px; text-align: left; vertical-align: top; }
|
|
th { background: #f5f5f5; font-weight: 600; }
|
|
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
a { color: #1c7ed6; text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
footer { margin-top: 20px; font-size: 8pt; color: #888; }
|
|
</style></head><body>
|
|
<h1>Reporte diario · ${escape(dateLabel)}</h1>
|
|
<div class="sub">${escape(data.date)} · ${escape(data.tz)}${
|
|
filterSub.length ? " · filtros: " + filterSub.map(escape).join(", ") : ""
|
|
}</div>
|
|
<div class="kpis">
|
|
<div class="kpi"><div class="l">Hechas</div><div class="v">${filteredDoneCards.length}</div></div>
|
|
<div class="kpi"><div class="l">Lead time avg</div><div class="v">${formatDuration(data.lead_time.avg_ms)}</div></div>
|
|
<div class="kpi"><div class="l">Deadlines on-time</div><div class="v">${data.deadlines.met}/${data.deadlines.met + data.deadlines.missed}</div></div>
|
|
<div class="kpi"><div class="l">Reabiertas</div><div class="v">${data.kpis.reopened}</div></div>
|
|
</div>
|
|
${summary?.summary ? `<p style="border-left:4px solid #1c7ed6; padding:8px 12px; background:#eef6fd; border-radius:4px;">${escape(summary.summary)}</p>` : ""}
|
|
<table>
|
|
<thead><tr>
|
|
<th class="num">#</th>
|
|
<th>Titulo</th>
|
|
<th>Solicitante</th>
|
|
<th>Asignado</th>
|
|
<th>Tags</th>
|
|
<th class="num">Lead time</th>
|
|
</tr></thead>
|
|
<tbody>${rows || '<tr><td colspan="6" style="text-align:center;color:#888;">Sin tareas que cumplan el filtro.</td></tr>'}</tbody>
|
|
</table>
|
|
<footer>Generado por kanban · ${escape(origin)}</footer>
|
|
<script>window.addEventListener("load", () => setTimeout(() => window.print(), 250));</script>
|
|
</body></html>`;
|
|
win.document.write(html);
|
|
win.document.close();
|
|
};
|
|
|
|
if (err) {
|
|
return (
|
|
<Alert color="red" icon={<IconAlertTriangle size={14} />}>
|
|
{err}
|
|
</Alert>
|
|
);
|
|
}
|
|
if (!data) {
|
|
return (
|
|
<Group justify="center" p="xl">
|
|
<Loader size="sm" />
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
const k = data.kpis;
|
|
const onTimePct =
|
|
k.deadlines_met + k.deadlines_missed > 0
|
|
? Math.round((k.deadlines_met / (k.deadlines_met + k.deadlines_missed)) * 100)
|
|
: null;
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
<Group justify="space-between" wrap="wrap">
|
|
<Group gap={6}>
|
|
<IconCalendarStats size={20} />
|
|
<Title order={4}>Reporte diario</Title>
|
|
</Group>
|
|
<Text size="sm" c="dimmed" tt="capitalize">
|
|
{fmtDate(data.date)}
|
|
</Text>
|
|
</Group>
|
|
|
|
<SimpleGrid cols={{ base: 2, sm: 4, md: 6 }} spacing="xs">
|
|
<KPI label="Hechas" value={k.done} color="teal" icon={<IconCheck size={14} color="var(--mantine-color-teal-6)" />} />
|
|
<KPI label="Creadas" value={k.created} icon={<IconPlus size={14} />} />
|
|
<KPI label="Movimientos" value={k.moves} icon={<IconRefresh size={14} />} />
|
|
<KPI
|
|
label="Bloqueado"
|
|
value={formatDuration(k.blocked_ms)}
|
|
color="yellow"
|
|
icon={<IconLock size={14} color="var(--mantine-color-yellow-6)" />}
|
|
/>
|
|
<KPI
|
|
label="Reabiertas"
|
|
value={k.reopened}
|
|
color={k.reopened > 0 ? "orange" : undefined}
|
|
icon={<IconArrowBackUp size={14} />}
|
|
/>
|
|
<KPI
|
|
label="Deadlines"
|
|
value={onTimePct != null ? `${onTimePct}%` : "—"}
|
|
color={onTimePct == null ? "dimmed" : onTimePct >= 80 ? "teal" : "red"}
|
|
sub={`${k.deadlines_met} on-time / ${k.deadlines_missed} vencidos`}
|
|
icon={<IconHourglass size={14} />}
|
|
/>
|
|
</SimpleGrid>
|
|
|
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="xs">
|
|
<RankingList
|
|
title="Asignado: mas hechas"
|
|
rows={data.top_assignees_done}
|
|
emptyText="Sin hechas con asignado."
|
|
withAvatar
|
|
/>
|
|
<RankingList
|
|
title="Asignado: mas creadas"
|
|
rows={data.top_assignees_created}
|
|
emptyText="Sin actor en creadas."
|
|
withAvatar
|
|
/>
|
|
<RankingList
|
|
title="Solicitante: mas atendidas"
|
|
rows={data.top_requesters_done}
|
|
emptyText="Sin solicitantes con hechas."
|
|
/>
|
|
<RankingList
|
|
title="Solicitante: mas aportadas"
|
|
rows={data.top_requesters_added}
|
|
emptyText="Sin nuevas con solicitante."
|
|
/>
|
|
</SimpleGrid>
|
|
|
|
{/* Bocadillo de agente — encima de tareas hechas */}
|
|
<Paper
|
|
withBorder
|
|
radius="md"
|
|
p="sm"
|
|
bg="var(--mantine-color-blue-light)"
|
|
style={{ borderLeftWidth: 4, borderLeftColor: "var(--mantine-color-blue-6)" }}
|
|
>
|
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
|
<Group gap={6} align="flex-start" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
|
<IconSparkles size={18} color="var(--mantine-color-blue-6)" style={{ flexShrink: 0, marginTop: 2 }} />
|
|
<Box style={{ flex: 1 }}>
|
|
{summaryErr && (
|
|
<Alert color="red" mb={4} icon={<IconAlertTriangle size={14} />}>
|
|
{summaryErr}
|
|
</Alert>
|
|
)}
|
|
{summaryLoading ? (
|
|
<Group gap={6}><Loader size="xs" /><Text size="sm" c="dimmed">Generando resumen…</Text></Group>
|
|
) : summary?.summary ? (
|
|
<>
|
|
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>{summary.summary}</Text>
|
|
{summary.generated_at && (
|
|
<Text size="xs" c="dimmed" mt={4}>
|
|
Generado {new Date(summary.generated_at).toLocaleString()} · {summary.model}
|
|
</Text>
|
|
)}
|
|
</>
|
|
) : (
|
|
<Text size="sm" c="dimmed" fs="italic">Aun no hay resumen del dia. Pulsa "Generar".</Text>
|
|
)}
|
|
</Box>
|
|
</Group>
|
|
<Group gap={4} wrap="nowrap">
|
|
<Tooltip label={summary?.exists ? "Regenerar" : "Generar"} withArrow>
|
|
<ActionIcon variant="subtle" color="blue" onClick={regenerateSummary} loading={summaryLoading} aria-label="Regenerar resumen">
|
|
<IconRefresh size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
<Tooltip label="Configurar prompt" withArrow>
|
|
<ActionIcon variant="subtle" color="gray" onClick={openSettings} aria-label="Configurar prompt">
|
|
<IconSettings size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Group>
|
|
</Group>
|
|
</Paper>
|
|
|
|
<MCard withBorder radius="md" p="sm">
|
|
<Group justify="space-between" mb="xs" wrap="wrap" gap={6}>
|
|
<Group gap={6} wrap="wrap">
|
|
<Text fw={600} size="sm">
|
|
Tareas hechas
|
|
</Text>
|
|
<Badge size="xs" variant="light">
|
|
N {filteredDoneCards.length}
|
|
{filteredDoneCards.length !== data.done_cards.length ? ` / ${data.done_cards.length}` : ""}
|
|
</Badge>
|
|
<Text size="xs" c="dimmed">
|
|
Lead time avg {data.lead_time.samples > 0 ? formatDuration(data.lead_time.avg_ms) : "—"} · p50{" "}
|
|
{data.lead_time.samples > 0 ? formatDuration(data.lead_time.p50_ms) : "—"} · p95{" "}
|
|
{data.lead_time.samples > 0 ? formatDuration(data.lead_time.p95_ms) : "—"}
|
|
</Text>
|
|
</Group>
|
|
<Group gap={6} wrap="nowrap">
|
|
<Select
|
|
size="xs"
|
|
placeholder="Solicitante"
|
|
data={requesterOptions}
|
|
value={filterRequester}
|
|
onChange={setFilterRequester}
|
|
clearable
|
|
searchable
|
|
style={{ width: 160 }}
|
|
aria-label="Filtrar por solicitante"
|
|
/>
|
|
<Select
|
|
size="xs"
|
|
placeholder="Asignado"
|
|
data={assigneeOptions}
|
|
value={filterAssignee}
|
|
onChange={setFilterAssignee}
|
|
clearable
|
|
searchable
|
|
style={{ width: 160 }}
|
|
aria-label="Filtrar por asignado"
|
|
/>
|
|
<Button
|
|
size="xs"
|
|
leftSection={<IconDownload size={14} />}
|
|
variant="light"
|
|
onClick={exportPDF}
|
|
data-test="daily-report-pdf"
|
|
>
|
|
PDF
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
{filteredDoneCards.length === 0 ? (
|
|
<Text size="xs" c="dimmed">
|
|
Sin hechas en este dia.
|
|
</Text>
|
|
) : (
|
|
<ScrollArea style={{ maxHeight: 280 }} type="auto">
|
|
<Table verticalSpacing={4} fz="xs" highlightOnHover striped="even">
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th style={{ width: 70 }}>#</Table.Th>
|
|
<Table.Th>Titulo</Table.Th>
|
|
<Table.Th>Solicitante</Table.Th>
|
|
<Table.Th>Asignado</Table.Th>
|
|
<Table.Th>Tags</Table.Th>
|
|
<Table.Th style={{ width: 110 }}>Lead time</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{filteredDoneCards.map((c) => (
|
|
<Table.Tr key={c.id}>
|
|
<Table.Td>
|
|
<Text size="xs" c="dimmed">
|
|
{String(c.seq_num).padStart(5, "0")}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<UnstyledButton onClick={() => onJumpToCard?.(c.id)} style={{ textAlign: "left" }}>
|
|
<Text size="xs" fw={500} td="underline">
|
|
{c.title}
|
|
</Text>
|
|
</UnstyledButton>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="xs">{c.requester || "—"}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="xs">{c.assignee_name || "—"}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Group gap={2} wrap="wrap">
|
|
{(c.tags || []).slice(0, 3).map((t) => (
|
|
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">
|
|
{t}
|
|
</Badge>
|
|
))}
|
|
</Group>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="xs" c="dimmed">
|
|
{formatDuration(c.lead_time_ms)}
|
|
</Text>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</ScrollArea>
|
|
)}
|
|
</MCard>
|
|
|
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
|
<MCard withBorder radius="md" p="sm">
|
|
<Group justify="space-between" mb={6}>
|
|
<Text fw={600} size="sm">
|
|
Movimientos por hora
|
|
</Text>
|
|
<Badge size="xs" variant="light">
|
|
{k.moves}
|
|
</Badge>
|
|
</Group>
|
|
{k.moves === 0 ? (
|
|
<Text size="xs" c="dimmed">
|
|
Sin movimientos.
|
|
</Text>
|
|
) : (
|
|
<BarChart
|
|
h={160}
|
|
data={hourlyChartData}
|
|
dataKey="hora"
|
|
series={[{ name: "movimientos", color: "blue.6" }]}
|
|
tickLine="y"
|
|
withTooltip
|
|
valueFormatter={(v: number) => String(v)}
|
|
/>
|
|
)}
|
|
</MCard>
|
|
|
|
<MCard withBorder radius="md" p="sm">
|
|
<Text fw={600} size="sm" mb={6}>
|
|
Tags trabajadas
|
|
</Text>
|
|
{data.tags_done.length === 0 ? (
|
|
<Text size="xs" c="dimmed">
|
|
Sin tags.
|
|
</Text>
|
|
) : (
|
|
<Group gap={4} wrap="wrap">
|
|
{data.tags_done.map((t) => (
|
|
<Badge key={t.name} variant="light" color={tagColor(t.name)} size="sm">
|
|
{t.name} · {t.count}
|
|
</Badge>
|
|
))}
|
|
</Group>
|
|
)}
|
|
</MCard>
|
|
</SimpleGrid>
|
|
|
|
{data.reopened_cards.length > 0 && (
|
|
<MCard withBorder radius="md" p="sm">
|
|
<Group gap={6} mb={6}>
|
|
<IconArrowBackUp size={14} color="var(--mantine-color-orange-6)" />
|
|
<Text fw={600} size="sm">
|
|
Reabiertas (Done → otra)
|
|
</Text>
|
|
<Badge size="xs" variant="light" color="orange">
|
|
{data.reopened_cards.length}
|
|
</Badge>
|
|
</Group>
|
|
<Stack gap={4}>
|
|
{data.reopened_cards.map((r) => (
|
|
<Group key={r.card_id + r.ts} gap={6} wrap="nowrap" justify="space-between">
|
|
<UnstyledButton onClick={() => onJumpToCard?.(r.card_id)} style={{ minWidth: 0, flex: 1 }}>
|
|
<Text size="xs" truncate td="underline">
|
|
{r.title}
|
|
</Text>
|
|
</UnstyledButton>
|
|
<Text size="xs" c="dimmed">
|
|
{r.from_column} → {r.to_column}
|
|
</Text>
|
|
{r.actor_name && (
|
|
<Badge size="xs" variant="light" color="cyan">
|
|
{r.actor_name}
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
</MCard>
|
|
)}
|
|
|
|
{(data.deadlines.missed > 0 || data.deadlines.met > 0) && (
|
|
<MCard withBorder radius="md" p="sm">
|
|
<Group gap={6} mb={6}>
|
|
<IconHourglass size={14} />
|
|
<Text fw={600} size="sm">
|
|
Deadlines
|
|
</Text>
|
|
<Badge size="xs" variant="light" color="teal">
|
|
{data.deadlines.met} on-time
|
|
</Badge>
|
|
<Badge size="xs" variant="light" color="red">
|
|
{data.deadlines.missed} vencidos
|
|
</Badge>
|
|
</Group>
|
|
{data.deadlines.list.length > 0 && (
|
|
<Stack gap={4}>
|
|
{data.deadlines.list.map((d) => (
|
|
<Group key={d.card_id} gap={6} justify="space-between" wrap="nowrap">
|
|
<UnstyledButton onClick={() => onJumpToCard?.(d.card_id)} style={{ minWidth: 0, flex: 1 }}>
|
|
<Text size="xs" truncate td="underline">
|
|
{d.title}
|
|
</Text>
|
|
</UnstyledButton>
|
|
<Text size="xs" c="red">
|
|
+{formatDuration(d.late_ms)} tarde
|
|
</Text>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</MCard>
|
|
)}
|
|
|
|
<MCard withBorder radius="md" p="sm">
|
|
<Group gap={6} mb={6}>
|
|
<IconTrendingUp size={14} />
|
|
<Text fw={600} size="sm">
|
|
Cards estancadas (al final del dia)
|
|
</Text>
|
|
<Badge size="xs" variant="light" color="orange">
|
|
{data.stale_cards.d7.length}d7
|
|
</Badge>
|
|
<Badge size="xs" variant="light" color="red">
|
|
{data.stale_cards.d14.length}d14
|
|
</Badge>
|
|
<Badge size="xs" variant="filled" color="red">
|
|
{data.stale_cards.d30.length}d30
|
|
</Badge>
|
|
</Group>
|
|
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
|
|
<Box>
|
|
<Text size="xs" fw={500} c="orange" mb={4}>
|
|
7-13 dias
|
|
</Text>
|
|
<Stack gap={2}>
|
|
{data.stale_cards.d7.slice(0, 8).map((s) => (
|
|
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
|
|
<Text size="xs" truncate>
|
|
{s.title}{" "}
|
|
<Text span c="dimmed" size="xs">
|
|
· {s.column_name} · {s.days}d
|
|
</Text>
|
|
</Text>
|
|
</UnstyledButton>
|
|
))}
|
|
{data.stale_cards.d7.length === 0 && (
|
|
<Text size="xs" c="dimmed">
|
|
Ninguna.
|
|
</Text>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
<Box>
|
|
<Text size="xs" fw={500} c="red" mb={4}>
|
|
14-29 dias
|
|
</Text>
|
|
<Stack gap={2}>
|
|
{data.stale_cards.d14.slice(0, 8).map((s) => (
|
|
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
|
|
<Text size="xs" truncate>
|
|
{s.title}{" "}
|
|
<Text span c="dimmed" size="xs">
|
|
· {s.column_name} · {s.days}d
|
|
</Text>
|
|
</Text>
|
|
</UnstyledButton>
|
|
))}
|
|
{data.stale_cards.d14.length === 0 && (
|
|
<Text size="xs" c="dimmed">
|
|
Ninguna.
|
|
</Text>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
<Box>
|
|
<Text size="xs" fw={500} c="red.8" mb={4}>
|
|
30+ dias
|
|
</Text>
|
|
<Stack gap={2}>
|
|
{data.stale_cards.d30.slice(0, 8).map((s) => (
|
|
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
|
|
<Text size="xs" truncate fw={600}>
|
|
{s.title}{" "}
|
|
<Text span c="dimmed" size="xs" fw={400}>
|
|
· {s.column_name} · {s.days}d
|
|
</Text>
|
|
</Text>
|
|
</UnstyledButton>
|
|
))}
|
|
{data.stale_cards.d30.length === 0 && (
|
|
<Text size="xs" c="dimmed">
|
|
Ninguna.
|
|
</Text>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
</SimpleGrid>
|
|
</MCard>
|
|
|
|
<Divider />
|
|
<Group gap={6} justify="space-between">
|
|
<Group gap={4}>
|
|
<IconClock size={14} />
|
|
<Text size="xs" c="dimmed">
|
|
TZ: {data.tz} · cards archivadas hoy: {data.archived_today}
|
|
</Text>
|
|
</Group>
|
|
</Group>
|
|
|
|
<Modal opened={settingsOpen} onClose={() => setSettingsOpen(false)} title="Prompt del agente diario" size="lg" zIndex={500}>
|
|
<Stack gap="sm">
|
|
<Text size="xs" c="dimmed">
|
|
Plantilla que el agente recibe junto al JSON del reporte. Compartida por todos los usuarios.
|
|
</Text>
|
|
<Textarea
|
|
autosize
|
|
minRows={6}
|
|
maxRows={20}
|
|
value={promptDraft}
|
|
onChange={(e) => setPromptDraft(e.currentTarget.value)}
|
|
data-test="daily-report-prompt"
|
|
/>
|
|
<Group justify="space-between">
|
|
<Button size="xs" variant="subtle" onClick={resetSettings}>
|
|
Restablecer por defecto
|
|
</Button>
|
|
<Group gap={6}>
|
|
<Button size="xs" variant="subtle" color="gray" onClick={() => setSettingsOpen(false)}>
|
|
Cancelar
|
|
</Button>
|
|
<Button size="xs" onClick={saveSettings} data-test="daily-report-prompt-save">
|
|
Guardar
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
</Stack>
|
|
);
|
|
}
|