브라우저가 공개 키로 테이블을 직접 읽고 쓰던 구조를 없앤다.
- api/db.js: 정해진 작업만 서비스 키로 실행 (임의 쿼리·열 지정 불가)
- api/_origin.js: 서버 함수 공용 출처 검사, 공개 배포에서는 404
- src/lib/db.ts: 화면용 호출 함수, 반환 형태 { data, error } 유지
- 빌드 큐에 바로 넣던 예전 진단 신청 모달 삭제
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
291 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|