331 lines
17 KiB
TypeScript
331 lines
17 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
Check,
|
|
Zap,
|
|
Layout,
|
|
Trophy
|
|
} from 'lucide-react';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { motion } from 'framer-motion';
|
|
import type { Zone, InternalRegion, UserSelection, FrameCandidate, SlidePlan } from '../types/designAgent';
|
|
import { getSectionsForZone } from '../utils/slidePlanUtils';
|
|
|
|
interface FramePanelProps {
|
|
slidePlan: SlidePlan | null;
|
|
selectedZone: Zone | null;
|
|
selectedRegion: InternalRegion | null;
|
|
userSelection: UserSelection;
|
|
onFrameSelect: (frameId: string) => void;
|
|
onNoDesignToggle: () => void;
|
|
}
|
|
|
|
export default function FramePanel({
|
|
slidePlan,
|
|
selectedZone,
|
|
selectedRegion,
|
|
userSelection,
|
|
onFrameSelect,
|
|
}: FramePanelProps) {
|
|
|
|
// Zone 중심 컨셉: 선택된 존에 할당된 섹션들의 프레임 후보들을 수집
|
|
const assignedSectionIds = selectedZone ? getSectionsForZone(selectedZone, userSelection) : [];
|
|
|
|
// 해당 섹션들이 포함된 원본 리전들에서 후보군 추출
|
|
const candidates: FrameCandidate[] = React.useMemo(() => {
|
|
if (!slidePlan || !selectedZone) return [];
|
|
|
|
// 단순화: 선택된 존의 첫 번째 리전의 후보군을 우선 사용 (Phase Z MVP1 기준)
|
|
// 실제로는 할당된 섹션에 따라 동적으로 필터링된 후보군이 필요함
|
|
const targetRegion = selectedRegion || selectedZone.internal_regions[0];
|
|
return targetRegion?.frame_candidates || [];
|
|
}, [slidePlan, selectedZone, selectedRegion]);
|
|
|
|
const currentFrameId = React.useMemo(() => {
|
|
const targetRegion = selectedRegion || selectedZone?.internal_regions[0];
|
|
if (!targetRegion) return null;
|
|
return userSelection.overrides.zone_frames[targetRegion.id] || targetRegion.frame_match_strategy.frame_id;
|
|
}, [selectedZone, selectedRegion, userSelection.overrides.zone_frames]);
|
|
|
|
if (!selectedZone) {
|
|
return (
|
|
<div className="h-full flex flex-col items-center justify-center bg-slate-50 p-8 text-center text-slate-400">
|
|
<Layout className="w-12 h-12 mb-4 opacity-10" />
|
|
<p className="text-xs font-bold uppercase tracking-[0.2em]">Select a Zone<br />to View Designs</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full flex flex-col bg-white">
|
|
{/* Header */}
|
|
<div className="p-5 border-b border-slate-100">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h3 className="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
|
|
<Zap className="w-3.5 h-3.5 fill-blue-500 text-blue-500" />
|
|
Design Wheel
|
|
</h3>
|
|
<Badge variant="outline" className="text-[9px] font-black border-slate-200 text-slate-400">
|
|
TOP {candidates.length}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-[11px] text-slate-500 font-medium leading-tight">
|
|
현재 구역의 섹션({assignedSectionIds.join(', ')})에 최적화된 추천 디자인입니다.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Vertical Wheel List */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-5">
|
|
{candidates.length === 0 ? (
|
|
<div className="py-20 text-center border-2 border-dashed border-slate-50 rounded-2xl">
|
|
<p className="text-[10px] font-bold text-slate-300 uppercase">No Candidates Available</p>
|
|
</div>
|
|
) : (
|
|
candidates.map((candidate, index) => {
|
|
const isSelected = currentFrameId === candidate.id;
|
|
|
|
const isReject = candidate.label === "reject";
|
|
// catalog 미등록 = backend Step 7-A 가 override 시도해도 skip.
|
|
// catalogRegistered === false 만 체크 (undefined = 정보 없음, 일반 처리).
|
|
const isCatalogMissing = candidate.catalogRegistered === false;
|
|
|
|
// ─── IMP-29 u3 — IMP-05 L2 candidate_evidence surface ───────────
|
|
// All evidence fields optional; silent degradation when undefined
|
|
// (pre-IMP-05 fixtures fall back to label/catalogRegistered only).
|
|
const isFilteredDirect = candidate.filteredForDirectExecution === true;
|
|
const hasDecision = candidate.decision === "selected" || candidate.decision === "skipped";
|
|
const isSkipped = candidate.decision === "skipped";
|
|
const isSelectedDecision = candidate.decision === "selected";
|
|
const showRouteChip =
|
|
candidate.routeHint && candidate.routeHint !== "direct_render";
|
|
const showStatusChip =
|
|
candidate.phaseZStatus && candidate.phaseZStatus !== "auto_renderable";
|
|
const hasCapacityFit =
|
|
candidate.capacityFit && candidate.capacityFit.fit_status;
|
|
const capacityMismatch =
|
|
hasCapacityFit && candidate.capacityFit!.fit_status !== "ok";
|
|
|
|
// Compose evidence tooltip lines (only when at least one signal present).
|
|
const evidenceLines: string[] = [];
|
|
if (candidate.decision) evidenceLines.push(`decision: ${candidate.decision}`);
|
|
if (candidate.reason) evidenceLines.push(`reason: ${candidate.reason}`);
|
|
if (candidate.routeHint) evidenceLines.push(`route: ${candidate.routeHint}`);
|
|
if (candidate.phaseZStatus)
|
|
evidenceLines.push(`phase_z_status: ${candidate.phaseZStatus}`);
|
|
if (hasCapacityFit) {
|
|
const cf = candidate.capacityFit!;
|
|
const capacityLine =
|
|
cf.fit_status === "ok"
|
|
? `capacity: ok${
|
|
typeof cf.item_count === "number"
|
|
? ` (items=${cf.item_count})`
|
|
: ""
|
|
}`
|
|
: `capacity: ${cf.fit_status}${
|
|
cf.mismatch_reason ? ` — ${cf.mismatch_reason}` : ""
|
|
}`;
|
|
evidenceLines.push(capacityLine);
|
|
}
|
|
const evidenceTooltip =
|
|
evidenceLines.length > 0 ? evidenceLines.join("\n") : undefined;
|
|
|
|
// Compose final tooltip: existing catalog/reject reasons first, then
|
|
// evidence detail (preserves Phase Q tooltip semantics).
|
|
const tooltipParts = [
|
|
isCatalogMissing
|
|
? "⚠ catalog 미등록 — render path 에서 적용 안 됨 (선택해도 backend 가 skip)"
|
|
: null,
|
|
isFilteredDirect
|
|
? "⚠ filtered_for_direct_execution — MVP1 직접 렌더 경로 제외"
|
|
: null,
|
|
isReject ? "V4 reject — render path 비추천" : null,
|
|
evidenceTooltip,
|
|
].filter((s): s is string => Boolean(s));
|
|
const composedTitle =
|
|
tooltipParts.length > 0 ? tooltipParts.join("\n\n") : undefined;
|
|
|
|
return (
|
|
<motion.div
|
|
key={candidate.id}
|
|
whileHover={{ y: -2 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
className="w-full"
|
|
>
|
|
<button
|
|
onClick={() => onFrameSelect(candidate.id)}
|
|
draggable
|
|
onDragStart={(e) => {
|
|
e.dataTransfer.setData("frameId", candidate.id);
|
|
}}
|
|
className={`w-full text-left rounded-3xl border-2 transition-all overflow-hidden group relative flex flex-col ${
|
|
isSelected
|
|
? 'border-blue-500 bg-white shadow-xl shadow-blue-500/10'
|
|
: isCatalogMissing
|
|
? 'border-slate-100 bg-slate-50/40 opacity-60 hover:opacity-90 hover:border-amber-200'
|
|
: isFilteredDirect
|
|
? 'border-slate-100 bg-slate-50/30 opacity-50 hover:opacity-90 hover:border-amber-200'
|
|
: isReject
|
|
? 'border-slate-100 bg-slate-50/30 opacity-50 hover:opacity-90 hover:border-slate-200'
|
|
: 'border-slate-100 bg-slate-50/50 hover:border-slate-200 hover:bg-white'
|
|
}`}
|
|
title={composedTitle}
|
|
>
|
|
{/* Rank Badge */}
|
|
<div className="absolute top-3 left-3 z-10">
|
|
<div className={`px-2 py-0.5 rounded-full text-[8px] font-black uppercase tracking-tighter flex items-center gap-1 shadow-sm ${
|
|
index === 0 ? 'bg-orange-500 text-white' : 'bg-slate-900 text-white'
|
|
}`}>
|
|
{index === 0 && <Trophy className="w-2 h-2" />}
|
|
Rank {index + 1}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Preview Image */}
|
|
<div className="h-36 bg-slate-200 relative overflow-hidden">
|
|
{candidate.thumbnailUrl ? (
|
|
<img
|
|
src={candidate.thumbnailUrl}
|
|
alt={candidate.name}
|
|
className={`w-full h-full object-contain bg-white transition-transform duration-500 ${isSelected ? 'scale-105' : 'group-hover:scale-105'}`}
|
|
onError={(e) => {
|
|
// preview.png 없는 frame — placeholder 로 swap.
|
|
const img = e.currentTarget;
|
|
img.style.display = "none";
|
|
const parent = img.parentElement;
|
|
if (parent && !parent.querySelector(".frame-fallback")) {
|
|
const ph = document.createElement("div");
|
|
ph.className = "frame-fallback w-full h-full flex flex-col items-center justify-center bg-slate-100 text-slate-400";
|
|
ph.innerHTML = `
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-8 h-8 mb-1">
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
|
<line x1="3" y1="9" x2="21" y2="9"/>
|
|
</svg>
|
|
<span style="font-size:9px; font-weight:700; letter-spacing:0.05em; text-transform:uppercase;">no preview</span>
|
|
`;
|
|
parent.appendChild(ph);
|
|
}
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<Layout className="w-10 h-10 text-slate-300" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Confidence Score Overlay */}
|
|
<div className="absolute bottom-3 right-3 px-2 py-1 rounded-lg bg-blue-600/90 backdrop-blur text-white text-[10px] font-black shadow-lg">
|
|
{(candidate.score * 100).toFixed(0)}% Match
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info Footer */}
|
|
<div className="p-4 bg-white">
|
|
<span className={`text-[11px] font-black uppercase tracking-tight block truncate mb-1 ${isSelected ? 'text-blue-700' : 'text-slate-800'}`}>
|
|
{candidate.name}
|
|
</span>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest truncate">{candidate.id}</span>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
{/* catalog 미등록 배지 — backend render path 미연결 명시 */}
|
|
{isCatalogMissing && (
|
|
<span
|
|
className="text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded bg-slate-200 text-slate-600"
|
|
title="catalog 미등록 — backend 적용 X"
|
|
>
|
|
no catalog
|
|
</span>
|
|
)}
|
|
{/* V4 label badge */}
|
|
{candidate.label && (
|
|
<span
|
|
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
|
|
candidate.label === "use_as_is"
|
|
? "bg-emerald-100 text-emerald-700"
|
|
: candidate.label === "light_edit"
|
|
? "bg-blue-100 text-blue-700"
|
|
: candidate.label === "restructure"
|
|
? "bg-amber-100 text-amber-700"
|
|
: "bg-red-100 text-red-700"
|
|
}`}
|
|
title={`V4 label: ${candidate.label}`}
|
|
>
|
|
{candidate.label}
|
|
</span>
|
|
)}
|
|
{/* IMP-29 u3 — route hint chip (skip when direct_render = default). */}
|
|
{showRouteChip && (
|
|
<span
|
|
className="text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded bg-slate-100 text-slate-600"
|
|
title={`route_hint: ${candidate.routeHint}`}
|
|
>
|
|
{candidate.routeHint === "deterministic_minor_adjustment"
|
|
? "adapt"
|
|
: candidate.routeHint === "ai_adaptation_required"
|
|
? "ai req"
|
|
: candidate.routeHint === "design_reference_only"
|
|
? "ref"
|
|
: candidate.routeHint}
|
|
</span>
|
|
)}
|
|
{/* IMP-29 u3 — phase_z status warning chip (skip when auto_renderable). */}
|
|
{showStatusChip && (
|
|
<span
|
|
className="text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded bg-amber-50 text-amber-700"
|
|
title={`phase_z_status: ${candidate.phaseZStatus}`}
|
|
>
|
|
{candidate.phaseZStatus!.replace(/_/g, " ")}
|
|
</span>
|
|
)}
|
|
{/* IMP-29 u3 — capacity_fit indicator (ok = subtle, mismatch = warning). */}
|
|
{hasCapacityFit && (
|
|
<span
|
|
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
|
|
capacityMismatch
|
|
? "bg-amber-100 text-amber-700"
|
|
: "bg-slate-100 text-slate-500"
|
|
}`}
|
|
title={`capacity_fit: ${candidate.capacityFit!.fit_status}${
|
|
candidate.capacityFit!.mismatch_reason
|
|
? ` — ${candidate.capacityFit!.mismatch_reason}`
|
|
: ""
|
|
}`}
|
|
>
|
|
{capacityMismatch
|
|
? `fit: ${candidate.capacityFit!.fit_status}`
|
|
: "fit ok"}
|
|
</span>
|
|
)}
|
|
{/* IMP-29 u3 — decision badge (Stage 2 contract: surface both selected & skipped). */}
|
|
{hasDecision && (
|
|
<span
|
|
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
|
|
isSelectedDecision
|
|
? "bg-emerald-50 text-emerald-700"
|
|
: "bg-red-50 text-red-600"
|
|
}`}
|
|
title={`decision: ${candidate.decision}${
|
|
candidate.reason ? ` — ${candidate.reason}` : ""
|
|
}`}
|
|
>
|
|
{isSkipped ? "skip" : "sel"}
|
|
</span>
|
|
)}
|
|
{isSelected && (
|
|
<div className="flex items-center gap-1 text-[8px] font-black text-emerald-500 uppercase">
|
|
<Check className="w-2.5 h-2.5 stroke-[4]" />
|
|
Applied
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</motion.div>
|
|
);
|
|
})
|
|
)}
|
|
<div className="h-4" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|