feat:E-Sign 템플릿 관리 시스템 Phase 2 구현

- 템플릿 관리 전용 페이지 (카드 그리드, 검색/필터, 편집/복제/삭제)
- API: showTemplate, updateTemplate, duplicateTemplate 추가
- indexTemplates에 category/search 필터 추가
- 계약 생성 시 템플릿 선택 UI 추가
- 필드 에디터에서 URL 파라미터 template_id 자동 적용
- EsignFieldTemplate 모델에 category 필드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-12 18:55:06 +09:00
parent 203d52300a
commit ec43fe1991
7 changed files with 598 additions and 5 deletions

View File

@@ -363,14 +363,19 @@ public function indexTemplates(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$query = EsignFieldTemplate::forTenant($tenantId)
->where('is_active', true)
->with('items');
->where('is_active', true);
if ($category = $request->input('category')) {
$query->where('category', $category);
}
if ($search = $request->input('search')) {
$query->where('name', 'like', "%{$search}%");
}
if ($signerCount = $request->input('signer_count')) {
$query->where('signer_count', $signerCount);
}
$templates = $query->orderBy('created_at', 'desc')->get();
$templates = $query->withCount('items')->with('creator:id,name')->latest()->get();
return response()->json(['success' => true, 'data' => $templates]);
}
@@ -383,6 +388,7 @@ public function storeTemplate(Request $request): JsonResponse
$request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string',
'category' => 'nullable|string|max:50',
'items' => 'required|array|min:1',
'items.*.signer_order' => 'required|integer|min:1',
'items.*.page_number' => 'required|integer|min:1',
@@ -406,6 +412,7 @@ public function storeTemplate(Request $request): JsonResponse
'tenant_id' => $tenantId,
'name' => $request->input('name'),
'description' => $request->input('description'),
'category' => $request->input('category'),
'signer_count' => $signerCount,
'is_active' => true,
'created_by' => auth()->id(),
@@ -437,6 +444,95 @@ public function storeTemplate(Request $request): JsonResponse
]);
}
/**
* 템플릿 단건 조회
*/
public function showTemplate(int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$template = EsignFieldTemplate::forTenant($tenantId)
->where('is_active', true)
->with(['items', 'creator:id,name'])
->findOrFail($id);
return response()->json(['success' => true, 'data' => $template]);
}
/**
* 템플릿 메타데이터 수정
*/
public function updateTemplate(Request $request, int $id): JsonResponse
{
$request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string',
'category' => 'nullable|string|max:50',
]);
$tenantId = session('selected_tenant_id', 1);
$template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id);
$template->update([
'name' => $request->input('name'),
'description' => $request->input('description'),
'category' => $request->input('category'),
]);
return response()->json([
'success' => true,
'message' => '템플릿이 수정되었습니다.',
'data' => $template->fresh()->load('creator:id,name'),
]);
}
/**
* 템플릿 복제
*/
public function duplicateTemplate(int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$template = EsignFieldTemplate::forTenant($tenantId)
->where('is_active', true)
->with('items')
->findOrFail($id);
$newTemplate = DB::transaction(function () use ($template, $tenantId) {
$newTemplate = EsignFieldTemplate::create([
'tenant_id' => $tenantId,
'name' => $template->name . ' (복사)',
'description' => $template->description,
'category' => $template->category,
'signer_count' => $template->signer_count,
'is_active' => true,
'created_by' => auth()->id(),
]);
foreach ($template->items as $item) {
EsignFieldTemplateItem::create([
'template_id' => $newTemplate->id,
'signer_order' => $item->signer_order,
'page_number' => $item->page_number,
'position_x' => $item->position_x,
'position_y' => $item->position_y,
'width' => $item->width,
'height' => $item->height,
'field_type' => $item->field_type,
'field_label' => $item->field_label,
'is_required' => $item->is_required,
'sort_order' => $item->sort_order,
]);
}
return $newTemplate;
});
return response()->json([
'success' => true,
'message' => '템플릿이 복제되었습니다.',
'data' => $newTemplate->load(['items', 'creator:id,name'])->loadCount('items'),
]);
}
/**
* 템플릿 삭제 (soft: is_active=false)
*/

View File

