feat: agregar edición de tareas (claim/unassign y PATCH due_date)

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

@ -1,15 +1,96 @@
<script lang="ts">
import Badge from '$lib/ui/atoms/Badge.svelte';
import { dueStatus } from '$lib/utils/date';
import { success, error as toastError } from '$lib/stores/toasts';
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;
const code = display_code ?? id;
$: status = dueStatus(due_date, 3);
$: isAssigned = currentUserId ? assignees.includes(currentUserId) : false;
let editing = false;
let dateValue: string = due_date ?? '';
let busy = false;
async function doClaim() {
if (busy) return;
busy = true;
try {
const res = await fetch(`/api/tasks/${id}/claim`, { method: 'POST' });
if (res.ok) {
success('Tarea reclamada');
location.reload();
} else {
const txt = await res.text();
toastError(txt || 'No se pudo reclamar');
}
} catch (e) {
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) {
success('Asignación eliminada');
location.reload();
} 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) {
success('Fecha actualizada');
location.reload();
} 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;
dateValue = due_date ?? '';
}
function clearDate() {
if (busy) return;
if (!confirm('¿Quitar la fecha de vencimiento?')) return;
dateValue = '';
saveDate();
}
</script>
<li class="task">
@ -26,18 +107,36 @@
{/if}
{/if}
</div>
{#if assignees?.length}
<div class="right">
<div class="meta">
{#if assignees?.length}
<small class="muted">asignados: {assignees.join(', ')}</small>
</div>
{/if}
{/if}
</div>
<div class="actions">
{#if !isAssigned}
<button class="btn" on:click|preventDefault={doClaim} disabled={busy}>Reclamar</button>
{:else}
<button class="btn" on:click|preventDefault={doUnassign} disabled={busy}>Soltar</button>
{/if}
{#if !editing}
<button class="btn secondary" on:click|preventDefault={toggleEdit} disabled={busy}>Editar fecha</button>
{:else}
<input class="date" type="date" bind:value={dateValue} />
<button class="btn primary" 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}
</div>
</li>
<style>
.task {
display: flex;
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: 8px 0;
border-bottom: 1px solid var(--color-border);
@ -48,12 +147,36 @@
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
min-width: 0;
}
.code {
font-weight: 600;
color: var(--color-text-muted);
}
.desc { }
.right { margin-left: auto; }
.desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.meta { justify-self: end; }
.muted { color: var(--color-text-muted); }
.actions {
display: inline-flex;
gap: 6px;
justify-self: end;
align-items: center;
flex-wrap: wrap;
}
.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;
}
.btn[disabled] { opacity: .6; cursor: not-allowed; }
.btn.primary { border-color: transparent; background: var(--color-primary); color: #fff; }
.btn.secondary { }
.btn.ghost { background: transparent; }
.btn.danger { background: var(--color-danger); color: #fff; border-color: transparent; }
.date { padding: 4px 6px; font-size: 14px; }
</style>

@ -0,0 +1,112 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
function isValidYmd(input: string): boolean {
const m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(input || '');
if (!m) return false;
const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
if (!Number.isFinite(y) || !Number.isFinite(mo) || !Number.isFinite(d)) return false;
if (mo < 1 || mo > 12 || d < 1 || d > 31) return false;
const dt = new Date(`${String(y).padStart(4, '0')}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}T00:00:00Z`);
// Comprobar que el Date resultante coincide (evita 2025-02-31)
return dt.getUTCFullYear() === y && (dt.getUTCMonth() + 1) === mo && dt.getUTCDate() === d;
}
export const PATCH: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const idStr = event.params.id || '';
const taskId = parseInt(idStr, 10);
if (!Number.isFinite(taskId) || taskId <= 0) {
return new Response('Bad Request', { status: 400 });
}
let payload: any = null;
try {
payload = await event.request.json();
} catch {
return new Response('Bad Request', { status: 400 });
}
const due_date_raw = payload?.due_date;
if (due_date_raw !== null && due_date_raw !== undefined && typeof due_date_raw !== 'string') {
return new Response('Bad Request', { status: 400 });
}
const due_date =
due_date_raw == null || String(due_date_raw).trim() === ''
? null
: String(due_date_raw).trim();
if (due_date !== null && !isValidYmd(due_date)) {
return new Response(JSON.stringify({ error: 'invalid_due_date' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
const db = await getDb();
// Cargar tarea y validar abierta
const task = db
.prepare(
`SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?`
)
.get(taskId) as any;
if (!task) {
return new Response(JSON.stringify({ status: 'not_found' }), {
status: 404,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
if (Number(task.completed) !== 0 || task.completed_at) {
return new Response(JSON.stringify({ status: 'completed' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Gating: grupo permitido + usuario miembro activo (si tiene group_id)
const groupId: string | null = task.group_id ? String(task.group_id) : null;
if (groupId) {
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
)
.get(groupId, userId);
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
}
}
// Aplicar actualización
db.prepare(`UPDATE tasks SET due_date = ? WHERE id = ?`).run(due_date, taskId);
const updated = db
.prepare(`SELECT id, description, due_date, display_code FROM tasks WHERE id = ?`)
.get(taskId) as any;
const body = {
status: 'updated',
task: {
id: Number(updated.id),
description: String(updated.description || ''),
due_date: updated.due_date ? String(updated.due_date) : null,
display_code: updated.display_code != null ? Number(updated.display_code) : null
}
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,99 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const idStr = event.params.id || '';
const taskId = parseInt(idStr, 10);
if (!Number.isFinite(taskId) || taskId <= 0) {
return new Response('Bad Request', { status: 400 });
}
const db = await getDb();
// Cargar tarea y validar abierta
const task = db
.prepare(
`SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?`
)
.get(taskId) as any;
if (!task) {
return new Response(JSON.stringify({ status: 'not_found' }), {
status: 404,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
if (Number(task.completed) !== 0 || task.completed_at) {
return new Response(JSON.stringify({ status: 'completed' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Gating: grupo permitido + usuario miembro activo (si tiene group_id)
const groupId: string | null = task.group_id ? String(task.group_id) : null;
if (groupId) {
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
)
.get(groupId, userId);
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
}
}
// Asegurar existencia del usuario (best-effort)
try {
db.transaction(() => {
db.prepare(
`INSERT INTO users (id, first_seen, last_seen)
VALUES (?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(id) DO NOTHING`
).run(userId);
db.prepare(
`UPDATE users SET last_seen = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`
).run(userId);
})();
} catch {}
// Reclamar (idempotente)
const res = db
.prepare(
`INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by)
VALUES (?, ?, ?)`
)
.run(taskId, userId, userId) as any;
const status = Number(res?.changes || 0) > 0 ? 'claimed' : 'already';
const body = {
status,
task: {
id: Number(task.id),
description: String(task.description || ''),
due_date: task.due_date ? String(task.due_date) : null,
display_code: task.display_code != null ? Number(task.display_code) : null
}
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -0,0 +1,84 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const idStr = event.params.id || '';
const taskId = parseInt(idStr, 10);
if (!Number.isFinite(taskId) || taskId <= 0) {
return new Response('Bad Request', { status: 400 });
}
const db = await getDb();
// Cargar tarea y validar abierta
const task = db
.prepare(
`SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?`
)
.get(taskId) as any;
if (!task) {
return new Response(JSON.stringify({ status: 'not_found' }), {
status: 404,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
if (Number(task.completed) !== 0 || task.completed_at) {
return new Response(JSON.stringify({ status: 'completed' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Gating: grupo permitido + usuario miembro activo (si tiene group_id)
const groupId: string | null = task.group_id ? String(task.group_id) : null;
if (groupId) {
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
)
.get(groupId, userId);
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
}
}
// Eliminar asignación (idempotente)
const delRes = db
.prepare(`DELETE FROM task_assignments WHERE task_id = ? AND user_id = ?`)
.run(taskId, userId) as any;
const cntRow = db
.prepare(`SELECT COUNT(*) AS cnt FROM task_assignments WHERE task_id = ?`)
.get(taskId) as any;
const remaining = Number(cntRow?.cnt || 0);
const status = Number(delRes?.changes || 0) > 0 ? 'unassigned' : 'not_assigned';
const body = {
status,
now_unassigned: remaining === 0,
task: {
id: Number(task.id),
description: String(task.description || ''),
due_date: task.due_date ? String(task.due_date) : null,
display_code: task.display_code != null ? Number(task.display_code) : null
}
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
};

@ -44,7 +44,7 @@
<Card>
<ul class="list">
{#each data.tasks as t}
<TaskItem {...t} />
<TaskItem {...t} currentUserId={data.userId} />
{/each}
</ul>
</Card>

Loading…
Cancel
Save