실시간 녹음 + 타임스탬프 메모 + STT 전사 + LLM 구조화 회의록 + PDF/MD 내보내기. CaptionService 연동, 동시 사용 충돌 방지, 긴 회의 2-pass 요약 지원. DB 2테이블, IPC 15채널, UI 3뷰(목록/녹음/상세), 12개 locale i18n.
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
// src/renderer/components/TemplateSection.tsx
|
|
// Phase 12.3: 딕테이션 템플릿 관리 UI (CommandsPage 내 섹션)
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Tooltip } from '@mui/material'
|
|
import AddIcon from '@mui/icons-material/Add'
|
|
import DeleteIcon from '@mui/icons-material/Delete'
|
|
import EditIcon from '@mui/icons-material/Edit'
|
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow'
|
|
import { MetalCard, PhosphorText, Led, PhysicalButton } from './ds'
|
|
import { PageHeader, EmptyStateCard } from './shared'
|
|
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../theme'
|
|
import { useI18n } from '../i18n'
|
|
import type { DictationTemplate, TemplateField, TemplateSessionInfo } from '@shared/types'
|
|
|
|
export function TemplateSection(): React.ReactElement {
|
|
const { t } = useI18n()
|
|
const [templates, setTemplates] = useState<DictationTemplate[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [dialogOpen, setDialogOpen] = useState(false)
|
|
const [editId, setEditId] = useState<string | null>(null)
|
|
const [formName, setFormName] = useState('')
|
|
const [formDesc, setFormDesc] = useState('')
|
|
const [formOutput, setFormOutput] = useState('')
|
|
const [formFields, setFormFields] = useState<TemplateField[]>([])
|
|
const [session, setSession] = useState<TemplateSessionInfo | null>(null)
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true)
|
|
const result = await window.electronAPI.dictationTemplate.getAll()
|
|
if (result.success) setTemplates(result.data)
|
|
const sessionResult = await window.electronAPI.dictationTemplate.getSessionState()
|
|
if (sessionResult.success) setSession(sessionResult.data)
|
|
setLoading(false)
|
|
}, [])
|
|
|
|
useEffect(() => { loadData() }, [loadData])
|
|
|
|
useEffect(() => {
|
|
const unsub = window.electronAPI.dictationTemplate.onSessionStateChanged((data) => {
|
|
setSession(data)
|
|
})
|
|
const unsubComplete = window.electronAPI.dictationTemplate.onSessionCompleted(() => {
|
|
setSession(null)
|
|
loadData()
|
|
})
|
|
return () => { unsub(); unsubComplete() }
|
|
}, [loadData])
|
|
|
|
const openCreate = () => {
|
|
setEditId(null)
|
|
setFormName('')
|
|
setFormDesc('')
|
|
setFormOutput('{{field1}}')
|
|
setFormFields([{ id: 'field1', name: 'field1', label: 'Field 1', promptText: '', required: true, maxDurationSec: 30 }])
|
|
setDialogOpen(true)
|
|
}
|
|
|
|
const openEdit = (template: DictationTemplate) => {
|
|
setEditId(template.id)
|
|
setFormName(template.name)
|
|
setFormDesc(template.description)
|
|
setFormOutput(template.outputFormat)
|
|
setFormFields([...template.fields])
|
|
setDialogOpen(true)
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
if (editId) {
|
|
await window.electronAPI.dictationTemplate.update({
|
|
id: editId,
|
|
name: formName.trim(),
|
|
description: formDesc.trim(),
|
|
fields: formFields,
|
|
outputFormat: formOutput,
|
|
})
|
|
} else {
|
|
await window.electronAPI.dictationTemplate.create({
|
|
name: formName.trim(),
|
|
description: formDesc.trim(),
|
|
fields: formFields,
|
|
outputFormat: formOutput,
|
|
})
|
|
}
|
|
setDialogOpen(false)
|
|
loadData()
|
|
}
|
|
|
|
const handleDelete = async (id: string) => {
|
|
await window.electronAPI.dictationTemplate.delete({ id })
|
|
loadData()
|
|
}
|
|
|
|
const handleStartSession = async (templateId: string) => {
|
|
await window.electronAPI.dictationTemplate.startSession({ templateId })
|
|
}
|
|
|
|
const handleCancelSession = async () => {
|
|
await window.electronAPI.dictationTemplate.cancelSession()
|
|
setSession(null)
|
|
}
|
|
|
|
const addField = () => {
|
|
const idx = formFields.length + 1
|
|
setFormFields([...formFields, {
|
|
id: `field${idx}`,
|
|
name: `field${idx}`,
|
|
label: `Field ${idx}`,
|
|
promptText: '',
|
|
required: true,
|
|
maxDurationSec: 30,
|
|
}])
|
|
}
|
|
|
|
const updateField = (index: number, updates: Partial<TemplateField>) => {
|
|
const updated = [...formFields]
|
|
updated[index] = { ...updated[index], ...updates }
|
|
setFormFields(updated)
|
|
}
|
|
|
|
const removeField = (index: number) => {
|
|
setFormFields(formFields.filter((_, i) => i !== index))
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ mt: 5 }}>
|
|
<PageHeader
|
|
title={t('template.title').toUpperCase()}
|
|
action={
|
|
<PhysicalButton size="small" onClick={openCreate}>
|
|
<AddIcon sx={{ fontSize: 14, mr: 0.5 }} /> {t('template.create')}
|
|
</PhysicalButton>
|
|
}
|
|
/>
|
|
|
|
{/* 활성 세션 표시 */}
|
|
{session && (
|
|
<MetalCard sx={{ mb: 2, border: `1px solid ${d3roPalette.accent.amber}` }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Led color="amber" pulse />
|
|
<PhosphorText variant="body">
|
|
{session.templateName}: {session.currentField?.label ?? '...'}
|
|
</PhosphorText>
|
|
<PhosphorText variant="dim">
|
|
({session.currentFieldIndex + 1}/{session.totalFields})
|
|
</PhosphorText>
|
|
</Box>
|
|
<PhysicalButton size="small" onClick={handleCancelSession}>
|
|
{t('template.cancelSession')}
|
|
</PhysicalButton>
|
|
</Box>
|
|
</MetalCard>
|
|
)}
|
|
|
|
{/* 템플릿 목록 */}
|
|
{templates.length === 0 && !loading ? (
|
|
<EmptyStateCard message={t('template.empty')} />
|
|
) : (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
{templates.map((tmpl) => (
|
|
<MetalCard key={tmpl.id}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
<Box sx={{ flex: 1 }}>
|
|
<PhosphorText variant="body" sx={{ fontWeight: d3roTypo.body.weight }}>
|
|
{tmpl.name}
|
|
{tmpl.isBuiltin && (
|
|
<Box component="span" sx={{ ml: 1, fontSize: d3roTypo.micro.size, color: d3roPalette.text.dimLabel }}>
|
|
PRESET
|
|
</Box>
|
|
)}
|
|
</PhosphorText>
|
|
<PhosphorText variant="dim" sx={{ mt: 0.25 }}>
|
|
{tmpl.fields.length} {t('template.fields').toLowerCase()} — {tmpl.description}
|
|
</PhosphorText>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
|
<Tooltip title={t('template.startSession')}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => handleStartSession(tmpl.id)}
|
|
disabled={!!session}
|
|
sx={{ color: d3roPalette.accent.amber, '&:hover': { opacity: 0.8 } }}
|
|
>
|
|
<PlayArrowIcon sx={{ fontSize: 18 }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => openEdit(tmpl)}
|
|
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
|
|
>
|
|
<EditIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
{!tmpl.isBuiltin && (
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => handleDelete(tmpl.id)}
|
|
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}
|
|
>
|
|
<DeleteIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</MetalCard>
|
|
))}
|
|
</Box>
|
|
)}
|
|
|
|
{/* 편집 다이얼로그 */}
|
|
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
|
<DialogTitle>{editId ? t('template.edit') : t('template.create')}</DialogTitle>
|
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '8px !important' }}>
|
|
<TextField
|
|
label={t('template.name')}
|
|
value={formName}
|
|
onChange={(e) => setFormName(e.target.value)}
|
|
size="small"
|
|
fullWidth
|
|
/>
|
|
<TextField
|
|
label={t('template.description')}
|
|
value={formDesc}
|
|
onChange={(e) => setFormDesc(e.target.value)}
|
|
size="small"
|
|
fullWidth
|
|
/>
|
|
|
|
<PhosphorText variant="label" sx={{ mt: 1, color: d3roPalette.text.dimLabel }}>
|
|
{t('template.fields').toUpperCase()}
|
|
</PhosphorText>
|
|
|
|
{formFields.map((field, idx) => (
|
|
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
|
<TextField
|
|
label={t('template.fieldName')}
|
|
value={field.name}
|
|
onChange={(e) => updateField(idx, { name: e.target.value, id: e.target.value })}
|
|
size="small"
|
|
sx={{ flex: 1 }}
|
|
/>
|
|
<TextField
|
|
label={t('template.fieldLabel')}
|
|
value={field.label}
|
|
onChange={(e) => updateField(idx, { label: e.target.value })}
|
|
size="small"
|
|
sx={{ flex: 1 }}
|
|
/>
|
|
<TextField
|
|
label={t('template.fieldPrompt')}
|
|
value={field.promptText}
|
|
onChange={(e) => updateField(idx, { promptText: e.target.value })}
|
|
size="small"
|
|
sx={{ flex: 2 }}
|
|
/>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => removeField(idx)}
|
|
disabled={formFields.length <= 1}
|
|
sx={{ color: d3roPalette.text.inactive }}
|
|
>
|
|
<DeleteIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</Box>
|
|
))}
|
|
|
|
<Button onClick={addField} startIcon={<AddIcon />} size="small" sx={{ alignSelf: 'flex-start' }}>
|
|
{t('template.addField')}
|
|
</Button>
|
|
|
|
<TextField
|
|
label={t('template.outputFormat')}
|
|
value={formOutput}
|
|
onChange={(e) => setFormOutput(e.target.value)}
|
|
size="small"
|
|
fullWidth
|
|
multiline
|
|
rows={3}
|
|
helperText={t('template.outputHelperText')}
|
|
/>
|
|
</DialogContent>
|
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
|
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button onClick={handleSave} variant="contained" disabled={!formName.trim() || formFields.length === 0}>
|
|
{t('common.save')}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
)
|
|
}
|