diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx index e8f808eb3..894dadce6 100644 --- a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx @@ -2,6 +2,7 @@ import { createClient } from '@op/api/serverClient'; import { notFound } from 'next/navigation'; import { ProcessBuilderContent } from '@/components/decisions/ProcessBuilder/ProcessBuilderContent'; +import { ProcessBuilderFooter } from '@/components/decisions/ProcessBuilder/ProcessBuilderFooter'; import { ProcessBuilderHeader } from '@/components/decisions/ProcessBuilder/ProcessBuilderHeader'; import { ProcessBuilderSidebar } from '@/components/decisions/ProcessBuilder/ProcessBuilderSectionNav'; import { ProcessBuilderStoreInitializer } from '@/components/decisions/ProcessBuilder/ProcessBuilderStoreInitializer'; @@ -48,7 +49,10 @@ const EditDecisionPage = async ({ />
- +
+ ); }; diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx index 9bf2b4147..d97344761 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx @@ -4,7 +4,8 @@ import { useUser } from '@/utils/UserProvider'; import { useTranslations } from '@/lib/i18n'; -import { type SectionProps, getContentComponent } from './contentRegistry'; +import { type SectionProps, getContentComponentFlat } from './contentRegistry'; +import { type SectionId } from './navigationConfig'; import { useNavigationConfig } from './useNavigationConfig'; import { useProcessNavigation } from './useProcessNavigation'; @@ -15,8 +16,7 @@ export function ProcessBuilderContent({ }: SectionProps) { const t = useTranslations(); const navigationConfig = useNavigationConfig(instanceId); - const { currentStep, currentSection } = - useProcessNavigation(navigationConfig); + const { currentSection } = useProcessNavigation(navigationConfig); const access = useUser(); const isAdmin = access.getPermissionsForProfile(decisionProfileId).admin; @@ -25,9 +25,8 @@ export function ProcessBuilderContent({ throw new Error('UNAUTHORIZED'); } - const ContentComponent = getContentComponent( - currentStep?.id, - currentSection?.id, + const ContentComponent = getContentComponentFlat( + currentSection?.id as SectionId | undefined, ); if (!ContentComponent) { diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderFooter.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderFooter.tsx new file mode 100644 index 000000000..53cbddbd4 --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderFooter.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { trpc } from '@op/api/client'; +import { ProcessStatus } from '@op/api/encoders'; +import { Button } from '@op/ui/Button'; +import { DialogTrigger } from '@op/ui/Dialog'; +import { Popover } from '@op/ui/Popover'; +import { toast } from '@op/ui/Toast'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { + LuCheck, + LuCircle, + LuCircleAlert, + LuLogOut, + LuPlus, + LuSave, +} from 'react-icons/lu'; + +import { Link, useTranslations } from '@/lib/i18n'; + +import { LaunchProcessModal } from './LaunchProcessModal'; +import { useProcessBuilderStore } from './stores/useProcessBuilderStore'; +import type { ValidationSummary } from './validation/processBuilderValidation'; +import { useProcessBuilderValidation } from './validation/useProcessBuilderValidation'; + +export const ProcessBuilderFooter = ({ + instanceId, + slug, + decisionProfileId, +}: { + instanceId: string; + slug: string; + decisionProfileId: string; +}) => { + const t = useTranslations(); + const router = useRouter(); + const [isLaunchModalOpen, setIsLaunchModalOpen] = useState(false); + + const validation = useProcessBuilderValidation(decisionProfileId); + + const { data: decisionProfile } = trpc.decision.getDecisionBySlug.useQuery( + { slug }, + { enabled: !!slug }, + ); + + const processInstance = decisionProfile?.processInstance; + const instanceStatus = processInstance?.status as ProcessStatus | undefined; + const isDraft = instanceStatus === ProcessStatus.DRAFT; + const isTerminalStatus = + instanceStatus === ProcessStatus.COMPLETED || + instanceStatus === ProcessStatus.CANCELLED; + + const storeData = useProcessBuilderStore( + (s) => s.instances[decisionProfileId], + ); + const displayName = + storeData?.name || decisionProfile?.name || t('New process'); + + const utils = trpc.useUtils(); + + const updateInstance = trpc.decision.updateDecisionInstance.useMutation({ + onSuccess: () => { + toast.success({ message: t('Changes saved successfully') }); + router.push(`/decisions/${slug}`); + }, + onError: (error) => { + toast.error({ + message: t('Failed to save changes'), + title: error.message, + }); + }, + onSettled: () => { + void utils.decision.getDecisionBySlug.invalidate({ slug }); + }, + }); + + const handleLaunchOrSave = () => { + if (isDraft) { + setIsLaunchModalOpen(true); + } else { + updateInstance.mutate({ + instanceId, + name: storeData?.name || undefined, + description: storeData?.description || undefined, + stewardProfileId: storeData?.stewardProfileId || undefined, + phases: storeData?.phases, + proposalTemplate: storeData?.proposalTemplate, + config: storeData?.config, + }); + } + }; + + return ( + <> +
+
+ + + {t('Exit')} + +
+ +
+ {validation.stepsRemaining > 0 && ( + + )} + +
+
+ + + + ); +}; + +const StepsRemainingPopover = ({ + validation, +}: { + validation: ValidationSummary; +}) => { + const t = useTranslations(); + + return ( + + + +

+ {t('Complete these steps to launch')} +

+
    + {validation.checklist.map((item) => ( +
  • + {item.isValid ? ( + + ) : ( + + )} + + {t(item.labelKey)} + +
  • + ))} +
+
+
+ ); +}; diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx index 223213e9b..88f01b04f 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx @@ -1,42 +1,22 @@ 'use client'; -import { useFeatureFlag } from '@/hooks/useFeatureFlag'; import { trpc } from '@op/api/client'; -import { ProcessStatus } from '@op/api/encoders'; -import { Button } from '@op/ui/Button'; -import { DialogTrigger } from '@op/ui/Dialog'; -import { Popover } from '@op/ui/Popover'; -import { Key } from '@op/ui/RAC'; import { Sidebar, SidebarProvider, SidebarTrigger, useSidebar, } from '@op/ui/Sidebar'; -import { Tab, TabList, Tabs } from '@op/ui/Tabs'; -import { toast } from '@op/ui/Toast'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import { - LuCheck, - LuChevronRight, - LuCircle, - LuCircleAlert, - LuHouse, - LuPlus, - LuSave, -} from 'react-icons/lu'; +import { useMemo } from 'react'; +import { LuChevronRight, LuCornerDownRight, LuHouse } from 'react-icons/lu'; import { Link, useTranslations } from '@/lib/i18n'; import { UserAvatarMenu } from '@/components/SiteHeader'; -import { LaunchProcessModal } from './LaunchProcessModal'; import { useProcessBuilderStore } from './stores/useProcessBuilderStore'; import { useNavigationConfig } from './useNavigationConfig'; import { useProcessNavigation } from './useProcessNavigation'; -import type { ValidationSummary } from './validation/processBuilderValidation'; -import { useProcessBuilderValidation } from './validation/useProcessBuilderValidation'; export const ProcessBuilderHeader = ({ instanceId, @@ -52,7 +32,7 @@ export const ProcessBuilderHeader = ({ return ( - + ); }; @@ -81,29 +61,19 @@ const CreateModeHeader = () => { }; const ProcessBuilderHeaderContent = ({ - instanceId, slug, }: { instanceId: string; slug?: string; }) => { const t = useTranslations(); - const rubricBuilderEnabled = useFeatureFlag('rubric_builder'); - const router = useRouter(); - const navigationConfig = useNavigationConfig(instanceId); - const { visibleSteps, currentStep, setStep } = - useProcessNavigation(navigationConfig); - const hasSteps = visibleSteps.length > 0; const { data: decisionProfile } = trpc.decision.getDecisionBySlug.useQuery( { slug: slug! }, { enabled: !!slug }, ); - const processInstance = decisionProfile?.processInstance; - const instanceStatus = processInstance?.status as ProcessStatus | undefined; const decisionProfileId = decisionProfile?.id; - const validation = useProcessBuilderValidation(decisionProfileId); const storeData = useProcessBuilderStore((s) => decisionProfileId ? s.instances[decisionProfileId] : undefined, @@ -111,166 +81,98 @@ const ProcessBuilderHeaderContent = ({ const displayName = storeData?.name || decisionProfile?.name || t('New process'); - const { setOpen } = useSidebar(); - const [isLaunchModalOpen, setIsLaunchModalOpen] = useState(false); - - const isDraft = instanceStatus === ProcessStatus.DRAFT; - const isTerminalStatus = - instanceStatus === ProcessStatus.COMPLETED || - instanceStatus === ProcessStatus.CANCELLED; - - const utils = trpc.useUtils(); - - // Save mutation for non-draft states - const updateInstance = trpc.decision.updateDecisionInstance.useMutation({ - onSuccess: () => { - toast.success({ message: t('Changes saved successfully') }); - if (slug) { - router.push(`/decisions/${slug}`); - } - }, - onError: (error) => { - toast.error({ - message: t('Failed to save changes'), - title: error.message, - }); - }, - onSettled: () => { - if (slug) { - void utils.decision.getDecisionBySlug.invalidate({ slug }); - } - }, - }); - - const handleLaunchOrSave = () => { - if (isDraft) { - setIsLaunchModalOpen(true); - } else { - updateInstance.mutate({ - instanceId, - name: storeData?.name || undefined, - description: storeData?.description || undefined, - stewardProfileId: storeData?.stewardProfileId || undefined, - phases: storeData?.phases, - proposalTemplate: storeData?.proposalTemplate, - config: storeData?.config, - }); - } - }; - - const handleSelectionChange = (key: Key) => { - setStep(String(key)); - setOpen(false); - }; - return ( -
-
-
- {hasSteps && } - - - - {t('Home')} - - - - {displayName} -
-
- {hasSteps && ( -
- {validation.stepsRemaining > 0 && ( - - )} - -
- )} - -
+
+
+ + + + {t('Home')} + + + {displayName} +
+
+
- - {slug && decisionProfileId && ( - - )} - {hasSteps && ( - - )}
); }; -const MobileSidebar = ({ instanceId }: { instanceId: string }) => { +const MobileSidebarWithProfile = ({ + instanceId, + slug, +}: { + instanceId: string; + slug?: string; +}) => { + const { data: decisionProfile } = trpc.decision.getDecisionBySlug.useQuery( + { slug: slug! }, + { enabled: !!slug }, + ); + return ( + + ); +}; + +const MobileSidebar = ({ + instanceId, + decisionProfileId, +}: { + instanceId: string; + decisionProfileId?: string; +}) => { const t = useTranslations(); const rubricBuilderEnabled = useFeatureFlag('rubric_builder'); const navigationConfig = useNavigationConfig(instanceId); - const { visibleSteps, currentStep, setStep } = + const { visibleSections, currentSection, setSection } = useProcessNavigation(navigationConfig); - const hasSteps = visibleSteps.length > 0; const { setOpen } = useSidebar(); - const handleSelectionChange = (key: Key) => { - setStep(String(key)); + const storePhases = useProcessBuilderStore((s) => + decisionProfileId ? s.instances[decisionProfileId]?.phases : undefined, + ); + + const { data: instance } = trpc.decision.getInstance.useQuery( + { instanceId }, + { enabled: !!instanceId }, + ); + + const phases = useMemo(() => { + // Prefer Zustand store phases (updated immediately on edit) over API data + if (storePhases?.length) { + return storePhases + .map((p) => ({ id: p.phaseId, name: p.name ?? '' })) + .filter((p) => p.name); + } + const instancePhases = instance?.instanceData?.phases; + if (instancePhases?.length) { + return instancePhases + .map((p) => ({ id: p.phaseId, name: p.name ?? '' })) + .filter((p) => p.name); + } + const templatePhases = instance?.process?.processSchema?.phases; + if (templatePhases?.length) { + return templatePhases.map((p) => ({ id: p.id, name: p.name })); + } + return []; + }, [storePhases, instance]); + + const handleSectionClick = (sectionId: string) => { + setSection(sectionId); setOpen(false); }; - if (!hasSteps) { + if (visibleSections.length === 0) { return null; } + return ( - - ); -}; - -const ComingSoonIndicator = () => { - const t = useTranslations(); - return ( - - {t('Coming soon')} - - ); -}; - -const StepsRemainingPopover = ({ - validation, -}: { - validation: ValidationSummary; -}) => { - const t = useTranslations(); - - return ( - - - -

- {t('Complete these steps to launch')} -

-
    - {validation.checklist.map((item) => ( -
  • - {item.isValid ? ( - - ) : ( - - )} - - {t(item.labelKey)} - -
  • - ))}
-
-
+ + ); }; diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderSectionNav.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderSectionNav.tsx index dc896ce0b..55f41eaed 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderSectionNav.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderSectionNav.tsx @@ -1,57 +1,97 @@ 'use client'; -import { Key } from '@op/ui/RAC'; -import { Tab, TabList, Tabs } from '@op/ui/Tabs'; +import { trpc } from '@op/api/client'; +import { useMemo } from 'react'; +import { LuCornerDownRight } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; +import { useProcessBuilderStore } from './stores/useProcessBuilderStore'; import { useNavigationConfig } from './useNavigationConfig'; import { useProcessNavigation } from './useProcessNavigation'; export const ProcessBuilderSidebar = ({ instanceId, + decisionProfileId, }: { instanceId: string; + decisionProfileId?: string; }) => { const t = useTranslations(); const navigationConfig = useNavigationConfig(instanceId); - const { visibleSections, currentSection, currentStep, setSection } = + const { visibleSections, currentSection, setSection } = useProcessNavigation(navigationConfig); - const handleSelectionChange = (key: Key) => { - setSection(String(key)); - }; + const storePhases = useProcessBuilderStore((s) => + decisionProfileId ? s.instances[decisionProfileId]?.phases : undefined, + ); + + const { data: instance } = trpc.decision.getInstance.useQuery( + { instanceId }, + { enabled: !!instanceId }, + ); - // Don't render sidebar for single-section steps - // These steps manage their own layout (e.g., template step with form builder) - if (visibleSections.length <= 1) { - return null; - } + const phases = useMemo(() => { + // Prefer Zustand store phases (updated immediately on edit) over API data + if (storePhases?.length) { + return storePhases + .map((p) => ({ id: p.phaseId, name: p.name ?? '' })) + .filter((p) => p.name); + } + const instancePhases = instance?.instanceData?.phases; + if (instancePhases?.length) { + return instancePhases + .map((p) => ({ id: p.phaseId, name: p.name ?? '' })) + .filter((p) => p.name); + } + const templatePhases = instance?.process?.processSchema?.phases; + if (templatePhases?.length) { + return templatePhases.map((p) => ({ id: p.id, name: p.name })); + } + return []; + }, [storePhases, instance]); return ( -