// src/renderer/components/meeting/MeetingDetailTabs.tsx // Phase 14.5: 풀스크린 탭 전환 컨테이너 — 전사 + 동적 문서 탭 import { useState, useEffect, useCallback, useRef } from 'react' import { Box, Tabs, Tab, IconButton, TextField, Tooltip, } from '@mui/material' import ArrowBackIcon from '@mui/icons-material/ArrowBack' import AddIcon from '@mui/icons-material/Add' import EditIcon from '@mui/icons-material/Edit' import CheckIcon from '@mui/icons-material/Check' import { TranscriptTab } from './TranscriptTab' import { DocumentTab } from './DocumentTab' import { AddDocumentDialog } from './AddDocumentDialog' import { MeetingChatPanel } from './MeetingChatPanel' import { PhosphorText } from '../ds/PhosphorText' import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme' import { useI18n } from '../../i18n' import type { MeetingSessionDetail, MeetingDocument, MeetingDocTemplate, MeetingExportFormat, } from '@shared/types' interface MeetingDetailTabsProps { detail: MeetingSessionDetail onBack: () => void } const AUTOSAVE_DELAY = 500 // 세그먼트 파싱: rawTranscript를 줄 단위로 분리 function parseSegments( rawTranscript: string | null, editedTranscript: string | null, ): Array<{ id: string; timestamp: number; text: string; edited: boolean }> { const source = rawTranscript ?? '' const edited = editedTranscript ?? '' // [MM:SS] 텍스트 패턴 파싱 시도 const regex = /^\[(\d{2}):(\d{2})\]\s*(.+)$/ return source .split('\n') .filter((line) => line.trim()) .map((line, idx) => { const m = regex.exec(line.trim()) if (m) { const min = parseInt(m[1], 10) const sec = parseInt(m[2], 10) const text = m[3] // 수정본이 있으면 edited 판단 const editedLine = edited.split('\n')[idx]?.trim() ?? '' return { id: `seg-${idx}`, timestamp: (min * 60 + sec) * 1000, text, edited: Boolean(editedLine) && editedLine !== line.trim(), } } // 타임스탬프 없는 라인: 인덱스 기반 return { id: `seg-${idx}`, timestamp: idx * 5000, text: line.trim(), edited: false, } }) } export function MeetingDetailTabs({ detail: initialDetail, onBack, }: MeetingDetailTabsProps): React.ReactElement { const { t } = useI18n() const [detail, setDetail] = useState(initialDetail) const [documents, setDocuments] = useState(initialDetail.documents ?? []) const [templates, setTemplates] = useState([]) const [tabIndex, setTabIndex] = useState(0) const [dialogOpen, setDialogOpen] = useState(false) const [generating, setGenerating] = useState(false) const [editingTitle, setEditingTitle] = useState(false) const [titleDraft, setTitleDraft] = useState(detail.title ?? '') const autoSaveTimers = useRef>>({}) // 세그먼트 파싱 (rawTranscript 기반) const segments = parseSegments(detail.rawTranscript, detail.editedTranscript) // 템플릿 로드 useEffect(() => { window.electronAPI.meetingDocTemplate.getAll().then((resp) => { if (resp.success) setTemplates(resp.data) }) }, []) // ── 제목 저장 ── const handleSaveTitle = useCallback(async () => { if (!titleDraft.trim()) return await window.electronAPI.meetingMode.updateTitle({ sessionId: detail.id, title: titleDraft.trim(), }) setDetail((prev) => ({ ...prev, title: titleDraft.trim() })) setEditingTitle(false) }, [detail.id, titleDraft]) // ── 세그먼트 편집 ── const handleEditSegment = useCallback( async (segmentId: string, newText: string) => { await window.electronAPI.meetingMode.editSegment({ sessionId: detail.id, segmentId, text: newText, }) // 로컬 editedTranscript 갱신 (단순 반영) setDetail((prev) => ({ ...prev, editedTranscript: prev.rawTranscript })) }, [detail.id], ) // ── 전사 저장 ── const handleSaveTranscript = useCallback( async (editedTranscript: string) => { await window.electronAPI.meetingMode.updateTranscript({ sessionId: detail.id, editedTranscript, }) setDetail((prev) => ({ ...prev, editedTranscript })) }, [detail.id], ) // ── 문서 내용 자동저장 ── const handleDocContentChange = useCallback( (docId: string, content: string) => { if (autoSaveTimers.current[docId]) clearTimeout(autoSaveTimers.current[docId]) autoSaveTimers.current[docId] = setTimeout(async () => { await window.electronAPI.meetingMode.updateDocument({ documentId: docId, content, }) setDocuments((prev) => prev.map((d) => (d.id === docId ? { ...d, content } : d)), ) }, AUTOSAVE_DELAY) }, [], ) // ── 문서 내보내기 ── const handleExportDocument = useCallback( async (docId: string, format: MeetingExportFormat) => { await window.electronAPI.meetingMode.exportDocument({ documentId: docId, format, }) }, [], ) // ── 문서 삭제 ── const handleDeleteDocument = useCallback( async (docId: string) => { await window.electronAPI.meetingMode.deleteDocument({ documentId: docId }) setDocuments((prev) => { const docTabIndex = prev.findIndex((d) => d.id === docId) + 1 // +1: 전사 탭 setTabIndex((currentTab) => { if (currentTab === docTabIndex) return 0 if (currentTab > docTabIndex) return currentTab - 1 return currentTab }) return prev.filter((d) => d.id !== docId) }) }, [], ) // ── 문서 생성 ── const handleGenerate = useCallback( async (templateId: string, customPrompt?: string, customTitle?: string) => { setGenerating(true) const resp = await window.electronAPI.meetingMode.generateDocument({ sessionId: detail.id, templateId, customPrompt, customTitle, }) setGenerating(false) if (resp.success) { const newDoc = resp.data.document setDocuments((prev) => { // 새 문서 탭으로 이동: 전사(0) + 기존문서수 + 1 setTabIndex(prev.length + 1) return [...prev, newDoc] }) setDialogOpen(false) } }, [detail.id], ) // 탭 수: 전사(1) + 문서들 + [+] 버튼(1) const totalTabCount = 1 + documents.length + 1 return ( {/* 헤더 */} {editingTitle ? ( setTitleDraft(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()} sx={{ flex: 1, '& .MuiInputBase-root': { fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size }, }} autoFocus /> ) : ( <> {detail.title ?? t('meeting.untitled')} { setEditingTitle(true); setTitleDraft(detail.title ?? '') }} > )} {/* 탭 바 */} { // 마지막 탭([+])은 다이얼로그 열기 if (v === totalTabCount - 1) { setDialogOpen(true) return } setTabIndex(v) }} sx={{ minHeight: 40, '& .MuiTab-root': { fontFamily: d3roFontMono, fontSize: d3roTypo.engrave.size, minHeight: 40, py: 0, color: d3roPalette.text.inactive, textTransform: 'uppercase', letterSpacing: '1px', '&.Mui-selected': { color: d3roPalette.accent.amber }, }, '& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.amber, height: 2 }, }} > {documents.map((doc) => ( ))} } label={t('meeting.addDocument')} iconPosition="start" sx={{ minWidth: 120 }} /> {/* 탭 콘텐츠 */} {tabIndex === 0 && ( )} {documents.map((doc, idx) => tabIndex === idx + 1 ? ( handleDocContentChange(doc.id, content)} onExport={(format) => handleExportDocument(doc.id, format)} onDelete={() => handleDeleteDocument(doc.id)} /> ) : null, )} {/* AI 채팅 패널 */} {/* 문서 생성 다이얼로그 */} setDialogOpen(false)} onGenerate={handleGenerate} templates={templates} generating={generating} /> ) }