"use client"; import { useSafeIntl } from "@kopexa/i18n"; import { EditIcon } from "@kopexa/icons"; import { Button, Card, Chip, Heading, Select, Textarea } from "@kopexa/sight"; import { type CardVariantProps, impactCard } from "@kopexa/theme"; import { useState } from "react"; import { messages } from "./messages"; import { getScale, type ImpactLevel, type ImpactScaleConfig, type ImpactScalePreset, impactLevels, } from "./scales"; // ============================================ // Types (aligned with Go ImpactMixin schema) // ============================================ export interface ImpactValue { /** Rating 0-5. Vertraulichkeit (Confidentiality). */ impactConfidentiality: ImpactLevel; /** Rating 0-5. Integrität (Integrity). */ impactIntegrity: ImpactLevel; /** Rating 0-5. Verfügbarkeit (Availability) - DORA Critical! */ impactAvailability: ImpactLevel; /** Rating 0-5. Authentizität (Authenticity/Accountability). */ impactAuthenticity?: ImpactLevel; /** Human-readable explanation for the chosen impact scores. Focus on the highest score. */ impactJustification?: string; } // ============================================ // Impact Item Row Component // ============================================ interface ImpactItemRowProps { label: string; shortLabel: string; value: ImpactLevel; isEditing: boolean; scale: ImpactScaleConfig; formatLabel: (level: ImpactLevel) => string; onLevelChange: (level: ImpactLevel) => void; } function ImpactItemRow({ label, shortLabel, value, isEditing, scale, formatLabel, onLevelChange, }: ImpactItemRowProps) { const config = scale[value]; const isUnrated = value === 0; const percentage = isUnrated ? 0 : (value / 5) * 100; const styles = impactCard({ unrated: isUnrated }); return (
{/* Icon */} {shortLabel} {/* Content */}
{label} {isEditing ? ( ) : (
{!isUnrated && ( {value} )} {formatLabel(value)}
)}
{/* Progress Bar */} {!isUnrated && !isEditing && (
)}
); } // ============================================ // Impact Card Component // ============================================ export interface ImpactCardProps { /** The impact data */ value?: ImpactValue; /** Callback when the impact changes */ onChange?: (impact: ImpactValue) => void; /** Show justification field */ showJustification?: boolean; /** Show authenticity as 4th dimension (CIAA) */ showAuthenticity?: boolean; /** Make the component read-only */ readOnly?: boolean; /** Scale preset or custom scale config */ scale?: ImpactScalePreset | ImpactScaleConfig; /** Custom title for the card */ title?: string; /** * Display variant: * - `card`: Wrapped in a Card with edit/save/cancel buttons (default) * - `inline`: No card wrapper, always editable, changes propagate immediately */ variant?: "card" | "inline"; /** Show attention indicator when impact values need to be filled */ needsAttention?: boolean; /** Props forwarded to the underlying `Card.Root` (e.g. `spacing`, `shadow`, `radius`). Only applies when `variant="card"`. */ cardProps?: CardVariantProps; /** Size of the edit icon button. Only applies when `variant="card"`. */ editButtonSize?: "sm" | "md" | "lg"; } const defaultImpact: ImpactValue = { impactConfidentiality: 0, impactIntegrity: 0, impactAvailability: 0, impactAuthenticity: 0, }; export function ImpactCard({ value, onChange, showJustification = false, showAuthenticity = false, readOnly = false, scale = "risk", title, variant = "card", needsAttention = false, cardProps, editButtonSize = "sm", }: ImpactCardProps) { const intl = useSafeIntl(); const isInline = variant === "inline"; const [isEditing, setIsEditing] = useState(false); const [editValues, setEditValues] = useState( value || defaultImpact, ); // For inline variant, always show as editing (form mode) const effectiveIsEditing = isInline ? !readOnly : isEditing; const styles = impactCard({ editing: !isInline && isEditing }); // Resolve scale config const scaleConfig: ImpactScaleConfig = typeof scale === "string" ? getScale(scale) : scale; // i18n helper for scale labels const formatLabel = (level: ImpactLevel): string => { const config = scaleConfig[level]; return intl.formatMessage(config.message); }; // i18n labels const t = { titleCia: intl.formatMessage(messages.title_cia), titleCiaa: intl.formatMessage(messages.title_ciaa), confidentiality: intl.formatMessage(messages.confidentiality), integrity: intl.formatMessage(messages.integrity), availability: intl.formatMessage(messages.availability), authenticity: intl.formatMessage(messages.authenticity), justification: intl.formatMessage(messages.justification), justificationPlaceholder: intl.formatMessage( messages.justification_placeholder, ), noJustification: intl.formatMessage(messages.no_justification), edit: intl.formatMessage(messages.edit), cancel: intl.formatMessage(messages.cancel), save: intl.formatMessage(messages.save), required: intl.formatMessage(messages.required), }; // Derive default title based on authenticity const defaultTitle = showAuthenticity ? t.titleCiaa : t.titleCia; const cardTitle = title ?? defaultTitle; const handleSave = () => { onChange?.(editValues); setIsEditing(false); }; const handleCancel = () => { setEditValues(value || defaultImpact); setIsEditing(false); }; const handleStartEdit = () => { setEditValues(value || defaultImpact); setIsEditing(true); }; // In inline mode, always use value; in card mode, use editValues when editing const currentImpact = isInline ? value || defaultImpact : isEditing ? editValues : value || defaultImpact; const handleLevelChange = ( key: | "impactConfidentiality" | "impactIntegrity" | "impactAvailability" | "impactAuthenticity", ) => (level: ImpactLevel) => { const newValues = { ...(isInline ? value || defaultImpact : editValues), [key]: level, }; if (isInline) { // Inline mode: propagate changes immediately onChange?.(newValues); } else { setEditValues(newValues); } }; const handleJustificationChange = (justification: string) => { const newValues = { ...(isInline ? value || defaultImpact : editValues), impactJustification: justification || undefined, }; if (isInline) { // Inline mode: propagate changes immediately onChange?.(newValues); } else { setEditValues(newValues); } }; // Calculate highest impact for justification hint const highestImpact = Math.max( currentImpact.impactConfidentiality, currentImpact.impactIntegrity, currentImpact.impactAvailability, currentImpact.impactAuthenticity ?? 0, ) as ImpactLevel; const highestLabel = formatLabel(highestImpact); const justificationHint = intl.formatMessage(messages.justification_hint, { level: highestLabel, }); // Shared content for both variants const impactRows = ( <> {showAuthenticity && ( )} ); const justificationContent = showJustification && (
{effectiveIsEditing ? (