algunos cambios de estilos

webui
brobert 2 weeks ago
parent 6dc6c79803
commit fe6e08d9df

@ -1,8 +1,14 @@
<script lang="ts"> <script lang="ts">
import Badge from '$lib/ui/atoms/Badge.svelte'; import Badge from "$lib/ui/atoms/Badge.svelte";
import { compareYmd, todayYmdUTC, ymdToDmy, isToday, isTomorrow } from '$lib/utils/date'; import {
import { success, error as toastError } from '$lib/stores/toasts'; compareYmd,
import { tick } from 'svelte'; todayYmdUTC,
ymdToDmy,
isToday,
isTomorrow,
} from "$lib/utils/date";
import { success, error as toastError } from "$lib/stores/toasts";
import { tick } from "svelte";
export let id: number; export let id: number;
export let description: string; export let description: string;
@ -14,15 +20,15 @@
export let completed_at: string | null = null; export let completed_at: string | null = null;
const code = display_code ?? id; const code = display_code ?? id;
const codeStr = String(code).padStart(4, '0'); const codeStr = String(code).padStart(4, "0");
$: isAssigned = currentUserId ? assignees.includes(currentUserId) : false; $: isAssigned = currentUserId ? assignees.includes(currentUserId) : false;
$: today = todayYmdUTC(); $: today = todayYmdUTC();
$: overdue = !!due_date && compareYmd(due_date, today) < 0; $: overdue = !!due_date && compareYmd(due_date, today) < 0;
$: imminent = !!due_date && (isToday(due_date) || isTomorrow(due_date)); $: imminent = !!due_date && (isToday(due_date) || isTomorrow(due_date));
$: dateDmy = due_date ? ymdToDmy(due_date) : ''; $: dateDmy = due_date ? ymdToDmy(due_date) : "";
let editing = false; let editing = false;
let dateValue: string = due_date ?? ''; let dateValue: string = due_date ?? "";
let busy = false; let busy = false;
// Edición de texto (inline) // Edición de texto (inline)
@ -33,16 +39,16 @@
if (busy) return; if (busy) return;
busy = true; busy = true;
try { try {
const res = await fetch(`/api/tasks/${id}/claim`, { method: 'POST' }); const res = await fetch(`/api/tasks/${id}/claim`, { method: "POST" });
if (res.ok) { if (res.ok) {
success('Tarea reclamada'); success("Tarea reclamada");
location.reload(); location.reload();
} else { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || 'No se pudo reclamar'); toastError(txt || "No se pudo reclamar");
} }
} catch (e) { } catch (e) {
toastError('Error de red'); toastError("Error de red");
} finally { } finally {
busy = false; busy = false;
} }
@ -52,16 +58,16 @@
if (busy || completed) return; if (busy || completed) return;
busy = true; busy = true;
try { try {
const res = await fetch(`/api/tasks/${id}/complete`, { method: 'POST' }); const res = await fetch(`/api/tasks/${id}/complete`, { method: "POST" });
if (res.ok) { if (res.ok) {
success('Tarea completada'); success("Tarea completada");
location.reload(); location.reload();
} else { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || 'No se pudo completar la tarea'); toastError(txt || "No se pudo completar la tarea");
} }
} catch { } catch {
toastError('Error de red'); toastError("Error de red");
} finally { } finally {
busy = false; busy = false;
} }
@ -71,16 +77,16 @@
if (busy) return; if (busy) return;
busy = true; busy = true;
try { try {
const res = await fetch(`/api/tasks/${id}/unassign`, { method: 'POST' }); const res = await fetch(`/api/tasks/${id}/unassign`, { method: "POST" });
if (res.ok) { if (res.ok) {
success('Asignación eliminada'); success("Asignación eliminada");
location.reload(); location.reload();
} else { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || 'No se pudo soltar'); toastError(txt || "No se pudo soltar");
} }
} catch { } catch {
toastError('Error de red'); toastError("Error de red");
} finally { } finally {
busy = false; busy = false;
} }
@ -90,21 +96,24 @@
if (busy) return; if (busy) return;
busy = true; busy = true;
try { try {
const body = { due_date: dateValue && dateValue.trim() !== '' ? dateValue.trim() : null }; const body = {
due_date:
dateValue && dateValue.trim() !== "" ? dateValue.trim() : null,
};
const res = await fetch(`/api/tasks/${id}`, { const res = await fetch(`/api/tasks/${id}`, {
method: 'PATCH', method: "PATCH",
headers: { 'content-type': 'application/json' }, headers: { "content-type": "application/json" },
body: JSON.stringify(body) body: JSON.stringify(body),
}); });
if (res.ok) { if (res.ok) {
success('Fecha actualizada'); success("Fecha actualizada");
location.reload(); location.reload();
} else { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || 'No se pudo actualizar la fecha'); toastError(txt || "No se pudo actualizar la fecha");
} }
} catch { } catch {
toastError('Error de red'); toastError("Error de red");
} finally { } finally {
busy = false; busy = false;
} }
@ -113,13 +122,13 @@
function toggleEdit() { function toggleEdit() {
editing = !editing; editing = !editing;
if (editing) editingText = false; if (editing) editingText = false;
dateValue = due_date ?? ''; dateValue = due_date ?? "";
} }
function clearDate() { function clearDate() {
if (busy) return; if (busy) return;
if (!confirm('¿Quitar la fecha de vencimiento?')) return; if (!confirm("¿Quitar la fecha de vencimiento?")) return;
dateValue = ''; dateValue = "";
saveDate(); saveDate();
} }
@ -151,9 +160,9 @@
async function saveText() { async function saveText() {
if (busy) return; if (busy) return;
const raw = (descEl?.textContent || '').replace(/\s+/g, ' ').trim(); const raw = (descEl?.textContent || "").replace(/\s+/g, " ").trim();
if (raw.length < 1 || raw.length > 1000) { if (raw.length < 1 || raw.length > 1000) {
toastError('La descripción debe tener entre 1 y 1000 caracteres.'); toastError("La descripción debe tener entre 1 y 1000 caracteres.");
return; return;
} }
if (raw === description) { if (raw === description) {
@ -163,20 +172,20 @@
busy = true; busy = true;
try { try {
const res = await fetch(`/api/tasks/${id}`, { const res = await fetch(`/api/tasks/${id}`, {
method: 'PATCH', method: "PATCH",
headers: { 'content-type': 'application/json' }, headers: { "content-type": "application/json" },
body: JSON.stringify({ description: raw }) body: JSON.stringify({ description: raw }),
}); });
if (res.ok) { if (res.ok) {
description = raw; description = raw;
success('Descripción actualizada'); success("Descripción actualizada");
editingText = false; editingText = false;
} else { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || 'No se pudo actualizar la descripción'); toastError(txt || "No se pudo actualizar la descripción");
} }
} catch { } catch {
toastError('Error de red'); toastError("Error de red");
} finally { } finally {
busy = false; busy = false;
} }
@ -190,65 +199,125 @@
} }
</script> </script>
<li class="task" class:completed={completed}> <li class="task" class:completed>
<div class="left"> <div class="left">
<span class="code">#{codeStr}</span> <span class="code">#{codeStr}</span>
<span <span
class="desc" class="desc"
class:editing={editingText} class:editing={editingText}
class:completed={completed} class:completed
contenteditable={editingText && !completed} contenteditable={editingText && !completed}
role="textbox" role="textbox"
aria-label="Descripción de la tarea" aria-label="Descripción de la tarea"
spellcheck="true" spellcheck="true"
bind:this={descEl} bind:this={descEl}
on:keydown={(e) => { on:keydown={(e) => {
if (e.key === 'Escape') { e.preventDefault(); cancelText(); } if (e.key === "Escape") {
else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); saveText(); } e.preventDefault();
else if (e.key === 'Enter') { e.preventDefault(); } cancelText();
}} } else if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
>{description}</span> e.preventDefault();
saveText();
} else if (e.key === "Enter") {
e.preventDefault();
}
}}>{description}</span
>
</div> </div>
<div class="meta"> <div class="meta">
{#if due_date} {#if due_date}
<span class="date-badge" class:overdue={overdue} class:soon={imminent} title={overdue ? 'Vencida' : (imminent ? 'Próxima' : 'Fecha')}> <span
📅 {dateDmy}{#if overdue}{/if} class="date-badge"
class:overdue
class:soon={imminent}
title={overdue ? "Vencida" : imminent ? "Próxima" : "Fecha"}
>
📅 {dateDmy}{#if overdue}
{/if}
</span> </span>
{/if} {/if}
</div>
{#if assignees?.length} {#if assignees?.length}
<span class="assignees"> <div class="assignees">
{#each assignees as a} {#each assignees as a}
<span class="assignee" title={a}>👤</span> <span class="assignee" title={a}>👤</span>
{/each} {/each}
</span>
{/if}
</div> </div>
{/if}
<div class="actions"> <div class="actions">
{#if !completed} {#if !completed}
{#if !isAssigned} {#if !isAssigned}
<button class="icon-btn" aria-label="Reclamar" on:click|preventDefault={doClaim} disabled={busy}>Reclamar</button> <button
class="icon-btn"
aria-label="Reclamar"
on:click|preventDefault={doClaim}
disabled={busy}>Reclamar</button
>
{:else} {:else}
<button class="icon-btn" aria-label="Soltar" title="Soltar" on:click|preventDefault={doUnassign} disabled={busy}>🫳</button> <button
class="icon-btn"
aria-label="Soltar"
title="Soltar"
on:click|preventDefault={doUnassign}
disabled={busy}>🫳</button
>
{/if} {/if}
<button class="icon-btn" aria-label="Completar" title="Completar" on:click|preventDefault={doComplete} disabled={busy}></button> <button
class="icon-btn"
aria-label="Completar"
title="Completar"
on:click|preventDefault={doComplete}
disabled={busy}>✅</button
>
{#if !editingText} {#if !editingText}
<button class="icon-btn" aria-label="Editar texto" title="Editar texto" on:click|preventDefault={toggleEditText} disabled={busy}>✍️</button> <button
class="icon-btn"
aria-label="Editar texto"
title="Editar texto"
on:click|preventDefault={toggleEditText}
disabled={busy}>✍️</button
>
{:else} {:else}
<button class="btn primary" on:click|preventDefault={saveText} disabled={busy}>Guardar</button> <button
<button class="btn ghost" on:click|preventDefault={cancelText} disabled={busy}>Cancelar</button> class="btn primary"
on:click|preventDefault={saveText}
disabled={busy}>Guardar</button
>
<button
class="btn ghost"
on:click|preventDefault={cancelText}
disabled={busy}>Cancelar</button
>
{/if} {/if}
{#if !editing} {#if !editing}
<button class="icon-btn" aria-label="Editar fecha" title="Editar fecha" on:click|preventDefault={toggleEdit} disabled={busy}>🗓️</button> <button
class="icon-btn"
aria-label="Editar fecha"
title="Editar fecha"
on:click|preventDefault={toggleEdit}
disabled={busy}>🗓️</button
>
{:else} {:else}
<input class="date" type="date" bind:value={dateValue} /> <input class="date" type="date" bind:value={dateValue} />
<button class="btn primary" on:click|preventDefault={saveDate} disabled={busy}>Guardar</button> <button
<button class="btn danger" on:click|preventDefault={clearDate} disabled={busy}>Quitar</button> class="btn primary"
<button class="btn ghost" on:click|preventDefault={toggleEdit} disabled={busy}>Cancelar</button> on:click|preventDefault={saveDate}
disabled={busy}>Guardar</button
>
<button
class="btn danger"
on:click|preventDefault={clearDate}
disabled={busy}>Quitar</button
>
<button
class="btn ghost"
on:click|preventDefault={toggleEdit}
disabled={busy}>Cancelar</button
>
{/if} {/if}
{/if} {/if}
</div> </div>
@ -257,13 +326,17 @@
<style> <style>
.task { .task {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 2fr 1fr;
align-items: center; grid-template-rows: max-content max-content;
gap: var(--space-2); grid-gap: 2px;
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
position: relative;
margin: 0 0 4px 0;
}
.task:last-child {
border-bottom: 0;
} }
.task:last-child { border-bottom: 0; }
.left { .left {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -273,21 +346,69 @@
} }
.code { .code {
font-weight: 600; font-weight: 600;
font-size: 0.8rem;
color: var(--color-text-muted); color: var(--color-text-muted);
font-family: monospace; font-family: monospace;
letter-spacing: 0.5px; letter-spacing: 0.5px;
position: absolute;
top: 0px;
left: 2px;
}
.desc {
padding: 8px;
grid-column: 1/2;
grid-row: 1/3;
}
.desc.editing {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
background: var(--color-surface);
padding: 2px 4px;
border-radius: 4px;
white-space: normal;
text-overflow: clip;
grid-column: 2/3;
grid-row: 2/3;
}
.desc.completed {
text-decoration: line-through;
}
.meta {
justify-self: end;
align-items: start;
gap: 8px;
}
.muted {
color: var(--color-text-muted);
}
.date-badge {
padding: 2px 6px;
border-radius: 6px;
border: 1px solid transparent;
font-size: 12px;
}
.date-badge.overdue {
border-color: var(--color-danger);
}
.date-badge.soon {
border-color: var(--color-warning);
}
.assignees {
grid-column: 1/2;
text-align: left;
align-self: end;
}
.assignee {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
}
.task.completed {
opacity: 0.7;
} }
.desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.desc.editing { outline: 2px solid var(--color-primary); outline-offset: 2px; background: var(--color-surface); padding: 2px 4px; border-radius: 4px; white-space: normal; text-overflow: clip; }
.desc.completed { text-decoration: line-through; }
.meta { justify-self: end; display: inline-flex; align-items: center; gap: 8px; }
.muted { color: var(--color-text-muted); }
.date-badge { padding: 2px 6px; border-radius: 6px; border: 1px solid transparent; font-size: 12px; }
.date-badge.overdue { border-color: var(--color-danger); }
.date-badge.soon { border-color: var(--color-warning); }
.assignees { display: inline-flex; gap: 4px; }
.assignee { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; }
.task.completed { opacity: .7; }
.actions { .actions {
display: inline-flex; display: inline-flex;
@ -295,6 +416,8 @@
justify-self: end; justify-self: end;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
grid-column: 2/3;
grid-row: 2/3;
} }
.btn { .btn {
padding: 4px 8px; padding: 4px 8px;
@ -305,11 +428,35 @@
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
} }
.btn[disabled] { opacity: .6; cursor: not-allowed; } .btn[disabled] {
.btn.primary { border-color: transparent; background: var(--color-primary); color: #fff; } opacity: 0.6;
.btn.secondary { } cursor: not-allowed;
.btn.ghost { background: transparent; } }
.btn.danger { background: var(--color-danger); color: #fff; border-color: transparent; } .btn.primary {
.icon-btn { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: 6px; background: var(--color-surface); font-size: 16px; line-height: 1; } border-color: transparent;
.date { padding: 4px 6px; font-size: 14px; } background: var(--color-primary);
color: #fff;
}
.btn.secondary {
}
.btn.ghost {
background: transparent;
}
.btn.danger {
background: var(--color-danger);
color: #fff;
border-color: transparent;
}
.icon-btn {
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
font-size: 16px;
line-height: 1;
}
.date {
padding: 4px 6px;
font-size: 14px;
}
</style> </style>

@ -8,6 +8,7 @@
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
padding: var(--space-4); padding: var(--space-1);
position: relative;
} }
</style> </style>

Loading…
Cancel
Save