Rewrites the desktop mirror as services/sync/SyncEngine: a persistent outbox, per-account server-clock keyset cursors with paging, pulls that never overwrite unsent local edits, deletions both ways through sync_tombstones and per-row failure isolation. It now covers history titles and favorites, dictionary, every meeting's memos and documents, memo tags, user commands and dictation/meeting templates, and registers the desktop as a device that the phone can disconnect. Fixes shipped defects: the first pull after sign-in fetched nothing, only the first meeting's children were pushed, team meetings leaked into the personal database and lost team_id on re-push, and Realtime never connected because Electron's Node 20 has no global WebSocket (ws is now the transport). Anonymous local-mode records are imported into the first account that signs in. The settings sync section is translated and shows pending/rejected changes; synced screens reload on app:dataChanged.
662 lines
24 KiB
TypeScript
662 lines
24 KiB
TypeScript
// src/renderer/components/meeting/MeetingDetailTabs.tsx
|
|
// Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad)
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|
import {
|
|
Box,
|
|
Tabs,
|
|
Tab,
|
|
IconButton,
|
|
TextField,
|
|
Tooltip,
|
|
Typography,
|
|
Checkbox,
|
|
} from '@mui/material'
|
|
import {
|
|
ArrowLeft,
|
|
Plus,
|
|
Pencil,
|
|
Check,
|
|
Users,
|
|
Copy,
|
|
Sparkles,
|
|
} from 'lucide-react'
|
|
import { TranscriptTab } from './TranscriptTab'
|
|
import { DocumentTab } from './DocumentTab'
|
|
import { AddDocumentDialog } from './AddDocumentDialog'
|
|
import { MeetingChatPanel } from './MeetingChatPanel'
|
|
import {
|
|
PhosphorText,
|
|
PhysicalButton,
|
|
SegmentControl,
|
|
TactileBadge,
|
|
MetalCard,
|
|
} from '@d3ro/ui/components/ds'
|
|
import { isImeComposingEvent } from '../../utils/keyboard'
|
|
import { d3roPalette, d3roFontSans, d3roRadius } from '@d3ro/ui/theme'
|
|
import { useI18n } from '@d3ro/i18n'
|
|
import type {
|
|
MeetingSessionDetail,
|
|
MeetingDocument,
|
|
MeetingDocTemplate,
|
|
MeetingExportFormat,
|
|
} from '@d3ro/core/types'
|
|
|
|
interface MeetingDetailTabsProps {
|
|
detail: MeetingSessionDetail
|
|
onBack: () => void
|
|
}
|
|
|
|
type ViewMode = 'split' | 'transcript' | 'doc'
|
|
|
|
// 세그먼트 파싱: rawTranscript를 줄 단위로 분리
|
|
function parseSegments(
|
|
rawTranscript: string | null,
|
|
editedTranscript: string | null,
|
|
): Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }> {
|
|
const source = editedTranscript || rawTranscript || ''
|
|
const regex = /^\[(\d{2}):(\d{2})\]\s*(?:\[([^\]]+)\]\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 speaker = m[3] || undefined
|
|
const text = m[4]
|
|
return {
|
|
id: `seg-${idx}`,
|
|
timestamp: (min * 60 + sec) * 1000,
|
|
text,
|
|
edited: false,
|
|
speaker,
|
|
}
|
|
}
|
|
return {
|
|
id: `seg-${idx}`,
|
|
timestamp: idx * 4000,
|
|
text: line.trim(),
|
|
edited: false,
|
|
}
|
|
})
|
|
}
|
|
|
|
// Extract Action items from documents
|
|
function extractActionItems(content: string): Array<{ id: string; text: string; done: boolean }> {
|
|
const lines = content.split('\n')
|
|
const items: Array<{ id: string; text: string; done: boolean }> = []
|
|
let idx = 0
|
|
for (const line of lines) {
|
|
const checkMatch = /^\s*[-*]\s*\[([ xX])\]\s*(.+)$/.exec(line)
|
|
if (checkMatch) {
|
|
items.push({
|
|
id: `todo-${idx++}`,
|
|
done: checkMatch[1].toLowerCase() === 'x',
|
|
text: checkMatch[2].trim(),
|
|
})
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
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 ?? []).filter(Boolean))
|
|
const [templates, setTemplates] = useState<MeetingDocTemplate[]>([])
|
|
|
|
// Tab key string state (e.g. `doc-${id}`, 'notepad', 'action-items')
|
|
const [activeTabKey, setActiveTabKey] = useState<string>(() => {
|
|
const docs = (initialDetail.documents ?? []).filter(Boolean)
|
|
return docs.length > 0 ? `doc-${docs[0].id}` : 'notepad'
|
|
})
|
|
|
|
const [dialogOpen, setDialogOpen] = useState(false)
|
|
const [generating, setGenerating] = useState(false)
|
|
const [diarizing, setDiarizing] = useState(false)
|
|
const [editingTitle, setEditingTitle] = useState(false)
|
|
const [titleDraft, setTitleDraft] = useState(detail.title ?? '')
|
|
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
|
|
|
// Granola-style Scratchpad & Action Items State
|
|
const [scratchpadNotes, setScratchpadNotes] = useState('')
|
|
const [actionItems, setActionItems] = useState<Array<{ id: string; text: string; done: boolean }>>([])
|
|
const [enhancingNotes, setEnhancingNotes] = useState(false)
|
|
|
|
// 세그먼트 파싱 (rawTranscript / editedTranscript 기반)
|
|
const segments = useMemo(
|
|
() => parseSegments(detail.rawTranscript, detail.editedTranscript),
|
|
[detail.rawTranscript, detail.editedTranscript],
|
|
)
|
|
|
|
// 템플릿 로드 (다른 기기에서 바뀐 템플릿이 반영되면 다시)
|
|
useEffect(() => {
|
|
const load = (): void => {
|
|
window.electronAPI.meetingDocTemplate.getAll().then((resp) => {
|
|
if (resp.success) setTemplates(resp.data)
|
|
})
|
|
}
|
|
load()
|
|
return window.electronAPI.app.onDataChanged((data) => {
|
|
if (data.type === 'cloud-sync' && data.entities?.includes('user_templates')) load()
|
|
})
|
|
}, [])
|
|
|
|
// Sync action items from primary document
|
|
useEffect(() => {
|
|
const primaryDoc = documents[0]
|
|
if (primaryDoc) {
|
|
const extracted = extractActionItems(primaryDoc.content)
|
|
if (extracted.length > 0) {
|
|
setActionItems(extracted)
|
|
}
|
|
}
|
|
}, [documents])
|
|
|
|
// If documents change, ensure activeTabKey is valid
|
|
useEffect(() => {
|
|
if (activeTabKey.startsWith('doc-')) {
|
|
const targetId = activeTabKey.replace('doc-', '')
|
|
const exists = documents.some((d) => d.id === targetId)
|
|
if (!exists && documents.length > 0) {
|
|
setActiveTabKey(`doc-${documents[0].id}`)
|
|
} else if (!exists) {
|
|
setActiveTabKey('notepad')
|
|
}
|
|
}
|
|
}, [documents, activeTabKey])
|
|
|
|
// ── 제목 저장 ──
|
|
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,
|
|
})
|
|
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(
|
|
async (docId: string, content: string) => {
|
|
await window.electronAPI.meetingMode.updateDocument({
|
|
documentId: docId,
|
|
content,
|
|
})
|
|
setDocuments((prev) =>
|
|
prev.map((d) => (d.id === docId ? { ...d, content } : d)),
|
|
)
|
|
},
|
|
[],
|
|
)
|
|
|
|
const isExportingRef = useRef(false)
|
|
const isDeletingDocRef = useRef(false)
|
|
|
|
// ── 문서 내보내기 ──
|
|
const handleExportDocument = useCallback(
|
|
async (docId: string, format: MeetingExportFormat) => {
|
|
if (isExportingRef.current) return
|
|
isExportingRef.current = true
|
|
try {
|
|
await window.electronAPI.meetingMode.exportDocument({
|
|
documentId: docId,
|
|
format,
|
|
})
|
|
} finally {
|
|
isExportingRef.current = false
|
|
}
|
|
},
|
|
[],
|
|
)
|
|
|
|
// ── 문서 삭제 ──
|
|
const handleDeleteDocument = useCallback(
|
|
async (docId: string) => {
|
|
if (isDeletingDocRef.current) return
|
|
isDeletingDocRef.current = true
|
|
try {
|
|
await window.electronAPI.meetingMode.deleteDocument({ documentId: docId })
|
|
setDocuments((prev) => {
|
|
const remaining = prev.filter((d) => d.id !== docId)
|
|
if (remaining.length > 0) {
|
|
setActiveTabKey(`doc-${remaining[0].id}`)
|
|
} else {
|
|
setActiveTabKey('notepad')
|
|
}
|
|
return remaining
|
|
})
|
|
} finally {
|
|
isDeletingDocRef.current = false
|
|
}
|
|
},
|
|
[],
|
|
)
|
|
|
|
// ── 화자 구분 ──
|
|
const handleDiarize = useCallback(async () => {
|
|
if (diarizing) return
|
|
setDiarizing(true)
|
|
try {
|
|
const resp = await window.electronAPI.meetingMode.diarize({ sessionId: detail.id })
|
|
if (resp.success) {
|
|
const updated = await window.electronAPI.meetingMode.getSession({ sessionId: detail.id })
|
|
if (updated.success) setDetail(updated.data)
|
|
}
|
|
} finally {
|
|
setDiarizing(false)
|
|
}
|
|
}, [detail.id, diarizing])
|
|
|
|
// ── Granola "Jot & Enhance" AI Note Enhancement ──
|
|
const handleEnhanceScratchpad = useCallback(async () => {
|
|
if (!scratchpadNotes.trim() || enhancingNotes) return
|
|
setEnhancingNotes(true)
|
|
try {
|
|
const prompt = `다음은 회의 중 작성한 메모와 전체 회의 전사 내용입니다. 사용자 메모의 맥락을 살려 체계적인 핵심 요약 및 결정 사항, 실행 과제로 깔끔하게 확장 정리해주세요:\n\n[사용자 메모]:\n${scratchpadNotes}\n\n[회의 전사]:\n${detail.editedTranscript || detail.rawTranscript}`
|
|
const resp = await window.electronAPI.llm.generate({ prompt })
|
|
if (resp.success && resp.data.text) {
|
|
const newDocResult = await window.electronAPI.meetingMode.generateDocument({
|
|
sessionId: detail.id,
|
|
templateId: 'custom',
|
|
customTitle: '📝 내 메모 기반 회의록 (AI 증강)',
|
|
customPrompt: prompt,
|
|
})
|
|
if (newDocResult.success) {
|
|
setDocuments((prev) => [...prev, newDocResult.data])
|
|
setActiveTabKey(`doc-${newDocResult.data.id}`)
|
|
}
|
|
}
|
|
} finally {
|
|
setEnhancingNotes(false)
|
|
}
|
|
}, [scratchpadNotes, detail.editedTranscript, detail.rawTranscript, detail.id, enhancingNotes])
|
|
|
|
// ── 새 문서 생성 다이얼로그 ──
|
|
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) {
|
|
setDocuments((prev) => [...prev, resp.data])
|
|
setActiveTabKey(`doc-${resp.data.id}`)
|
|
setDialogOpen(false)
|
|
}
|
|
},
|
|
[detail.id],
|
|
)
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
|
{/* ── 1. Header Toolbar ──────────────────────────────────────── */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
px: 2.5,
|
|
py: 1.25,
|
|
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
|
|
bgcolor: d3roPalette.bg.sidebar,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{/* Left: Back & Title */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0, flex: 1 }}>
|
|
<IconButton size="small" onClick={onBack} sx={{ color: d3roPalette.text.secondary }}>
|
|
<ArrowLeft size={18} />
|
|
</IconButton>
|
|
|
|
{editingTitle ? (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, maxWidth: 400 }}>
|
|
<TextField
|
|
size="small"
|
|
value={titleDraft}
|
|
onChange={(e) => setTitleDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (isImeComposingEvent(e)) return
|
|
if (e.key === 'Enter') handleSaveTitle()
|
|
}}
|
|
sx={{
|
|
flex: 1,
|
|
'& .MuiInputBase-root': { fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 600 },
|
|
}}
|
|
autoFocus
|
|
/>
|
|
<IconButton size="small" onClick={handleSaveTitle} sx={{ color: d3roPalette.accent.light }}>
|
|
<Check size={16} />
|
|
</IconButton>
|
|
</Box>
|
|
) : (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
|
<Typography
|
|
sx={{
|
|
fontFamily: d3roFontSans,
|
|
fontSize: '15px',
|
|
fontWeight: 500,
|
|
color: d3roPalette.text.primary,
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
}}
|
|
>
|
|
{detail.title || t('meeting.untitled')}
|
|
</Typography>
|
|
<Tooltip title={t('common.edit')}>
|
|
<IconButton size="small" onClick={() => setEditingTitle(true)} sx={{ color: d3roPalette.text.inactive, p: 0.5 }}>
|
|
<Pencil size={13} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Box>
|
|
)}
|
|
|
|
<TactileBadge mono tone="mono">
|
|
{new Date(detail.startedAt || Date.now()).toLocaleDateString()}
|
|
</TactileBadge>
|
|
</Box>
|
|
|
|
{/* Right: Viewport Mode Switcher (Split / Transcript / Doc) */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
<SegmentControl
|
|
options={[
|
|
{ value: 'split', label: 'Split ◫' },
|
|
{ value: 'transcript', label: 'Transcript ▤' },
|
|
{ value: 'doc', label: 'Minutes ▥' },
|
|
]}
|
|
value={viewMode}
|
|
onChange={(v) => setViewMode(v as ViewMode)}
|
|
size="small"
|
|
/>
|
|
|
|
<PhysicalButton
|
|
tone="accent"
|
|
size="small"
|
|
onClick={() => {
|
|
if (!dialogOpen && !generating) setDialogOpen(true)
|
|
}}
|
|
disabled={dialogOpen || generating}
|
|
trailingIcon={<Plus size={13} />}
|
|
>
|
|
{t('meeting.addDocument')}
|
|
</PhysicalButton>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* ── 2. Dual-Canvas Workspace (Split View) ─────────────────── */}
|
|
<Box sx={{ display: 'flex', flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
|
{/* Left Canvas: Interactive Transcript Timeline */}
|
|
{(viewMode === 'split' || viewMode === 'transcript') && (
|
|
<Box
|
|
sx={{
|
|
width: viewMode === 'split' ? '45%' : '100%',
|
|
height: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
borderRight: viewMode === 'split' ? `1px solid ${d3roPalette.glass.hairline}` : 'none',
|
|
bgcolor: d3roPalette.bg.app,
|
|
p: 2,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5, pb: 1, borderBottom: `1px solid ${d3roPalette.glass.hairline}` }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Users size={16} style={{ color: d3roPalette.accent.light }} />
|
|
<PhosphorText variant="heading" sx={{ fontSize: '13px', fontWeight: 500 }}>
|
|
SPEAKER TRANSCRIPT TIMELINE
|
|
</PhosphorText>
|
|
</Box>
|
|
<TactileBadge mono tone="mono">
|
|
{segments.length} UTTERANCES
|
|
</TactileBadge>
|
|
</Box>
|
|
|
|
<Box sx={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
|
<TranscriptTab
|
|
sessionId={detail.id}
|
|
segments={segments}
|
|
rawTranscript={detail.rawTranscript}
|
|
editedTranscript={detail.editedTranscript}
|
|
memos={detail.memos ?? []}
|
|
onEditSegment={handleEditSegment}
|
|
onSaveTranscript={handleSaveTranscript}
|
|
onDiarize={handleDiarize}
|
|
diarizing={diarizing}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Right Canvas: Structured AI Minutes & Granola Notepad */}
|
|
{(viewMode === 'split' || viewMode === 'doc') && (
|
|
<Box
|
|
sx={{
|
|
width: viewMode === 'split' ? '55%' : '100%',
|
|
height: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
bgcolor: d3roPalette.bg.sidebar,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{/* Right Sub-Tabs */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
px: 2,
|
|
pt: 1,
|
|
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
|
|
bgcolor: d3roPalette.bg.card,
|
|
}}
|
|
>
|
|
<Tabs
|
|
value={activeTabKey}
|
|
onChange={(_, v) => setActiveTabKey(v)}
|
|
sx={{
|
|
minHeight: 38,
|
|
'& .MuiTab-root': {
|
|
fontFamily: d3roFontSans,
|
|
fontSize: '12px',
|
|
fontWeight: 600,
|
|
minHeight: 38,
|
|
py: 0.5,
|
|
px: 1.75,
|
|
color: d3roPalette.text.secondary,
|
|
'&.Mui-selected': { color: d3roPalette.accent.light },
|
|
},
|
|
}}
|
|
>
|
|
{documents.map((doc, idx) => (
|
|
<Tab key={doc.id} value={`doc-${doc.id}`} label={doc.title || `Document ${idx + 1}`} />
|
|
))}
|
|
<Tab value="notepad" label="📝 Granola Notepad" />
|
|
<Tab value="action-items" label={`✅ Action Items (${actionItems.length})`} />
|
|
</Tabs>
|
|
</Box>
|
|
|
|
{/* Document Content View */}
|
|
<Box sx={{ flex: 1, p: 2.5, minHeight: 0, overflow: 'auto' }}>
|
|
{activeTabKey.startsWith('doc-') ? (
|
|
// AI Document Editor Tab
|
|
(() => {
|
|
const targetDocId = activeTabKey.replace('doc-', '')
|
|
const currentDoc = documents.find((d) => d.id === targetDocId) || documents[0]
|
|
return currentDoc ? (
|
|
<DocumentTab
|
|
document={currentDoc}
|
|
onContentChange={(c) => handleDocContentChange(currentDoc.id, c)}
|
|
onExport={(fmt) => handleExportDocument(currentDoc.id, fmt)}
|
|
onDelete={() => handleDeleteDocument(currentDoc.id)}
|
|
/>
|
|
) : (
|
|
<Box sx={{ py: 8, textAlign: 'center' }}>
|
|
<PhosphorText variant="dim">문서가 없습니다. 새 문서를 생성해주세요.</PhosphorText>
|
|
</Box>
|
|
)
|
|
})()
|
|
) : activeTabKey === 'notepad' ? (
|
|
// Granola-style Notepad Tab
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 2 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Box>
|
|
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 500, color: d3roPalette.text.primary }}>
|
|
Granola-style Hybrid Notepad
|
|
</Typography>
|
|
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', color: d3roPalette.text.secondary }}>
|
|
회의 중 메모를 자유롭게 적어보세요. AI가 전체 발화 내용과 결합하여 완벽한 회의록으로 증강해 드립니다.
|
|
</Typography>
|
|
</Box>
|
|
<PhysicalButton
|
|
tone="accent"
|
|
size="small"
|
|
onClick={handleEnhanceScratchpad}
|
|
disabled={!scratchpadNotes.trim() || enhancingNotes}
|
|
>
|
|
<Sparkles size={14} style={{ marginRight: 4 }} />
|
|
{enhancingNotes ? '증강 중...' : '⚡ AI로 메모 증강하기'}
|
|
</PhysicalButton>
|
|
</Box>
|
|
|
|
<TextField
|
|
multiline
|
|
rows={14}
|
|
value={scratchpadNotes}
|
|
onChange={(e) => setScratchpadNotes(e.target.value)}
|
|
placeholder="- 논의된 주요 이슈 요약 - 고객사 피드백 핵심 포인트 - 내가 해야 할 일 체크리스트..."
|
|
fullWidth
|
|
sx={{
|
|
flex: 1,
|
|
'& .MuiOutlinedInput-root': {
|
|
fontFamily: d3roFontSans,
|
|
fontSize: '13px',
|
|
lineHeight: 1.7,
|
|
bgcolor: d3roPalette.bg.inset,
|
|
borderRadius: d3roRadius.inner,
|
|
p: 2,
|
|
},
|
|
}}
|
|
/>
|
|
</Box>
|
|
) : (
|
|
// Interactive Action Items Checklist Tab
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 2 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 500, color: d3roPalette.text.primary }}>
|
|
Interactive Action Items Checklist
|
|
</Typography>
|
|
<PhysicalButton
|
|
tone="glass"
|
|
size="small"
|
|
onClick={() => {
|
|
const formatted = actionItems.map((a) => `- [${a.done ? 'x' : ' '}] ${a.text}`).join('\n')
|
|
navigator.clipboard.writeText(formatted)
|
|
}}
|
|
>
|
|
<Copy size={13} style={{ marginRight: 4 }} />
|
|
Copy for Slack / Notion
|
|
</PhysicalButton>
|
|
</Box>
|
|
|
|
{actionItems.length === 0 ? (
|
|
<Box sx={{ py: 8, textAlign: 'center' }}>
|
|
<PhosphorText variant="dim">문서에서 추출된 액션 아이템이 없습니다.</PhosphorText>
|
|
</Box>
|
|
) : (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
{actionItems.map((item) => (
|
|
<MetalCard
|
|
key={item.id}
|
|
inset
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1.5,
|
|
p: 1.5,
|
|
bgcolor: item.done ? d3roPalette.bg.inset : d3roPalette.bg.card,
|
|
opacity: item.done ? 0.6 : 1,
|
|
}}
|
|
>
|
|
<Checkbox
|
|
checked={item.done}
|
|
onChange={(e) => {
|
|
setActionItems((prev) =>
|
|
prev.map((a) => (a.id === item.id ? { ...a, done: e.target.checked } : a)),
|
|
)
|
|
}}
|
|
size="small"
|
|
sx={{ color: d3roPalette.accent.light, p: 0.5 }}
|
|
/>
|
|
<Typography
|
|
sx={{
|
|
fontFamily: d3roFontSans,
|
|
fontSize: '13px',
|
|
color: d3roPalette.text.primary,
|
|
textDecoration: item.done ? 'line-through' : 'none',
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{item.text}
|
|
</Typography>
|
|
</MetalCard>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{/* ── 3. Bottom Meeting Copilot Chat Panel ──────────────────── */}
|
|
<MeetingChatPanel sessionId={detail.id} />
|
|
|
|
{/* Add Document Modal */}
|
|
<AddDocumentDialog
|
|
open={dialogOpen}
|
|
onClose={() => setDialogOpen(false)}
|
|
templates={templates}
|
|
generating={generating}
|
|
onGenerate={handleGenerate}
|
|
/>
|
|
</Box>
|
|
)
|
|
}
|