d3ro-voice/src/renderer/components/meeting/MeetingDetailTabs.tsx
Yun Chan 403985e2cd fix: 문서 생성 시 메모 참조 누락 + documents 배열 undefined 방어
generateDocument 프롬프트에 참석자 메모(📝) 포함.
MeetingDetailTabs에서 documents ?? [] 폴백 추가.
2026-04-08 11:23:26 +09:00

340 lines
11 KiB
TypeScript

// 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<MeetingSessionDetail>(initialDetail)
const [documents, setDocuments] = useState<MeetingDocument[]>(initialDetail.documents ?? [])
const [templates, setTemplates] = useState<MeetingDocTemplate[]>([])
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<Record<string, ReturnType<typeof setTimeout>>>({})
// 세그먼트 파싱 (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 (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* 헤더 */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
flexShrink: 0,
}}
>
<IconButton size="small" onClick={onBack}>
<ArrowBackIcon sx={{ fontSize: 18, color: d3roPalette.text.secondary }} />
</IconButton>
{editingTitle ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1 }}>
<TextField
size="small"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()}
sx={{
flex: 1,
'& .MuiInputBase-root': { fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size },
}}
autoFocus
/>
<IconButton size="small" onClick={handleSaveTitle}>
<CheckIcon sx={{ fontSize: 16, color: d3roPalette.accent.amber }} />
</IconButton>
</Box>
) : (
<>
<PhosphorText variant="label" sx={{ flex: 1 }}>
{detail.title ?? t('meeting.untitled')}
</PhosphorText>
<Tooltip title={t('meeting.editTitle')}>
<IconButton
size="small"
onClick={() => { setEditingTitle(true); setTitleDraft(detail.title ?? '') }}
>
<EditIcon sx={{ fontSize: 15, color: d3roPalette.text.inactive }} />
</IconButton>
</Tooltip>
</>
)}
</Box>
{/* 탭 바 */}
<Box sx={{ borderBottom: `1px solid ${d3roPalette.border.subtle}`, flexShrink: 0 }}>
<Tabs
value={tabIndex}
onChange={(_e, v: number) => {
// 마지막 탭([+])은 다이얼로그 열기
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 },
}}
>
<Tab label={t('meeting.transcriptTab')} />
{documents.map((doc) => (
<Tab key={doc.id} label={doc.title} />
))}
<Tab
icon={<AddIcon sx={{ fontSize: 16 }} />}
label={t('meeting.addDocument')}
iconPosition="start"
sx={{ minWidth: 120 }}
/>
</Tabs>
</Box>
{/* 탭 콘텐츠 */}
<Box sx={{ flex: 1, overflow: 'auto', p: 2, minHeight: 0 }}>
{tabIndex === 0 && (
<TranscriptTab
sessionId={detail.id}
segments={segments}
rawTranscript={detail.rawTranscript}
editedTranscript={detail.editedTranscript}
memos={detail.memos}
onEditSegment={handleEditSegment}
onSaveTranscript={handleSaveTranscript}
/>
)}
{documents.map((doc, idx) =>
tabIndex === idx + 1 ? (
<DocumentTab
key={doc.id}
document={doc}
onContentChange={(content) => handleDocContentChange(doc.id, content)}
onExport={(format) => handleExportDocument(doc.id, format)}
onDelete={() => handleDeleteDocument(doc.id)}
/>
) : null,
)}
</Box>
{/* AI 채팅 패널 */}
<MeetingChatPanel sessionId={detail.id} />
{/* 문서 생성 다이얼로그 */}
<AddDocumentDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onGenerate={handleGenerate}
templates={templates}
generating={generating}
/>
</Box>
)
}