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)}