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 894dadce6..8643ad8ea 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 @@ -5,6 +5,7 @@ import { ProcessBuilderContent } from '@/components/decisions/ProcessBuilder/Pro import { ProcessBuilderFooter } from '@/components/decisions/ProcessBuilder/ProcessBuilderFooter'; import { ProcessBuilderHeader } from '@/components/decisions/ProcessBuilder/ProcessBuilderHeader'; import { ProcessBuilderSidebar } from '@/components/decisions/ProcessBuilder/ProcessBuilderSectionNav'; +import { ProcessBuilderShell } from '@/components/decisions/ProcessBuilder/ProcessBuilderShell'; import { ProcessBuilderStoreInitializer } from '@/components/decisions/ProcessBuilder/ProcessBuilderStoreInitializer'; import type { ProcessBuilderInstanceData } from '@/components/decisions/ProcessBuilder/stores/useProcessBuilderStore'; @@ -41,32 +42,34 @@ const EditDecisionPage = async ({ }; return ( -
- - -
- +
+ -
- +
+ -
+
+ +
+
+
- -
+ ); }; diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx index d97344761..a1016c319 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderContent.tsx @@ -8,6 +8,7 @@ import { type SectionProps, getContentComponentFlat } from './contentRegistry'; import { type SectionId } from './navigationConfig'; import { useNavigationConfig } from './useNavigationConfig'; import { useProcessNavigation } from './useProcessNavigation'; +import { useProcessPhases } from './useProcessPhases'; export function ProcessBuilderContent({ decisionProfileId, @@ -16,7 +17,10 @@ export function ProcessBuilderContent({ }: SectionProps) { const t = useTranslations(); const navigationConfig = useNavigationConfig(instanceId); - const { currentSection } = useProcessNavigation(navigationConfig); + + const phases = useProcessPhases(instanceId, decisionProfileId); + + const { currentSection } = useProcessNavigation(navigationConfig, phases); const access = useUser(); const isAdmin = access.getPermissionsForProfile(decisionProfileId).admin; diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderFooter.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderFooter.tsx index 53cbddbd4..d3cbc1ccc 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderFooter.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderFooter.tsx @@ -3,27 +3,25 @@ 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 { SidebarTrigger } from '@op/ui/Sidebar'; 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 { LuLogOut, LuSave } from 'react-icons/lu'; import { Link, useTranslations } from '@/lib/i18n'; import { LaunchProcessModal } from './LaunchProcessModal'; +import { ProgressIndicator } from './components/ProgressIndicator'; import { useProcessBuilderStore } from './stores/useProcessBuilderStore'; -import type { ValidationSummary } from './validation/processBuilderValidation'; +import { useNavigationConfig } from './useNavigationConfig'; +import { useProcessNavigation } from './useProcessNavigation'; +import { useProcessPhases } from './useProcessPhases'; import { useProcessBuilderValidation } from './validation/useProcessBuilderValidation'; +const NAV_BTN_CLS = + 'inline-flex h-10 items-center justify-center rounded-lg border border-neutral-gray1 px-3 text-sm text-primary shadow-[0px_0px_16px_0px_rgba(20,35,38,0.04)] transition-colors hover:bg-neutral-gray1'; + export const ProcessBuilderFooter = ({ instanceId, slug, @@ -38,6 +36,14 @@ export const ProcessBuilderFooter = ({ const [isLaunchModalOpen, setIsLaunchModalOpen] = useState(false); const validation = useProcessBuilderValidation(decisionProfileId); + const navigationConfig = useNavigationConfig(instanceId); + + const phases = useProcessPhases(instanceId, decisionProfileId); + + const { goNext, goBack, hasNext, hasPrev } = useProcessNavigation( + navigationConfig, + phases, + ); const { data: decisionProfile } = trpc.decision.getDecisionBySlug.useQuery( { slug }, @@ -93,42 +99,85 @@ export const ProcessBuilderFooter = ({ return ( <> - @@ -143,53 +192,3 @@ export const ProcessBuilderFooter = ({ ); }; - -const StepsRemainingPopover = ({ - validation, -}: { - validation: ValidationSummary; -}) => { - const t = useTranslations(); - - return ( - - - -

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

- -
-
- ); -}; diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx index 88f01b04f..0f19b8573 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx @@ -1,22 +1,22 @@ 'use client'; import { trpc } from '@op/api/client'; -import { - Sidebar, - SidebarProvider, - SidebarTrigger, - useSidebar, -} from '@op/ui/Sidebar'; -import { useMemo } from 'react'; -import { LuChevronRight, LuCornerDownRight, LuHouse } from 'react-icons/lu'; +import { Sheet, SheetBody } from '@op/ui/Sheet'; +import { useSidebar } from '@op/ui/Sidebar'; +import { LuChevronRight, LuHouse, LuList } from 'react-icons/lu'; import { Link, useTranslations } from '@/lib/i18n'; +import { LocaleChooser } from '@/components/LocaleChooser'; import { UserAvatarMenu } from '@/components/SiteHeader'; +import { SidebarNavItems } from './components/SidebarNavItems'; import { useProcessBuilderStore } from './stores/useProcessBuilderStore'; import { useNavigationConfig } from './useNavigationConfig'; +import { usePhaseValidation } from './usePhaseValidation'; import { useProcessNavigation } from './useProcessNavigation'; +import { useProcessPhases } from './useProcessPhases'; +import { useProcessBuilderValidation } from './validation/useProcessBuilderValidation'; export const ProcessBuilderHeader = ({ instanceId, @@ -30,10 +30,10 @@ export const ProcessBuilderHeader = ({ } return ( - + <> - + ); }; @@ -43,17 +43,16 @@ const CreateModeHeader = () => { return (
- + {t('Home')} - + + {t('New process')}
-
+
+
@@ -83,19 +82,20 @@ const ProcessBuilderHeaderContent = ({ return (
-
- +
{t('Home')} - + + {displayName}
-
+
+
@@ -128,42 +128,18 @@ const MobileSidebar = ({ instanceId: string; decisionProfileId?: string; }) => { - const t = useTranslations(); - const rubricBuilderEnabled = useFeatureFlag('rubric_builder'); const navigationConfig = useNavigationConfig(instanceId); - const { visibleSections, currentSection, setSection } = - useProcessNavigation(navigationConfig); - const { setOpen } = useSidebar(); - - const storePhases = useProcessBuilderStore((s) => - decisionProfileId ? s.instances[decisionProfileId]?.phases : undefined, + const { open, setOpen } = useSidebar(); + const { sections: validationSections } = + useProcessBuilderValidation(decisionProfileId); + const phases = useProcessPhases(instanceId, decisionProfileId); + const phaseValidation = usePhaseValidation(instanceId, decisionProfileId); + + const { visibleSections, currentSection, setSection } = useProcessNavigation( + navigationConfig, + phases, ); - 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); @@ -174,51 +150,24 @@ const MobileSidebar = ({ } return ( - - - + + + + + ); }; diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderSectionNav.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderSectionNav.tsx index 55f41eaed..07c5479f2 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderSectionNav.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderSectionNav.tsx @@ -1,14 +1,13 @@ 'use client'; -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 { SidebarNavItems } from './components/SidebarNavItems'; import { useNavigationConfig } from './useNavigationConfig'; +import { usePhaseValidation } from './usePhaseValidation'; import { useProcessNavigation } from './useProcessNavigation'; +import { useProcessPhases } from './useProcessPhases'; +import { useProcessBuilderValidation } from './validation/useProcessBuilderValidation'; export const ProcessBuilderSidebar = ({ instanceId, @@ -19,79 +18,29 @@ export const ProcessBuilderSidebar = ({ }) => { const t = useTranslations(); const navigationConfig = useNavigationConfig(instanceId); - const { visibleSections, currentSection, setSection } = - useProcessNavigation(navigationConfig); - - const storePhases = useProcessBuilderStore((s) => - decisionProfileId ? s.instances[decisionProfileId]?.phases : undefined, + const { sections: validationSections } = + useProcessBuilderValidation(decisionProfileId); + const phases = useProcessPhases(instanceId, decisionProfileId); + const phaseValidation = usePhaseValidation(instanceId, decisionProfileId); + + const { visibleSections, currentSection, setSection } = useProcessNavigation( + navigationConfig, + phases, ); - 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]); - return ( ); }; diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderShell.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderShell.tsx new file mode 100644 index 000000000..81d35635f --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderShell.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { SidebarProvider } from '@op/ui/Sidebar'; + +export function ProcessBuilderShell({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/components/CornerDownRight.tsx b/apps/app/src/components/decisions/ProcessBuilder/components/CornerDownRight.tsx new file mode 100644 index 000000000..4d65f65a9 --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/components/CornerDownRight.tsx @@ -0,0 +1,19 @@ +export function CornerDownRight({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/components/ProgressIndicator.tsx b/apps/app/src/components/decisions/ProcessBuilder/components/ProgressIndicator.tsx new file mode 100644 index 000000000..acdb7e20d --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/components/ProgressIndicator.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useTranslations } from '@/lib/i18n'; + +const GRADIENT = 'linear-gradient(to right, #3EC300, #0396A6)'; + +export function ProgressIndicator({ + percentage, + variant, +}: { + percentage: number; + variant: 'bar' | 'strip'; +}) { + const t = useTranslations(); + const clamped = Math.min(100, Math.max(0, percentage)); + + if (variant === 'strip') { + return ( +
+
+
+ ); + } + + return ( +
+
+
+
+ + {t('{count}% complete', { count: clamped })} + +
+ ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/components/SidebarNavItems.tsx b/apps/app/src/components/decisions/ProcessBuilder/components/SidebarNavItems.tsx new file mode 100644 index 000000000..138dc467f --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/components/SidebarNavItems.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { cn } from '@op/ui/utils'; + +import { useTranslations } from '@/lib/i18n'; + +import { + type SectionId, + type SidebarItem, + isPhaseSection, + isSectionId, + phaseToSectionId, +} from '../navigationConfig'; +import type { ProcessPhase } from '../useProcessPhases'; +import { CornerDownRight } from './CornerDownRight'; + +type StaticSidebarItem = Extract; + +interface SidebarNavItemsProps { + visibleSections: SidebarItem[]; + phases: ProcessPhase[]; + currentSectionId: string | undefined; + phaseValidation: Record; + validationSections: Record; + onSectionClick: (sectionId: string) => void; +} + +export function SidebarNavItems({ + visibleSections, + phases, + currentSectionId, + phaseValidation, + validationSections, + onSectionClick, +}: SidebarNavItemsProps) { + return ( +
    + {visibleSections + .filter((section): section is StaticSidebarItem => !section.isDynamic) + .map((section) => ( + + ))} +
+ ); +} + +interface SectionItemProps { + section: StaticSidebarItem; + phases: ProcessPhase[]; + currentSectionId: string | undefined; + phaseValidation: Record; + validationSections: Record; + onSectionClick: (sectionId: string) => void; +} + +function SectionItem({ + section, + phases, + currentSectionId, + phaseValidation, + validationSections, + onSectionClick, +}: SectionItemProps) { + const t = useTranslations(); + const isActive = currentSectionId === section.id; + + return ( +
  • + + {section.id === 'phases' && phases.length > 0 && ( +
      + {phases.map((phase) => ( + + ))} +
    + )} +
  • + ); +} + +interface PhaseItemProps { + phase: ProcessPhase; + currentSectionId: string | undefined; + phaseValidation: Record; + onSectionClick: (sectionId: string) => void; +} + +function PhaseItem({ + phase, + currentSectionId, + phaseValidation, + onSectionClick, +}: PhaseItemProps) { + const t = useTranslations(); + const phaseSectionId = phaseToSectionId(phase.phaseId); + const isActive = + currentSectionId !== undefined && + isPhaseSection(currentSectionId) && + currentSectionId === phaseSectionId; + + return ( +
  • + +
  • + ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/contentRegistry.tsx b/apps/app/src/components/decisions/ProcessBuilder/contentRegistry.tsx index dca703c8f..40213d88b 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/contentRegistry.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/contentRegistry.tsx @@ -2,12 +2,18 @@ import { type ComponentType } from 'react'; -import type { SectionId, StepId } from './navigationConfig'; +import { + type SectionId, + type StepId, + isPhaseSection, +} from './navigationConfig'; import OverviewSection from './stepContent/general/OverviewSection'; +import PhaseDetailSection from './stepContent/general/PhaseDetailSection'; import PhasesSection from './stepContent/general/PhasesSection'; import ProposalCategoriesSection from './stepContent/general/ProposalCategoriesSection'; import ParticipantsSection from './stepContent/participants/ParticipantsSection'; import RolesSection from './stepContent/participants/RolesSection'; +import SummarySectionContent from './stepContent/participants/SummarySectionContent'; import CriteriaSection from './stepContent/rubric/CriteriaSection'; import TemplateEditorSection from './stepContent/template/TemplateEditorSection'; @@ -40,6 +46,7 @@ const CONTENT_REGISTRY: ContentRegistry = { participants: { roles: RolesSection, participants: ParticipantsSection, + summary: SummarySectionContent, }, }; @@ -62,13 +69,17 @@ const FLAT_CONTENT_REGISTRY: Record = { criteria: CriteriaSection, roles: RolesSection, participants: ParticipantsSection, + summary: SummarySectionContent, }; export function getContentComponentFlat( - sectionId: SectionId | undefined, + sectionId: SectionId | string | undefined, ): SectionComponent | null { if (!sectionId) { return null; } + if (isPhaseSection(sectionId)) { + return PhaseDetailSection; + } return FLAT_CONTENT_REGISTRY[sectionId] ?? null; } diff --git a/apps/app/src/components/decisions/ProcessBuilder/navigationConfig.ts b/apps/app/src/components/decisions/ProcessBuilder/navigationConfig.ts index f26cdbd94..0795e06ea 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/navigationConfig.ts +++ b/apps/app/src/components/decisions/ProcessBuilder/navigationConfig.ts @@ -7,6 +7,7 @@ export const STEPS = [ { id: 'template', labelKey: 'Proposal Template' }, { id: 'rubric', labelKey: 'Review Rubric' }, { id: 'participants', labelKey: 'Participants' }, + { id: 'summary', labelKey: 'Summary' }, ] as const; // Derive StepId first so we can use it in SECTIONS_BY_STEP @@ -24,6 +25,7 @@ export const SECTIONS_BY_STEP = { { id: 'roles', labelKey: 'Roles & permissions' }, { id: 'participants', labelKey: 'Participants' }, ], + summary: [{ id: 'summary', labelKey: 'Summary' }], } as const satisfies Record< StepId, readonly { id: string; labelKey: TranslationKey }[] @@ -45,21 +47,26 @@ export const DEFAULT_NAVIGATION_CONFIG: NavigationConfig = { template: true, rubric: false, participants: true, + summary: true, }, sections: { general: ['overview', 'phases', 'proposalCategories'], template: ['templateEditor'], rubric: ['criteria'], participants: ['roles', 'participants'], + summary: ['summary'], }, }; // Flat sidebar items for the unified sidebar navigation -export interface SidebarItem { - id: SectionId; - labelKey: TranslationKey; - parentStepId?: StepId; -} +export type SidebarItem = + | { + id: SectionId; + labelKey: TranslationKey; + parentStepId?: StepId; + isDynamic?: false; + } + | { id: string; labelKey: string; parentStepId?: StepId; isDynamic: true }; export const SIDEBAR_ITEMS: SidebarItem[] = [ { id: 'overview', labelKey: 'Overview', parentStepId: 'general' }, @@ -74,6 +81,11 @@ export const SIDEBAR_ITEMS: SidebarItem[] = [ labelKey: 'Proposal Template', parentStepId: 'template', }, + { + id: 'criteria', + labelKey: 'Review Rubric', + parentStepId: 'rubric', + }, { id: 'roles', labelKey: 'Roles & permissions', @@ -81,7 +93,41 @@ export const SIDEBAR_ITEMS: SidebarItem[] = [ }, { id: 'participants', - labelKey: 'Participants', + labelKey: 'Manage Participants', parentStepId: 'participants', }, + { + id: 'summary', + labelKey: 'Summary', + parentStepId: 'summary', + }, ]; + +// Helper to create a dynamic phase section ID +export function phaseToSectionId(phaseId: string): string { + return `phase-${phaseId}`; +} + +// Helper to extract phaseId from a dynamic phase section ID +export function sectionIdToPhaseId(sectionId: string): string | null { + if (sectionId.startsWith('phase-')) { + return sectionId.slice(6); + } + return null; +} + +// Check if a section ID is a dynamic phase section +export function isPhaseSection(sectionId: string): boolean { + return sectionId.startsWith('phase-'); +} + +const SECTION_ID_SET = new Set( + Object.values(SECTIONS_BY_STEP).flatMap((sections) => + sections.map((s) => s.id), + ), +); + +// Type guard to narrow an arbitrary string to SectionId +export function isSectionId(id: string): id is SectionId { + return SECTION_ID_SET.has(id); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhaseDetailPage.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhaseDetailPage.tsx new file mode 100644 index 000000000..c492c855d --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhaseDetailPage.tsx @@ -0,0 +1,462 @@ +'use client'; + +import { parseDate } from '@internationalized/date'; +import { trpc } from '@op/api/client'; +import type { PhaseDefinition, PhaseRules } from '@op/api/encoders'; +import { useDebouncedCallback } from '@op/hooks'; +import { Button } from '@op/ui/Button'; +import { DatePicker } from '@op/ui/DatePicker'; +import { Header2 } from '@op/ui/Header'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; +import { TextField } from '@op/ui/TextField'; +import { ToggleButton } from '@op/ui/ToggleButton'; +import { useQueryState } from 'nuqs'; +import { useRef, useState } from 'react'; +import { LuTrash2 } from 'react-icons/lu'; + +import { useTranslations } from '@/lib/i18n'; + +import { RichTextEditorWithToolbar } from '@/components/RichTextEditor/RichTextEditorWithToolbar'; + +import { SaveStatusIndicator } from '../../components/SaveStatusIndicator'; +import { ToggleRow } from '../../components/ToggleRow'; +import type { SectionProps } from '../../contentRegistry'; +import { isPhaseSection, sectionIdToPhaseId } from '../../navigationConfig'; +import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; + +const AUTOSAVE_DEBOUNCE_MS = 1000; + +export function PhaseDetailPage({ + instanceId, + decisionProfileId, +}: SectionProps) { + const [sectionParam, setSectionParam] = useQueryState('section', { + history: 'push', + }); + const phaseId = + sectionParam && isPhaseSection(sectionParam) + ? sectionIdToPhaseId(sectionParam) + : null; + + if (!phaseId) { + return null; + } + + return ( + setSectionParam('phases')} + /> + ); +} + +function PhaseDetailForm({ + instanceId, + decisionProfileId, + phaseId, + onDelete, +}: { + instanceId: string; + decisionProfileId: string; + phaseId: string; + onDelete: () => void; +}) { + const t = useTranslations(); + const [instance] = trpc.decision.getInstance.useSuspenseQuery({ instanceId }); + const instancePhases = instance.instanceData?.phases; + const templatePhases = instance.process?.processSchema?.phases; + const isDraft = instance.status === 'draft'; + + const storePhases = useProcessBuilderStore( + (s) => s.instances[decisionProfileId]?.phases, + ); + const setInstanceData = useProcessBuilderStore((s) => s.setInstanceData); + const setSaveStatus = useProcessBuilderStore((s) => s.setSaveStatus); + const markSaved = useProcessBuilderStore((s) => s.markSaved); + const saveState = useProcessBuilderStore((s) => + s.getSaveState(decisionProfileId), + ); + + // Resolve the initial phase data (same priority as PhasesSectionContent) + const allPhases: PhaseDefinition[] = (() => { + const source = + !isDraft && storePhases?.length ? storePhases : instancePhases; + return ( + source?.map((p) => ({ + id: p.phaseId, + name: p.name ?? '', + description: p.description, + headline: p.headline, + additionalInfo: p.additionalInfo, + rules: p.rules ?? {}, + startDate: p.startDate, + endDate: p.endDate, + })) ?? + templatePhases ?? + [] + ); + })(); + + const initialPhase = allPhases.find((p) => p.id === phaseId); + const [phase, setPhase] = useState(initialPhase); + + const utils = trpc.useUtils(); + const debouncedSaveRef = useRef<() => boolean>(null); + const updateInstance = trpc.decision.updateDecisionInstance.useMutation({ + onSuccess: () => markSaved(decisionProfileId), + onError: () => setSaveStatus(decisionProfileId, 'error'), + onSettled: () => { + if (debouncedSaveRef.current?.()) { + return; + } + void utils.decision.getInstance.invalidate({ instanceId }); + }, + }); + + const debouncedSave = useDebouncedCallback( + (updatedPhase: PhaseDefinition) => { + setSaveStatus(decisionProfileId, 'saving'); + + // Build the full phases array with this phase updated + const phasesPayload = allPhases.map((p) => { + const source = p.id === phaseId ? updatedPhase : p; + return { + phaseId: source.id, + name: source.name, + description: source.description, + headline: source.headline, + additionalInfo: source.additionalInfo, + startDate: source.startDate, + endDate: source.endDate, + rules: source.rules, + }; + }); + + setInstanceData(decisionProfileId, { phases: phasesPayload }); + + if (isDraft) { + updateInstance.mutate({ instanceId, phases: phasesPayload }); + } else { + markSaved(decisionProfileId); + } + }, + AUTOSAVE_DEBOUNCE_MS, + ); + debouncedSaveRef.current = () => debouncedSave.isPending(); + + const updatePhase = (updates: Partial) => { + setPhase((prev) => { + if (!prev) { + return prev; + } + const updated = { ...prev, ...updates }; + debouncedSave(updated); + return updated; + }); + }; + + const updateRules = (updates: Partial) => { + if (!phase) { + return; + } + const newRules = { ...phase.rules, ...updates }; + updatePhase({ rules: newRules }); + + // Optimistically update getInstance cache so useNavigationConfig reacts + utils.decision.getInstance.setData({ instanceId }, (old) => { + if (!old?.instanceData?.phases) { + return old; + } + return { + ...old, + instanceData: { + ...old.instanceData, + phases: old.instanceData.phases.map((p) => + p.phaseId === phaseId ? { ...p, rules: newRules } : p, + ), + }, + }; + }); + }; + + // Delete phase + const [showDeleteModal, setShowDeleteModal] = useState(false); + const confirmDelete = () => { + setSaveStatus(decisionProfileId, 'saving'); + const remainingPhases = allPhases + .filter((p) => p.id !== phaseId) + .map((p) => ({ + phaseId: p.id, + name: p.name, + description: p.description, + headline: p.headline, + additionalInfo: p.additionalInfo, + startDate: p.startDate, + endDate: p.endDate, + rules: p.rules, + })); + + setInstanceData(decisionProfileId, { phases: remainingPhases }); + + if (isDraft) { + updateInstance.mutate( + { instanceId, phases: remainingPhases }, + { + onSuccess: () => { + onDelete(); + }, + }, + ); + } else { + markSaved(decisionProfileId); + onDelete(); + } + }; + + // Validation + const [touchedFields, setTouchedFields] = useState>(new Set()); + const markTouched = (field: string) => { + setTouchedFields((prev) => new Set(prev).add(field)); + }; + + const getErrors = () => { + if (!phase) { + return {}; + } + const errors: Record = {}; + if (!phase.name?.trim()) { + errors.name = t('Phase name is required'); + } + if (!phase.headline?.trim()) { + errors.headline = t('Headline is required'); + } + if (!phase.description?.trim()) { + errors.description = t('Description is required'); + } + if (!phase.endDate) { + errors.endDate = t('End date is required'); + } + if (phase.startDate && phase.endDate) { + const startPart = phase.startDate.split('T')[0] ?? ''; + const endPart = phase.endDate.split('T')[0] ?? ''; + if (endPart.localeCompare(startPart) < 0) { + errors.endDate = t('End date must be on or after the start date'); + } + } + return errors; + }; + + const errors = getErrors(); + const getErrorMessage = (field: string) => + touchedFields.has(field) ? errors[field] : undefined; + + // Date helpers + const safeParseDateString = (dateStr: string | undefined) => { + if (!dateStr) { + return undefined; + } + try { + const datePart = dateStr.split('T')[0]; + return datePart ? parseDate(datePart) : undefined; + } catch { + return undefined; + } + }; + + const formatDateValue = (date: { + year: number; + month: number; + day: number; + }) => { + return new Date(date.year, date.month - 1, date.day).toISOString(); + }; + + if (!phase) { + return null; + } + + return ( +
    +
    + + {phase.name || t('Phases')} + + +
    + +
    + updatePhase({ name: value })} + onBlur={() => markTouched('name')} + errorMessage={getErrorMessage('name')} + /> + updatePhase({ headline: value })} + onBlur={() => markTouched('headline')} + errorMessage={getErrorMessage('headline')} + description={t('This text appears as the header of the page.')} + /> + updatePhase({ description: value })} + onBlur={() => markTouched('description')} + errorMessage={getErrorMessage('description')} + textareaProps={{ rows: 3 }} + description={t( + 'This text appears below the headline on the phase page.', + )} + /> +
    + + updatePhase({ additionalInfo: content })} + toolbarPosition="bottom" + className="rounded-md border border-border" + editorClassName="min-h-24 p-3" + /> +

    + {t( + 'Any additional information will appear in a modal titled "About the process"', + )} +

    +
    +
    +
    + + updatePhase({ startDate: formatDateValue(date) }) + } + /> +
    +
    markTouched('endDate')}> + { + updatePhase({ endDate: formatDateValue(date) }); + markTouched('endDate'); + }} + errorMessage={getErrorMessage('endDate')} + /> +
    +
    +
    + + {/* Phase controls */} +
    + + + updateRules({ + proposals: { ...phase.rules?.proposals, submit: val }, + }) + } + size="small" + /> + + + + updateRules({ + proposals: { ...phase.rules?.proposals, edit: val }, + }) + } + size="small" + /> + + + + updateRules({ + proposals: { ...phase.rules?.proposals, review: val }, + }) + } + size="small" + /> + + + + updateRules({ + voting: { ...phase.rules?.voting, submit: val }, + }) + } + size="small" + /> + +
    + + {/* Delete */} +
    + +
    + + { + if (!open) { + setShowDeleteModal(false); + } + }} + > + {t('Delete phase')} + +

    + {t( + 'Are you sure you want to delete this phase? This action cannot be undone.', + )} +

    +
    + + + + +
    +
    + ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhaseDetailSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhaseDetailSection.tsx new file mode 100644 index 000000000..7a8b0c2d5 --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhaseDetailSection.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { Skeleton } from '@op/ui/Skeleton'; +import { Suspense, useEffect, useState } from 'react'; + +import type { SectionProps } from '../../contentRegistry'; +import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; +import { PhaseDetailPage } from './PhaseDetailPage'; + +function PhaseDetailSkeleton() { + return ( +
    +
    + + +
    +
    + + + + +
    + + +
    +
    +
    + ); +} + +export default function PhaseDetailSection(props: SectionProps) { + const [hasHydrated, setHasHydrated] = useState(() => + useProcessBuilderStore.persist.hasHydrated(), + ); + + useEffect(() => { + const unsubscribe = useProcessBuilderStore.persist.onFinishHydration(() => { + setHasHydrated(true); + }); + + void useProcessBuilderStore.persist.rehydrate(); + + return unsubscribe; + }, []); + + if (!hasHydrated) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx index 586bc221a..33fd3a3e5 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx @@ -1,44 +1,23 @@ 'use client'; -import { parseDate } from '@internationalized/date'; import { trpc } from '@op/api/client'; -import { ProcessStatus } from '@op/api/encoders'; -import type { PhaseDefinition, PhaseRules } from '@op/api/encoders'; +import { type PhaseDefinition, ProcessStatus } from '@op/api/encoders'; import { useDebouncedCallback } from '@op/hooks'; -import { - Accordion, - AccordionContent, - AccordionHeader, - AccordionIndicator, - AccordionItem, - AccordionTrigger, -} from '@op/ui/Accordion'; -import { AutoSizeInput } from '@op/ui/AutoSizeInput'; import { Button } from '@op/ui/Button'; -import { DatePicker } from '@op/ui/DatePicker'; -import type { Key } from '@op/ui/RAC'; -import { DisclosureStateContext } from '@op/ui/RAC'; +import { Header2 } from '@op/ui/Header'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; import { DragHandle, Sortable } from '@op/ui/Sortable'; -import { TextField } from '@op/ui/TextField'; -import { ToggleButton } from '@op/ui/ToggleButton'; import { cn } from '@op/ui/utils'; -import { use, useEffect, useRef, useState } from 'react'; -import { - LuChevronRight, - LuCircleAlert, - LuGripVertical, - LuPlus, - LuTrash2, -} from 'react-icons/lu'; +import { useQueryState } from 'nuqs'; +import { useRef, useState } from 'react'; +import { LuCheck, LuGripVertical, LuPlus, LuTrash2 } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; -import { ConfirmDeleteModal } from '@/components/ConfirmDeleteModal'; -import { RichTextEditorWithToolbar } from '@/components/RichTextEditor/RichTextEditorWithToolbar'; -import { SaveStatusIndicator } from '@/components/decisions/ProcessBuilder/components/SaveStatusIndicator'; -import { ToggleRow } from '@/components/decisions/ProcessBuilder/components/ToggleRow'; -import type { SectionProps } from '@/components/decisions/ProcessBuilder/contentRegistry'; -import { useProcessBuilderStore } from '@/components/decisions/ProcessBuilder/stores/useProcessBuilderStore'; +import { SaveStatusIndicator } from '../../components/SaveStatusIndicator'; +import type { SectionProps } from '../../contentRegistry'; +import { phaseToSectionId } from '../../navigationConfig'; +import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; const AUTOSAVE_DEBOUNCE_MS = 1000; @@ -51,7 +30,6 @@ export function PhasesSectionContent({ const templatePhases = instance.process?.processSchema?.phases; const isDraft = instance.status === ProcessStatus.DRAFT; - // Store: used as a localStorage buffer for non-draft edits only const storePhases = useProcessBuilderStore( (s) => s.instances[decisionProfileId]?.phases, ); @@ -62,8 +40,6 @@ export function PhasesSectionContent({ s.getSaveState(decisionProfileId), ); - // Non-draft: prefer store (localStorage buffer) over API data. - // Draft: use API data (query cache kept fresh via onSettled invalidation). const initialPhases: PhaseDefinition[] = (() => { const source = !isDraft && storePhases?.length ? storePhases : instancePhases; @@ -84,6 +60,8 @@ export function PhasesSectionContent({ })(); const [phases, setPhases] = useState(initialPhases); const t = useTranslations(); + const [, setSectionParam] = useQueryState('section', { history: 'push' }); + const setSection = (sectionId: string) => setSectionParam(sectionId); const utils = trpc.useUtils(); const debouncedSaveRef = useRef<() => boolean>(null); @@ -91,9 +69,6 @@ export function PhasesSectionContent({ onSuccess: () => markSaved(decisionProfileId), onError: () => setSaveStatus(decisionProfileId, 'error'), onSettled: () => { - // Skip invalidation if another debounced save is pending — that save's - // onSettled will reconcile. This prevents a stale refetch from overwriting - // optimistic cache updates made between the two saves. if (debouncedSaveRef.current?.()) { return; } @@ -101,11 +76,8 @@ export function PhasesSectionContent({ }, }); - // Debounced save: draft persists to API, non-draft buffers in localStorage. - const debouncedSave = useDebouncedCallback((data: PhaseDefinition[]) => { - setSaveStatus(decisionProfileId, 'saving'); - - const phasesPayload = data.map((phase) => ({ + const toPayload = (data: PhaseDefinition[]) => + data.map((phase) => ({ phaseId: phase.id, name: phase.name, description: phase.description, @@ -116,7 +88,10 @@ export function PhasesSectionContent({ rules: phase.rules, })); - // Always update the store so validation stays reactive + const debouncedSave = useDebouncedCallback((data: PhaseDefinition[]) => { + setSaveStatus(decisionProfileId, 'saving'); + + const phasesPayload = toPayload(data); setInstanceData(decisionProfileId, { phases: phasesPayload }); if (isDraft) { @@ -127,7 +102,6 @@ export function PhasesSectionContent({ }, AUTOSAVE_DEBOUNCE_MS); debouncedSaveRef.current = () => debouncedSave.isPending(); - // Update phases and trigger debounced save const updatePhases = ( updater: | PhaseDefinition[] @@ -140,550 +114,178 @@ export function PhasesSectionContent({ }); }; - const updatePhase = (phaseId: string, updates: Partial) => { - updatePhases((prev) => - prev.map((phase) => - phase.id === phaseId ? { ...phase, ...updates } : phase, - ), - ); - }; - - return ( -
    -
    -

    {t('Phases')}

    - -
    -

    - {t('Define the phases of your decision-making process')} -

    - -
    - ); -} - -export const PhaseEditor = ({ - phases, - setPhases, - updatePhase, - instanceId, -}: { - phases: PhaseDefinition[]; - setPhases: (phases: PhaseDefinition[]) => void; - updatePhase: (phaseId: string, updates: Partial) => void; - instanceId: string; -}) => { - const t = useTranslations(); - - // Safely parse a date string (ISO datetime or YYYY-MM-DD) to DateValue - const safeParseDateString = (dateStr: string | undefined) => { - if (!dateStr) { - return undefined; - } - try { - // Handle ISO datetime strings by extracting just the date part - const datePart = dateStr.split('T')[0]; - return datePart ? parseDate(datePart) : undefined; - } catch { - return undefined; - } - }; - - // Format DateValue to ISO datetime string - const formatDateValue = (date: { - year: number; - month: number; - day: number; - }) => { - return new Date(date.year, date.month - 1, date.day).toISOString(); - }; - - // Validation: track which fields have been blurred per phase - const [touchedFields, setTouchedFields] = useState< - Record> - >({}); - - const markTouched = (phaseId: string, fieldName: string) => { - setTouchedFields((prev) => { - const phaseSet = new Set(prev[phaseId]); - phaseSet.add(fieldName); - return { ...prev, [phaseId]: phaseSet }; - }); - }; - - // Compare the date portions of two ISO datetime strings. - // Returns negative if a < b, 0 if equal, positive if a > b. - const compareDateStrings = (a: string, b: string): number => { - const dateA = a.split('T')[0] ?? ''; - const dateB = b.split('T')[0] ?? ''; - return dateA.localeCompare(dateB); - }; - - const getPhaseErrors = (phase: PhaseDefinition) => { - const errors: Record = {}; - if (!phase.name?.trim()) { - errors.name = t('Phase name is required'); - } - if (!phase.headline?.trim()) { - errors.headline = t('Headline is required'); - } - if (!phase.description?.trim()) { - errors.description = t('Description is required'); - } - if (!phase.endDate) { - errors.endDate = t('End date is required'); - } - // Within-phase date validation: end date must be >= start date - if ( - phase.startDate && - phase.endDate && - compareDateStrings(phase.endDate, phase.startDate) < 0 - ) { - errors.endDate = t('End date must be on or after the start date'); - } - return errors; - }; - - const getErrorMessage = ( - phaseId: string, - field: string, - errors: Record, - ) => { - return touchedFields[phaseId]?.has(field) ? errors[field] : undefined; - }; - - const phaseHasVisibleErrors = (phaseId: string) => { - const phase = phases.find((p) => p.id === phaseId); - if (!phase) { - return false; - } - const errors = getPhaseErrors(phase); - const touched = touchedFields[phaseId]; - if (!touched) { - return false; - } - return Object.keys(errors).some((field) => touched.has(field)); - }; - - const [expandedKeys, setExpandedKeys] = useState>(new Set()); - const [autoFocusPhaseId, setAutoFocusPhaseId] = useState(null); - const [phaseToDelete, setPhaseToDelete] = useState(null); - const addPhase = () => { const newPhase: PhaseDefinition = { id: crypto.randomUUID().slice(0, 8), name: t('New phase'), rules: {}, }; - setPhases([...phases, newPhase]); - setExpandedKeys((prev) => new Set([...prev, newPhase.id])); - setAutoFocusPhaseId(newPhase.id); + const updated = [...phases, newPhase]; + setPhases(updated); + // Immediately update store so navigation sees the new phase + setInstanceData(decisionProfileId, { phases: toPayload(updated) }); + debouncedSave(updated); + setSection(phaseToSectionId(newPhase.id)); }; + const [phaseToDelete, setPhaseToDelete] = useState(null); + const confirmRemovePhase = () => { if (!phaseToDelete) { return; } - setPhases(phases.filter((p) => p.id !== phaseToDelete)); - setTouchedFields((prev) => { - const next = { ...prev }; - delete next[phaseToDelete]; - return next; - }); + const updated = phases.filter((p) => p.id !== phaseToDelete); + setPhases(updated); + // Immediately update store so navigation reflects the removal + setInstanceData(decisionProfileId, { phases: toPayload(updated) }); + debouncedSave(updated); setPhaseToDelete(null); }; - if (phases.length === 0) { - return ( -
    -
    -

    {t('No phases defined')}

    -
    - -
    + /** Check if a phase has its required fields filled in */ + const isPhaseConfigured = (phase: PhaseDefinition) => { + return !!( + phase.name?.trim() && + phase.headline?.trim() && + phase.description?.trim() && + phase.endDate ); - } + }; return ( -
    - - phase.name} - className="gap-2" - renderDragPreview={(items) => ( - - )} - renderDropIndicator={PhaseDropIndicator} - > - {(phase, { dragHandleProps, isDragging }) => { - const errors = getPhaseErrors(phase); - return ( - - +
    +
    + {t('Phases')} + +
    +

    + {t('Define the phases of your decision-making process')} +

    + + {phases.length === 0 ? ( +
    +
    +

    {t('No phases defined')}

    +
    + +
    + ) : ( +
    + phase.name} + className="gap-2" + renderDragPreview={(items) => ( + + )} + renderDropIndicator={PhaseDropIndicator} + > + {(phase, { dragHandleProps, isDragging }) => { + const configured = isPhaseConfigured(phase); + return ( +
    - - - - updatePhase(phase.id, { name })} - onBlur={() => markTouched(phase.id, 'name')} - hasError={!!getErrorMessage(phase.id, 'name', errors)} - aria-label={t('Phase name')} - autoFocus={autoFocusPhaseId === phase.id} - onAutoFocused={() => setAutoFocusPhaseId(null)} - /> - {phaseHasVisibleErrors(phase.id) && } - - -
    -
    - - updatePhase(phase.id, { headline: value }) - } - onBlur={() => markTouched(phase.id, 'headline')} - errorMessage={getErrorMessage( - phase.id, - 'headline', - errors, - )} - description={t( - 'This text appears as the header of the page.', +
    +
    +

    {phase.name}

    + {configured ? ( + + + {t('Configured')} + + ) : ( + + {t('Not configured yet')} + )} - /> - - updatePhase(phase.id, { description: value }) - } - onBlur={() => markTouched(phase.id, 'description')} - errorMessage={getErrorMessage( - phase.id, - 'description', - errors, - )} - textareaProps={{ rows: 3 }} - description={t( - 'This text appears below the headline on the phase page.', - )} - /> -
    - - - updatePhase(phase.id, { additionalInfo: content }) - } - toolbarPosition="bottom" - className="rounded-md border border-border" - editorClassName="min-h-24 p-3" - /> -

    - {t( - 'Any additional information will appear in a modal titled "About the process"', - )} -

    -
    -
    - - updatePhase(phase.id, { - startDate: formatDateValue(date), - }) - } - /> -
    -
    markTouched(phase.id, 'endDate')} +
    +
    + {t('Configure')} + +
    - updatePhase(phase.id, updates)} - /> -
    - -
    - - - ); - }} - - - - setPhaseToDelete(null)} - /> -
    - ); -}; - -/** Controls for configuring phase behavior (proposals, voting) */ -const PhaseControls = ({ - phase, - instanceId, - onUpdate, -}: { - phase: PhaseDefinition; - instanceId: string; - onUpdate: (updates: Partial) => void; -}) => { - const t = useTranslations(); - const utils = trpc.useUtils(); - - const updateRules = (updates: Partial) => { - const newRules = { ...phase.rules, ...updates }; - onUpdate({ rules: newRules }); - - // Optimistically update getInstance cache so useNavigationConfig reacts instantly - utils.decision.getInstance.setData({ instanceId }, (old) => { - if (!old?.instanceData?.phases) { - return old; - } - return { - ...old, - instanceData: { - ...old.instanceData, - phases: old.instanceData.phases.map((p) => - p.phaseId === phase.id ? { ...p, rules: newRules } : p, - ), - }, - }; - }); - }; +
    + ); + }} + + +
    + )} - return ( -
    - - - updateRules({ - proposals: { - ...phase.rules?.proposals, - submit: val, - }, - }) - } - size="small" - /> - - - - updateRules({ - proposals: { - ...phase.rules?.proposals, - edit: val, - }, - }) - } - size="small" - /> - - - - updateRules({ - proposals: { - ...phase.rules?.proposals, - review: val, - }, - }) - } - size="small" - /> - - - - updateRules({ - voting: { - ...phase.rules?.voting, - submit: val, - }, - }) + { + if (!open) { + setPhaseToDelete(null); } - size="small" - /> - -
    - ); -}; - -/** Input that is only editable when the accordion is expanded */ -const AccordionTitleInput = ({ - value, - onChange, - onBlur, - hasError, - autoFocus, - onAutoFocused, - 'aria-label': ariaLabel, -}: { - value: string; - onChange: (value: string) => void; - onBlur?: () => void; - hasError?: boolean; - autoFocus?: boolean; - onAutoFocused?: () => void; - 'aria-label'?: string; -}) => { - const t = useTranslations(); - const state = use(DisclosureStateContext); - const isExpanded = state?.isExpanded ?? false; - const inputRef = useRef(null); - - useEffect(() => { - if (autoFocus && isExpanded && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - onAutoFocused?.(); - } - }, [autoFocus, isExpanded, onAutoFocused]); - - if (!isExpanded) { - return ( - - ); - } - - return ( -
    - - {hasError && ( -

    - {t('Add a label for this phase.')} -

    - )} + {t('Delete phase?')} + +

    + {t( + 'Are you sure you want to delete this phase? This action cannot be undone.', + )} +

    +
    + + + + +
    ); -}; - -/** Warning icon shown on collapsed accordion headers when a phase has validation errors */ -const PhaseErrorIndicator = () => { - const t = useTranslations(); - const state = use(DisclosureStateContext); - const isExpanded = state?.isExpanded ?? false; - - if (isExpanded) { - return null; - } - - return ( - - - - ); -}; +} /** Element to show when a phase is being dragged */ const PhaseDragPreview = ({ name }: { name?: string }) => { @@ -692,7 +294,6 @@ const PhaseDragPreview = ({ name }: { name?: string }) => {
    -

    {name}

    ); diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/participants/SummarySectionContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/participants/SummarySectionContent.tsx new file mode 100644 index 000000000..b0ef91c6c --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/participants/SummarySectionContent.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { Skeleton } from '@op/ui/Skeleton'; +import { Suspense, useEffect, useState } from 'react'; + +import type { SectionProps } from '../../contentRegistry'; +import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; +import { SummarySectionInner } from './SummarySectionInner'; + +function SummarySkeleton() { + return ( +
    +
    + + +
    +
    + + +
    +
    + {[1, 2, 3].map((i) => ( +
    + + +
    + ))} +
    +
    + ); +} + +export default function SummarySectionContent(props: SectionProps) { + const [hasHydrated, setHasHydrated] = useState(() => + useProcessBuilderStore.persist.hasHydrated(), + ); + + useEffect(() => { + const unsubscribe = useProcessBuilderStore.persist.onFinishHydration(() => { + setHasHydrated(true); + }); + + void useProcessBuilderStore.persist.rehydrate(); + + return unsubscribe; + }, []); + + if (!hasHydrated) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/participants/SummarySectionInner.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/participants/SummarySectionInner.tsx new file mode 100644 index 000000000..57a9d22df --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/participants/SummarySectionInner.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { trpc } from '@op/api/client'; +import { Button } from '@op/ui/Button'; +import { Header2 } from '@op/ui/Header'; +import { useQueryState } from 'nuqs'; + +import { useTranslations } from '@/lib/i18n'; + +import type { SectionProps } from '../../contentRegistry'; +import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; +import { useProcessBuilderValidation } from '../../validation/useProcessBuilderValidation'; + +const CHECKLIST_SECTION_MAP: Record = { + processNameDescription: 'overview', + atLeastOnePhase: 'phases', + phaseDetails: 'phases', + proposalTemplate: 'templateEditor', + proposalTemplateErrors: 'templateEditor', + inviteMembers: 'participants', +}; + +export function SummarySectionInner({ + decisionProfileId, + instanceId, + decisionName, +}: SectionProps) { + const t = useTranslations(); + // Fetch up to the API maximum (100) to count active participants. + // The API does not expose a dedicated count endpoint, so we use the + // max page size. For profiles with >100 members the displayed count + // will be a minimum (usersData.next will be non-null in that case). + const [[instance, usersData, invites]] = trpc.useSuspenseQueries((t) => [ + t.decision.getInstance({ instanceId }), + t.profile.listUsers({ profileId: decisionProfileId, limit: 100 }), + t.profile.listProfileInvites({ profileId: decisionProfileId }), + ]); + + const storePhases = useProcessBuilderStore( + (s) => s.instances[decisionProfileId]?.phases, + ); + const storeCategories = useProcessBuilderStore( + (s) => s.instances[decisionProfileId]?.config?.categories, + ); + + const { isReadyToLaunch, checklist } = + useProcessBuilderValidation(decisionProfileId); + + const [, setSectionParam] = useQueryState('section', { history: 'push' }); + + const isDraft = instance.status === 'draft'; + const instancePhases = instance.instanceData?.phases; + const instanceCategories = instance.instanceData?.config?.categories; + const templatePhases = instance.process?.processSchema?.phases; + + const phasesCount = + (!isDraft && storePhases?.length + ? storePhases.length + : instancePhases?.length) ?? + templatePhases?.length ?? + 0; + + const categories = storeCategories ?? instanceCategories ?? []; + const activeUsersCount = usersData.items?.length ?? 0; + const participantsCount = activeUsersCount + (invites?.length ?? 0); + + const processName = decisionName || instance.name || ''; + + if (!isReadyToLaunch) { + const incompleteItems = checklist.filter((item) => !item.isValid); + + return ( +
    +
    +

    {t('Summary')}

    + + {t('Your process still needs more information')} + +
    +

    + {processName}{' '} + {t('is missing information in order to go live.')} +

    +
    + {incompleteItems.map((item, index) => ( +
    +
    + + {t(item.labelKey)} + + +
    + {index < incompleteItems.length - 1 && ( +
    + )} +
    + ))} +
    +
    + ); + } + + return ( +
    +
    +

    {t('Summary')} 🚀

    + + {t('Review your process')} + +
    +

    + {processName}{' '} + {t( + 'is ready to go live. Launching your process will invite and notify participants.', + )} +

    +

    {t('You can always edit and invite participants after launching.')}

    +
    +
    +
    + {t('Phases')} + + {phasesCount} + +
    +
    +
    +
    +
    + + {t('Categories')} + + + {categories.length} + +
    +
    +
    +
    + + {t('Participants Invited')} + + + {participantsCount} + +
    +
    +
    + ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/usePhaseValidation.ts b/apps/app/src/components/decisions/ProcessBuilder/usePhaseValidation.ts new file mode 100644 index 000000000..c9a10a7d0 --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/usePhaseValidation.ts @@ -0,0 +1,26 @@ +'use client'; + +import { trpc } from '@op/api/client'; +import { useMemo } from 'react'; + +import { useProcessBuilderStore } from './stores/useProcessBuilderStore'; +import { isPhaseValid } from './validation/processBuilderValidation'; + +export function usePhaseValidation( + instanceId: string, + decisionProfileId?: string, +): Record { + const storePhases = useProcessBuilderStore((s) => + decisionProfileId ? s.instances[decisionProfileId]?.phases : undefined, + ); + + const { data: instance } = trpc.decision.getInstance.useQuery( + { instanceId }, + { enabled: !!instanceId }, + ); + + return useMemo(() => { + const source = storePhases ?? instance?.instanceData?.phases ?? []; + return Object.fromEntries(source.map((p) => [p.phaseId, isPhaseValid(p)])); + }, [storePhases, instance]); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/useProcessNavigation.ts b/apps/app/src/components/decisions/ProcessBuilder/useProcessNavigation.ts index 63ce1b57a..23531f703 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/useProcessNavigation.ts +++ b/apps/app/src/components/decisions/ProcessBuilder/useProcessNavigation.ts @@ -9,11 +9,21 @@ import { SIDEBAR_ITEMS, STEPS, type SectionId, + type SidebarItem, type StepId, + isPhaseSection, + phaseToSectionId, } from './navigationConfig'; +export interface PhaseNavItem { + phaseId: string; + name: string; +} + export function useProcessNavigation( navigationConfig: NavigationConfig = DEFAULT_NAVIGATION_CONFIG, + phases: PhaseNavItem[] = [], + excludedSectionIds: string[] = [], ) { const [sectionParam, setSectionParam] = useQueryState('section', { history: 'push', @@ -24,9 +34,10 @@ export function useProcessNavigation( history: 'replace', }); - // Filter SIDEBAR_ITEMS to only visible sections based on navigationConfig + // Filter SIDEBAR_ITEMS to only visible sections based on navigationConfig, + // then insert dynamic phase sections after 'phases' const visibleSections = useMemo(() => { - return SIDEBAR_ITEMS.filter((item) => { + const filtered = SIDEBAR_ITEMS.filter((item) => { const stepId = item.parentStepId; if (!stepId) { return true; @@ -40,9 +51,41 @@ export function useProcessNavigation( if (!allowedSectionIds) { return false; } - return allowedSectionIds.some((id) => id === item.id); + if (!allowedSectionIds.some((id) => id === item.id)) { + return false; + } + // Section must not be explicitly excluded + return !excludedSectionIds.includes(item.id); }); - }, [navigationConfig.steps, navigationConfig.sections]); + + // Insert dynamic phase sections after 'phases' item + if (phases.length === 0) { + return filtered; + } + + const phasesIndex = filtered.findIndex((item) => item.id === 'phases'); + if (phasesIndex === -1) { + return filtered; + } + + const phaseSections: SidebarItem[] = phases.map((phase) => ({ + id: phaseToSectionId(phase.phaseId), + labelKey: phase.name, + parentStepId: 'general' as const, + isDynamic: true, + })); + + return [ + ...filtered.slice(0, phasesIndex + 1), + ...phaseSections, + ...filtered.slice(phasesIndex + 1), + ]; + }, [ + navigationConfig.steps, + navigationConfig.sections, + phases, + excludedSectionIds, + ]); // Backward compatibility: derive section from old step+section params useEffect(() => { @@ -53,11 +96,31 @@ export function useProcessNavigation( } }, [legacyStepParam, sectionParam, setLegacyStepParam]); - // Current section (fallback to first visible section) + // Current section (fallback to last visible section if excluded, else first) const currentSection = useMemo(() => { const found = visibleSections.find((s) => s.id === sectionParam); - return found ?? visibleSections[0]; - }, [sectionParam, visibleSections]); + if (found) { + return found; + } + // If the current section was explicitly excluded, redirect to the last + // visible section (e.g., when summary is hidden, go back to participants) + if (sectionParam && excludedSectionIds.includes(sectionParam)) { + return visibleSections[visibleSections.length - 1] ?? visibleSections[0]; + } + return visibleSections[0]; + }, [sectionParam, visibleSections, excludedSectionIds]); + + // Current index in visible sections for next/back navigation + const currentIndex = useMemo(() => { + if (!currentSection) { + return 0; + } + const idx = visibleSections.findIndex((s) => s.id === currentSection.id); + return idx === -1 ? 0 : idx; + }, [currentSection, visibleSections]); + + const hasNext = currentIndex < visibleSections.length - 1; + const hasPrev = currentIndex > 0; // Derive currentStep from currentSection's parentStepId (for backward compat with consumers) const currentStep = useMemo(() => { @@ -77,9 +140,15 @@ export function useProcessNavigation( [navigationConfig.steps], ); - // Replace invalid section param in URL + // Replace invalid section param in URL (skip dynamic phase sections — + // not all hook consumers receive the phases list, so phase-* IDs may be + // absent from visibleSections without being truly invalid) useEffect(() => { - if (sectionParam && !visibleSections.some((s) => s.id === sectionParam)) { + if ( + sectionParam && + !visibleSections.some((s) => s.id === sectionParam) && + !isPhaseSection(sectionParam) + ) { setSectionParam(currentSection?.id ?? null); } }, [sectionParam, visibleSections, currentSection, setSectionParam]); @@ -109,6 +178,18 @@ export function useProcessNavigation( [visibleSteps, visibleSections, setSectionParam], ); + const goNext = useCallback(() => { + if (hasNext) { + setSectionParam(visibleSections[currentIndex + 1]!.id); + } + }, [hasNext, currentIndex, visibleSections, setSectionParam]); + + const goBack = useCallback(() => { + if (hasPrev) { + setSectionParam(visibleSections[currentIndex - 1]!.id); + } + }, [hasPrev, currentIndex, visibleSections, setSectionParam]); + return { currentStep, currentSection, @@ -116,5 +197,9 @@ export function useProcessNavigation( visibleSections, setStep, setSection, + goNext, + goBack, + hasNext, + hasPrev, }; } diff --git a/apps/app/src/components/decisions/ProcessBuilder/useProcessPhases.ts b/apps/app/src/components/decisions/ProcessBuilder/useProcessPhases.ts new file mode 100644 index 000000000..64f29d505 --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/useProcessPhases.ts @@ -0,0 +1,46 @@ +'use client'; + +import { trpc } from '@op/api/client'; +import { useMemo } from 'react'; + +import { useProcessBuilderStore } from './stores/useProcessBuilderStore'; + +export interface ProcessPhase { + phaseId: string; + name: string; +} + +export function useProcessPhases( + instanceId: string, + decisionProfileId?: string, +): ProcessPhase[] { + const storePhases = useProcessBuilderStore((s) => + decisionProfileId ? s.instances[decisionProfileId]?.phases : undefined, + ); + + const { data: instance } = trpc.decision.getInstance.useQuery( + { instanceId }, + { enabled: !!instanceId }, + ); + + return useMemo(() => { + if (storePhases?.length) { + return storePhases.map((p) => ({ + phaseId: p.phaseId, + name: p.name ?? '', + })); + } + const instancePhases = instance?.instanceData?.phases; + if (instancePhases?.length) { + return instancePhases.map((p) => ({ + phaseId: p.phaseId, + name: p.name ?? '', + })); + } + const templatePhases = instance?.process?.processSchema?.phases; + if (templatePhases?.length) { + return templatePhases.map((p) => ({ phaseId: p.id, name: p.name })); + } + return []; + }, [storePhases, instance]); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts b/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts index 65660b59f..7f8803586 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts +++ b/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts @@ -13,6 +13,7 @@ export interface ValidationSummary { sections: Record; stepsRemaining: number; isReadyToLaunch: boolean; + completionPercentage: number; checklist: { id: string; labelKey: TranslationKey; isValid: boolean }[]; } @@ -61,6 +62,7 @@ const SECTION_VALIDATORS: Record = { criteria: () => true, roles: () => true, participants: () => true, + summary: (data) => LAUNCH_CHECKLIST.every((item) => item.validate(data)), }; // ============ Checklist Items ============ @@ -124,6 +126,17 @@ const LAUNCH_CHECKLIST: ChecklistItem[] = [ }, ]; +// ============ Phase Validation ============ + +export function isPhaseValid(phase: { + name?: string | null; + headline?: string | null; + description?: string | null; + endDate?: string | null; +}): boolean { + return phaseSchema.safeParse(phase).success; +} + // ============ Validation ============ export function validateAll( @@ -141,11 +154,18 @@ export function validateAll( })); const stepsRemaining = checklist.filter((item) => !item.isValid).length; + const completionPercentage = + checklist.length > 0 + ? Math.round( + ((checklist.length - stepsRemaining) / checklist.length) * 100, + ) + : 0; return { sections, stepsRemaining, isReadyToLaunch: stepsRemaining === 0, + completionPercentage, checklist, }; } diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index 5cafdb0d4..4fd37d827 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -131,6 +131,7 @@ "Budget": "বাজেট", "Proposals": "প্রস্তাবনা", "Participants": "অংশগ্রহণকারী", + "Summary": "সারসংক্ষেপ", "View Details": "বিস্তারিত দেখুন", "Edit Process": "প্রক্রিয়া সম্পাদনা করুন", "Participate": "অংশগ্রহণ করুন", @@ -259,6 +260,7 @@ "About the process": "এই প্রক্রিয়া সম্পর্কে", "Delete Proposal": "প্রস্তাব মুছুন", "Delete phase": "পর্যায় মুছুন", + "Delete phase?": "পর্যায় মুছবেন?", "Are you sure you want to delete this phase? This action cannot be undone.": "আপনি কি নিশ্চিত যে আপনি এই পর্যায়টি মুছতে চান? এই ক্রিয়াটি পূর্বাবস্থায় ফেরানো যাবে না।", "Delete": "মুছুন", "Deleting...": "মুছে ফেলা হচ্ছে...", @@ -523,6 +525,7 @@ "No phases defined": "কোনো পর্যায় নির্ধারিত নেই", "Add phase": "পর্যায় যোগ করুন", "New phase": "নতুন পর্যায়", + "Untitled phase": "শিরোনামহীন পর্যায়", "Proposal Categories": "প্রস্তাব বিভাগসমূহ", "Voting": "ভোটদান", "Template Editor": "টেমপ্লেট এডিটর", @@ -929,5 +932,18 @@ "{roleName} plural": "{roleName}", "Launch process?": "প্রক্রিয়া চালু করবেন?", "Launching your process will notify": "আপনার প্রক্রিয়া চালু করলে বিজ্ঞপ্তি পাবেন", - "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 জন অংশগ্রহণকারী} other {# জন অংশগ্রহণকারী}}।" + "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 জন অংশগ্রহণকারী} other {# জন অংশগ্রহণকারী}}।", + "Not configured yet": "এখনও কনফিগার করা হয়নি", + "Configured": "কনফিগার করা হয়েছে", + "Configure": "কনফিগার করুন", + "Your process still needs more information": "আপনার প্রক্রিয়াটির জন্য এখনও আরও তথ্য প্রয়োজন", + "is missing information in order to go live.": "লাইভ হওয়ার জন্য তথ্য অনুপস্থিত।", + "Take me there": "আমাকে সেখানে নিয়ে যাও", + "Next": "পরবর্তী", + "{count}% complete": "{count}% সম্পূর্ণ", + "Review your process": "আপনার প্রক্রিয়া পর্যালোচনা করুন", + "is ready to go live. Launching your process will invite and notify participants.": "লাইভ হতে প্রস্তুত। আপনার প্রক্রিয়া চালু করলে অংশগ্রহণকারীদের আমন্ত্রণ জানানো এবং বিজ্ঞপ্তি পাঠানো হবে।", + "You can always edit and invite participants after launching.": "চালু করার পরেও আপনি সম্পাদনা করতে এবং অংশগ্রহণকারীদের আমন্ত্রণ জানাতে পারবেন।", + "Participants Invited": "আমন্ত্রিত অংশগ্রহণকারী", + "Manage Participants": "অংশগ্রহণকারীদের পরিচালনা করুন" } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 7429d73e3..977ffdd9c 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -131,6 +131,7 @@ "Budget": "Budget", "Proposals": "Proposals", "Participants": "Participants", + "Summary": "Summary", "View Details": "View Details", "Edit Process": "Edit Process", "Participate": "Participate", @@ -260,6 +261,7 @@ "About the process": "About the process", "Delete Proposal": "Delete Proposal", "Delete phase": "Delete phase", + "Delete phase?": "Delete phase?", "Are you sure you want to delete this phase? This action cannot be undone.": "Are you sure you want to delete this phase? This action cannot be undone.", "Delete": "Delete", "Deleting...": "Deleting...", @@ -516,6 +518,7 @@ "No phases defined": "No phases defined", "Add phase": "Add phase", "New phase": "New phase", + "Untitled phase": "Untitled phase", "Proposal Categories": "Proposal Categories", "Voting": "Voting", "Template Editor": "Template Editor", @@ -922,5 +925,18 @@ "{roleName} plural": "{roleName}s", "Launch process?": "Launch process?", "Launching your process will notify": "Launching your process will notify", - "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participant} other {# participants}}." + "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participant} other {# participants}}.", + "Not configured yet": "Not configured yet", + "Configured": "Configured", + "Configure": "Configure", + "Your process still needs more information": "Your process still needs more information", + "is missing information in order to go live.": "is missing information in order to go live.", + "Take me there": "Take me there", + "Next": "Next", + "{count}% complete": "{count}% complete", + "Review your process": "Review your process", + "is ready to go live. Launching your process will invite and notify participants.": "is ready to go live. Launching your process will invite and notify participants.", + "You can always edit and invite participants after launching.": "You can always edit and invite participants after launching.", + "Participants Invited": "Participants Invited", + "Manage Participants": "Manage Participants" } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index c5cfb1601..07cfb7eef 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -130,6 +130,7 @@ "Budget": "Presupuesto", "Proposals": "Propuestas", "Participants": "Participantes", + "Summary": "Resumen", "View Details": "Ver detalles", "Edit Process": "Editar proceso", "Participate": "Participar", @@ -259,6 +260,7 @@ "About the process": "Acerca del proceso", "Delete Proposal": "Eliminar propuesta", "Delete phase": "Eliminar fase", + "Delete phase?": "¿Eliminar fase?", "Are you sure you want to delete this phase? This action cannot be undone.": "¿Estás seguro de que quieres eliminar esta fase? Esta acción no se puede deshacer.", "Delete": "Eliminar", "Deleting...": "Eliminando...", @@ -515,6 +517,7 @@ "No phases defined": "No hay fases definidas", "Add phase": "Agregar fase", "New phase": "Nueva fase", + "Untitled phase": "Fase sin título", "Proposal Categories": "Categorías de propuestas", "Voting": "Votación", "Template Editor": "Editor de plantilla", @@ -922,5 +925,18 @@ "{roleName} plural": "{roleName}s", "Launch process?": "¿Lanzar proceso?", "Launching your process will notify": "Al lanzar su proceso se notificará a", - "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participante} other {# participantes}}." + "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participante} other {# participantes}}.", + "Not configured yet": "Aún no configurado", + "Configured": "Configurado", + "Configure": "Configurar", + "Your process still needs more information": "Su proceso aún necesita más información", + "is missing information in order to go live.": "le falta información para publicarse.", + "Take me there": "Llevarme allí", + "Next": "Siguiente", + "{count}% complete": "{count}% completado", + "Review your process": "Revisa tu proceso", + "is ready to go live. Launching your process will invite and notify participants.": "está listo para publicarse. Lanzar tu proceso invitará y notificará a los participantes.", + "You can always edit and invite participants after launching.": "Siempre puedes editar e invitar participantes después de lanzar.", + "Participants Invited": "Participantes Invitados", + "Manage Participants": "Gestionar Participantes" } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index 17dc2ba6c..0a853749b 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -131,6 +131,7 @@ "Budget": "Budget", "Proposals": "Propositions", "Participants": "Participants", + "Summary": "Résumé", "View Details": "Voir les détails", "Edit Process": "Modifier le processus", "Participate": "Participer", @@ -260,6 +261,7 @@ "About the process": "À propos du processus", "Delete Proposal": "Supprimer la proposition", "Delete phase": "Supprimer la phase", + "Delete phase?": "Supprimer la phase ?", "Are you sure you want to delete this phase? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer cette phase ? Cette action est irréversible.", "Delete": "Supprimer", "Deleting...": "Suppression...", @@ -516,6 +518,7 @@ "No phases defined": "Aucune phase définie", "Add phase": "Ajouter une phase", "New phase": "Nouvelle phase", + "Untitled phase": "Phase sans titre", "Proposal Categories": "Catégories de propositions", "Voting": "Vote", "Template Editor": "Éditeur de modèle", @@ -922,5 +925,18 @@ "{roleName} plural": "{roleName}s", "Launch process?": "Lancer le processus ?", "Launching your process will notify": "Le lancement de votre processus notifiera", - "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participant} other {# participants}}." + "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participant} other {# participants}}.", + "Not configured yet": "Pas encore configuré", + "Configured": "Configuré", + "Configure": "Configurer", + "Your process still needs more information": "Votre processus nécessite encore plus d'informations", + "is missing information in order to go live.": "manque d'informations pour être publié.", + "Take me there": "M'y emmener", + "Next": "Suivant", + "{count}% complete": "{count}% terminé", + "Review your process": "Révisez votre processus", + "is ready to go live. Launching your process will invite and notify participants.": "est prêt à être lancé. Le lancement de votre processus invitera et notifiera les participants.", + "You can always edit and invite participants after launching.": "Vous pouvez toujours modifier et inviter des participants après le lancement.", + "Participants Invited": "Participants Invités", + "Manage Participants": "Gérer les Participants" } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 448c84fcb..3648394d4 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -131,6 +131,7 @@ "Budget": "Orçamento", "Proposals": "Propostas", "Participants": "Participantes", + "Summary": "Resumo", "View Details": "Ver detalhes", "Edit Process": "Editar processo", "Participate": "Participar", @@ -260,6 +261,7 @@ "About the process": "Sobre o processo", "Delete Proposal": "Excluir proposta", "Delete phase": "Excluir fase", + "Delete phase?": "Excluir fase?", "Are you sure you want to delete this phase? This action cannot be undone.": "Tem certeza de que deseja excluir esta fase? Esta ação não pode ser desfeita.", "Delete": "Excluir", "Deleting...": "Excluindo...", @@ -516,6 +518,7 @@ "No phases defined": "Nenhuma fase definida", "Add phase": "Adicionar fase", "New phase": "Nova fase", + "Untitled phase": "Fase sem título", "Proposal Categories": "Categorias de propostas", "Voting": "Votação", "Template Editor": "Editor de modelo", @@ -922,5 +925,18 @@ "{roleName} plural": "{roleName}s", "Launch process?": "Lançar processo?", "Launching your process will notify": "O lançamento do seu processo notificará", - "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participante} other {# participantes}}." + "{count, plural, =1 {1 participant} other {# participants}}.": "{count, plural, =1 {1 participante} other {# participantes}}.", + "Not configured yet": "Ainda não configurado", + "Configured": "Configurado", + "Configure": "Configurar", + "Your process still needs more information": "Seu processo ainda precisa de mais informações", + "is missing information in order to go live.": "está faltando informações para entrar no ar.", + "Take me there": "Me leve lá", + "Next": "Próximo", + "{count}% complete": "{count}% concluído", + "Review your process": "Revise seu processo", + "is ready to go live. Launching your process will invite and notify participants.": "está pronto para ser lançado. Lançar seu processo convidará e notificará os participantes.", + "You can always edit and invite participants after launching.": "Você sempre pode editar e convidar participantes após o lançamento.", + "Participants Invited": "Participantes Convidados", + "Manage Participants": "Gerenciar Participantes" } diff --git a/packages/ui/package.json b/packages/ui/package.json index 9129d7824..ec8148caf 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -55,6 +55,7 @@ "./SearchField": "./src/components/SearchField.tsx", "./Select": "./src/components/Select.tsx", "./ShaderBackground": "./src/components/ShaderBackground/index.ts", + "./Sheet": "./src/components/Sheet.tsx", "./Sidebar": "./src/components/Sidebar/index.ts", "./Skeleton": "./src/components/Skeleton.tsx", "./SocialLinks": "./src/components/SocialLinks.tsx", diff --git a/packages/ui/src/components/Sheet.tsx b/packages/ui/src/components/Sheet.tsx new file mode 100644 index 000000000..c1cd5e142 --- /dev/null +++ b/packages/ui/src/components/Sheet.tsx @@ -0,0 +1,104 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { + Dialog, + DialogTrigger, + Modal, + ModalOverlay, +} from 'react-aria-components'; +import type { ModalOverlayProps } from 'react-aria-components'; +import { LuX } from 'react-icons/lu'; + +import { cn } from '../lib/utils'; + +const SIDE_CLASSES: Record = { + bottom: + 'inset-x-0 bottom-0 top-auto w-full max-h-[85svh] rounded-t-2xl entering:animate-in entering:slide-in-from-bottom exiting:animate-out exiting:slide-out-to-bottom', + left: 'inset-y-0 left-0 right-auto h-full max-w-xs w-full rounded-none entering:animate-in entering:slide-in-from-left exiting:animate-out exiting:slide-out-to-left', + right: + 'inset-y-0 right-0 left-auto h-full max-w-xs w-full rounded-none entering:animate-in entering:slide-in-from-right exiting:animate-out exiting:slide-out-to-right', +}; + +type SheetSide = 'bottom' | 'left' | 'right'; + +export const Sheet = ({ + side = 'bottom', + className, + children, + isDismissable = true, + ...props +}: ModalOverlayProps & { + side?: SheetSide; + className?: string; + children: ReactNode; +}) => { + return ( + + + + {children} + + + + ); +}; + +export const SheetTrigger = DialogTrigger; + +export const SheetHeader = ({ + children, + className, + onClose, +}: { + children?: ReactNode; + className?: string; + onClose?: () => void; +}) => { + return ( +
    + {children && {children}} + {onClose && ( + + )} +
    + ); +}; + +export const SheetBody = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) => { + return ( +
    + {children} +
    + ); +}; + +export { type ModalOverlayProps as SheetProps }; diff --git a/packages/ui/stories/Sheet.stories.tsx b/packages/ui/stories/Sheet.stories.tsx new file mode 100644 index 000000000..4024f0f4c --- /dev/null +++ b/packages/ui/stories/Sheet.stories.tsx @@ -0,0 +1,140 @@ +import { Button } from '../src/components/Button'; +import { + Sheet, + SheetBody, + SheetHeader, + SheetTrigger, +} from '../src/components/Sheet'; + +export default { + title: 'Sheet', + component: Sheet, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export const BottomSheet = () => ( + + + + Navigation + + + + + +); + +export const RightSheet = () => ( + + + + Filters + +
    +
    +

    Status

    +
    + {['Draft', 'Active', 'Completed'].map((status) => ( + + ))} +
    +
    +
    +

    Priority

    +
    + {['High', 'Medium', 'Low'].map((priority) => ( + + ))} +
    +
    +
    +
    +
    +
    +); + +export const LeftSheet = () => ( + + + + Menu + + + + + +); + +export const AllSides = () => ( +
    + + + + Bottom Sheet + +

    + Slides up from the bottom. Useful for mobile navigation menus. +

    +
    +
    +
    + + + + + Right Sheet + +

    + Slides in from the right. Useful for detail panels and filters. +

    +
    +
    +
    + + + + + Left Sheet + +

    + Slides in from the left. Useful for sidebars and navigation. +

    +
    +
    +
    +
    +); diff --git a/scripts/prd.json b/scripts/prd.json new file mode 100644 index 000000000..9d564ca3d --- /dev/null +++ b/scripts/prd.json @@ -0,0 +1,96 @@ +{ + "project": "OP Common", + "branchName": "decision-builder-sidebar-stepper", + "description": "Process Builder Summary Step - Add a conditional Summary section that appears when all validation passes, showing process stats before launch", + "userStories": [ + { + "id": "US-001", + "title": "Add Summary section to navigation config and content registry", + "description": "As a developer, I need to register a new 'summary' section ID in the navigation config, sidebar items, and content registry so the Summary page can be navigated to and rendered.", + "acceptanceCriteria": [ + "Add 'summary' as a new section ID in navigationConfig.ts under the 'participants' step (or a new step)", + "Add a SIDEBAR_ITEMS entry for Summary with labelKey 'Summary' as the last item", + "Add 'summary' to SECTIONS_BY_STEP so it's included in the navigation flow", + "Create a placeholder SummarySectionContent component that renders 'Summary' heading", + "Register the Summary component in contentRegistry.tsx (both CONTENT_REGISTRY and FLAT_CONTENT_REGISTRY)", + "Add a Suspense-wrapped section component following the existing pattern", + "Add translation key 'Summary' to ALL language files in apps/app/src/lib/i18n/dictionaries/", + "Navigating to ?section=summary renders the placeholder component", + "Typecheck passes" + ], + "priority": 1, + "passes": true, + "notes": "Follow the pattern of existing sections. The summary section should be the very last in SIDEBAR_ITEMS. Place it under the 'participants' step in SECTIONS_BY_STEP or create a dedicated step. Add it to DEFAULT_NAVIGATION_CONFIG sections." + }, + { + "id": "US-002", + "title": "Conditionally show Summary in sidebar based on validation", + "description": "As a user, I want the Summary section to only appear in the sidebar when all validation checks pass, so I know the process is ready to launch.", + "acceptanceCriteria": [ + "Summary sidebar item is hidden when isReadyToLaunch is false", + "Summary sidebar item appears at the bottom of the sidebar when isReadyToLaunch is true", + "The stepper (Next/Back) includes Summary in its sequence only when it is visible", + "If validation becomes invalid while on Summary, the user is redirected to the previous section (participants)", + "The mobile sidebar also conditionally shows/hides the Summary item", + "Typecheck passes" + ], + "priority": 2, + "passes": true, + "notes": "The conditional visibility should happen in useProcessNavigation or at the sidebar level. useProcessBuilderValidation provides isReadyToLaunch. Pass it to the navigation hook or filter visibleSections. The ProcessBuilderSectionNav and MobileSidebar in ProcessBuilderHeader both need the conditional logic." + }, + { + "id": "US-003", + "title": "Build SummarySectionContent with process stats", + "description": "As a user, I want to see a summary of my process configuration before launching so I can confirm everything is correct.", + "acceptanceCriteria": [ + "Display 'Summary 🚀' as a small text-xs label in text-neutral-gray4", + "Display 'Review your process' as the heading using font-serif text-title-sm", + "Display bold process name followed by 'is ready to go live. Launching your process will invite and notify participants.'", + "Display 'You can always edit and invite participants after launching.' as a second paragraph", + "Display a bordered summary table (rounded-lg border) with rows: Phases count, Categories count, Participants Invited count", + "Table rows separated by border-b, labels in text-neutral-gray4, values in text-charcoal text-right", + "Phases count comes from store phases or instance phases", + "Categories count comes from instance proposalCategories", + "Participants count comes from tRPC profile.listUsers or profile.listProfileInvites queries", + "All user-facing strings wrapped with t() and added to ALL language files", + "Typecheck passes", + "Verify in browser using dev-browser skill" + ], + "priority": 3, + "passes": true, + "notes": "Follow the Figma design at node 6734:1689. Use the same layout pattern as other section pages (mx-auto w-full space-y-4 p-4 md:max-w-160 md:p-8). Read phases from useProcessBuilderStore, categories from instance data, participants from existing tRPC queries used on the Participants page." + }, + { + "id": "US-004", + "title": "Hide Next button on Summary page", + "description": "As a user, when I'm on the Summary page I should not see the Next button since it's the last step, and the Launch Process button is the primary action.", + "acceptanceCriteria": [ + "The Next button is hidden when the current section is 'summary' (hasNext is false since it's the last section)", + "The Launch Process / Update Process button remains visible and enabled on the Summary page", + "The Back button still works to go back to the previous section (participants)", + "The progress bar shows 100% when on the Summary page", + "The StepsRemaining popover is hidden when on Summary (since validation is complete)", + "Typecheck passes", + "Verify in browser using dev-browser skill" + ], + "priority": 4, + "passes": true, + "notes": "This should mostly work automatically if Summary is correctly the last item in visibleSections - hasNext will be false. The StepsRemaining popover already hides when stepsRemaining === 0. Verify these behaviors work correctly." + }, + { + "id": "US-005", + "title": "Add all translation keys for Summary step", + "description": "As a developer, I need all new user-facing strings properly internationalized across all language files.", + "acceptanceCriteria": [ + "Translation keys added for: 'Summary', 'Review your process', '{name} is ready to go live. Launching your process will invite and notify participants.', 'You can always edit and invite participants after launching.', 'Phases', 'Categories', 'Participants Invited'", + "Keys added to ALL language files in apps/app/src/lib/i18n/dictionaries/", + "All new strings in SummarySectionContent use t() from useTranslations", + "No hardcoded user-facing strings in any modified files", + "Typecheck passes" + ], + "priority": 5, + "passes": true, + "notes": "Check all .json files in the dictionaries folder. Some keys like 'Phases' and 'Categories' may already exist. Only add missing ones. Use t('{name} is ready to go live...', { name: processName }) for interpolation." + } + ] +} diff --git a/scripts/progress.txt b/scripts/progress.txt new file mode 100644 index 000000000..7e1a7b005 --- /dev/null +++ b/scripts/progress.txt @@ -0,0 +1,61 @@ +## Codebase Patterns +- `SECTIONS_BY_STEP` in navigationConfig.ts must include ALL StepIds (satisfies Record). Adding a section requires updating SECTIONS_BY_STEP, SIDEBAR_ITEMS, DEFAULT_NAVIGATION_CONFIG.sections, and SECTION_VALIDATORS in processBuilderValidation.ts +- `SectionId` type is derived automatically from SECTIONS_BY_STEP, so adding a section there makes it available everywhere +- Section components live in `stepContent/{stepName}/ComponentName.tsx` and receive `SectionProps` from contentRegistry +- Always add to both `CONTENT_REGISTRY` and `FLAT_CONTENT_REGISTRY` in contentRegistry.tsx + +# Ralph Progress Log +Started: Fri Mar 6 14:38:34 CET 2026 +--- + +## 2026-03-06 - US-001 +- Added 'summary' section ID to SECTIONS_BY_STEP (participants step), SIDEBAR_ITEMS, DEFAULT_NAVIGATION_CONFIG +- Created SummarySectionContent.tsx placeholder component +- Registered in CONTENT_REGISTRY and FLAT_CONTENT_REGISTRY +- Added 'summary' to SECTION_VALIDATORS (required due to Record type) +- Added 'Summary' translation key to all 5 language files (en, es, fr, pt, bn) +- Files changed: navigationConfig.ts, contentRegistry.tsx, processBuilderValidation.ts, SummarySectionContent.tsx, all 5 dict files +- **Learnings for future iterations:** + - processBuilderValidation.ts uses `Record` - must add new sections there too + - fr.json had "Participants" → "Participants" (same in French), needed careful grep to find right occurrence +--- + +## 2026-03-06 - US-002 +- Added `excludedSectionIds: string[]` parameter to `useProcessNavigation` hook +- Excluded sections are filtered from visibleSections in the useMemo +- When current sectionParam is in excludedSectionIds, currentSection falls back to last visible section (redirects to participants) +- Called `useProcessBuilderValidation(decisionProfileId)` in ProcessBuilderSectionNav, MobileSidebar (ProcessBuilderHeader), ProcessBuilderContent, and ProcessBuilderFooter +- Passes `excludedSectionIds = isReadyToLaunch ? [] : ['summary']` to useProcessNavigation in all 4 consumers +- Files changed: useProcessNavigation.ts, ProcessBuilderSectionNav.tsx, ProcessBuilderHeader.tsx, ProcessBuilderFooter.tsx, ProcessBuilderContent.tsx +- **Learnings for future iterations:** + - useProcessNavigation is called in 4 separate components (SectionNav, MobileSidebar, Footer, Content) - all must be updated consistently for conditional section visibility + - The redirect effect in useProcessNavigation uses `currentSection` which falls back to `visibleSections[0]` - override to use `visibleSections[visibleSections.length-1]` for excluded sections to navigate to "previous" section + - ProcessBuilderContent also needs the exclusion to prevent rendering excluded section content +--- + +## 2026-03-06 - US-003 + US-005 +- Refactored SummarySectionContent.tsx into a Suspense wrapper with store hydration (same pattern as PhasesSection) +- Created SummarySectionInner.tsx with actual content: phases/categories/participants stats +- Stats sources: phases from store or instance phases (same logic as PhasesSectionContent), categories from store or instance config, participants from trpc.profile.listUsers + trpc.profile.listProfileInvites counts +- Styled with rounded-lg border table, text-neutral-gray4 labels, text-neutral-charcoal values +- Added translation keys to all 5 language files: 'Review your process', 'is ready to go live...', 'You can always edit...', 'Participants Invited' +- Also marked US-005 as passing since all required translation keys were added in this story +- Files changed: SummarySectionContent.tsx, SummarySectionInner.tsx (new), en/es/fr/pt/bn.json +- **Learnings for future iterations:** + - The Suspense wrapper pattern: wrapper handles hydration (useState + useEffect on persist.onFinishHydration), inner component uses useSuspenseQuery + - SectionProps includes: decisionProfileId, instanceId, decisionName - all three are available + - For participant count: listUsers returns { items, next } with cursor pagination; use items.length for count (with limit:100) + - listProfileInvites returns array directly (not paginated) + - For bold name with i18n, use separate span: {name} + t('rest of sentence') +--- + +## 2026-03-06 - US-004 +- No code changes needed - verified all acceptance criteria work automatically from existing implementation +- hasNext is false when on summary (it's last in visibleSections) +- Launch button always visible, enabled when isReadyToLaunch = true (which is required for summary to appear) +- hasPrev = true on summary (participants is before it) +- completionPercentage = 100 when stepsRemaining = 0 (when isReadyToLaunch = true) +- StepsRemaining popover: `{validation.stepsRemaining > 0 && ...}` - hidden when 0 +- **Learnings for future iterations:** + - PRD notes were accurate: "This should mostly work automatically" - trust the notes when they say things work automatically +---