히스토리 패널 UI 작업 완료

- T-008 태스크 완료: 히스토리 패널 UI 마크업 구현 (슬라이드 인)
- 히스토리, UNDO, 전송하기 버튼을 세로로 균등 간격 배치
- Tailwind CSS v3/v4 버전 충돌 문제 해결
  - v4 패키지 완전 제거 (@tailwindcss/postcss 등)
  - PostCSS 설정을 v3 방식으로 수정
  - CSS 파일에서 수동 클래스 정의 제거
  - Vite 캐시 완전 삭제로 설정 변경 반영
- 히스토리 패널 기능 개선
  - 우측 슬라이드 인 애니메이션
  - 파일 업로드 시에만 표시
  - 상태별 아이콘과 시간순 로그 리스트
  - 재적용 및 전체 삭제 기능
- 새로운 rule 파일 생성: tailwind-css-management.mdc
This commit is contained in:
sheetEasy AI Team
2025-06-26 18:25:25 +09:00
parent 2d8e4524b7
commit e5ee01553a
9 changed files with 489 additions and 514 deletions

View File

@@ -15,10 +15,12 @@ import "@univerjs/presets/lib/styles/preset-sheets-core.css";
import { cn } from "../../lib/utils";
import LuckyExcel from "@zwight/luckyexcel";
import PromptInput from "./PromptInput";
import HistoryPanel from "../ui/historyPanel";
import { useAppStore } from "../../stores/useAppStore";
import { rangeToAddress } from "../../utils/cellUtils";
import { CellSelectionHandler } from "../../utils/cellSelectionHandler";
import { aiProcessor } from "../../utils/aiProcessor";
import type { HistoryEntry } from "../../types/ai";
// 전역 고유 키 생성
const GLOBAL_UNIVER_KEY = "__GLOBAL_UNIVER_INSTANCE__";
@@ -337,6 +339,52 @@ const TestSheetViewer: React.FC = () => {
// CellSelectionHandler 인스턴스 생성
const cellSelectionHandler = useRef(new CellSelectionHandler());
// 히스토리 관련 상태 추가
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [history, setHistory] = useState<HistoryEntry[]>([]);
// 히스토리 관련 핸들러
const handleHistoryToggle = () => {
console.log("🔄 히스토리 토글:", !isHistoryOpen);
setIsHistoryOpen(!isHistoryOpen);
};
const handleHistoryClear = () => {
if (window.confirm("모든 히스토리를 삭제하시겠습니까?")) {
setHistory([]);
}
};
const handleHistoryReapply = (entry: HistoryEntry) => {
// 히스토리 항목 재적용 로직
setPrompt(entry.prompt);
console.log("🔄 히스토리 재적용:", entry);
// TODO: 실제 액션 재실행 로직 구현
};
// 새 히스토리 항목 추가 함수
const addHistoryEntry = (
prompt: string,
range: string,
sheetName: string,
actions: any[],
status: "success" | "error" | "pending",
error?: string,
) => {
const newEntry: HistoryEntry = {
id: `history-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
timestamp: new Date(),
prompt,
range,
sheetName,
actions,
status,
error,
};
setHistory((prev) => [newEntry, ...prev]); // 최신 항목을 맨 위에
};
// Univer 초기화 함수 (Presets 기반)
const initializeUniver = useCallback(
async (workbookData?: any) => {
@@ -718,7 +766,23 @@ const TestSheetViewer: React.FC = () => {
<div style={{ height: "1rem" }} />
{/* 프롬프트 입력창 - Univer 하단에 이어서 */}
<PromptInput value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<PromptInput
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onHistoryToggle={handleHistoryToggle}
historyCount={history.length}
/>
{/* 히스토리 패널 - 파일이 업로드된 후에만 표시 */}
{!showUploadOverlay && (
<HistoryPanel
isOpen={isHistoryOpen}
onClose={() => setIsHistoryOpen(false)}
history={history}
onReapply={handleHistoryReapply}
onClear={handleHistoryClear}
/>
)}
{/* 파일 업로드 오버레이 - 레이어 분리 */}
{showUploadOverlay && (

View File

@@ -8,6 +8,8 @@ interface PromptInputProps {
onExecute?: () => void;
disabled?: boolean;
maxLength?: number;
onHistoryToggle?: () => void;
historyCount?: number;
}
/**
@@ -24,6 +26,8 @@ const PromptInput: React.FC<PromptInputProps> = ({
onExecute,
disabled = true,
maxLength = 500,
onHistoryToggle,
historyCount,
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [showCellInsertFeedback, setShowCellInsertFeedback] = useState(false);
@@ -182,18 +186,53 @@ const PromptInput: React.FC<PromptInputProps> = ({
rows={5}
/>
<div style={{ width: "1rem" }} />
<button
className="ml-2 px-6 py-2 rounded-lg text-white font-semibold text-base shadow transition disabled:opacity-60 disabled:cursor-not-allowed"
style={{
background: isProcessing
? "#6b7280"
: "linear-gradient(90deg, #a18fff 0%, #6f6fff 100%)",
}}
onClick={handleExecute}
disabled={isProcessing || !value.trim()}
>
{isProcessing ? "처리 중..." : "전송하기"}
</button>
{/* 버튼들을 세로로 배치 */}
<div className="flex flex-col gap-3">
{/* 히스토리 버튼 - 맨 위 */}
{onHistoryToggle && (
<button
className="px-4 py-2 rounded-lg text-gray-600 border border-gray-300 hover:bg-gray-50 font-semibold text-base shadow transition disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
onClick={onHistoryToggle}
disabled={isProcessing}
aria-label="작업 히스토리 보기"
>
📝
{historyCount !== undefined && historyCount > 0 && (
<span className="text-xs bg-blue-500 text-white rounded-full px-2 py-0.5 min-w-[20px] h-5 flex items-center justify-center">
{historyCount > 99 ? "99+" : historyCount}
</span>
)}
</button>
)}
{/* UNDO 버튼 - 중간 */}
<button
className="px-4 py-2 rounded-lg text-gray-600 border border-gray-300 hover:bg-gray-50 font-semibold text-base shadow transition disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
onClick={() => {
// TODO: UNDO 기능 구현
console.log("🔄 UNDO 버튼 클릭");
}}
disabled={isProcessing}
aria-label="실행 취소"
>
</button>
{/* 전송하기 버튼 - 맨 아래 */}
<button
className="px-6 py-2 rounded-lg text-white font-semibold text-base shadow transition disabled:opacity-60 disabled:cursor-not-allowed"
style={{
background: isProcessing
? "#6b7280"
: "linear-gradient(90deg, #a18fff 0%, #6f6fff 100%)",
}}
onClick={handleExecute}
disabled={isProcessing || !value.trim()}
>
{isProcessing ? "처리 중..." : "전송하기"}
</button>
</div>
</div>
<div className="w-full max-w-3xl flex justify-between items-center mt-1 px-1">
<span className="text-xs text-gray-500">

View File

@@ -0,0 +1,287 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "./card";
import { Button } from "./button";
import type { HistoryPanelProps, HistoryEntry } from "../../types/ai";
import { cn } from "../../lib/utils";
/**
* 히스토리 패널 컴포넌트
* 우측에서 슬라이드 인하는 방식으로 작동
*/
const HistoryPanel: React.FC<HistoryPanelProps> = ({
isOpen,
onClose,
history,
onReapply,
onClear,
}) => {
// 키보드 접근성: Escape 키로 패널 닫기
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && isOpen) {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
// 포커스 트랩을 위해 패널에 포커스 설정
const panel = document.getElementById("history-panel");
if (panel) {
panel.focus();
}
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen, onClose]);
// 시간 포맷팅 함수
const formatTime = (date: Date): string => {
return new Intl.DateTimeFormat("ko-KR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(date);
};
// 날짜 포맷팅 함수
const formatDate = (date: Date): string => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return "오늘";
} else if (date.toDateString() === yesterday.toDateString()) {
return "어제";
} else {
return new Intl.DateTimeFormat("ko-KR", {
month: "short",
day: "numeric",
}).format(date);
}
};
// 상태별 아이콘 및 색상
const getStatusIcon = (status: HistoryEntry["status"]) => {
switch (status) {
case "success":
return <span className="text-green-500"></span>;
case "error":
return <span className="text-red-500"></span>;
case "pending":
return <span className="text-yellow-500 animate-pulse"></span>;
default:
return <span className="text-gray-400"></span>;
}
};
// 액션 요약 생성
const getActionSummary = (actions: HistoryEntry["actions"]): string => {
if (actions.length === 0) return "액션 없음";
const actionTypes = actions.map((action) => {
switch (action.type) {
case "formula":
return "수식";
case "style":
return "스타일";
case "chart":
return "차트";
default:
return "기타";
}
});
return `${actionTypes.join(", ")} (${actions.length}개)`;
};
return (
<>
{/* 백드롭 오버레이 */}
{isOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-60 z-40 transition-opacity duration-300"
onClick={onClose}
aria-hidden="true"
/>
)}
{/* 히스토리 패널 */}
<div
id="history-panel"
className={cn(
"bg-white shadow-2xl z-50",
"transform transition-transform duration-300 ease-in-out",
"flex flex-col border-l border-gray-200",
isOpen ? "translate-x-0" : "translate-x-full",
)}
style={{
position: "fixed",
top: 65,
right: 0,
height: "100vh",
width: "384px", // w-96 = 384px
backgroundColor: "#ffffff",
zIndex: 50,
}}
role="dialog"
aria-modal="true"
aria-labelledby="history-panel-title"
aria-describedby="history-panel-description"
tabIndex={-1}
>
{/* 헤더 */}
<div className="flex-shrink-0 border-b border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<h2
id="history-panel-title"
className="text-lg font-semibold text-gray-900"
>
📝
</h2>
<p
id="history-panel-description"
className="text-sm text-gray-500 mt-1"
>
AI ({history.length})
</p>
</div>
{/* 닫기 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={onClose}
aria-label="히스토리 패널 닫기"
className="text-gray-400 hover:text-gray-600"
>
</Button>
</div>
{/* 전체 삭제 버튼 */}
{history.length > 0 && onClear && (
<div className="mt-3">
<Button
variant="outline"
size="sm"
onClick={onClear}
className="w-full text-red-600 border-red-200 hover:bg-red-50"
aria-label="모든 히스토리 삭제"
>
🗑
</Button>
</div>
)}
</div>
{/* 히스토리 목록 */}
<div className="flex-1 overflow-y-auto">
{history.length === 0 ? (
// 빈 상태
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<div className="text-4xl mb-4">📋</div>
<p className="text-center px-4">
AI .
<br />
<span className="text-sm text-gray-400">
!
</span>
</p>
</div>
) : (
// 히스토리 항목들
<div className="p-4 space-y-3">
{history.map((entry, index) => (
<Card
key={entry.id}
className={cn(
"transition-all duration-200 hover:shadow-md",
entry.status === "error" && "border-red-200 bg-red-50",
entry.status === "success" &&
"border-green-200 bg-green-50",
entry.status === "pending" &&
"border-yellow-200 bg-yellow-50",
)}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-2">
{getStatusIcon(entry.status)}
<div className="text-xs text-gray-500">
{formatDate(entry.timestamp)}{" "}
{formatTime(entry.timestamp)}
</div>
</div>
<div className="text-xs text-gray-400">
#{history.length - index}
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
{/* 프롬프트 */}
<div className="mb-3">
<p className="text-sm font-medium text-gray-900 mb-1">
:
</p>
<p className="text-sm text-gray-700 bg-gray-100 rounded-md p-2 break-words">
{entry.prompt}
</p>
</div>
{/* 범위 및 시트 정보 */}
<div className="mb-3 text-xs text-gray-600">
<span className="font-medium">:</span> {entry.range} |
<span className="font-medium ml-1">:</span>{" "}
{entry.sheetName}
</div>
{/* 액션 요약 */}
<div className="mb-3">
<p className="text-xs text-gray-600">
<span className="font-medium"> :</span>{" "}
{getActionSummary(entry.actions)}
</p>
</div>
{/* 에러 메시지 */}
{entry.status === "error" && entry.error && (
<div className="mb-3 p-2 bg-red-100 border border-red-200 rounded-md">
<p className="text-xs text-red-700">
<span className="font-medium">:</span>{" "}
{entry.error}
</p>
</div>
)}
{/* 재적용 버튼 */}
{entry.status === "success" && onReapply && (
<div className="mt-3">
<Button
variant="outline"
size="sm"
onClick={() => onReapply(entry)}
className="w-full text-blue-600 border-blue-200 hover:bg-blue-50"
aria-label={`프롬프트 재적용: ${entry.prompt.slice(0, 20)}...`}
>
🔄
</Button>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
</>
);
};
export default HistoryPanel;

View File

@@ -11,109 +11,6 @@
@tailwind components;
@tailwind utilities;
/* 필요한 색상 클래스들 추가 */
.text-gray-500 { color: #6b7280; }
.text-gray-600 { color: #4b5563; }
.text-gray-900 { color: #111827; }
.text-blue-600 { color: #2563eb; }
.text-blue-700 { color: #1d4ed8; }
.text-blue-800 { color: #1e40af; }
.bg-gray-50 { background-color: #f9fafb; }
.bg-blue-50 { background-color: #eff6ff; }
.bg-blue-100 { background-color: #dbeafe; }
.bg-blue-200 { background-color: #bfdbfe; }
.border-gray-300 { border-color: #d1d5db; }
.border-blue-200 { border-color: #bfdbfe; }
.border-blue-400 { border-color: #60a5fa; }
.border-blue-500 { border-color: #3b82f6; }
.hover\:border-blue-400:hover { border-color: #60a5fa; }
.hover\:bg-blue-50:hover { background-color: #eff6ff; }
.focus\:ring-blue-500:focus {
--tw-ring-color: #3b82f6;
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
}
/* 추가 유틸리티 클래스들 */
.max-w-7xl { max-width: 80rem; }
.max-w-2xl { max-width: 42rem; }
.max-w-lg { max-width: 32rem; }
.max-w-md { max-width: 28rem; }
.h-16 { height: 4rem; }
.h-20 { height: 5rem; }
.h-24 { height: 6rem; }
.w-20 { width: 5rem; }
.w-24 { width: 6rem; }
.h-6 { height: 1.5rem; }
.w-6 { width: 1.5rem; }
.h-10 { height: 2.5rem; }
.w-10 { width: 2.5rem; }
.h-12 { height: 3rem; }
.w-12 { width: 3rem; }
.space-x-4 > :not([hidden]) ~ :not([hidden]) { margin-left: 1rem; }
.space-x-2 > :not([hidden]) ~ :not([hidden]) { margin-left: 0.5rem; }
.space-y-1 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.25rem; }
.space-y-2 > :not([hidden]) ~ :not([hidden]) { margin-top: 0.5rem; }
.space-y-4 > :not([hidden]) ~ :not([hidden]) { margin-top: 1rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.p-8 { padding: 2rem; }
.p-12 { padding: 3rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mb-8 { margin-bottom: 2rem; }
.mt-6 { margin-top: 1.5rem; }
.text-xs { font-size: 0.75rem; line-height: 1rem; }
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.text-base { font-size: 1rem; line-height: 1.5rem; }
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
.text-4xl { font-size: 2.25rem; line-height: 2.5rem; }
.text-6xl { font-size: 3.75rem; line-height: 1; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.rounded-lg { border-radius: 0.5rem; }
.rounded-md { border-radius: 0.375rem; }
.shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); }
@media (min-width: 640px) {
.sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
}
@media (min-width: 768px) {
.md\:h-24 { height: 6rem; }
.md\:w-24 { width: 6rem; }
.md\:h-12 { height: 3rem; }
.md\:w-12 { width: 3rem; }
.md\:p-12 { padding: 3rem; }
.md\:text-base { font-size: 1rem; line-height: 1.5rem; }
.md\:text-lg { font-size: 1.125rem; line-height: 1.75rem; }
.md\:text-2xl { font-size: 1.5rem; line-height: 2rem; }
.md\:text-6xl { font-size: 3.75rem; line-height: 1; }
}
@media (min-width: 1024px) {
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
}
/* 전역 스타일 */
html, body, #root {
height: 100%;
@@ -199,61 +96,53 @@ html, body, #root {
}
@keyframes pulse {
0% {
transform: scale(1);
0%, 100% {
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.7;
}
100% {
transform: scale(1);
opacity: 1;
opacity: .5;
}
}
/* 에러 메시지 스타일 */
.error-message {
color: #dc2626;
background-color: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 12px;
border-radius: 6px;
font-size: 14px;
line-height: 1.4;
border-radius: 0.375rem;
padding: 0.75rem;
margin: 0.5rem 0;
}
/* 성공 메시지 스타일 */
.success-message {
color: #059669;
background-color: #f0fdf4;
border: 1px solid #bbf7d0;
color: #16a34a;
padding: 12px;
border-radius: 6px;
font-size: 14px;
line-height: 1.4;
border-radius: 0.375rem;
padding: 0.75rem;
margin: 0.5rem 0;
}
/* 정보 메시지 스타일 */
.info-message {
color: #2563eb;
background-color: #eff6ff;
border: 1px solid #bfdbfe;
color: #2563eb;
padding: 12px;
border-radius: 6px;
font-size: 14px;
line-height: 1.4;
border-radius: 0.375rem;
padding: 0.75rem;
margin: 0.5rem 0;
}
/* 반응형 디자인 */
/* 모바일 반응형 */
@media (max-width: 768px) {
.file-upload-area {
padding: 15px;
padding: 16px;
font-size: 14px;
}
.univer-container {
min-height: 400px;
font-size: 12px;
}
}
@@ -262,6 +151,7 @@ html, body, #root {
.file-upload-area {
border-color: #374151;
background-color: #1f2937;
color: #f9fafb;
}
.file-upload-area:hover {

View File

@@ -48,3 +48,23 @@ export interface AIHistory {
success: boolean;
error?: string;
}
// 히스토리 관련 타입 추가
export interface HistoryEntry {
id: string;
timestamp: Date;
prompt: string;
range: string;
sheetName: string;
actions: AIAction[];
status: "success" | "error" | "pending";
error?: string;
}
export interface HistoryPanelProps {
isOpen: boolean;
onClose: () => void;
history: HistoryEntry[];
onReapply?: (entry: HistoryEntry) => void;
onClear?: () => void;
}