diff --git a/apps/app/src/components/ConfirmDeleteModal.tsx b/apps/app/src/components/ConfirmDeleteModal.tsx new file mode 100644 index 000000000..19c782373 --- /dev/null +++ b/apps/app/src/components/ConfirmDeleteModal.tsx @@ -0,0 +1,52 @@ +import { Button } from '@op/ui/Button'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; + +import { useTranslations } from '@/lib/i18n'; + +export function ConfirmDeleteModal({ + isOpen, + title, + message, + onConfirm, + onCancel, +}: { + isOpen: boolean; + title: string; + message: string; + onConfirm: () => void; + onCancel: () => void; +}) { + const t = useTranslations(); + return ( + { + if (!open) { + onCancel(); + } + }} + > + {title} + +

{message}

+
+ + + + +
+ ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx index a58b4e1b2..223213e9b 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx @@ -1,5 +1,6 @@ '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'; @@ -87,6 +88,7 @@ const ProcessBuilderHeaderContent = ({ slug?: string; }) => { const t = useTranslations(); + const rubricBuilderEnabled = useFeatureFlag('rubric_builder'); const router = useRouter(); const navigationConfig = useNavigationConfig(instanceId); const { visibleSteps, currentStep, setStep } = @@ -239,7 +241,9 @@ const ProcessBuilderHeaderContent = ({ className="flex h-full cursor-pointer items-center gap-2" > {t(step.labelKey)} - {step.id === 'rubric' && } + {step.id === 'rubric' && !rubricBuilderEnabled && ( + + )} ))} @@ -252,6 +256,7 @@ const ProcessBuilderHeaderContent = ({ const MobileSidebar = ({ instanceId }: { instanceId: string }) => { const t = useTranslations(); + const rubricBuilderEnabled = useFeatureFlag('rubric_builder'); const navigationConfig = useNavigationConfig(instanceId); const { visibleSteps, currentStep, setStep } = useProcessNavigation(navigationConfig); @@ -293,7 +298,9 @@ const MobileSidebar = ({ instanceId }: { instanceId: string }) => { className="flex h-8 items-center gap-2 bg-transparent selected:bg-neutral-offWhite" > {t(step.labelKey)} - {step.id === 'rubric' && } + {step.id === 'rubric' && !rubricBuilderEnabled && ( + + )} ))} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx index 3a26ad90b..32c4c0a9a 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx @@ -1,6 +1,7 @@ 'use client'; import { trpc } from '@op/api/client'; +import { ProcessStatus } from '@op/api/encoders'; import { useDebouncedCallback } from '@op/hooks'; import { SelectItem } from '@op/ui/Select'; import { useEffect, useRef } from 'react'; @@ -9,13 +10,12 @@ import { z } from 'zod'; import { useTranslations } from '@/lib/i18n'; import type { TranslateFn } from '@/lib/i18n'; +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 { getFieldErrorMessage, useAppForm } from '@/components/form/utils'; -import { SaveStatusIndicator } from '../../components/SaveStatusIndicator'; -import { ToggleRow } from '../../components/ToggleRow'; -import type { SectionProps } from '../../contentRegistry'; -import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; - const AUTOSAVE_DEBOUNCE_MS = 1000; const createOverviewValidator = (t: TranslateFn) => @@ -78,7 +78,7 @@ export function OverviewSectionForm({ const utils = trpc.useUtils(); const [instance] = trpc.decision.getInstance.useSuspenseQuery({ instanceId }); - const isDraft = instance.status === 'draft'; + const isDraft = instance.status === ProcessStatus.DRAFT; // Store: used as a localStorage buffer for non-draft edits only const instanceData = useProcessBuilderStore( 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 72087d1f6..586bc221a 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx @@ -2,6 +2,7 @@ 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 { useDebouncedCallback } from '@op/hooks'; import { @@ -15,7 +16,6 @@ import { import { AutoSizeInput } from '@op/ui/AutoSizeInput'; import { Button } from '@op/ui/Button'; import { DatePicker } from '@op/ui/DatePicker'; -import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; import type { Key } from '@op/ui/RAC'; import { DisclosureStateContext } from '@op/ui/RAC'; import { DragHandle, Sortable } from '@op/ui/Sortable'; @@ -33,12 +33,12 @@ import { import { useTranslations } from '@/lib/i18n'; +import { ConfirmDeleteModal } from '@/components/ConfirmDeleteModal'; import { RichTextEditorWithToolbar } from '@/components/RichTextEditor/RichTextEditorWithToolbar'; - -import { SaveStatusIndicator } from '../../components/SaveStatusIndicator'; -import { ToggleRow } from '../../components/ToggleRow'; -import type { SectionProps } from '../../contentRegistry'; -import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; +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'; const AUTOSAVE_DEBOUNCE_MS = 1000; @@ -49,7 +49,7 @@ export function PhasesSectionContent({ const [instance] = trpc.decision.getInstance.useSuspenseQuery({ instanceId }); const instancePhases = instance.instanceData?.phases; const templatePhases = instance.process?.processSchema?.phases; - const isDraft = instance.status === 'draft'; + const isDraft = instance.status === ProcessStatus.DRAFT; // Store: used as a localStorage buffer for non-draft edits only const storePhases = useProcessBuilderStore( @@ -489,40 +489,15 @@ export const PhaseEditor = ({ {t('Add phase')} - { - if (!open) { - setPhaseToDelete(null); - } - }} - > - {t('Delete phase')} - -

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

-
- - - - -
+ title={t('Delete phase')} + message={t( + 'Are you sure you want to delete this phase? This action cannot be undone.', + )} + onConfirm={confirmRemovePhase} + onCancel={() => setPhaseToDelete(null)} + /> ); }; diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/ProposalCategoriesSectionContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/ProposalCategoriesSectionContent.tsx index e35f42944..03722b305 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/ProposalCategoriesSectionContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/ProposalCategoriesSectionContent.tsx @@ -1,6 +1,7 @@ 'use client'; import { trpc } from '@op/api/client'; +import { ProcessStatus } from '@op/api/encoders'; import type { ProposalCategory } from '@op/common'; import { useDebouncedCallback } from '@op/hooks'; import { Button } from '@op/ui/Button'; @@ -14,9 +15,9 @@ import { LuLeaf, LuPencil, LuPlus, LuTrash2 } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; -import { ensureLockedFields } from '../../../proposalTemplate'; -import type { SectionProps } from '../../contentRegistry'; -import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; +import type { SectionProps } from '@/components/decisions/ProcessBuilder/contentRegistry'; +import { useProcessBuilderStore } from '@/components/decisions/ProcessBuilder/stores/useProcessBuilderStore'; +import { ensureLockedFields } from '@/components/decisions/proposalTemplate'; const AUTOSAVE_DEBOUNCE_MS = 1000; const CATEGORY_TITLE_MAX_LENGTH = 40; @@ -36,7 +37,7 @@ export function ProposalCategoriesSectionContent({ // Fetch server data for seeding const [instance] = trpc.decision.getInstance.useSuspenseQuery({ instanceId }); - const isDraft = instance.status === 'draft'; + const isDraft = instance.status === ProcessStatus.DRAFT; const serverConfig = instance.instanceData?.config; const storeData = useProcessBuilderStore( diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx index 509c08ac0..396fa85f5 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx @@ -5,32 +5,30 @@ import { Suspense } from 'react'; import { useTranslations } from '@/lib/i18n'; -import type { SectionProps } from '../../contentRegistry'; +import ErrorBoundary from '@/components/ErrorBoundary'; +import { ErrorMessage } from '@/components/ErrorMessage'; +import type { SectionProps } from '@/components/decisions/ProcessBuilder/contentRegistry'; + import { CodeAnimation } from './RubricComingSoonAnimation'; -import { RubricParticipantPreview } from './RubricParticipantPreview'; -import { DUMMY_RUBRIC_TEMPLATE } from './dummyRubricTemplate'; +import { RubricEditorContent } from './RubricEditorContent'; +import { RubricEditorSkeleton } from './RubricEditorSkeleton'; export default function CriteriaSection(props: SectionProps) { return ( - - - + }> + }> + + + ); } -function CriteriaSectionContent(_props: SectionProps) { +function CriteriaSectionContent(props: SectionProps) { const t = useTranslations(); const rubricBuilderEnabled = useFeatureFlag('rubric_builder'); if (rubricBuilderEnabled) { - return ( -
- {/* Left panel — placeholder for the future rubric builder */} -
- - -
- ); + return ; } return ( diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx new file mode 100644 index 000000000..8dfa445ad --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx @@ -0,0 +1,402 @@ +'use client'; + +import { + AccordionContent, + AccordionHeader, + AccordionIndicator, + AccordionTrigger, +} from '@op/ui/Accordion'; +import { Button } from '@op/ui/Button'; +import { NumberField } from '@op/ui/NumberField'; +import { Radio, RadioGroup } from '@op/ui/RadioGroup'; +import { DragHandle } from '@op/ui/Sortable'; +import type { SortableItemControls } from '@op/ui/Sortable'; +import { TextField } from '@op/ui/TextField'; +import { useState } from 'react'; +import { LuChevronRight, LuGripVertical, LuTrash2 } from 'react-icons/lu'; + +import { useTranslations } from '@/lib/i18n'; +import type { TranslationKey } from '@/lib/i18n/routing'; + +import type { + CriterionView, + RubricCriterionType, +} from '@/components/decisions/rubricTemplate'; + +import { + CRITERION_TYPES, + CRITERION_TYPE_REGISTRY, +} from './rubricCriterionRegistry'; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface RubricCriterionCardProps { + criterion: CriterionView; + /** 1-based display index for the header (e.g. "Criterion 1") */ + index: number; + errors?: TranslationKey[]; + controls?: SortableItemControls; + onRemove?: (criterionId: string) => void; + onUpdateLabel?: (criterionId: string, label: string) => void; + onUpdateDescription?: (criterionId: string, description: string) => void; + onChangeType?: (criterionId: string, newType: RubricCriterionType) => void; + onUpdateMaxPoints?: (criterionId: string, maxPoints: number) => void; + onUpdateScoreLabel?: ( + criterionId: string, + scoreValue: number, + label: string, + ) => void; +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +/** + * A collapsible accordion card for a single rubric criterion. + * + * Built directly with Accordion primitives (not FieldConfigCard) to match + * the mockup: static "Criterion N" header, separate name/description fields + * in the body, and a criterion type radio selector. + * + * Must be rendered inside an `` which is inside an ``. + */ +export function RubricCriterionCard({ + criterion, + index, + errors = [], + controls, + onRemove, + onUpdateLabel, + onUpdateDescription, + onChangeType, + onUpdateMaxPoints, + onUpdateScoreLabel, +}: RubricCriterionCardProps) { + const t = useTranslations(); + + return ( + <> + {/* Header: drag handle + chevron + "Criterion N" + delete button */} + + {controls && ( + + )} + + + + {t('Criterion {number}', { number: index })} + + + {criterion.label || t('New criterion')} + + + + {onRemove && ( + + )} + + + {/* Collapsible body */} + +
+
+ {/* Criterion name */} + onUpdateLabel?.(criterion.id, value)} + inputProps={{ + placeholder: t('e.g., Goal Alignment'), + }} + description={t( + 'Add a short, clear name for this evaluation criterion', + )} + /> + + {/* Description */} + onUpdateDescription?.(criterion.id, value)} + textareaProps={{ + placeholder: t( + "What should reviewers evaluate? Be specific about what you're looking for.", + ), + className: 'min-h-24 resize-none', + }} + description={t('Help reviewers understand what to assess')} + /> + +
+ + {/* Criterion type radio selector */} + onChangeType?.(criterion.id, newType)} + /> + + {/* Type-specific configuration */} + {criterion.criterionType === 'scored' && ( + <> +
+ + onUpdateMaxPoints?.(criterion.id, max) + } + onUpdateScoreLabel={(scoreValue, label) => + onUpdateScoreLabel?.(criterion.id, scoreValue, label) + } + /> + + )} + + {/* Validation errors */} + {errors.length > 0 && ( +
+ {errors.map((error) => ( +

+ {t(error)} +

+ ))} +
+ )} +
+
+ + ); +} + +// --------------------------------------------------------------------------- +// Criterion type radio selector +// --------------------------------------------------------------------------- + +function CriterionTypeSelector({ + value, + onChange, +}: { + value: RubricCriterionType; + onChange: (type: RubricCriterionType) => void; +}) { + const t = useTranslations(); + + return ( + onChange(newValue as RubricCriterionType)} + orientation="vertical" + labelClassName="text-base" + > + {CRITERION_TYPES.map((type) => { + const entry = CRITERION_TYPE_REGISTRY[type]; + return ( + +
+ {t(entry.labelKey)} +

