132 lines
5.5 KiB
TypeScript
132 lines
5.5 KiB
TypeScript
import React from 'react';
|
|
import clsx from 'clsx';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { TrendingUp, AlertTriangle, CheckCircle, XCircle, RotateCcw, Lightbulb } from 'lucide-react';
|
|
import { Card } from '@/components/ui/Card';
|
|
import { Badge } from '@/components/ui/Badge';
|
|
import { Button } from '@/components/ui/Button';
|
|
import type { ImprovementProposal, ProposalStatus } from '@/api/improvement';
|
|
|
|
export interface ProposalCardProps {
|
|
proposal: ImprovementProposal;
|
|
onApprove?: (id: string) => void;
|
|
onReject?: (id: string) => void;
|
|
onRollback?: (id: string) => void;
|
|
busy?: boolean;
|
|
}
|
|
|
|
const statusVariant: Record<ProposalStatus, 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'secondary'> = {
|
|
draft: 'secondary',
|
|
evaluating: 'info',
|
|
pending_approval: 'warning',
|
|
approved: 'primary',
|
|
rejected: 'danger',
|
|
active: 'success',
|
|
rolled_back: 'default',
|
|
expired: 'default',
|
|
};
|
|
|
|
function formatScore(score: number | undefined): string {
|
|
if (score === undefined || Number.isNaN(score)) return '—';
|
|
return `${Math.round(score)}%`;
|
|
}
|
|
|
|
export function ProposalCard({ proposal, onApprove, onReject, onRollback, busy = false }: ProposalCardProps) {
|
|
const { t } = useTranslation();
|
|
const score = proposal.evaluation_result?.score;
|
|
const canApprove = proposal.status === 'pending_approval' || proposal.status === 'draft' || proposal.status === 'evaluating';
|
|
const canReject = proposal.status === 'pending_approval' || proposal.status === 'draft' || proposal.status === 'evaluating';
|
|
const canRollback = proposal.status === 'active';
|
|
|
|
return (
|
|
<Card className="flex flex-col" data-testid={`proposal-card-${proposal.id}`}>
|
|
<div className="flex items-center justify-between gap-2 mb-4">
|
|
<h3 className="flex items-center gap-2 text-lg font-semibold text-secondary-900">
|
|
<Lightbulb className="w-4 h-4 text-primary-500 flex-shrink-0" aria-hidden="true" />
|
|
{proposal.title}
|
|
</h3>
|
|
<Badge variant={statusVariant[proposal.status]} dot>
|
|
{t(`improvement.status.${proposal.status}`)}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-secondary-700">{proposal.description}</p>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-1">
|
|
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">
|
|
{t('improvement.rationale')}
|
|
</h4>
|
|
<p className="text-sm text-secondary-700">{proposal.rationale}</p>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">
|
|
{t('improvement.expectedBenefit')}
|
|
</h4>
|
|
<p className="text-sm text-secondary-700">{proposal.expected_benefit}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-start gap-2 rounded-md bg-warning-50 border border-warning-200 p-3">
|
|
<AlertTriangle className="w-4 h-4 text-warning-600 mt-0.5 flex-shrink-0" aria-hidden="true" />
|
|
<div className="space-y-1">
|
|
<h4 className="text-xs font-semibold text-warning-700 uppercase tracking-wide">
|
|
{t('improvement.riskAssessment')}
|
|
</h4>
|
|
<p className="text-sm text-warning-800">{proposal.risk_assessment}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{proposal.evidence_refs.length > 0 && (
|
|
<div className="space-y-1">
|
|
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">
|
|
{t('improvement.evidence')}
|
|
</h4>
|
|
<ul className="flex flex-wrap gap-2">
|
|
{proposal.evidence_refs.map((ref) => (
|
|
<li key={ref}>
|
|
<code className="px-2 py-0.5 rounded bg-secondary-100 text-xs text-secondary-700">{ref}</code>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between rounded-md bg-secondary-50 border border-secondary-200 p-3">
|
|
<div className="flex items-center gap-2">
|
|
<TrendingUp className="w-4 h-4 text-primary-600" aria-hidden="true" />
|
|
<span className="text-sm font-medium text-secondary-700">{t('improvement.score')}</span>
|
|
</div>
|
|
<span
|
|
className={clsx(
|
|
'text-lg font-semibold',
|
|
score !== undefined && score >= 80 ? 'text-success-600' : score !== undefined && score >= 60 ? 'text-warning-600' : 'text-secondary-600'
|
|
)}
|
|
>
|
|
{formatScore(score)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 pt-4 border-t border-secondary-200 flex flex-wrap items-center gap-2">
|
|
{canApprove && onApprove && (
|
|
<Button size="sm" variant="primary" icon={<CheckCircle className="w-4 h-4" />} isLoading={busy} onClick={() => onApprove(proposal.id)}>
|
|
{t('improvement.approve')}
|
|
</Button>
|
|
)}
|
|
{canReject && onReject && (
|
|
<Button size="sm" variant="danger" icon={<XCircle className="w-4 h-4" />} isLoading={busy} onClick={() => onReject(proposal.id)}>
|
|
{t('improvement.reject')}
|
|
</Button>
|
|
)}
|
|
{canRollback && onRollback && (
|
|
<Button size="sm" variant="secondary" icon={<RotateCcw className="w-4 h-4" />} isLoading={busy} onClick={() => onRollback(proposal.id)}>
|
|
{t('improvement.rollback')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|