mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* feat(panel): Workstation page — Products and Projects as tabs behind one sidebar entry * docs(map): workstation page, extracted views, and the local-filter scroll trade-off --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
595 lines
20 KiB
TypeScript
595 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect } from "react";
|
|
import { Task, Complexity, TaskNature } from "@/types";
|
|
import { useUpdateTask } from "@/hooks/use-tasks";
|
|
import { useProject } from "@/hooks/use-projects";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
User,
|
|
Calendar,
|
|
Clock,
|
|
Target,
|
|
AlertTriangle,
|
|
GitBranch,
|
|
FolderGit2,
|
|
Wrench,
|
|
Briefcase,
|
|
Hash,
|
|
GitPullRequest,
|
|
ExternalLink,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { getAgentDisplayName, resolveToSlug } from "@/lib/agent-utils";
|
|
import { branchUrl } from "@/lib/repo-url";
|
|
import { formatAbsoluteTimestamp } from "@/lib/utils";
|
|
import { CopyButton } from "@/components/ui/copy-button";
|
|
import { HelpTip } from "@/components/ui/help-tip";
|
|
import { TaskTypeBadge } from "../task-type-badge";
|
|
import { DocsStatusBadge } from "../docs-status-badge";
|
|
import Link from "next/link";
|
|
|
|
// Priority colors
|
|
const priorityColors: Record<number, string> = {
|
|
0: "bg-red-200 text-red-700 dark:bg-red-900 dark:text-red-200",
|
|
1: "bg-orange-200 text-orange-700 dark:bg-orange-900 dark:text-orange-200",
|
|
2: "bg-blue-200 text-blue-700 dark:bg-blue-900 dark:text-blue-200",
|
|
3: "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200",
|
|
};
|
|
|
|
const priorityLabels: Record<number, string> = {
|
|
0: "P0 - Highest",
|
|
1: "P1 - High",
|
|
2: "P2 - Medium",
|
|
3: "P3 - Low",
|
|
};
|
|
|
|
const complexityLabels: Record<string, string> = {
|
|
[Complexity.LOW]: "Low",
|
|
[Complexity.MEDIUM]: "Medium",
|
|
[Complexity.HIGH]: "High",
|
|
};
|
|
|
|
interface TaskMetadataProps {
|
|
task: Task;
|
|
}
|
|
|
|
function formatDate(date: string | null): string {
|
|
if (!date) return "-";
|
|
return new Date(date).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
function formatRelativeTime(date: string | null): string {
|
|
if (!date) return "-";
|
|
const now = new Date();
|
|
const d = new Date(date);
|
|
const diffMs = now.getTime() - d.getTime();
|
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
|
|
if (diffHours < 1) return "Just now";
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
return formatDate(date);
|
|
}
|
|
|
|
function formatDateForInput(date: string | null): string {
|
|
if (!date) return "";
|
|
const d = new Date(date);
|
|
return d.toISOString().slice(0, 16); // Format: YYYY-MM-DDTHH:mm
|
|
}
|
|
|
|
export function TaskMetadata({ task }: TaskMetadataProps) {
|
|
const updateTask = useUpdateTask();
|
|
const { data: project } = useProject(task.project_id ?? "");
|
|
const branchHref = branchUrl(project?.git_url, task.branch_name);
|
|
|
|
// Editing states - use local state only while editing
|
|
const [editingAssigned, setEditingAssigned] = useState(false);
|
|
const [localAssignedValue, setLocalAssignedValue] = useState("");
|
|
const assignedInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const [editingTargetDate, setEditingTargetDate] = useState(false);
|
|
const [localTargetDateValue, setLocalTargetDateValue] = useState("");
|
|
const targetDateInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Display prop value when not editing, local value when editing
|
|
const assignedValue = editingAssigned
|
|
? localAssignedValue
|
|
: (task.assigned_to ?? "");
|
|
const setAssignedValue = (value: string) => setLocalAssignedValue(value);
|
|
|
|
const targetDateValue = editingTargetDate
|
|
? localTargetDateValue
|
|
: formatDateForInput(task.target_date);
|
|
const setTargetDateValue = (value: string) => setLocalTargetDateValue(value);
|
|
|
|
// Start editing - copy current prop value to local state (resolved to slug)
|
|
const startEditingAssigned = () => {
|
|
setLocalAssignedValue(resolveToSlug(task.assigned_to));
|
|
setEditingAssigned(true);
|
|
};
|
|
|
|
const startEditingTargetDate = () => {
|
|
setLocalTargetDateValue(formatDateForInput(task.target_date));
|
|
setEditingTargetDate(true);
|
|
};
|
|
|
|
// Focus inputs when editing starts
|
|
useEffect(() => {
|
|
if (editingAssigned && assignedInputRef.current) {
|
|
assignedInputRef.current.focus();
|
|
assignedInputRef.current.select();
|
|
}
|
|
}, [editingAssigned]);
|
|
|
|
useEffect(() => {
|
|
if (editingTargetDate && targetDateInputRef.current) {
|
|
targetDateInputRef.current.focus();
|
|
}
|
|
}, [editingTargetDate]);
|
|
|
|
const handlePriorityChange = async (value: string) => {
|
|
try {
|
|
await updateTask.mutateAsync({
|
|
taskId: task.id,
|
|
updates: { priority: parseInt(value, 10) },
|
|
});
|
|
} catch {
|
|
toast.error("Failed to update priority");
|
|
}
|
|
};
|
|
|
|
const handleComplexityChange = async (value: string) => {
|
|
try {
|
|
await updateTask.mutateAsync({
|
|
taskId: task.id,
|
|
updates: { estimated_complexity: value as Complexity },
|
|
});
|
|
} catch {
|
|
toast.error("Failed to update complexity");
|
|
}
|
|
};
|
|
|
|
const handleAssignedSave = async () => {
|
|
const newValue = assignedValue.trim() || null;
|
|
if (newValue === task.assigned_to) {
|
|
setEditingAssigned(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await updateTask.mutateAsync({
|
|
taskId: task.id,
|
|
updates: { assigned_to: newValue },
|
|
});
|
|
setEditingAssigned(false);
|
|
} catch {
|
|
toast.error("Failed to update assignment");
|
|
setAssignedValue(resolveToSlug(task.assigned_to));
|
|
}
|
|
};
|
|
|
|
const handleAssignedKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter") {
|
|
handleAssignedSave();
|
|
} else if (e.key === "Escape") {
|
|
setAssignedValue(resolveToSlug(task.assigned_to));
|
|
setEditingAssigned(false);
|
|
}
|
|
};
|
|
|
|
const handleTargetDateSave = async () => {
|
|
const newValue = targetDateValue
|
|
? new Date(targetDateValue).toISOString()
|
|
: null;
|
|
if (newValue === task.target_date) {
|
|
setEditingTargetDate(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await updateTask.mutateAsync({
|
|
taskId: task.id,
|
|
updates: { target_date: newValue },
|
|
});
|
|
setEditingTargetDate(false);
|
|
} catch {
|
|
toast.error("Failed to update target date");
|
|
setTargetDateValue(formatDateForInput(task.target_date));
|
|
}
|
|
};
|
|
|
|
const handleTargetDateKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter") {
|
|
handleTargetDateSave();
|
|
} else if (e.key === "Escape") {
|
|
setTargetDateValue(formatDateForInput(task.target_date));
|
|
setEditingTargetDate(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
|
{/* Priority - Editable Select */}
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
Priority
|
|
</div>
|
|
<HelpTip label="How urgently this should be worked — P0 is highest, P3 is lowest">
|
|
<Select
|
|
value={task.priority.toString()}
|
|
onValueChange={handlePriorityChange}
|
|
disabled={updateTask.isPending}
|
|
>
|
|
<SelectTrigger
|
|
className={`w-full h-8 text-sm border-0 ${priorityColors[task.priority] ?? priorityColors[2]}`}
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(priorityLabels).map(([value, label]) => (
|
|
<SelectItem key={value} value={value}>
|
|
<span
|
|
className={`px-2 py-0.5 rounded ${priorityColors[parseInt(value)]}`}
|
|
>
|
|
{label}
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</HelpTip>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Complexity - Editable Select */}
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
<Target className="h-4 w-4" />
|
|
Complexity
|
|
</div>
|
|
<HelpTip label="A manual estimate of implementation effort; not derived from the plan or ACs">
|
|
<Select
|
|
value={task.estimated_complexity}
|
|
onValueChange={handleComplexityChange}
|
|
disabled={updateTask.isPending}
|
|
>
|
|
<SelectTrigger className="w-full h-8 text-sm border-0 bg-transparent hover:bg-muted/50">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.values(Complexity).map((complexity) => (
|
|
<SelectItem key={complexity} value={complexity}>
|
|
{complexityLabels[complexity]}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</HelpTip>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Assigned To - Editable Input */}
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
<User className="h-4 w-4" />
|
|
Assigned To
|
|
</div>
|
|
{editingAssigned ? (
|
|
<Input
|
|
ref={assignedInputRef}
|
|
value={assignedValue}
|
|
onChange={(e) => setAssignedValue(e.target.value)}
|
|
onKeyDown={handleAssignedKeyDown}
|
|
onBlur={handleAssignedSave}
|
|
placeholder="Agent ID"
|
|
className="h-8 text-sm"
|
|
disabled={updateTask.isPending}
|
|
/>
|
|
) : (
|
|
<HelpTip label="Click to reassign — enter an agent slug (e.g. be-dev-1), or clear to unassign">
|
|
<span
|
|
className="font-medium cursor-pointer hover:bg-muted/50 px-2 py-1 -mx-2 rounded transition-colors inline-block"
|
|
onClick={startEditingAssigned}
|
|
>
|
|
{getAgentDisplayName(task.assigned_to)}
|
|
</span>
|
|
</HelpTip>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Created By - Read-only */}
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
<User className="h-4 w-4" />
|
|
Created By
|
|
</div>
|
|
<HelpTip label="The agent or human who originally created this task record">
|
|
<span className="font-medium">
|
|
{getAgentDisplayName(task.created_by)}
|
|
</span>
|
|
</HelpTip>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Sequence - Read-only */}
|
|
{task.sequence != null && (
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
<Hash className="h-4 w-4" />
|
|
Sequence
|
|
</div>
|
|
<HelpTip label="Delegation order among same-parent siblings — a sibling with a strictly lower sequence must reach a terminal state before this task can be claimed. Ties run in parallel.">
|
|
<span className="font-medium">#{task.sequence}</span>
|
|
</HelpTip>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Created At - Read-only */}
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
<Calendar className="h-4 w-4" />
|
|
Created
|
|
</div>
|
|
<HelpTip
|
|
label={
|
|
task.created_at
|
|
? `Exact timestamp: ${formatAbsoluteTimestamp(task.created_at)}`
|
|
: ""
|
|
}
|
|
>
|
|
<span className="font-medium">
|
|
{formatRelativeTime(task.created_at)}
|
|
</span>
|
|
</HelpTip>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Started At - Read-only */}
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
<Clock className="h-4 w-4" />
|
|
Started
|
|
</div>
|
|
<HelpTip
|
|
label={
|
|
task.started_at
|
|
? `Exact timestamp: ${formatAbsoluteTimestamp(task.started_at)}`
|
|
: ""
|
|
}
|
|
>
|
|
<span className="font-medium">
|
|
{formatRelativeTime(task.started_at)}
|
|
</span>
|
|
</HelpTip>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Target Date - Editable */}
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
<Target className="h-4 w-4" />
|
|
Target Date
|
|
</div>
|
|
{editingTargetDate ? (
|
|
<Input
|
|
ref={targetDateInputRef}
|
|
type="datetime-local"
|
|
value={targetDateValue}
|
|
onChange={(e) => setTargetDateValue(e.target.value)}
|
|
onKeyDown={handleTargetDateKeyDown}
|
|
onBlur={handleTargetDateSave}
|
|
className="h-8 text-sm"
|
|
disabled={updateTask.isPending}
|
|
/>
|
|
) : (
|
|
<HelpTip label="Click to set/change the target completion date">
|
|
<span
|
|
className="font-medium cursor-pointer hover:bg-muted/50 px-2 py-1 -mx-2 rounded transition-colors inline-block"
|
|
onClick={startEditingTargetDate}
|
|
>
|
|
{task.target_date ? formatDate(task.target_date) : "Not set"}
|
|
</span>
|
|
</HelpTip>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Completed At - Read-only */}
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
<Clock className="h-4 w-4" />
|
|
Completed
|
|
</div>
|
|
<HelpTip
|
|
label={
|
|
task.completed_at
|
|
? `Exact timestamp: ${formatAbsoluteTimestamp(task.completed_at)}`
|
|
: ""
|
|
}
|
|
>
|
|
<span className="font-medium">
|
|
{formatRelativeTime(task.completed_at)}
|
|
</span>
|
|
</HelpTip>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Task Type - Read-only */}
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
<Wrench className="h-4 w-4" />
|
|
Task Type
|
|
</div>
|
|
<TaskTypeBadge type={task.task_type} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Nature - Read-only */}
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
<Briefcase className="h-4 w-4" />
|
|
Nature
|
|
</div>
|
|
<HelpTip
|
|
label={
|
|
task.nature === TaskNature.TECHNICAL
|
|
? "Code/dev work — routes through the normal PM chain."
|
|
: "Strategic work (product/marketing) — routes to the Board for review."
|
|
}
|
|
>
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
task.nature === TaskNature.TECHNICAL
|
|
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
|
: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300"
|
|
}
|
|
>
|
|
{task.nature === TaskNature.TECHNICAL
|
|
? "Technical"
|
|
: "Non-Technical"}
|
|
</Badge>
|
|
</HelpTip>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Branch Name - read-only, but clickable (opens the branch on GitHub)
|
|
and copyable. Same look as before; the link/copy are additive. */}
|
|
{task.branch_name && (
|
|
<Card className="col-span-2">
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
<GitBranch className="h-4 w-4" />
|
|
Branch
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{branchHref ? (
|
|
<HelpTip label="Open this branch on GitHub in a new tab">
|
|
<a
|
|
href={branchHref}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex transition-opacity hover:opacity-80"
|
|
>
|
|
<Badge variant="outline" className="font-mono text-sm">
|
|
{task.branch_name}
|
|
</Badge>
|
|
</a>
|
|
</HelpTip>
|
|
) : (
|
|
<HelpTip label="The git branch for this task; no project git_url configured to link out to it">
|
|
<Badge variant="outline" className="font-mono text-sm">
|
|
{task.branch_name}
|
|
</Badge>
|
|
</HelpTip>
|
|
)}
|
|
<CopyButton value={task.branch_name} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Pull Request - Read-only Link (only show when PR exists) */}
|
|
{task.pr_number && (
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
<GitPullRequest className="h-4 w-4" />
|
|
Pull Request
|
|
</div>
|
|
{task.pr_url ? (
|
|
<HelpTip label="Opens this task's pull request on GitHub in a new tab">
|
|
<a
|
|
href={task.pr_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 text-blue-600 hover:underline dark:text-blue-400"
|
|
>
|
|
PR #{task.pr_number}
|
|
<ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
</HelpTip>
|
|
) : (
|
|
<HelpTip label="PR number on record; no pr_url stored to link out to it">
|
|
<span className="font-medium">#{task.pr_number}</span>
|
|
</HelpTip>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Project - Read-only Link */}
|
|
<Card>
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
|
<FolderGit2 className="h-4 w-4" />
|
|
Project
|
|
</div>
|
|
{task.project_id && project ? (
|
|
<HelpTip label="Opens the Projects list (not a deep link to this specific project)">
|
|
<Link
|
|
prefetch={false}
|
|
href={`/workstation?tab=projects`}
|
|
className="font-medium text-blue-600 hover:underline dark:text-blue-400"
|
|
>
|
|
{project.name}
|
|
</Link>
|
|
</HelpTip>
|
|
) : (
|
|
<HelpTip label="This task has no associated project">
|
|
<span className="font-medium text-muted-foreground">-</span>
|
|
</HelpTip>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Docs/PR Status - Read-only (only show for relevant statuses) */}
|
|
{(task.docs_complete !== undefined || task.pr_created !== undefined) && (
|
|
<Card className="col-span-2">
|
|
<CardContent className="pt-4">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
|
Completion Status
|
|
</div>
|
|
<DocsStatusBadge
|
|
docsComplete={task.docs_complete}
|
|
prCreated={task.pr_created}
|
|
variant="full"
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|