Phase 14.5 구현: 회의 모드 고도화 — 다중 문서 생성/편집/내보내기

PLAUD 수준의 기능 확장:
- 전사 편집: 인라인 편집 + 원본 보존 + 수정됨 표시(점선 밑줄)
- 다중 문서 생성: 회의록/보고서/아이디어노트/커스텀 템플릿
- MD 에디터: 렌더링/편집 토글 (react-markdown + remark-gfm)
- 상세 페이지 리디자인: 풀스크린 탭 전환 구조
- 내보내기: MD, PDF, TXT, DOCX 4종 (docx 패키지)
- 커스텀 프롬프트 템플릿 저장/재사용 (electron-store)
- SSOT: 마크다운 파싱 유틸 추출, 후처리에서 자동 문서 생성 제거

DB: meeting_documents 테이블 + edited_transcript 컬럼
IPC: 8+4 채널, 서비스 2개(MeetingModeService 확장 + MeetingDocTemplateService 신규)
UI: 8개 신규 컴포넌트 (meeting/ 디렉토리), 12개 locale i18n
This commit is contained in:
Yun Chan 2026-04-08 09:53:54 +09:00
parent 6197ceb132
commit d4928ffa60
38 changed files with 4791 additions and 438 deletions

View file

@ -4,22 +4,15 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Box,
TextField,
IconButton,
Tooltip,
LinearProgress,
Typography,
TextField,
} 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 { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs'
import { EditableSegment } from '../components/meeting/EditableSegment'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { d3roPalette, d3roFontMono, d3roTypo } from '../theme'
import { useI18n } from '../i18n'
@ -57,8 +50,6 @@ export function MeetingModePage(): React.ReactElement {
// 상세 뷰 상태
const [detail, setDetail] = useState<MeetingSessionDetail | null>(null)
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState('')
// ── 세션 목록 로드 ──
const loadSessions = useCallback(async () => {
@ -183,32 +174,6 @@ export function MeetingModePage(): React.ReactElement {
}
}, [])
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 => {
@ -355,17 +320,14 @@ export function MeetingModePage(): React.ReactElement {
}}
>
{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 - (recordingStartRef.current || seg.timestamp))}]
</Typography>
<Typography component="span" sx={{ fontSize: 13, fontFamily: d3roFontMono }}>
{seg.text}
</Typography>
</Box>
<EditableSegment
key={seg.id}
segmentId={seg.id}
timestamp={Math.max(0, seg.timestamp - (recordingStartRef.current || seg.timestamp))}
text={seg.text}
edited={false}
onEdit={() => { /* 녹음 중 편집 비활성 */ }}
/>
))}
</Box>
</ScreenPanel>
@ -445,156 +407,11 @@ export function MeetingModePage(): React.ReactElement {
// ── 렌더: 상세 뷰 ──
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="body" 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="body" 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="body" 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="body" 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.tag.red, mb: 1 }}>
{t('meeting.error')}
</PhosphorText>
<PhosphorText variant="body">{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="body">{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>
<MeetingDetailTabs
detail={detail}
onBack={() => { setDetail(null); setView('list') }}
/>
)
}