"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 ? (
);
// Inline variant: no card wrapper, always editable
if (isInline) {
return (
{title && (
{title}
)}
{impactRows}
{justificationContent}
);
}
// Card variant: wrapped in Card with edit/save/cancel
return (
{cardTitle}
{isEditing && (
{t.edit}
)}
{needsAttention && !isEditing && (
{t.required}
)}
{!readOnly &&
(!isEditing ? (
) : (
))}
{impactRows}
{justificationContent}
);
}