feat: implement AI checkpoint and task suggestion UI with approval flow
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
## Your Task US-022
|
## Your Task US-024
|
||||||
|
|
||||||
1. Read the full app PRD at `prd-main.md` (in the same directory as this file)
|
1. Read the full app PRD at `prd-main.md` (in the same directory as this file)
|
||||||
2. Read the PRD at `prd.json` (in the same directory as this file)
|
2. Read the PRD at `prd.json` (in the same directory as this file)
|
||||||
@@ -23,17 +23,20 @@ APPEND to progress.txt (never replace, always append):
|
|||||||
|
|
||||||
## USER REQUEST
|
## USER REQUEST
|
||||||
{
|
{
|
||||||
"id": "US-022",
|
"id": "US-024",
|
||||||
"title": "LanceDB vector store setup and note embedding pipeline",
|
"title": "AI checkpoint suggestions UI",
|
||||||
"description": "As a developer, I need notes embedded into LanceDB so that semantic search across all project notes is possible.",
|
"description": "As a user, I want the AI to suggest timeline checkpoints from my meeting notes, which I can approve or reject directly in the timeline.",
|
||||||
"acceptanceCriteria": [
|
"acceptanceCriteria": [
|
||||||
"vectordb (LanceDB Node.js binding) installed and initialized in main process only; vector DB stored at app.getPath('userData')/vectors/",
|
"'Suggest checkpoints' shadcn/ui Button (variant=outline, sparkles Lucide icon) in the Project Detail timeline header calls ai.chat with a suggest_checkpoints intent for the current project",
|
||||||
"After notes.create or notes.update, note content is embedded via the GitHub Copilot SDK embeddings endpoint and upserted in LanceDB with metadata: { noteId, projectId, content }",
|
"Suggested checkpoints returned by @ProjectAgent are inserted into the checkpoints table via checkpoints.create with isAiSuggested=1, isApproved=0",
|
||||||
"On first app startup, a migration routine checks if the 'notes' LanceDB table exists; if not, embeds all existing SQLite notes and populates LanceDB",
|
"Pending suggestions appear as shadcn/ui Card components (with dashed border via className 'border-dashed') above or below the GanttChart in the Project Detail timeline section",
|
||||||
"Embedding errors are caught and logged to console (console.error) but do not reject the notes.update/create promise",
|
"'Approve' shadcn/ui Button (variant=default, size=sm) on each card calls checkpoints.update({ id, isApproved: 1 }); the checkpoint then appears as a normal dot on the Gantt",
|
||||||
"Typecheck passes"
|
"'Reject' shadcn/ui Button (variant=ghost, size=sm) calls checkpoints.delete({ id }) and removes the card",
|
||||||
|
"All UI uses shadcn/ui components (already installed)",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 22,
|
"priority": 24,
|
||||||
"passes": false,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": ""
|
||||||
}
|
}
|
||||||
4
prd.json
4
prd.json
@@ -434,8 +434,8 @@
|
|||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 24,
|
"priority": 24,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": "Completed: suggest_checkpoints tool in orchestrator.ts now persists suggestions to DB (isAiSuggested=1, isApproved=0). New suggest_tasks tool added with same pattern. ProjectDetail.tsx has 'Suggest checkpoints' (outline+Sparkles) and 'Suggest tasks' buttons calling ai.chat. Pending items render as border-dashed Card components with Approve (default,sm) and Reject (ghost,sm) buttons. Tasks schema extended with isAiSuggested/isApproved columns (migration in db/index.ts). KanbanBoard and tasks route filter out unapproved AI suggestions. Typecheck passes."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-025",
|
"id": "US-025",
|
||||||
|
|||||||
29
progress.txt
29
progress.txt
@@ -533,3 +533,32 @@
|
|||||||
- LanceDB `table.search(vector).limit(k).execute()` returns objects with all stored fields plus `_distance` (L2 distance, lower = more similar)
|
- LanceDB `table.search(vector).limit(k).execute()` returns objects with all stored fields plus `_distance` (L2 distance, lower = more similar)
|
||||||
- The `SearchResult` type is exported from `vectordb.ts` for reuse in the orchestrator — keep vector DB types in the DB module, not the AI module
|
- The `SearchResult` type is exported from `vectordb.ts` for reuse in the orchestrator — keep vector DB types in the DB module, not the AI module
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-02-24 - US-024
|
||||||
|
- Implemented AI checkpoint suggestions UI with approve/reject flow
|
||||||
|
- Extended to also support AI task suggestions (user-requested scope expansion)
|
||||||
|
- Modified suggest_checkpoints tool in orchestrator to persist suggestions to DB (isAiSuggested=1, isApproved=0)
|
||||||
|
- Created new suggest_tasks tool with same pattern (analyzes notes, extracts actionable tasks, persists to DB)
|
||||||
|
- Added isAiSuggested/isApproved columns to tasks table schema + ALTER TABLE migration for existing databases
|
||||||
|
- Updated tasks.create/update router to accept new fields
|
||||||
|
- Added TaskItem type fields for isAiSuggested/isApproved
|
||||||
|
- ProjectDetail.tsx: "Suggest checkpoints" button (outline+Sparkles) in timeline header, "Suggest tasks" button in tasks header
|
||||||
|
- Pending suggestions render as border-dashed Card components below GanttChart / above KanbanBoard
|
||||||
|
- Approve (variant=default, size=sm) calls update with isApproved=1; Reject (variant=ghost, size=sm) calls delete
|
||||||
|
- KanbanBoard and workspace tasks route filter out unapproved AI suggestions
|
||||||
|
- Files changed:
|
||||||
|
- src/main/db/schema.ts (added isAiSuggested + isApproved to tasks)
|
||||||
|
- src/main/db/index.ts (ALTER TABLE migration + updated CREATE TABLE)
|
||||||
|
- src/main/router/index.ts (tasks.create/update/list updated)
|
||||||
|
- src/main/ai/orchestrator.ts (persist checkpoint suggestions + new suggest_tasks tool + updated system prompt)
|
||||||
|
- src/renderer/components/projects/ProjectDetail.tsx (suggest buttons + pending cards for both)
|
||||||
|
- src/renderer/components/projects/KanbanBoard.tsx (filter out pending AI suggestions)
|
||||||
|
- src/renderer/components/tasks/TaskRow.tsx (TaskItem type extended)
|
||||||
|
- src/renderer/routes/tasks.tsx (filter out pending AI suggestions)
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Tasks table defaults isApproved=1 (unlike checkpoints which default=0) so existing/manually-created tasks remain visible
|
||||||
|
- SQLite has no ADD COLUMN IF NOT EXISTS — use try/catch around ALTER TABLE statements
|
||||||
|
- The suggest_checkpoints/suggest_tasks tools persist directly via db.insert() in the tool handler, then query invalidation on the frontend picks up new records
|
||||||
|
- TaskItem type in TaskRow.tsx is manually defined (not auto-inferred from tRPC) — must be updated when adding columns to the tasks select
|
||||||
|
- The ai.chat mutation can be instantiated multiple times for independent suggest flows (suggestCheckpoints vs suggestTasks)
|
||||||
|
---
|
||||||
|
|||||||
@@ -248,7 +248,20 @@ function buildProjectTools(projectId: string): StructuredTool[] {
|
|||||||
const match = content.match(/\[[\s\S]*\]/);
|
const match = content.match(/\[[\s\S]*\]/);
|
||||||
const jsonStr = match ? match[0] : '[]';
|
const jsonStr = match ? match[0] : '[]';
|
||||||
try {
|
try {
|
||||||
JSON.parse(jsonStr);
|
const parsed = JSON.parse(jsonStr) as Array<{ title: string; date: string }>;
|
||||||
|
for (const s of parsed) {
|
||||||
|
const ts = new Date(s.date).getTime();
|
||||||
|
if (Number.isNaN(ts)) continue;
|
||||||
|
db.insert(checkpoints).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
projectId,
|
||||||
|
title: s.title,
|
||||||
|
date: ts,
|
||||||
|
isAiSuggested: 1,
|
||||||
|
isApproved: 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
return jsonStr;
|
return jsonStr;
|
||||||
} catch {
|
} catch {
|
||||||
return '[]';
|
return '[]';
|
||||||
@@ -263,7 +276,57 @@ function buildProjectTools(projectId: string): StructuredTool[] {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return [readProjectNotesTool, addTaskTool, getSummaryTool, suggestCheckpointsTool] as StructuredTool[];
|
const suggestTasksTool = tool(
|
||||||
|
async (_input: Record<string, never>) => {
|
||||||
|
const contextData = buildProjectContext(projectId);
|
||||||
|
const suggestionLlm = await getLLM();
|
||||||
|
if (!suggestionLlm) return '[]';
|
||||||
|
const result = await suggestionLlm.invoke([
|
||||||
|
new SystemMessage(
|
||||||
|
'You are a project planning assistant. Analyze the project data below and extract ' +
|
||||||
|
'actionable tasks from the notes (e.g. "set up CI pipeline", "draft proposal for client").\n\n' +
|
||||||
|
'Return ONLY a valid JSON array of objects with shape { "title": string, "description"?: string, "priority"?: "low"|"medium"|"high", "dueDate"?: string } ' +
|
||||||
|
'where dueDate is ISO 8601 format (YYYY-MM-DD) if mentioned. Return [] if no actionable tasks are found.\n\n' +
|
||||||
|
'Example: [{"title":"Set up CI pipeline","description":"Configure GitHub Actions for automated testing","priority":"high"},{"title":"Draft client proposal","dueDate":"2026-03-20"}]',
|
||||||
|
),
|
||||||
|
new HumanMessage(contextData),
|
||||||
|
]);
|
||||||
|
const content = typeof result.content === 'string' ? result.content.trim() : '[]';
|
||||||
|
const match = content.match(/\[[\s\S]*\]/);
|
||||||
|
const jsonStr = match ? match[0] : '[]';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonStr) as Array<{ title: string; description?: string; priority?: string; dueDate?: string }>;
|
||||||
|
for (const s of parsed) {
|
||||||
|
db.insert(tasks).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
projectId,
|
||||||
|
title: s.title,
|
||||||
|
description: s.description ?? null,
|
||||||
|
status: 'todo',
|
||||||
|
priority: s.priority ?? 'medium',
|
||||||
|
assignee: null,
|
||||||
|
dueDate: s.dueDate ? new Date(s.dueDate).getTime() : null,
|
||||||
|
isAiSuggested: 1,
|
||||||
|
isApproved: 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
return jsonStr;
|
||||||
|
} catch {
|
||||||
|
return '[]';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'suggest_tasks',
|
||||||
|
description:
|
||||||
|
'Analyzes project notes for actionable tasks and returns a JSON array of ' +
|
||||||
|
'{ title: string, description?: string, priority?: string, dueDate?: string } suggested tasks. ' +
|
||||||
|
'Use when the user asks for task suggestions.',
|
||||||
|
schema: z.object({}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return [readProjectNotesTool, addTaskTool, getSummaryTool, suggestCheckpointsTool, suggestTasksTool] as StructuredTool[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -379,8 +442,9 @@ You also have access to the following tools — use them proactively when approp
|
|||||||
- add_task: Create a new task in this project. Use when the user asks to add a task.
|
- add_task: Create a new task in this project. Use when the user asks to add a task.
|
||||||
- get_summary: Generate and save a 2-3 sentence project summary. Use when asked to summarize.
|
- get_summary: Generate and save a 2-3 sentence project summary. Use when asked to summarize.
|
||||||
- suggest_checkpoints: Extract date-based milestone suggestions from notes. Use for timeline/checkpoint questions.
|
- suggest_checkpoints: Extract date-based milestone suggestions from notes. Use for timeline/checkpoint questions.
|
||||||
|
- suggest_tasks: Extract actionable task suggestions from notes. Use when the user asks for task suggestions.
|
||||||
|
|
||||||
When suggest_checkpoints returns a JSON array, present the checkpoints in a readable bullet-point format.` : '';
|
When suggest_checkpoints or suggest_tasks returns a JSON array, present the items in a readable bullet-point format.` : '';
|
||||||
|
|
||||||
return `You are @ProjectAgent, an AI assistant specialized in a specific project within Adiuva.
|
return `You are @ProjectAgent, an AI assistant specialized in a specific project within Adiuva.
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const MIGRATION_SQL = `
|
|||||||
priority TEXT NOT NULL DEFAULT 'medium',
|
priority TEXT NOT NULL DEFAULT 'medium',
|
||||||
assignee TEXT,
|
assignee TEXT,
|
||||||
due_date INTEGER,
|
due_date INTEGER,
|
||||||
|
is_ai_suggested INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_approved INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -79,6 +81,10 @@ export function initDb(): DbInstance {
|
|||||||
// Run non-destructive migrations on every start
|
// Run non-destructive migrations on every start
|
||||||
sqlite.exec(MIGRATION_SQL);
|
sqlite.exec(MIGRATION_SQL);
|
||||||
|
|
||||||
|
// Additive column migrations (SQLite has no ADD COLUMN IF NOT EXISTS)
|
||||||
|
try { sqlite.exec('ALTER TABLE tasks ADD COLUMN is_ai_suggested INTEGER NOT NULL DEFAULT 0'); } catch { /* already exists */ }
|
||||||
|
try { sqlite.exec('ALTER TABLE tasks ADD COLUMN is_approved INTEGER NOT NULL DEFAULT 1'); } catch { /* already exists */ }
|
||||||
|
|
||||||
dbInstance = drizzle(sqlite, { schema });
|
dbInstance = drizzle(sqlite, { schema });
|
||||||
return dbInstance;
|
return dbInstance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export const tasks = sqliteTable('tasks', {
|
|||||||
priority: text('priority').notNull().default('medium'),
|
priority: text('priority').notNull().default('medium'),
|
||||||
assignee: text('assignee'),
|
assignee: text('assignee'),
|
||||||
dueDate: integer('due_date', { mode: 'number' }),
|
dueDate: integer('due_date', { mode: 'number' }),
|
||||||
|
isAiSuggested: integer('is_ai_suggested', { mode: 'number' }).notNull().default(0),
|
||||||
|
isApproved: integer('is_approved', { mode: 'number' }).notNull().default(1),
|
||||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,8 @@ const tasksRouter = router({
|
|||||||
priority: tasks.priority,
|
priority: tasks.priority,
|
||||||
assignee: tasks.assignee,
|
assignee: tasks.assignee,
|
||||||
dueDate: tasks.dueDate,
|
dueDate: tasks.dueDate,
|
||||||
|
isAiSuggested: tasks.isAiSuggested,
|
||||||
|
isApproved: tasks.isApproved,
|
||||||
createdAt: tasks.createdAt,
|
createdAt: tasks.createdAt,
|
||||||
projectName: projects.name,
|
projectName: projects.name,
|
||||||
clientName: sql<string | null>`CASE WHEN ${clients.parentId} IS NOT NULL THEN ${parentClients.name} ELSE ${clients.name} END`,
|
clientName: sql<string | null>`CASE WHEN ${clients.parentId} IS NOT NULL THEN ${parentClients.name} ELSE ${clients.name} END`,
|
||||||
@@ -268,6 +270,8 @@ const tasksRouter = router({
|
|||||||
assignees: z.array(z.string()).optional(),
|
assignees: z.array(z.string()).optional(),
|
||||||
dueDate: z.number().optional(),
|
dueDate: z.number().optional(),
|
||||||
projectId: z.string().optional(),
|
projectId: z.string().optional(),
|
||||||
|
isAiSuggested: z.number().optional(),
|
||||||
|
isApproved: z.number().optional(),
|
||||||
}))
|
}))
|
||||||
.mutation(({ input }) => {
|
.mutation(({ input }) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
@@ -281,6 +285,8 @@ const tasksRouter = router({
|
|||||||
assignee: input.assignees?.length ? JSON.stringify(input.assignees) : null,
|
assignee: input.assignees?.length ? JSON.stringify(input.assignees) : null,
|
||||||
dueDate: input.dueDate ?? null,
|
dueDate: input.dueDate ?? null,
|
||||||
projectId: input.projectId ?? null,
|
projectId: input.projectId ?? null,
|
||||||
|
isAiSuggested: input.isAiSuggested ?? 0,
|
||||||
|
isApproved: input.isApproved ?? 1,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
return { id };
|
return { id };
|
||||||
@@ -296,6 +302,7 @@ const tasksRouter = router({
|
|||||||
assignees: z.array(z.string()).optional(),
|
assignees: z.array(z.string()).optional(),
|
||||||
dueDate: z.number().optional(),
|
dueDate: z.number().optional(),
|
||||||
projectId: z.string().optional(),
|
projectId: z.string().optional(),
|
||||||
|
isApproved: z.number().optional(),
|
||||||
}))
|
}))
|
||||||
.mutation(({ input }) => {
|
.mutation(({ input }) => {
|
||||||
const set: Partial<{
|
const set: Partial<{
|
||||||
@@ -306,6 +313,7 @@ const tasksRouter = router({
|
|||||||
assignee: string | null;
|
assignee: string | null;
|
||||||
dueDate: number | null;
|
dueDate: number | null;
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
|
isApproved: number;
|
||||||
}> = {};
|
}> = {};
|
||||||
if (input.title !== undefined) set.title = input.title;
|
if (input.title !== undefined) set.title = input.title;
|
||||||
if (input.description !== undefined) set.description = input.description;
|
if (input.description !== undefined) set.description = input.description;
|
||||||
@@ -313,6 +321,7 @@ const tasksRouter = router({
|
|||||||
if (input.priority !== undefined) set.priority = input.priority;
|
if (input.priority !== undefined) set.priority = input.priority;
|
||||||
if (input.assignees !== undefined) set.assignee = input.assignees.length ? JSON.stringify(input.assignees) : null;
|
if (input.assignees !== undefined) set.assignee = input.assignees.length ? JSON.stringify(input.assignees) : null;
|
||||||
if (input.dueDate !== undefined) set.dueDate = input.dueDate;
|
if (input.dueDate !== undefined) set.dueDate = input.dueDate;
|
||||||
|
if (input.isApproved !== undefined) set.isApproved = input.isApproved;
|
||||||
if (input.projectId !== undefined) set.projectId = input.projectId;
|
if (input.projectId !== undefined) set.projectId = input.projectId;
|
||||||
if (Object.keys(set).length > 0) {
|
if (Object.keys(set).length > 0) {
|
||||||
getDb().update(tasks).set(set).where(eq(tasks.id, input.id)).run();
|
getDb().update(tasks).set(set).where(eq(tasks.id, input.id)).run();
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan
|
|||||||
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
||||||
const [viewTask, setViewTask] = 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 columns = useMemo(() => {
|
||||||
const tasks = tasksList ?? [];
|
const tasks = (tasksList ?? []).filter(
|
||||||
|
(t) => !(t.isAiSuggested === 1 && t.isApproved === 0),
|
||||||
|
);
|
||||||
const grouped: Record<ColumnId, TaskItem[]> = {
|
const grouped: Record<ColumnId, TaskItem[]> = {
|
||||||
todo: [],
|
todo: [],
|
||||||
in_progress: [],
|
in_progress: [],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
BreadcrumbList,
|
BreadcrumbList,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from '@/components/ui/breadcrumb';
|
} from '@/components/ui/breadcrumb';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { KanbanBoard } from './KanbanBoard';
|
import { KanbanBoard } from './KanbanBoard';
|
||||||
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
||||||
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
||||||
@@ -63,6 +64,16 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
return { approved, total: all.length };
|
return { approved, total: all.length };
|
||||||
}, [checkpointsList]);
|
}, [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
|
// Map checkpoints to GanttChart format
|
||||||
const ganttCheckpoints: GanttCheckpoint[] = useMemo(() => {
|
const ganttCheckpoints: GanttCheckpoint[] = useMemo(() => {
|
||||||
return (checkpointsList ?? []).map((c) => ({
|
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({
|
const createNote = trpc.notes.create.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
void utils.notes.list.invalidate({ projectId });
|
void utils.notes.list.invalidate({ projectId });
|
||||||
@@ -196,11 +231,27 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">Project Timeline</h2>
|
<h2 className="text-lg font-semibold">Project Timeline</h2>
|
||||||
|
<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)}>
|
<Button variant="secondary" size="sm" onClick={() => setAddCheckpointOpen(true)}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<GanttChart
|
<GanttChart
|
||||||
checkpoints={ganttCheckpoints}
|
checkpoints={ganttCheckpoints}
|
||||||
startDate={ganttStart}
|
startDate={ganttStart}
|
||||||
@@ -211,6 +262,38 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
updateCheckpoint.mutate({ id, isApproved: current === 1 ? 0 : 1 })
|
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
|
<AddCheckpointDialog
|
||||||
open={addCheckpointOpen}
|
open={addCheckpointOpen}
|
||||||
onOpenChange={setAddCheckpointOpen}
|
onOpenChange={setAddCheckpointOpen}
|
||||||
@@ -226,11 +309,61 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">Tasks</h2>
|
<h2 className="text-lg font-semibold">Tasks</h2>
|
||||||
|
<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)}>
|
<Button variant="secondary" size="sm" onClick={() => setNewTaskOpen(true)}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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
|
<KanbanBoard
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
newTaskOpen={newTaskOpen}
|
newTaskOpen={newTaskOpen}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export type TaskItem = {
|
|||||||
priority: string | null;
|
priority: string | null;
|
||||||
assignee: string | null;
|
assignee: string | null;
|
||||||
dueDate: number | null;
|
dueDate: number | null;
|
||||||
|
isAiSuggested: number;
|
||||||
|
isApproved: number;
|
||||||
projectName: string | null;
|
projectName: string | null;
|
||||||
clientName: string | null;
|
clientName: string | null;
|
||||||
subClientName: string | null;
|
subClientName: string | null;
|
||||||
|
|||||||
@@ -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)
|
// Compute stats from all tasks (unfiltered)
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user