diff --git a/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx b/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx index c07db43..e393278 100644 --- a/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx +++ b/src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx @@ -20,6 +20,7 @@ import { } from '@/components/ui/dropdown-menu' import { toast } from 'sonner' import { cn } from '@/lib/utils' +import type { Route } from 'next' import { ArrowLeft, MoreHorizontal, @@ -30,6 +31,7 @@ import { Loader2, ChevronDown, Save, + Wand2, } from 'lucide-react' import { InlineEditableText } from '@/components/ui/inline-editable-text' @@ -41,8 +43,8 @@ import { AwardsSection } from '@/components/admin/pipeline/sections/awards-secti import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section' import { RoutingRulesEditor } from '@/components/admin/pipeline/routing-rules-editor' import { AwardGovernanceEditor } from '@/components/admin/pipeline/award-governance-editor' -import { normalizeStageConfig } from '@/lib/stage-config-schema' import { defaultNotificationConfig } from '@/lib/pipeline-defaults' +import { toWizardTrackConfig } from '@/lib/pipeline-conversions' import type { WizardTrackConfig } from '@/types/pipeline-wizard' const statusColors: Record = { @@ -52,71 +54,6 @@ const statusColors: Record = { CLOSED: 'bg-blue-100 text-blue-700', } -function toWizardTrackConfig( - track: { - id: string - name: string - slug: string - kind: 'MAIN' | 'AWARD' | 'SHOWCASE' - sortOrder: number - routingMode: 'PARALLEL' | 'EXCLUSIVE' | 'POST_MAIN' | null - decisionMode: - | 'JURY_VOTE' - | 'AWARD_MASTER_DECISION' - | 'ADMIN_DECISION' - | null - stages: Array<{ - id: string - name: string - slug: string - stageType: - | 'INTAKE' - | 'FILTER' - | 'EVALUATION' - | 'SELECTION' - | 'LIVE_FINAL' - | 'RESULTS' - sortOrder: number - configJson: unknown - }> - specialAward?: { - name: string - description: string | null - scoringMode: 'PICK_WINNER' | 'RANKED' | 'SCORED' - } | null - } -): WizardTrackConfig { - return { - id: track.id, - name: track.name, - slug: track.slug, - kind: track.kind, - sortOrder: track.sortOrder, - routingModeDefault: track.routingMode ?? undefined, - decisionMode: track.decisionMode ?? undefined, - stages: track.stages - .map((stage) => ({ - id: stage.id, - name: stage.name, - slug: stage.slug, - stageType: stage.stageType, - sortOrder: stage.sortOrder, - configJson: normalizeStageConfig( - stage.stageType, - stage.configJson as Record | null - ), - })) - .sort((a, b) => a.sortOrder - b.sortOrder), - awardConfig: track.specialAward - ? { - name: track.specialAward.name, - description: track.specialAward.description ?? undefined, - scoringMode: track.specialAward.scoringMode, - } - : undefined, - } -} - export default function PipelineDetailPage() { const params = useParams() const pipelineId = params.id as string @@ -450,6 +387,13 @@ export default function PipelineDetailPage() { + + + + Edit in Wizard + + + {pipeline.status === 'DRAFT' && ( -

Routing Rules

-

- Define conditions for routing projects between tracks. -

(null) + const [currentStep, setCurrentStep] = useState(0) + const initialStateRef = useRef('') + + // Load existing pipeline data + const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery( + { id: pipelineId }, + { enabled: !!pipelineId } + ) + + // Initialize state when pipeline data loads + useEffect(() => { + if (pipeline && !state) { + const settings = (pipeline.settingsJson as Record | null) ?? {} + const initialState: WizardState = { + name: pipeline.name, + slug: pipeline.slug, + programId: pipeline.programId, + settingsJson: settings, + tracks: pipeline.tracks + .sort((a, b) => a.sortOrder - b.sortOrder) + .map(track => toWizardTrackConfig({ + id: track.id, + name: track.name, + slug: track.slug, + kind: track.kind as 'MAIN' | 'AWARD' | 'SHOWCASE', + sortOrder: track.sortOrder, + routingMode: track.routingMode as 'PARALLEL' | 'EXCLUSIVE' | 'POST_MAIN' | null, + decisionMode: track.decisionMode as 'JURY_VOTE' | 'AWARD_MASTER_DECISION' | 'ADMIN_DECISION' | null, + stages: track.stages.map(s => ({ + id: s.id, + name: s.name, + slug: s.slug, + stageType: s.stageType as 'INTAKE' | 'FILTER' | 'EVALUATION' | 'SELECTION' | 'LIVE_FINAL' | 'RESULTS', + sortOrder: s.sortOrder, + configJson: s.configJson, + })), + specialAward: track.specialAward ? { + name: track.specialAward.name, + description: track.specialAward.description, + scoringMode: track.specialAward.scoringMode as 'PICK_WINNER' | 'RANKED' | 'SCORED', + } : null, + })), + notificationConfig: (settings.notificationConfig as Record) ?? defaultNotificationConfig(), + overridePolicy: (settings.overridePolicy as Record) ?? { allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] }, + } + setState(initialState) + initialStateRef.current = JSON.stringify(initialState) + } + }, [pipeline, state]) + + // Dirty tracking — warn on navigate away + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (state && JSON.stringify(state) !== initialStateRef.current) { + e.preventDefault() + } + } + window.addEventListener('beforeunload', handleBeforeUnload) + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, [state]) + + const updateState = useCallback((updates: Partial) => { + setState((prev) => prev ? { ...prev, ...updates } : prev) + }, []) + + // Mutations + const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({ + onError: (err) => { + toast.error(err.message) + }, + }) + + const updateSettingsMutation = trpc.pipeline.update.useMutation({ + onError: (err) => { + toast.error(err.message) + }, + }) + + const publishMutation = trpc.pipeline.publish.useMutation({ + onSuccess: () => { + toast.success('Pipeline published successfully') + }, + onError: (err) => { + toast.error(err.message) + }, + }) + + const handleSave = async (publish: boolean) => { + if (!state) return + + const validation = validateAll(state) + if (!validation.valid) { + toast.error('Please fix validation errors before saving') + if (!validation.sections.basics.valid) setCurrentStep(0) + else if (!validation.sections.tracks.valid) setCurrentStep(2) + return + } + + await updateStructureMutation.mutateAsync({ + id: pipelineId, + name: state.name, + slug: state.slug, + settingsJson: { + ...state.settingsJson, + notificationConfig: state.notificationConfig, + overridePolicy: state.overridePolicy, + }, + tracks: state.tracks.map((t) => ({ + id: t.id, + name: t.name, + slug: t.slug, + kind: t.kind, + sortOrder: t.sortOrder, + routingModeDefault: t.routingModeDefault, + decisionMode: t.decisionMode, + stages: t.stages.map((s) => ({ + id: s.id, + name: s.name, + slug: s.slug, + stageType: s.stageType, + sortOrder: s.sortOrder, + configJson: s.configJson, + })), + awardConfig: t.awardConfig, + })), + autoTransitions: true, + }) + + await updateSettingsMutation.mutateAsync({ + id: pipelineId, + settingsJson: { + ...state.settingsJson, + notificationConfig: state.notificationConfig, + overridePolicy: state.overridePolicy, + }, + }) + + if (publish) { + await publishMutation.mutateAsync({ id: pipelineId }) + } + + initialStateRef.current = JSON.stringify(state) + toast.success(publish ? 'Pipeline saved and published' : 'Pipeline changes saved') + router.push(`/admin/rounds/pipeline/${pipelineId}` as Route) + } + + const isSaving = updateStructureMutation.isPending && !publishMutation.isPending + const isSubmitting = publishMutation.isPending + + // Loading state + if (isLoading || !state) { + return ( +
+
+ + + +
+

Edit Pipeline (Wizard)

+

Loading pipeline data...

+
+
+
+ +
+
+ ) + } + + // Get stage configs from the main track + const mainTrack = state.tracks.find((t) => t.kind === 'MAIN') + const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE') + const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER') + const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION') + const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL') + + const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig + const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig + const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig + const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig + + const updateStageConfig = (stageType: string, configJson: Record) => { + setState((prev) => { + if (!prev) return prev + return { + ...prev, + tracks: prev.tracks.map((track) => { + if (track.kind !== 'MAIN') return track + return { + ...track, + stages: track.stages.map((stage) => + stage.stageType === stageType ? { ...stage, configJson } : stage + ), + } + }), + } + }) + } + + const updateMainTrackStages = (stages: WizardState['tracks'][0]['stages']) => { + setState((prev) => { + if (!prev) return prev + return { + ...prev, + tracks: prev.tracks.map((track) => + track.kind === 'MAIN' ? { ...track, stages } : track + ), + } + }) + } + + // Validation + const basicsValid = validateBasics(state).valid + const tracksValid = validateTracks(state.tracks).valid + const allValid = validateAll(state).valid + + // Step configuration + const steps: StepConfig[] = [ + { + title: 'Basics', + description: 'Pipeline name and program', + isValid: basicsValid, + }, + { + title: 'Intake', + description: 'Submission window & files', + isValid: !!intakeStage, + }, + { + title: 'Main Track Stages', + description: 'Configure pipeline stages', + isValid: tracksValid, + }, + { + title: 'Screening', + description: 'Gate rules and AI screening', + isValid: !!filterStage, + }, + { + title: 'Evaluation', + description: 'Jury assignment strategy', + isValid: !!evalStage, + }, + { + title: 'Awards', + description: 'Special award tracks', + isValid: true, + }, + { + title: 'Live Finals', + description: 'Voting and reveal settings', + isValid: !!liveStage, + }, + { + title: 'Notifications', + description: 'Event notifications', + isValid: true, + }, + { + title: 'Review & Save', + description: 'Validation summary', + isValid: allValid, + }, + ] + + return ( +
+ {/* Header */} +
+ + + +
+

Edit Pipeline (Wizard)

+

+ Modify the pipeline structure for project evaluation +

+
+
+ + {/* Sidebar Stepper */} + handleSave(false)} + onSubmit={() => handleSave(true)} + isSaving={isSaving} + isSubmitting={isSubmitting} + saveLabel="Save Changes" + submitLabel="Save & Publish" + canSubmit={allValid} + > + {/* Step 0: Basics */} +
+ +
+ + {/* Step 1: Intake */} +
+ + updateStageConfig('INTAKE', c as unknown as Record) + } + /> +
+ + {/* Step 2: Main Track Stages */} +
+ +
+ + {/* Step 3: Screening */} +
+ + updateStageConfig('FILTER', c as unknown as Record) + } + /> +
+ + {/* Step 4: Evaluation */} +
+ + updateStageConfig('EVALUATION', c as unknown as Record) + } + /> +
+ + {/* Step 5: Awards */} +
+ updateState({ tracks })} + /> +
+ + {/* Step 6: Live Finals */} +
+ + updateStageConfig('LIVE_FINAL', c as unknown as Record) + } + /> +
+ + {/* Step 7: Notifications */} +
+ updateState({ notificationConfig })} + overridePolicy={state.overridePolicy} + onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })} + /> +
+ + {/* Step 8: Review & Save */} +
+ +
+
+
+ ) +} diff --git a/src/app/(admin)/admin/rounds/pipelines/page.tsx b/src/app/(admin)/admin/rounds/pipelines/page.tsx index 62c99f5..b165144 100644 --- a/src/app/(admin)/admin/rounds/pipelines/page.tsx +++ b/src/app/(admin)/admin/rounds/pipelines/page.tsx @@ -12,14 +12,12 @@ import { CardTitle, } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' -import { - Plus, - Layers, - GitBranch, - Calendar, - Workflow, - Pencil, -} from 'lucide-react' +import { + Plus, + Layers, + Calendar, + Workflow, +} from 'lucide-react' import { cn } from '@/lib/utils' import { formatDistanceToNow } from 'date-fns' import { useEdition } from '@/contexts/edition-context' @@ -148,19 +146,15 @@ export default function PipelineListPage() { const config = statusConfig[status] || statusConfig.DRAFT const description = (pipeline.settingsJson as Record | null)?.description as string | undefined - return ( - - -
-
- - - {pipeline.name} - - -

