diff --git a/app/api/projects/[id]/decisions/[decisionId]/export/route.ts b/app/api/projects/[id]/decisions/[decisionId]/export/route.ts new file mode 100644 index 0000000..bd01eb0 --- /dev/null +++ b/app/api/projects/[id]/decisions/[decisionId]/export/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { + formatDecision, + ExportFormat, + DecisionExportData, +} from "@/components/DecisionExportDialog"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string; decisionId: string }> } +) { + try { + const { id: projectId, decisionId } = await params; + const { searchParams } = new URL(request.url); + const format = (searchParams.get("format") as ExportFormat) || "markdown"; + + // Validate format + const validFormats = [ + "cursor", + "linear", + "jira", + "slack", + "notion", + "markdown", + "json", + ]; + if (!validFormats.includes(format)) { + return NextResponse.json( + { error: `Invalid format. Valid formats: ${validFormats.join(", ")}` }, + { status: 400 } + ); + } + + // Fetch the decision + const decision = await prisma.decision.findUnique({ + where: { id: decisionId }, + include: { + project: { + select: { name: true }, + }, + }, + }); + + if (!decision) { + return NextResponse.json({ error: "Decision not found" }, { status: 404 }); + } + + // Verify the decision belongs to the project + if (decision.projectId !== projectId) { + return NextResponse.json( + { error: "Decision does not belong to this project" }, + { status: 403 } + ); + } + + // Build export data + const exportData: DecisionExportData = { + id: decision.id, + title: decision.title, + summary: decision.summary, + scope: decision.scope, + nonGoals: decision.nonGoals, + acceptanceCriteria: decision.acceptanceCriteria, + risks: decision.risks, + confidenceScore: decision.confidenceScore, + linkedFeedbackIds: decision.linkedFeedbackIds, + projectName: decision.project.name, + }; + + // Format the decision + const content = formatDecision(exportData, format); + + // Return appropriate response based on format + if (format === "json") { + return NextResponse.json(JSON.parse(content), { + headers: { + "Content-Type": "application/json", + }, + }); + } + + // For text formats, return plain text with appropriate headers + const contentType = + format === "markdown" ? "text/markdown" : "text/plain"; + const filename = `decision-${decision.title + .toLowerCase() + .replace(/\s+/g, "-")}.${format === "markdown" ? "md" : format}`; + + return new NextResponse(content, { + headers: { + "Content-Type": contentType, + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); + } catch (error) { + console.error("Error exporting decision:", error); + return NextResponse.json( + { error: "Failed to export decision" }, + { status: 500 } + ); + } +} diff --git a/app/api/projects/[id]/decisions/route.ts b/app/api/projects/[id]/decisions/route.ts index 8b21252..728bc71 100644 --- a/app/api/projects/[id]/decisions/route.ts +++ b/app/api/projects/[id]/decisions/route.ts @@ -50,7 +50,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ scope: scope || "", risks: risks || "", nonGoals: nonGoals || "", - confidence: confidence || "medium", + confidenceScore: confidence || 0.5, status: "draft", linkedFeedbackIds: linkedFeedbackIds || [], }, diff --git a/app/projects/[id]/analysis/page.tsx b/app/projects/[id]/analysis/page.tsx new file mode 100644 index 0000000..26d08b8 --- /dev/null +++ b/app/projects/[id]/analysis/page.tsx @@ -0,0 +1,291 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft, BrainCircuit, AlertTriangle, TrendingUp, Target, Zap } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { PriorityMatrix } from "@/components/PriorityMatrix"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +interface OpportunityItem { + id: string; + title: string; + description: string; + impact: 'high' | 'medium' | 'low'; + effort: 'high' | 'medium' | 'low'; + impactScore: number; + feedbackCount: number; + category: string; +} + +export default async function AnalysisPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id: projectId } = await params; + + const project = await prisma.project.findUnique({ + where: { id: projectId }, + include: { + _count: { select: { feedback: true } }, + analyses: { + orderBy: { createdAt: "desc" }, + take: 1, + }, + }, + }); + + if (!project) { + notFound(); + } + + // Fetch latest analysis + const analysis = project.analyses[0]; + + if (!analysis) { + return ( +
+
+ + Back to {project.name} + +
+ + + +

No analysis yet

+

+ Add feedback and run analysis to see opportunities prioritized by impact and effort. +

+ +
+
+
+ ); + } + + // Parse analysis content + const analysisContent = analysis.content as { + feedbackCount: number; + summary: { bugs: number; features: number; other: number }; + opportunities: Array<{ + id: string; + title: string; + description: string; + impact: string; + impactScore: number; + feedbackCount: number; + category: string; + }>; + unclusteredCount: number; + generatedAt: string; + }; + + const opportunities: OpportunityItem[] = analysisContent.opportunities.map((opp) => ({ + id: opp.id, + title: opp.title, + description: opp.description, + impact: opp.impact as 'high' | 'medium' | 'low', + effort: opp.impactScore >= 0.7 ? 'high' : opp.impactScore >= 0.4 ? 'medium' : 'low', + impactScore: opp.impactScore, + feedbackCount: opp.feedbackCount, + category: opp.category, + })); + + // Sort by impact score descending + opportunities.sort((a, b) => b.impactScore - a.impactScore); + + return ( +
+ {/* Header */} +
+
+
+ + Back to {project.name} + +
+

