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

@ -1037,7 +1037,7 @@ export class CommandService {
VALUES (?, ?, ?, NULL)
`).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();
return [{
@ -1050,7 +1050,7 @@ export class CommandService {
const feature = String(process.env.FEATURE_HELP_V2 ?? 'true').toLowerCase();
const helpV2Enabled = !['false', '0', 'no'].includes(feature);
try { Metrics.inc('commands_unknown_total'); } catch {}
try { Metrics.inc('commands_unknown_total'); } catch { }
if (!helpV2Enabled) {
return [{
recipient: context.sender,
@ -1093,7 +1093,7 @@ export class CommandService {
try {
const gid = isGroupId(context.groupId) ? context.groupId : 'dm';
Metrics.inc('onboarding_assign_failures_total', 1, { group_id: String(gid), source, reason });
} catch {}
} catch { }
};
// 1) Menciones aportadas por el backend (JIDs crudos)
@ -1229,7 +1229,7 @@ export class CommandService {
`).run(taskId, groupIdToUse, context.messageId);
}
}
} catch {}
} catch { }
// Recuperar la tarea creada para obtener display_code asignado
const createdTask = TaskService.getTaskById(taskId);
@ -1291,16 +1291,16 @@ export class CommandService {
const enabled = isTest
? String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true'
: (() => {
const v = process.env.ONBOARDING_PROMPTS_ENABLED;
return v == null ? true : ['true','1','yes'].includes(String(v).toLowerCase());
})();
const v = process.env.ONBOARDING_PROMPTS_ENABLED;
return v == null ? true : ['true', '1', 'yes'].includes(String(v).toLowerCase());
})();
const groupLabel = isGroupId(context.groupId) ? String(context.groupId) : 'dm';
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 {
const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
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 {
const list = unresolvedList.join(', ');
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}`;
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)
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();
if (mode === 'enforce') {
try {
if (!AllowedGroups.isAllowed(context.groupId)) {
try { Metrics.inc('commands_blocked_total'); } catch {}
try { Metrics.inc('commands_blocked_total'); } catch { }
return { responses: [], ok: true };
}
} catch {

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

Loading…
Cancel
Save