Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e20106c59 | ||
|
|
c547e556ec | ||
|
|
74da091dd4 | ||
|
|
a33593460b |
@@ -153,7 +153,7 @@ A secret URL, `POST /api/hooks/<token>` (`src/pages/api/hooks/[token].ts`, publi
|
||||
|
||||
### Tasks (the to-do tracker)
|
||||
|
||||
`tasks` (a bot's own list is `bot_id` alone; a workspace's task has `workspace_id` plus the `bot_id` that has it there, null while shared; a bot reaches its own list and its own plus shared tasks in its workspaces, a workspace admin all of that workspace's, `botTaskScopesSql`; a shared task becomes the bot's when it starts it; removing or deleting a bot makes its workspace tasks shared again), `task_notes`, `task_media` (deliverable|reference). Not the same as `bot_tasks`/`bots_queue` (those record jobs). Visibility SQL in `src/utils/task-access/`. Helpers in `src/functions/backend/db/tasks/`; tools `list_tasks`/`create_task`/`update_task`; API `api/admin/tasks/` (`userAuth` -> `isReadOnlyUser` -> `assertUserReachesTask` -> `safeErrorMessage`). UI in the workspace dashboard and the header's Tasks modal (shared components in `src/components/general/`). **Hand-over:** `handOverTask` (bot: `delegate_task` with `task_id`; UI: Hand over, crew managers) moves an open task to the new bot (a workspace's stays in the workspace, now the new bot's, and is worked there), sets it doing and queues a job with `params.tracker_task_id`; `noteTrackerTask` notes the job's end on it and blocks it on failure. **Not built yet:** task steps, `server:task-changed` pushes, a `/admin/tasks` page.
|
||||
`tasks` (a bot's own list is `bot_id` alone; a workspace's task has `workspace_id` plus the `bot_id` that has it there, null while shared; a bot reaches its own list and its own plus shared tasks in its workspaces, a workspace admin all of that workspace's, `botTaskScopesSql`; a shared task becomes the bot's when it starts it; removing or deleting a bot makes its workspace tasks shared again), `task_notes`, `task_media` (deliverable|reference). Not the same as `bot_tasks`/`bots_queue` (those record jobs). Visibility SQL in `src/utils/task-access/`. Helpers in `src/functions/backend/db/tasks/`; tools `list_tasks`/`create_task`/`update_task`; API `api/admin/tasks/` (`userAuth` -> `isReadOnlyUser` -> `assertUserReachesTask` -> `safeErrorMessage`). UI in the workspace dashboard and the header's Tasks modal (shared components in `src/components/general/`); a card's title opens `TaskDetailModal` with everything about the task and its status moves (a modal over the list, never swapped in place). **Hand-over:** `handOverTask` (bot: `delegate_task` with `task_id`; UI: Hand over, crew managers) moves an open task to the new bot (a workspace's stays in the workspace, now the new bot's, and is worked there), sets it doing and queues a job with `params.tracker_task_id`; `noteTrackerTask` notes the job's end on it and blocks it on failure. **Not built yet:** task steps, `server:task-changed` pushes, a `/admin/tasks` page.
|
||||
|
||||
### Envs
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cobble",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.4",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { TaskDetail, TaskWithNames } from "@/src/types";
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
import taskPlaceLabel from "@/src/utils/task-place-label";
|
||||
import TaskDetailPanel from "./task-detail-panel";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The list row clicked; open while set. Its notes and files load when
|
||||
* it opens, so the list stays small.
|
||||
*/
|
||||
task?: TaskWithNames | null;
|
||||
/**
|
||||
* A crew manager can change it, file onto it and delete it.
|
||||
*/
|
||||
can_manage?: boolean;
|
||||
onClose: () => void;
|
||||
onChanged: (params: { task: TaskDetail; msg?: string }) => void;
|
||||
onDeleted: (params: { task_id: number }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A task opened from a list in a modal of its own: everything about it, and
|
||||
* for a crew manager its edit, hand-over, files and delete.
|
||||
*/
|
||||
export default function TaskDetailModal({ task, can_manage, onClose, onChanged, onDeleted }: Props) {
|
||||
const [detail, set_detail] = useState<TaskDetail | null>(null);
|
||||
const [error, set_error] = useState<string>();
|
||||
const task_id = task?.id ? Number(task.id) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
set_detail(null);
|
||||
set_error(undefined);
|
||||
|
||||
if (!task_id) return;
|
||||
|
||||
let live = true;
|
||||
|
||||
fetchApi<{}, APIResponseObject<TaskDetail>>(`/api/admin/tasks/get-task?task_id=${task_id}`, {
|
||||
method: "GET",
|
||||
})
|
||||
.catch(() => null)
|
||||
.then((res) => {
|
||||
if (!live) return;
|
||||
|
||||
if (!res?.success || !res.singleRes) {
|
||||
set_error(res?.msg || "Couldn't open the task.");
|
||||
return;
|
||||
}
|
||||
|
||||
set_detail(res.singleRes);
|
||||
});
|
||||
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [task_id]);
|
||||
|
||||
const shown = detail || task;
|
||||
|
||||
return (
|
||||
<AddModalShell
|
||||
open={Boolean(task_id)}
|
||||
setOpen={(open) => !open && onClose()}
|
||||
title={shown?.title || (task_id ? `Task ${task_id}` : "Task")}
|
||||
description={shown ? taskPlaceLabel({ task: shown }) : undefined}
|
||||
>
|
||||
<Stack className="w-full mt-4 gap-0! items-stretch">
|
||||
{detail ? (
|
||||
<TaskDetailPanel
|
||||
key={detail.id}
|
||||
task={detail}
|
||||
can_manage={can_manage}
|
||||
onChanged={(saved, msg) => {
|
||||
set_detail(saved);
|
||||
onChanged({ task: saved, msg });
|
||||
}}
|
||||
onDeleted={() => onDeleted({ task_id })}
|
||||
/>
|
||||
) : (
|
||||
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">
|
||||
{error || "Loading the task..."}
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useContext, useRef, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
@@ -10,6 +10,14 @@ import TaskFileRow from "@/src/components/general/task-file-row";
|
||||
import HandOverForm from "@/src/components/general/hand-over-form";
|
||||
import TaskForm from "@/src/components/general/task-form";
|
||||
import TaskStatusChip from "@/src/components/general/task-status-chip";
|
||||
import TaskStatusMoves from "@/src/components/general/task-status-moves";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import TaskFactRow from "@/src/components/general/task-fact-row";
|
||||
import TaskNoteRow from "@/src/components/general/task-note-row";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
@@ -24,12 +32,14 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* One task opened: what it is, its notes oldest first, its files (each
|
||||
* opening its bytes), a box to add a note, and edit or delete. A crew
|
||||
* One task opened: its status (a crew manager moves it along), who has it and where, who added it and
|
||||
* when it last moved, what it is, its files (each opening its bytes), its
|
||||
* notes oldest first, a box to add a note, and edit or delete. A crew
|
||||
* manager can file a new deliverable straight onto it, or hand an open
|
||||
* one to another bot, which starts on it.
|
||||
*/
|
||||
export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [handing_over, setHandingOver] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
@@ -135,13 +145,21 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
|
||||
<Stack className="w-full gap-4! items-stretch">
|
||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||
<TaskStatusChip status={task.status} />
|
||||
<Span className="grow min-w-0 text-[13px] leading-[18px] text-ink-secondary truncate">
|
||||
{task.workspace_id
|
||||
? task.workspace_name || `Workspace ${task.workspace_id}`
|
||||
: task.bot_name || `Bot ${task.bot_id}`}
|
||||
</Span>
|
||||
{can_manage ? (
|
||||
<Row className="shrink-0 gap-1! flex-nowrap">
|
||||
<TaskStatusMoves
|
||||
status={task.status}
|
||||
disabled={busy}
|
||||
onMove={(next) =>
|
||||
run({
|
||||
path: "tasks/save-task",
|
||||
body: { task_id: Number(task.id), status: next },
|
||||
done: "Status changed.",
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{can_manage ? (
|
||||
<Row className="ml-auto shrink-0 gap-1! flex-nowrap">
|
||||
{(OpenTaskStatuses as readonly string[]).includes(task.status || "") ? (
|
||||
<Button
|
||||
title="Hand the task to another bot"
|
||||
@@ -200,6 +218,59 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
|
||||
) : null}
|
||||
</Row>
|
||||
|
||||
<Stack className="w-full gap-1.5! items-stretch">
|
||||
<TaskFactRow label="Who has it">
|
||||
{task.bot_id ? (
|
||||
<>
|
||||
<BotAvatar
|
||||
image_media_id={task.bot_image_media_id}
|
||||
{...botStoneProps({ color: task.bot_color, stone: task.bot_stone, hue: task.bot_hue })}
|
||||
face={(task.bot_face || "idle") as BotFace}
|
||||
size={18}
|
||||
/>
|
||||
<Span className="min-w-0 truncate">{task.bot_name || `Bot ${task.bot_id}`}</Span>
|
||||
</>
|
||||
) : (
|
||||
<Span className="text-ink-secondary">Shared, nobody has taken it up yet</Span>
|
||||
)}
|
||||
</TaskFactRow>
|
||||
<TaskFactRow label="Where">
|
||||
<Span className="min-w-0 truncate">
|
||||
{task.workspace_id
|
||||
? task.workspace_name || `Workspace ${task.workspace_id}`
|
||||
: `${task.bot_name || "The bot"}'s own list`}
|
||||
</Span>
|
||||
</TaskFactRow>
|
||||
<TaskFactRow label="Added">
|
||||
<Span className="min-w-0 truncate">
|
||||
{[
|
||||
task.created_by_bot_id
|
||||
? `By ${task.created_by_bot_name || `bot ${task.created_by_bot_id}`}`
|
||||
: task.created_by_user_id
|
||||
? task.created_by_user_id == pageProps?.user?.id
|
||||
? "By you"
|
||||
: `By ${task.created_by_user_name || "a user"}`
|
||||
: task.origin == "bot"
|
||||
? "By a bot"
|
||||
: "By hand",
|
||||
task.created_at ? relativeTime({ at: Number(task.created_at) }) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</Span>
|
||||
</TaskFactRow>
|
||||
{task.updated_at ? (
|
||||
<TaskFactRow label="Last moved">
|
||||
<Span>{relativeTime({ at: Number(task.updated_at) })}</Span>
|
||||
</TaskFactRow>
|
||||
) : null}
|
||||
{task.completed_at ? (
|
||||
<TaskFactRow label="Finished">
|
||||
<Span>{relativeTime({ at: Number(task.completed_at) })}</Span>
|
||||
</TaskFactRow>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{task.status == "blocked" && task.blocked_reason ? (
|
||||
<Span className="text-[13px] leading-[18px] text-ink">{`Waiting on you: ${task.blocked_reason}`}</Span>
|
||||
) : null}
|
||||
@@ -209,7 +280,9 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
|
||||
) : null}
|
||||
|
||||
<Stack className="w-full gap-1! items-stretch">
|
||||
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">Files</Span>
|
||||
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">
|
||||
{task.files.length ? `Files (${task.files.length})` : "Files"}
|
||||
</Span>
|
||||
{deliverables.length || references.length ? (
|
||||
<Stack className="w-full gap-0! items-stretch">
|
||||
{[...deliverables, ...references].map((file) => (
|
||||
@@ -253,18 +326,15 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
|
||||
</Stack>
|
||||
|
||||
<Stack className="w-full gap-1! items-stretch">
|
||||
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">Notes</Span>
|
||||
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">
|
||||
{task.notes.length ? `Notes (${task.notes.length})` : "Notes"}
|
||||
</Span>
|
||||
{task.notes.length ? (
|
||||
task.notes.map((entry) => (
|
||||
<Row key={entry.id} className="w-full gap-2! flex-nowrap items-start">
|
||||
<Span className="shrink-0 text-[11px] leading-[18px] text-ink-secondary">
|
||||
{entry.author == "user" ? "You" : "Bot"}
|
||||
</Span>
|
||||
<Span className="grow min-w-0 whitespace-pre-wrap text-[13px] leading-[18px] text-ink">
|
||||
{entry.text}
|
||||
</Span>
|
||||
</Row>
|
||||
))
|
||||
<Stack className="w-full gap-0! items-stretch">
|
||||
{task.notes.map((entry) => (
|
||||
<TaskNoteRow key={entry.id} note={entry} />
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">No notes yet.</Span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* One fact about an opened task: a label, then its value.
|
||||
*/
|
||||
export default function TaskFactRow({ label, children }: Props) {
|
||||
return (
|
||||
<Row className="w-full gap-3! flex-nowrap items-center">
|
||||
<Span className="w-24 shrink-0 text-[12px] leading-[18px] text-ink-secondary">{label}</Span>
|
||||
<Row className="grow min-w-0 gap-2! flex-nowrap items-center text-[13px] leading-[18px] text-ink">
|
||||
{children}
|
||||
</Row>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import Input from "@moduletrace/twui/form/Input";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
@@ -26,15 +27,25 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The one task form: a title, the brief, the status and, while it's
|
||||
* The one task form: a title, the brief (in the code editor), the status and, while it's
|
||||
* blocked, what the user has to do. The workspace's tasks group and the
|
||||
* conversation's tasks modal both add and edit with it.
|
||||
*/
|
||||
export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel }: Props) {
|
||||
export default function TaskForm({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
task,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const [title, setTitle] = useState(task?.title || "");
|
||||
const [description, setDescription] = useState(task?.description || "");
|
||||
const [status, setStatus] = useState<TaskStatus>((task?.status as TaskStatus) || "todo");
|
||||
const [blocked_reason, setBlockedReason] = useState(task?.blocked_reason || "");
|
||||
const [status, setStatus] = useState<TaskStatus>(
|
||||
(task?.status as TaskStatus) || "todo",
|
||||
);
|
||||
const [blocked_reason, setBlockedReason] = useState(
|
||||
task?.blocked_reason || "",
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
@@ -49,21 +60,22 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await fetchApi<Record<string, unknown>, APIResponseObject<TaskDetail>>(
|
||||
"/api/admin/tasks/save-task",
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
task_id: task?.id ? Number(task.id) : undefined,
|
||||
bot_id: task ? undefined : bot_id || undefined,
|
||||
workspace_id: task ? undefined : workspace_id || undefined,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
blocked_reason: status == "blocked" ? blocked_reason || null : null,
|
||||
},
|
||||
const res = await fetchApi<
|
||||
Record<string, unknown>,
|
||||
APIResponseObject<TaskDetail>
|
||||
>("/api/admin/tasks/save-task", {
|
||||
method: "POST",
|
||||
body: {
|
||||
task_id: task?.id ? Number(task.id) : undefined,
|
||||
bot_id: task ? undefined : bot_id || undefined,
|
||||
workspace_id: task ? undefined : workspace_id || undefined,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
blocked_reason:
|
||||
status == "blocked" ? blocked_reason || null : null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
@@ -77,25 +89,28 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
|
||||
|
||||
return (
|
||||
<Form onSubmit={submit} className="gap-3!">
|
||||
<Stack className="gap-3! items-stretch">
|
||||
<Stack className="gap-6! items-stretch">
|
||||
<Input
|
||||
title="Title"
|
||||
label="Title"
|
||||
showLabel
|
||||
value={title}
|
||||
changeHandler={setTitle}
|
||||
placeholder="What needs doing?"
|
||||
maxLength={AppData.TaskTitleMaxLength}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Textarea
|
||||
label="Brief"
|
||||
value={description}
|
||||
changeHandler={setDescription}
|
||||
<CodeEditor
|
||||
mode="markdown"
|
||||
className="h-[500px]"
|
||||
title="Brief"
|
||||
placeholder="What to do, where, and how you'll know it's done."
|
||||
maxLength={AppData.TaskDescriptionMaxLength}
|
||||
rows={5}
|
||||
disabled={loading}
|
||||
content={description}
|
||||
info={`Up to ${AppData.TaskDescriptionMaxLength.toLocaleString()} characters.`}
|
||||
onChange={setDescription}
|
||||
/>
|
||||
<Select
|
||||
title="Status"
|
||||
label="Status"
|
||||
showLabel
|
||||
options={TaskStatuses.map((it) => ({
|
||||
@@ -103,11 +118,15 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
|
||||
value: it.value,
|
||||
default: it.value == status,
|
||||
}))}
|
||||
changeHandler={(value) => setStatus(String(value) as TaskStatus)}
|
||||
changeHandler={(value) =>
|
||||
setStatus(String(value) as TaskStatus)
|
||||
}
|
||||
/>
|
||||
{status == "blocked" ? (
|
||||
<Textarea
|
||||
title="Waiting on the user for"
|
||||
label="Waiting on the user for"
|
||||
showLabel
|
||||
value={blocked_reason}
|
||||
changeHandler={setBlockedReason}
|
||||
placeholder="What the user has to do before this can go on."
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useContext } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
@@ -8,35 +9,41 @@ import { AppContext } from "@/src/pages/__root";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace } from "@/src/dict/bot-dict";
|
||||
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||
import { TaskStatuses } from "@/src/dict/task-dict";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
import taskPlaceLabel from "@/src/utils/task-place-label";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import RowIconButton from "./row-icon-button";
|
||||
import TaskStatusChip from "./task-status-chip";
|
||||
import TaskStatusMoves from "./task-status-moves";
|
||||
|
||||
type Props = {
|
||||
task: TaskWithNames;
|
||||
/**
|
||||
* Opens the task to read its notes and files, and to edit it.
|
||||
* Opens the task in its modal: everything about it, and for a crew
|
||||
* manager its edit, hand-over, files and delete. The title opens it.
|
||||
*/
|
||||
onOpen?: () => void;
|
||||
/**
|
||||
* Moves it to one status; only crew managers get the controls.
|
||||
*/
|
||||
onStatusChange?: (status: string) => void;
|
||||
/**
|
||||
* Deletes it after asking; only crew managers get the button.
|
||||
*/
|
||||
onDelete?: () => void;
|
||||
busy?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* One task: whose it is (a bot's stone), its title and status, where it
|
||||
* is and who has it, what it's blocked on, its latest note and how long ago it
|
||||
* moved.
|
||||
* moved. Its title opens it in a modal.
|
||||
*/
|
||||
export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Props) {
|
||||
export default function TaskListItem({ task, onOpen, onStatusChange, onDelete, busy }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
|
||||
const done = task.status == "done" || task.status == "cancelled";
|
||||
const moves = TaskStatuses.filter((it) => ["todo", "doing", "done"].includes(it.value));
|
||||
|
||||
return (
|
||||
<Row className="w-full gap-3! flex-nowrap items-start px-3 py-3 border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
@@ -49,18 +56,27 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
|
||||
<Stack className="grow min-w-0 gap-0.5! items-stretch">
|
||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||
<Button
|
||||
title={task.title || "Open the task"}
|
||||
title={`Open ${task.title || `task ${task.id}`}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
onClick={onOpen}
|
||||
className={`grow min-w-0 justify-start px-0! h-auto! py-0! text-sm! font-bold! leading-5! truncate text-left hover:bg-transparent! hover:underline ${
|
||||
done ? "text-ink-secondary! line-through" : "text-ink!"
|
||||
}`}
|
||||
className={twMerge(
|
||||
"grow min-w-0 justify-start px-0! h-auto! py-0! text-sm! font-bold! leading-5! truncate text-left hover:bg-transparent! hover:underline",
|
||||
done ? "text-ink-secondary! line-through" : "text-ink!",
|
||||
)}
|
||||
>
|
||||
<Span className="truncate">{task.title || `Task ${task.id}`}</Span>
|
||||
</Button>
|
||||
<TaskStatusChip status={task.status} />
|
||||
{can_manage && onDelete ? (
|
||||
<RowIconButton
|
||||
icon={Trash2}
|
||||
title={`Delete ${task.title || `task ${task.id}`}`}
|
||||
disabled={busy}
|
||||
onClick={onDelete}
|
||||
/>
|
||||
) : null}
|
||||
</Row>
|
||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||
<Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate">
|
||||
@@ -81,25 +97,7 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
|
||||
<Span className="text-xs leading-4 text-ink-secondary line-clamp-2">{task.latest_note}</Span>
|
||||
) : null}
|
||||
{can_manage && onStatusChange ? (
|
||||
<Row className="gap-1! flex-nowrap items-center pt-0.5">
|
||||
{moves
|
||||
.filter((it) => it.value != task.status)
|
||||
.map((it) => (
|
||||
<Button
|
||||
key={it.value}
|
||||
title={`Mark it ${it.title.toLowerCase()}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => onStatusChange(it.value)}
|
||||
className="px-1.5! text-[12px]"
|
||||
>
|
||||
{it.title}
|
||||
</Button>
|
||||
))}
|
||||
</Row>
|
||||
<TaskStatusMoves status={task.status} disabled={busy} onMove={onStatusChange} className="pt-0.5" />
|
||||
) : null}
|
||||
</Stack>
|
||||
</Row>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { TaskNote } from "@/src/types";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
|
||||
type Props = {
|
||||
note: TaskNote;
|
||||
};
|
||||
|
||||
/**
|
||||
* One note on an opened task: who wrote it (the user, or the bot by name),
|
||||
* how long ago, and what it says.
|
||||
*/
|
||||
export default function TaskNoteRow({ note }: Props) {
|
||||
const author = note.author == "user" ? "You" : note.bot_name || "Bot";
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-0.5! items-stretch py-1.5 border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||
<Span className="text-[12px] font-bold leading-4 text-ink">{author}</Span>
|
||||
{note.created_at ? (
|
||||
<Span className="text-[11px] leading-4 text-ink-secondary">
|
||||
{relativeTime({ at: Number(note.created_at) })}
|
||||
</Span>
|
||||
) : null}
|
||||
</Row>
|
||||
<Span className="whitespace-pre-wrap text-[13px] leading-[18px] text-ink">{note.text}</Span>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Tag from "@moduletrace/twui/elements/Tag";
|
||||
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
|
||||
|
||||
@@ -14,15 +15,20 @@ export default function TaskStatusChip({ status = "todo" }: Props) {
|
||||
const value = (status || "todo") as TaskStatus;
|
||||
const title = TaskStatuses.find((it) => it.value == value)?.title || "To do";
|
||||
|
||||
const tone =
|
||||
value == "blocked"
|
||||
? "text-ink border-ink"
|
||||
: value == "doing"
|
||||
? "text-ink border-hairline-strong"
|
||||
: "text-ink-secondary border-hairline";
|
||||
|
||||
/**
|
||||
* A `Tag` is a filled primary button by default (ink in light mode), so
|
||||
* the chip clears its fill and sets its own text: only the outline speaks.
|
||||
*/
|
||||
return (
|
||||
<Tag className={`shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid ${tone}`}>
|
||||
<Tag
|
||||
className={twMerge(
|
||||
"shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid",
|
||||
"bg-transparent! dark:bg-transparent! hover:bg-transparent!",
|
||||
value == "blocked" && "text-ink! dark:text-ink! border-ink",
|
||||
value == "doing" && "text-ink! dark:text-ink! border-hairline-strong",
|
||||
value != "blocked" && value != "doing" && "text-ink-secondary! dark:text-ink-secondary! border-hairline",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onMove: (status: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves a task to one status: To do, Doing or Done.
|
||||
*/
|
||||
export default function TaskStatusMoveButton({ title, value, disabled, onMove }: Props) {
|
||||
return (
|
||||
<Button
|
||||
title={`Mark it ${title.toLowerCase()}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
onClick={() => onMove(value)}
|
||||
className="px-1.5! text-[12px]"
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { TaskStatuses } from "@/src/dict/task-dict";
|
||||
import TaskStatusMoveButton from "./task-status-move-button";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The task's status now; its own button is left out.
|
||||
*/
|
||||
status?: string | null;
|
||||
disabled?: boolean;
|
||||
onMove: (status: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const MoveStatuses = TaskStatuses.filter((it) => ["todo", "doing", "done"].includes(it.value));
|
||||
|
||||
/**
|
||||
* The buttons that move a task along (To do, Doing, Done), the one it's
|
||||
* at left out. Shown to crew managers on a list row and in the task modal.
|
||||
*/
|
||||
export default function TaskStatusMoves({ status, disabled, onMove, className }: Props) {
|
||||
return (
|
||||
<Row className={twMerge("gap-1! flex-nowrap items-center", className)}>
|
||||
{MoveStatuses.filter((it) => it.value != status).map((it) => (
|
||||
<TaskStatusMoveButton
|
||||
key={it.value}
|
||||
title={it.title}
|
||||
value={it.value}
|
||||
disabled={disabled}
|
||||
onMove={onMove}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_TASK_NOTES } from "@/db/types/db";
|
||||
import type { TaskDetail } from "@/src/types";
|
||||
import type { TaskDetail, TaskNote } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import botFaceSql from "@/src/utils/bot-face-sql";
|
||||
import taskScopesSql from "@/src/utils/task-access/task-scopes-sql";
|
||||
@@ -16,7 +15,8 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* One task with its notes (oldest first) and files, only if the user
|
||||
* One task with its notes (oldest first, each with the bot that wrote it),
|
||||
* its files and who added it, only if the user
|
||||
* reaches the bot or workspace it belongs to (`taskScopesSql`, the same
|
||||
* condition the lists use).
|
||||
*/
|
||||
@@ -26,10 +26,14 @@ export default async function grabTask({ user_id, task_id }: Params): Promise<Ta
|
||||
|
||||
const row_res = await BunSQLite.sql<TaskDetail>({
|
||||
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.color AS bot_color, b.stone AS bot_stone,
|
||||
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name
|
||||
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name,
|
||||
cb.name AS created_by_bot_name,
|
||||
NULLIF(TRIM(COALESCE(cu.first_name, '') || ' ' || COALESCE(cu.last_name, '')), '') AS created_by_user_name
|
||||
FROM tasks t
|
||||
LEFT JOIN bots b ON b.id = t.bot_id
|
||||
LEFT JOIN workspaces w ON w.id = t.workspace_id
|
||||
LEFT JOIN bots cb ON cb.id = t.created_by_bot_id
|
||||
LEFT JOIN users cu ON cu.id = t.created_by_user_id
|
||||
WHERE t.id = ? AND ${access.sql}
|
||||
LIMIT 1`,
|
||||
values: [...face.values, Number(task_id), ...access.values],
|
||||
@@ -42,8 +46,10 @@ export default async function grabTask({ user_id, task_id }: Params): Promise<Ta
|
||||
}
|
||||
|
||||
const [notes_res, files] = await Promise.all([
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_NOTES>({
|
||||
sql: `SELECT * FROM task_notes WHERE task_id = ? ORDER BY id ASC`,
|
||||
BunSQLite.sql<TaskNote>({
|
||||
sql: `SELECT n.*, nb.name AS bot_name FROM task_notes n
|
||||
LEFT JOIN bots nb ON nb.id = n.bot_id
|
||||
WHERE n.task_id = ? ORDER BY n.id ASC`,
|
||||
values: [Number(task_id)],
|
||||
}),
|
||||
grabTaskMedia({ task_id }),
|
||||
|
||||
@@ -10,7 +10,7 @@ import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import type { BotWithLastMessage, TaskDetail, TaskWithNames } from "@/src/types";
|
||||
import TaskDetailPanel from "@/src/components/general/task-detail-panel";
|
||||
import TaskDetailModal from "@/src/components/general/task-detail-modal";
|
||||
import TaskForm from "@/src/components/general/task-form";
|
||||
import TaskListItem from "@/src/components/general/task-list-item";
|
||||
|
||||
@@ -33,13 +33,14 @@ type Props = {
|
||||
* The tasks this conversation works on: a bot's own list, or in a workspace
|
||||
* the ones it has there plus the shared ones nobody has yet. Open it from the header menu; the list loads
|
||||
* then, so the page's props stay small. A crew manager adds, moves, edits
|
||||
* and deletes; a task opens to show its notes and files.
|
||||
* and deletes; a card opens the task in a modal of its own with everything
|
||||
* about it.
|
||||
*/
|
||||
export default function BotTasksModal({ bot, workspace, menu, on_open }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [adding, set_adding] = useState(false);
|
||||
const [open_task, set_open_task] = useState<TaskDetail | null>(null);
|
||||
const [open_task, set_open_task] = useState<TaskWithNames | null>(null);
|
||||
const [busy, set_busy] = useState(false);
|
||||
const [status, set_status] = useState<string>();
|
||||
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
|
||||
@@ -80,24 +81,27 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
||||
replace({ task: res.singleRes });
|
||||
};
|
||||
|
||||
const show = async ({ task }: { task: TaskWithNames }) => {
|
||||
const remove = async ({ task }: { task: TaskWithNames }) => {
|
||||
if (busy) return;
|
||||
if (!window.confirm(`Delete "${task.title || `task ${task.id}`}"? Its notes and links go with it.`)) return;
|
||||
|
||||
set_busy(true);
|
||||
set_status(undefined);
|
||||
|
||||
/**
|
||||
* The list already has the task; the detail endpoint serves its
|
||||
* notes and files.
|
||||
*/
|
||||
const detail = await fetchApi<{}, APIResponseObject<TaskDetail>>(
|
||||
`/api/admin/tasks/get-task?task_id=${task.id}`,
|
||||
{ method: "GET" },
|
||||
).catch(() => null);
|
||||
const res = await fetchApi<Record<string, unknown>, APIResponseObject>(
|
||||
"/api/admin/tasks/delete-task",
|
||||
{ method: "POST", body: { task_id: Number(task.id) } },
|
||||
);
|
||||
|
||||
if (!detail?.success || !detail.singleRes) {
|
||||
set_status(detail?.msg || "Couldn't open the task.");
|
||||
set_busy(false);
|
||||
|
||||
if (!res.success) {
|
||||
set_status(res.msg || "That didn't work.");
|
||||
return;
|
||||
}
|
||||
|
||||
set_open_task(detail.singleRes);
|
||||
setTasks((prev) => prev.filter((item) => item.id != task.id));
|
||||
set_status("Task deleted.");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -136,72 +140,30 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
||||
<Stack className="w-full gap-1! items-stretch">
|
||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">
|
||||
{open_task ? open_task.title || "Task" : title}
|
||||
{title}
|
||||
</H2>
|
||||
{open_task ? (
|
||||
<Button
|
||||
title="Back to the list"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => set_open_task(null)}
|
||||
className="px-2! text-[12px]"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
) : can_manage ? (
|
||||
{can_manage ? (
|
||||
<Button
|
||||
title="Add a task"
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
set_adding(true);
|
||||
set_open_task(null);
|
||||
}}
|
||||
onClick={() => set_adding(true)}
|
||||
>
|
||||
Add task
|
||||
</Button>
|
||||
) : null}
|
||||
</Row>
|
||||
{!open_task ? (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{workspace?.id
|
||||
? `What ${name} has in ${workspace.name || "this workspace"}, and the shared tasks there nobody has taken up yet. The workspace shows every bot's.`
|
||||
: `What ${name} is working through, and what it's waiting on.`}
|
||||
</Span>
|
||||
) : null}
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{workspace?.id
|
||||
? `What ${name} has in ${workspace.name || "this workspace"}, and the shared tasks there nobody has taken up yet. The workspace shows every bot's.`
|
||||
: `What ${name} is working through, and what it's waiting on.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
|
||||
<Stack className="w-full mt-4 gap-0! items-stretch overflow-y-auto max-h-[55vh]">
|
||||
{open_task ? (
|
||||
<TaskDetailPanel
|
||||
task={open_task}
|
||||
can_manage={can_manage}
|
||||
onChanged={(saved, msg) => {
|
||||
set_status(msg);
|
||||
|
||||
/**
|
||||
* Handed to another bot, it's that bot's now.
|
||||
*/
|
||||
if (saved.bot_id && saved.bot_id != bot.id) {
|
||||
setTasks((prev) => prev.filter((item) => item.id != saved.id));
|
||||
set_open_task(null);
|
||||
return;
|
||||
}
|
||||
|
||||
set_open_task(saved);
|
||||
replace({ task: saved });
|
||||
}}
|
||||
onDeleted={() => {
|
||||
setTasks((prev) => prev.filter((item) => item.id != open_task.id));
|
||||
set_open_task(null);
|
||||
set_status("Task deleted.");
|
||||
}}
|
||||
/>
|
||||
) : adding ? (
|
||||
{adding ? (
|
||||
<TaskForm
|
||||
bot_id={Number(bot.id)}
|
||||
workspace_id={workspace?.id ? Number(workspace.id) : null}
|
||||
@@ -218,8 +180,9 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
||||
key={task.id}
|
||||
task={task}
|
||||
busy={busy}
|
||||
onOpen={() => show({ task }).catch(() => {})}
|
||||
onOpen={() => set_open_task(task)}
|
||||
onStatusChange={(next) => move({ task, next })}
|
||||
onDelete={() => remove({ task }).catch(() => {})}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
@@ -234,6 +197,31 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
||||
{status}
|
||||
</Span>
|
||||
) : null}
|
||||
|
||||
<TaskDetailModal
|
||||
task={open_task}
|
||||
can_manage={can_manage}
|
||||
onClose={() => set_open_task(null)}
|
||||
onChanged={({ task: saved, msg }) => {
|
||||
set_status(msg);
|
||||
|
||||
/**
|
||||
* Handed to another bot, it's that bot's now.
|
||||
*/
|
||||
if (saved.bot_id && saved.bot_id != bot.id) {
|
||||
setTasks((prev) => prev.filter((item) => item.id != saved.id));
|
||||
set_open_task(null);
|
||||
return;
|
||||
}
|
||||
|
||||
replace({ task: saved });
|
||||
}}
|
||||
onDeleted={({ task_id }) => {
|
||||
setTasks((prev) => prev.filter((item) => item.id != task_id));
|
||||
set_open_task(null);
|
||||
set_status("Task deleted.");
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { TaskDetail, TaskWithNames } from "@/src/types";
|
||||
import TaskDetailPanel from "@/src/components/general/task-detail-panel";
|
||||
import TaskDetailModal from "@/src/components/general/task-detail-modal";
|
||||
import TaskForm from "@/src/components/general/task-form";
|
||||
import TaskListItem from "@/src/components/general/task-list-item";
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
@@ -35,7 +35,7 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<{ error?: boolean; success?: boolean; msg: string }>();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [open_task, setOpenTask] = useState<TaskDetail | null>(null);
|
||||
const [open_task, setOpenTask] = useState<TaskWithNames | null>(null);
|
||||
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
|
||||
|
||||
const replace = ({ task }: { task: TaskWithNames }) =>
|
||||
@@ -62,20 +62,27 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
|
||||
replace({ task: res.singleRes });
|
||||
};
|
||||
|
||||
const show = async ({ task }: { task: TaskWithNames }) => {
|
||||
const remove = async ({ task }: { task: TaskWithNames }) => {
|
||||
if (busy) return;
|
||||
if (!window.confirm(`Delete "${task.title || `task ${task.id}`}"? Its notes and links go with it.`)) return;
|
||||
|
||||
setBusy(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await fetchApi<{}, APIResponseObject<TaskDetail>>(
|
||||
`/api/admin/tasks/get-task?task_id=${task.id}`,
|
||||
{ method: "GET" },
|
||||
).catch(() => null);
|
||||
const res = await fetchApi<Record<string, unknown>, APIResponseObject>(
|
||||
"/api/admin/tasks/delete-task",
|
||||
{ method: "POST", body: { task_id: Number(task.id) } },
|
||||
);
|
||||
|
||||
if (!res?.success || !res.singleRes) {
|
||||
setStatus({ error: true, msg: res?.msg || "Couldn't open the task." });
|
||||
setBusy(false);
|
||||
|
||||
if (!res.success) {
|
||||
setStatus({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
setOpenTask(res.singleRes);
|
||||
setItems((prev) => prev.filter((item) => item.id != task.id));
|
||||
setStatus({ success: true, msg: "Task deleted." });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -85,70 +92,48 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
|
||||
description="Every task in this workspace, whichever bot has it. A bot sees its own here and the shared ones nobody has taken up yet."
|
||||
>
|
||||
<Stack className="w-full gap-2! items-stretch">
|
||||
{open_task ? (
|
||||
<Stack className="w-full gap-3! items-stretch">
|
||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||
<Span className="grow min-w-0 text-sm font-bold leading-5 text-ink truncate">
|
||||
{open_task.title || "Task"}
|
||||
</Span>
|
||||
<Button
|
||||
title="Back to the list"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => setOpenTask(null)}
|
||||
className="px-2! text-[12px]"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</Row>
|
||||
<TaskDetailPanel
|
||||
task={open_task}
|
||||
can_manage={can_manage}
|
||||
onChanged={(saved, msg) => {
|
||||
setOpenTask(saved);
|
||||
replace({ task: saved });
|
||||
setStatus(msg ? { success: true, msg } : undefined);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
setItems((prev) => prev.filter((item) => item.id != open_task.id));
|
||||
setOpenTask(null);
|
||||
setStatus({ success: true, msg: "Task deleted." });
|
||||
}}
|
||||
{can_manage ? (
|
||||
<Row className="justify-end">
|
||||
<Button
|
||||
title="Add a task"
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
Add task
|
||||
</Button>
|
||||
</Row>
|
||||
) : null}
|
||||
<SettingsList is_empty={!items.length} empty="No tasks in this workspace yet.">
|
||||
{items.map((task) => (
|
||||
<TaskListItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
busy={busy}
|
||||
onOpen={() => setOpenTask(task)}
|
||||
onStatusChange={(next) => move({ task, next })}
|
||||
onDelete={() => remove({ task }).catch(() => {})}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
{can_manage ? (
|
||||
<Row className="justify-end">
|
||||
<Button
|
||||
title="Add a task"
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
Add task
|
||||
</Button>
|
||||
</Row>
|
||||
) : null}
|
||||
<SettingsList is_empty={!items.length} empty="No tasks in this workspace yet.">
|
||||
{items.map((task) => (
|
||||
<TaskListItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
busy={busy}
|
||||
onOpen={() => show({ task }).catch(() => {})}
|
||||
onStatusChange={(next) => move({ task, next })}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
</>
|
||||
)}
|
||||
))}
|
||||
</SettingsList>
|
||||
<SettingsStatus status={status} />
|
||||
</Stack>
|
||||
<TaskDetailModal
|
||||
task={open_task}
|
||||
can_manage={can_manage}
|
||||
onClose={() => setOpenTask(null)}
|
||||
onChanged={({ task, msg }) => {
|
||||
replace({ task });
|
||||
setStatus(msg ? { success: true, msg } : undefined);
|
||||
}}
|
||||
onDeleted={({ task_id }) => {
|
||||
setItems((prev) => prev.filter((item) => item.id != task_id));
|
||||
setOpenTask(null);
|
||||
setStatus({ success: true, msg: "Task deleted." });
|
||||
}}
|
||||
/>
|
||||
<AddModalShell
|
||||
open={adding}
|
||||
setOpen={setAdding}
|
||||
|
||||
+12
-3
@@ -236,11 +236,20 @@ export type TaskFile = BUN_SQLITE_COBBLE_TASK_MEDIA & {
|
||||
};
|
||||
|
||||
/**
|
||||
* A task with its notes and files: what the task modal and the workspace's
|
||||
* tasks group render.
|
||||
* A note on a task, with the name of the bot that wrote it.
|
||||
*/
|
||||
export type TaskNote = BUN_SQLITE_COBBLE_TASK_NOTES & {
|
||||
bot_name?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A task with its notes and files and who added it: what the task modal
|
||||
* renders.
|
||||
*/
|
||||
export type TaskDetail = TaskWithNames & {
|
||||
notes: BUN_SQLITE_COBBLE_TASK_NOTES[];
|
||||
created_by_bot_name?: string | null;
|
||||
created_by_user_name?: string | null;
|
||||
notes: TaskNote[];
|
||||
files: TaskFile[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user