You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

603 lines
16 KiB
Svelte

<script lang="ts">
import {
compareYmd,
todayYmdUTC,
ymdToDmy,
isToday,
isTomorrow,
} from "$lib/utils/date";
import { success, error as toastError } from "$lib/stores/toasts";
import { tick, createEventDispatcher } from "svelte";
import { fade } from "svelte/transition";
import { normalizeDigits } from "$lib/utils/phone";
import { colorForGroup } from "$lib/utils/groupColor";
import DueOkIcon from "$lib/ui/icons/DueOkIcon.svelte";
import DueSoonIcon from "$lib/ui/icons/DueSoonIcon.svelte";
import DueOverdueIcon from "$lib/ui/icons/DueOverdueIcon.svelte";
import AssigneesIcon from "$lib/ui/icons/AssigneesIcon.svelte";
import ClaimIcon from "$lib/ui/icons/ClaimIcon.svelte";
import UnassignIcon from "$lib/ui/icons/UnassignIcon.svelte";
import EditIcon from "$lib/ui/icons/EditIcon.svelte";
import CalendarEditIcon from "$lib/ui/icons/CalendarEditIcon.svelte";
import CheckCircleSuccessIcon from "$lib/ui/icons/CheckCircleSuccessIcon.svelte";
import TaskDueBadge from "$lib/ui/data/task/TaskDueBadge.svelte";
import TaskAssignees from "$lib/ui/data/task/TaskAssignees.svelte";
export let id: number;
export let description: string;
export let due_date: string | null = null;
export let display_code: number | null = null;
export let assignees: string[] = [];
export let currentUserId: string | null | undefined = null;
export let completed: boolean = false;
export let completed_at: string | null = null;
export let groupName: string | null = null;
export let groupId: string | null = null;
const dispatch = createEventDispatcher<{ changed: { id: number; action: string; patch: any } }>();
const code = display_code ?? id;
const codeStr = String(code).padStart(4, "0");
$: isAssigned =
!!currentUserId &&
assignees.some(
(a) => normalizeDigits(a) === normalizeDigits(currentUserId),
);
$: today = todayYmdUTC();
$: overdue = !!due_date && compareYmd(due_date, today) < 0;
$: imminent = !!due_date && (isToday(due_date) || isTomorrow(due_date));
$: dateDmy = due_date ? ymdToDmy(due_date) : "";
$: groupLabel = groupName != null ? groupName : "Personal";
$: gc = groupId ? colorForGroup(groupId) : null;
let editing = false;
let dateValue: string = due_date ?? "";
let busy = false;
// Popover de responsables
$: assigneesCount = Array.isArray(assignees) ? assignees.length : 0;
$: canUnassign = !(groupId == null && assigneesCount === 1 && isAssigned);
// Edición de texto (inline)
let editingText = false;
let descEl: HTMLElement | null = null;
async function doClaim() {
if (busy) return;
busy = true;
try {
const res = await fetch(`/api/tasks/${id}/claim`, { method: "POST" });
if (res.ok) {
// Actualizar estado local (añadirte si no estabas)
if (currentUserId) {
const set = new Set<string>(assignees || []);
set.add(String(currentUserId));
assignees = Array.from(set);
}
success("Tarea reclamada");
dispatch("changed", { id, action: "claim", patch: { assignees } });
} else {
const txt = await res.text();
toastError(txt || "No se pudo reclamar");
}
} catch (e) {
toastError("Error de red");
} finally {
busy = false;
}
}
function toIsoSqlLocal(d: Date = new Date()): string {
const iso = d.toISOString();
return iso.substring(0, 23).replace('T', ' ');
}
async function doComplete() {
if (busy || completed) return;
busy = true;
try {
const hadNoAssignees = assigneesCount === 0;
const res = await fetch(`/api/tasks/${id}/complete`, { method: "POST" });
if (res.ok) {
const data = await res.json().catch(() => null);
const newCompletedAt: string | null = data?.task?.completed_at ? String(data.task.completed_at) : toIsoSqlLocal(new Date());
// Si no tenía responsables, el backend te auto-asigna: reflejarlo localmente
if (hadNoAssignees && currentUserId) {
const set = new Set<string>(assignees || []);
set.add(String(currentUserId));
assignees = Array.from(set);
}
completed = true;
completed_at = newCompletedAt;
success(hadNoAssignees ? "Te has asignado y completado la tarea" : "Tarea completada");
dispatch("changed", { id, action: "complete", patch: { completed: true, completed_at, assignees } });
} else {
const txt = await res.text();
toastError(txt || "No se pudo completar la tarea");
}
} catch {
toastError("Error de red");
} finally {
busy = false;
}
}
async function doUncomplete() {
if (busy || !completed) return;
busy = true;
try {
const res = await fetch(`/api/tasks/${id}/uncomplete`, { method: "POST" });
if (res.ok) {
await res.json().catch(() => null);
completed = false;
completed_at = null;
success("Tarea reabierta");
dispatch("changed", { id, action: "uncomplete", patch: { completed: false, completed_at: null } });
} else {
const txt = await res.text();
toastError(txt || "No se pudo deshacer completar");
}
} catch {
toastError("Error de red");
} finally {
busy = false;
}
}
async function doUnassign() {
if (busy) return;
busy = true;
try {
const res = await fetch(`/api/tasks/${id}/unassign`, { method: "POST" });
if (res.ok) {
if (currentUserId) {
const after = (assignees || []).filter((a) => normalizeDigits(a) !== normalizeDigits(String(currentUserId)));
assignees = after;
}
success("Asignación eliminada");
dispatch("changed", { id, action: "unassign", patch: { assignees } });
} else {
const txt = await res.text();
toastError(txt || "No se pudo soltar");
}
} catch {
toastError("Error de red");
} finally {
busy = false;
}
}
async function saveDate() {
if (busy) return;
busy = true;
try {
const body = {
due_date: dateValue && dateValue.trim() !== "" ? dateValue.trim() : null,
};
const res = await fetch(`/api/tasks/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
due_date = body.due_date;
success("Fecha actualizada");
dispatch("changed", { id, action: "update_due", patch: { due_date } });
editing = false;
} else {
const txt = await res.text();
toastError(txt || "No se pudo actualizar la fecha");
}
} catch {
toastError("Error de red");
} finally {
busy = false;
}
}
function toggleEdit() {
editing = !editing;
if (editing) editingText = false;
dateValue = due_date ?? "";
}
function clearDate() {
if (busy) return;
if (!confirm("¿Quitar la fecha de vencimiento?")) return;
dateValue = "";
saveDate();
}
function toggleEditText() {
editingText = !editingText;
if (editingText) {
editing = false;
// Asegurar que el elemento refleja el texto actual y enfocarlo
if (descEl) {
descEl.textContent = description;
}
tick().then(() => {
if (descEl) {
descEl.focus();
placeCaretAtEnd(descEl);
}
});
}
}
function placeCaretAtEnd(el: HTMLElement) {
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
async function saveText() {
if (busy) return;
const raw = (descEl?.textContent || "").replace(/\s+/g, " ").trim();
if (raw.length < 1 || raw.length > 1000) {
toastError("La descripción debe tener entre 1 y 1000 caracteres.");
return;
}
if (raw === description) {
editingText = false;
return;
}
busy = true;
try {
const res = await fetch(`/api/tasks/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ description: raw }),
});
if (res.ok) {
description = raw;
success("Descripción actualizada");
dispatch("changed", { id, action: "update_desc", patch: { description } });
editingText = false;
} else {
const txt = await res.text();
toastError(txt || "No se pudo actualizar la descripción");
}
} catch {
toastError("Error de red");
} finally {
busy = false;
}
}
function cancelText() {
if (descEl) {
descEl.textContent = description;
}
editingText = false;
}
</script>
<li class="task" class:completed in:fade={{ duration: 180 }} out:fade={{ duration: 180 }}>
<div class="code">{codeStr}</div>
<div
tabindex="0"
class="desc"
class:editing={editingText}
class:completed
contenteditable={editingText && !completed}
role="textbox"
aria-label="Descripción de la tarea"
spellcheck="true"
bind:this={descEl}
on:keydown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
cancelText();
} else if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
saveText();
} else if (e.key === "Enter") {
e.preventDefault();
}
}}
>
{description}
</div>
<div class="meta">
<span
class="group-badge"
title="Grupo"
style={gc
? `--gc-border: ${gc.border}; --gc-bg: ${gc.bg}; --gc-text: ${gc.text};`
: undefined}>{groupLabel}</span
>
{#if due_date}
<TaskDueBadge {due_date} />
{/if}
</div>
<div class="complete">
{#if completed}
<button
class="btn primary primary-action"
aria-label="Deshacer completar"
title="Deshacer completar"
on:click|preventDefault={doUncomplete}
disabled={busy}
>
↩️ Deshacer
</button>
{:else}
<button
class="btn primary primary-action"
aria-label="Completar"
title="Completar"
on:click|preventDefault={doComplete}
disabled={busy}
><CheckCircleSuccessIcon />
Completar
</button>
{/if}
</div>
<div class="assignees-container">
<TaskAssignees {id} {assignees} {currentUserId} />
</div>
<div class="actions">
{#if !completed}
{#if !isAssigned}
<button
class="icon-btn secondary-action"
aria-label="Reclamar"
on:click|preventDefault={doClaim}
disabled={busy}
><ClaimIcon />
Reclamar</button
>
{:else}
<button
class="icon-btn secondary-action"
aria-label="Soltar"
title={canUnassign ? "Soltar" : "No puedes soltar una tarea personal. Márcala como completada para eliminarla"}
on:click|preventDefault={doUnassign}
disabled={busy || !canUnassign}
><UnassignIcon />
Soltar</button
>
{/if}
{#if !editingText}
<button
class="icon-btn secondary-action"
aria-label="Editar texto"
title="Editar texto"
on:click|preventDefault={toggleEditText}
disabled={busy}
><EditIcon />
Editar</button
>
{:else}
<button
class="btn primary secondary-action"
on:click|preventDefault={saveText}
disabled={busy}>Guardar</button
>
<button
class="btn ghost secondary-action"
on:click|preventDefault={cancelText}
disabled={busy}>Cancelar</button
>
{/if}
{#if !editing}
<button
class="icon-btn secondary-action"
aria-label="Editar fecha"
title="Editar fecha"
on:click|preventDefault={toggleEdit}
disabled={busy}
><CalendarEditIcon />
Fecha</button
>
{:else}
<input class="date" type="date" bind:value={dateValue} />
<button
class="btn primary secondary-action"
on:click|preventDefault={saveDate}
disabled={busy}>Guardar</button
>
<button
class="btn danger secondary-action"
on:click|preventDefault={clearDate}
disabled={busy}>Quitar</button
>
<button
class="btn ghost secondary-action"
on:click|preventDefault={toggleEdit}
disabled={busy}>Cancelar</button
>
{/if}
{/if}
</div>
</li>
<style>
.task {
display: grid;
grid-template-columns: 1fr max-content;
grid-template-rows: min-content max-content max-content max-content;
grid-gap: 2px;
padding: 4px 0 8px 0;
border-bottom: 2px dashed var(--color-border);
position: relative;
margin: 0 0 4px 0;
}
.task:last-child {
border-bottom: 0;
}
.code {
font-weight: 300;
font-size: 0.8rem;
color: var(--color-text-muted);
font-family: monospace;
letter-spacing: 0.5px;
grid-row: 1/2;
grid-column: 1/2;
align-self: center;
}
.completed {
opacity: 0.5;
}
.desc {
padding: 8px 4px;
grid-column: 1/3;
grid-row: 2/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: 1/3;
grid-row: 2/3;
margin: 16px 0;
}
.desc.completed {
text-decoration: line-through;
}
.meta {
justify-self: end;
align-items: start;
grid-row: 1/2;
grid-column: 2/3;
display: inline-flex;
gap: 6px;
align-items: center;
}
.muted {
color: var(--color-text-muted);
}
.group-badge {
padding: 2px 6px;
border-radius: 6px;
border: 1px solid var(--gc-border, var(--color-border));
background: var(--gc-bg, transparent);
color: var(--gc-text, inherit);
font-size: 12px;
}
.assignees-container {
grid-row: 4/5;
grid-column: 1/2;
}
.task.completed {
opacity: 0.7;
}
.complete {
grid-row: 3/4;
grid-column: 2/3;
justify-self: end;
}
.actions {
justify-self: stretch;
grid-column: 2/3;
grid-row: 4/5;
margin: 2px 0 4px 0;
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
justify-content: flex-end;
}
.btn {
padding: 4px 8px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
border-radius: 6px;
font-size: 13px;
cursor: pointer;
font-family: monospace;
box-shadow: 0 0 8px 4px var(--color-border);
margin-bottom: 4px;
}
.btn[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
.btn.primary {
border-color: var(--color-primary);
background: var(--color-primary-muted);
color: var(--color-text);
}
:global(.task .btn.primary svg) {
margin-right: 8px;
}
.btn.ghost {
background: transparent;
}
.btn.danger {
background: var(--color-danger);
color: #fff;
border-color: transparent;
}
.icon-btn {
border: 1px solid var(--color-surface);
border-radius: 6px;
background: var(--color-surface);
font-size: 12px;
line-height: 1;
font-family: monospace;
box-shadow: 0 0 8px 4px var(--color-border);
}
:global(.task .icon-btn svg) {
margin-right: 8px;
}
.date {
padding: 4px 6px;
font-size: 14px;
}
@media (max-width: 768px) {
.actions {
justify-self: stretch;
}
.actions .secondary-action {
flex: 0 0 auto;
}
/* Botón de completar a ancho completo en mobile */
.complete {
grid-column: 1/3;
justify-self: stretch;
}
.complete .btn {
width: 100%;
}
}
@media (max-width: 480px) {
.task {
grid-template-columns: 1fr;
}
.meta {
justify-self: end;
}
/* En 1 columna, colocamos acciones en una fila propia bajo el badge */
.actions {
grid-row: 4/5;
grid-column: 1/3;
justify-self: flex-end;
}
.icon-btn {
padding: 2px 8px;
}
}
/* Badge de responsables */
.icon-btn-svg {
fill-rule: evenodd;
clip-rule: evenodd;
fill: var(--color-text);
}
</style>