215 lines
9.0 KiB
Svelte
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)}
|
|
/>
|