+ {t(entry.descriptionKey)} +

+
+
+ ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// Scored criterion config (max points + score labels) +// --------------------------------------------------------------------------- + +function ScoredCriterionConfig({ + criterion, + onUpdateMaxPoints, + onUpdateScoreLabel, +}: { + criterion: CriterionView; + onUpdateMaxPoints: (max: number) => void; + onUpdateScoreLabel: (scoreValue: number, label: string) => void; +}) { + const t = useTranslations(); + const max = criterion.maxPoints ?? 5; + + // Cache descriptions that would be lost when maxPoints decreases. + // Key is 1-based score value, value is the description text. + // Cache persists until user navigates away from this criterion card. + const [cachedDescriptions, setCachedDescriptions] = useState< + Record + >({}); + + const handleMaxPointsChange = (value: number | null) => { + if (value === null || value < 2) { + return; + } + + const newMax = value; + + if (newMax < max) { + // Decreasing - cache descriptions that will be removed + const toCache: Record = { ...cachedDescriptions }; + for (let i = newMax + 1; i <= max; i++) { + const label = criterion.scoreLabels[i - 1]; // scoreLabels is 0-indexed + if (label) { + toCache[i] = label; + } + } + setCachedDescriptions(toCache); + } else if (newMax > max) { + // Increasing - restore cached descriptions after update + const labelsToRestore: Array<{ score: number; label: string }> = []; + for (let i = max + 1; i <= newMax; i++) { + const cached = cachedDescriptions[i]; + if (cached) { + labelsToRestore.push({ score: i, label: cached }); + } + } + + // Clear restored items from cache + if (labelsToRestore.length > 0) { + const newCache = { ...cachedDescriptions }; + labelsToRestore.forEach(({ score }) => delete newCache[score]); + setCachedDescriptions(newCache); + + // Restore labels after state update + setTimeout(() => { + labelsToRestore.forEach(({ score, label }) => { + onUpdateScoreLabel(score, label); + }); + }, 0); + } + } + + onUpdateMaxPoints(newMax); + }; + + return ( +
+ + +
+

