Spaces:
Running
Running
| /** | |
| * @license | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import React from 'react'; | |
| import { NetworkType, StrengthOption, Question } from '../../types'; | |
| import { defaultsService, SystemDefaults } from '../../services/defaultsService'; | |
| import { Tabs } from '../common/Tabs'; | |
| import { Button } from '../common/Button'; | |
| import { Input } from '../common/Input'; | |
| import { TextArea } from '../common/TextArea'; | |
| import { PjtBodyCard } from '../pjt/PjtBodyCard'; | |
| import { QuestionRow } from '../pjt/QuestionRow'; | |
| import { Modal } from '../common/Modal'; | |
| import { | |
| Plus, | |
| Save, | |
| Trash2, | |
| ChevronUp, | |
| ChevronDown, | |
| RotateCcw, | |
| ArrowLeft, | |
| } from 'lucide-react'; | |
| import { | |
| Table, | |
| TableHeader, | |
| TableBody, | |
| TableRow, | |
| TableHead, | |
| TableCell, | |
| } from '../common/Table'; | |
| interface OpsDefaultsProps { | |
| onNavigateHome: () => void; | |
| } | |
| export const OpsDefaults: React.FC<OpsDefaultsProps> = ({ onNavigateHome }) => { | |
| const [activeTab, setActiveTab] = React.useState<'questions' | 'network_types' | 'strength_scales' | 'notice_template'>('questions'); | |
| const [defaults, setDefaults] = React.useState<SystemDefaults | null>(null); | |
| // Modal alert/confirm configuration state | |
| const [modalConfig, setModalConfig] = React.useState<{ | |
| isOpen: boolean; | |
| title: string; | |
| message: string; | |
| onConfirm?: () => void; | |
| showCancel?: boolean; | |
| }>({ | |
| isOpen: false, | |
| title: '', | |
| message: '', | |
| }); | |
| const showAlert = (title: string, message: string) => { | |
| setModalConfig({ | |
| isOpen: true, | |
| title, | |
| message, | |
| showCancel: false, | |
| }); | |
| }; | |
| const showConfirm = (title: string, message: string, onConfirm: () => void) => { | |
| setModalConfig({ | |
| isOpen: true, | |
| title, | |
| message, | |
| onConfirm, | |
| showCancel: true, | |
| }); | |
| }; | |
| // Load defaults on mount | |
| React.useEffect(() => { | |
| defaultsService.getDefaults().then((data) => { | |
| setDefaults(data); | |
| }); | |
| }, []); | |
| // 1. Question states & actions | |
| const [newQuestionText, setNewQuestionText] = React.useState(''); | |
| const [newNetworkType, setNewNetworkType] = React.useState('work'); | |
| const [newMaxSelections, setNewMaxSelections] = React.useState(5); | |
| const handleUpdateQuestion = (qId: string, updates: Partial<Question>) => { | |
| if (!defaults) return; | |
| const updatedQs = defaults.QUESTIONS.map((q) => | |
| q.question_id === qId ? { ...q, ...updates } : q | |
| ); | |
| setDefaults({ ...defaults, QUESTIONS: updatedQs }); | |
| }; | |
| const handleMoveQuestion = (index: number, direction: 'up' | 'down') => { | |
| if (!defaults) return; | |
| const targetIdx = direction === 'up' ? index - 1 : index + 1; | |
| if (targetIdx < 0 || targetIdx >= defaults.QUESTIONS.length) return; | |
| const copy = [...defaults.QUESTIONS]; | |
| const temp = copy[index]; | |
| copy[index] = copy[targetIdx]; | |
| copy[targetIdx] = temp; | |
| const remapped = copy.map((q, idx) => ({ ...q, order_no: idx + 1 })); | |
| setDefaults({ ...defaults, QUESTIONS: remapped }); | |
| }; | |
| const handleDeleteQuestion = (qId: string) => { | |
| if (!defaults) return; | |
| showConfirm( | |
| '기본 질문 삭제', | |
| '이 질문 문항을 기본 템플릿에서 삭제하시겠습니까? 이후 생성되는 프로젝트의 기본 문항 목록에 영향을 줍니다.', | |
| () => { | |
| const updatedQs = defaults.QUESTIONS.filter((q) => q.question_id !== qId); | |
| const remapped = updatedQs.map((q, idx) => ({ ...q, order_no: idx + 1 })); | |
| setDefaults({ ...defaults, QUESTIONS: remapped }); | |
| } | |
| ); | |
| }; | |
| const handleAddQuestion = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (!defaults || !newQuestionText.trim()) return; | |
| const newQ: Question = { | |
| question_id: `Q${defaults.QUESTIONS.length + 1}_${Date.now().toString().slice(-4)}`, | |
| network_type: newNetworkType, | |
| question_text: newQuestionText.trim(), | |
| max_selections: Number(newMaxSelections), | |
| is_required: true, | |
| order_no: defaults.QUESTIONS.length + 1, | |
| strength_options: defaults.STRENGTH_OPTIONS, // Use template strength options | |
| }; | |
| setDefaults({ | |
| ...defaults, | |
| QUESTIONS: [...defaults.QUESTIONS, newQ], | |
| }); | |
| setNewQuestionText(''); | |
| showAlert('알림', '기본 질문 문항이 추가되었습니다. 저장하기를 누르시면 최종 저장됩니다.'); | |
| }; | |
| // 2. Network type states & actions | |
| const [newTypeName, setNewTypeName] = React.useState(''); | |
| const [newTypeEnglish, setNewTypeEnglish] = React.useState(''); | |
| const handleUpdateNetworkType = (index: number, field: 'value' | 'label', val: string) => { | |
| if (!defaults) return; | |
| const updated = [...defaults.NETWORK_TYPES]; | |
| updated[index] = { | |
| ...updated[index], | |
| [field]: field === 'value' ? val.trim().toLowerCase().replace(/\s+/g, '_') : val, | |
| }; | |
| setDefaults({ ...defaults, NETWORK_TYPES: updated }); | |
| }; | |
| const handleMoveNetworkType = (index: number, direction: 'up' | 'down') => { | |
| if (!defaults) return; | |
| const targetIdx = direction === 'up' ? index - 1 : index + 1; | |
| if (targetIdx < 0 || targetIdx >= defaults.NETWORK_TYPES.length) return; | |
| const copy = [...defaults.NETWORK_TYPES]; | |
| const temp = copy[index]; | |
| copy[index] = copy[targetIdx]; | |
| copy[targetIdx] = temp; | |
| setDefaults({ ...defaults, NETWORK_TYPES: copy }); | |
| }; | |
| const handleDeleteNetworkType = (code: string) => { | |
| if (!defaults) return; | |
| showConfirm( | |
| '기본 네트워크 분류 삭제', | |
| '이 네트워크 분류를 기본 템플릿에서 삭제하시겠습니까?', | |
| () => { | |
| const updated = defaults.NETWORK_TYPES.filter((t) => t.value !== code); | |
| setDefaults({ ...defaults, NETWORK_TYPES: updated }); | |
| } | |
| ); | |
| }; | |
| const handleAddNetworkType = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (!defaults) return; | |
| const english = newTypeEnglish.trim().toLowerCase().replace(/\s+/g, '_'); | |
| const name = newTypeName.trim(); | |
| if (!english || !name) return; | |
| if (defaults.NETWORK_TYPES.some((t) => t.value === english)) { | |
| showAlert('알림', '이미 존재하는 영문 분류 코드입니다.'); | |
| return; | |
| } | |
| setDefaults({ | |
| ...defaults, | |
| NETWORK_TYPES: [...defaults.NETWORK_TYPES, { value: english, label: name }], | |
| }); | |
| setNewTypeEnglish(''); | |
| setNewTypeName(''); | |
| showAlert('알림', '새로운 네트워크 분류가 임시 추가되었습니다. 저장하기를 누르시면 최종 저장됩니다.'); | |
| }; | |
| // 3. Strength Option states & actions | |
| const [newStrengthLabel, setNewStrengthLabel] = React.useState(''); | |
| const handleUpdateStrengthLabel = (index: number, label: string) => { | |
| if (!defaults) return; | |
| const updated = [...defaults.STRENGTH_OPTIONS]; | |
| updated[index] = { ...updated[index], label }; | |
| setDefaults({ ...defaults, STRENGTH_OPTIONS: updated }); | |
| }; | |
| const handleMoveStrength = (index: number, direction: 'up' | 'down') => { | |
| if (!defaults) return; | |
| const targetIdx = direction === 'up' ? index - 1 : index + 1; | |
| if (targetIdx < 0 || targetIdx >= defaults.STRENGTH_OPTIONS.length) return; | |
| const copy = [...defaults.STRENGTH_OPTIONS]; | |
| const temp = copy[index]; | |
| copy[index] = copy[targetIdx]; | |
| copy[targetIdx] = temp; | |
| const updated = copy.map((opt, idx) => ({ | |
| ...opt, | |
| value: String(idx + 1), | |
| })); | |
| setDefaults({ ...defaults, STRENGTH_OPTIONS: updated }); | |
| }; | |
| const handleDeleteStrength = (value: string) => { | |
| if (!defaults) return; | |
| showConfirm( | |
| '기본 관계 척도 삭제', | |
| '이 관계 빈도 척도를 기본 템플릿에서 삭제하시겠습니까?', | |
| () => { | |
| const filtered = defaults.STRENGTH_OPTIONS.filter((opt) => opt.value !== value); | |
| const updated = filtered.map((opt, idx) => ({ | |
| ...opt, | |
| value: String(idx + 1), | |
| })); | |
| setDefaults({ ...defaults, STRENGTH_OPTIONS: updated }); | |
| } | |
| ); | |
| }; | |
| const handleAddStrength = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (!defaults || !newStrengthLabel.trim()) return; | |
| const nextIndex = defaults.STRENGTH_OPTIONS.length + 1; | |
| const val = String(nextIndex); | |
| const lbl = newStrengthLabel.trim(); | |
| setDefaults({ | |
| ...defaults, | |
| STRENGTH_OPTIONS: [...defaults.STRENGTH_OPTIONS, { value: val, label: lbl }], | |
| }); | |
| setNewStrengthLabel(''); | |
| showAlert('알림', '새로운 관계 빈도 척도가 임시 추가되었습니다. 저장하기를 누르시면 최종 저장됩니다.'); | |
| }; | |
| // 4. Notice template actions | |
| const handleUpdateNoticeTemplate = (text: string) => { | |
| if (!defaults) return; | |
| setDefaults({ ...defaults, NOTICE_TEMPLATE: text }); | |
| }; | |
| // Save / Factory Reset | |
| const handleSaveAll = () => { | |
| if (!defaults) return; | |
| defaultsService.saveDefaults(defaults) | |
| .then(() => { | |
| showAlert('성공', '시스템 초기값 설정이 성공적으로 저장되었습니다. 이후 생성되는 프로젝트에 자동 적용됩니다.'); | |
| }) | |
| .catch((err) => { | |
| console.error(err); | |
| showAlert('오류', '시스템 초기값 설정 저장 중 오류가 발생했습니다.'); | |
| }); | |
| }; | |
| const handleFactoryReset = () => { | |
| showConfirm( | |
| '시스템 초기값 완전 초기화', | |
| '모든 설정을 공장 출시 시점의 defaults.json 파일 내용으로 초기화하시겠습니까? 기존 커스텀 초기 설정은 모두 삭제됩니다.', | |
| () => { | |
| defaultsService.resetDefaults() | |
| .then((initial) => { | |
| setDefaults(initial); | |
| showAlert('알림', '모든 초기값 설정이 공장 출시 상태로 초기화되었습니다.'); | |
| }) | |
| .catch((err) => { | |
| console.error(err); | |
| showAlert('오류', '시스템 초기값 초기화 중 오류가 발생했습니다.'); | |
| }); | |
| } | |
| ); | |
| }; | |
| if (!defaults) { | |
| return ( | |
| <div className="flex items-center justify-center p-12 text-sm font-semibold text-slate-500"> | |
| 설정 정보 로딩 중... | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="space-y-6 font-sans antialiased" id="ops-defaults-view"> | |
| {/* Top Header Controls */} | |
| <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 pb-2"> | |
| <div> | |
| <button | |
| onClick={onNavigateHome} | |
| className="flex items-center gap-1.5 text-sm font-semibold text-slate-500 hover:text-slate-800 transition-colors border-0 bg-transparent cursor-pointer p-0 mb-1" | |
| > | |
| <ArrowLeft className="w-4 h-4" /> | |
| <span>홈으로 이동</span> | |
| </button> | |
| <h2 className="text-xl md:text-2xl font-bold text-slate-900 tracking-tight">시스템 초기값 설정 관리</h2> | |
| <p className="text-sm text-slate-400 font-medium"> | |
| 새 프로젝트 생성 시 자동으로 적용되는 기본 문항 및 템플릿을 설정합니다. | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-2 w-full md:w-auto"> | |
| <Button | |
| onClick={handleFactoryReset} | |
| variant="outline" | |
| size="md" | |
| leftIcon={<RotateCcw className="w-4 h-4" />} | |
| id="factory-reset-defaults-btn" | |
| className="flex-1 md:flex-initial" | |
| > | |
| 초기 기본값 리셋 | |
| </Button> | |
| <Button | |
| onClick={handleSaveAll} | |
| size="md" | |
| leftIcon={<Save className="w-4 h-4" />} | |
| id="save-defaults-btn" | |
| className="flex-1 md:flex-initial" | |
| > | |
| 전체 저장하기 | |
| </Button> | |
| </div> | |
| </div> | |
| {/* Tabs */} | |
| <Tabs | |
| options={[ | |
| { key: 'network_types', label: '네트워크 분류', mobileLabel: '분류' }, | |
| { key: 'questions', label: '기본 질문', mobileLabel: '질문' }, | |
| { key: 'strength_scales', label: '기본 척도', mobileLabel: '척도' }, | |
| { key: 'notice_template', label: '안내문 템플릿', mobileLabel: '안내문' }, | |
| ]} | |
| activeKey={activeTab} | |
| onChange={(key) => setActiveTab(key as any)} | |
| /> | |
| {/* Pane content matching active tab */} | |
| <div className="grid grid-cols-1 gap-6" id="defaults-settings-pane"> | |
| {/* 1. Questions Tab */} | |
| {activeTab === 'questions' && ( | |
| <div className="space-y-6"> | |
| <PjtBodyCard | |
| title="기본 질문 목록" | |
| info="새 프로젝트 생성 시 적용될 기본 질문 목록입니다." | |
| > | |
| <div className="border-0 md:border md:border-slate-100 md:rounded-lg overflow-x-auto"> | |
| <Table id="defaults-questions-table" className="w-full block md:table [&_td]:py-2"> | |
| <TableHeader className="hidden md:table-header-group"> | |
| <TableRow className="bg-slate-50 border-b border-slate-100 text-slate-400 text-sm font-bold uppercase tracking-tight"> | |
| <TableHead className="p-2 text-center">순서</TableHead> | |
| <TableHead className="p-2 text-center min-w-30">유형</TableHead> | |
| <TableHead className="p-2 text-center min-w-100">질문 문구</TableHead> | |
| <TableHead className="p-2 text-center">최대 지목 인원</TableHead> | |
| <TableHead className="p-2 text-center">정렬</TableHead> | |
| <TableHead className="p-2 text-center">작업</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody className="block md:table-row-group space-y-3 md:space-y-0"> | |
| {defaults.QUESTIONS.map((question, idx) => ( | |
| <QuestionRow | |
| key={question.question_id} | |
| question={question} | |
| index={idx} | |
| isFirst={idx === 0} | |
| isLast={idx === defaults.QUESTIONS.length - 1} | |
| onMove={(direction) => handleMoveQuestion(idx, direction)} | |
| onDelete={() => handleDeleteQuestion(question.question_id)} | |
| onUpdateQuestion={handleUpdateQuestion} | |
| networkTypes={defaults.NETWORK_TYPES} | |
| /> | |
| ))} | |
| {defaults.QUESTIONS.length === 0 && ( | |
| <TableRow> | |
| <TableCell colSpan={6} className="p-6 text-center text-slate-400 block md:table-cell"> | |
| 등록된 기본 질문이 없습니다. 아래에서 새로 추가해주세요. | |
| </TableCell> | |
| </TableRow> | |
| )} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| </PjtBodyCard> | |
| <PjtBodyCard | |
| title="새로운 기본 질문 추가" | |
| info="템플릿에 등록할 기본 네트워크 질문 문항을 생성합니다." | |
| > | |
| <form onSubmit={handleAddQuestion} className="space-y-4" id="new-default-q-form"> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-8"> | |
| <Input | |
| label="네트워크 분류 *" | |
| type="select" | |
| value={newNetworkType} | |
| onChange={(e) => setNewNetworkType(e.target.value)} | |
| options={defaults.NETWORK_TYPES} | |
| required | |
| /> | |
| <Input | |
| label="최대 지목 인원 수 *" | |
| type="number" | |
| min={1} | |
| max={20} | |
| value={newMaxSelections} | |
| onChange={(e) => setNewMaxSelections(Number(e.target.value))} | |
| required | |
| /> | |
| </div> | |
| <Input | |
| label="질문 문구 *" | |
| type="text" | |
| value={newQuestionText} | |
| onChange={(e) => setNewQuestionText(e.target.value)} | |
| placeholder="예: 업무적으로 조언을 구하는 사람은?" | |
| required | |
| /> | |
| <Button | |
| type="submit" | |
| id="add-default-question-btn" | |
| leftIcon={<Plus className="w-4 h-4" />} | |
| className="w-full" | |
| > | |
| 기본 질문 목록에 추가 | |
| </Button> | |
| </form> | |
| </PjtBodyCard> | |
| </div> | |
| )} | |
| {/* 2. Network Types Tab */} | |
| {activeTab === 'network_types' && ( | |
| <div className="space-y-6"> | |
| <PjtBodyCard | |
| title="기본 네트워크 분류 목록" | |
| info="설문 문항 분류 필터 및 분석 등에 기본 제공되는 네트워크 유형입니다." | |
| > | |
| <div className="border border-slate-100 rounded-lg overflow-x-auto"> | |
| <Table id="defaults-network-type-table" className="text-xs sm:text-sm"> | |
| <TableHeader> | |
| <TableRow className="bg-slate-50 border-b border-slate-100 text-slate-400 font-bold uppercase tracking-tight"> | |
| <TableHead className="p-2 text-center">순서</TableHead> | |
| <TableHead className="p-2 text-center min-w-30">영문 코드</TableHead> | |
| <TableHead className="p-2 text-center min-w-30">분류 표시 문구</TableHead> | |
| <TableHead className="p-2 text-center">정렬</TableHead> | |
| <TableHead className="p-2 text-center">작업</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {defaults.NETWORK_TYPES.map((type, idx) => ( | |
| <TableRow key={idx}> | |
| <TableCell className="p-3 text-center font-semibold text-slate-500">{idx + 1}</TableCell> | |
| <TableCell className="p-3"> | |
| <input | |
| type="text" | |
| value={type.value} | |
| onChange={(e) => handleUpdateNetworkType(idx, 'value', e.target.value)} | |
| className="w-full px-2 py-1 text-sm border border-slate-200 focus:border-orange-500 focus:ring-1 focus:ring-orange-500/20 rounded outline-none text-slate-600" | |
| required | |
| /> | |
| </TableCell> | |
| <TableCell className="p-3"> | |
| <input | |
| type="text" | |
| value={type.label} | |
| onChange={(e) => handleUpdateNetworkType(idx, 'label', e.target.value)} | |
| className="w-full px-2 py-1 text-sm border border-slate-200 focus:border-orange-500 focus:ring-1 focus:ring-orange-500/20 rounded outline-none text-slate-800" | |
| required | |
| /> | |
| </TableCell> | |
| <TableCell className="p-3 text-center"> | |
| <div className="flex justify-center gap-1"> | |
| <button | |
| type="button" | |
| onClick={() => handleMoveNetworkType(idx, 'up')} | |
| disabled={idx === 0} | |
| className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded cursor-pointer" | |
| > | |
| <ChevronUp className="w-4 h-4" /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => handleMoveNetworkType(idx, 'down')} | |
| disabled={idx === defaults.NETWORK_TYPES.length - 1} | |
| className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded cursor-pointer" | |
| > | |
| <ChevronDown className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </TableCell> | |
| <TableCell className="p-3 text-center"> | |
| <button | |
| type="button" | |
| onClick={() => handleDeleteNetworkType(type.value)} | |
| className="p-1 text-slate-300 hover:text-red-500 hover:bg-red-50 rounded cursor-pointer inline-flex items-center justify-center" | |
| > | |
| <Trash2 className="w-4 h-4" /> | |
| </button> | |
| </TableCell> | |
| </TableRow> | |
| ))} | |
| {defaults.NETWORK_TYPES.length === 0 && ( | |
| <TableRow> | |
| <TableCell colSpan={5} className="p-6 text-center text-slate-400">등록된 기본 네트워크 분류가 없습니다.</TableCell> | |
| </TableRow> | |
| )} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| </PjtBodyCard> | |
| <PjtBodyCard | |
| title="네트워크 분류 추가" | |
| info="기본 템플릿에 적용될 네트워크 유형 분류 옵션을 추가합니다." | |
| > | |
| <form onSubmit={handleAddNetworkType} className="space-y-4"> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-8"> | |
| <Input | |
| label="영문 분류 코드 *" | |
| value={newTypeEnglish} | |
| onChange={(e) => setNewTypeEnglish(e.target.value)} | |
| placeholder="예: work, cooperation" | |
| required | |
| /> | |
| <Input | |
| label="분류 표시 문구 *" | |
| value={newTypeName} | |
| onChange={(e) => setNewTypeName(e.target.value)} | |
| placeholder="예: 업무 협력, 동적 네트워크" | |
| required | |
| /> | |
| </div> | |
| <Button | |
| type="submit" | |
| size="md" | |
| leftIcon={<Plus className="w-4 h-4" />} | |
| id="add-default-network-type-btn" | |
| className="w-full" | |
| > | |
| 기본 분류에 추가 | |
| </Button> | |
| </form> | |
| </PjtBodyCard> | |
| </div> | |
| )} | |
| {/* 3. Strength Scales Tab */} | |
| {activeTab === 'strength_scales' && ( | |
| <div className="space-y-6"> | |
| <PjtBodyCard | |
| title="기본 관계 척도 목록" | |
| info="지목한 동료와의 관계 빈도/강도를 묻는 척도의 초기 템플릿입니다." | |
| > | |
| <div className="border border-slate-100 rounded-lg overflow-x-auto"> | |
| <Table id="defaults-scales-table" className="text-xs sm:text-sm"> | |
| <TableHeader> | |
| <TableRow className="bg-slate-50 border-b border-slate-100 text-slate-400 font-bold uppercase tracking-tight"> | |
| <TableHead className="p-2 text-center">순서</TableHead> | |
| <TableHead className="p-2 text-center min-w-20">척도 표시 문구</TableHead> | |
| <TableHead className="p-2 text-center">정렬</TableHead> | |
| <TableHead className="p-2 text-center">작업</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {defaults.STRENGTH_OPTIONS.map((opt, idx) => ( | |
| <TableRow key={idx}> | |
| <TableCell className="p-3 text-center font-semibold text-slate-500">{idx + 1}</TableCell> | |
| <TableCell className="p-3"> | |
| <input | |
| type="text" | |
| value={opt.label} | |
| onChange={(e) => handleUpdateStrengthLabel(idx, e.target.value)} | |
| className="w-full px-2 py-1 text-sm border border-slate-200 focus:border-orange-500 focus:ring-1 focus:ring-orange-500/20 rounded outline-none text-slate-800" | |
| required | |
| /> | |
| </TableCell> | |
| <TableCell className="p-3 text-center"> | |
| <div className="flex justify-center gap-0.5"> | |
| <button | |
| type="button" | |
| onClick={() => handleMoveStrength(idx, 'up')} | |
| disabled={idx === 0} | |
| className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded cursor-pointer" | |
| > | |
| <ChevronUp className="w-4 h-4" /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => handleMoveStrength(idx, 'down')} | |
| disabled={idx === defaults.STRENGTH_OPTIONS.length - 1} | |
| className="p-1 text-slate-400 hover:text-slate-600 disabled:opacity-30 rounded cursor-pointer" | |
| > | |
| <ChevronDown className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </TableCell> | |
| <TableCell className="p-3 text-center"> | |
| <button | |
| type="button" | |
| onClick={() => handleDeleteStrength(opt.value)} | |
| className="p-1 text-slate-300 hover:text-red-500 hover:bg-red-50 rounded cursor-pointer inline-flex items-center justify-center" | |
| > | |
| <Trash2 className="w-4 h-4" /> | |
| </button> | |
| </TableCell> | |
| </TableRow> | |
| ))} | |
| {defaults.STRENGTH_OPTIONS.length === 0 && ( | |
| <TableRow> | |
| <TableCell colSpan={4} className="p-6 text-center text-slate-400">등록된 기본 척도가 없습니다.</TableCell> | |
| </TableRow> | |
| )} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| </PjtBodyCard> | |
| <PjtBodyCard | |
| title="새로운 척도 추가" | |
| info="기본 템플릿에 관계 강도/빈도 척도 옵션을 추가합니다." | |
| > | |
| <form onSubmit={handleAddStrength} className="space-y-4"> | |
| <div className="flex items-end gap-4 w-full"> | |
| <div className="flex-1 w-full"> | |
| <Input | |
| label="척도 표시 문구 *" | |
| type="text" | |
| value={newStrengthLabel} | |
| onChange={(e) => setNewStrengthLabel(e.target.value)} | |
| placeholder="예: 거의 매일, 주 1~2회" | |
| required | |
| /> | |
| </div> | |
| <Button type="submit" className="w-auto">추가</Button> | |
| </div> | |
| </form> | |
| </PjtBodyCard> | |
| </div> | |
| )} | |
| {/* 4. Notice Template Tab */} | |
| {activeTab === 'notice_template' && ( | |
| <div className="space-y-6"> | |
| <PjtBodyCard | |
| title="기본 안내문 템플릿 편집" | |
| info="임직원 설문 안내 시 활용되는 공통 공지 템플릿입니다." | |
| > | |
| <div className="space-y-4"> | |
| <TextArea | |
| label="본문 내용 *" | |
| value={defaults.NOTICE_TEMPLATE} | |
| onChange={(e) => handleUpdateNoticeTemplate(e.target.value)} | |
| rows={15} | |
| required | |
| className="font-mono" | |
| /> | |
| <div className="p-4 bg-slate-50 border border-slate-100 rounded-lg text-slate-500 text-sm leading-relaxed space-y-2"> | |
| <p className="font-bold text-slate-700">⚠️ 템플릿 치환자(Placeholder) 수정 금지</p> | |
| <p>안내문 발송 시점에 프로젝트 정보로 자동 치환되는 특수 키워드들입니다. 템플릿 작성 시 그대로 포함하여 작성해 주세요.</p> | |
| <ul className="list-disc list-inside space-y-1 font-semibold text-slate-600 pl-1"> | |
| <li><code className="text-orange-600 font-mono font-bold bg-orange-50 px-1 py-0.5 rounded">{"{start_date}"}</code> : 설문 시작 일자</li> | |
| <li><code className="text-orange-600 font-mono font-bold bg-orange-50 px-1 py-0.5 rounded">{"{end_date}"}</code> : 설문 종료 일자</li> | |
| <li><code className="text-orange-600 font-mono font-bold bg-orange-50 px-1 py-0.5 rounded">{"{common_survey_url}"}</code> : 설문 조사 참여용 공통 URL</li> | |
| <li><code className="text-orange-600 font-mono font-bold bg-orange-50 px-1 py-0.5 rounded">{"{survey_login_id}"}</code> : 설문 참여용 로그인 ID</li> | |
| <li><code className="text-orange-600 font-mono font-bold bg-orange-50 px-1 py-0.5 rounded">{"{survey_password_hash}"}</code> : 설문 참여용 비밀번호</li> | |
| </ul> | |
| </div> | |
| </div> | |
| </PjtBodyCard> | |
| </div> | |
| )} | |
| </div> | |
| {/* Modal dialog */} | |
| <Modal | |
| isOpen={modalConfig.isOpen} | |
| onClose={() => setModalConfig({ ...modalConfig, isOpen: false })} | |
| title={modalConfig.title} | |
| showCloseButton={true} | |
| footerActions={ | |
| <div className="flex justify-end gap-2 w-full"> | |
| {modalConfig.showCancel && ( | |
| <Button variant="secondary" onClick={() => setModalConfig({ ...modalConfig, isOpen: false })}> | |
| 취소 | |
| </Button> | |
| )} | |
| <Button | |
| variant="primary" | |
| onClick={() => { | |
| modalConfig.onConfirm?.(); | |
| setModalConfig({ ...modalConfig, isOpen: false }); | |
| }} | |
| > | |
| 확인 | |
| </Button> | |
| </div> | |
| } | |
| > | |
| <div className="py-2 text-sm text-slate-600 leading-relaxed"> | |
| {modalConfig.message} | |
| </div> | |
| </Modal> | |
| </div> | |
| ); | |
| }; | |