From db9f071889421e23e53fa7963fbd746c4a5c060a Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 10 Jun 2026 20:33:57 +0000 Subject: [PATCH] feat(ui): implement pipeline board cards and guarded drag UX --- ui/src/api/pipelines.ts | 269 ++++++++ ui/src/pages/Pipelines.tsx | 1319 ++++++++++++++++++++++++++++++++++++ 2 files changed, 1588 insertions(+) create mode 100644 ui/src/api/pipelines.ts create mode 100644 ui/src/pages/Pipelines.tsx diff --git a/ui/src/api/pipelines.ts b/ui/src/api/pipelines.ts new file mode 100644 index 00000000000..d14afb35839 --- /dev/null +++ b/ui/src/api/pipelines.ts @@ -0,0 +1,269 @@ +import { api } from "./client"; + +export type PipelineStatus = "active" | "paused" | "archived"; + +export type PipelineConnectionRef = + | string + | { + id?: string | null; + pipelineId?: string | null; + upstreamPipelineId?: string | null; + downstreamPipelineId?: string | null; + feedsIntoPipelineId?: string | null; + fedByPipelineId?: string | null; + direction?: string | null; + }; + +export interface PipelineConnections { + feedsIntoPipelineId?: string | null; + downstreamPipelineId?: string | null; + upstreamPipelineIds?: string[]; + downstreamPipelineIds?: string[]; + feedsInto?: PipelineConnectionRef[]; + fedBy?: PipelineConnectionRef[]; + upstream?: PipelineConnectionRef[]; + downstream?: PipelineConnectionRef[]; + [key: string]: unknown; +} + +export interface Pipeline { + id: string; + companyId: string; + name: string; + description: string | null; + status: PipelineStatus; + attentionCount?: number | null; + inMotionCount?: number | null; + openItemCount?: number | null; + openCaseCount?: number | null; + lastActivityAt?: string | Date | null; + updatedAt?: string | Date | null; + createdAt?: string | Date | null; + connections?: PipelineConnections | null; + stages?: PipelineStage[]; + transitions?: PipelineTransition[]; + guidanceDocuments?: PipelineGuidanceDocument[]; + caseCount?: number; +} + +export interface PipelineStage { + id: string; + pipelineId: string; + name: string; + kind: "open" | "working" | "review" | "done" | "cancelled" | string; + position: number; + config?: Record | null; + createdAt?: string | Date; + updatedAt?: string | Date; +} + +export interface PipelineTransition { + fromStageId: string; + toStageId: string; + config?: Record | null; +} + +export interface PipelineGuidanceDocument { + id: string; + pipelineId: string; + key: string; + title: string; + body: string; + createdAt?: string | Date; + updatedAt?: string | Date; +} + +export interface PipelineCase { + id: string; + pipelineId: string; + stageId: string | null; + title: string; + activeWork?: boolean | number | null; + status?: string | null; + fields?: Record | null; + openBlockers?: number | null; + blockedByCaseIds?: string[] | null; + thisChanged?: boolean | null; + childrenSummary?: number | Record | null; + createdAt?: string | Date; + updatedAt?: string | Date; + lastActivityAt?: string | Date | null; +} + +export interface PipelineCaseEvent { + id: string; + companyId: string; + pipelineId: string; + caseId: string; + kind: string; + summary?: string | null; + payload?: Record | null; + createdAt: string | Date; + caseTitle?: string | null; + pipelineName?: string | null; + fromStageName?: string | null; + toStageName?: string | null; +} + +export interface PipelineBlocker { + id: string; + caseId: string; + blockedByCaseId: string; + reason?: string | null; + resolvedAt?: string | Date | null; + createdAt?: string | Date; +} + +export interface PipelineIssueLink { + id: string; + caseId: string; + issueId: string; + role: "origin" | "conversation" | "work" | "automation" | string; + createdAt?: string | Date; +} + +export interface PipelineAttentionItem { + id: string; + pipelineId: string; + caseId?: string | null; + kind: string; + title: string; + summary?: string | null; + createdAt?: string | Date; +} + +export interface PipelineIntakeForm { + pipelineId: string; + stageId: string | null; + fields: Array<{ + key: string; + label: string; + type: string; + required?: boolean; + options?: string[]; + }>; +} + +export interface ListPipelinesOptions { + includeConnections?: boolean; + includeCounts?: boolean; + q?: string; +} + +export interface ListCasesOptions { + stageId?: string; + status?: string; + q?: string; + limit?: number; + offset?: number; +} + +export interface ReviewCasesOptions { + pipelineId?: string; + kind?: string; + limit?: number; + offset?: number; +} + +export interface ListCompanyCaseEventsOptions { + pipelineId?: string; + types?: string; + limit?: number; + offset?: number; +} + +function queryString(filters: object) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(filters)) { + if (value === null || value === undefined || value === "") continue; + params.set(key, String(value)); + } + const qs = params.toString(); + return qs ? `?${qs}` : ""; +} + +export const pipelinesApi = { + list: (companyId: string, options: ListPipelinesOptions = {}) => + api.get(`/companies/${companyId}/pipelines${queryString(options)}`), + get: (pipelineId: string) => api.get(`/pipelines/${pipelineId}`), + create: (companyId: string, data: { name: string; description?: string | null }) => + api.post(`/companies/${companyId}/pipelines`, data), + update: (pipelineId: string, data: Partial>) => + api.patch(`/pipelines/${pipelineId}`, data), + remove: (pipelineId: string) => api.delete(`/pipelines/${pipelineId}`), + + listStages: (pipelineId: string) => + api.get(`/pipelines/${pipelineId}/stages`), + createStage: (pipelineId: string, data: Partial) => + api.post(`/pipelines/${pipelineId}/stages`, data), + updateStage: (pipelineId: string, stageId: string, data: Partial) => + api.patch(`/pipelines/${pipelineId}/stages/${stageId}`, data), + deleteStage: (pipelineId: string, stageId: string) => + api.delete(`/pipelines/${pipelineId}/stages/${stageId}`), + setTransitions: (pipelineId: string, data: { transitions: PipelineTransition[]; enforceTransitions?: boolean }) => + api.put<{ transitions: PipelineTransition[] }>(`/pipelines/${pipelineId}/transitions`, data), + + listGuidanceDocuments: (pipelineId: string) => + api.get(`/pipelines/${pipelineId}/guidance-documents`), + getGuidanceDocument: (pipelineId: string, key: string) => + api.get(`/pipelines/${pipelineId}/guidance-documents/${key}`), + upsertGuidanceDocument: ( + pipelineId: string, + key: string, + data: Pick, + ) => + api.put(`/pipelines/${pipelineId}/guidance-documents/${key}`, data), + deleteGuidanceDocument: (pipelineId: string, key: string) => + api.delete(`/pipelines/${pipelineId}/guidance-documents/${key}`), + + listCases: (pipelineId: string, options: ListCasesOptions = {}) => + api.get(`/pipelines/${pipelineId}/cases${queryString(options)}`), + getCase: (caseId: string) => api.get(`/cases/${caseId}`), + updateCase: (caseId: string, data: Partial) => + api.patch(`/cases/${caseId}`, data), + ingestCase: (pipelineId: string, data: Record) => + api.post(`/pipelines/${pipelineId}/cases/ingest`, data), + ingestCasesBatch: (pipelineId: string, data: { items: Record[] }) => + api.post<{ cases: PipelineCase[] }>(`/pipelines/${pipelineId}/cases/batch`, data), + transitionCase: (caseId: string, data: { toStageId: string; reason?: string | null }) => + api.post(`/cases/${caseId}/transition`, data), + suggestTransition: (caseId: string, data: { toStageId: string; reason?: string | null }) => + api.post(`/cases/${caseId}/suggest-transition`, data), + resolveSuggestion: (caseId: string, data: { decision: "accept" | "decline"; note?: string | null }) => + api.post(`/cases/${caseId}/resolve-suggestion`, data), + + listReviewCases: (companyId: string, options: ReviewCasesOptions = {}) => + api.get(`/companies/${companyId}/review-cases${queryString(options)}`), + reviewCase: (caseId: string, data: { decision: "approve" | "request_changes" | "drop"; note?: string | null }) => + api.post(`/cases/${caseId}/review`, data), + bulkReviewCases: ( + companyId: string, + data: { caseIds: string[]; decision: "approve" | "request_changes" | "drop"; note?: string | null }, + ) => + api.post<{ cases: PipelineCase[] }>(`/companies/${companyId}/review-cases/bulk`, data), + + listBlockers: (caseId: string) => api.get(`/cases/${caseId}/blockers`), + createBlocker: (caseId: string, data: { blockedByCaseId: string; reason?: string | null }) => + api.post(`/cases/${caseId}/blockers`, data), + resolveBlocker: (caseId: string, blockerId: string) => + api.post(`/cases/${caseId}/blockers/${blockerId}/resolve`, {}), + + listIssueLinks: (caseId: string) => api.get(`/cases/${caseId}/issue-links`), + createIssueLink: (caseId: string, data: { issueId: string; role: PipelineIssueLink["role"] }) => + api.post(`/cases/${caseId}/issue-links`, data), + deleteIssueLink: (caseId: string, linkId: string) => + api.delete(`/cases/${caseId}/issue-links/${linkId}`), + openConversation: (caseId: string, data: Record = {}) => + api.post(`/cases/${caseId}/open-conversation`, data), + + listEvents: (pipelineId: string) => + api.get(`/pipelines/${pipelineId}/events`), + listCaseEvents: (caseId: string) => api.get(`/cases/${caseId}/events`), + getIntakeForm: (pipelineId: string) => + api.get(`/pipelines/${pipelineId}/intake-form`), + listAttention: (companyId: string) => + api.get(`/companies/${companyId}/pipelines-attention`), + listCompanyCaseEvents: (companyId: string, options: ListCompanyCaseEventsOptions = {}) => + api.get(`/companies/${companyId}/case-events${queryString(options)}`), + listChildren: (caseId: string) => api.get(`/cases/${caseId}/children`), +}; diff --git a/ui/src/pages/Pipelines.tsx b/ui/src/pages/Pipelines.tsx new file mode 100644 index 00000000000..d8f97701b90 --- /dev/null +++ b/ui/src/pages/Pipelines.tsx @@ -0,0 +1,1319 @@ +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + BookOpenText, + ChevronDown, + ChevronRight, + Hexagon, + Plus, + Search, +} from "lucide-react"; +import { + DndContext, + DragOverlay, + PointerSensor, + useDroppable, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, + type DragOverEvent, +} from "@dnd-kit/core"; +import { CSS } from "@dnd-kit/utilities"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import type { + Pipeline, + PipelineCase, + PipelineCaseEvent, + PipelineConnectionRef, + PipelineConnections, + PipelineStage, + PipelineTransition, +} from "../api/pipelines"; +import { pipelinesApi } from "../api/pipelines"; +import { useCompany } from "../context/CompanyContext"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useToastActions } from "../context/ToastContext"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { EmptyState } from "../components/EmptyState"; +import { PageSkeleton } from "../components/PageSkeleton"; +import { formatLearningEvent } from "../lib/pipeline-learnings"; +import { queryKeys } from "../lib/queryKeys"; +import { cn, formatNumber, formatShortDate, relativeTime } from "../lib/utils"; +import { Link, useNavigate, useParams } from "@/lib/router"; + +export type PipelineViewMode = "nested" | "flat"; + +export interface PipelineTableRow { + pipeline: Pipeline; + depth: number; + feedsIntoName: string | null; + hasChildren: boolean; + expanded: boolean; +} + +function connectionId(ref: PipelineConnectionRef | null | undefined): string | null { + if (!ref) return null; + if (typeof ref === "string") return ref; + return ( + ref.pipelineId ?? + ref.downstreamPipelineId ?? + ref.feedsIntoPipelineId ?? + ref.id ?? + null + ); +} + +function connectionListIds(refs: PipelineConnectionRef[] | null | undefined): string[] { + if (!Array.isArray(refs)) return []; + return refs.map(connectionId).filter((id): id is string => Boolean(id)); +} + +function downstreamPipelineIds(connections: PipelineConnections | null | undefined): string[] { + if (!connections) return []; + + const ids = [ + connections.feedsIntoPipelineId, + connections.downstreamPipelineId, + ...(connections.downstreamPipelineIds ?? []), + ...connectionListIds(connections.feedsInto), + ...connectionListIds(connections.downstream), + ]; + + return ids.filter((id): id is string => Boolean(id)); +} + +function hasConnectionsField(pipeline: Pipeline): boolean { + return Object.prototype.hasOwnProperty.call(pipeline, "connections"); +} + +function pipelineOpenItemCount(pipeline: Pipeline) { + return pipeline.openItemCount ?? pipeline.openCaseCount ?? 0; +} + +function pipelineAttentionCount(pipeline: Pipeline) { + return pipeline.attentionCount ?? 0; +} + +function pipelineInMotionCount(pipeline: Pipeline) { + return pipeline.inMotionCount ?? 0; +} + +function pipelineActivityTime(pipeline: Pipeline) { + return pipeline.lastActivityAt ?? pipeline.updatedAt ?? pipeline.createdAt ?? null; +} + +function compareByInputOrder(order: Map) { + return (left: Pipeline, right: Pipeline) => + (order.get(left.id) ?? Number.MAX_SAFE_INTEGER) - (order.get(right.id) ?? Number.MAX_SAFE_INTEGER); +} + +export function pipelinesHaveConnectionData(pipelines: Pipeline[]) { + return pipelines.some(hasConnectionsField); +} + +export function buildPipelineTableRows( + pipelines: Pipeline[], + options: { + viewMode: PipelineViewMode; + collapsedPipelineIds?: Set; + }, +): PipelineTableRow[] { + if (options.viewMode === "flat") { + return pipelines.map((pipeline) => ({ + pipeline, + depth: 0, + feedsIntoName: null, + hasChildren: false, + expanded: true, + })); + } + + const pipelinesById = new Map(pipelines.map((pipeline) => [pipeline.id, pipeline])); + const inputOrder = new Map(pipelines.map((pipeline, index) => [pipeline.id, index])); + const parentByChild = new Map(); + + for (const pipeline of pipelines) { + const downstreamId = downstreamPipelineIds(pipeline.connections).find((id) => pipelinesById.has(id)); + if (downstreamId && downstreamId !== pipeline.id) { + parentByChild.set(pipeline.id, downstreamId); + } + } + + const childrenByParent = new Map(); + for (const [childId, parentId] of parentByChild.entries()) { + const child = pipelinesById.get(childId); + if (!child) continue; + const children = childrenByParent.get(parentId) ?? []; + children.push(child); + childrenByParent.set(parentId, children); + } + for (const children of childrenByParent.values()) { + children.sort(compareByInputOrder(inputOrder)); + } + + const rows: PipelineTableRow[] = []; + const visited = new Set(); + const collapsed = options.collapsedPipelineIds ?? new Set(); + + function visit(pipeline: Pipeline, depth: number, stack: Set) { + if (visited.has(pipeline.id) || stack.has(pipeline.id)) return; + visited.add(pipeline.id); + + const children = childrenByParent.get(pipeline.id) ?? []; + const parentId = parentByChild.get(pipeline.id); + rows.push({ + pipeline, + depth, + feedsIntoName: parentId ? pipelinesById.get(parentId)?.name ?? null : null, + hasChildren: children.length > 0, + expanded: !collapsed.has(pipeline.id), + }); + + if (collapsed.has(pipeline.id)) return; + + const nextStack = new Set(stack); + nextStack.add(pipeline.id); + for (const child of children) { + visit(child, depth + 1, nextStack); + } + } + + const roots = pipelines + .filter((pipeline) => !parentByChild.has(pipeline.id)) + .sort(compareByInputOrder(inputOrder)); + for (const root of roots) visit(root, 0, new Set()); + for (const pipeline of pipelines) visit(pipeline, 0, new Set()); + + return rows; +} + +function formatOpenItems(count: number) { + return `${formatNumber(count)} open`; +} + +function formatPipelineActivity(value: string | Date | null) { + if (!value) return "No activity"; + const then = new Date(value).getTime(); + if (!Number.isFinite(then)) return "No activity"; + const diffSeconds = Math.max(0, Math.round((Date.now() - then) / 1000)); + if (diffSeconds < 60) return "just now"; + const diffMinutes = Math.round(diffSeconds / 60); + if (diffMinutes < 60) return `${diffMinutes} min ago`; + const diffHours = Math.round(diffMinutes / 60); + if (diffHours < 24) return diffHours === 1 ? "1 hr ago" : `${diffHours} hr ago`; + const diffDays = Math.round(diffHours / 24); + if (diffDays === 1) return "yesterday"; + if (diffDays < 7) return `${diffDays} days ago`; + if (diffDays < 14) return "last week"; + if (diffDays < 30) return `${Math.round(diffDays / 7)} weeks ago`; + return new Date(value).toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +const LEARNINGS_PAGE_SIZE = 100; +const LEARNING_EVENT_TYPES = "review_decided,transition_forced"; + +function learningDayKey(value: string | Date) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Unknown"; + return date.toISOString().slice(0, 10); +} + +function learningDayLabel(value: string | Date) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Unknown"; + const today = new Date(); + const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime(); + const startOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + const diffDays = Math.round((startOfToday - startOfDay) / 86_400_000); + if (diffDays === 0) return "Today"; + if (diffDays === 1) return "Yesterday"; + return formatShortDate(date); +} + +function groupLearningEventsByDay(events: PipelineCaseEvent[]) { + const groups: Array<{ key: string; label: string; events: PipelineCaseEvent[] }> = []; + for (const event of events) { + const key = learningDayKey(event.createdAt); + const existing = groups.find((group) => group.key === key); + if (existing) { + existing.events.push(event); + continue; + } + groups.push({ key, label: learningDayLabel(event.createdAt), events: [event] }); + } + return groups; +} + +function PipelineStatusChip({ status }: { status: Pipeline["status"] }) { + const paused = status === "paused"; + return ( + + {paused ? "Paused" : "Active"} + + ); +} + +interface PipelinesIndexTableProps { + pipelines: Pipeline[]; + viewMode: PipelineViewMode; + onViewModeChange: (mode: PipelineViewMode) => void; + connectionsAvailable: boolean; + search: string; + onSearchChange: (search: string) => void; +} + +export function PipelinesIndexTable({ + pipelines, + viewMode, + onViewModeChange, + connectionsAvailable, + search, + onSearchChange, +}: PipelinesIndexTableProps) { + const [collapsedPipelineIds, setCollapsedPipelineIds] = useState>(() => new Set()); + const filteredPipelines = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return pipelines; + return pipelines.filter((pipeline) => pipeline.name.toLowerCase().includes(q)); + }, [pipelines, search]); + const effectiveViewMode = connectionsAvailable ? viewMode : "flat"; + const rows = useMemo( + () => + buildPipelineTableRows(filteredPipelines, { + viewMode: effectiveViewMode, + collapsedPipelineIds, + }), + [collapsedPipelineIds, effectiveViewMode, filteredPipelines], + ); + + const togglePipeline = (pipelineId: string) => { + setCollapsedPipelineIds((current) => { + const next = new Set(current); + if (next.has(pipelineId)) next.delete(pipelineId); + else next.add(pipelineId); + return next; + }); + }; + + return ( +
+
+ +
+ View +
+ + +
+
+
+ + {rows.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {rows.map((row) => { + const attentionCount = pipelineAttentionCount(row.pipeline); + const inMotionCount = pipelineInMotionCount(row.pipeline); + return ( + + + + + + + + ); + })} + +
NameAttentionOpen itemsStatusLast activity
+
+ {row.hasChildren ? ( + + ) : ( +
+
+
+ {attentionCount > 0 ? ( + + + ) : null} + {inMotionCount > 0 ? ( + + {formatNumber(inMotionCount)} in motion + + ) : null} +
+
{formatOpenItems(pipelineOpenItemCount(row.pipeline))}{formatPipelineActivity(pipelineActivityTime(row.pipeline))}
+

+ Showing {formatNumber(rows.length)} of {formatNumber(filteredPipelines.length)}. +

+
+ )} +
+ ); +} + +const UNASSIGNED_STAGE_ID = "__pipeline_unassigned_stage"; +const UNASSIGNED_STAGE_NAME = "Unassigned"; + +function asText(value: unknown): string | null { + if (typeof value !== "string") return null; + const next = value.trim(); + return next.length === 0 ? null : next; +} + +function asBoolean(value: unknown): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value > 0; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on"; + } + return false; +} + +function asPositiveInteger(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + return Math.floor(value); + } + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed >= 0) return Math.floor(parsed); + } + return null; +} + +function getCaseTitle(caseItem: PipelineCase) { + const fields = caseItem.fields ?? {}; + const candidateKeys = [ + "title", + "name", + "summary", + "subject", + "item_title", + "itemTitle", + "issueTitle", + "ticketTitle", + ] as const; + + for (const key of candidateKeys) { + const value = asText(fields[key]); + if (value) return value; + } + return asText(caseItem.title) ?? "Untitled item"; +} + +function isWorkingCase(caseItem: PipelineCase) { + const fields = caseItem.fields ?? {}; + return ( + asBoolean(caseItem.activeWork) || + asBoolean(fields.activeWork) || + asBoolean(fields.active_work) || + asBoolean(fields.isActiveWork) || + asBoolean(fields.working) || + asBoolean(fields.isWorking) + ); +} + +function getOpenBlockerCount(caseItem: PipelineCase) { + if (typeof caseItem.openBlockers === "number") return Math.max(0, Math.floor(caseItem.openBlockers)); + if (Array.isArray(caseItem.blockedByCaseIds)) return caseItem.blockedByCaseIds.length; + const fields = caseItem.fields ?? {}; + const fromFields = asPositiveInteger(fields.openBlockers); + if (fromFields != null) return fromFields; + return 0; +} + +function hasThisChanged(caseItem: PipelineCase) { + const fields = caseItem.fields ?? {}; + return ( + asBoolean(caseItem.thisChanged) || + asBoolean(fields.thisChanged) || + asBoolean(fields["this changed"]) || + asBoolean(fields.this_changed) || + asBoolean(fields.hasThisChanged) + ); +} + +function getChildrenSummaryCount(caseItem: PipelineCase) { + if (typeof caseItem.childrenSummary === "number") return asPositiveInteger(caseItem.childrenSummary); + const fields = caseItem.fields ?? {}; + const fromFields = asPositiveInteger(fields.childrenSummary); + if (fromFields != null) return fromFields; + if (caseItem.childrenSummary && typeof caseItem.childrenSummary === "object") { + const candidate = caseItem.childrenSummary as { + total?: unknown; + count?: unknown; + builtFrom?: unknown; + n?: unknown; + }; + return ( + asPositiveInteger(candidate.total) ?? + asPositiveInteger(candidate.count) ?? + asPositiveInteger(candidate.builtFrom) ?? + asPositiveInteger(candidate.n) + ); + } + return null; +} + +function createUnassignedStage(pipelineId: string): PipelineStage { + return { + id: UNASSIGNED_STAGE_ID, + pipelineId, + name: UNASSIGNED_STAGE_NAME, + kind: "open", + position: Number.MAX_SAFE_INTEGER, + config: {}, + }; +} + +function isGuardedTransitionAllowed( + transitions: PipelineTransition[], + sourceStageId: string | null, + targetStageId: string, +) { + if (!transitions.length) return true; + if (!sourceStageId) return false; + if (sourceStageId === targetStageId) return true; + + for (const transition of transitions) { + if (transition.fromStageId === sourceStageId && transition.toStageId === targetStageId) { + return true; + } + } + return false; +} + +function resolvePipelineTargetStageId( + overId: string, + columns: Set, + caseToColumnId: Map, +) { + if (columns.has(overId)) return overId; + return caseToColumnId.get(overId) ?? null; +} + +function PipelineCaseCard({ + caseItem, + isOverlay = false, +}: { + caseItem: PipelineCase; + isOverlay?: boolean; +}) { + const title = getCaseTitle(caseItem); + const isWorking = isWorkingCase(caseItem); + const blockerCount = getOpenBlockerCount(caseItem); + const hasNeedsAttention = blockerCount > 0; + const hasThisChanged = hasThisChanged(caseItem); + const childrenSummary = getChildrenSummaryCount(caseItem); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: caseItem.id, data: { caseItem } }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ { + if (isDragging) event.preventDefault(); + }} + className="block text-inherit no-underline" + > +

