cambia algunos iconos para que sin responsable sea 🙅 y que las badges en la web estén centradas aunque la row sea de dos lineas

main
borja 6 days ago
parent 226e1bc01f
commit a24e591cb4

@ -1,240 +1,251 @@
<script lang="ts"> <script lang="ts">
import TaskItem from "$lib/ui/data/TaskItem.svelte"; import TaskItem from "$lib/ui/data/TaskItem.svelte";
import Card from "$lib/ui/layout/Card.svelte"; import Card from "$lib/ui/layout/Card.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { slide } from "svelte/transition"; import { slide } from "svelte/transition";
type GroupItem = { type GroupItem = {
id: string; id: string;
name: string | null; name: string | null;
counts: { open: number; unassigned: number }; counts: { open: number; unassigned: number };
}; };
type Task = { type Task = {
id: number; id: number;
description: string; description: string;
due_date: string | null; due_date: string | null;
display_code: number | null; display_code: number | null;
group_id?: string | null; group_id?: string | null;
assignees?: string[]; assignees?: string[];
}; };
export let data: { export let data: {
userId: string | null; userId: string | null;
groups: GroupItem[]; groups: GroupItem[];
itemsByGroup: Record<string, Task[]>; itemsByGroup: Record<string, Task[]>;
unassignedFirst?: boolean; unassignedFirst?: boolean;
}; };
const groups = data.groups || []; const groups = data.groups || [];
let itemsByGroup: Record<string, Task[]> = {}; let itemsByGroup: Record<string, Task[]> = {};
for (const [gid, arr] of Object.entries(data.itemsByGroup || {})) { for (const [gid, arr] of Object.entries(data.itemsByGroup || {})) {
itemsByGroup[gid] = Array.isArray(arr as any) ? ([...(arr as any)]) : []; itemsByGroup[gid] = Array.isArray(arr as any) ? [...(arr as any)] : [];
} }
function buildQuery(params: { unassignedFirst?: boolean }) { function buildQuery(params: { unassignedFirst?: boolean }) {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
if (params.unassignedFirst) sp.set("unassignedFirst", "true"); if (params.unassignedFirst) sp.set("unassignedFirst", "true");
return sp.toString(); return sp.toString();
} }
const storageKey = `groupsCollapsed:v1:${data.userId ?? "anon"}`; const storageKey = `groupsCollapsed:v1:${data.userId ?? "anon"}`;
let collapsed: Record<string, boolean> = {}; let collapsed: Record<string, boolean> = {};
function hasTasks(groupId: string): boolean { function hasTasks(groupId: string): boolean {
const arr = itemsByGroup[groupId] || []; const arr = itemsByGroup[groupId] || [];
return Array.isArray(arr) && arr.length > 0; return Array.isArray(arr) && arr.length > 0;
} }
function defaultCollapsedFor(groupId: string): boolean { function defaultCollapsedFor(groupId: string): boolean {
// Por defecto, colapsado si no tiene tareas abiertas // Por defecto, colapsado si no tiene tareas abiertas
return !hasTasks(groupId); return !hasTasks(groupId);
} }
function isOpen(groupId: string): boolean { function isOpen(groupId: string): boolean {
const v = collapsed[groupId]; const v = collapsed[groupId];
if (typeof v === "boolean") return !v; if (typeof v === "boolean") return !v;
return !defaultCollapsedFor(groupId); return !defaultCollapsedFor(groupId);
} }
function saveCollapsed() { function saveCollapsed() {
try { try {
const currentIds = new Set(groups.map((g) => g.id)); const currentIds = new Set(groups.map((g) => g.id));
const pruned: Record<string, boolean> = {}; const pruned: Record<string, boolean> = {};
for (const id of Object.keys(collapsed)) { for (const id of Object.keys(collapsed)) {
if (currentIds.has(id)) pruned[id] = !!collapsed[id]; if (currentIds.has(id)) pruned[id] = !!collapsed[id];
} }
localStorage.setItem(storageKey, JSON.stringify(pruned)); localStorage.setItem(storageKey, JSON.stringify(pruned));
} catch {} } catch {}
} }
function handleToggle(groupId: string, e: Event) { function handleToggle(groupId: string, e: Event) {
const open = (e.currentTarget as HTMLDetailsElement).open; const open = (e.currentTarget as HTMLDetailsElement).open;
collapsed = { ...collapsed, [groupId]: !open }; collapsed = { ...collapsed, [groupId]: !open };
saveCollapsed(); saveCollapsed();
} }
onMount(() => { onMount(() => {
try { try {
const raw = localStorage.getItem(storageKey); const raw = localStorage.getItem(storageKey);
const saved = raw ? JSON.parse(raw) : {}; const saved = raw ? JSON.parse(raw) : {};
const map: Record<string, boolean> = {}; const map: Record<string, boolean> = {};
const currentIds = new Set(groups.map((g) => g.id)); const currentIds = new Set(groups.map((g) => g.id));
for (const g of groups) { for (const g of groups) {
map[g.id] = map[g.id] =
typeof saved?.[g.id] === "boolean" typeof saved?.[g.id] === "boolean"
? !!saved[g.id] ? !!saved[g.id]
: defaultCollapsedFor(g.id); : defaultCollapsedFor(g.id);
} }
// Limpieza de claves obsoletas en storage // Limpieza de claves obsoletas en storage
const cleaned: Record<string, boolean> = {}; const cleaned: Record<string, boolean> = {};
for (const k of Object.keys(saved || {})) { for (const k of Object.keys(saved || {})) {
if (currentIds.has(k)) cleaned[k] = !!saved[k]; if (currentIds.has(k)) cleaned[k] = !!saved[k];
} }
collapsed = map; collapsed = map;
localStorage.setItem(storageKey, JSON.stringify(cleaned || map)); localStorage.setItem(storageKey, JSON.stringify(cleaned || map));
} catch { } catch {
// si falla, dejamos los defaults (basados en tareas) // si falla, dejamos los defaults (basados en tareas)
collapsed = {}; collapsed = {};
} }
}); });
function maintainScrollWhile(mutate: () => void) { function maintainScrollWhile(mutate: () => void) {
const y = window.scrollY; const y = window.scrollY;
mutate(); mutate();
queueMicrotask(() => window.scrollTo({ top: y })); queueMicrotask(() => window.scrollTo({ top: y }));
} }
function updateGroupTask(groupId: string, detail: { id: number; action: string; patch: Partial<Task & { completed?: boolean; completed_at?: string | null }> }) { function updateGroupTask(
const { id, action, patch } = detail; groupId: string,
const arr = itemsByGroup[groupId] || []; detail: {
const idx = arr.findIndex((t) => t.id === id); id: number;
action: string;
if (action === 'complete') { patch: Partial<
if (idx >= 0) { Task & { completed?: boolean; completed_at?: string | null }
maintainScrollWhile(() => { >;
arr.splice(idx, 1); },
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] }; ) {
}); const { id, action, patch } = detail;
} const arr = itemsByGroup[groupId] || [];
return; const idx = arr.findIndex((t) => t.id === id);
}
if (action === "complete") {
if (idx >= 0) { if (idx >= 0) {
arr[idx] = { ...arr[idx], ...patch }; maintainScrollWhile(() => {
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] }; arr.splice(idx, 1);
} itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
} });
}
return;
}
if (idx >= 0) {
arr[idx] = { ...arr[idx], ...patch };
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
}
}
</script> </script>
<svelte:head> <svelte:head>
<title>Grupos</title> <title>Grupos</title>
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
</svelte:head> </svelte:head>
{#if groups.length === 0} {#if groups.length === 0}
<p>No perteneces a ningún grupo permitido.</p> <p>No perteneces a ningún grupo permitido.</p>
{:else} {:else}
<h1 class="title">Grupos</h1> <h1 class="title">Grupos</h1>
<div class="toolbar"> <div class="toolbar">
<label class="toggle"> <label class="toggle">
<input <input
type="checkbox" type="checkbox"
checked={!!data.unassignedFirst} checked={!!data.unassignedFirst}
on:change={(e) => { on:change={(e) => {
const checked = (e.currentTarget as HTMLInputElement).checked; const checked = (e.currentTarget as HTMLInputElement).checked;
const q = buildQuery({ unassignedFirst: checked }); const q = buildQuery({ unassignedFirst: checked });
location.href = q ? `/app/groups?${q}` : `/app/groups`; location.href = q ? `/app/groups?${q}` : `/app/groups`;
}} }}
/> />
Sin responsable primero Sin responsable primero
</label> </label>
</div> </div>
{#each groups as g (g.id)} {#each groups as g (g.id)}
<details <details
class="group" class="group"
open={isOpen(g.id)} open={isOpen(g.id)}
on:toggle={(e) => handleToggle(g.id, e)} on:toggle={(e) => handleToggle(g.id, e)}
> >
<summary class="group-header"> <summary class="group-header">
<span class="name">{g.name ?? g.id}</span> <span class="name">{g.name ?? g.id}</span>
<span class="counts"> <span class="counts">
<span class="badge">tareas: {g.counts.open}</span> <span class="badge">tareas: {g.counts.open}</span>
<span class="badge warn">🙅‍♂️: {g.counts.unassigned}</span> <span class="badge warn">🙅‍♂️: {g.counts.unassigned}</span>
</span> </span>
</summary> </summary>
{#if isOpen(g.id)} {#if isOpen(g.id)}
<div in:slide={{ duration: 180 }} out:slide={{ duration: 180 }}> <div in:slide={{ duration: 180 }} out:slide={{ duration: 180 }}>
<Card> <Card>
<ul class="list"> <ul class="list">
{#each itemsByGroup[g.id] || [] as t (t.id)} {#each itemsByGroup[g.id] || [] as t (t.id)}
<TaskItem <TaskItem
id={t.id} id={t.id}
description={t.description} description={t.description}
due_date={t.due_date} due_date={t.due_date}
display_code={t.display_code} display_code={t.display_code}
assignees={t.assignees || []} assignees={t.assignees || []}
currentUserId={data.userId} currentUserId={data.userId}
groupName={g.name ?? g.id} groupName={g.name ?? g.id}
groupId={t.group_id ?? g.id} groupId={t.group_id ?? g.id}
on:changed={(e) => updateGroupTask(g.id, e.detail)} on:changed={(e) => updateGroupTask(g.id, e.detail)}
/> />
{/each} {/each}
</ul> </ul>
</Card> </Card>
</div> </div>
{/if} {/if}
</details> </details>
{/each} {/each}
{/if} {/if}
<style> <style>
.title { .title {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.toolbar { .toolbar {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.toggle { .toggle {
display: inline-flex; display: inline-flex;
gap: 6px; gap: 6px;
align-items: center; align-items: center;
} }
.group { .group {
margin: 0.5rem 0; margin: 0.5rem 0;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
background: var(--color-surface); background: var(--color-surface);
} }
.group-header { .group-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
cursor: pointer; cursor: pointer;
list-style: none; list-style: none;
user-select: none; user-select: none;
} }
.group-header .name { .group-header .name {
font-weight: 600; font-weight: 600;
} }
.counts { .counts {
display: inline-flex; display: inline-flex;
gap: 0.5rem; gap: 0.5rem;
} }
.badge { .badge {
padding: 2px 8px; padding: 2px 8px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 999px; border-radius: 999px;
font-size: 12px; font-size: 12px;
} align-self: center;
.badge.warn { white-space: nowrap;
border-color: var(--color-warning); }
} .badge.warn {
.list { border-color: var(--color-warning);
margin: 0; }
padding: 0.25rem 0.5rem 0.5rem 0.5rem; .list {
list-style: none; margin: 0;
} padding: 0.25rem 0.5rem 0.5rem 0.5rem;
list-style: none;
}
</style> </style>

@ -1037,7 +1037,7 @@ export class CommandService {
VALUES (?, ?, ?, NULL) VALUES (?, ?, ?, NULL)
`).run(ensured, tokenHash, expiresIso); `).run(ensured, tokenHash, expiresIso);
try { Metrics.inc('web_tokens_issued_total'); } catch {} try { Metrics.inc('web_tokens_issued_total'); } catch { }
const url = new URL(`/login?token=${encodeURIComponent(token)}`, base).toString(); const url = new URL(`/login?token=${encodeURIComponent(token)}`, base).toString();
return [{ return [{
@ -1050,7 +1050,7 @@ export class CommandService {
const feature = String(process.env.FEATURE_HELP_V2 ?? 'true').toLowerCase(); const feature = String(process.env.FEATURE_HELP_V2 ?? 'true').toLowerCase();
const helpV2Enabled = !['false', '0', 'no'].includes(feature); const helpV2Enabled = !['false', '0', 'no'].includes(feature);
try { Metrics.inc('commands_unknown_total'); } catch {} try { Metrics.inc('commands_unknown_total'); } catch { }
if (!helpV2Enabled) { if (!helpV2Enabled) {
return [{ return [{
recipient: context.sender, recipient: context.sender,
@ -1093,7 +1093,7 @@ export class CommandService {
try { try {
const gid = isGroupId(context.groupId) ? context.groupId : 'dm'; const gid = isGroupId(context.groupId) ? context.groupId : 'dm';
Metrics.inc('onboarding_assign_failures_total', 1, { group_id: String(gid), source, reason }); Metrics.inc('onboarding_assign_failures_total', 1, { group_id: String(gid), source, reason });
} catch {} } catch { }
}; };
// 1) Menciones aportadas por el backend (JIDs crudos) // 1) Menciones aportadas por el backend (JIDs crudos)
@ -1229,7 +1229,7 @@ export class CommandService {
`).run(taskId, groupIdToUse, context.messageId); `).run(taskId, groupIdToUse, context.messageId);
} }
} }
} catch {} } catch { }
// Recuperar la tarea creada para obtener display_code asignado // Recuperar la tarea creada para obtener display_code asignado
const createdTask = TaskService.getTaskById(taskId); const createdTask = TaskService.getTaskById(taskId);
@ -1291,16 +1291,16 @@ export class CommandService {
const enabled = isTest const enabled = isTest
? String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true' ? String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true'
: (() => { : (() => {
const v = process.env.ONBOARDING_PROMPTS_ENABLED; const v = process.env.ONBOARDING_PROMPTS_ENABLED;
return v == null ? true : ['true','1','yes'].includes(String(v).toLowerCase()); return v == null ? true : ['true', '1', 'yes'].includes(String(v).toLowerCase());
})(); })();
const groupLabel = isGroupId(context.groupId) ? String(context.groupId) : 'dm'; const groupLabel = isGroupId(context.groupId) ? String(context.groupId) : 'dm';
if (!enabled) { if (!enabled) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'disabled' }); } catch {} try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'disabled' }); } catch { }
} else { } else {
const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim(); const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
if (!bot || !/^\d+$/.test(bot)) { if (!bot || !/^\d+$/.test(bot)) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'missing_bot_number' }); } catch {} try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'missing_bot_number' }); } catch { }
} else { } else {
const list = unresolvedList.join(', '); const list = unresolvedList.join(', ');
let groupCtx = ''; let groupCtx = '';
@ -1310,7 +1310,7 @@ export class CommandService {
} }
const msg = `No puedo asignar a ${list} aún${groupCtx}. Pídele que toque este enlace y diga 'activar': https://wa.me/${bot}`; const msg = `No puedo asignar a ${list} aún${groupCtx}. Pídele que toque este enlace y diga 'activar': https://wa.me/${bot}`;
responses.push({ recipient: createdBy, message: msg }); responses.push({ recipient: createdBy, message: msg });
try { Metrics.inc('onboarding_prompts_sent_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure' }); } catch {} try { Metrics.inc('onboarding_prompts_sent_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure' }); } catch { }
} }
} }
} }
@ -1332,12 +1332,12 @@ export class CommandService {
// Gating de grupos en modo 'enforce' (cuando CommandService se invoca directamente) // Gating de grupos en modo 'enforce' (cuando CommandService se invoca directamente)
if (isGroupId(context.groupId)) { if (isGroupId(context.groupId)) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {} try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch { }
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') { if (mode === 'enforce') {
try { try {
if (!AllowedGroups.isAllowed(context.groupId)) { if (!AllowedGroups.isAllowed(context.groupId)) {
try { Metrics.inc('commands_blocked_total'); } catch {} try { Metrics.inc('commands_blocked_total'); } catch { }
return { responses: [], ok: true }; return { responses: [], ok: true };
} }
} catch { } catch {

@ -1,14 +1,14 @@
export const ICONS = { export const ICONS = {
create: '📝', create: '📝',
complete: '✅', complete: '✅',
assignNotice: '📬', assignNotice: '📬',
reminder: '⏰', reminder: '⏰',
date: '📅', date: '📅',
unassigned: '🚫👤', unassigned: '🙅',
take: '✋', take: '✋',
unassign: '↩️', unassign: '↩️',
info: '', info: '',
warn: '⚠️', warn: '⚠️',
person: '👤', person: '👤',
people: '👥', people: '👥',
} as const; } as const;

Loading…
Cancel
Save