|
|
|
|
@ -22,7 +22,9 @@
|
|
|
|
|
export let groupName: string | null = null;
|
|
|
|
|
export let groupId: string | null = null;
|
|
|
|
|
|
|
|
|
|
const dispatch = createEventDispatcher<{ changed: { id: number; action: string; patch: any } }>();
|
|
|
|
|
const dispatch = createEventDispatcher<{
|
|
|
|
|
changed: { id: number; action: string; patch: any };
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
const code = display_code ?? id;
|
|
|
|
|
const codeStr = String(code).padStart(4, "0");
|
|
|
|
|
@ -74,7 +76,7 @@
|
|
|
|
|
|
|
|
|
|
function toIsoSqlLocal(d: Date = new Date()): string {
|
|
|
|
|
const iso = d.toISOString();
|
|
|
|
|
return iso.substring(0, 23).replace('T', ' ');
|
|
|
|
|
return iso.substring(0, 23).replace("T", " ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function doComplete() {
|
|
|
|
|
@ -85,7 +87,9 @@
|
|
|
|
|
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());
|
|
|
|
|
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 || []);
|
|
|
|
|
@ -94,8 +98,16 @@
|
|
|
|
|
}
|
|
|
|
|
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 } });
|
|
|
|
|
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");
|
|
|
|
|
@ -111,13 +123,19 @@
|
|
|
|
|
if (busy || !completed) return;
|
|
|
|
|
busy = true;
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/tasks/${id}/uncomplete`, { method: "POST" });
|
|
|
|
|
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 } });
|
|
|
|
|
dispatch("changed", {
|
|
|
|
|
id,
|
|
|
|
|
action: "uncomplete",
|
|
|
|
|
patch: { completed: false, completed_at: null },
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
const txt = await res.text();
|
|
|
|
|
toastError(txt || "No se pudo deshacer completar");
|
|
|
|
|
@ -136,7 +154,10 @@
|
|
|
|
|
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)));
|
|
|
|
|
const after = (assignees || []).filter(
|
|
|
|
|
(a) =>
|
|
|
|
|
normalizeDigits(a) !== normalizeDigits(String(currentUserId)),
|
|
|
|
|
);
|
|
|
|
|
assignees = after;
|
|
|
|
|
}
|
|
|
|
|
success("Asignación eliminada");
|
|
|
|
|
@ -198,10 +219,10 @@
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function saveText(text?: string) {
|
|
|
|
|
if (busy) return;
|
|
|
|
|
const rawSource = typeof text === "string" ? text : (taskText?.getCurrentText?.() ?? "");
|
|
|
|
|
const rawSource =
|
|
|
|
|
typeof text === "string" ? text : (taskText?.getCurrentText?.() ?? "");
|
|
|
|
|
const raw = String(rawSource).replace(/\s+/g, " ").trim();
|
|
|
|
|
if (raw.length < 1 || raw.length > 1000) {
|
|
|
|
|
toastError("La descripción debe tener entre 1 y 1000 caracteres.");
|
|
|
|
|
@ -221,7 +242,11 @@
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
description = raw;
|
|
|
|
|
success("Descripción actualizada");
|
|
|
|
|
dispatch("changed", { id, action: "update_desc", patch: { description } });
|
|
|
|
|
dispatch("changed", {
|
|
|
|
|
id,
|
|
|
|
|
action: "update_desc",
|
|
|
|
|
patch: { description },
|
|
|
|
|
});
|
|
|
|
|
editingText = false;
|
|
|
|
|
} else {
|
|
|
|
|
const txt = await res.text();
|
|
|
|
|
@ -239,16 +264,22 @@
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<li class="task" class:completed in:fade={{ duration: 180 }} out:fade={{ duration: 180 }}>
|
|
|
|
|
<li
|
|
|
|
|
class="task"
|
|
|
|
|
class:completed
|
|
|
|
|
in:fade={{ duration: 180 }}
|
|
|
|
|
out:fade={{ duration: 180 }}
|
|
|
|
|
>
|
|
|
|
|
<div class="code">{codeStr}</div>
|
|
|
|
|
<TaskText
|
|
|
|
|
description={description}
|
|
|
|
|
{description}
|
|
|
|
|
{completed}
|
|
|
|
|
editing={editingText}
|
|
|
|
|
{busy}
|
|
|
|
|
bind:this={taskText}
|
|
|
|
|
on:toggleEdit={toggleEditText}
|
|
|
|
|
on:saveText={(e) => saveText((e as CustomEvent<{ text: string }>).detail.text)}
|
|
|
|
|
on:saveText={(e) =>
|
|
|
|
|
saveText((e as CustomEvent<{ text: string }>).detail.text)}
|
|
|
|
|
on:cancelText={cancelText}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
@ -272,16 +303,17 @@
|
|
|
|
|
{canUnassign}
|
|
|
|
|
{busy}
|
|
|
|
|
{completed}
|
|
|
|
|
editingText={editingText}
|
|
|
|
|
{editingText}
|
|
|
|
|
editingDate={editing}
|
|
|
|
|
dateValue={dateValue}
|
|
|
|
|
{dateValue}
|
|
|
|
|
on:claim={doClaim}
|
|
|
|
|
on:unassign={doUnassign}
|
|
|
|
|
on:toggleEditText={toggleEditText}
|
|
|
|
|
on:saveText={saveText}
|
|
|
|
|
on:cancelText={cancelText}
|
|
|
|
|
on:toggleEditDate={toggleEdit}
|
|
|
|
|
on:saveDate={(e) => saveDate((e as CustomEvent<{ value: string | null }>).detail.value)}
|
|
|
|
|
on:saveDate={(e) =>
|
|
|
|
|
saveDate((e as CustomEvent<{ value: string | null }>).detail.value)}
|
|
|
|
|
on:clearDate={clearDate}
|
|
|
|
|
on:cancelDate={() => (editing = false)}
|
|
|
|
|
/>
|
|
|
|
|
@ -324,9 +356,6 @@
|
|
|
|
|
gap: 6px;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
.muted {
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
}
|
|
|
|
|
.assignees-container {
|
|
|
|
|
grid-row: 4/5;
|
|
|
|
|
grid-column: 1/2;
|
|
|
|
|
@ -350,69 +379,21 @@
|
|
|
|
|
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 {
|
|
|
|
|
@ -427,16 +408,7 @@
|
|
|
|
|
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>
|
|
|
|
|
|