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');