Phase 14 구현: 회의 모드 (Meeting Mode)
실시간 녹음 + 타임스탬프 메모 + STT 전사 + LLM 구조화 회의록 + PDF/MD 내보내기. CaptionService 연동, 동시 사용 충돌 방지, 긴 회의 2-pass 요약 지원. DB 2테이블, IPC 15채널, UI 3뷰(목록/녹음/상세), 12개 locale i18n.
This commit is contained in:
parent
dd68fc9ccd
commit
d0c4f9ee53
38 changed files with 2550 additions and 769 deletions
570
src/renderer/pages/MeetingModePage.tsx
Normal file
570
src/renderer/pages/MeetingModePage.tsx
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
// src/renderer/pages/MeetingModePage.tsx
|
||||
// Phase 14: Meeting Mode — 실시간 녹음 + 메모 + 회의록 UI
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import StopIcon from '@mui/icons-material/Stop'
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
|
||||
import DescriptionIcon from '@mui/icons-material/Description'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import CheckIcon from '@mui/icons-material/Check'
|
||||
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'
|
||||
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '../components/ds'
|
||||
import { PageHeader, EmptyStateCard } from '../components/shared'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '../theme'
|
||||
import { useI18n } from '../i18n'
|
||||
import type {
|
||||
MeetingSessionSummary,
|
||||
MeetingSessionDetail,
|
||||
MeetingModeStateInfo,
|
||||
MeetingProcessingProgress,
|
||||
CaptionSegment,
|
||||
MeetingMemo,
|
||||
} from '@shared/types'
|
||||
|
||||
type MeetingView = 'list' | 'recording' | 'detail'
|
||||
|
||||
export function MeetingModePage(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [view, setView] = useState<MeetingView>('list')
|
||||
const [sessions, setSessions] = useState<MeetingSessionSummary[]>([])
|
||||
const [totalSessions, setTotalSessions] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 20
|
||||
|
||||
// 녹음 뷰 상태
|
||||
const [stateInfo, setStateInfo] = useState<MeetingModeStateInfo | null>(null)
|
||||
const [segments, setSegments] = useState<CaptionSegment[]>([])
|
||||
const [memos, setMemos] = useState<MeetingMemo[]>([])
|
||||
const [memoInput, setMemoInput] = useState('')
|
||||
const [elapsedMs, setElapsedMs] = useState(0)
|
||||
const [progress, setProgress] = useState<MeetingProcessingProgress | null>(null)
|
||||
const transcriptRef = useRef<HTMLDivElement>(null)
|
||||
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const recordingStartRef = useRef<number>(0)
|
||||
|
||||
// 상세 뷰 상태
|
||||
const [detail, setDetail] = useState<MeetingSessionDetail | null>(null)
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [titleDraft, setTitleDraft] = useState('')
|
||||
|
||||
// ── 세션 목록 로드 ──
|
||||
const loadSessions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const resp = await window.electronAPI.meetingMode.getSessions({ page, pageSize })
|
||||
if (resp.success) {
|
||||
setSessions(resp.data.sessions)
|
||||
setTotalSessions(resp.data.total)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [page])
|
||||
|
||||
useEffect(() => {
|
||||
if (view === 'list') loadSessions()
|
||||
}, [view, loadSessions])
|
||||
|
||||
// ── IPC 이벤트 구독 ──
|
||||
useEffect(() => {
|
||||
const unsubState = window.electronAPI.meetingMode.onStateChanged((info) => {
|
||||
setStateInfo(info)
|
||||
if (info.state === 'idle' && view === 'recording') {
|
||||
// 후처리 완료 → 목록으로 이동
|
||||
if (!progress) {
|
||||
setView('list')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const unsubSegment = window.electronAPI.meetingMode.onSegment((seg) => {
|
||||
setSegments((prev) => [...prev, seg])
|
||||
// 자동 스크롤
|
||||
setTimeout(() => {
|
||||
if (transcriptRef.current) {
|
||||
transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight
|
||||
}
|
||||
}, 50)
|
||||
})
|
||||
|
||||
const unsubProgress = window.electronAPI.meetingMode.onProcessingProgress((p) => {
|
||||
setProgress(p)
|
||||
})
|
||||
|
||||
const unsubCompleted = window.electronAPI.meetingMode.onSessionCompleted(() => {
|
||||
setProgress(null)
|
||||
setView('list')
|
||||
})
|
||||
|
||||
const unsubError = window.electronAPI.meetingMode.onError(() => {
|
||||
setProgress(null)
|
||||
setView('list')
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubState()
|
||||
unsubSegment()
|
||||
unsubProgress()
|
||||
unsubCompleted()
|
||||
unsubError()
|
||||
}
|
||||
}, [view, progress])
|
||||
|
||||
// ── 경과 시간 타이머 ──
|
||||
useEffect(() => {
|
||||
if (view === 'recording') {
|
||||
recordingStartRef.current = Date.now()
|
||||
elapsedTimerRef.current = setInterval(() => {
|
||||
setElapsedMs(Date.now() - recordingStartRef.current)
|
||||
}, 1000)
|
||||
}
|
||||
return () => {
|
||||
if (elapsedTimerRef.current) {
|
||||
clearInterval(elapsedTimerRef.current)
|
||||
elapsedTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [view])
|
||||
|
||||
// ── 핸들러 ──
|
||||
const handleStartRecording = useCallback(async () => {
|
||||
const resp = await window.electronAPI.meetingMode.startRecording()
|
||||
if (resp.success) {
|
||||
setSegments([])
|
||||
setMemos([])
|
||||
setElapsedMs(0)
|
||||
setProgress(null)
|
||||
setView('recording')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleStopRecording = useCallback(async () => {
|
||||
await window.electronAPI.meetingMode.stopRecording()
|
||||
}, [])
|
||||
|
||||
const handleAddMemo = useCallback(async () => {
|
||||
if (!memoInput.trim()) return
|
||||
const resp = await window.electronAPI.meetingMode.addMemo({ content: memoInput.trim() })
|
||||
if (resp.success) {
|
||||
setMemos((prev) => [...prev, resp.data])
|
||||
setMemoInput('')
|
||||
}
|
||||
}, [memoInput])
|
||||
|
||||
const handleMemoKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleAddMemo()
|
||||
}
|
||||
},
|
||||
[handleAddMemo],
|
||||
)
|
||||
|
||||
const handleViewDetail = useCallback(async (sessionId: string) => {
|
||||
const resp = await window.electronAPI.meetingMode.getSession({ sessionId })
|
||||
if (resp.success) {
|
||||
setDetail(resp.data)
|
||||
setView('detail')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!detail) return
|
||||
await window.electronAPI.meetingMode.deleteSession({ sessionId: detail.id })
|
||||
setDetail(null)
|
||||
setView('list')
|
||||
}, [detail])
|
||||
|
||||
const handleExportPdf = useCallback(async () => {
|
||||
if (!detail) return
|
||||
await window.electronAPI.meetingMode.exportPdf({ sessionId: detail.id })
|
||||
}, [detail])
|
||||
|
||||
const handleExportMarkdown = useCallback(async () => {
|
||||
if (!detail) return
|
||||
await window.electronAPI.meetingMode.exportMarkdown({ sessionId: detail.id })
|
||||
}, [detail])
|
||||
|
||||
const handleSaveTitle = useCallback(async () => {
|
||||
if (!detail || !titleDraft.trim()) return
|
||||
await window.electronAPI.meetingMode.updateTitle({
|
||||
sessionId: detail.id,
|
||||
title: titleDraft.trim(),
|
||||
})
|
||||
setDetail({ ...detail, title: titleDraft.trim() })
|
||||
setEditingTitle(false)
|
||||
}, [detail, titleDraft])
|
||||
|
||||
// ── 유틸 ──
|
||||
const formatTime = (ms: number): string => {
|
||||
const totalSec = Math.floor(ms / 1000)
|
||||
const min = Math.floor(totalSec / 60)
|
||||
const sec = totalSec % 60
|
||||
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const formatDate = (ts: number): string => {
|
||||
const d = new Date(ts)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const statusColor = (status: string): 'green' | 'amber' | 'red' | 'blue' => {
|
||||
switch (status) {
|
||||
case 'completed': return 'green'
|
||||
case 'recording': return 'red'
|
||||
case 'processing': return 'amber'
|
||||
case 'error': return 'red'
|
||||
default: return 'blue'
|
||||
}
|
||||
}
|
||||
|
||||
// ── 렌더: 목록 뷰 ──
|
||||
if (view === 'list') {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<PageHeader
|
||||
title={t('meeting.title')}
|
||||
count={totalSessions > 0 ? t('meeting.sessions', { count: totalSessions }) : undefined}
|
||||
action={
|
||||
<PhysicalButton size="small" onClick={handleStartRecording}>
|
||||
<AddIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.newMeeting')}
|
||||
</PhysicalButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<LinearProgress sx={{ mt: 2 }} />
|
||||
) : sessions.length === 0 ? (
|
||||
<EmptyStateCard message={`${t('meeting.noSessions')}\n${t('meeting.noSessionsDesc')}`} />
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{sessions.map((session) => (
|
||||
<MetalCard
|
||||
key={session.id}
|
||||
sx={{ cursor: 'pointer', '&:hover': { opacity: 0.85 } }}
|
||||
onClick={() => handleViewDetail(session.id)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box>
|
||||
<PhosphorText variant="label">
|
||||
{session.title ?? t('meeting.untitled')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
|
||||
{session.durationMs != null
|
||||
? t('meeting.duration', { minutes: Math.round(session.durationMs / 60000) })
|
||||
: '—'}
|
||||
{' · '}
|
||||
{t('meeting.memos')}: {session.memoCount}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PhosphorText variant="dim">{formatDate(session.startedAt)}</PhosphorText>
|
||||
<Led color={statusColor(session.status)} size={8} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 프로세싱 중인 세션 표시 */}
|
||||
{session.status === 'processing' && progress && progress.sessionId === session.id && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress.percent}
|
||||
sx={{ height: 4, borderRadius: 2 }}
|
||||
/>
|
||||
<PhosphorText variant="dim" sx={{ mt: 0.5, fontSize: 11 }}>
|
||||
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
)}
|
||||
</MetalCard>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 렌더: 녹음 뷰 ──
|
||||
if (view === 'recording') {
|
||||
const isProcessing = stateInfo?.state === 'processing'
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 상단 바 */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<FiberManualRecordIcon sx={{ color: d3roPalette.indicator.red, fontSize: 16, animation: 'pulse 1s infinite' }} />
|
||||
<PhosphorText variant="label" sx={{ fontFamily: d3roFontMono }}>
|
||||
{formatTime(elapsedMs)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="dim">
|
||||
{isProcessing ? t('meeting.processing') : t('meeting.recording')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
{!isProcessing && (
|
||||
<PhysicalButton size="small" color="error" onClick={handleStopRecording}>
|
||||
<StopIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.stopRecording')}
|
||||
</PhysicalButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 프로세싱 진행률 */}
|
||||
{isProcessing && progress && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<LinearProgress variant="determinate" value={progress.percent} sx={{ height: 6, borderRadius: 3 }} />
|
||||
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
|
||||
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])} ({progress.percent}%)
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 메인 패널: 전사 + 메모 */}
|
||||
<Box sx={{ flex: 1, display: 'flex', gap: 2, minHeight: 0 }}>
|
||||
{/* 좌: 실시간 전사 */}
|
||||
<ScreenPanel sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<PhosphorText variant="dim" sx={{ mb: 1, fontSize: 11 }}>
|
||||
{t('meeting.transcript')}
|
||||
</PhosphorText>
|
||||
<Box
|
||||
ref={transcriptRef}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.reading.fontSize,
|
||||
color: d3roPalette.text.primary,
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
{segments.map((seg) => (
|
||||
<Box key={seg.id} sx={{ mb: 0.5 }}>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ color: d3roPalette.text.inactive, fontSize: 11, mr: 1, fontFamily: d3roFontMono }}
|
||||
>
|
||||
[{formatTime(seg.timestamp)}]
|
||||
</Typography>
|
||||
<Typography component="span" sx={{ fontSize: 13, fontFamily: d3roFontMono }}>
|
||||
{seg.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</ScreenPanel>
|
||||
|
||||
{/* 우: 메모 입력 */}
|
||||
<Box sx={{ width: 300, display: 'flex', flexDirection: 'column' }}>
|
||||
<PhosphorText variant="dim" sx={{ mb: 1, fontSize: 11 }}>
|
||||
{t('meeting.memos')}
|
||||
</PhosphorText>
|
||||
<Box sx={{ flex: 1, overflow: 'auto', mb: 1 }}>
|
||||
{memos.map((memo) => (
|
||||
<Box
|
||||
key={memo.id}
|
||||
sx={{
|
||||
mb: 0.5,
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
}}
|
||||
>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: 10, fontFamily: d3roFontMono }}>
|
||||
{formatTime(memo.timestampMs)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="reading" sx={{ fontSize: 12 }}>
|
||||
{memo.content}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{!isProcessing && (
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder={t('meeting.memoPlaceholder')}
|
||||
value={memoInput}
|
||||
onChange={(e) => setMemoInput(e.target.value)}
|
||||
onKeyDown={handleMemoKeyDown}
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 렌더: 상세 뷰 ──
|
||||
if (view === 'detail' && detail) {
|
||||
const durationMin = detail.durationMs ? Math.round(detail.durationMs / 60000) : 0
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, overflow: 'auto' }}>
|
||||
{/* 헤더 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<IconButton size="small" onClick={() => { setDetail(null); setView('list') }}>
|
||||
<ArrowBackIcon sx={{ fontSize: 18 }} />
|
||||
</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 }}
|
||||
autoFocus
|
||||
/>
|
||||
<IconButton size="small" onClick={handleSaveTitle}>
|
||||
<CheckIcon sx={{ fontSize: 16 }} />
|
||||
</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: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 메타 */}
|
||||
<PhosphorText variant="dim" sx={{ mb: 3 }}>
|
||||
{formatDate(detail.startedAt)}
|
||||
{detail.endedAt ? ` ~ ${new Date(detail.endedAt).getHours()}:${String(new Date(detail.endedAt).getMinutes()).padStart(2, '0')}` : ''}
|
||||
{' · '}
|
||||
{t('meeting.duration', { minutes: durationMin })}
|
||||
</PhosphorText>
|
||||
|
||||
{/* 회의록 본문 */}
|
||||
{detail.minutes && (
|
||||
<>
|
||||
{/* 요약 */}
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.summary')}</PhosphorText>
|
||||
<PhosphorText variant="reading" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{detail.minutes.summary}
|
||||
</PhosphorText>
|
||||
</MetalCard>
|
||||
|
||||
{/* 결정사항 */}
|
||||
{detail.minutes.decisions.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.decisions')}</PhosphorText>
|
||||
{detail.minutes.decisions.map((d, i) => (
|
||||
<PhosphorText key={i} variant="reading" sx={{ mb: 0.5 }}>
|
||||
{'• '}{d}
|
||||
</PhosphorText>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 할 일 */}
|
||||
{detail.minutes.actionItems.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.actionItems')}</PhosphorText>
|
||||
{detail.minutes.actionItems.map((item, i) => (
|
||||
<PhosphorText key={i} variant="reading" sx={{ mb: 0.5 }}>
|
||||
{'☐ '}{item.assignee ? `${item.assignee}: ` : ''}{item.task}
|
||||
{item.deadline ? ` (${item.deadline})` : ''}
|
||||
</PhosphorText>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 타임라인 */}
|
||||
{detail.minutes.timeline.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.timeline')}</PhosphorText>
|
||||
{detail.minutes.timeline.map((tl, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 2, mb: 0.5 }}>
|
||||
<PhosphorText variant="dim" sx={{ fontFamily: d3roFontMono, minWidth: 50 }}>
|
||||
{tl.time}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="reading" sx={{ flex: 1 }}>
|
||||
{tl.type === 'memo' ? '📝 ' : ''}{tl.content}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{detail.status === 'error' && detail.errorMessage && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.indicator.red, mb: 1 }}>
|
||||
{t('meeting.error')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="reading">{detail.errorMessage}</PhosphorText>
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 메모 */}
|
||||
{detail.memos.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.memos')}</PhosphorText>
|
||||
{detail.memos.map((m) => (
|
||||
<Box key={m.id} sx={{ display: 'flex', gap: 2, mb: 0.5 }}>
|
||||
<PhosphorText variant="dim" sx={{ fontFamily: d3roFontMono, minWidth: 50 }}>
|
||||
{formatTime(m.timestampMs)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="reading">{m.content}</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 3 }}>
|
||||
<Tooltip title={t('meeting.exportPdf')}>
|
||||
<PhysicalButton size="small" onClick={handleExportPdf}>
|
||||
<PictureAsPdfIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.exportPdf')}
|
||||
</PhysicalButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('meeting.exportMarkdown')}>
|
||||
<PhysicalButton size="small" onClick={handleExportMarkdown}>
|
||||
<DescriptionIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.exportMarkdown')}
|
||||
</PhysicalButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('meeting.delete')}>
|
||||
<PhysicalButton size="small" color="error" onClick={handleDelete}>
|
||||
<DeleteIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.delete')}
|
||||
</PhysicalButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return <Box />
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue