Files
sam-manage/resources/views/esign/templates.blade.php
김보곤 a464cf40de feat:템플릿 편집 모달 확장 (PDF 관리 + 서식 필드 관리)
백엔드:
- uploadTemplatePdf: 템플릿 PDF 업로드/교체 API
- removeTemplatePdf: 템플릿 PDF 제거 API
- destroyTemplateItem: 개별 필드 아이템 삭제 API (signer_count 자동 재계산)
- updateTemplate 응답에 items 관계 포함

프론트엔드:
- 모달 폭 420px → 680px 확장
- 3개 탭 구성: 기본 정보 / PDF 파일 / 서식 필드
- PDF 탭: 현재 파일 정보, 다운로드, 교체, 제거 기능
- 서식 필드 탭: 필드 목록 테이블 (유형/라벨/서명자/페이지/위치/필수), 개별 삭제
- 편집 시 상세 데이터(items 포함) 로드

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 10:11:14 +09:00

622 lines
34 KiB
PHP

@extends('layouts.app')
@section('title', 'SAM E-Sign - 템플릿 관리')
@section('content')
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="esign-templates-root"></div>
@endsection
@push('scripts')
@include('partials.react-cdn')
@verbatim
<script type="text/babel">
const { useState, useEffect, useCallback, useRef } = React;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const getHeaders = () => ({
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || csrfToken,
});
const CATEGORIES = ['영업파트너', '고객'];
// ─── TemplateToast ───
const TemplateToast = ({ message, type = 'success', onClose }) => {
useEffect(() => {
const t = setTimeout(onClose, 3000);
return () => clearTimeout(t);
}, []);
const bg = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500';
return (
<div className={`fixed top-4 right-4 z-50 ${bg} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm flex items-center gap-2 animate-slide-in`}>
<span>{message}</span>
<button onClick={onClose} className="text-white/80 hover:text-white ml-1">&times;</button>
</div>
);
};
const FIELD_TYPE_MAP = {
signature: { label: '서명', color: 'bg-blue-100 text-blue-700' },
stamp: { label: '도장', color: 'bg-purple-100 text-purple-700' },
text: { label: '텍스트', color: 'bg-gray-100 text-gray-700' },
date: { label: '날짜', color: 'bg-green-100 text-green-700' },
checkbox: { label: '체크박스', color: 'bg-yellow-100 text-yellow-700' },
};
// ─── EditTemplateModal ───
const EditTemplateModal = ({ open, template, onClose, onUpdate }) => {
const [tab, setTab] = useState('info');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [category, setCategory] = useState('');
const [saving, setSaving] = useState(false);
const [tpl, setTpl] = useState(null); // 로컬 상태 (PDF/필드 변경 반영)
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef(null);
useEffect(() => {
if (template) {
setName(template.name || '');
setDescription(template.description || '');
setCategory(template.category || '');
setTpl(template);
setTab('info');
}
}, [template]);
if (!open || !tpl) return null;
const handleSave = async () => {
if (!name.trim()) { alert('템플릿 이름을 입력해주세요.'); return; }
setSaving(true);
try {
const res = await fetch(`/esign/contracts/templates/${tpl.id}`, {
method: 'PUT', headers: { ...getHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), description: description.trim(), category: category || null }),
});
const json = await res.json();
if (json.success) { onUpdate(json.data); onClose(); }
else alert(json.message || '수정 실패');
} catch (_) { alert('서버 오류'); }
setSaving(false);
};
const handleUploadPdf = async (e) => {
const file = e.target.files[0];
if (!file) return;
setUploading(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`/esign/contracts/templates/${tpl.id}/upload-pdf`, {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: fd,
});
const json = await res.json();
if (json.success) { setTpl(json.data); onUpdate(json.data); }
else alert(json.message || '업로드 실패');
} catch (_) { alert('서버 오류'); }
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleRemovePdf = async () => {
if (!confirm('PDF 파일을 제거하시겠습니까?')) return;
try {
const res = await fetch(`/esign/contracts/templates/${tpl.id}/remove-pdf`, {
method: 'DELETE', headers: getHeaders(),
});
const json = await res.json();
if (json.success) { setTpl(json.data); onUpdate(json.data); }
else alert(json.message || '제거 실패');
} catch (_) { alert('서버 오류'); }
};
const handleDeleteItem = async (itemId) => {
if (!confirm('이 필드를 삭제하시겠습니까?')) return;
try {
const res = await fetch(`/esign/contracts/templates/${tpl.id}/items/${itemId}`, {
method: 'DELETE', headers: getHeaders(),
});
const json = await res.json();
if (json.success) { setTpl(json.data); onUpdate(json.data); }
else alert(json.message || '삭제 실패');
} catch (_) { alert('서버 오류'); }
};
const tabs = [
{ key: 'info', label: '기본 정보' },
{ key: 'pdf', label: 'PDF 파일' },
{ key: 'fields', label: `서식 필드 (${tpl.items?.length || 0})` },
];
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-xl shadow-2xl w-[680px] max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
{/* 헤더 + 탭 */}
<div className="px-6 pt-5 pb-0 flex-shrink-0">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold">템플릿 편집</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
</div>
<div className="flex gap-1 border-b -mx-6 px-6">
{tabs.map(t => (
<button key={t.key} onClick={() => setTab(t.key)}
className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${tab === t.key ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{t.label}
</button>
))}
</div>
</div>
{/* 탭 콘텐츠 */}
<div className="px-6 py-5 flex-1 overflow-y-auto">
{/* 기본 정보 탭 */}
{tab === 'info' && (
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500 block mb-1">템플릿 이름 *</label>
<input type="text" value={name} onChange={e => setName(e.target.value)}
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-200 focus:border-blue-400 outline-none" autoFocus />
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">설명</label>
<textarea value={description} onChange={e => setDescription(e.target.value)}
rows={3} className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-200 focus:border-blue-400 outline-none resize-none" />
</div>
<div>
<label className="text-xs text-gray-500 block mb-1">카테고리</label>
<select value={category} onChange={e => setCategory(e.target.value)}
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-200 focus:border-blue-400 outline-none">
<option value="">카테고리 없음</option>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="grid grid-cols-2 gap-3 pt-1">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-[11px] text-gray-400 mb-0.5">서명자 </p>
<p className="text-sm font-medium">{tpl.signer_count}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-[11px] text-gray-400 mb-0.5">필드 </p>
<p className="text-sm font-medium">{tpl.items_count ?? tpl.items?.length ?? 0}</p>
</div>
</div>
</div>
)}
{/* PDF 파일 탭 */}
{tab === 'pdf' && (
<div className="space-y-4">
{tpl.file_path ? (
<div className="border rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-50 rounded-lg flex items-center justify-center flex-shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#EF4444" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{tpl.file_name || 'PDF 파일'}</p>
<p className="text-xs text-gray-400">{tpl.file_size ? `${(tpl.file_size / 1024 / 1024).toFixed(2)} MB` : ''}</p>
</div>
<a href={`/esign/contracts/templates/${tpl.id}/download`} target="_blank"
className="px-3 py-1.5 text-xs border rounded-lg text-gray-600 hover:bg-gray-50">다운로드</a>
</div>
<div className="flex gap-2 mt-3 pt-3 border-t">
<button onClick={() => fileInputRef.current?.click()} disabled={uploading}
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
{uploading ? '업로드 중...' : 'PDF 교체'}
</button>
<button onClick={handleRemovePdf}
className="px-3 py-1.5 text-xs border border-red-300 text-red-600 rounded-lg hover:bg-red-50">
PDF 제거
</button>
</div>
</div>
) : (
<div className="border-2 border-dashed rounded-lg p-8 text-center">
<svg className="mx-auto mb-3 text-gray-300" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/>
</svg>
<p className="text-sm text-gray-500 mb-3">연결된 PDF 파일이 없습니다</p>
<button onClick={() => fileInputRef.current?.click()} disabled={uploading}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
{uploading ? '업로드 중...' : 'PDF 업로드'}
</button>
</div>
)}
<input ref={fileInputRef} type="file" accept=".pdf" onChange={handleUploadPdf} className="hidden" />
<p className="text-xs text-gray-400">PDF 파일은 템플릿으로 계약 생성 자동 사용됩니다. 최대 20MB.</p>
</div>
)}
{/* 서식 필드 탭 */}
{tab === 'fields' && (
<div>
{(!tpl.items || tpl.items.length === 0) ? (
<div className="text-center py-8 text-gray-400">
<p className="text-sm mb-1">서식 필드가 없습니다.</p>
<p className="text-xs">계약의 필드 에디터에서 "템플릿으로 저장" 필드가 포함됩니다.</p>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-[11px] font-medium text-gray-500">유형</th>
<th className="px-3 py-2 text-left text-[11px] font-medium text-gray-500">라벨</th>
<th className="px-3 py-2 text-center text-[11px] font-medium text-gray-500">서명자</th>
<th className="px-3 py-2 text-center text-[11px] font-medium text-gray-500">페이지</th>
<th className="px-3 py-2 text-center text-[11px] font-medium text-gray-500">위치 (X, Y)</th>
<th className="px-3 py-2 text-center text-[11px] font-medium text-gray-500">필수</th>
<th className="px-3 py-2 w-10"></th>
</tr>
</thead>
<tbody className="divide-y">
{tpl.items.map(item => {
const ft = FIELD_TYPE_MAP[item.field_type] || { label: item.field_type, color: 'bg-gray-100 text-gray-700' };
return (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-3 py-2">
<span className={`inline-block px-2 py-0.5 rounded text-[11px] font-medium ${ft.color}`}>{ft.label}</span>
</td>
<td className="px-3 py-2 text-sm text-gray-700">{item.field_label || '-'}</td>
<td className="px-3 py-2 text-center text-xs text-gray-500">#{item.signer_order}</td>
<td className="px-3 py-2 text-center text-xs text-gray-500">{item.page_number}</td>
<td className="px-3 py-2 text-center text-xs text-gray-400 font-mono">{Number(item.position_x).toFixed(0)}, {Number(item.position_y).toFixed(0)}</td>
<td className="px-3 py-2 text-center text-xs">{item.is_required ? <span className="text-green-600">&#10003;</span> : <span className="text-gray-300">-</span>}</td>
<td className="px-3 py-2 text-center">
<button onClick={() => handleDeleteItem(item.id)} title="필드 삭제"
className="text-gray-400 hover:text-red-500 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
{/* 하단 버튼 */}
<div className="px-6 py-4 border-t flex-shrink-0 flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-1.5 text-sm border rounded-lg hover:bg-gray-50">닫기</button>
{tab === 'info' && (
<button onClick={handleSave} disabled={saving}
className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
{saving ? '저장 중...' : '저장'}
</button>
)}
</div>
</div>
</div>
);
};
// ─── TemplateCard ───
const TemplateCard = ({ template, onEdit, onDuplicate, onDelete }) => {
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef(null);
useEffect(() => {
const handler = (e) => { if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const timeAgo = (dateStr) => {
if (!dateStr) return '';
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return '방금';
if (mins < 60) return `${mins}분 전`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}시간 전`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}일 전`;
const months = Math.floor(days / 30);
return `${months}개월 전`;
};
return (
<div className="bg-white border rounded-xl p-4 hover:shadow-md transition-shadow relative group">
{/* 메뉴 버튼 */}
<div className="absolute top-3 right-3" ref={menuRef}>
<button onClick={() => setMenuOpen(o => !o)}
className="w-7 h-7 flex items-center justify-center rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 opacity-0 group-hover:opacity-100 transition-opacity">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/>
</svg>
</button>
{menuOpen && (
<div className="absolute right-0 top-full mt-1 w-32 bg-white border rounded-lg shadow-lg z-10 py-1">
<button onClick={() => { setMenuOpen(false); onEdit(template); }}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-gray-50">편집</button>
<button onClick={() => { setMenuOpen(false); onDuplicate(template.id); }}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-gray-50">복제</button>
<div className="border-t my-1"></div>
<button onClick={() => { setMenuOpen(false); onDelete(template.id, template.name); }}
className="w-full text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-50">삭제</button>
</div>
)}
</div>
{/* 카테고리 뱃지 */}
<div className="flex items-start gap-2 mb-3">
<div className="w-9 h-9 bg-blue-50 rounded-lg flex items-center justify-center text-blue-500 text-lg flex-shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
</div>
<div className="flex-1 min-w-0 pr-6">
<h3 className="font-semibold text-gray-900 text-sm truncate">{template.name}</h3>
{template.category && (
<span className="inline-block mt-1 px-2 py-0.5 bg-gray-100 text-gray-600 text-[11px] rounded-full">{template.category}</span>
)}
</div>
</div>
{template.description && (
<p className="text-xs text-gray-500 mb-3 line-clamp-2">{template.description}</p>
)}
{/* PDF 포함 표시 */}
{template.file_path && (
<div className="flex items-center gap-1.5 mb-2 px-2 py-1.5 bg-blue-50 rounded-lg">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#3B82F6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
<span className="text-[11px] text-blue-600 font-medium truncate">{template.file_name || 'PDF 포함'}</span>
</div>
)}
{/* 메타 정보 */}
<div className="flex items-center gap-3 text-[11px] text-gray-400">
<span>서명자 {template.signer_count}</span>
<span className="w-1 h-1 bg-gray-300 rounded-full"></span>
<span>필드 {template.items_count ?? template.items?.length ?? 0}</span>
</div>
{/* 하단: 생성자 + 시간 */}
<div className="flex items-center justify-between mt-3 pt-3 border-t">
<span className="text-[11px] text-gray-400">
{template.creator?.name || '알 수 없음'}
</span>
<span className="text-[11px] text-gray-400">
{timeAgo(template.created_at)}
</span>
</div>
</div>
);
};
// ─── App ───
const App = () => {
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('');
const [signerCount, setSignerCount] = useState('');
const [editModal, setEditModal] = useState({ open: false, template: null });
const [toast, setToast] = useState(null);
const searchTimeout = useRef(null);
const showToast = (message, type = 'success') => setToast({ message, type });
const fetchTemplates = useCallback(async (params = {}) => {
setLoading(true);
const qs = new URLSearchParams();
if (params.search || search) qs.set('search', params.search ?? search);
if (params.category || category) qs.set('category', params.category ?? category);
if (params.signerCount || signerCount) qs.set('signer_count', params.signerCount ?? signerCount);
try {
const res = await fetch(`/esign/contracts/templates?${qs}`, { headers: getHeaders() });
const json = await res.json();
if (json.success) setTemplates(json.data);
} catch (_) {}
setLoading(false);
}, [search, category, signerCount]);
useEffect(() => { fetchTemplates(); }, []);
const handleSearchChange = (val) => {
setSearch(val);
clearTimeout(searchTimeout.current);
searchTimeout.current = setTimeout(() => fetchTemplates({ search: val }), 300);
};
const handleCategoryChange = (val) => {
setCategory(val);
fetchTemplates({ category: val });
};
const handleSignerCountChange = (val) => {
setSignerCount(val);
fetchTemplates({ signerCount: val });
};
const handleEdit = async (template) => {
// 상세 데이터(items 포함) 로드
try {
const res = await fetch(`/esign/contracts/templates/${template.id}`, { headers: getHeaders() });
const json = await res.json();
if (json.success) {
setEditModal({ open: true, template: json.data });
} else {
setEditModal({ open: true, template });
}
} catch (_) {
setEditModal({ open: true, template });
}
};
const handleUpdate = (updatedTemplate) => {
setTemplates(prev => prev.map(t => t.id === updatedTemplate.id
? { ...t, ...updatedTemplate, items_count: updatedTemplate.items?.length ?? t.items_count }
: t
));
showToast('템플릿이 수정되었습니다.');
};
const handleDuplicate = async (id) => {
try {
const res = await fetch(`/esign/contracts/templates/${id}/duplicate`, {
method: 'POST', headers: getHeaders(),
});
const json = await res.json();
if (json.success) {
setTemplates(prev => [json.data, ...prev]);
showToast('템플릿이 복제되었습니다.');
} else {
alert(json.message || '복제 실패');
}
} catch (_) { alert('서버 오류'); }
};
const handleDelete = async (id, name) => {
if (!confirm(`"${name}" 템플릿을 삭제하시겠습니까?`)) return;
try {
const res = await fetch(`/esign/contracts/templates/${id}`, {
method: 'DELETE', headers: getHeaders(),
});
const json = await res.json();
if (json.success) {
setTemplates(prev => prev.filter(t => t.id !== id));
showToast('템플릿이 삭제되었습니다.');
}
} catch (_) { alert('서버 오류'); }
};
const handleNewTemplate = () => {
showToast('새 계약을 생성한 후, 필드 에디터에서 "템플릿으로 저장"을 사용해주세요.', 'info');
};
// 카테고리 목록 (데이터에서 동적으로 추출 + 기본값)
const allCategories = [...new Set([...CATEGORIES, ...templates.map(t => t.category).filter(Boolean)])].sort();
return (
<div className="p-4 sm:p-6">
{toast && <TemplateToast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
{/* 헤더 */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<a href="/esign" className="text-gray-400 hover:text-gray-600 text-lg" hx-boost="false">&larr;</a>
<div>
<h1 className="text-xl font-bold text-gray-900">템플릿 관리</h1>
<p className="text-xs text-gray-400 mt-0.5">전자계약 필드 템플릿을 관리합니다</p>
</div>
<button onClick={() => fetchTemplates()} title="새로고침"
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
</svg>
</button>
</div>
<button onClick={handleNewTemplate}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition-colors flex items-center gap-1.5">
<span className="text-base leading-none">+</span> 템플릿
</button>
</div>
{/* 필터 바 */}
<div className="flex items-center gap-3 mb-5 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-[320px]">
<input type="text" value={search} onChange={e => handleSearchChange(e.target.value)}
placeholder="템플릿 검색..."
className="w-full border rounded-lg pl-9 pr-3 py-2 text-sm focus:ring-2 focus:ring-blue-200 focus:border-blue-400 outline-none" />
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
</svg>
</div>
<select value={category} onChange={e => handleCategoryChange(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-200 focus:border-blue-400 outline-none min-w-[130px]">
<option value="">전체 카테고리</option>
{allCategories.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<select value={signerCount} onChange={e => handleSignerCountChange(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-200 focus:border-blue-400 outline-none min-w-[130px]">
<option value="">전체 서명자 </option>
<option value="1">서명자 1</option>
<option value="2">서명자 2</option>
<option value="3">서명자 3</option>
</select>
{(search || category || signerCount) && (
<button onClick={() => { setSearch(''); setCategory(''); setSignerCount(''); fetchTemplates({ search: '', category: '', signerCount: '' }); }}
className="text-xs text-gray-500 hover:text-gray-700 underline">초기화</button>
)}
</div>
{/* 템플릿 그리드 */}
{loading ? (
<div className="flex items-center justify-center py-20 text-gray-400">
<svg className="animate-spin mr-2 h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
불러오는 ...
</div>
) : templates.length === 0 ? (
<div className="text-center py-20">
<div className="text-gray-300 text-5xl mb-3">
<svg className="mx-auto" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
</div>
<p className="text-gray-500 text-sm mb-1">
{search || category || signerCount ? '조건에 맞는 템플릿이 없습니다.' : '아직 템플릿이 없습니다.'}
</p>
<p className="text-gray-400 text-xs">필드 에디터에서 "템플릿으로 저장"으로 만들 있습니다.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{templates.map(t => (
<TemplateCard
key={t.id}
template={t}
onEdit={handleEdit}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* 편집 모달 */}
<EditTemplateModal
open={editModal.open}
template={editModal.template}
onClose={() => setEditModal({ open: false, template: null })}
onUpdate={handleUpdate}
/>
<style>{`
@keyframes slide-in {
from { transform: translateX(100px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.animate-slide-in { animation: slide-in 0.3s ease-out; }
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
`}</style>
</div>
);
};
ReactDOM.createRoot(document.getElementById('esign-templates-root')).render(<App />);
</script>
@endverbatim
@endpush