From a464cf40de1974a1f9b274baec4a73d5ea16c577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 13 Feb 2026 06:50:13 +0900 Subject: [PATCH] =?UTF-8?q?feat:=ED=85=9C=ED=94=8C=EB=A6=BF=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EB=AA=A8=EB=8B=AC=20=ED=99=95=EC=9E=A5=20(PDF=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20+=20=EC=84=9C=EC=8B=9D=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EA=B4=80=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드: - 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 --- .../Controllers/ESign/EsignApiController.php | 84 ++++- resources/views/esign/templates.blade.php | 291 +++++++++++++++--- routes/web.php | 3 + 3 files changed, 333 insertions(+), 45 deletions(-) diff --git a/app/Http/Controllers/ESign/EsignApiController.php b/app/Http/Controllers/ESign/EsignApiController.php index d48702d7..9996b7c9 100644 --- a/app/Http/Controllers/ESign/EsignApiController.php +++ b/app/Http/Controllers/ESign/EsignApiController.php @@ -732,7 +732,89 @@ public function updateTemplate(Request $request, int $id): JsonResponse return response()->json([ 'success' => true, 'message' => '템플릿이 수정되었습니다.', - 'data' => $template->fresh()->load('creator:id,name'), + 'data' => $template->fresh()->load(['creator:id,name', 'items'])->loadCount('items'), + ]); + } + + /** + * 템플릿 PDF 교체 + */ + public function uploadTemplatePdf(Request $request, int $id): JsonResponse + { + $request->validate([ + 'file' => 'required|file|mimes:pdf|max:20480', + ]); + + $tenantId = session('selected_tenant_id', 1); + $template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id); + + // 기존 파일 삭제 + if ($template->file_path && Storage::disk('local')->exists($template->file_path)) { + Storage::disk('local')->delete($template->file_path); + } + + $file = $request->file('file'); + $filePath = $file->store("esign/{$tenantId}/templates", 'local'); + + $template->update([ + 'file_path' => $filePath, + 'file_name' => $file->getClientOriginalName(), + 'file_hash' => hash_file('sha256', $file->getRealPath()), + 'file_size' => $file->getSize(), + ]); + + return response()->json([ + 'success' => true, + 'message' => 'PDF가 교체되었습니다.', + 'data' => $template->fresh()->load(['creator:id,name', 'items'])->loadCount('items'), + ]); + } + + /** + * 템플릿 PDF 제거 + */ + public function removeTemplatePdf(int $id): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id); + + if ($template->file_path && Storage::disk('local')->exists($template->file_path)) { + Storage::disk('local')->delete($template->file_path); + } + + $template->update([ + 'file_path' => null, + 'file_name' => null, + 'file_hash' => null, + 'file_size' => null, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'PDF가 제거되었습니다.', + 'data' => $template->fresh()->load(['creator:id,name', 'items'])->loadCount('items'), + ]); + } + + /** + * 템플릿 필드 아이템 삭제 + */ + public function destroyTemplateItem(int $templateId, int $itemId): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($templateId); + $item = EsignFieldTemplateItem::where('template_id', $template->id)->findOrFail($itemId); + + $item->delete(); + + // signer_count 재계산 + $maxOrder = EsignFieldTemplateItem::where('template_id', $template->id)->max('signer_order'); + $template->update(['signer_count' => $maxOrder ?: 0]); + + return response()->json([ + 'success' => true, + 'message' => '필드가 삭제되었습니다.', + 'data' => $template->fresh()->load(['creator:id,name', 'items'])->loadCount('items'), ]); } diff --git a/resources/views/esign/templates.blade.php b/resources/views/esign/templates.blade.php index 50a54aa2..408b45a8 100644 --- a/resources/views/esign/templates.blade.php +++ b/resources/views/esign/templates.blade.php @@ -38,62 +38,262 @@ ); }; +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, onSave }) => { +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 || !template) return null; + if (!open || !tpl) 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 }); + 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 (
-
e.stopPropagation()}> -

템플릿 편집

-
-
- - 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 /> +
e.stopPropagation()}> + {/* 헤더 + 탭 */} +
+
+

템플릿 편집

+
-
- -