From edc69040abe79d8912862cfb45e229e6a19fe751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Wed, 11 Mar 2026 11:55:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[esign]=20=EC=A0=84=EC=9E=90=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - draft 상태 계약의 제목, 설명, 서명자 정보, 파일 수정 가능 - 계약 상세 페이지에 '계약 정보 수정' 버튼 추가 - create.blade.php를 생성/수정 겸용으로 확장 --- .../Controllers/ESign/EsignApiController.php | 91 +++++++++++++++++ .../Controllers/ESign/EsignController.php | 9 ++ resources/views/esign/create.blade.php | 99 +++++++++++++++---- resources/views/esign/detail.blade.php | 3 + routes/web.php | 2 + 5 files changed, 186 insertions(+), 18 deletions(-) diff --git a/app/Http/Controllers/ESign/EsignApiController.php b/app/Http/Controllers/ESign/EsignApiController.php index 2f6be04e..16f83ea5 100644 --- a/app/Http/Controllers/ESign/EsignApiController.php +++ b/app/Http/Controllers/ESign/EsignApiController.php @@ -572,6 +572,97 @@ public function store(Request $request): JsonResponse ]); } + /** + * 계약 수정 (draft 상태만) + */ + public function update(Request $request, int $id): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $contract = EsignContract::forTenant($tenantId)->findOrFail($id); + + if ($contract->status !== 'draft') { + return response()->json(['success' => false, 'message' => '초안 상태의 계약만 수정할 수 있습니다.'], 422); + } + + $request->validate([ + 'title' => 'required|string|max:200', + 'description' => 'nullable|string', + 'sign_order_type' => 'required|in:counterpart_first,creator_first', + 'expires_at' => 'nullable|date', + 'signers' => 'required|array|size:2', + 'signers.*.name' => 'required|string|max:100', + 'signers.*.email' => 'required|email|max:200', + 'signers.*.phone' => 'nullable|string|max:20', + 'signers.*.role' => 'required|in:creator,counterpart', + 'file' => 'nullable|file|mimes:pdf,doc,docx|max:20480', + ]); + + $userId = auth()->id(); + + // PDF 파일 교체 + if ($request->hasFile('file')) { + // 기존 파일 삭제 + if ($contract->original_file_path && Storage::disk('local')->exists($contract->original_file_path)) { + Storage::disk('local')->delete($contract->original_file_path); + } + + $file = $request->file('file'); + $converter = new DocxToPdfConverter; + $result = $converter->convertAndStore($file, "esign/{$tenantId}/contracts"); + + $contract->original_file_path = $result['path']; + $contract->original_file_name = $result['name']; + $contract->original_file_hash = $result['hash']; + $contract->original_file_size = $result['size']; + } + + $contract->title = $request->input('title'); + $contract->description = $request->input('description'); + $contract->sign_order_type = $request->input('sign_order_type'); + $contract->expires_at = $request->input('expires_at') + ? \Carbon\Carbon::parse($request->input('expires_at')) + : $contract->expires_at; + $contract->updated_by = $userId; + $contract->save(); + + // 서명자 정보 업데이트 + $signers = $request->input('signers'); + foreach ($signers as $signerData) { + $existingSigner = EsignSigner::withoutGlobalScopes() + ->where('contract_id', $contract->id) + ->where('role', $signerData['role']) + ->first(); + + if ($existingSigner) { + $existingSigner->update([ + 'name' => $signerData['name'], + 'email' => $signerData['email'], + 'phone' => $signerData['phone'] ?? null, + 'sign_order' => $signerData['role'] === 'creator' + ? ($request->input('sign_order_type') === 'creator_first' ? 1 : 2) + : ($request->input('sign_order_type') === 'counterpart_first' ? 1 : 2), + ]); + } + } + + // 감사 로그 + EsignAuditLog::create([ + 'tenant_id' => $tenantId, + 'contract_id' => $contract->id, + 'action' => 'contract_updated', + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'metadata' => ['updated_by' => $userId], + 'created_at' => now(), + ]); + + return response()->json([ + 'success' => true, + 'message' => '계약이 수정되었습니다.', + 'data' => $contract->load('signers'), + ]); + } + /** * 계약 취소 */ diff --git a/app/Http/Controllers/ESign/EsignController.php b/app/Http/Controllers/ESign/EsignController.php index 95ed5179..14e0d256 100644 --- a/app/Http/Controllers/ESign/EsignController.php +++ b/app/Http/Controllers/ESign/EsignController.php @@ -27,6 +27,15 @@ public function create(Request $request): View|Response return view('esign.create'); } + public function edit(Request $request, int $id): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('esign.edit', $id)); + } + + return view('esign.create', ['contractId' => $id]); + } + public function detail(Request $request, int $id): View|Response { if ($request->header('HX-Request')) { diff --git a/resources/views/esign/create.blade.php b/resources/views/esign/create.blade.php index 70f0c824..14edc264 100644 --- a/resources/views/esign/create.blade.php +++ b/resources/views/esign/create.blade.php @@ -1,10 +1,10 @@ @extends('layouts.app') -@section('title', 'SAM E-Sign - 새 계약 생성') +@section('title', isset($contractId) ? 'SAM E-Sign - 계약 수정' : 'SAM E-Sign - 새 계약 생성') @section('content') -
+
@endsection @push('scripts') @@ -22,6 +22,8 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; const IS_ADMIN = window.__esignCreate?.isAdmin || false; +const EDIT_CONTRACT_ID = document.getElementById('esign-create-root')?.dataset.contractId || ''; +const IS_EDIT = !!EDIT_CONTRACT_ID; const SIGNER_COLORS = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B']; const FIELD_TYPE_INFO = { @@ -456,6 +458,8 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i = const [metadata, setMetadata] = useState({}); const [partnerModalOpen, setPartnerModalOpen] = useState(false); const [tenantModalOpen, setTenantModalOpen] = useState(false); + const [editLoading, setEditLoading] = useState(IS_EDIT); + const [existingFileName, setExistingFileName] = useState(''); const fileRef = useRef(null); const hasTemplates = templates.length > 0; @@ -472,6 +476,46 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i = .then(r => r.json()) .then(json => { if (json.success) setTemplates(json.data); }) .catch(() => {}); + + // 수정 모드: 기존 계약 데이터 로드 + if (IS_EDIT) { + fetch(`/esign/contracts/${EDIT_CONTRACT_ID}`, { headers: { 'Accept': 'application/json' } }) + .then(r => r.json()) + .then(json => { + if (json.success) { + const c = json.data; + if (c.status !== 'draft') { + alert('초안 상태의 계약만 수정할 수 있습니다.'); + location.href = `/esign/${EDIT_CONTRACT_ID}`; + return; + } + const creator = (c.signers || []).find(s => s.role === 'creator') || {}; + const counterpart = (c.signers || []).find(s => s.role === 'counterpart') || {}; + const expiresAt = c.expires_at ? new Date(c.expires_at) : null; + const expiresStr = expiresAt + ? expiresAt.getFullYear() + '-' + String(expiresAt.getMonth()+1).padStart(2,'0') + '-' + String(expiresAt.getDate()).padStart(2,'0') + 'T' + String(expiresAt.getHours()).padStart(2,'0') + ':' + String(expiresAt.getMinutes()).padStart(2,'0') + : ''; + setForm({ + title: c.title || '', + description: c.description || '', + sign_order_type: c.sign_order_type || 'counterpart_first', + expires_at: expiresStr, + creator_name: creator.name || '', + creator_email: creator.email || '', + creator_phone: creator.phone || '', + counterpart_name: counterpart.name || '', + counterpart_email: counterpart.email || '', + counterpart_phone: counterpart.phone || '', + }); + // 제목 타입 판별 + const preset = TITLE_PRESETS.find(p => p.value === c.title); + setTitleType(preset ? c.title : '__custom__'); + if (c.original_file_name) setExistingFileName(c.original_file_name); + } + }) + .catch(() => { alert('계약 정보를 불러오지 못했습니다.'); }) + .finally(() => setEditLoading(false)); + } }, []); const handleChange = (key, val) => setForm(f => ({...f, [key]: val})); @@ -697,6 +741,7 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i = const goNext = () => { if (step === 1) { if (!validateStep1()) return; + if (IS_EDIT) { handleSubmit(); return; } if (hasTemplates) { setStep(2); } else { handleSubmit(); } } else if (step === 2) { applyTitleDefaults(form.title); @@ -718,7 +763,7 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i = } }; - // 계약 생성 제출 + // 계약 생성/수정 제출 const handleSubmit = async (goToSend = false) => { setSubmitting(true); setErrors({}); @@ -726,8 +771,8 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i = const fd = new FormData(); Object.entries(form).forEach(([k, v]) => { if (v) fd.append(k, v); }); if (file) fd.append('file', file); - if (templateId) fd.append('template_id', templateId); - if (Object.keys(metadata).length > 0) { + if (!IS_EDIT && templateId) fd.append('template_id', templateId); + if (!IS_EDIT && Object.keys(metadata).length > 0) { Object.entries(metadata).forEach(([k, v]) => { fd.append(`metadata[${k}]`, v || ''); }); } @@ -742,21 +787,35 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i = fd.append('signers[1][phone]', form.counterpart_phone || ''); fd.append('signers[1][role]', 'counterpart'); - const res = await fetch('/esign/contracts/store', { + let url, method; + if (IS_EDIT) { + url = `/esign/contracts/${EDIT_CONTRACT_ID}`; + method = 'PUT'; + // FormData는 PUT을 지원하지 않으므로 _method 사용 + fd.append('_method', 'PUT'); + } else { + url = '/esign/contracts/store'; + method = 'POST'; + } + + const res = await fetch(url, { method: 'POST', headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: fd, }); const json = await res.json(); if (json.success) { - if (goToSend && json.auto_applied) { - location.href = `/esign/${json.data.id}/send`; + const contractId = IS_EDIT ? EDIT_CONTRACT_ID : json.data.id; + if (IS_EDIT) { + location.href = `/esign/${contractId}`; + } else if (goToSend && json.auto_applied) { + location.href = `/esign/${contractId}/send`; } else { - location.href = `/esign/${json.data.id}/fields`; + location.href = `/esign/${contractId}/fields`; } } else { setErrors(json.errors || { general: json.message }); - if (json.errors) setStep(1); // 서버 유효성 에러 → step 1으로 + if (json.errors) setStep(1); } } catch (e) { setErrors({ general: '서버 오류가 발생했습니다.' }); @@ -804,6 +863,7 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus: {file &&

