o2o-infinith-demo/src/components/plan/StrategyAdjustmentSection.tsx
Haewon Kam 0be730f0f9 fix: 데모 화면의 DB 조회·쓰기를 서버 함수로 옮긴다
브라우저가 공개 키로 테이블을 직접 읽고 쓰던 구조를 없앤다.
- api/db.js: 정해진 작업만 서비스 키로 실행 (임의 쿼리·열 지정 불가)
- api/_origin.js: 서버 함수 공용 출처 검사, 공개 배포에서는 404
- src/lib/db.ts: 화면용 호출 함수, 반환 형태 { data, error } 유지
- 빌드 큐에 바로 넣던 예전 진단 신청 모달 삭제

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:09:29 +09:00

291 lines
12 KiB
TypeScript

import { useState, useEffect } from 'react';
import { motion } from 'motion/react';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import { MegaphoneFilled } from '../icons/FilledIcons';
import { triggerStrategyAdjustment } from '../../lib/supabase';
import { db } from '../../lib/db';
interface StrategyAdjustmentSectionProps {
clinicId: string | null;
planId?: string;
}
interface KpiProgress {
metric: string;
current: string;
target: string;
progress: number;
}
interface StrategySuggestion {
adjustmentType: string;
channel: string;
description: string;
reason: string;
priority: 'high' | 'medium' | 'low';
beforeValue: string;
afterValue: string;
}
interface AdjustmentHistoryItem {
id: string;
adjustment_type: string;
description: string;
reason: string;
created_at: string;
}
const priorityColors: Record<string, { bg: string; text: string; dot: string }> = {
high: { bg: 'bg-red-50', text: 'text-red-700', dot: 'bg-red-400' },
medium: { bg: 'bg-amber-50', text: 'text-amber-700', dot: 'bg-amber-400' },
low: { bg: 'bg-blue-50', text: 'text-blue-700', dot: 'bg-blue-400' },
};
const typeLabels: Record<string, string> = {
frequency_change: '게시 빈도 조정',
pillar_shift: '콘텐츠 필러 전환',
channel_add: '채널 추가',
channel_pause: '채널 일시 중지',
content_format_change: '포맷 변경',
tone_shift: '톤 조정',
other: '기타 조정',
};
export default function StrategyAdjustmentSection({ clinicId, planId }: StrategyAdjustmentSectionProps) {
const [kpiProgress, setKpiProgress] = useState<KpiProgress[]>([]);
const [suggestions, setSuggestions] = useState<StrategySuggestion[]>([]);
const [history, setHistory] = useState<AdjustmentHistoryItem[]>([]);
const [overallAssessment, setOverallAssessment] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [hasData, setHasData] = useState(false);
useEffect(() => {
if (!clinicId) return;
loadExistingData();
}, [clinicId]);
async function loadExistingData() {
setIsLoading(true);
try {
// Load latest performance metrics
const { data: perf } = await db<{ kpi_progress: unknown; strategy_suggestions: unknown }>('perf.latest', { clinicId: clinicId! });
if (perf) {
setKpiProgress((perf.kpi_progress as KpiProgress[]) || []);
setSuggestions((perf.strategy_suggestions as StrategySuggestion[]) || []);
setHasData(true);
}
// Load adjustment history
const { data: adj } = await db<AdjustmentHistoryItem[]>('adjustments.list', { clinicId: clinicId! });
if (adj) setHistory(adj);
} catch {
// No data yet — show empty state
} finally {
setIsLoading(false);
}
}
async function handleRunAdjustment() {
if (!clinicId || isRunning) return;
setIsRunning(true);
try {
const result = await triggerStrategyAdjustment(clinicId);
if (result.success) {
setKpiProgress(result.kpiProgress || []);
setSuggestions(result.adjustments?.strategySuggestions || []);
setOverallAssessment(result.adjustments?.overallAssessment || '');
setHasData(true);
await loadExistingData(); // Refresh history
}
} catch (err) {
console.error('Strategy adjustment failed:', err);
} finally {
setIsRunning(false);
}
}
if (isLoading) {
return (
<SectionWrapper id="strategy-adjustment" title="Strategy Adjustment" subtitle="전략 조정" dark>
<div className="flex justify-center py-12">
<div className="w-6 h-6 border-2 border-purple-300 border-t-purple-600 rounded-full animate-spin" />
</div>
</SectionWrapper>
);
}
return (
<SectionWrapper id="strategy-adjustment" title="Strategy Adjustment" subtitle="성과 기반 전략 조정" dark>
{/* Action Button */}
<div className="flex items-center justify-between mb-6" data-no-print>
<p className="text-sm text-slate-500">
채널 성과 데이터를 분석하여 콘텐츠 전략을 자동 조정합니다
</p>
<button
onClick={handleRunAdjustment}
disabled={isRunning}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all ${
isRunning
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-[#6C5CE7] text-white hover:bg-[#5A4BD6] shadow-sm'
}`}
>
{isRunning ? (
<>
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-500 rounded-full animate-spin" />
분석 중...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
전략 조정 실행
</>
)}
</button>
</div>
{!hasData ? (
/* Empty state */
<div className="text-center py-12 bg-white rounded-2xl border border-dashed border-slate-200">
<div className="w-10 h-10 rounded-xl bg-[#F3F0FF] flex items-center justify-center mx-auto mb-3">
<MegaphoneFilled size={20} className="text-[#6C5CE7]" />
</div>
<p className="text-sm font-medium text-slate-600 mb-1">아직 성과 데이터가 없습니다</p>
<p className="text-xs text-slate-400">
채널 분석을 2회 이상 실행하면 성과 비교 및 전략 조정이 가능합니다
</p>
</div>
) : (
<>
{/* Overall Assessment */}
{overallAssessment && (
<motion.div
className="bg-purple-50 border border-purple-100 rounded-2xl p-4 mb-6"
animate={{ opacity: 1, y: 0 }}
>
<p className="text-sm text-purple-800">{overallAssessment}</p>
</motion.div>
)}
{/* KPI Progress */}
{kpiProgress.length > 0 && (
<div className="mb-8">
<h4 className="text-sm font-bold text-[#0A1128] mb-3">KPI 달성률</h4>
<div className="space-y-3">
{kpiProgress.map((kpi, i) => (
<motion.div
key={i}
className="bg-white rounded-xl p-4 border border-slate-100"
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05 }}
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-slate-600">{kpi.metric}</span>
<span className={`text-xs font-bold ${
kpi.progress >= 100 ? 'text-green-600' :
kpi.progress >= 70 ? 'text-amber-600' : 'text-red-600'
}`}>
{kpi.progress}%
</span>
</div>
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden">
<motion.div
className={`h-full rounded-full ${
kpi.progress >= 100 ? 'bg-green-400' :
kpi.progress >= 70 ? 'bg-amber-400' : 'bg-red-400'
}`}
initial={{ width: 0 }}
animate={{ width: `${Math.min(kpi.progress, 100)}%` }}
transition={{ duration: 0.8, delay: i * 0.1 }}
/>
</div>
<div className="flex justify-between mt-1.5 text-[10px] text-slate-400">
<span>현재: {kpi.current}</span>
<span>목표: {kpi.target}</span>
</div>
</motion.div>
))}
</div>
</div>
)}
{/* Strategy Suggestions */}
{suggestions.length > 0 && (
<div className="mb-8">
<h4 className="text-sm font-bold text-[#0A1128] mb-3">전략 조정 제안</h4>
<div className="space-y-3">
{suggestions.map((s, i) => {
const colors = priorityColors[s.priority] || priorityColors.medium;
return (
<motion.div
key={i}
className={`${colors.bg} rounded-xl p-4 border border-slate-100`}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08 }}
>
<div className="flex items-start gap-3">
<span className={`w-2 h-2 rounded-full ${colors.dot} mt-1.5 flex-shrink-0`} />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-bold ${colors.text}`}>
{typeLabels[s.adjustmentType] || s.adjustmentType}
</span>
{s.channel && (
<span className="text-[10px] text-slate-400 bg-white px-2 py-0.5 rounded-full">
{s.channel}
</span>
)}
</div>
<p className="text-sm text-slate-700 mb-1">{s.description}</p>
<p className="text-xs text-slate-500">{s.reason}</p>
{s.beforeValue && s.afterValue && (
<div className="flex items-center gap-2 mt-2 text-xs">
<span className="text-slate-400 line-through">{s.beforeValue}</span>
<span className="text-slate-400">→</span>
<span className={`font-medium ${colors.text}`}>{s.afterValue}</span>
</div>
)}
</div>
</div>
</motion.div>
);
})}
</div>
</div>
)}
{/* Adjustment History */}
{history.length > 0 && (
<div>
<h4 className="text-sm font-bold text-[#0A1128] mb-3">조정 이력</h4>
<div className="relative border-l-2 border-slate-200 ml-2 pl-4 space-y-4">
{history.map((item) => (
<div key={item.id} className="relative">
<div className="absolute -left-[21px] top-1 w-2.5 h-2.5 rounded-full bg-purple-300 border-2 border-white" />
<div className="bg-white rounded-xl p-3 border border-slate-100">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-purple-600">
{typeLabels[item.adjustment_type] || item.adjustment_type}
</span>
<span className="text-[10px] text-slate-400">
{new Date(item.created_at).toLocaleDateString('ko-KR')}
</span>
</div>
<p className="text-xs text-slate-600">{item.description}</p>
</div>
</div>
))}
</div>
</div>
)}
</>
)}
</SectionWrapper>
);
}