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:
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(); };
|
||||
|
||||
402
resources/views/esign/templates.blade.php
Normal file
402
resources/views/esign/templates.blade.php
Normal 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">×</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">←</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
|
||||
@@ -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');
|
||||
|
||||
// 템플릿 적용 / 필드 복사
|
||||
|
||||
Reference in New Issue
Block a user