{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)

} + {!file && IS_EDIT && existingFileName &&

현재 파일: {existingFileName} (새 파일을 선택하면 교체됩니다)

}
@@ -862,10 +922,10 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus: {/* 네비게이션 */}
- 취소 -
@@ -1095,7 +1155,7 @@ className="px-4 py-1.5 border border-gray-300 rounded-md text-gray-700 hover:bg- ← 이전
- {templateId && ( + {!IS_EDIT && templateId && (
@@ -1117,9 +1177,11 @@ className="px-4 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text- setPartnerModalOpen(false)} onSelect={handlePartnerSelect} /> setTenantModalOpen(false)} onSelect={handleTenantSelect} /> {/* 헤더 */} + {editLoading &&
계약 정보를 불러오는 중...
} + {!editLoading && <>
- -

새 계약 생성

+ +

{IS_EDIT ? '계약 수정' : '새 계약 생성'}

{IS_ADMIN && (
); }; diff --git a/resources/views/esign/detail.blade.php b/resources/views/esign/detail.blade.php index d6c099cf..35d83c63 100644 --- a/resources/views/esign/detail.blade.php +++ b/resources/views/esign/detail.blade.php @@ -255,6 +255,9 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hov
{c.status === 'draft' && ( <> + + 계약 정보 수정 + 서명 위치 설정 diff --git a/routes/web.php b/routes/web.php index 3cd7713d..21e80b07 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1783,6 +1783,7 @@ Route::get('/templates', [EsignController::class, 'templates'])->name('templates'); Route::get('/templates/{templateId}/fields', [EsignController::class, 'templateFields'])->whereNumber('templateId')->name('template-fields'); Route::get('/{id}', [EsignController::class, 'detail'])->whereNumber('id')->name('detail'); + Route::get('/{id}/edit', [EsignController::class, 'edit'])->whereNumber('id')->name('edit'); Route::get('/{id}/fields', [EsignController::class, 'fields'])->whereNumber('id')->name('fields'); Route::get('/{id}/send', [EsignController::class, 'send'])->whereNumber('id')->name('send'); @@ -1800,6 +1801,7 @@ Route::get('/stats', [EsignApiController::class, 'stats'])->name('stats'); Route::get('/list', [EsignApiController::class, 'index'])->name('list'); Route::post('/store', [EsignApiController::class, 'store'])->name('store'); + Route::put('/{id}', [EsignApiController::class, 'update'])->whereNumber('id')->name('update'); Route::get('/{id}', [EsignApiController::class, 'show'])->whereNumber('id')->name('show'); Route::post('/{id}/cancel', [EsignApiController::class, 'cancel'])->whereNumber('id')->name('cancel'); Route::delete('/destroy', [EsignApiController::class, 'destroy'])->name('destroy');