- {pipeline.slug} -

+ return ( + + + +
+
+ + {pipeline.name} +
- {/* Description */} {description && (

{description} @@ -182,10 +175,9 @@ export default function PipelineListPage() { )} - - {/* Track Indicator - Simplified visualization */} -

-
+ +
+
{pipeline._count.tracks === 0 @@ -195,56 +187,15 @@ export default function PipelineListPage() { : `${pipeline._count.tracks} tracks`}
- {pipeline._count.tracks > 0 && ( -
- {Array.from({ length: Math.min(pipeline._count.tracks, 5) }).map((_, i) => ( -
-
-
- ))} - {pipeline._count.tracks > 5 && ( - - +{pipeline._count.tracks - 5} - - )} -
- )} + Updated {formatDistanceToNow(new Date(pipeline.updatedAt))} ago
- - {/* Stats */} -
-
-
- - Routing rules -
- - {pipeline._count.routingRules} - -
- -
- Updated {formatDistanceToNow(new Date(pipeline.updatedAt))} ago -
-
- -
- - - -
- - - ) - })} -
- )} +
+ + + ) + })} +
+ )}
) } diff --git a/src/components/admin/pipeline/predicate-builder.tsx b/src/components/admin/pipeline/predicate-builder.tsx index 0e11e52..2db9b92 100644 --- a/src/components/admin/pipeline/predicate-builder.tsx +++ b/src/components/admin/pipeline/predicate-builder.tsx @@ -1,11 +1,10 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useCallback } from 'react' import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Select, SelectContent, @@ -13,184 +12,459 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Code } from 'lucide-react' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { Plus, X, Loader2, Sparkles, AlertCircle } from 'lucide-react' +import { trpc } from '@/lib/trpc/client' +import { toast } from 'sonner' + +// ─── Field & Operator Definitions ──────────────────────────────────────────── const FIELD_OPTIONS = [ - { value: 'competitionCategory', label: 'Competition Category' }, - { value: 'oceanIssue', label: 'Ocean Issue' }, - { value: 'country', label: 'Country' }, - { value: 'geographicZone', label: 'Geographic Zone' }, - { value: 'wantsMentorship', label: 'Wants Mentorship' }, - { value: 'tags', label: 'Tags' }, + { value: 'competitionCategory', label: 'Competition Category', tooltip: 'Values: STARTUP, BUSINESS_CONCEPT' }, + { value: 'oceanIssue', label: 'Ocean Issue', tooltip: 'The ocean issue the project addresses' }, + { value: 'country', label: 'Country', tooltip: 'Country of origin' }, + { value: 'geographicZone', label: 'Geographic Zone', tooltip: 'Geographic zone of the project' }, + { value: 'wantsMentorship', label: 'Wants Mentorship', tooltip: 'Boolean: true or false' }, + { value: 'tags', label: 'Tags', tooltip: 'Project tags (comma-separated for "in" operator)' }, ] as const const OPERATOR_OPTIONS = [ - { value: 'equals', label: 'equals' }, - { value: 'not_equals', label: 'not equals' }, + { value: 'eq', label: 'equals' }, + { value: 'neq', label: 'does not equal' }, + { value: 'in', label: 'is one of' }, { value: 'contains', label: 'contains' }, - { value: 'in', label: 'in' }, + { value: 'gt', label: 'greater than' }, + { value: 'lt', label: 'less than' }, ] as const -type SimplePredicate = { +// ─── Types ─────────────────────────────────────────────────────────────────── + +type SimpleCondition = { field: string operator: string - value: string + value: unknown +} + +type CompoundPredicate = { + logic: 'and' | 'or' + conditions: SimpleCondition[] } type PredicateBuilderProps = { value: Record onChange: (predicate: Record) => void + pipelineId?: string } -function isSimplePredicate(obj: Record): obj is SimplePredicate { - return ( - typeof obj.field === 'string' && - typeof obj.operator === 'string' && - (typeof obj.value === 'string' || typeof obj.value === 'boolean') +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function isSimpleCondition(obj: Record): obj is SimpleCondition { + return typeof obj.field === 'string' && typeof obj.operator === 'string' && 'value' in obj +} + +function isCompoundPredicate(obj: Record): obj is CompoundPredicate { + return 'logic' in obj && Array.isArray((obj as CompoundPredicate).conditions) +} + +function detectInitialMode(value: Record): 'simple' | 'ai' | 'advanced' { + if (isCompoundPredicate(value)) return 'simple' + if (isSimpleCondition(value)) return 'simple' + // Empty object or unknown shape + if (Object.keys(value).length === 0) return 'simple' + return 'advanced' +} + +function valueToConditions(value: Record): SimpleCondition[] { + if (isCompoundPredicate(value)) { + return value.conditions.map((c) => ({ + field: c.field || 'competitionCategory', + operator: c.operator || 'eq', + value: c.value ?? '', + })) + } + if (isSimpleCondition(value)) { + return [{ field: value.field, operator: value.operator, value: value.value }] + } + return [{ field: 'competitionCategory', operator: 'eq', value: '' }] +} + +function valueToLogic(value: Record): 'and' | 'or' { + if (isCompoundPredicate(value)) return value.logic + return 'and' +} + +function conditionsToPredicate( + conditions: SimpleCondition[], + logic: 'and' | 'or' +): Record { + if (conditions.length === 1) { + return conditions[0] as unknown as Record + } + return { logic, conditions } +} + +function displayValue(val: unknown): string { + if (Array.isArray(val)) return val.join(', ') + if (typeof val === 'boolean') return val ? 'true' : 'false' + return String(val ?? '') +} + +function parseInputValue(text: string, field: string): unknown { + if (field === 'wantsMentorship') { + return text.toLowerCase() === 'true' + } + if (text.includes(',')) { + return text.split(',').map((s) => s.trim()).filter(Boolean) + } + return text +} + +// ─── Simple Mode ───────────────────────────────────────────────────────────── + +function SimpleMode({ + value, + onChange, +}: { + value: Record + onChange: (predicate: Record) => void +}) { + const [conditions, setConditions] = useState(() => valueToConditions(value)) + const [logic, setLogic] = useState<'and' | 'or'>(() => valueToLogic(value)) + + const emitChange = useCallback( + (nextConditions: SimpleCondition[], nextLogic: 'and' | 'or') => { + onChange(conditionsToPredicate(nextConditions, nextLogic)) + }, + [onChange] ) -} -function isCompound(obj: Record): boolean { - return 'or' in obj || 'and' in obj || 'not' in obj -} - -export function PredicateBuilder({ value, onChange }: PredicateBuilderProps) { - const [jsonMode, setJsonMode] = useState(false) - const [jsonText, setJsonText] = useState('') - - const compound = isCompound(value) - const simple = !compound && isSimplePredicate(value) - - useEffect(() => { - if (compound) { - setJsonMode(true) - setJsonText(JSON.stringify(value, null, 2)) - } - }, [compound, value]) - - if (jsonMode) { - return ( -
-
-
- - {compound && ( - - Complex condition - - )} -
- {!compound && ( - - )} -
-