Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 95 additions & 1 deletion src/app/(dashboard)/workspaces/[workspaceId]/programs/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import {
Shield,
ChevronRight,
Layers,
Info,
CheckCircle2,
BarChart3,
GitMerge,
Milestone,
} from "lucide-react";
import Link from "next/link";
import { useState, useMemo } from "react";
Expand Down Expand Up @@ -51,6 +56,12 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id";
import { useGetPrograms } from "@/features/programs/api/use-get-programs";
import { useDeleteProgram } from "@/features/programs/api/use-delete-program";
Expand Down Expand Up @@ -121,6 +132,7 @@ export const ProgramsClient = () => {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [view, setView] = useState<"grid" | "list">("grid");
const [showInfoModal, setShowInfoModal] = useState(false);

const [DeleteDialog, confirmDelete] = useConfirm(
"Delete Program",
Expand Down Expand Up @@ -160,6 +172,79 @@ export const ProgramsClient = () => {
<CreateProgramModal />
<EditProgramModal />

{/* Programs Info Modal */}
<Dialog open={showInfoModal} onOpenChange={setShowInfoModal}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-xl">
<div className="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10">
<FolderKanban className="size-5 text-primary" />
</div>
What is a Program?
</DialogTitle>
</DialogHeader>
<div className="space-y-6 mt-2">
<p className="text-muted-foreground text-sm leading-relaxed">
A <strong className="text-foreground">Program</strong> is a strategic umbrella that groups related projects under a shared objective. It gives your organization a bird&apos;s-eye view of work spanning multiple teams, timelines, and deliverables.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{[
{ icon: Layers, title: "Multi-Project Oversight", desc: "Bundle related projects so stakeholders see progress in one place instead of switching between boards.", colorClass: "bg-purple-500/10 border-purple-500/20", iconColor: "text-purple-600 dark:text-purple-400" },
{ icon: Milestone, title: "Milestones", desc: "Define key delivery checkpoints — e.g. Beta Launch, Go-Live — shared across every project in the program.", colorClass: "bg-blue-500/10 border-blue-500/20", iconColor: "text-blue-600 dark:text-blue-400" },
{ icon: Users, title: "Cross-team Members", desc: "Add contributors from any project team so everyone on the initiative is visible in one place.", colorClass: "bg-emerald-500/10 border-emerald-500/20", iconColor: "text-emerald-600 dark:text-emerald-400" },
{ icon: BarChart3, title: "Analytics", desc: "Rolled-up completion rates, task counts, and timeline health for the entire program at a glance.", colorClass: "bg-amber-500/10 border-amber-500/20", iconColor: "text-amber-600 dark:text-amber-400" },
].map(({ icon: Icon, title, desc, colorClass, iconColor }) => (
<div key={title} className={`p-4 rounded-xl border ${colorClass}`}>
<div className="flex items-center gap-2 mb-2">
<Icon className={`size-4 ${iconColor}`} />
<p className="text-sm font-semibold">{title}</p>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">{desc}</p>
</div>
))}
</div>
<div>
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<GitMerge className="size-4 text-muted-foreground" />
How it works in industry
</h4>
<div className="space-y-3">
{[
{ company: "Product Company", program: "Q3 Platform Revamp", projects: ["API v2 Migration", "New Design System", "Mobile App Rewrite"] },
{ company: "E-commerce", program: "Holiday Season Launch", projects: ["Checkout Optimisation", "Inventory Sync", "Marketing Campaigns"] },
{ company: "Enterprise", program: "Cloud Migration", projects: ["Infra Lift & Shift", "Data Pipeline Rebuild", "Security Hardening"] },
].map((ex) => (
<div key={ex.program} className="p-3 rounded-lg border bg-muted/30">
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className="text-[10px]">{ex.company}</Badge>
<p className="text-sm font-medium">{ex.program}</p>
</div>
<div className="flex flex-wrap gap-1.5">
{ex.projects.map((proj) => (
<div key={proj} className="flex items-center gap-1 text-[11px] text-muted-foreground bg-background border rounded-full px-2 py-0.5">
<CheckCircle2 className="size-3 text-emerald-500" />
{proj}
</div>
))}
</div>
</div>
))}
</div>
</div>
<div className="p-4 rounded-xl bg-primary/5 border border-primary/20">
<p className="text-sm font-medium mb-1">Ready to get started?</p>
<p className="text-xs text-muted-foreground mb-3">Create your first program, link your projects, and start tracking progress at the strategic level.</p>
{isAdmin && (
<Button size="sm" onClick={() => { setShowInfoModal(false); openCreate(); }}>
<Plus className="size-3.5 mr-1.5" />
Create a Program
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>

{/* Header */}
<div className="border-b bg-background">
<div className="px-6 py-5 flex items-center justify-between">
Expand All @@ -168,7 +253,16 @@ export const ProgramsClient = () => {
<Layers className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Programs</h1>
<div className="flex items-center gap-1.5">
<h1 className="text-2xl font-semibold tracking-tight">Programs</h1>
<button
onClick={() => setShowInfoModal(true)}
className="p-1 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="What is a Program?"
>
<Info className="size-4" />
</button>
</div>
<p className="text-sm text-muted-foreground">
Manage strategic initiatives and portfolio health
</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ const WorkflowEditor = () => {
// ─────────────────────────────────────────────────────────────────────────

const hasSyncedOnMount = useRef(false);
const hasAutoSyncedProjects = useRef(false);
const _hasAutoSyncedProjects = useRef(false);
useEffect(() => {
if (!hasSyncedOnMount.current && workflowId) {
hasSyncedOnMount.current = true;
Expand All @@ -135,9 +135,12 @@ const WorkflowEditor = () => {
const { mutateAsync: updateTransition } = useUpdateTransition();
const { mutateAsync: deleteTransitionMutation } = useDeleteTransition();
const { mutate: updateProject, isPending: isUpdatingProject } = useUpdateProject();
const { mutate: syncFromProject, isPending: isSyncing } = useSyncFromProject();
const { mutate: syncFromProject } = useSyncFromProject();
const { mutate: syncWithResolution } = useSyncWithResolution();

// Track which project is currently syncing (instead of a single global boolean)
const [syncingProjectId, setSyncingProjectId] = useState<string | null>(null);

const projects = useMemo(() => {
if (!projectsData?.documents) return [];
return projectsData.documents.filter((p) => p.spaceId === spaceId);
Expand All @@ -148,15 +151,6 @@ const WorkflowEditor = () => {
[projects, workflowId]
);

useEffect(() => {
if (!hasAutoSyncedProjects.current && connectedProjects.length > 0 && !workflowLoading) {
hasAutoSyncedProjects.current = true;
const projectToSync = connectedProjects[0];
if (projectToSync) {
syncFromProject({ param: { workflowId, projectId: projectToSync.$id } });
}
}
}, [connectedProjects, workflowLoading, workflowId, syncFromProject]);

const connectedProjectIds = useMemo(
() => connectedProjects.map((p) => p.$id),
Expand Down Expand Up @@ -424,6 +418,28 @@ const WorkflowEditor = () => {
handleEdgeEdit, handleEdgeDelete,
]);

// ── auto-fit to preview nodes when preview mode activates ────────────────
const hasAutoFittedPreview = useRef(false);
useEffect(() => {
if (!previewSuggestion) {
hasAutoFittedPreview.current = false;
return;
}
if (hasAutoFittedPreview.current) return;
const previewNodes = nodes.filter((n) => (n.data as StatusNodeData)?.isPreview);
if (previewNodes.length === 0) return;
hasAutoFittedPreview.current = true;
const timer = setTimeout(() => {
reactFlowInstance.fitView({
nodes: previewNodes.map((n) => ({ id: n.id })),
duration: 700,
padding: 0.35,
});
}, 350);
return () => clearTimeout(timer);
}, [nodes, previewSuggestion, reactFlowInstance]);
// ─────────────────────────────────────────────────────────────────────────

const handleDelete = async () => {
const ok = await confirmDelete();
if (!ok) return;
Expand All @@ -445,12 +461,11 @@ const WorkflowEditor = () => {
{
onSuccess: () => {
setConnectProjectOpen(false);
syncFromProject({ param: { workflowId, projectId } });
},
}
);
},
[workflowId, updateProject, syncFromProject, syncWithResolution]
[workflowId, updateProject, syncWithResolution]
);

const handleDisconnectProject = useCallback(
Expand All @@ -463,7 +478,13 @@ const WorkflowEditor = () => {
);

const handleSyncFromProject = useCallback(
(projectId: string) => syncFromProject({ param: { workflowId, projectId } }),
(projectId: string) => {
setSyncingProjectId(projectId);
syncFromProject(
{ param: { workflowId, projectId } },
{ onSettled: () => setSyncingProjectId(null) }
);
},
[workflowId, syncFromProject]
);

Expand Down Expand Up @@ -739,15 +760,15 @@ const WorkflowEditor = () => {
<Button
size="sm"
className="h-7 px-3 text-xs bg-purple-600 hover:bg-purple-700"
onClick={() => { handleApplyFullWorkflow(previewSuggestion); setPreviewSuggestion(null); }}
onClick={() => { handleApplyFullWorkflow(previewSuggestion); setPreviewSuggestion(null); setPanelOpen(true); }}
>
Apply All
Apply
</Button>
<Button
size="sm"
variant="outline"
className="h-7 px-3 text-xs"
onClick={() => setPreviewSuggestion(null)}
onClick={() => { setPreviewSuggestion(null); setPanelOpen(true); }}
>
Exit
</Button>
Expand Down Expand Up @@ -845,16 +866,7 @@ const WorkflowEditor = () => {
</>
)}

{/* Toggle side panel */}
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-foreground"
onClick={() => setPanelOpen((o) => !o)}
title={panelOpen ? "Hide panel" : "Show panel"}
>
{panelOpen ? <ChevronRight className="size-4" /> : <ChevronLeft className="size-4" />}
</Button>

</div>
</div>

Expand All @@ -864,6 +876,18 @@ const WorkflowEditor = () => {
Slides in/out with a CSS transition.
Does NOT affect canvas width — canvas always stays full-bleed.
*/}

{/* Floating tab to reopen panel when it is closed */}
{!panelOpen && (
<button
onClick={() => setPanelOpen(true)}
title="Open panel"
className="absolute top-[100px] right-0 z-30 flex items-center justify-center w-6 h-12 bg-background/90 backdrop-blur border border-r-0 rounded-l-lg shadow-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<ChevronLeft className="size-3.5" />
</button>
)}

<div
className="
absolute top-[88px] right-5 bottom-5 z-20
Expand All @@ -884,9 +908,9 @@ className="
{/* Inner wrapper keeps content at full width so it doesn't squish during animation */}
<div className="flex flex-col h-full" style={{ width: `${PANEL_WIDTH}px` }}>
<Tabs defaultValue="builder" className="flex flex-col h-full">
{/* Tab headers */}
<div className="px-4 pt-4 pb-2 shrink-0">
<TabsList className="grid w-full grid-cols-2 h-11 rounded-xl bg-black/[0.03] dark:bg-white/[0.04] p-1"> <TabsTrigger value="builder" className="
{/* Tab headers + close button */}
<div className="flex items-center gap-1 px-4 pt-4 pb-2 shrink-0">
<TabsList className="grid flex-1 grid-cols-2 h-11 rounded-xl bg-black/[0.03] dark:bg-white/[0.04] p-1"> <TabsTrigger value="builder" className="
text-xs gap-1.5 rounded-lg py-1
data-[state=active]:bg-blue-600/10
data-[state=active]:text-blue-600
Expand All @@ -911,6 +935,15 @@ className="
AI Assistant
</TabsTrigger>
</TabsList>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => setPanelOpen(false)}
title="Close panel"
>
<ChevronRight className="size-4" />
</Button>
</div>

{/* Builder tab */}
Expand Down Expand Up @@ -963,7 +996,7 @@ className="
onConnectProject={() => setConnectProjectOpen(true)}
onDisconnectProject={handleDisconnectProject}
onSyncFromProject={handleSyncFromProject}
isSyncing={isSyncing}
syncingProjectId={syncingProjectId ?? undefined}
onRemoveStatus={handleRemoveStatus}
/>
</div>
Expand All @@ -987,6 +1020,8 @@ className="
} else {
setPreviewSuggestion({ statuses: [], transitions: [suggestion as TransitionSuggestion] });
}
// Hide the panel so the full canvas is visible in preview mode
setPanelOpen(false);
}}
onApplyFullWorkflow={handleApplyFullWorkflow}
/>
Expand Down
Loading
Loading