+ + AI Analysis +

+

+ Generated {new Date(analysisContent.generatedAt).toLocaleString()} +

+
+ +
+ + {/* Summary Stats */} +
+ + +
+ +
+
+

{analysisContent.feedbackCount}

+

Total Feedback

+
+
+
+ + +
+ +
+
+

{analysisContent.summary.bugs}

+

Bugs Reported

+
+
+
+ + +
+ +
+
+

{analysisContent.summary.features}

+

Feature Requests

+
+
+
+ + +
+ +
+
+

{opportunities.length}

+

Opportunities

+
+
+
+
+ + {/* Tabs for different views */} + + + + Priority Matrix + + + All Opportunities + + + Unclustered ({analysisContent.unclusteredCount}) + + + + + + + Eisenhower Priority Matrix + + Prioritize opportunities by Impact (customer value) vs Effort (engineering cost) + + + + + + + + + + + + All Opportunities + + Ranked by impact score — opportunities with the highest customer value first + + + +
+ {opportunities.map((opp, index) => ( +
+
+
+
+ #{index + 1} + {opp.category} + + {opp.impact} impact + + + {opp.feedbackCount} votes + +
+

{opp.title}

+

{opp.description}

+
+
+
+
+ {Math.round(opp.impactScore * 100)}% +
+

impact score

+
+ +
+
+
+ ))} + {opportunities.length === 0 && ( +

+ No opportunities identified yet. Add more feedback and re-run analysis. +

+ )} +
+
+
+
+ + + + + Unclustered Feedback + + {analysisContent.unclusteredCount} feedback items couldn't be grouped into themes + + + +

+ Unclustered feedback details would appear here with options to: +

+
    +
  • • Review each item manually
  • +
  • • Add them to existing opportunities
  • +
  • • Create new opportunities from them
  • +
