180 lines
6.0 KiB
TypeScript
180 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import { Badge } from "@kandev/ui/badge";
|
|
import { IconAlertTriangle, IconFileText, IconUsersGroup } from "@tabler/icons-react";
|
|
import type { TaskContextDTO, TaskRefDTO } from "@/lib/api/domains/office-task-context-api";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
type Props = {
|
|
/**
|
|
* The task context envelope from GET /api/v1/tasks/:id/context.
|
|
* Pass null when the backend's HandoffService is not configured or the
|
|
* fetch failed; the panel renders nothing in that case so the page
|
|
* falls back to its pre-handoffs layout.
|
|
*/
|
|
context: TaskContextDTO | null;
|
|
};
|
|
|
|
/**
|
|
* Office task-handoffs phase 8.2 — task detail panel.
|
|
*
|
|
* Surfaces the cross-task relations + document references + workspace
|
|
* group state introduced by the handoffs feature. Document bodies are
|
|
* never rendered here; the panel only shows references that the user
|
|
* can click through to fetch full content.
|
|
*/
|
|
export function TaskDetailContextPanel({ context }: Props) {
|
|
if (!context) return null;
|
|
const showRelations =
|
|
!!context.parent ||
|
|
context.children.length > 0 ||
|
|
context.siblings.length > 0 ||
|
|
context.blockers.length > 0 ||
|
|
context.blocked_by.length > 0;
|
|
const showDocs = context.available_documents.length > 0;
|
|
const showWorkspace = !!context.workspace_group;
|
|
if (!showRelations && !showDocs && !showWorkspace) return null;
|
|
|
|
return (
|
|
<section
|
|
data-testid="task-detail-context-panel"
|
|
className="space-y-4 rounded-md border border-border bg-card p-4 text-sm"
|
|
>
|
|
<WorkspaceStatusBanner context={context} />
|
|
{showWorkspace && context.workspace_group && (
|
|
<WorkspaceSection group={context.workspace_group} mode={context.workspace_mode} />
|
|
)}
|
|
{showRelations && <RelationsSection context={context} />}
|
|
{showDocs && <DocumentsSection docs={context.available_documents} />}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function WorkspaceStatusBanner({ context }: { context: TaskContextDTO }) {
|
|
const { t } = useTranslation();
|
|
if (context.workspace_status !== "requires_configuration") return null;
|
|
return (
|
|
<div
|
|
role="alert"
|
|
data-testid="task-detail-context-panel-requires-config"
|
|
className="flex items-start gap-2 rounded-md border border-amber-300/60 bg-amber-50 p-3 text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/40 dark:text-amber-200"
|
|
>
|
|
<IconAlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<div className="space-y-1">
|
|
<div className="text-xs font-medium">{t("task:workspaceRequiresConfiguration")}</div>
|
|
<div className="text-[11px]">{t("task:restoringThisTaskSMaterializedWorkspace")}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WorkspaceSection({
|
|
group,
|
|
mode,
|
|
}: {
|
|
group: NonNullable<TaskContextDTO["workspace_group"]>;
|
|
mode?: TaskContextDTO["workspace_mode"];
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const memberCount = group.members.length;
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<IconUsersGroup className="h-4 w-4 text-muted-foreground" />
|
|
<Badge variant="outline" className="font-normal">
|
|
{t("task:sharedWorkspaceMembers", { count: memberCount })}
|
|
</Badge>
|
|
{mode && (
|
|
<span className="text-[11px] text-muted-foreground" data-testid="workspace-mode-label">
|
|
{t("task:workspaceModeLabel", { mode })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{group.materialized_path && (
|
|
<div className="font-mono text-[11px] text-muted-foreground">{group.materialized_path}</div>
|
|
)}
|
|
{memberCount > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{group.members.map((m) => (
|
|
<Badge key={m.id} variant="secondary" className="font-normal">
|
|
{refLabel(m)}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RelationsSection({ context }: { context: TaskContextDTO }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-medium text-muted-foreground">{t("task:relatedTasks")}</div>
|
|
<div className="space-y-1 text-xs">
|
|
{context.parent && <Relation label={t("task:parent")} task={context.parent} />}
|
|
{context.siblings.map((related) => (
|
|
<Relation key={related.id} label={t("task:sibling")} task={related} />
|
|
))}
|
|
{context.blockers.map((related) => (
|
|
<Relation key={related.id} label={t("task:blockedBy")} task={related} variant="warning" />
|
|
))}
|
|
{context.blocked_by.map((related) => (
|
|
<Relation key={related.id} label={t("task:blocks")} task={related} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Relation({
|
|
label,
|
|
task,
|
|
variant,
|
|
}: {
|
|
label: string;
|
|
task: TaskRefDTO;
|
|
variant?: "warning";
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant={variant === "warning" ? "destructive" : "outline"} className="font-normal">
|
|
{label}
|
|
</Badge>
|
|
<span data-testid={`relation-${task.id}`} className="text-foreground">
|
|
{refLabel(task)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DocumentsSection({ docs }: { docs: TaskContextDTO["available_documents"] }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
<IconFileText className="h-4 w-4" /> {t("task:documentsAvailable")}
|
|
</div>
|
|
<ul className="space-y-1 text-xs">
|
|
{docs.map((d) => (
|
|
<li
|
|
key={`${d.task.id}:${d.key}`}
|
|
data-testid={`document-ref-${d.task.id}-${d.key}`}
|
|
className="flex items-baseline gap-2"
|
|
>
|
|
<Badge variant="outline" className="font-normal">
|
|
{refLabel(d.task)}
|
|
</Badge>
|
|
<span className="font-mono text-foreground">{d.key}</span>
|
|
{d.title && <span className="text-muted-foreground">- {d.title}</span>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function refLabel(t: TaskRefDTO): string {
|
|
return t.identifier ? `${t.identifier} ${t.title}` : t.title;
|
|
}
|