feat: implement AI checkpoint and task suggestion UI with approval flow

This commit is contained in:
Roberto Musso
2026-02-24 22:29:09 +01:00
parent 77b94e2b27
commit 5445bb0eec
11 changed files with 278 additions and 26 deletions

View File

@@ -37,9 +37,11 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan
const [editTask, setEditTask] = useState<TaskItem | null>(null);
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
// Group tasks by status
// Group tasks by status (exclude unapproved AI suggestions)
const columns = useMemo(() => {
const tasks = tasksList ?? [];
const tasks = (tasksList ?? []).filter(
(t) => !(t.isAiSuggested === 1 && t.isApproved === 0),
);
const grouped: Record<ColumnId, TaskItem[]> = {
todo: [],
in_progress: [],

View File

@@ -11,6 +11,7 @@ import {
BreadcrumbList,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { Card, CardContent } from '@/components/ui/card';
import { KanbanBoard } from './KanbanBoard';
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
@@ -63,6 +64,16 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
return { approved, total: all.length };
}, [checkpointsList]);
const pendingCheckpoints = useMemo(() =>
(checkpointsList ?? []).filter((c) => c.isAiSuggested === 1 && c.isApproved === 0),
[checkpointsList],
);
const pendingTasks = useMemo(() =>
(tasksList ?? []).filter((t) => t.isAiSuggested === 1 && t.isApproved === 0),
[tasksList],
);
// Map checkpoints to GanttChart format
const ganttCheckpoints: GanttCheckpoint[] = useMemo(() => {
return (checkpointsList ?? []).map((c) => ({
@@ -102,6 +113,30 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
},
});
const updateTask = trpc.tasks.update.useMutation({
onSuccess: () => {
void utils.tasks.list.invalidate({ projectId });
},
});
const deleteTask = trpc.tasks.delete.useMutation({
onSuccess: () => {
void utils.tasks.list.invalidate({ projectId });
},
});
const suggestCheckpoints = trpc.ai.chat.useMutation({
onSuccess: () => {
void utils.checkpoints.list.invalidate({ projectId });
},
});
const suggestTasks = trpc.ai.chat.useMutation({
onSuccess: () => {
void utils.tasks.list.invalidate({ projectId });
},
});
const createNote = trpc.notes.create.useMutation({
onSuccess: (data) => {
void utils.notes.list.invalidate({ projectId });
@@ -196,10 +231,26 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Project Timeline</h2>
<Button variant="secondary" size="sm" onClick={() => setAddCheckpointOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={suggestCheckpoints.isPending}
onClick={() =>
suggestCheckpoints.mutate({
message: 'Suggest checkpoints for this project based on the notes.',
context: { type: 'project', projectId },
})
}
>
<Sparkles className="h-4 w-4 mr-1" />
{suggestCheckpoints.isPending ? 'Suggesting…' : 'Suggest checkpoints'}
</Button>
<Button variant="secondary" size="sm" onClick={() => setAddCheckpointOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
</div>
<GanttChart
checkpoints={ganttCheckpoints}
@@ -211,6 +262,38 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
updateCheckpoint.mutate({ id, isApproved: current === 1 ? 0 : 1 })
}
/>
{pendingCheckpoints.length > 0 && (
<div className="flex flex-col gap-2">
{pendingCheckpoints.map((cp) => (
<Card key={cp.id} className="border-dashed py-3">
<CardContent className="flex items-center justify-between px-4 py-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{cp.title}</span>
<span className="text-xs text-muted-foreground">
{format(new Date(cp.date), 'PPP')}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={() => updateCheckpoint.mutate({ id: cp.id, isApproved: 1 })}
>
Approve
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteCheckpoint.mutate({ id: cp.id })}
>
Reject
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<AddCheckpointDialog
open={addCheckpointOpen}
onOpenChange={setAddCheckpointOpen}
@@ -226,11 +309,61 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Tasks</h2>
<Button variant="secondary" size="sm" onClick={() => setNewTaskOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={suggestTasks.isPending}
onClick={() =>
suggestTasks.mutate({
message: 'Suggest tasks for this project based on the notes.',
context: { type: 'project', projectId },
})
}
>
<Sparkles className="h-4 w-4 mr-1" />
{suggestTasks.isPending ? 'Suggesting…' : 'Suggest tasks'}
</Button>
<Button variant="secondary" size="sm" onClick={() => setNewTaskOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
</div>
{pendingTasks.length > 0 && (
<div className="flex flex-col gap-2">
{pendingTasks.map((t) => (
<Card key={t.id} className="border-dashed py-3">
<CardContent className="flex items-center justify-between px-4 py-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{t.title}</span>
{t.description && (
<span className="text-xs text-muted-foreground line-clamp-1">
{t.description}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={() => updateTask.mutate({ id: t.id, isApproved: 1 })}
>
Approve
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteTask.mutate({ id: t.id })}
>
Reject
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<KanbanBoard
projectId={projectId}
newTaskOpen={newTaskOpen}

View File

@@ -24,6 +24,8 @@ export type TaskItem = {
priority: string | null;
assignee: string | null;
dueDate: number | null;
isAiSuggested: number;
isApproved: number;
projectName: string | null;
clientName: string | null;
subClientName: string | null;

View File

@@ -84,7 +84,9 @@ function TasksPage() {
},
});
const tasksList = filteredTasks ?? [];
const tasksList = (filteredTasks ?? []).filter(
(t) => !(t.isAiSuggested === 1 && t.isApproved === 0),
);
// Compute stats from all tasks (unfiltered)
const stats = useMemo(() => {