{title}

+
+ {isWorking ? ( + + + Working + + ) : null} + {hasNeedsAttention ? ( + + Needs attention + + ) : null} + {hasThisChanged ? ( + + This changed + + ) : null} +
+ {childrenSummary != null ? ( +

+ Built from {formatNumber(childrenSummary)} items +

+ ) : null} + +
+ ); +} + +function PipelineBoardColumn({ + stage, + cases, + onColumnEmpty, + isDragTargeted, + isDragBlocked, +}: { + stage: PipelineStage; + cases: PipelineCase[]; + onColumnEmpty?: (stage: PipelineStage) => string; + isDragTargeted?: boolean; + isDragBlocked?: boolean; +}) { + const { setNodeRef, isOver } = useDroppable({ id: stage.id }); + + const isBlockedDropTarget = !!isDragTargeted && !!isDragBlocked; + + return ( +
+
+ {stage.name} + {cases.length} +
+
+ {isBlockedDropTarget ? ( +

+ Transition not allowed by guardrails +

+ ) : null} + entry.id)} strategy={verticalListSortingStrategy}> + {cases.length > 0 ? ( + cases.map((item) => ) + ) : ( +
+ {onColumnEmpty ? onColumnEmpty(stage) : "Empty"} +
+ )} +
+
+
+ ); +} + +function NewPipelineDialog({ + open, + onOpenChange, + onSubmit, + pending, + error, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: { name: string; description: string }) => void; + pending: boolean; + error: string | null; +}) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + + useEffect(() => { + if (!open) { + setName(""); + setDescription(""); + } + }, [open]); + + const submit = (event: FormEvent) => { + event.preventDefault(); + const trimmedName = name.trim(); + if (!trimmedName) return; + onSubmit({ name: trimmedName, description: description.trim() }); + }; + + return ( + + +
+ + New pipeline + Name the pipeline and add a short description. + +
+ +