From 36654d999275a6761a942d67a9477a2002d15e25 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:45:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EA=B3=84=EC=95=BD=20=ED=9C=B4=EC=A7=80?= =?UTF-8?q?=ED=86=B5=20=EA=B8=B0=EB=8A=A5=20(=EC=86=8C=ED=94=84=ED=8A=B8?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C/=EB=B3=B5=EA=B5=AC/=EC=98=81=EA=B5=AC?= =?UTF-8?q?=EC=82=AD=EC=A0=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드: - destroy를 SoftDelete 방식으로 변경 (deleted_at + deleted_by 기록) - trashed: 휴지통 목록 조회 API 추가 - restore: 선택 복구 API 추가 - forceDestroy: 영구 삭제 API 추가 (파일+관련 레코드 완전 삭제) - 라우트 3개 추가 (trashed, restore, force-destroy) 프론트엔드: - 대시보드에 탭 UI 추가 (계약 목록 / 휴지통) - 휴지통 탭: 삭제된 계약 목록, 삭제일 표시 - 선택 복구(파란색) / 영구삭제(빨간색) 버튼 - 휴지통 건수 뱃지 표시 - 삭제 시 메시지를 "휴지통으로 이동"으로 변경 Co-Authored-By: Claude Opus 4.6 --- .../Controllers/ESign/EsignApiController.php | 97 ++++- resources/views/esign/dashboard.blade.php | 351 ++++++++++++++---- routes/web.php | 3 + 3 files changed, 372 insertions(+), 79 deletions(-) diff --git a/app/Http/Controllers/ESign/EsignApiController.php b/app/Http/Controllers/ESign/EsignApiController.php index 82da550e..d48702d7 100644 --- a/app/Http/Controllers/ESign/EsignApiController.php +++ b/app/Http/Controllers/ESign/EsignApiController.php @@ -220,7 +220,7 @@ public function cancel(Request $request, int $id): JsonResponse } /** - * 계약 삭제 (단건/복수) + * 계약 삭제 - 휴지통으로 이동 (SoftDelete) */ public function destroy(Request $request): JsonResponse { @@ -247,6 +247,95 @@ public function destroy(Request $request): JsonResponse ], 422); } + $deletedCount = 0; + foreach ($contracts as $contract) { + $contract->update(['deleted_by' => auth()->id()]); + $contract->delete(); // SoftDelete → deleted_at 설정 + $deletedCount++; + } + + return response()->json([ + 'success' => true, + 'message' => "{$deletedCount}건의 계약이 휴지통으로 이동되었습니다.", + ]); + } + + /** + * 휴지통 목록 + */ + public function trashed(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $query = EsignContract::onlyTrashed() + ->where('tenant_id', $tenantId) + ->with(['signers:id,contract_id,name,role,status']); + + if ($search = $request->input('search')) { + $query->where(function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('contract_code', 'like', "%{$search}%"); + }); + } + + $perPage = $request->input('per_page', 20); + $data = $query->orderBy('deleted_at', 'desc')->paginate($perPage); + + return response()->json(['success' => true, 'data' => $data]); + } + + /** + * 휴지통에서 복구 + */ + public function restore(Request $request): JsonResponse + { + $request->validate([ + 'ids' => 'required|array|min:1', + 'ids.*' => 'required|integer', + ]); + + $tenantId = session('selected_tenant_id', 1); + $contracts = EsignContract::onlyTrashed() + ->where('tenant_id', $tenantId) + ->whereIn('id', $request->input('ids')) + ->get(); + + if ($contracts->isEmpty()) { + return response()->json(['success' => false, 'message' => '복구할 계약을 찾을 수 없습니다.'], 404); + } + + $restoredCount = 0; + foreach ($contracts as $contract) { + $contract->update(['deleted_by' => null]); + $contract->restore(); + $restoredCount++; + } + + return response()->json([ + 'success' => true, + 'message' => "{$restoredCount}건의 계약이 복구되었습니다.", + ]); + } + + /** + * 영구 삭제 + */ + public function forceDestroy(Request $request): JsonResponse + { + $request->validate([ + 'ids' => 'required|array|min:1', + 'ids.*' => 'required|integer', + ]); + + $tenantId = session('selected_tenant_id', 1); + $contracts = EsignContract::onlyTrashed() + ->where('tenant_id', $tenantId) + ->whereIn('id', $request->input('ids')) + ->get(); + + if ($contracts->isEmpty()) { + return response()->json(['success' => false, 'message' => '삭제할 계약을 찾을 수 없습니다.'], 404); + } + $deletedCount = 0; foreach ($contracts as $contract) { // 관련 파일 삭제 @@ -265,17 +354,17 @@ public function destroy(Request $request): JsonResponse } } - // 관련 레코드 삭제 + // 관련 레코드 영구 삭제 EsignSignField::where('contract_id', $contract->id)->delete(); EsignAuditLog::where('contract_id', $contract->id)->delete(); EsignSigner::withoutGlobalScopes()->where('contract_id', $contract->id)->delete(); - $contract->delete(); + $contract->forceDelete(); $deletedCount++; } return response()->json([ 'success' => true, - 'message' => "{$deletedCount}건의 계약이 삭제되었습니다.", + 'message' => "{$deletedCount}건의 계약이 영구 삭제되었습니다.", ]); } diff --git a/resources/views/esign/dashboard.blade.php b/resources/views/esign/dashboard.blade.php index 7be1dd50..8237e4ab 100644 --- a/resources/views/esign/dashboard.blade.php +++ b/resources/views/esign/dashboard.blade.php @@ -43,9 +43,21 @@ ); -const App = () => { +const RefreshIcon = () => ( + + + +); + +const TrashIcon = ({ size = 14 }) => ( + + + +); + +// ─── 계약 목록 탭 ─── +const ContractList = ({ onRefreshStats }) => { const [contracts, setContracts] = useState([]); - const [stats, setStats] = useState({}); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState({ status: '', search: '' }); const [page, setPage] = useState(1); @@ -53,14 +65,6 @@ const [selected, setSelected] = useState(new Set()); const [deleting, setDeleting] = useState(false); - const fetchStats = useCallback(async () => { - try { - const res = await fetch('/esign/contracts/stats', { headers: getHeaders() }); - const json = await res.json(); - if (json.success) setStats(json.data); - } catch (e) { console.error(e); } - }, []); - const fetchContracts = useCallback(async () => { setLoading(true); try { @@ -79,32 +83,20 @@ 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; - }); - }; + useEffect(() => { fetchContracts(); }, [fetchContracts]); + 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))); - } + selected.size === contracts.length ? setSelected(new Set()) : 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; + const activeIds = contracts.filter(c => selected.has(c.id) && ['pending', 'partially_signed'].includes(c.status)); + if (activeIds.length > 0) { alert('서명 진행 중인 계약은 삭제할 수 없습니다. 먼저 취소해 주세요.'); return; } + if (!confirm(`선택한 ${selected.size}건의 계약을 휴지통으로 이동하시겠습니까?`)) return; setDeleting(true); try { @@ -114,51 +106,14 @@ body: JSON.stringify({ ids: [...selected] }), }); const json = await res.json(); - if (json.success) { - fetchContracts(); - fetchStats(); - } else { - alert(json.message || '삭제에 실패했습니다.'); - } - } catch (e) { - alert('삭제 중 오류가 발생했습니다.'); - } + if (json.success) { fetchContracts(); onRefreshStats(); } + else alert(json.message || '삭제에 실패했습니다.'); + } catch (e) { alert('삭제 중 오류가 발생했습니다.'); } setDeleting(false); }; - useEffect(() => { fetchStats(); }, [fetchStats]); - useEffect(() => { fetchContracts(); }, [fetchContracts]); - return ( -
-
-
-

SAM E-Sign

- -
- - + 새 계약 생성 - -
- - {/* 통계 카드 */} -
- - - - - - - -
- + <> {/* 필터 */}
{ setSearch(e.target.value); setPage(1); }} + className="border rounded-lg px-3 py-2 text-sm flex-1 max-w-xs" /> +
+ + +
+
+ + {/* 목록 */} +
+ + + + + + + + + + + + + {loading ? ( + + ) : contracts.length === 0 ? ( + + ) : contracts.map(c => ( + + + + + + + + + ))} + +
+ 0 && selected.size === contracts.length} + onChange={toggleSelectAll} className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" /> + 계약코드제목서명자상태삭제일
로딩 중...
+
+ + + + 휴지통이 비어있습니다. +
+
+ toggleSelect(c.id)} + className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" /> + {c.contract_code}{c.title} + {(c.signers || []).map(s => {s.name})} + {timeAgo(c.deleted_at)}
+
+ + {/* 페이지네이션 */} + {pagination.last_page > 1 && ( +
+ 총 {pagination.total}건 +
+ {Array.from({length: pagination.last_page}, (_, i) => i + 1).map(p => ( + + ))} +
+
+ )} + + ); +}; + +// ─── App ─── +const App = () => { + const [tab, setTab] = useState('contracts'); // 'contracts' | 'trash' + const [stats, setStats] = useState({}); + const [trashCount, setTrashCount] = useState(0); + + const fetchStats = useCallback(async () => { + try { + const res = await fetch('/esign/contracts/stats', { headers: getHeaders() }); + const json = await res.json(); + if (json.success) setStats(json.data); + } catch (e) { console.error(e); } + }, []); + + const fetchTrashCount = useCallback(async () => { + try { + const res = await fetch('/esign/contracts/trashed?per_page=1', { headers: getHeaders() }); + const json = await res.json(); + if (json.success) setTrashCount(json.data.total || 0); + } catch (e) { console.error(e); } + }, []); + + const refreshAll = () => { fetchStats(); fetchTrashCount(); }; + + useEffect(() => { fetchStats(); fetchTrashCount(); }, []); + + return ( +
+
+
+

SAM E-Sign

+ +
+ + + 새 계약 생성 + +
+ + {/* 통계 카드 (계약 목록 탭에서만) */} + {tab === 'contracts' && ( +
+ + + + + + + +
+ )} + + {/* 탭 */} +
+ + +
+ + {/* 탭 콘텐츠 */} + {tab === 'contracts' && } + {tab === 'trash' && }
); }; diff --git a/routes/web.php b/routes/web.php index 326a2b6d..a7e73ac6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1411,6 +1411,9 @@ 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::get('/trashed', [EsignApiController::class, 'trashed'])->name('trashed'); + Route::post('/restore', [EsignApiController::class, 'restore'])->name('restore'); + Route::delete('/force-destroy', [EsignApiController::class, 'forceDestroy'])->name('force-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');