@@ -54,6 +54,15 @@ public function send(Request $request, int $id): View|Response
return view('esign.send', ['contractId' => $id]);
}
public function templates(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('esign.templates'));
}
return view('esign.templates');
}
public function docs(Request $request): View|Response
{
if ($request->header('HX-Request')) {

View File

@@ -14,6 +14,7 @@ class EsignFieldTemplate extends Model
'tenant_id',
'name',
'description',
'category',
'signer_count',
'is_active',
'created_by',
@@ -29,6 +30,11 @@ public function scopeForTenant($query, $tenantId)
return $query->where('tenant_id', $tenantId);
}
public function scopeByCategory($query, $category)
{
return $query->where('category', $category);
}
public function items(): HasMany
{
return $this->hasMany(EsignFieldTemplateItem::class, 'template_id')->orderBy('sort_order');

View File

@@ -11,7 +11,7 @@
@include('partials.react-cdn')
@verbatim
<script type="text/babel">
const { useState, useRef } = React;
const { useState, useEffect, useRef } = React;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
@@ -52,8 +52,19 @@ className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring
const [file, setFile] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState({});
const [templates, setTemplates] = useState([]);
const [templateId, setTemplateId] = useState('');
const [templateCategory, setTemplateCategory] = useState('');
const [templateSearch, setTemplateSearch] = useState('');
const fileRef = useRef(null);
useEffect(() => {
fetch('/esign/contracts/templates', { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(json => { if (json.success) setTemplates(json.data); })
.catch(() => {});
}, []);
const handleChange = (key, val) => setForm(f => ({...f, [key]: val}));
const handleSubmit = async (e) => {
@@ -83,7 +94,10 @@ className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring
});
const json = await res.json();
if (json.success) {
location.href = `/esign/${json.data.id}/fields`;
const url = templateId
? `/esign/${json.data.id}/fields?template_id=${templateId}`
: `/esign/${json.data.id}/fields`;
location.href = url;
} else {
setErrors(json.errors || { general: json.message });
}
@@ -148,6 +162,55 @@ className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring
</div>
</div>
{/* 필드 템플릿 (선택) */}
{templates.length > 0 && (
<div className="bg-white rounded-lg border p-5">
<h2 className="text-base font-semibold text-gray-900 mb-1">필드 템플릿</h2>
<p className="text-xs text-gray-400 mb-3">계약 생성 필드 에디터에서 자동으로 적용됩니다 (선택사항)</p>
<div className="flex gap-2 mb-3">
<select value={templateCategory} onChange={e => setTemplateCategory(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
<option value="">전체 카테고리</option>
{[...new Set(templates.map(t => t.category).filter(Boolean))].sort().map(c =>
<option key={c} value={c}>{c}</option>
)}
</select>
<input type="text" value={templateSearch} onChange={e => setTemplateSearch(e.target.value)}
placeholder="검색..." className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none flex-1" />
</div>
<div className="space-y-1.5 max-h-[200px] overflow-y-auto">
<label className={`flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer transition-colors ${!templateId ? 'border-blue-400 bg-blue-50' : 'hover:bg-gray-50'}`}>
<input type="radio" name="template" checked={!templateId} onChange={() => setTemplateId('')}
className="text-blue-600 focus:ring-blue-500" />
<div>
<span className="text-sm text-gray-700">없음</span>
<span className="text-xs text-gray-400 ml-1">(수동 배치)</span>
</div>
</label>
{templates
.filter(t => !templateCategory || t.category === templateCategory)
.filter(t => !templateSearch || t.name.toLowerCase().includes(templateSearch.toLowerCase()))
.map(t => (
<label key={t.id}
className={`flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer transition-colors ${templateId == t.id ? 'border-blue-400 bg-blue-50' : 'hover:bg-gray-50'}`}>
<input type="radio" name="template" checked={templateId == t.id} onChange={() => setTemplateId(t.id)}
className="text-blue-600 focus:ring-blue-500" />
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-800">{t.name}</span>
<div className="flex items-center gap-2 mt-0.5">
{t.category && <span className="text-[11px] px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded">{t.category}</span>}
<span className="text-[11px] text-gray-400">필드 {t.items_count ?? t.items?.length ?? 0}</span>
<span className="text-[11px] text-gray-400">서명자 {t.signer_count}</span>
</div>
</div>
</label>
))}
</div>
</div>
)}
{/* 버튼 */}
<div className="flex justify-end gap-3 pt-1">
<a href="/esign" className="px-5 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 text-sm transition-colors" hx-boost="false">취소</a>

View File

@@ -760,6 +760,19 @@ className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-7
useEffect(() => { loadPdf(); }, [loadPdf]);
useEffect(() => { renderPage(currentPage); }, [renderPage, currentPage]);
// URL 파라미터로 템플릿 자동 적용
const autoAppliedRef = useRef(false);
useEffect(() => {
if (!contract || autoAppliedRef.current) return;
const params = new URLSearchParams(window.location.search);
const urlTemplateId = params.get('template_id');
if (urlTemplateId && fields.length === 0) {
autoAppliedRef.current = true;
handleApplyTemplate(parseInt(urlTemplateId));
window.history.replaceState({}, '', window.location.pathname);
}
}, [contract]);
// PDF.js 메모리 정리
useEffect(() => {
return () => { if (pdfDoc) pdfDoc.destroy(); };

View File

@@ -0,0 +1,402 @@
@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 = ['일반', '근로계약', '공급계약', '비밀유지', '임대차'];
// ─── Toast ───
const Toast = ({ 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>
);
};
// ─── EditTemplateModal ───
const EditTemplateModal = ({ open, template, onClose, onSave }) => {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [category, setCategory] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (template) {
setName(template.name || '');
setDescription(template.description || '');
setCategory(template.category || '');
}
}, [template]);
if (!open || !template) return null;
const handleSave = async () => {
if (!name.trim()) { alert('템플릿 이름을 입력해주세요.'); return; }
setSaving(true);
await onSave(template.id, { name: name.trim(), description: description.trim(), category: category || null });
setSaving(false);
};
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-[420px] p-5" onClick={e => e.stopPropagation()}>
<h3 className="text-base font-semibold mb-4">템플릿 편집</h3>
<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={2} 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>
<div className="flex gap-2">
<select value={category} onChange={e => setCategory(e.target.value)}
className="flex-1 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>
</div>
<div className="flex justify-end gap-2 mt-5">
<button onClick={onClose} className="px-4 py-1.5 text-sm border rounded-lg hover:bg-gray-50">취소</button>
<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>
)}
{/* 메타 정보 */}
<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 = (template) => {
setEditModal({ open: true, template });
};
const handleSaveEdit = async (id, data) => {
try {
const res = await fetch(`/esign/contracts/templates/${id}`, {
method: 'PUT', headers: getHeaders(), body: JSON.stringify(data),
});
const json = await res.json();
if (json.success) {
setTemplates(prev => prev.map(t => t.id === id ? { ...t, ...data } : t));
setEditModal({ open: false, template: null });
showToast('템플릿이 수정되었습니다.');
} else {
alert(json.message || '수정 실패');
}
} catch (_) { alert('서버 오류'); }
};
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 && <Toast 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>
</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 })}
onSave={handleSaveEdit}
/>
<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

View File

@@ -1398,6 +1398,7 @@
Route::get('/', [EsignController::class, 'dashboard'])->name('dashboard');
Route::get('/create', [EsignController::class, 'create'])->name('create');
Route::get('/docs', [EsignController::class, 'docs'])->name('docs');
Route::get('/templates', [EsignController::class, 'templates'])->name('templates');
Route::get('/{id}', [EsignController::class, 'detail'])->whereNumber('id')->name('detail');
Route::get('/{id}/fields', [EsignController::class, 'fields'])->whereNumber('id')->name('fields');
Route::get('/{id}/send', [EsignController::class, 'send'])->whereNumber('id')->name('send');
@@ -1417,6 +1418,9 @@
// 필드 템플릿
Route::get('/templates', [EsignApiController::class, 'indexTemplates'])->name('templates.index');
Route::post('/templates', [EsignApiController::class, 'storeTemplate'])->name('templates.store');
Route::get('/templates/{templateId}', [EsignApiController::class, 'showTemplate'])->whereNumber('templateId')->name('templates.show');
Route::put('/templates/{templateId}', [EsignApiController::class, 'updateTemplate'])->whereNumber('templateId')->name('templates.update');
Route::post('/templates/{templateId}/duplicate', [EsignApiController::class, 'duplicateTemplate'])->whereNumber('templateId')->name('templates.duplicate');
Route::delete('/templates/{templateId}', [EsignApiController::class, 'destroyTemplate'])->whereNumber('templateId')->name('templates.destroy');
// 템플릿 적용 / 필드 복사