Files
sam-manage/resources/views/esign/dashboard.blade.php
김보곤 2dea60de23 feat:계약 대시보드 선택 삭제 기능 추가
- EsignApiController에 destroy 메서드 추가 (복수 삭제 지원)
- 관련 파일(PDF, 서명이미지) 및 레코드(서명자, 필드, 감사로그) 일괄 삭제
- 서명 진행 중(pending, partially_signed) 계약은 삭제 차단
- DELETE /esign/contracts/destroy 라우트 추가
- 대시보드에 체크박스 전체/개별 선택 + 삭제 버튼 UI 추가
- 삭제 전 confirm 확인 다이얼로그

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 10:11:14 +09:00

244 lines
12 KiB
PHP

@extends('layouts.app')
@section('title', 'SAM E-Sign')
@section('content')
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="esign-dashboard-root"></div>
@endsection
@push('scripts')
@include('partials.react-cdn')
<script src="https://unpkg.com/lucide@latest"></script>
@verbatim
<script type="text/babel">
const { useState, useEffect, useCallback } = React;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const getHeaders = () => ({
'Accept': 'application/json',
'X-CSRF-TOKEN': csrfToken,
});
const STATUS_MAP = {
draft: { label: '초안', color: 'bg-gray-100 text-gray-700' },
pending: { label: '서명 대기', color: 'bg-blue-100 text-blue-700' },
partially_signed: { label: '부분 서명', color: 'bg-yellow-100 text-yellow-700' },
completed: { label: '완료', color: 'bg-green-100 text-green-700' },
expired: { label: '만료', color: 'bg-red-100 text-red-700' },
cancelled: { label: '취소', color: 'bg-gray-100 text-gray-500' },
rejected: { label: '거절', color: 'bg-red-100 text-red-700' },
};
const StatusBadge = ({ status }) => {
const s = STATUS_MAP[status] || { label: status, color: 'bg-gray-100 text-gray-700' };
return <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${s.color}`}>{s.label}</span>;
};
const StatsCard = ({ label, value, color }) => (
<div className={`bg-white rounded-lg border p-4 ${color || ''}`}>
<p className="text-sm text-gray-500">{label}</p>
<p className="text-2xl font-bold mt-1">{value}</p>
</div>
);
const App = () => {
const [contracts, setContracts] = useState([]);
const [stats, setStats] = useState({});
const [loading, setLoading] = useState(true);
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 {
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 {
const params = new URLSearchParams({ page, per_page: 20 });
if (filter.status) params.append('status', filter.status);
if (filter.search) params.append('search', filter.search);
const res = await fetch(`/esign/contracts/list?${params}`, { headers: getHeaders() });
const json = await res.json();
if (json.success) {
setContracts(json.data.data || []);
setPagination({ current_page: json.data.current_page, last_page: json.data.last_page, total: json.data.total });
}
} 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]);
return (
<div className="p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">SAM E-Sign</h1>
<a href="/esign/create"
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium"
hx-boost="false">
+ 계약 생성
</a>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3 mb-6">
<StatsCard label="전체" value={stats.total || 0} />
<StatsCard label="초안" value={stats.draft || 0} />
<StatsCard label="서명 대기" value={stats.pending || 0} color="border-blue-200" />
<StatsCard label="부분 서명" value={stats.partially_signed || 0} color="border-yellow-200" />
<StatsCard label="완료" value={stats.completed || 0} color="border-green-200" />
<StatsCard label="만료" value={stats.expired || 0} />
<StatsCard label="취소/거절" value={(stats.cancelled || 0) + (stats.rejected || 0)} />
</div>
{/* 필터 */}
<div className="flex items-center gap-3 mb-4">
<select value={filter.status} onChange={e => { setFilter(f => ({...f, status: e.target.value})); setPage(1); }}
className="border rounded-lg px-3 py-2 text-sm">
<option value="">전체 상태</option>
{Object.entries(STATUS_MAP).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<input type="text" placeholder="제목 또는 코드 검색..." value={filter.search}
onChange={e => { 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 && (
<button onClick={handleDelete} disabled={deleting}
className="ml-auto inline-flex items-center gap-1.5 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium disabled:opacity-50 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
</svg>
{deleting ? '삭제 중...' : `${selected.size}건 삭제`}
</button>
)}
</div>
{/* 목록 */}
<div className="bg-white rounded-lg border overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-3 w-10">
<input type="checkbox" checked={contracts.length > 0 && selected.size === contracts.length}
onChange={toggleSelectAll} className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">계약코드</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">제목</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">서명자</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">상태</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">생성일</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">만료일</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr><td colSpan="7" className="px-4 py-8 text-center text-gray-400">로딩 ...</td></tr>
) : contracts.length === 0 ? (
<tr><td colSpan="7" className="px-4 py-8 text-center text-gray-400">계약이 없습니다.</td></tr>
) : contracts.map(c => (
<tr key={c.id} className={`hover:bg-gray-50 cursor-pointer ${selected.has(c.id) ? 'bg-blue-50' : ''}`}>
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selected.has(c.id)} onChange={() => toggleSelect(c.id)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
</td>
<td className="px-4 py-3 text-sm font-mono text-gray-600" onClick={() => location.href = `/esign/${c.id}`}>{c.contract_code}</td>
<td className="px-4 py-3 text-sm font-medium text-gray-900" onClick={() => location.href = `/esign/${c.id}`}>{c.title}</td>
<td className="px-4 py-3 text-sm text-gray-600" onClick={() => location.href = `/esign/${c.id}`}>
{(c.signers || []).map(s => (
<span key={s.id} className="inline-flex items-center mr-2">
<span className={`w-2 h-2 rounded-full mr-1 ${s.status === 'signed' ? 'bg-green-500' : 'bg-gray-300'}`}></span>
{s.name}
</span>
))}
</td>
<td className="px-4 py-3" onClick={() => location.href = `/esign/${c.id}`}><StatusBadge status={c.status} /></td>
<td className="px-4 py-3 text-sm text-gray-500" onClick={() => location.href = `/esign/${c.id}`}>{c.created_at?.slice(0,10)}</td>
<td className="px-4 py-3 text-sm text-gray-500" onClick={() => location.href = `/esign/${c.id}`}>{c.expires_at?.slice(0,10)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
{pagination.last_page > 1 && (
<div className="flex items-center justify-between mt-4">
<span className="text-sm text-gray-500"> {pagination.total}</span>
<div className="flex gap-1">
{Array.from({length: pagination.last_page}, (_, i) => i + 1).map(p => (
<button key={p} onClick={() => setPage(p)}
className={`px-3 py-1 text-sm rounded ${p === pagination.current_page ? 'bg-blue-600 text-white' : 'bg-white border text-gray-700 hover:bg-gray-50'}`}>
{p}
</button>
))}
</div>
</div>
)}
</div>
);
};
ReactDOM.createRoot(document.getElementById('esign-dashboard-root')).render(<App />);
</script>
@endverbatim
@endpush