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 (
-