884 lines
43 KiB
TypeScript
884 lines
43 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import Link from "next/link"
|
|
import { usePathname } from "next/navigation"
|
|
import {
|
|
Command,
|
|
Frame,
|
|
Settings,
|
|
Terminal,
|
|
Target,
|
|
Inbox,
|
|
Plus,
|
|
ArrowUpRight,
|
|
ChevronsUpDown
|
|
} from "lucide-react"
|
|
|
|
import { NavUser } from "@/components/nav-user"
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarHeader,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarGroup,
|
|
SidebarGroupLabel,
|
|
SidebarGroupContent,
|
|
} from "@/components/ui/sidebar"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu"
|
|
import { useQuery, useMutation } from "convex/react"
|
|
import { api } from "@/convex/_generated/api"
|
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
import { Label } from "@/components/ui/label"
|
|
import { useProject } from "@/components/project-context"
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { AnalysisTimeline } from "@/components/analysis-timeline"
|
|
|
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|
const pathname = usePathname()
|
|
const projects = useQuery(api.projects.getProjects);
|
|
const currentUser = useQuery(api.users.getCurrent);
|
|
const { selectedProjectId, setSelectedProjectId } = useProject();
|
|
const addDataSource = useMutation(api.dataSources.addDataSource);
|
|
const updateDataSourceStatus = useMutation(api.dataSources.updateDataSourceStatus);
|
|
const createAnalysis = useMutation(api.analyses.createAnalysis);
|
|
const createAnalysisJob = useMutation(api.analysisJobs.create);
|
|
const updateAnalysisJob = useMutation(api.analysisJobs.update);
|
|
const [isAdding, setIsAdding] = React.useState(false);
|
|
const [isCreatingProject, setIsCreatingProject] = React.useState(false);
|
|
const [projectName, setProjectName] = React.useState("");
|
|
const [projectDefault, setProjectDefault] = React.useState(true);
|
|
const [projectError, setProjectError] = React.useState<string | null>(null);
|
|
const [isSubmittingProject, setIsSubmittingProject] = React.useState(false);
|
|
const createProject = useMutation(api.projects.createProject);
|
|
const updateProject = useMutation(api.projects.updateProject);
|
|
const deleteProject = useMutation(api.projects.deleteProject);
|
|
const [isEditingProject, setIsEditingProject] = React.useState(false);
|
|
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
|
|
const [editingProjectName, setEditingProjectName] = React.useState("");
|
|
const [editingProjectDefault, setEditingProjectDefault] = React.useState(false);
|
|
const [editingProjectError, setEditingProjectError] = React.useState<string | null>(null);
|
|
const [deleteConfirmName, setDeleteConfirmName] = React.useState("");
|
|
const [deleteProjectError, setDeleteProjectError] = React.useState<string | null>(null);
|
|
const [isDeletingProject, setIsDeletingProject] = React.useState(false);
|
|
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = React.useState(false);
|
|
const [isSubmittingEdit, setIsSubmittingEdit] = React.useState(false);
|
|
const [sourceUrl, setSourceUrl] = React.useState("");
|
|
const [sourceName, setSourceName] = React.useState("");
|
|
const [sourceError, setSourceError] = React.useState<string | null>(null);
|
|
const [sourceNotice, setSourceNotice] = React.useState<string | null>(null);
|
|
const [isSubmittingSource, setIsSubmittingSource] = React.useState(false);
|
|
const [manualMode, setManualMode] = React.useState(false);
|
|
const [manualProductName, setManualProductName] = React.useState("");
|
|
const [manualDescription, setManualDescription] = React.useState("");
|
|
const [manualFeatures, setManualFeatures] = React.useState("");
|
|
const [pendingSourceId, setPendingSourceId] = React.useState<string | null>(null);
|
|
const [pendingProjectId, setPendingProjectId] = React.useState<string | null>(null);
|
|
const [pendingJobId, setPendingJobId] = React.useState<string | null>(null);
|
|
const sourceUrlRef = React.useRef<HTMLInputElement | null>(null);
|
|
const sourceNameRef = React.useRef<HTMLInputElement | null>(null);
|
|
const manualProductNameRef = React.useRef<HTMLInputElement | null>(null);
|
|
const manualDescriptionRef = React.useRef<HTMLTextAreaElement | null>(null);
|
|
const analysisJob = useQuery(
|
|
api.analysisJobs.getById,
|
|
pendingJobId ? { jobId: pendingJobId as any } : "skip"
|
|
);
|
|
const opportunities = useQuery(
|
|
api.opportunities.listByProject,
|
|
selectedProjectId
|
|
? {
|
|
projectId: selectedProjectId as any,
|
|
limit: 200,
|
|
}
|
|
: "skip"
|
|
);
|
|
|
|
// Set default selected project
|
|
React.useEffect(() => {
|
|
if (projects && projects.length > 0 && !selectedProjectId) {
|
|
// Prefer default project, otherwise first
|
|
const defaultProj = projects.find(p => p.isDefault);
|
|
setSelectedProjectId(defaultProj ? defaultProj._id : projects[0]._id);
|
|
}
|
|
}, [projects, selectedProjectId]);
|
|
|
|
// Data Sources Query
|
|
const dataSources = useQuery(
|
|
api.dataSources.getProjectDataSources,
|
|
selectedProjectId ? { projectId: selectedProjectId as any } : "skip"
|
|
);
|
|
|
|
const toggleConfig = useMutation(api.projects.toggleDataSourceConfig);
|
|
|
|
const selectedProject = projects?.find(p => p._id === selectedProjectId);
|
|
const editingProject = projects?.find((project) => project._id === editingProjectId);
|
|
const canDeleteProject = (projects?.length ?? 0) > 1;
|
|
const selectedSourceIds = selectedProject?.dorkingConfig?.selectedSourceIds || [];
|
|
const inboxCount = React.useMemo(() => {
|
|
if (!opportunities) return 0;
|
|
const normalized = opportunities as { status?: string }[];
|
|
return normalized.filter((lead) => {
|
|
const status = lead.status ?? "new";
|
|
if (status === "ignored") return false;
|
|
if (status === "converted") return false;
|
|
if (status === "archived") return false;
|
|
if (status === "sent") return false;
|
|
return true;
|
|
}).length;
|
|
}, [opportunities]);
|
|
const selectedProjectName = selectedProject?.name || "Select Project";
|
|
|
|
const handleToggle = async (sourceId: string, checked: boolean) => {
|
|
if (!selectedProjectId) return;
|
|
await toggleConfig({
|
|
projectId: selectedProjectId as any,
|
|
sourceId: sourceId as any,
|
|
selected: checked
|
|
});
|
|
};
|
|
|
|
const handleAddSource = async () => {
|
|
if (!sourceUrl) {
|
|
setSourceError("Please enter a URL.");
|
|
return;
|
|
}
|
|
|
|
setSourceError(null);
|
|
setIsSubmittingSource(true);
|
|
|
|
try {
|
|
const result = await addDataSource({
|
|
projectId: selectedProjectId as any,
|
|
url: sourceUrl,
|
|
name: sourceName || sourceUrl,
|
|
type: "website",
|
|
});
|
|
|
|
if ((result as any).isExisting) {
|
|
setSourceNotice("This source already exists and was reused.");
|
|
}
|
|
|
|
const jobId = await createAnalysisJob({
|
|
projectId: result.projectId,
|
|
dataSourceId: result.sourceId,
|
|
});
|
|
setPendingSourceId(result.sourceId);
|
|
setPendingProjectId(result.projectId);
|
|
setPendingJobId(jobId);
|
|
|
|
await updateDataSourceStatus({
|
|
dataSourceId: result.sourceId,
|
|
analysisStatus: "pending",
|
|
lastError: undefined,
|
|
lastAnalyzedAt: undefined,
|
|
});
|
|
|
|
const response = await fetch("/api/analyze", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ url: sourceUrl, jobId }),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
if (data.needsManualInput) {
|
|
setManualMode(true);
|
|
setManualProductName(
|
|
sourceName || sourceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")
|
|
);
|
|
setPendingSourceId(result.sourceId);
|
|
setPendingProjectId(result.projectId);
|
|
setSourceError(data.error || "Manual input required.");
|
|
return;
|
|
}
|
|
|
|
await updateDataSourceStatus({
|
|
dataSourceId: result.sourceId,
|
|
analysisStatus: "failed",
|
|
lastError: data.error || "Analysis failed",
|
|
lastAnalyzedAt: Date.now(),
|
|
});
|
|
throw new Error(data.error || "Analysis failed");
|
|
}
|
|
|
|
if (!data.persisted) {
|
|
await createAnalysis({
|
|
projectId: result.projectId,
|
|
dataSourceId: result.sourceId,
|
|
analysis: data.data,
|
|
});
|
|
|
|
await updateDataSourceStatus({
|
|
dataSourceId: result.sourceId,
|
|
analysisStatus: "completed",
|
|
lastError: undefined,
|
|
lastAnalyzedAt: Date.now(),
|
|
});
|
|
}
|
|
|
|
setSourceUrl("");
|
|
setSourceName("");
|
|
setSourceNotice(null);
|
|
setManualMode(false);
|
|
setManualProductName("");
|
|
setManualDescription("");
|
|
setManualFeatures("");
|
|
setPendingSourceId(null);
|
|
setPendingProjectId(null);
|
|
setPendingJobId(null);
|
|
setIsAdding(false);
|
|
} catch (err: any) {
|
|
setSourceError(err?.message || "Failed to add source.");
|
|
} finally {
|
|
setIsSubmittingSource(false);
|
|
}
|
|
};
|
|
|
|
const handleManualAnalyze = async () => {
|
|
if (!manualProductName || !manualDescription) {
|
|
setSourceError("Product name and description are required.");
|
|
return;
|
|
}
|
|
|
|
if (!pendingSourceId || !pendingProjectId) {
|
|
setSourceError("Missing pending source.");
|
|
return;
|
|
}
|
|
|
|
setIsSubmittingSource(true);
|
|
setSourceError(null);
|
|
|
|
try {
|
|
const response = await fetch("/api/analyze-manual", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
productName: manualProductName,
|
|
description: manualDescription,
|
|
features: manualFeatures,
|
|
jobId: pendingJobId || undefined,
|
|
}),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
await updateDataSourceStatus({
|
|
dataSourceId: pendingSourceId as any,
|
|
analysisStatus: "failed",
|
|
lastError: data.error || "Manual analysis failed",
|
|
lastAnalyzedAt: Date.now(),
|
|
});
|
|
throw new Error(data.error || "Manual analysis failed");
|
|
}
|
|
|
|
if (!data.persisted) {
|
|
await createAnalysis({
|
|
projectId: pendingProjectId as any,
|
|
dataSourceId: pendingSourceId as any,
|
|
analysis: data.data,
|
|
});
|
|
|
|
await updateDataSourceStatus({
|
|
dataSourceId: pendingSourceId as any,
|
|
analysisStatus: "completed",
|
|
lastError: undefined,
|
|
lastAnalyzedAt: Date.now(),
|
|
});
|
|
}
|
|
|
|
setSourceUrl("");
|
|
setSourceName("");
|
|
setManualMode(false);
|
|
setManualProductName("");
|
|
setManualDescription("");
|
|
setManualFeatures("");
|
|
setPendingSourceId(null);
|
|
setPendingProjectId(null);
|
|
setPendingJobId(null);
|
|
setIsAdding(false);
|
|
} catch (err: any) {
|
|
setSourceError(err?.message || "Manual analysis failed.");
|
|
} finally {
|
|
setIsSubmittingSource(false);
|
|
}
|
|
};
|
|
|
|
const handleInputEnter = (
|
|
event: React.KeyboardEvent<HTMLInputElement>,
|
|
next?: React.RefObject<HTMLElement>,
|
|
onSubmit?: () => void
|
|
) => {
|
|
if (event.key !== "Enter" || isSubmittingSource) return;
|
|
event.preventDefault();
|
|
if (next?.current) {
|
|
next.current.focus();
|
|
return;
|
|
}
|
|
onSubmit?.();
|
|
};
|
|
|
|
return (
|
|
<Sidebar variant="inset" {...props}>
|
|
<SidebarHeader>
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<SidebarMenuButton size="lg">
|
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
<span className="truncate font-semibold">{selectedProjectName}</span>
|
|
<span className="truncate text-xs text-muted-foreground">Projects</span>
|
|
</div>
|
|
<ChevronsUpDown className="ml-auto size-4" />
|
|
</SidebarMenuButton>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent side="bottom" align="start" className="w-64">
|
|
<DropdownMenuLabel>Switch project</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
{projects?.map((project) => (
|
|
<DropdownMenuItem
|
|
key={project._id}
|
|
className="justify-between"
|
|
onSelect={(event) => {
|
|
if (event.defaultPrevented) return
|
|
setSelectedProjectId(project._id)
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Frame className="text-muted-foreground" />
|
|
<span className="truncate">{project.name}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
data-project-settings
|
|
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
|
|
onPointerDown={(event) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
}}
|
|
onClick={(event) => {
|
|
event.preventDefault()
|
|
event.stopPropagation();
|
|
setEditingProjectId(project._id);
|
|
setEditingProjectName(project.name);
|
|
setEditingProjectDefault(project.isDefault);
|
|
setEditingProjectError(null);
|
|
setDeleteConfirmName("");
|
|
setDeleteProjectError(null);
|
|
setIsDeleteConfirmOpen(false);
|
|
setIsEditingProject(true);
|
|
}}
|
|
aria-label={`Project settings for ${project.name}`}
|
|
>
|
|
<Settings className="size-4" />
|
|
</button>
|
|
</DropdownMenuItem>
|
|
))}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onSelect={() => setIsCreatingProject(true)}>
|
|
<Plus />
|
|
Create Project
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
</SidebarHeader>
|
|
<SidebarContent>
|
|
{/* Platform Nav */}
|
|
<SidebarGroup>
|
|
<SidebarGroupLabel>Main</SidebarGroupLabel>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton
|
|
asChild
|
|
tooltip="Overview"
|
|
isActive={pathname === "/app/dashboard"}
|
|
>
|
|
<Link href="/app/dashboard">
|
|
<Terminal />
|
|
<span>Overview</span>
|
|
</Link>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton
|
|
asChild
|
|
tooltip="Search"
|
|
isActive={pathname === "/app/search"}
|
|
>
|
|
<Link href="/app/search">
|
|
<Target />
|
|
<span>Search</span>
|
|
</Link>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton
|
|
asChild
|
|
tooltip="Inbox"
|
|
isActive={pathname === "/app/inbox"}
|
|
>
|
|
<Link href="/app/inbox">
|
|
<Inbox />
|
|
<span className="truncate">Inbox</span>
|
|
{inboxCount > 0 && (
|
|
<div className="relative ml-auto flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground group-data-[collapsible=icon]:hidden">
|
|
<span className="absolute -inset-0.5 animate-ping rounded-full bg-primary/60" />
|
|
<span className="relative">{inboxCount}</span>
|
|
</div>
|
|
)}
|
|
</Link>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
|
|
{/* Data Sources Config */}
|
|
{selectedProjectId && (
|
|
<SidebarGroup>
|
|
<SidebarGroupLabel>
|
|
Selected Sources
|
|
<span className="ml-2 text-xs font-normal text-muted-foreground">({selectedProject?.name})</span>
|
|
</SidebarGroupLabel>
|
|
<SidebarGroupContent>
|
|
<div className="flex flex-col gap-2 p-2">
|
|
{(!dataSources || dataSources.length === 0) && (
|
|
<div className="text-sm text-muted-foreground pl-2">No data sources yet.</div>
|
|
)}
|
|
{dataSources?.map((source) => (
|
|
<div
|
|
key={source._id}
|
|
className="flex items-center justify-between gap-2 rounded-md px-2 py-1 hover:bg-muted/40"
|
|
>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<Checkbox
|
|
id={source._id}
|
|
checked={selectedSourceIds.includes(source._id)}
|
|
onCheckedChange={(checked) => handleToggle(source._id, checked === true)}
|
|
/>
|
|
<Label
|
|
htmlFor={source._id}
|
|
className="truncate text-sm font-medium leading-none cursor-pointer"
|
|
>
|
|
{source.name || source.url}
|
|
</Label>
|
|
</div>
|
|
<Link
|
|
href={`/app/data-sources/${source._id}`}
|
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
Details
|
|
<ArrowUpRight className="size-3" />
|
|
</Link>
|
|
</div>
|
|
))}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsAdding(true)}
|
|
>
|
|
Add Data Source
|
|
</Button>
|
|
</div>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
)}
|
|
</SidebarContent>
|
|
<SidebarFooter>
|
|
{currentUser && (currentUser.name || currentUser.email) && (
|
|
<NavUser
|
|
user={{
|
|
name: currentUser.name || currentUser.email || "",
|
|
email: currentUser.email || "",
|
|
avatar: currentUser.image || "",
|
|
}}
|
|
/>
|
|
)}
|
|
</SidebarFooter>
|
|
<Dialog
|
|
open={isAdding}
|
|
onOpenChange={(open) => {
|
|
if (!open && manualMode && pendingSourceId) {
|
|
updateDataSourceStatus({
|
|
dataSourceId: pendingSourceId as any,
|
|
analysisStatus: "failed",
|
|
lastError: "Manual input cancelled",
|
|
lastAnalyzedAt: Date.now(),
|
|
});
|
|
}
|
|
if (!open && manualMode && pendingJobId) {
|
|
updateAnalysisJob({
|
|
jobId: pendingJobId as any,
|
|
status: "failed",
|
|
error: "Manual input cancelled",
|
|
});
|
|
}
|
|
if (!open) {
|
|
setManualMode(false);
|
|
setSourceError(null);
|
|
setSourceNotice(null);
|
|
setPendingSourceId(null);
|
|
setPendingProjectId(null);
|
|
setPendingJobId(null);
|
|
}
|
|
setIsAdding(open);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Add Data Source</DialogTitle>
|
|
<DialogDescription>
|
|
Add a website to analyze for this project.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
{!manualMode && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="sourceUrl">Website URL</Label>
|
|
<Input
|
|
id="sourceUrl"
|
|
placeholder="https://example.com"
|
|
value={sourceUrl}
|
|
onChange={(event) => setSourceUrl(event.target.value)}
|
|
onKeyDown={(event) => handleInputEnter(event, sourceNameRef)}
|
|
disabled={isSubmittingSource}
|
|
ref={sourceUrlRef}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="sourceName">Name (optional)</Label>
|
|
<Input
|
|
id="sourceName"
|
|
placeholder="Product name"
|
|
value={sourceName}
|
|
onChange={(event) => setSourceName(event.target.value)}
|
|
onKeyDown={(event) => handleInputEnter(event, undefined, handleAddSource)}
|
|
disabled={isSubmittingSource}
|
|
ref={sourceNameRef}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
{manualMode && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="manualProductName">Product Name</Label>
|
|
<Input
|
|
id="manualProductName"
|
|
value={manualProductName}
|
|
onChange={(event) => setManualProductName(event.target.value)}
|
|
onKeyDown={(event) => handleInputEnter(event, manualDescriptionRef)}
|
|
disabled={isSubmittingSource}
|
|
ref={manualProductNameRef}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="manualDescription">Description</Label>
|
|
<Textarea
|
|
id="manualDescription"
|
|
value={manualDescription}
|
|
onChange={(event) => setManualDescription(event.target.value)}
|
|
disabled={isSubmittingSource}
|
|
rows={3}
|
|
ref={manualDescriptionRef}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="manualFeatures">Key Features (one per line)</Label>
|
|
<Textarea
|
|
id="manualFeatures"
|
|
value={manualFeatures}
|
|
onChange={(event) => setManualFeatures(event.target.value)}
|
|
disabled={isSubmittingSource}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
{sourceNotice && (
|
|
<div className="text-sm text-muted-foreground">{sourceNotice}</div>
|
|
)}
|
|
{sourceError && (
|
|
<div className="text-sm text-destructive">{sourceError}</div>
|
|
)}
|
|
{analysisJob?.timeline?.length ? (
|
|
<AnalysisTimeline items={analysisJob.timeline} />
|
|
) : null}
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsAdding(false)}
|
|
disabled={isSubmittingSource}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
{manualMode ? (
|
|
<Button onClick={handleManualAnalyze} disabled={isSubmittingSource}>
|
|
{isSubmittingSource ? "Analyzing..." : "Analyze Manually"}
|
|
</Button>
|
|
) : (
|
|
<Button onClick={handleAddSource} disabled={isSubmittingSource}>
|
|
{isSubmittingSource ? "Analyzing..." : "Add Source"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<Dialog open={isEditingProject} onOpenChange={setIsEditingProject}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Project Settings</DialogTitle>
|
|
<DialogDescription>
|
|
Update the project name and default status.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="editProjectName">Project Name</Label>
|
|
<Input
|
|
id="editProjectName"
|
|
value={editingProjectName}
|
|
onChange={(event) => setEditingProjectName(event.target.value)}
|
|
disabled={isSubmittingEdit}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="editProjectDefault"
|
|
checked={editingProjectDefault}
|
|
onCheckedChange={(checked) => setEditingProjectDefault(checked === true)}
|
|
disabled={isSubmittingEdit}
|
|
/>
|
|
<Label htmlFor="editProjectDefault">Set as default</Label>
|
|
</div>
|
|
{editingProjectError && (
|
|
<div className="text-sm text-destructive">{editingProjectError}</div>
|
|
)}
|
|
<div className="border-t border-border pt-4 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-semibold text-destructive">Delete project</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
This removes the project and all related data sources, analyses, and opportunities.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
disabled={!canDeleteProject}
|
|
onClick={() => {
|
|
setDeleteConfirmName("");
|
|
setDeleteProjectError(null);
|
|
setIsDeleteConfirmOpen(true);
|
|
}}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
{!canDeleteProject && (
|
|
<div className="text-xs text-muted-foreground">
|
|
You must keep at least one project.
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsEditingProject(false)}
|
|
disabled={isSubmittingEdit}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={async () => {
|
|
if (!editingProjectId) return;
|
|
if (!editingProjectName.trim()) {
|
|
setEditingProjectError("Project name is required.");
|
|
return;
|
|
}
|
|
setIsSubmittingEdit(true);
|
|
setEditingProjectError(null);
|
|
try {
|
|
await updateProject({
|
|
projectId: editingProjectId as any,
|
|
name: editingProjectName.trim(),
|
|
isDefault: editingProjectDefault,
|
|
});
|
|
setIsEditingProject(false);
|
|
} catch (err: any) {
|
|
setEditingProjectError(err?.message || "Failed to update project.");
|
|
} finally {
|
|
setIsSubmittingEdit(false);
|
|
}
|
|
}}
|
|
disabled={isSubmittingEdit}
|
|
>
|
|
Save Changes
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<Dialog open={isDeleteConfirmOpen} onOpenChange={setIsDeleteConfirmOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete project</DialogTitle>
|
|
<DialogDescription>
|
|
This action is permanent. You are deleting{" "}
|
|
<span className="font-semibold text-foreground">
|
|
{editingProject?.name || "this project"}
|
|
</span>
|
|
. Type the project name to confirm deletion.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="deleteProjectConfirm">Project name</Label>
|
|
<Input
|
|
id="deleteProjectConfirm"
|
|
value={deleteConfirmName}
|
|
onChange={(event) => setDeleteConfirmName(event.target.value)}
|
|
disabled={isDeletingProject || !canDeleteProject}
|
|
/>
|
|
</div>
|
|
{deleteProjectError && (
|
|
<div className="text-sm text-destructive">{deleteProjectError}</div>
|
|
)}
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsDeleteConfirmOpen(false)}
|
|
disabled={isDeletingProject}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={
|
|
isDeletingProject ||
|
|
!canDeleteProject ||
|
|
!editingProject ||
|
|
deleteConfirmName.trim() !== editingProject.name
|
|
}
|
|
onClick={async () => {
|
|
if (!editingProjectId || !editingProject) return;
|
|
if (deleteConfirmName.trim() !== editingProject.name) {
|
|
setDeleteProjectError("Project name does not match.");
|
|
return;
|
|
}
|
|
setDeleteProjectError(null);
|
|
setIsDeletingProject(true);
|
|
try {
|
|
const result = await deleteProject({
|
|
projectId: editingProjectId as any,
|
|
});
|
|
if (selectedProjectId === editingProjectId && result?.newDefaultProjectId) {
|
|
setSelectedProjectId(result.newDefaultProjectId);
|
|
}
|
|
setIsDeleteConfirmOpen(false);
|
|
setIsEditingProject(false);
|
|
} catch (err: any) {
|
|
setDeleteProjectError(err?.message || "Failed to delete project.");
|
|
} finally {
|
|
setIsDeletingProject(false);
|
|
}
|
|
}}
|
|
>
|
|
{isDeletingProject ? "Deleting..." : "Delete Project"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<Dialog open={isCreatingProject} onOpenChange={setIsCreatingProject}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Create Project</DialogTitle>
|
|
<DialogDescription>
|
|
Add a new project for a separate product or workflow.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="projectName">Project Name</Label>
|
|
<Input
|
|
id="projectName"
|
|
value={projectName}
|
|
onChange={(event) => setProjectName(event.target.value)}
|
|
disabled={isSubmittingProject}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Checkbox
|
|
id="projectDefault"
|
|
checked={projectDefault}
|
|
onCheckedChange={(checked) => setProjectDefault(checked === true)}
|
|
/>
|
|
<Label htmlFor="projectDefault">Make this the default project</Label>
|
|
</div>
|
|
{projectError && (
|
|
<div className="text-sm text-destructive">{projectError}</div>
|
|
)}
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsCreatingProject(false)}
|
|
disabled={isSubmittingProject}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={async () => {
|
|
if (!projectName.trim()) {
|
|
setProjectError("Project name is required.");
|
|
return;
|
|
}
|
|
setProjectError(null);
|
|
setIsSubmittingProject(true);
|
|
try {
|
|
const projectId = await createProject({
|
|
name: projectName.trim(),
|
|
isDefault: projectDefault,
|
|
});
|
|
setSelectedProjectId(projectId as any);
|
|
setProjectName("");
|
|
setProjectDefault(true);
|
|
setIsCreatingProject(false);
|
|
} catch (err: any) {
|
|
setProjectError(err?.message || "Failed to create project.");
|
|
} finally {
|
|
setIsSubmittingProject(false);
|
|
}
|
|
}}
|
|
disabled={isSubmittingProject}
|
|
>
|
|
{isSubmittingProject ? "Creating..." : "Create Project"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</Sidebar>
|
|
)
|
|
}
|