feat: añadir badge de responsables y popover con enlaces wa.me

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
borja 2 weeks ago
parent 4039390ab0
commit f4632859e0

@ -8,6 +8,8 @@
} from "$lib/utils/date";
import { success, error as toastError } from "$lib/stores/toasts";
import { tick } from "svelte";
import Popover from "$lib/ui/feedback/Popover.svelte";
import { normalizeDigits, buildWaMeUrl } from "$lib/utils/phone";
export let id: number;
export let description: string;
@ -32,6 +34,15 @@
let dateValue: string = due_date ?? "";
let busy = false;
// Popover de responsables
let showAssignees = false;
let assigneesButtonEl: HTMLButtonElement | null = null;
$: assigneesCount = Array.isArray(assignees) ? assignees.length : 0;
$: assigneesLabel =
assigneesCount === 0
? "Sin responsable"
: `Responsables: ${assigneesCount}${isAssigned ? " (tú)" : ""}`;
// Edición de texto (inline)
let editingText = false;
let descEl: HTMLElement | null = null;
@ -270,13 +281,20 @@
</span>
{/if}
</div>
{#if assignees?.length}
<div class="assignees">
{#each assignees as a}
<span class="assignee" title={a}>👤</span>
{/each}
</div>
{/if}
<div class="assignees">
<button
class="assignees-badge"
type="button"
aria-haspopup="dialog"
aria-expanded={showAssignees}
aria-controls={"assignees-popover-" + id}
title={assigneesCount === 0 ? "Sin responsable" : "Ver responsables"}
on:click|preventDefault={() => (showAssignees = true)}
bind:this={assigneesButtonEl}
>
👥 {assigneesLabel}
</button>
</div>
<div class="actions">
{#if !completed}
{#if !isAssigned}
@ -356,6 +374,28 @@
{/if}
{/if}
</div>
<Popover bind:open={showAssignees} ariaLabel="Responsables" id={"assignees-popover-" + id} on:closed={() => { tick().then(() => assigneesButtonEl?.focus()); }}>
<h3 class="popover-title">Responsables</h3>
{#if assigneesCount === 0}
<p class="muted">No hay responsables asignados.</p>
{:else}
<ul class="assignees-list">
{#each assignees as a}
<li>
<a href={buildWaMeUrl(normalizeDigits(a))} target="_blank" rel="noreferrer nofollow">
{normalizeDigits(a)}
</a>
{#if currentUserId && normalizeDigits(a) === normalizeDigits(currentUserId)}
<span class="you-pill"></span>
{/if}
</li>
{/each}
</ul>
{/if}
<div class="popover-actions">
<button class="btn ghost" on:click={() => (showAssignees = false)}>Cerrar</button>
</div>
</Popover>
</li>
<style>
@ -524,9 +564,6 @@
.meta {
justify-self: end;
}
.assignees {
display: none;
}
.actions {
grid-row: 3/4;
grid-column: 1/3;
@ -536,4 +573,57 @@
padding: 2px;
}
}
/* Badge de responsables */
.assignees-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-surface);
font-size: 12px;
cursor: pointer;
}
.assignees-badge[aria-expanded="true"] {
border-color: var(--color-primary);
}
.assignees-list {
list-style: none;
margin: 8px 0;
padding: 0;
}
.assignees-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.assignees-list a {
color: var(--color-primary);
text-decoration: none;
}
.assignees-list a:hover,
.assignees-list a:focus-visible {
text-decoration: underline;
}
.popover-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.popover-title {
margin: 0 0 4px 0;
font-size: 0.95rem;
}
.you-pill {
margin-left: 6px;
padding: 1px 6px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.12);
color: var(--color-primary);
font-size: 11px;
}
</style>

@ -0,0 +1,111 @@
<script lang="ts">
import { tick, onDestroy } from 'svelte';
import { createEventDispatcher } from 'svelte';
export let open: boolean = false;
export let ariaLabel: string = 'Diálogo';
export let id: string | undefined;
const dispatch = createEventDispatcher();
let panelEl: HTMLElement | null = null;
let lastActive: Element | null = null;
function close() {
open = false;
dispatch('closed');
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
close();
} else if (e.key === 'Tab' && panelEl) {
const focusables = Array.from(
panelEl.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
)
).filter((el) => el.offsetParent !== null);
if (focusables.length === 0) {
e.preventDefault();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey) {
if (active === first || !panelEl.contains(active)) {
e.preventDefault();
last.focus();
}
} else {
if (active === last) {
e.preventDefault();
first.focus();
}
}
}
}
$: if (open) {
lastActive = document.activeElement;
tick().then(() => {
panelEl?.focus();
document.body.style.overflow = 'hidden';
});
} else {
document.body.style.overflow = '';
if (lastActive instanceof HTMLElement) {
tick().then(() => lastActive?.focus());
}
}
onDestroy(() => {
document.body.style.overflow = '';
});
</script>
{#if open}
<div class="popover-overlay" on:click={close} />
<div
class="popover-panel"
role="dialog"
aria-modal="true"
{id}
aria-label={ariaLabel}
tabindex="-1"
bind:this={panelEl}
on:keydown={handleKeydown}
>
<slot />
</div>
{/if}
<style>
.popover-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 1000;
}
.popover-panel {
position: fixed;
z-index: 1001;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: min(420px, 92vw);
width: 92vw;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 10px;
box-shadow: var(--shadow-lg);
padding: 12px;
outline: none;
}
@media (prefers-color-scheme: dark) {
.popover-overlay {
background: rgba(0, 0, 0, 0.5);
}
}
</style>

@ -0,0 +1,8 @@
export function normalizeDigits(input: string | null | undefined): string {
return String(input ?? '').replace(/\D+/g, '');
}
export function buildWaMeUrl(input: string): string {
const digits = normalizeDigits(input);
return `https://wa.me/${digits}`;
}
Loading…
Cancel
Save