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"; import Stack from "@moduletrace/twui/layout/Stack"; import Textarea from "@moduletrace/twui/form/Textarea"; import { OpenTaskStatuses } from "@/src/dict/task-dict"; import type { TaskDetail, TaskFile } from "@/src/types"; 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"; type Props = { task: TaskDetail; /** * A crew manager can change it, file onto it and delete it. */ can_manage?: boolean; onChanged: (task: TaskDetail, msg?: string) => void; onDeleted: () => void; }; /** * 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(""); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(); const file_input = useRef(null); const run = async (params: { path: string; body: Record; done: string }) => { if (busy) return; setBusy(true); setStatus(undefined); const res = await fetchApi, APIResponseObject>( `/api/admin/${params.path}`, { method: "POST", body: params.body }, ); setBusy(false); if (!res.success || !res.singleRes) { setStatus(res.msg || "That didn't work."); return; } setStatus(params.done); onChanged(res.singleRes, params.done); }; const addNote = async () => { if (!note.trim()) return; await run({ path: "tasks/add-note", body: { task_id: Number(task.id), text: note }, done: "Note added." }); setNote(""); }; const attach = async (file: File) => { if (busy) return; const data_url = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = () => reject(reader.error); reader.readAsDataURL(file); }).catch(() => ""); if (!data_url) { setStatus(`${file.name} couldn't be read.`); return; } await run({ path: "tasks/attach-file", body: { task_id: Number(task.id), data_url, name: file.name, kind: "deliverable" }, done: `${file.name} added as a deliverable.`, }); }; const detach = async ({ file }: { file: TaskFile }) => { if (!window.confirm(`Unlink ${file.name || "this file"} from the task?`)) return; await run({ path: "tasks/detach-file", body: { task_id: Number(task.id), task_media_id: Number(file.id) }, done: "File unlinked.", }); }; if (editing) { return ( setEditing(false)} onSaved={(saved, msg) => { setEditing(false); onChanged(saved, msg); }} /> ); } if (handing_over) { return ( what={`"${task.title}"`} targets_path="tasks/hand-over-targets" submit_path="tasks/hand-over-task" body={{ task_id: Number(task.id) }} with_note onCancel={() => setHandingOver(false)} onHandedOver={({ saved, msg }) => { setHandingOver(false); onChanged(saved, msg); }} /> ); } const deliverables = task.files.filter((file) => file.kind != "reference"); const references = task.files.filter((file) => file.kind == "reference"); return ( {can_manage ? ( run({ path: "tasks/save-task", body: { task_id: Number(task.id), status: next }, done: "Status changed.", }) } /> ) : null} {can_manage ? ( {(OpenTaskStatuses as readonly string[]).includes(task.status || "") ? ( ) : null} ) : null} {task.bot_id ? ( <> {task.bot_name || `Bot ${task.bot_id}`} ) : ( No bot )} {task.workspace_id ? task.workspace_name || `Workspace ${task.workspace_id}` : `${task.bot_name || "The bot"}'s own list`} {[ 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(" ยท ")} {task.updated_at ? ( {relativeTime({ at: Number(task.updated_at) })} ) : null} {task.completed_at ? ( {relativeTime({ at: Number(task.completed_at) })} ) : null} {task.status == "blocked" && task.blocked_reason ? ( {`Waiting on you: ${task.blocked_reason}`} ) : null} {task.description ? ( {task.description} ) : null} {task.files.length ? `Files (${task.files.length})` : "Files"} {deliverables.length || references.length ? ( {[...deliverables, ...references].map((file) => ( detach({ file }) : undefined} /> ))} ) : ( Nothing filed yet. )} {can_manage ? ( { const file = event.target.files?.[0]; if (file) attach(file).catch(() => {}); event.target.value = ""; }} /> ) : null} {task.notes.length ? `Notes (${task.notes.length})` : "Notes"} {task.notes.length ? ( {task.notes.map((entry) => ( ))} ) : ( No notes yet. )}