From 2dea60de23fdf72c13fbbc05a4aa4b930fd5ff8f 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:32:09 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EA=B3=84=EC=95=BD=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EC=84=A0=ED=83=9D=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EsignApiController에 destroy 메서드 추가 (복수 삭제 지원) - 관련 파일(PDF, 서명이미지) 및 레코드(서명자, 필드, 감사로그) 일괄 삭제 - 서명 진행 중(pending, partially_signed) 계약은 삭제 차단 - DELETE /esign/contracts/destroy 라우트 추가 - 대시보드에 체크박스 전체/개별 선택 + 삭제 버튼 UI 추가 - 삭제 전 confirm 확인 다이얼로그 Co-Authored-By: Claude Opus 4.6 --- .../Controllers/ESign/EsignApiController.php | 60 +++++++++++++ resources/views/esign/dashboard.blade.php | 87 ++++++++++++++++--- routes/web.php | 1 + 3 files changed, 138 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/ESign/EsignApiController.php b/app/Http/Controllers/ESign/EsignApiController.php index 440106a6..82da550e 100644 --- a/app/Http/Controllers/ESign/EsignApiController.php +++ b/app/Http/Controllers/ESign/EsignApiController.php @@ -219,6 +219,66 @@ public function cancel(Request $request, int $id): JsonResponse return response()->json(['success' => true, 'message' => '계약이 취소되었습니다.']); } + /** + * 계약 삭제 (단건/복수) + */ + public function destroy(Request $request): JsonResponse + { + $request->validate([ + 'ids' => 'required|array|min:1', + 'ids.*' => 'required|integer', + ]); + + $tenantId = session('selected_tenant_id', 1); + $ids = $request->input('ids'); + + $contracts = EsignContract::forTenant($tenantId)->whereIn('id', $ids)->get(); + + if ($contracts->isEmpty()) { + return response()->json(['success' => false, 'message' => '삭제할 계약을 찾을 수 없습니다.'], 404); + } + + // 진행 중인 계약(pending, partially_signed) 차단 + $activeContracts = $contracts->filter(fn ($c) => in_array($c->status, ['pending', 'partially_signed'])); + if ($activeContracts->isNotEmpty()) { + return response()->json([ + 'success' => false, + 'message' => '서명 진행 중인 계약은 삭제할 수 없습니다. 먼저 취소해 주세요.', + ], 422); + } + + $deletedCount = 0; + foreach ($contracts as $contract) { + // 관련 파일 삭제 + if ($contract->original_file_path && Storage::disk('local')->exists($contract->original_file_path)) { + Storage::disk('local')->delete($contract->original_file_path); + } + if ($contract->signed_file_path && Storage::disk('local')->exists($contract->signed_file_path)) { + Storage::disk('local')->delete($contract->signed_file_path); + } + + // 서명 이미지 파일 삭제 + $signers = EsignSigner::withoutGlobalScopes()->where('contract_id', $contract->id)->get(); + foreach ($signers as $signer) { + if ($signer->signature_image_path && Storage::disk('local')->exists($signer->signature_image_path)) { + Storage::disk('local')->delete($signer->signature_image_path); + } + } + + // 관련 레코드 삭제 + EsignSignField::where('contract_id', $contract->id)->delete(); + EsignAuditLog::where('contract_id', $contract->id)->delete(); + EsignSigner::withoutGlobalScopes()->where('contract_id', $contract->id)->delete(); + $contract->delete(); + $deletedCount++; + } + + return response()->json([ + 'success' => true, + 'message' => "{$deletedCount}건의 계약이 삭제되었습니다.", + ]); + } + /** * 서명 위치 설정 */ diff --git a/resources/views/esign/dashboard.blade.php b/resources/views/esign/dashboard.blade.php index 0e420582..52b83d76 100644 --- a/resources/views/esign/dashboard.blade.php +++ b/resources/views/esign/dashboard.blade.php @@ -50,6 +50,8 @@ const [filter, setFilter] = useState({ status: '', search: '' }); const [page, setPage] = useState(1); const [pagination, setPagination] = useState({}); + const [selected, setSelected] = useState(new Set()); + const [deleting, setDeleting] = useState(false); const fetchStats = useCallback(async () => { try { @@ -74,8 +76,56 @@ } } catch (e) { console.error(e); } setLoading(false); + setSelected(new Set()); }, [filter, page]); + const toggleSelect = (id) => { + setSelected(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (selected.size === contracts.length) { + setSelected(new Set()); + } else { + setSelected(new Set(contracts.map(c => c.id))); + } + }; + + const handleDelete = async () => { + if (selected.size === 0) return; + + const activeIds = contracts.filter(c => selected.has(c.id) && ['pending', 'partially_signed'].includes(c.status)).map(c => c.id); + if (activeIds.length > 0) { + alert('서명 진행 중인 계약은 삭제할 수 없습니다. 먼저 취소해 주세요.'); + return; + } + + if (!confirm(`선택한 ${selected.size}건의 계약을 삭제하시겠습니까?\n\n삭제된 계약은 복구할 수 없습니다.`)) return; + + setDeleting(true); + try { + const res = await fetch('/esign/contracts/destroy', { + method: 'DELETE', + headers: { ...getHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: [...selected] }), + }); + const json = await res.json(); + if (json.success) { + fetchContracts(); + fetchStats(); + } else { + alert(json.message || '삭제에 실패했습니다.'); + } + } catch (e) { + alert('삭제 중 오류가 발생했습니다.'); + } + setDeleting(false); + }; + useEffect(() => { fetchStats(); }, [fetchStats]); useEffect(() => { fetchContracts(); }, [fetchContracts]); @@ -102,7 +152,7 @@ className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg {/* 필터 */} -
+
{ setFilter(f => ({...f, search: e.target.value})); setPage(1); }} className="border rounded-lg px-3 py-2 text-sm flex-1 max-w-xs" /> + {selected.size > 0 && ( + + )}
{/* 목록 */} @@ -118,6 +177,10 @@ className="border rounded-lg px-3 py-2 text-sm flex-1 max-w-xs" /> + @@ -128,14 +191,18 @@ className="border rounded-lg px-3 py-2 text-sm flex-1 max-w-xs" /> {loading ? ( - + ) : contracts.length === 0 ? ( - + ) : contracts.map(c => ( - location.href = `/esign/${c.id}`}> - - - + + + + - - - + + + ))} diff --git a/routes/web.php b/routes/web.php index 40ac6c9e..326a2b6d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1410,6 +1410,7 @@ Route::post('/store', [EsignApiController::class, 'store'])->name('store'); 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'); Route::post('/{id}/fields', [EsignApiController::class, 'configureFields'])->whereNumber('id')->name('fields'); Route::post('/{id}/send', [EsignApiController::class, 'send'])->whereNumber('id')->name('send'); Route::post('/{id}/remind', [EsignApiController::class, 'remind'])->whereNumber('id')->name('remind');
+ 0 && selected.size === contracts.length} + onChange={toggleSelectAll} className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" /> + 계약코드 제목 서명자
로딩 중...
로딩 중...
계약이 없습니다.
계약이 없습니다.
{c.contract_code}{c.title} +
e.stopPropagation()}> + toggleSelect(c.id)} + className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" /> + location.href = `/esign/${c.id}`}>{c.contract_code} location.href = `/esign/${c.id}`}>{c.title} location.href = `/esign/${c.id}`}> {(c.signers || []).map(s => ( @@ -143,9 +210,9 @@ className="border rounded-lg px-3 py-2 text-sm flex-1 max-w-xs" /> ))} {c.created_at?.slice(0,10)}{c.expires_at?.slice(0,10)} location.href = `/esign/${c.id}`}> location.href = `/esign/${c.id}`}>{c.created_at?.slice(0,10)} location.href = `/esign/${c.id}`}>{c.expires_at?.slice(0,10)}