Files
srbhr--resume-matcher/apps/frontend/components/tailor/diff-preview-modal.tsx
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

471 lines
16 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
import { AlertTriangle, CheckCircle, X, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useTranslations } from '@/lib/i18n';
import type {
ResumeDiffSummary,
ResumeFieldDiff,
} from '@/components/common/resume_previewer_context';
interface DiffPreviewModalProps {
isOpen: boolean;
isConfirming?: boolean;
onClose: () => void;
onReject: () => void;
onConfirm: () => void;
diffSummary?: ResumeDiffSummary;
detailedChanges?: ResumeFieldDiff[];
errorMessage?: string;
}
export function DiffPreviewModal({
isOpen,
isConfirming = false,
onClose,
onReject,
onConfirm,
diffSummary,
detailedChanges,
errorMessage,
}: DiffPreviewModalProps) {
const { t } = useTranslations();
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['summary', 'skills', 'descriptions', 'experience'])
);
// Elapsed timer while confirming
const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (isConfirming) {
setElapsed(0);
intervalRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
} else {
if (intervalRef.current) clearInterval(intervalRef.current);
setElapsed(0);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isConfirming]);
if (!diffSummary || !detailedChanges) {
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open && !isConfirming) {
onClose();
}
}}
>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col p-6 bg-background border-2 border-black shadow-sw-lg">
<DialogHeader className="border-b-2 border-black pb-4 bg-white -mx-6 -mt-6 px-6 pt-6">
<DialogTitle className="font-serif text-2xl font-bold uppercase tracking-tight">
{t('tailor.missingDiffDialog.title')}
</DialogTitle>
</DialogHeader>
<div className="mt-6 border-2 border-black bg-white p-4 font-mono text-xs text-ink-soft">
{t('tailor.missingDiffDialog.description')}
</div>
<div className="mt-3 flex items-center gap-2 font-mono text-xs text-amber-700">
<AlertTriangle className="w-4 h-4" />
<span>{t('tailor.missingDiffDialog.confirmLabel')}</span>
</div>
<div className="flex justify-end items-center gap-3 pt-4 border-t-2 border-black bg-white -mx-6 -mb-6 px-6 py-4">
<Button variant="outline" onClick={onClose} disabled={isConfirming} className="gap-2">
{t('common.cancel')}
</Button>
<Button variant="warning" onClick={onConfirm} disabled={isConfirming} className="gap-2">
{isConfirming ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('common.saving')}
</>
) : (
t('tailor.missingDiffDialog.confirmLabel')
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
const toggleSection = (section: string) => {
const newExpanded = new Set(expandedSections);
if (newExpanded.has(section)) {
newExpanded.delete(section);
} else {
newExpanded.add(section);
}
setExpandedSections(newExpanded);
};
// Group changes by type
const summaryChanges = detailedChanges.filter((c) => c.field_type === 'summary');
const skillChanges = detailedChanges.filter((c) => c.field_type === 'skill');
const descChanges = detailedChanges.filter((c) => c.field_type === 'description');
const certChanges = detailedChanges.filter((c) => c.field_type === 'certification');
const experienceChanges = detailedChanges.filter((c) => c.field_type === 'experience');
const educationChanges = detailedChanges.filter((c) => c.field_type === 'education');
const projectChanges = detailedChanges.filter((c) => c.field_type === 'project');
const languageChanges = detailedChanges.filter((c) => c.field_type === 'language');
const awardChanges = detailedChanges.filter((c) => c.field_type === 'award');
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open && !isConfirming) {
onClose();
}
}}
>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col p-6 bg-background border-2 border-black shadow-sw-lg">
<DialogHeader className="border-b-2 border-black pb-4 bg-white -mx-6 -mt-6 px-6 pt-6">
<DialogTitle className="font-serif text-2xl font-bold uppercase tracking-tight">
{t('tailor.diffModal.title')}
</DialogTitle>
<p className="font-mono text-xs text-ink-soft mt-2">
{'// '}
{t('tailor.diffModal.subtitle')}
</p>
</DialogHeader>
{/* Summary cards */}
<div className="border-2 border-black bg-white p-4 mt-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-3 h-3 bg-primary"></div>
<h3 className="font-mono text-sm font-bold uppercase tracking-wider">
{t('tailor.diffModal.summary')}
</h3>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<StatCard
label={t('tailor.diffModal.skillsAdded')}
value={diffSummary.skills_added}
variant="success"
/>
<StatCard
label={t('tailor.diffModal.skillsRemoved')}
value={diffSummary.skills_removed}
variant="warning"
/>
<StatCard
label={t('tailor.diffModal.certificationsAdded')}
value={diffSummary.certifications_added}
variant="info"
/>
<StatCard
label={t('tailor.diffModal.descriptionsModified')}
value={diffSummary.descriptions_modified}
variant="info"
/>
<StatCard
label={t('tailor.diffModal.highRiskChanges')}
value={diffSummary.high_risk_changes}
variant={diffSummary.high_risk_changes > 0 ? 'danger' : 'success'}
/>
</div>
{diffSummary.high_risk_changes > 0 && (
<div className="mt-4 border-2 border-warning bg-[#FFF7ED] p-3 flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-warning shrink-0 mt-0.5" />
<div>
<p className="font-mono text-xs font-bold uppercase text-[#C2410C]">
{t('tailor.diffModal.warningTitle', {
count: diffSummary.high_risk_changes,
})}
</p>
<p className="font-mono text-xs text-[#C2410C] mt-1">
{t('tailor.diffModal.warningMessage')}
</p>
</div>
</div>
)}
</div>
{errorMessage && (
<div className="mt-4 border-2 border-red-600 bg-red-50 p-3 font-mono text-xs text-red-700">
{errorMessage}
</div>
)}
{/* Detailed changes list */}
<div className="flex-1 min-h-0 overflow-y-auto mt-4 space-y-4">
{/* Summary changes */}
{summaryChanges.length > 0 && (
<ChangeSection
title={t('tailor.diffModal.summaryChanges')}
count={summaryChanges.length}
isExpanded={expandedSections.has('summary')}
onToggle={() => toggleSection('summary')}
>
{summaryChanges.map((change, idx) => (
<ChangeItem key={idx} change={change} />
))}
</ChangeSection>
)}
{/* Skill changes */}
{skillChanges.length > 0 && (
<ChangeSection
title={t('tailor.diffModal.skillChanges')}
count={skillChanges.length}
isExpanded={expandedSections.has('skills')}
onToggle={() => toggleSection('skills')}
>
{skillChanges.map((change, idx) => (
<ChangeItem key={idx} change={change} />
))}
</ChangeSection>
)}
{/* Experience changes */}
{experienceChanges.length > 0 && (
<ChangeSection
title={t('tailor.diffModal.experienceChanges')}
count={experienceChanges.length}
isExpanded={expandedSections.has('experience')}
onToggle={() => toggleSection('experience')}
>
{experienceChanges.map((change, idx) => (
<ChangeItem key={idx} change={change} />
))}
</ChangeSection>
)}
{/* Description changes */}
{descChanges.length > 0 && (
<ChangeSection
title={t('tailor.diffModal.descriptionChanges')}
count={descChanges.length}
isExpanded={expandedSections.has('descriptions')}
onToggle={() => toggleSection('descriptions')}
>
{descChanges.map((change, idx) => (
<ChangeItem key={idx} change={change} />
))}
</ChangeSection>
)}
{/* Education changes */}
{educationChanges.length > 0 && (
<ChangeSection
title={t('tailor.diffModal.educationChanges')}
count={educationChanges.length}
isExpanded={expandedSections.has('education')}
onToggle={() => toggleSection('education')}
>
{educationChanges.map((change, idx) => (
<ChangeItem key={idx} change={change} />
))}
</ChangeSection>
)}
{/* Project changes */}
{projectChanges.length > 0 && (
<ChangeSection
title={t('tailor.diffModal.projectChanges')}
count={projectChanges.length}
isExpanded={expandedSections.has('project')}
onToggle={() => toggleSection('project')}
>
{projectChanges.map((change, idx) => (
<ChangeItem key={idx} change={change} />
))}
</ChangeSection>
)}
{/* Certification changes */}
{certChanges.length > 0 && (
<ChangeSection
title={t('tailor.diffModal.certificationChanges')}
count={certChanges.length}
isExpanded={expandedSections.has('certifications')}
onToggle={() => toggleSection('certifications')}
>
{certChanges.map((change, idx) => (
<ChangeItem key={idx} change={change} />
))}
</ChangeSection>
)}
{/* Language changes */}
{languageChanges.length > 0 && (
<ChangeSection
title={t('tailor.diffModal.languageChanges')}
count={languageChanges.length}
isExpanded={expandedSections.has('languages')}
onToggle={() => toggleSection('languages')}
>
{languageChanges.map((change, idx) => (
<ChangeItem key={idx} change={change} />
))}
</ChangeSection>
)}
{/* Award changes */}
{awardChanges.length > 0 && (
<ChangeSection
title={t('tailor.diffModal.awardChanges')}
count={awardChanges.length}
isExpanded={expandedSections.has('awards')}
onToggle={() => toggleSection('awards')}
>
{awardChanges.map((change, idx) => (
<ChangeItem key={idx} change={change} />
))}
</ChangeSection>
)}
</div>
{/* Action buttons */}
<div className="flex justify-between items-center pt-4 border-t-2 border-black bg-white -mx-6 -mb-6 px-6 py-4">
<Button variant="outline" onClick={onReject} disabled={isConfirming} className="gap-2">
<X className="w-4 h-4" />
{t('tailor.diffModal.rejectButton')}
</Button>
<div className="flex items-center gap-3">
{isConfirming && elapsed > 0 && (
<span className="font-mono text-xs text-steel-grey">{elapsed}s</span>
)}
<Button
onClick={onConfirm}
disabled={isConfirming}
className="gap-2 bg-success hover:bg-green-800"
>
{isConfirming ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('common.saving')}
</>
) : (
<>
<CheckCircle className="w-4 h-4" />
{t('tailor.diffModal.confirmButton')}
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
// Helper component: stat card
interface StatCardProps {
label: string;
value: number;
variant: 'success' | 'warning' | 'danger' | 'info';
}
function StatCard({ label, value, variant }: StatCardProps) {
const colors = {
success: 'border-success bg-[#F0FDF4] text-success',
warning: 'border-warning bg-[#FFF7ED] text-warning',
danger: 'border-destructive bg-[#FEF2F2] text-destructive',
info: 'border-primary bg-[#EFF6FF] text-primary',
};
return (
<div className={`border-2 p-3 ${colors[variant]}`}>
<div className="font-mono text-2xl font-bold">{value}</div>
<div className="font-mono text-xs uppercase tracking-wider mt-1">{label}</div>
</div>
);
}
// Helper component: collapsible change section
interface ChangeSectionProps {
title: string;
count: number;
isExpanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function ChangeSection({ title, count, isExpanded, onToggle, children }: ChangeSectionProps) {
return (
<div className="border-2 border-black bg-white">
<button
onClick={onToggle}
className="w-full flex items-center justify-between p-3 hover:bg-paper-tint"
>
<div className="flex items-center gap-2">
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
<span className="font-mono text-sm font-bold uppercase tracking-wider">
{title} ({count})
</span>
</div>
</button>
{isExpanded && <div className="border-t-2 border-black p-4 space-y-3">{children}</div>}
</div>
);
}
// Helper component: change item
interface ChangeItemProps {
change: ResumeFieldDiff;
}
function ChangeItem({ change }: ChangeItemProps) {
// Background tint + leading glyph instead of left-stripe borders.
// Side-stripe borders are an impeccable absolute_ban (BAN 1) — the most
// overused dashboard "design touch". The leading +/-/~ glyph carries the
// semantic load and the bg tint reinforces it.
const typeBackgrounds = {
added: 'bg-[#F0FDF4]',
removed: 'bg-[#FEF2F2]',
modified: 'bg-[#EFF6FF]',
};
const typeGlyphColors = {
added: 'text-success',
removed: 'text-destructive',
modified: 'text-primary',
};
const typeLabels = {
added: '+',
removed: '-',
modified: '~',
};
return (
<div className={`p-3 border border-black ${typeBackgrounds[change.change_type]}`}>
<div className="flex items-start gap-2">
<span
className={`font-mono text-base font-bold uppercase tracking-wider ${typeGlyphColors[change.change_type]}`}
aria-hidden="true"
>
{typeLabels[change.change_type]}
</span>
<div className="flex-1">
{change.original_value && (
<div className="line-through text-destructive font-mono text-sm mb-1">
{change.original_value}
</div>
)}
{change.new_value && (
<div className="text-ink-soft font-mono text-sm">{change.new_value}</div>
)}
</div>
{change.change_type === 'added' && change.confidence === 'high' && (
<AlertTriangle className="w-4 h-4 text-warning shrink-0" />
)}
</div>
</div>
);
}