@creezio/tasks 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +279 -0
- package/dist/.creezio-src-hash.json +4 -0
- package/dist/ai-task-agent.d.ts +47 -0
- package/dist/ai-task-agent.d.ts.map +1 -0
- package/dist/ai-task-agent.js +558 -0
- package/dist/ai-task-agent.js.map +1 -0
- package/dist/ai-task-runner.d.ts +78 -0
- package/dist/ai-task-runner.d.ts.map +1 -0
- package/dist/ai-task-runner.js +645 -0
- package/dist/ai-task-runner.js.map +1 -0
- package/dist/api-mount.d.ts +4 -0
- package/dist/api-mount.d.ts.map +1 -0
- package/dist/api-mount.js +91 -0
- package/dist/api-mount.js.map +1 -0
- package/dist/assistant-adapter.d.ts +7 -0
- package/dist/assistant-adapter.d.ts.map +1 -0
- package/dist/assistant-adapter.js +96 -0
- package/dist/assistant-adapter.js.map +1 -0
- package/dist/brand/config.d.ts +165 -0
- package/dist/brand/config.d.ts.map +1 -0
- package/dist/brand/config.js +39 -0
- package/dist/brand/config.js.map +1 -0
- package/dist/env-bridge.d.ts +12 -0
- package/dist/env-bridge.d.ts.map +1 -0
- package/dist/env-bridge.js +33 -0
- package/dist/env-bridge.js.map +1 -0
- package/dist/hono-routes.d.ts +8 -0
- package/dist/hono-routes.d.ts.map +1 -0
- package/dist/hono-routes.js +563 -0
- package/dist/hono-routes.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/kanban-service.d.ts +136 -0
- package/dist/kanban-service.d.ts.map +1 -0
- package/dist/kanban-service.js +580 -0
- package/dist/kanban-service.js.map +1 -0
- package/dist/mcp-host-tools.d.ts +59 -0
- package/dist/mcp-host-tools.d.ts.map +1 -0
- package/dist/mcp-host-tools.js +326 -0
- package/dist/mcp-host-tools.js.map +1 -0
- package/dist/mcp-workspace-tools.d.ts +15 -0
- package/dist/mcp-workspace-tools.d.ts.map +1 -0
- package/dist/mcp-workspace-tools.js +338 -0
- package/dist/mcp-workspace-tools.js.map +1 -0
- package/dist/memory-store.d.ts +3 -0
- package/dist/memory-store.d.ts.map +1 -0
- package/dist/memory-store.js +75 -0
- package/dist/memory-store.js.map +1 -0
- package/dist/sqlite-driver.d.ts +13 -0
- package/dist/sqlite-driver.d.ts.map +1 -0
- package/dist/sqlite-driver.js +13 -0
- package/dist/sqlite-driver.js.map +1 -0
- package/dist/sqlite-store.d.ts +15 -0
- package/dist/sqlite-store.d.ts.map +1 -0
- package/dist/sqlite-store.js +113 -0
- package/dist/sqlite-store.js.map +1 -0
- package/dist/task-runs.d.ts +108 -0
- package/dist/task-runs.d.ts.map +1 -0
- package/dist/task-runs.js +427 -0
- package/dist/task-runs.js.map +1 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- package/dist-cjs/ai-task-agent.js +569 -0
- package/dist-cjs/ai-task-agent.js.map +1 -0
- package/dist-cjs/ai-task-runner.js +660 -0
- package/dist-cjs/ai-task-runner.js.map +1 -0
- package/dist-cjs/api-mount.js +94 -0
- package/dist-cjs/api-mount.js.map +1 -0
- package/dist-cjs/assistant-adapter.js +99 -0
- package/dist-cjs/assistant-adapter.js.map +1 -0
- package/dist-cjs/brand/config.js +47 -0
- package/dist-cjs/brand/config.js.map +1 -0
- package/dist-cjs/env-bridge.js +36 -0
- package/dist-cjs/env-bridge.js.map +1 -0
- package/dist-cjs/hono-routes.js +566 -0
- package/dist-cjs/hono-routes.js.map +1 -0
- package/dist-cjs/index.js +117 -0
- package/dist-cjs/index.js.map +1 -0
- package/dist-cjs/kanban-service.js +601 -0
- package/dist-cjs/kanban-service.js.map +1 -0
- package/dist-cjs/mcp-host-tools.js +333 -0
- package/dist-cjs/mcp-host-tools.js.map +1 -0
- package/dist-cjs/mcp-workspace-tools.js +342 -0
- package/dist-cjs/mcp-workspace-tools.js.map +1 -0
- package/dist-cjs/memory-store.js +81 -0
- package/dist-cjs/memory-store.js.map +1 -0
- package/dist-cjs/package.json +3 -0
- package/dist-cjs/sqlite-driver.js +16 -0
- package/dist-cjs/sqlite-driver.js.map +1 -0
- package/dist-cjs/sqlite-store.js +119 -0
- package/dist-cjs/sqlite-store.js.map +1 -0
- package/dist-cjs/task-runs.js +457 -0
- package/dist-cjs/task-runs.js.map +1 -0
- package/dist-cjs/types.js +18 -0
- package/dist-cjs/types.js.map +1 -0
- package/package.json +79 -0
- package/scripts/port-from-tempoflow.mjs +405 -0
- package/ui/ai-activity-panel.tsx +559 -0
- package/ui/index.ts +7 -0
- package/ui/task-detail-sheet.tsx +668 -0
- package/ui/tasks-kanban-client.tsx +647 -0
- package/ui/tasks-types.ts +120 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Bot,
|
|
6
|
+
CheckCircle2,
|
|
7
|
+
ChevronRight,
|
|
8
|
+
Loader2,
|
|
9
|
+
Plus,
|
|
10
|
+
RefreshCw,
|
|
11
|
+
Repeat2,
|
|
12
|
+
Search,
|
|
13
|
+
Sparkles,
|
|
14
|
+
User,
|
|
15
|
+
} from "lucide-react";
|
|
16
|
+
import { formatDistanceToNow } from "date-fns";
|
|
17
|
+
import { fr } from "date-fns/locale";
|
|
18
|
+
import { toast } from "sonner";
|
|
19
|
+
import { Badge } from "@creezio/shell-ui/ui/kit";
|
|
20
|
+
import { Button } from "@creezio/shell-ui/ui/kit";
|
|
21
|
+
import { Input } from "@creezio/shell-ui/ui/kit";
|
|
22
|
+
import { cn } from "@creezio/shell-ui";
|
|
23
|
+
import { TaskDetailSheet } from "./task-detail-sheet";
|
|
24
|
+
import {
|
|
25
|
+
EXECUTOR_META,
|
|
26
|
+
KANBAN_COLUMNS,
|
|
27
|
+
previewBrief,
|
|
28
|
+
STATUS_META,
|
|
29
|
+
sourceLabel,
|
|
30
|
+
type ColumnKey,
|
|
31
|
+
type TaskAssignee,
|
|
32
|
+
type TaskCard,
|
|
33
|
+
} from "./tasks-types";
|
|
34
|
+
|
|
35
|
+
type ExecutorFilter = "all" | TaskCard["executor_kind"];
|
|
36
|
+
|
|
37
|
+
const EXECUTOR_FILTERS: Array<{ id: ExecutorFilter; label: string }> = [
|
|
38
|
+
{ id: "all", label: "Tous" },
|
|
39
|
+
{ id: "human", label: "Humains" },
|
|
40
|
+
{ id: "ai", label: "IA" },
|
|
41
|
+
{ id: "hermes", label: "Hermes" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export function TasksKanbanClient() {
|
|
45
|
+
const [columns, setColumns] = useState<Record<string, TaskCard[]>>({});
|
|
46
|
+
const [hermesConfigured, setHermesConfigured] = useState(false);
|
|
47
|
+
const [bridgeReady, setBridgeReady] = useState(false);
|
|
48
|
+
const [assignees, setAssignees] = useState<TaskAssignee[]>([]);
|
|
49
|
+
const [loading, setLoading] = useState(true);
|
|
50
|
+
const [syncing, setSyncing] = useState(false);
|
|
51
|
+
|
|
52
|
+
// Composer
|
|
53
|
+
const [composerOpen, setComposerOpen] = useState(false);
|
|
54
|
+
const [title, setTitle] = useState("");
|
|
55
|
+
const [body, setBody] = useState("");
|
|
56
|
+
const [executor, setExecutor] = useState<TaskCard["executor_kind"]>("human");
|
|
57
|
+
const [assigneeId, setAssigneeId] = useState("");
|
|
58
|
+
const [schedule, setSchedule] = useState("");
|
|
59
|
+
const [launchNow, setLaunchNow] = useState(true);
|
|
60
|
+
const [creating, setCreating] = useState(false);
|
|
61
|
+
|
|
62
|
+
// Board
|
|
63
|
+
const [filter, setFilter] = useState("");
|
|
64
|
+
const [executorFilter, setExecutorFilter] = useState<ExecutorFilter>("all");
|
|
65
|
+
// Inbox missions d'un collaborateur (P4) : ?assignee=<userId> — utilisé par
|
|
66
|
+
// le workspace IA (atterrissage) et les liens « Ses tâches ».
|
|
67
|
+
const [assigneeFilterId, setAssigneeFilterId] = useState("");
|
|
68
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
69
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
70
|
+
const [dragId, setDragId] = useState<string | null>(null);
|
|
71
|
+
const [dropCol, setDropCol] = useState<ColumnKey | null>(null);
|
|
72
|
+
|
|
73
|
+
const load = useCallback(async (sync = true) => {
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch(`/api/v1/tasks?sync=${sync ? "1" : "0"}`);
|
|
76
|
+
const data = (await res.json()) as {
|
|
77
|
+
columns?: Record<string, TaskCard[]>;
|
|
78
|
+
hermes_configured?: boolean;
|
|
79
|
+
host_bridge_ready?: boolean;
|
|
80
|
+
error?: string;
|
|
81
|
+
sync?: { errors?: string[] } | null;
|
|
82
|
+
};
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
toast.error(data.error || `Erreur ${res.status}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
setColumns(data.columns || {});
|
|
88
|
+
setHermesConfigured(Boolean(data.hermes_configured));
|
|
89
|
+
setBridgeReady(Boolean(data.host_bridge_ready));
|
|
90
|
+
if (data.sync?.errors?.length) {
|
|
91
|
+
toast.message(`Sync Hermes : ${data.sync.errors[0]}`);
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
setLoading(false);
|
|
95
|
+
}
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
// Filtre initial via ?q= (ex. lien « Ses tâches » depuis Collaborateurs).
|
|
100
|
+
const sp = new URLSearchParams(window.location.search);
|
|
101
|
+
const q = sp.get("q");
|
|
102
|
+
if (q) setFilter(q);
|
|
103
|
+
const assignee = sp.get("assignee");
|
|
104
|
+
if (assignee) setAssigneeFilterId(assignee);
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
void load();
|
|
109
|
+
void fetch("/api/v1/tasks/meta")
|
|
110
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
111
|
+
.then((d: { assignable_users?: TaskAssignee[] } | null) => {
|
|
112
|
+
if (d?.assignable_users) setAssignees(d.assignable_users);
|
|
113
|
+
})
|
|
114
|
+
.catch(() => {});
|
|
115
|
+
// Poll léger sans re-sync Hermes à chaque fois (sync toutes les 3 itérations).
|
|
116
|
+
let tick = 0;
|
|
117
|
+
const t = setInterval(() => {
|
|
118
|
+
tick += 1;
|
|
119
|
+
void load(tick % 3 === 0);
|
|
120
|
+
}, 15000);
|
|
121
|
+
return () => clearInterval(t);
|
|
122
|
+
}, [load]);
|
|
123
|
+
|
|
124
|
+
const eligibleAssignees = useMemo(() => {
|
|
125
|
+
if (executor === "hermes") return [];
|
|
126
|
+
return assignees.filter((a) => a.kind === (executor === "ai" ? "ai" : "human"));
|
|
127
|
+
}, [assignees, executor]);
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
// L'assigné doit rester cohérent avec l'exécutant choisi.
|
|
131
|
+
if (executor === "hermes") {
|
|
132
|
+
setAssigneeId("");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (assigneeId && !eligibleAssignees.some((a) => a.id === assigneeId)) {
|
|
136
|
+
setAssigneeId("");
|
|
137
|
+
}
|
|
138
|
+
if (executor === "ai" && !assigneeId && eligibleAssignees.length === 1) {
|
|
139
|
+
setAssigneeId(eligibleAssignees[0]!.id);
|
|
140
|
+
}
|
|
141
|
+
}, [executor, assigneeId, eligibleAssignees]);
|
|
142
|
+
|
|
143
|
+
const filteredColumns = useMemo(() => {
|
|
144
|
+
const q = filter.trim().toLowerCase();
|
|
145
|
+
const out: Record<string, TaskCard[]> = {};
|
|
146
|
+
for (const [k, list] of Object.entries(columns)) {
|
|
147
|
+
out[k] = (list || []).filter((t) => {
|
|
148
|
+
if (executorFilter !== "all" && t.executor_kind !== executorFilter) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
if (assigneeFilterId && t.assignee?.id !== assigneeFilterId) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (!q) return true;
|
|
155
|
+
return (
|
|
156
|
+
t.title.toLowerCase().includes(q) ||
|
|
157
|
+
t.body.toLowerCase().includes(q) ||
|
|
158
|
+
(t.assignee?.username || "").toLowerCase().includes(q)
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}, [columns, filter, executorFilter, assigneeFilterId]);
|
|
164
|
+
|
|
165
|
+
const totalCount = useMemo(
|
|
166
|
+
() =>
|
|
167
|
+
Object.values(columns).reduce((acc, list) => acc + (list?.length || 0), 0),
|
|
168
|
+
[columns],
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
async function syncNow() {
|
|
172
|
+
setSyncing(true);
|
|
173
|
+
try {
|
|
174
|
+
const res = await fetch("/api/v1/tasks/sync", { method: "POST" });
|
|
175
|
+
const data = (await res.json()) as {
|
|
176
|
+
columns?: Record<string, TaskCard[]>;
|
|
177
|
+
sync?: { updated: number; created: number };
|
|
178
|
+
error?: string;
|
|
179
|
+
};
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
toast.error(data.error || "Sync impossible");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
setColumns(data.columns || {});
|
|
185
|
+
toast.success(
|
|
186
|
+
`Sync Hermes : +${data.sync?.created ?? 0} créées · ${data.sync?.updated ?? 0} mises à jour`,
|
|
187
|
+
);
|
|
188
|
+
} finally {
|
|
189
|
+
setSyncing(false);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function createTask(e: React.FormEvent) {
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
if (!title.trim()) return;
|
|
196
|
+
if (executor === "ai" && !assigneeId) {
|
|
197
|
+
toast.error("Choisissez le collaborateur IA qui exécutera la tâche");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
setCreating(true);
|
|
201
|
+
try {
|
|
202
|
+
const res = await fetch("/api/v1/tasks", {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { "Content-Type": "application/json" },
|
|
205
|
+
body: JSON.stringify({
|
|
206
|
+
title: title.trim(),
|
|
207
|
+
body: body.trim() || undefined,
|
|
208
|
+
executor_kind: executor,
|
|
209
|
+
assignee_user_id: assigneeId || null,
|
|
210
|
+
recurring_schedule:
|
|
211
|
+
executor === "hermes" && schedule.trim() ? schedule.trim() : null,
|
|
212
|
+
// Hermes : envoi immédiat. IA : lancement optionnel du run.
|
|
213
|
+
dispatch: executor === "hermes",
|
|
214
|
+
launch: executor === "ai" && launchNow,
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
const data = (await res.json()) as {
|
|
218
|
+
task?: TaskCard;
|
|
219
|
+
warning?: string | null;
|
|
220
|
+
error?: string;
|
|
221
|
+
};
|
|
222
|
+
if (!res.ok) {
|
|
223
|
+
toast.error(data.error || "Création impossible");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (data.warning) toast.message(data.warning);
|
|
227
|
+
else if (executor === "hermes") toast.success("Tâche envoyée à Hermes");
|
|
228
|
+
else if (executor === "ai" && launchNow) toast.success("Tâche créée — run IA en file");
|
|
229
|
+
else toast.success("Tâche créée");
|
|
230
|
+
setTitle("");
|
|
231
|
+
setBody("");
|
|
232
|
+
setSchedule("");
|
|
233
|
+
setComposerOpen(false);
|
|
234
|
+
await load(false);
|
|
235
|
+
if (data.task?.id) {
|
|
236
|
+
setSelectedId(data.task.id);
|
|
237
|
+
setSheetOpen(true);
|
|
238
|
+
}
|
|
239
|
+
} finally {
|
|
240
|
+
setCreating(false);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const setStatus = useCallback(
|
|
245
|
+
async (id: string, status: string) => {
|
|
246
|
+
const res = await fetch(`/api/v1/tasks/${id}`, {
|
|
247
|
+
method: "PATCH",
|
|
248
|
+
headers: { "Content-Type": "application/json" },
|
|
249
|
+
body: JSON.stringify({ status }),
|
|
250
|
+
});
|
|
251
|
+
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
|
252
|
+
if (!res.ok) {
|
|
253
|
+
toast.error(data.error || "Mise à jour impossible");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
await load(false);
|
|
257
|
+
},
|
|
258
|
+
[load],
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
function onDropTo(col: ColumnKey) {
|
|
262
|
+
setDropCol(null);
|
|
263
|
+
if (!dragId) return;
|
|
264
|
+
const current = Object.values(columns)
|
|
265
|
+
.flat()
|
|
266
|
+
.find((t) => t.id === dragId);
|
|
267
|
+
setDragId(null);
|
|
268
|
+
if (!current || current.status === col) return;
|
|
269
|
+
void setStatus(current.id, col);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function openTask(id: string) {
|
|
273
|
+
setSelectedId(id);
|
|
274
|
+
setSheetOpen(true);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<div className="space-y-5">
|
|
279
|
+
{/* Toolbar */}
|
|
280
|
+
<div className="flex flex-col gap-3 rounded-2xl border border-slate-200/80 bg-[linear-gradient(135deg,#f8fafc_0%,#ffffff_45%,#f0f9ff_100%)] p-4 shadow-sm shadow-slate-900/5">
|
|
281
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
282
|
+
<div className="min-w-0 space-y-1.5">
|
|
283
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
284
|
+
<span className="inline-flex items-center gap-1.5 rounded-full bg-slate-900 px-2.5 py-1 text-[11px] font-medium text-white">
|
|
285
|
+
<Bot className="h-3 w-3" />
|
|
286
|
+
{totalCount} tâche{totalCount !== 1 ? "s" : ""}
|
|
287
|
+
</span>
|
|
288
|
+
<Badge
|
|
289
|
+
variant="outline"
|
|
290
|
+
className={cn(
|
|
291
|
+
"gap-1 border-0 text-[11px]",
|
|
292
|
+
hermesConfigured
|
|
293
|
+
? "bg-emerald-100 text-emerald-900"
|
|
294
|
+
: "bg-amber-100 text-amber-900",
|
|
295
|
+
)}
|
|
296
|
+
>
|
|
297
|
+
<span
|
|
298
|
+
className={cn(
|
|
299
|
+
"h-1.5 w-1.5 rounded-full",
|
|
300
|
+
hermesConfigured ? "bg-emerald-500" : "bg-amber-500",
|
|
301
|
+
)}
|
|
302
|
+
/>
|
|
303
|
+
{hermesConfigured ? "Hermes connecté" : "Hermes hors ligne"}
|
|
304
|
+
</Badge>
|
|
305
|
+
<Badge
|
|
306
|
+
variant="outline"
|
|
307
|
+
className={cn(
|
|
308
|
+
"gap-1 border-0 text-[11px]",
|
|
309
|
+
bridgeReady
|
|
310
|
+
? "bg-violet-100 text-violet-900"
|
|
311
|
+
: "bg-slate-100 text-slate-500",
|
|
312
|
+
)}
|
|
313
|
+
>
|
|
314
|
+
<span
|
|
315
|
+
className={cn(
|
|
316
|
+
"h-1.5 w-1.5 rounded-full",
|
|
317
|
+
bridgeReady ? "bg-violet-500" : "bg-slate-400",
|
|
318
|
+
)}
|
|
319
|
+
/>
|
|
320
|
+
{bridgeReady ? "Workspaces IA prêts" : "Desktop IA hors ligne"}
|
|
321
|
+
</Badge>
|
|
322
|
+
</div>
|
|
323
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
324
|
+
{EXECUTOR_FILTERS.map((f) => (
|
|
325
|
+
<button
|
|
326
|
+
key={f.id}
|
|
327
|
+
type="button"
|
|
328
|
+
onClick={() => setExecutorFilter(f.id)}
|
|
329
|
+
className={cn(
|
|
330
|
+
"rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors",
|
|
331
|
+
executorFilter === f.id
|
|
332
|
+
? "bg-slate-900 text-white"
|
|
333
|
+
: "bg-white/80 text-slate-600 hover:bg-slate-100",
|
|
334
|
+
)}
|
|
335
|
+
>
|
|
336
|
+
{f.label}
|
|
337
|
+
</button>
|
|
338
|
+
))}
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
342
|
+
<div className="relative">
|
|
343
|
+
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
|
344
|
+
<Input
|
|
345
|
+
value={filter}
|
|
346
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
347
|
+
placeholder="Filtrer…"
|
|
348
|
+
className="h-9 w-44 border-slate-200 bg-white/90 pl-8 text-xs shadow-none"
|
|
349
|
+
/>
|
|
350
|
+
</div>
|
|
351
|
+
{hermesConfigured ? (
|
|
352
|
+
<Button
|
|
353
|
+
type="button"
|
|
354
|
+
variant="outline"
|
|
355
|
+
size="sm"
|
|
356
|
+
className="h-9 bg-white/90"
|
|
357
|
+
disabled={syncing || loading}
|
|
358
|
+
onClick={() => void syncNow()}
|
|
359
|
+
>
|
|
360
|
+
{syncing ? (
|
|
361
|
+
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
362
|
+
) : (
|
|
363
|
+
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
|
364
|
+
)}
|
|
365
|
+
Sync Hermes
|
|
366
|
+
</Button>
|
|
367
|
+
) : null}
|
|
368
|
+
<Button
|
|
369
|
+
type="button"
|
|
370
|
+
size="sm"
|
|
371
|
+
className="h-9"
|
|
372
|
+
onClick={() => setComposerOpen((v) => !v)}
|
|
373
|
+
>
|
|
374
|
+
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
|
375
|
+
Nouvelle tâche
|
|
376
|
+
</Button>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
|
|
380
|
+
{composerOpen ? (
|
|
381
|
+
<form
|
|
382
|
+
onSubmit={(e) => void createTask(e)}
|
|
383
|
+
className="grid gap-2 rounded-xl border border-slate-200 bg-white/90 p-3 shadow-sm animate-in fade-in slide-in-from-top-1"
|
|
384
|
+
>
|
|
385
|
+
<Input
|
|
386
|
+
placeholder="Titre de la tâche"
|
|
387
|
+
value={title}
|
|
388
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
389
|
+
className="border-slate-200 text-sm font-medium"
|
|
390
|
+
autoFocus
|
|
391
|
+
/>
|
|
392
|
+
<textarea
|
|
393
|
+
placeholder="Brief (contexte, contraintes, livrable attendu)…"
|
|
394
|
+
value={body}
|
|
395
|
+
onChange={(e) => setBody(e.target.value)}
|
|
396
|
+
rows={4}
|
|
397
|
+
className="w-full resize-y rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 outline-none ring-offset-white placeholder:text-slate-400 focus-visible:ring-2 focus-visible:ring-sky-400"
|
|
398
|
+
/>
|
|
399
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
400
|
+
<span className="text-[11px] font-medium uppercase tracking-wide text-slate-500">
|
|
401
|
+
Exécutant
|
|
402
|
+
</span>
|
|
403
|
+
{(["human", "ai", "hermes"] as const).map((k) => (
|
|
404
|
+
<button
|
|
405
|
+
key={k}
|
|
406
|
+
type="button"
|
|
407
|
+
onClick={() => setExecutor(k)}
|
|
408
|
+
className={cn(
|
|
409
|
+
"rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors",
|
|
410
|
+
executor === k
|
|
411
|
+
? "border-slate-900 bg-slate-900 text-white"
|
|
412
|
+
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50",
|
|
413
|
+
)}
|
|
414
|
+
>
|
|
415
|
+
{EXECUTOR_META[k].label}
|
|
416
|
+
</button>
|
|
417
|
+
))}
|
|
418
|
+
</div>
|
|
419
|
+
{executor !== "hermes" ? (
|
|
420
|
+
<select
|
|
421
|
+
value={assigneeId}
|
|
422
|
+
onChange={(e) => setAssigneeId(e.target.value)}
|
|
423
|
+
className="h-9 rounded-md border border-slate-200 bg-white px-2 text-sm text-slate-800"
|
|
424
|
+
>
|
|
425
|
+
<option value="">
|
|
426
|
+
{executor === "ai"
|
|
427
|
+
? "— Choisir le collaborateur IA —"
|
|
428
|
+
: "Non assignée"}
|
|
429
|
+
</option>
|
|
430
|
+
{eligibleAssignees.map((a) => (
|
|
431
|
+
<option key={a.id} value={a.id}>
|
|
432
|
+
{a.username}
|
|
433
|
+
</option>
|
|
434
|
+
))}
|
|
435
|
+
</select>
|
|
436
|
+
) : (
|
|
437
|
+
<Input
|
|
438
|
+
placeholder="Récurrence optionnelle (every 1d, 0 9 * * 1…)"
|
|
439
|
+
value={schedule}
|
|
440
|
+
onChange={(e) => setSchedule(e.target.value)}
|
|
441
|
+
className="border-slate-200 text-xs"
|
|
442
|
+
/>
|
|
443
|
+
)}
|
|
444
|
+
{executor === "ai" ? (
|
|
445
|
+
<label className="flex items-center gap-2 text-xs text-slate-600">
|
|
446
|
+
<input
|
|
447
|
+
type="checkbox"
|
|
448
|
+
checked={launchNow}
|
|
449
|
+
onChange={(e) => setLaunchNow(e.target.checked)}
|
|
450
|
+
className="h-3.5 w-3.5 rounded border-slate-300"
|
|
451
|
+
/>
|
|
452
|
+
Lancer le run IA immédiatement
|
|
453
|
+
</label>
|
|
454
|
+
) : null}
|
|
455
|
+
<div className="flex justify-end gap-2">
|
|
456
|
+
<Button
|
|
457
|
+
type="button"
|
|
458
|
+
variant="ghost"
|
|
459
|
+
size="sm"
|
|
460
|
+
onClick={() => setComposerOpen(false)}
|
|
461
|
+
>
|
|
462
|
+
Annuler
|
|
463
|
+
</Button>
|
|
464
|
+
<Button type="submit" size="sm" disabled={creating || !title.trim()}>
|
|
465
|
+
{creating ? (
|
|
466
|
+
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
467
|
+
) : (
|
|
468
|
+
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
|
469
|
+
)}
|
|
470
|
+
{executor === "hermes"
|
|
471
|
+
? "Créer & envoyer à Hermes"
|
|
472
|
+
: executor === "ai" && launchNow
|
|
473
|
+
? "Créer & lancer"
|
|
474
|
+
: "Créer"}
|
|
475
|
+
</Button>
|
|
476
|
+
</div>
|
|
477
|
+
</form>
|
|
478
|
+
) : null}
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
{/* Board */}
|
|
482
|
+
{loading && totalCount === 0 ? (
|
|
483
|
+
<div className="flex items-center gap-2 py-16 text-sm text-slate-500">
|
|
484
|
+
<Loader2 className="h-4 w-4 animate-spin" /> Chargement du board…
|
|
485
|
+
</div>
|
|
486
|
+
) : (
|
|
487
|
+
<div className="-mx-1 flex gap-3 overflow-x-auto px-1 pb-2 md:grid md:grid-cols-4 md:overflow-visible">
|
|
488
|
+
{KANBAN_COLUMNS.map((col) => {
|
|
489
|
+
const meta = STATUS_META[col.key];
|
|
490
|
+
const items = filteredColumns[col.key] || [];
|
|
491
|
+
return (
|
|
492
|
+
<section
|
|
493
|
+
key={col.key}
|
|
494
|
+
onDragOver={(e) => {
|
|
495
|
+
e.preventDefault();
|
|
496
|
+
setDropCol(col.key);
|
|
497
|
+
}}
|
|
498
|
+
onDragLeave={() =>
|
|
499
|
+
setDropCol((c) => (c === col.key ? null : c))
|
|
500
|
+
}
|
|
501
|
+
onDrop={(e) => {
|
|
502
|
+
e.preventDefault();
|
|
503
|
+
onDropTo(col.key);
|
|
504
|
+
}}
|
|
505
|
+
className={cn(
|
|
506
|
+
"flex w-[270px] shrink-0 flex-col rounded-2xl border bg-gradient-to-b shadow-sm transition-colors md:w-auto",
|
|
507
|
+
meta?.colTint || "from-slate-50 to-white",
|
|
508
|
+
dropCol === col.key && dragId
|
|
509
|
+
? "border-sky-400 ring-2 ring-sky-200"
|
|
510
|
+
: "border-slate-200/80",
|
|
511
|
+
)}
|
|
512
|
+
>
|
|
513
|
+
<header className="flex items-center justify-between px-3 py-2.5">
|
|
514
|
+
<div className="flex items-center gap-2">
|
|
515
|
+
<span
|
|
516
|
+
className={cn(
|
|
517
|
+
"h-2 w-2 rounded-full",
|
|
518
|
+
meta?.barClass || "bg-slate-400",
|
|
519
|
+
)}
|
|
520
|
+
/>
|
|
521
|
+
<h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-slate-600">
|
|
522
|
+
{col.label}
|
|
523
|
+
</h2>
|
|
524
|
+
</div>
|
|
525
|
+
<span className="rounded-full bg-white/80 px-2 py-0.5 text-[11px] font-medium text-slate-500 shadow-sm">
|
|
526
|
+
{items.length}
|
|
527
|
+
</span>
|
|
528
|
+
</header>
|
|
529
|
+
|
|
530
|
+
<ul className="flex min-h-[320px] flex-1 flex-col gap-2 px-2 pb-3">
|
|
531
|
+
{items.length === 0 ? (
|
|
532
|
+
<li className="flex flex-1 items-center justify-center rounded-xl border border-dashed border-slate-200/80 px-3 py-8 text-center text-[11px] text-slate-400">
|
|
533
|
+
{dragId ? "Déposer ici" : "Vide"}
|
|
534
|
+
</li>
|
|
535
|
+
) : (
|
|
536
|
+
items.map((t) => {
|
|
537
|
+
const preview = previewBrief(t.body);
|
|
538
|
+
const exec = EXECUTOR_META[t.executor_kind];
|
|
539
|
+
const running =
|
|
540
|
+
t.executor_kind !== "human" &&
|
|
541
|
+
t.status === "in_progress";
|
|
542
|
+
return (
|
|
543
|
+
<li key={t.id}>
|
|
544
|
+
<button
|
|
545
|
+
type="button"
|
|
546
|
+
draggable
|
|
547
|
+
onDragStart={() => setDragId(t.id)}
|
|
548
|
+
onDragEnd={() => {
|
|
549
|
+
setDragId(null);
|
|
550
|
+
setDropCol(null);
|
|
551
|
+
}}
|
|
552
|
+
onClick={() => openTask(t.id)}
|
|
553
|
+
className={cn(
|
|
554
|
+
"group w-full cursor-grab rounded-xl border border-slate-200/90 bg-white p-3 text-left shadow-sm transition active:cursor-grabbing",
|
|
555
|
+
"hover:-translate-y-0.5 hover:border-sky-300 hover:shadow-md hover:shadow-sky-900/5",
|
|
556
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400",
|
|
557
|
+
dragId === t.id && "opacity-50",
|
|
558
|
+
selectedId === t.id && sheetOpen
|
|
559
|
+
? "border-sky-400 ring-2 ring-sky-200"
|
|
560
|
+
: null,
|
|
561
|
+
)}
|
|
562
|
+
>
|
|
563
|
+
<div
|
|
564
|
+
className={cn(
|
|
565
|
+
"mb-2 h-0.5 w-10 rounded-full",
|
|
566
|
+
meta?.barClass || "bg-slate-300",
|
|
567
|
+
)}
|
|
568
|
+
/>
|
|
569
|
+
<div className="flex items-start justify-between gap-2">
|
|
570
|
+
<p className="text-sm font-semibold leading-snug text-slate-900">
|
|
571
|
+
{t.title}
|
|
572
|
+
</p>
|
|
573
|
+
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-slate-300 transition group-hover:translate-x-0.5 group-hover:text-sky-500" />
|
|
574
|
+
</div>
|
|
575
|
+
{preview ? (
|
|
576
|
+
<p className="mt-1.5 line-clamp-2 text-[11px] leading-relaxed text-slate-600">
|
|
577
|
+
{preview}
|
|
578
|
+
</p>
|
|
579
|
+
) : null}
|
|
580
|
+
|
|
581
|
+
<div className="mt-2.5 flex flex-wrap items-center gap-1">
|
|
582
|
+
<span
|
|
583
|
+
className={cn(
|
|
584
|
+
"inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium",
|
|
585
|
+
exec.badgeClass,
|
|
586
|
+
)}
|
|
587
|
+
>
|
|
588
|
+
{t.executor_kind === "human" ? (
|
|
589
|
+
<User className="h-3 w-3" />
|
|
590
|
+
) : (
|
|
591
|
+
<Bot className="h-3 w-3" />
|
|
592
|
+
)}
|
|
593
|
+
{t.assignee?.username || exec.short}
|
|
594
|
+
{running ? (
|
|
595
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
596
|
+
) : null}
|
|
597
|
+
</span>
|
|
598
|
+
{t.result ? (
|
|
599
|
+
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800">
|
|
600
|
+
<CheckCircle2 className="h-3 w-3" />
|
|
601
|
+
Rapport
|
|
602
|
+
</span>
|
|
603
|
+
) : null}
|
|
604
|
+
{t.recurring_schedule ? (
|
|
605
|
+
<span className="inline-flex items-center gap-1 rounded-md bg-violet-50 px-1.5 py-0.5 text-[10px] font-medium text-violet-800">
|
|
606
|
+
<Repeat2 className="h-3 w-3" />
|
|
607
|
+
Récurrente
|
|
608
|
+
</span>
|
|
609
|
+
) : null}
|
|
610
|
+
<span className="rounded-md bg-slate-50 px-1.5 py-0.5 text-[10px] text-slate-500">
|
|
611
|
+
{sourceLabel(t.source)}
|
|
612
|
+
</span>
|
|
613
|
+
</div>
|
|
614
|
+
|
|
615
|
+
<div className="mt-2 flex items-center justify-between text-[10px] text-slate-400">
|
|
616
|
+
<span>
|
|
617
|
+
{formatDistanceToNow(new Date(t.updated_at), {
|
|
618
|
+
addSuffix: true,
|
|
619
|
+
locale: fr,
|
|
620
|
+
})}
|
|
621
|
+
</span>
|
|
622
|
+
<span className="font-medium text-sky-600 opacity-0 transition group-hover:opacity-100">
|
|
623
|
+
Voir le détail
|
|
624
|
+
</span>
|
|
625
|
+
</div>
|
|
626
|
+
</button>
|
|
627
|
+
</li>
|
|
628
|
+
);
|
|
629
|
+
})
|
|
630
|
+
)}
|
|
631
|
+
</ul>
|
|
632
|
+
</section>
|
|
633
|
+
);
|
|
634
|
+
})}
|
|
635
|
+
</div>
|
|
636
|
+
)}
|
|
637
|
+
|
|
638
|
+
<TaskDetailSheet
|
|
639
|
+
taskId={selectedId}
|
|
640
|
+
open={sheetOpen}
|
|
641
|
+
onOpenChange={setSheetOpen}
|
|
642
|
+
onStatusChange={setStatus}
|
|
643
|
+
onChanged={() => void load(false)}
|
|
644
|
+
/>
|
|
645
|
+
</div>
|
|
646
|
+
);
|
|
647
|
+
}
|