+ {t('Define what each score means')} +

+

+ {t( + 'Help reviewers score consistently by describing what each point value represents', + )} +

+
+ {criterion.scoreLabels.map((_, i) => { + const revIdx = criterion.scoreLabels.length - 1 - i; + const label = criterion.scoreLabels[revIdx]!; + const scoreValue = max - i; + return ( +
+ + {scoreValue} + + onUpdateScoreLabel(scoreValue, value)} + textareaProps={{ + placeholder: t('Describe what earns {number} points...', { + number: scoreValue, + }), + }} + className="w-full" + /> +
+ ); + })} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Shared criterion badges (type + points) +// --------------------------------------------------------------------------- + +function CriterionBadges({ criterion }: { criterion: CriterionView }) { + const t = useTranslations(); + return ( + + + {t(CRITERION_TYPE_REGISTRY[criterion.criterionType].labelKey)} + + {criterion.criterionType === 'scored' && criterion.maxPoints && ( + + {criterion.maxPoints} {t('pts')} + + )} + + ); +} + +// --------------------------------------------------------------------------- +// Drag preview +// --------------------------------------------------------------------------- + +export function RubricCriterionDragPreview({ + criterion, + index, +}: { + criterion: CriterionView; + index: number; +}) { + const t = useTranslations(); + return ( +
+
+ +
+ + + {t('Criterion {number}', { number: index })} + + + {criterion.label || t('New criterion')} + + +
+ +
+
+ ); +} + +export function RubricCriterionDropIndicator() { + return ( +
+ ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx new file mode 100644 index 000000000..981e04d9e --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx @@ -0,0 +1,396 @@ +'use client'; + +import { trpc } from '@op/api/client'; +import { ProcessStatus } from '@op/api/encoders'; +import type { RubricTemplateSchema } from '@op/common/client'; +import { useDebouncedCallback } from '@op/hooks'; +import { Accordion, AccordionItem } from '@op/ui/Accordion'; +import { Button } from '@op/ui/Button'; +import { EmptyState } from '@op/ui/EmptyState'; +import { Header2 } from '@op/ui/Header'; +import type { Key } from '@op/ui/RAC'; +import { Sortable } from '@op/ui/Sortable'; +import { cn } from '@op/ui/utils'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { LuLeaf, LuPlus } from 'react-icons/lu'; +import { useShallow } from 'zustand/react/shallow'; + +import { useTranslations } from '@/lib/i18n'; +import type { TranslationKey } from '@/lib/i18n/routing'; + +import { ConfirmDeleteModal } from '@/components/ConfirmDeleteModal'; +import { SaveStatusIndicator } from '@/components/decisions/ProcessBuilder/components/SaveStatusIndicator'; +import type { SectionProps } from '@/components/decisions/ProcessBuilder/contentRegistry'; +import { useProcessBuilderStore } from '@/components/decisions/ProcessBuilder/stores/useProcessBuilderStore'; +import type { + CriterionView, + RubricCriterionType, +} from '@/components/decisions/rubricTemplate'; +import { + addCriterion, + changeCriterionType, + createEmptyRubricTemplate, + getCriteria, + getCriterionErrors, + getCriterionSchema, + getCriterionType, + removeCriterion, + reorderCriteria, + updateCriterionDescription, + updateCriterionJsonSchema, + updateCriterionLabel, + updateScoreLabel, + updateScoredMaxPoints, +} from '@/components/decisions/rubricTemplate'; + +import { + RubricCriterionCard, + RubricCriterionDragPreview, + RubricCriterionDropIndicator, +} from './RubricCriterionCard'; +import { RubricParticipantPreview } from './RubricParticipantPreview'; + +const AUTOSAVE_DEBOUNCE_MS = 1000; + +export function RubricEditorContent({ + decisionProfileId, + instanceId, +}: SectionProps) { + const t = useTranslations(); + + // Load instance data from the backend + const [instance] = trpc.decision.getInstance.useSuspenseQuery({ instanceId }); + const isDraft = instance.status === ProcessStatus.DRAFT; + const utils = trpc.useUtils(); + const instanceData = instance.instanceData; + + const initialTemplate = useMemo(() => { + const saved = instanceData?.rubricTemplate; + if (saved && Object.keys(saved.properties ?? {}).length > 0) { + return saved as RubricTemplateSchema; + } + return createEmptyRubricTemplate(); + }, [instanceData?.rubricTemplate]); + + const [template, setTemplate] = + useState(initialTemplate); + const isInitialLoadRef = useRef(true); + + // Validation: "show on blur, clear on change" + const [criterionErrors, setCriterionErrors] = useState< + Map + >(new Map()); + + // Accordion expansion state + const [expandedKeys, setExpandedKeys] = useState>(new Set()); + + // Delete confirmation modal + const [criterionToDelete, setCriterionToDelete] = useState( + null, + ); + + // Cache scored config so switching type and back doesn't lose score labels + const scoredConfigCacheRef = useRef< + Map + >(new Map()); + + const { setRubricTemplateSchema, setSaveStatus, markSaved, getSaveState } = + useProcessBuilderStore( + useShallow((s) => ({ + setRubricTemplateSchema: s.setRubricTemplateSchema, + setSaveStatus: s.setSaveStatus, + markSaved: s.markSaved, + getSaveState: s.getSaveState, + })), + ); + const saveState = getSaveState(decisionProfileId); + + 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 }); + }, + }); + + // Derive criterion views from the template + const criteria = useMemo(() => getCriteria(template), [template]); + const criteriaIndexMap = useMemo( + () => new Map(criteria.map((c, i) => [c.id, i])), + [criteria], + ); + + // TODO: Extract this debounced auto-save pattern into a shared useAutoSave() hook + // (same pattern is used in TemplateEditorContent and useProposalDraft) + const debouncedSave = useDebouncedCallback( + (updatedTemplate: RubricTemplateSchema) => { + setRubricTemplateSchema(decisionProfileId, updatedTemplate); + + if (isDraft) { + updateInstance.mutate({ + instanceId, + rubricTemplate: updatedTemplate, + }); + } else { + markSaved(decisionProfileId); + } + }, + AUTOSAVE_DEBOUNCE_MS, + ); + debouncedSaveRef.current = () => debouncedSave.isPending(); + + // Trigger debounced save when template changes (skip initial load) + useEffect(() => { + if (isInitialLoadRef.current) { + isInitialLoadRef.current = false; + return; + } + + setSaveStatus(decisionProfileId, 'saving'); + debouncedSave(template); + }, [template, decisionProfileId, setSaveStatus, debouncedSave]); + + // --- Handlers --- + + const handleAddCriterion = useCallback(() => { + const criterionId = crypto.randomUUID().slice(0, 8); + const label = t('New criterion'); + setTemplate((prev) => addCriterion(prev, criterionId, 'scored', label)); + setExpandedKeys((prev) => new Set([...prev, criterionId])); + }, [t]); + + const handleRemoveCriterion = useCallback((criterionId: string) => { + setCriterionToDelete(criterionId); + }, []); + + const confirmRemoveCriterion = useCallback(() => { + if (!criterionToDelete) { + return; + } + setTemplate((prev) => removeCriterion(prev, criterionToDelete)); + setCriterionErrors((prev) => { + const next = new Map(prev); + next.delete(criterionToDelete); + return next; + }); + scoredConfigCacheRef.current.delete(criterionToDelete); + setCriterionToDelete(null); + }, [criterionToDelete]); + + const handleReorderCriteria = useCallback((newItems: CriterionView[]) => { + setTemplate((prev) => + reorderCriteria( + prev, + newItems.map((item) => item.id), + ), + ); + }, []); + + const handleUpdateLabel = useCallback( + (criterionId: string, label: string) => { + setTemplate((prev) => updateCriterionLabel(prev, criterionId, label)); + }, + [], + ); + + const handleUpdateDescription = useCallback( + (criterionId: string, description: string) => { + setTemplate((prev) => + updateCriterionDescription(prev, criterionId, description || undefined), + ); + }, + [], + ); + + const handleChangeType = useCallback( + (criterionId: string, newType: RubricCriterionType) => { + setTemplate((prev) => { + // Stash scored config before switching away from scored + if (getCriterionType(prev, criterionId) === 'scored') { + const schema = getCriterionSchema(prev, criterionId); + const oneOfEntries = (schema?.oneOf ?? []).filter( + (e): e is { const: number; title: string } => + typeof e === 'object' && + e !== null && + 'const' in e && + typeof (e as Record).const === 'number' && + 'title' in e && + typeof (e as Record).title === 'string', + ); + scoredConfigCacheRef.current.set(criterionId, { + maximum: schema?.maximum ?? 5, + oneOf: oneOfEntries, + }); + } + + // Change the type (rebuilds schema from scratch) + let updated = changeCriterionType(prev, criterionId, newType); + + // Restore cached scored config when switching back to scored + if (newType === 'scored') { + const cached = scoredConfigCacheRef.current.get(criterionId); + if (cached) { + updated = updateCriterionJsonSchema(updated, criterionId, { + maximum: cached.maximum, + oneOf: cached.oneOf, + }); + } + } + + return updated; + }); + }, + [], + ); + + const handleUpdateMaxPoints = useCallback( + (criterionId: string, maxPoints: number) => { + setTemplate((prev) => + updateScoredMaxPoints(prev, criterionId, maxPoints), + ); + }, + [], + ); + + const handleUpdateScoreLabel = useCallback( + (criterionId: string, scoreValue: number, label: string) => { + setTemplate((prev) => + updateScoreLabel(prev, criterionId, scoreValue, label), + ); + }, + [], + ); + + return ( +
+
+
+
+ + {t('Review Criteria')} + + +
+ + {criteria.length === 0 ? ( +
+ }> +
+ + {t('No review criteria yet')} + + + {t( + 'Add criteria to help reviewers evaluate proposals consistently', + )} + + +
+
+
+ ) : ( + <> + + criterion.label} + className="gap-3" + renderDragPreview={(items) => { + const item = items[0]; + if (!item) { + return null; + } + const idx = criteriaIndexMap.get(item.id) ?? 0; + return ( + + ); + }} + renderDropIndicator={RubricCriterionDropIndicator} + aria-label={t('Rubric criteria')} + > + {(criterion, controls) => { + const idx = criteriaIndexMap.get(criterion.id) ?? 0; + const snapshotErrors = + criterionErrors.get(criterion.id) ?? []; + const liveErrors = getCriterionErrors(criterion); + const displayedErrors = snapshotErrors.filter((e) => + liveErrors.includes(e), + ); + + return ( + + + + ); + }} + + + + + + )} +
+
+ + + + setCriterionToDelete(null)} + /> +
+ ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorSkeleton.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorSkeleton.tsx new file mode 100644 index 000000000..42d9e1295 --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorSkeleton.tsx @@ -0,0 +1,44 @@ +import { Skeleton } from '@op/ui/Skeleton'; + +/** + * Skeleton loading state for the rubric criteria editor. + */ +export function RubricEditorSkeleton() { + return ( +
+ {/* Main content skeleton */} +
+
+
+ + +
+ + {/* Criterion card skeletons */} + {[1, 2, 3].map((i) => ( +
+
+ + + + +
+
+ ))} + + +
+
+ + {/* Preview skeleton */} +
+
+ + + + +
+
+
+ ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx index 647d1725f..04f04262b 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx @@ -30,7 +30,7 @@ export function RubricParticipantPreview({ } return ( -