- 템플릿 관리 전용 페이지 (카드 그리드, 검색/필터, 편집/복제/삭제) - API: showTemplate, updateTemplate, duplicateTemplate 추가 - indexTemplates에 category/search 필터 추가 - 계약 생성 시 템플릿 선택 UI 추가 - 필드 에디터에서 URL 파라미터 template_id 자동 적용 - EsignFieldTemplate 모델에 category 필드 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
403 lines
19 KiB
PHP
403 lines
19 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 = ['일반', '근로계약', '공급계약', '비밀유지', '임대차'];
|
|
|
|
// ─── 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
|