+
+
+
+
+
+ ); +} diff --git a/app/projects/[id]/decisions/[decisionId]/page.tsx b/app/projects/[id]/decisions/[decisionId]/page.tsx new file mode 100644 index 0000000..11c2211 --- /dev/null +++ b/app/projects/[id]/decisions/[decisionId]/page.tsx @@ -0,0 +1,202 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft, Scale, Download, CheckCircle, AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { prisma } from "@/lib/prisma"; +import { DecisionExportDialog } from "@/components/DecisionExportDialog"; + +export const dynamic = "force-dynamic"; + +export default async function DecisionDetailPage({ + params, +}: { + params: Promise<{ id: string; decisionId: string }>; +}) { + const { id: projectId, decisionId } = await params; + + const decision = await prisma.decision.findUnique({ + where: { id: decisionId }, + include: { + project: { + select: { name: true }, + }, + }, + }); + + if (!decision || decision.projectId !== projectId) { + notFound(); + } + + const confidencePercent = Math.round(decision.confidenceScore * 100); + const confidenceLevel = + decision.confidenceScore >= 0.8 + ? "high" + : decision.confidenceScore >= 0.6 + ? "medium" + : "low"; + + return ( +
+ {/* Header */} +
+
+
+ + Back to {decision.project.name} + +
+

+ {decision.title} +

+
+ + {decision.status.replace("_", " ")} + + + {confidenceLevel === "high" ? ( + + ) : ( + + )} + {confidencePercent}% confidence + +
+
+ +
+ + {/* Summary */} + + + Summary + + +

+ {decision.summary} +

+
+
+ + {/* Two column layout */} +
+ {/* Scope */} + + + + Scope + + + +

+ {decision.scope} +

+
+
+ + {/* Non-Goals */} + + + Out of Scope + + +

+ {decision.nonGoals} +

+
+
+
+ + {/* Acceptance Criteria */} + + + Acceptance Criteria + + +
    + {decision.acceptanceCriteria + .split("\n") + .filter((line) => line.trim()) + .map((criteria, i) => ( +
  • + + {criteria.trim()} +
  • + ))} +
+
+
+ + {/* Risks */} + + + + Risks + & Considerations + + + +

+ {decision.risks} +

+
+
+ + {/* Linked Feedback */} + {decision.linkedFeedbackIds.length > 0 && ( + + + Linked Feedback + + +

+ This decision addresses {decision.linkedFeedbackIds.length} customer + feedback items. +

+
+ {decision.linkedFeedbackIds.map((feedbackId) => ( + + {feedbackId} + + ))} +
+
+
+ )} +
+ ); +} diff --git a/app/projects/[id]/decisions/new/page.tsx b/app/projects/[id]/decisions/new/page.tsx new file mode 100644 index 0000000..121733e --- /dev/null +++ b/app/projects/[id]/decisions/new/page.tsx @@ -0,0 +1,335 @@ +"use client"; + +import { useState, use } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft, Save, Loader2, AlertTriangle, Scale, CheckCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Slider } from "@/components/ui/slider"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +interface CreateDecisionPageProps { + params: Promise<{ id: string }>; +} + +export default function CreateDecisionPage({ params }: CreateDecisionPageProps) { + const resolvedParams = use(params); + const projectId = resolvedParams.id; + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const [formData, setFormData] = useState({ + title: "", + summary: "", + scope: "", + nonGoals: "", + acceptanceCriteria: "", + risks: "", + confidence: 70, + linkedFeedbackIds: "", + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + setError(null); + + try { + const response = await fetch(`/api/projects/${projectId}/decisions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: formData.title, + summary: formData.summary, + scope: formData.scope, + nonGoals: formData.nonGoals, + acceptanceCriteria: formData.acceptanceCriteria, + risks: formData.risks, + confidence: formData.confidence / 100, // Convert to 0-1 scale + linkedFeedbackIds: formData.linkedFeedbackIds + .split(",") + .map((id) => id.trim()) + .filter(Boolean), + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to create decision"); + } + + const decision = await response.json(); + + // Redirect to the new decision page + router.push(`/projects/${projectId}/decisions/${decision.id}`); + } catch (err: any) { + setError(err.message || "An error occurred while creating the decision"); + } finally { + setIsSubmitting(false); + } + }; + + const getConfidenceLabel = (value: number) => { + if (value >= 80) return "High Confidence"; + if (value >= 60) return "Medium Confidence"; + if (value >= 40) return "Low Confidence"; + return "Very Low Confidence"; + }; + + const getConfidenceColor = (value: number) => { + if (value >= 80) return "text-green-600"; + if (value >= 60) return "text-blue-600"; + if (value >= 40) return "text-yellow-600"; + return "text-red-600"; + }; + + return ( +
+ {/* Header */} +
+ + Back to Decisions + +
+ +
+

+ Create New Decision +

+

+ Document a product decision with full context for your team. +

+
+ + {error && ( + + {error} + + )} + +
+ {/* Basic Info */} + + + Basic Information + + Start with the high-level summary of what you're deciding. + + + +
+ + + setFormData({ ...formData, title: e.target.value }) + } + required + /> +
+ +
+ +