Files
lassistanoque/backend/web/src/routes/(authenticated)/[space]/tasks/+page.svelte
T

215 lines
9.0 KiB
Svelte

<script lang="ts">
import { invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import { taskApi } from "$lib/api/endpoints/task";
import { chatApi } from "$lib/api/endpoints/chat";
import { putMessage } from "$lib/state/toaster.svelte";
import ConfirmDialog from "$lib/components/ConfirmDialog.svelte";
import type { PageProps } from './$types';
import type { Chat, Task } from "$lib/types/api";
let { data }: PageProps = $props();
// svelte-ignore state_referenced_locally -- la liste est gérée localement
const tasks = $derived<Task[]>(data.tasks ?? []);
let editing = $state<Task | null>(null);
let taskToDelete = $state<Task | null>(null);
let saving = $state(false);
// conversations liées à une tâche (chargées à la demande) ;
// ?task=<id> dans l'URL ouvre directement la section
let expandedTask = $state<string | null>(page.url.searchParams.get("task"));
let taskConversations = $state<Record<string, Chat[]>>({});
let loadingConversations = $state(false);
const loadConversations = async (taskId: string) => {
loadingConversations = true;
try {
const result = await chatApi.allChats(data.space, 0, undefined, taskId);
taskConversations = { ...taskConversations, [taskId]: result ?? [] };
} catch {
putMessage("failure", "Erreur lors du chargement des conversations");
} finally {
loadingConversations = false;
}
};
const toggleConversations = async (task: Task) => {
if (expandedTask === task.id) {
expandedTask = null;
return;
}
expandedTask = task.id;
if (!taskConversations[task.id]) {
await loadConversations(task.id);
}
};
// ouverture initiale depuis ?task=<id>
let initialExpandDone = false;
$effect(() => {
if (!initialExpandDone && expandedTask) {
initialExpandDone = true;
loadConversations(expandedTask);
}
initialExpandDone = true;
});
const formatDate = (value: string | null) => {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const startEdit = (task: Task) => {
editing = { ...task };
};
const handleSave = async (e: Event) => {
e.preventDefault();
if (!editing) return;
if (!editing.label.trim() || !editing.prompt.trim() || !editing.cron.trim()) {
putMessage("failure", "Titre, prompt et planification sont obligatoires");
return;
}
saving = true;
try {
await taskApi.updateTask(data.space, editing.id, {
label: editing.label,
prompt: editing.prompt,
cron: editing.cron,
repeat: editing.repeat,
});
putMessage("success", "Tâche mise à jour");
editing = null;
await invalidateAll();
} catch {
putMessage("failure", "Erreur lors de la mise à jour de la tâche");
} finally {
saving = false;
}
};
const handleDelete = async () => {
if (!taskToDelete) return;
try {
await taskApi.deleteTask(data.space, taskToDelete.id);
putMessage("success", "Tâche supprimée");
await invalidateAll();
} catch {
putMessage("failure", "Erreur lors de la suppression de la tâche");
} finally {
taskToDelete = null;
}
};
</script>
<div class="container">
{#if tasks.length === 0}
<p class="my-4 text-center text-sm text-stone-500">
Aucune tâche planifiée. Demandez-en une dans le chat
(« résume-moi l'actualité tous les jours à 8h »).
</p>
{/if}
{#each tasks as task (task.id)}
{#if editing && editing.id === task.id}
<div class="dark:bg-stone-800 rounded p-3 my-2">
<form onsubmit={handleSave}>
<div class="formgroup my-2">
<div class="formelement">
<label for={`label-${task.id}`}>Titre</label>
<input id={`label-${task.id}`} class="textinput" type="text" bind:value={editing.label} />
</div>
<div class="formelement">
<label for={`cron-${task.id}`}>Planification (cron)</label>
<input id={`cron-${task.id}`} class="textinput" type="text" bind:value={editing.cron} />
</div>
<div class="formelement">
<label for={`repeat-${task.id}`}>Répétition</label>
<input id={`repeat-${task.id}`} type="checkbox" bind:checked={editing.repeat} />
</div>
</div>
<div class="formelement">
<label for={`prompt-${task.id}`}>Prompt</label>
<textarea id={`prompt-${task.id}`} class="textinput w-full" rows="4" bind:value={editing.prompt}></textarea>
</div>
<div class="mt-2 flex gap-2">
<button class="bt small" type="submit" disabled={saving}>
{saving ? "Enregistrement..." : "Enregistrer"}
</button>
<button class="bt small" type="button" onclick={() => (editing = null)}>Annuler</button>
</div>
</form>
</div>
{:else}
<div class="dark:bg-stone-800 rounded p-2 my-2" class:ring-2={expandedTask === task.id}
class:ring-sky-500={expandedTask === task.id}>
<div class="flex flex-row items-center">
<div class="flex-1">
<div class="font-bold text-base">{task.label}</div>
<div class="text-xs text-stone-500">
{task.cron}
{task.repeat ? "· répétée" : "· ponctuelle"}
{task.status === "done" ? "· terminée" : ""}
</div>
</div>
<button
class="bt small"
title="Conversations liées"
onclick={() => toggleConversations(task)}
>
<i class="icon-chat"></i>
</button>
<button class="bt small" title="Modifier la tâche" onclick={() => startEdit(task)}>
<i class="icon-edit"></i>
</button>
<button class="bt small" title="Supprimer la tâche" onclick={() => (taskToDelete = task)}>
<i class="icon-trash-empty"></i>
</button>
</div>
<p class="mt-1 text-sm text-stone-600 dark:text-stone-400">{task.prompt}</p>
<span class="text-xs">Prochaine exécution : {formatDate(task.nextDatetime)}</span>
{#if expandedTask === task.id}
<div class="mt-2 border-t border-stone-600 pt-2">
<div class="text-xs font-medium uppercase text-stone-500">Conversations liées</div>
{#if loadingConversations && !taskConversations[task.id]}
<p class="text-sm text-stone-500">Chargement...</p>
{:else if (taskConversations[task.id] ?? []).length === 0}
<p class="text-sm text-stone-500">Aucune conversation pour cette tâche.</p>
{:else}
<ul class="mt-1">
{#each taskConversations[task.id] as conversation (conversation.id)}
<li class="my-1 text-sm">
<a class="text-sky-700 dark:text-sky-300" href={`/${data.space}/${conversation.id}`}>
{conversation.title}
</a>
<span class="ml-2 text-xs text-stone-500">{formatDate(conversation._date_created)}</span>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
{/if}
{/each}
</div>
<ConfirmDialog
open={taskToDelete !== null}
title="Supprimer la tâche"
message={`Voulez-vous vraiment supprimer « ${taskToDelete?.label} » ? Cette action est irréversible.`}
confirmLabel="Supprimer"
onConfirm={handleDelete}
onCancel={() => (taskToDelete = null)}
/>