feat: [esign] 전자계약 수정 기능 추가
- draft 상태 계약의 제목, 설명, 서명자 정보, 파일 수정 가능 - 계약 상세 페이지에 '계약 정보 수정' 버튼 추가 - create.blade.php를 생성/수정 겸용으로 확장
This commit is contained in:
@@ -572,6 +572,97 @@ public function store(Request $request): JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 수정 (draft 상태만)
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$contract = EsignContract::forTenant($tenantId)->findOrFail($id);
|
||||
|
||||
if ($contract->status !== 'draft') {
|
||||
return response()->json(['success' => false, 'message' => '초안 상태의 계약만 수정할 수 있습니다.'], 422);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:200',
|
||||
'description' => 'nullable|string',
|
||||
'sign_order_type' => 'required|in:counterpart_first,creator_first',
|
||||
'expires_at' => 'nullable|date',
|
||||
'signers' => 'required|array|size:2',
|
||||
'signers.*.name' => 'required|string|max:100',
|
||||
'signers.*.email' => 'required|email|max:200',
|
||||
'signers.*.phone' => 'nullable|string|max:20',
|
||||
'signers.*.role' => 'required|in:creator,counterpart',
|
||||
'file' => 'nullable|file|mimes:pdf,doc,docx|max:20480',
|
||||
]);
|
||||
|
||||
$userId = auth()->id();
|
||||
|
||||
// PDF 파일 교체
|
||||
if ($request->hasFile('file')) {
|
||||
// 기존 파일 삭제
|
||||
if ($contract->original_file_path && Storage::disk('local')->exists($contract->original_file_path)) {
|
||||
Storage::disk('local')->delete($contract->original_file_path);
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
$converter = new DocxToPdfConverter;
|
||||
$result = $converter->convertAndStore($file, "esign/{$tenantId}/contracts");
|
||||
|
||||
$contract->original_file_path = $result['path'];
|
||||
$contract->original_file_name = $result['name'];
|
||||
$contract->original_file_hash = $result['hash'];
|
||||
$contract->original_file_size = $result['size'];
|
||||
}
|
||||
|
||||
$contract->title = $request->input('title');
|
||||
$contract->description = $request->input('description');
|
||||
$contract->sign_order_type = $request->input('sign_order_type');
|
||||
$contract->expires_at = $request->input('expires_at')
|
||||
? \Carbon\Carbon::parse($request->input('expires_at'))
|
||||
: $contract->expires_at;
|
||||
$contract->updated_by = $userId;
|
||||
$contract->save();
|
||||
|
||||
// 서명자 정보 업데이트
|
||||
$signers = $request->input('signers');
|
||||
foreach ($signers as $signerData) {
|
||||
$existingSigner = EsignSigner::withoutGlobalScopes()
|
||||
->where('contract_id', $contract->id)
|
||||
->where('role', $signerData['role'])
|
||||
->first();
|
||||
|
||||
if ($existingSigner) {
|
||||
$existingSigner->update([
|
||||
'name' => $signerData['name'],
|
||||
'email' => $signerData['email'],
|
||||
'phone' => $signerData['phone'] ?? null,
|
||||
'sign_order' => $signerData['role'] === 'creator'
|
||||
? ($request->input('sign_order_type') === 'creator_first' ? 1 : 2)
|
||||
: ($request->input('sign_order_type') === 'counterpart_first' ? 1 : 2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 감사 로그
|
||||
EsignAuditLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'contract_id' => $contract->id,
|
||||
'action' => 'contract_updated',
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'metadata' => ['updated_by' => $userId],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '계약이 수정되었습니다.',
|
||||
'data' => $contract->load('signers'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 취소
|
||||
*/
|
||||
|
||||
@@ -27,6 +27,15 @@ public function create(Request $request): View|Response
|
||||
return view('esign.create');
|
||||
}
|
||||
|
||||
public function edit(Request $request, int $id): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('esign.edit', $id));
|
||||
}
|
||||
|
||||
return view('esign.create', ['contractId' => $id]);
|
||||
}
|
||||
|
||||
public function detail(Request $request, int $id): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'SAM E-Sign - 새 계약 생성')
|
||||
@section('title', isset($contractId) ? 'SAM E-Sign - 계약 수정' : 'SAM E-Sign - 새 계약 생성')
|
||||
|
||||
@section('content')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<div id="esign-create-root"></div>
|
||||
<div id="esign-create-root" data-contract-id="{{ $contractId ?? '' }}"></div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
const IS_ADMIN = window.__esignCreate?.isAdmin || false;
|
||||
const EDIT_CONTRACT_ID = document.getElementById('esign-create-root')?.dataset.contractId || '';
|
||||
const IS_EDIT = !!EDIT_CONTRACT_ID;
|
||||
|
||||
const SIGNER_COLORS = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'];
|
||||
const FIELD_TYPE_INFO = {
|
||||
@@ -456,6 +458,8 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
|
||||
const [metadata, setMetadata] = useState({});
|
||||
const [partnerModalOpen, setPartnerModalOpen] = useState(false);
|
||||
const [tenantModalOpen, setTenantModalOpen] = useState(false);
|
||||
const [editLoading, setEditLoading] = useState(IS_EDIT);
|
||||
const [existingFileName, setExistingFileName] = useState('');
|
||||
const fileRef = useRef(null);
|
||||
|
||||
const hasTemplates = templates.length > 0;
|
||||
@@ -472,6 +476,46 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
|
||||
.then(r => r.json())
|
||||
.then(json => { if (json.success) setTemplates(json.data); })
|
||||
.catch(() => {});
|
||||
|
||||
// 수정 모드: 기존 계약 데이터 로드
|
||||
if (IS_EDIT) {
|
||||
fetch(`/esign/contracts/${EDIT_CONTRACT_ID}`, { headers: { 'Accept': 'application/json' } })
|
||||
.then(r => r.json())
|
||||
.then(json => {
|
||||
if (json.success) {
|
||||
const c = json.data;
|
||||
if (c.status !== 'draft') {
|
||||
alert('초안 상태의 계약만 수정할 수 있습니다.');
|
||||
location.href = `/esign/${EDIT_CONTRACT_ID}`;
|
||||
return;
|
||||
}
|
||||
const creator = (c.signers || []).find(s => s.role === 'creator') || {};
|
||||
const counterpart = (c.signers || []).find(s => s.role === 'counterpart') || {};
|
||||
const expiresAt = c.expires_at ? new Date(c.expires_at) : null;
|
||||
const expiresStr = expiresAt
|
||||
? expiresAt.getFullYear() + '-' + String(expiresAt.getMonth()+1).padStart(2,'0') + '-' + String(expiresAt.getDate()).padStart(2,'0') + 'T' + String(expiresAt.getHours()).padStart(2,'0') + ':' + String(expiresAt.getMinutes()).padStart(2,'0')
|
||||
: '';
|
||||
setForm({
|
||||
title: c.title || '',
|
||||
description: c.description || '',
|
||||
sign_order_type: c.sign_order_type || 'counterpart_first',
|
||||
expires_at: expiresStr,
|
||||
creator_name: creator.name || '',
|
||||
creator_email: creator.email || '',
|
||||
creator_phone: creator.phone || '',
|
||||
counterpart_name: counterpart.name || '',
|
||||
counterpart_email: counterpart.email || '',
|
||||
counterpart_phone: counterpart.phone || '',
|
||||
});
|
||||
// 제목 타입 판별
|
||||
const preset = TITLE_PRESETS.find(p => p.value === c.title);
|
||||
setTitleType(preset ? c.title : '__custom__');
|
||||
if (c.original_file_name) setExistingFileName(c.original_file_name);
|
||||
}
|
||||
})
|
||||
.catch(() => { alert('계약 정보를 불러오지 못했습니다.'); })
|
||||
.finally(() => setEditLoading(false));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleChange = (key, val) => setForm(f => ({...f, [key]: val}));
|
||||
@@ -697,6 +741,7 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
|
||||
const goNext = () => {
|
||||
if (step === 1) {
|
||||
if (!validateStep1()) return;
|
||||
if (IS_EDIT) { handleSubmit(); return; }
|
||||
if (hasTemplates) { setStep(2); } else { handleSubmit(); }
|
||||
} else if (step === 2) {
|
||||
applyTitleDefaults(form.title);
|
||||
@@ -718,7 +763,7 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
|
||||
}
|
||||
};
|
||||
|
||||
// 계약 생성 제출
|
||||
// 계약 생성/수정 제출
|
||||
const handleSubmit = async (goToSend = false) => {
|
||||
setSubmitting(true);
|
||||
setErrors({});
|
||||
@@ -726,8 +771,8 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
|
||||
const fd = new FormData();
|
||||
Object.entries(form).forEach(([k, v]) => { if (v) fd.append(k, v); });
|
||||
if (file) fd.append('file', file);
|
||||
if (templateId) fd.append('template_id', templateId);
|
||||
if (Object.keys(metadata).length > 0) {
|
||||
if (!IS_EDIT && templateId) fd.append('template_id', templateId);
|
||||
if (!IS_EDIT && Object.keys(metadata).length > 0) {
|
||||
Object.entries(metadata).forEach(([k, v]) => { fd.append(`metadata[${k}]`, v || ''); });
|
||||
}
|
||||
|
||||
@@ -742,21 +787,35 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
|
||||
fd.append('signers[1][phone]', form.counterpart_phone || '');
|
||||
fd.append('signers[1][role]', 'counterpart');
|
||||
|
||||
const res = await fetch('/esign/contracts/store', {
|
||||
let url, method;
|
||||
if (IS_EDIT) {
|
||||
url = `/esign/contracts/${EDIT_CONTRACT_ID}`;
|
||||
method = 'PUT';
|
||||
// FormData는 PUT을 지원하지 않으므로 _method 사용
|
||||
fd.append('_method', 'PUT');
|
||||
} else {
|
||||
url = '/esign/contracts/store';
|
||||
method = 'POST';
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
||||
body: fd,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
if (goToSend && json.auto_applied) {
|
||||
location.href = `/esign/${json.data.id}/send`;
|
||||
const contractId = IS_EDIT ? EDIT_CONTRACT_ID : json.data.id;
|
||||
if (IS_EDIT) {
|
||||
location.href = `/esign/${contractId}`;
|
||||
} else if (goToSend && json.auto_applied) {
|
||||
location.href = `/esign/${contractId}/send`;
|
||||
} else {
|
||||
location.href = `/esign/${json.data.id}/fields`;
|
||||
location.href = `/esign/${contractId}/fields`;
|
||||
}
|
||||
} else {
|
||||
setErrors(json.errors || { general: json.message });
|
||||
if (json.errors) setStep(1); // 서버 유효성 에러 → step 1으로
|
||||
if (json.errors) setStep(1);
|
||||
}
|
||||
} catch (e) {
|
||||
setErrors({ general: '서버 오류가 발생했습니다.' });
|
||||
@@ -804,6 +863,7 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:
|
||||
<input ref={fileRef} type="file" accept=".pdf,.doc,.docx" onChange={handleFileSelect}
|
||||
className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm file:mr-3 file:py-0.5 file:px-2.5 file:rounded file:border-0 file:bg-blue-50 file:text-blue-700 file:text-xs file:font-medium file:cursor-pointer" />
|
||||
{file && <p className="text-xs text-gray-500 mt-1">{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)</p>}
|
||||
{!file && IS_EDIT && existingFileName && <p className="text-xs text-gray-500 mt-1">현재 파일: {existingFileName} <span className="text-gray-400">(새 파일을 선택하면 교체됩니다)</span></p>}
|
||||
</div>
|
||||
<div className="flex gap-3" style={{ flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: '1 1 200px' }}>
|
||||
@@ -862,10 +922,10 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:
|
||||
|
||||
{/* 네비게이션 */}
|
||||
<div className="flex justify-between">
|
||||
<a href="/esign" className="px-4 py-1.5 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 text-sm transition-colors" hx-boost="false">취소</a>
|
||||
<button type="button" onClick={goNext}
|
||||
className="px-5 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium transition-colors">
|
||||
{hasTemplates ? '다음 \u2192' : '계약 생성 및 서명 위치 설정'}
|
||||
<a href={IS_EDIT ? `/esign/${EDIT_CONTRACT_ID}` : '/esign'} className="px-4 py-1.5 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 text-sm transition-colors" hx-boost="false">취소</a>
|
||||
<button type="button" onClick={goNext} disabled={submitting}
|
||||
className="px-5 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium transition-colors disabled:opacity-50">
|
||||
{IS_EDIT ? (submitting ? '수정 중...' : '계약 수정') : hasTemplates ? '다음 \u2192' : '계약 생성 및 서명 위치 설정'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1095,7 +1155,7 @@ className="px-4 py-1.5 border border-gray-300 rounded-md text-gray-700 hover:bg-
|
||||
← 이전
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
{templateId && (
|
||||
{!IS_EDIT && templateId && (
|
||||
<button type="button" disabled={submitting}
|
||||
onClick={() => handleSubmit(true)}
|
||||
className="px-4 py-1.5 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm font-medium disabled:opacity-50 transition-colors">
|
||||
@@ -1105,7 +1165,7 @@ className="px-4 py-1.5 bg-green-600 text-white rounded-md hover:bg-green-700 tex
|
||||
<button type="button" disabled={submitting}
|
||||
onClick={() => handleSubmit(false)}
|
||||
className="px-4 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium disabled:opacity-50 transition-colors">
|
||||
{submitting ? '생성 중...' : templateId ? '계약 생성 및 필드 확인' : '계약 생성 및 서명 위치 설정'}
|
||||
{submitting ? (IS_EDIT ? '수정 중...' : '생성 중...') : IS_EDIT ? '계약 수정' : templateId ? '계약 생성 및 필드 확인' : '계약 생성 및 서명 위치 설정'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1117,9 +1177,11 @@ className="px-4 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-
|
||||
<PartnerSearchModal open={partnerModalOpen} onClose={() => setPartnerModalOpen(false)} onSelect={handlePartnerSelect} />
|
||||
<TenantSearchModal open={tenantModalOpen} onClose={() => setTenantModalOpen(false)} onSelect={handleTenantSelect} />
|
||||
{/* 헤더 */}
|
||||
{editLoading && <div className="p-6 text-center text-gray-400">계약 정보를 불러오는 중...</div>}
|
||||
{!editLoading && <>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<a href="/esign" className="text-gray-400 hover:text-gray-600 text-lg" hx-boost="false">←</a>
|
||||
<h1 className="text-xl font-bold text-gray-900">새 계약 생성</h1>
|
||||
<a href={IS_EDIT ? `/esign/${EDIT_CONTRACT_ID}` : '/esign'} className="text-gray-400 hover:text-gray-600 text-lg" hx-boost="false">←</a>
|
||||
<h1 className="text-xl font-bold text-gray-900">{IS_EDIT ? '계약 수정' : '새 계약 생성'}</h1>
|
||||
{IS_ADMIN && (
|
||||
<button type="button" onClick={fillRandomCounterpart} title="랜덤 상대방 정보 채우기"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-amber-500 hover:bg-amber-50 hover:text-amber-600 transition-colors">
|
||||
@@ -1140,6 +1202,7 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-amber-500 ho
|
||||
{step === 1 && renderStep1()}
|
||||
{step === 2 && renderStep2()}
|
||||
{step === 3 && renderStep3()}
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -255,6 +255,9 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hov
|
||||
<div className="space-y-3">
|
||||
{c.status === 'draft' && (
|
||||
<>
|
||||
<a href={`/esign/${c.id}/edit`} className="block w-full text-center px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 text-sm font-medium" hx-boost="false">
|
||||
계약 정보 수정
|
||||
</a>
|
||||
<a href={`/esign/${c.id}/fields`} className="block w-full text-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium" hx-boost="false">
|
||||
서명 위치 설정
|
||||
</a>
|
||||
|
||||
@@ -1783,6 +1783,7 @@
|
||||
Route::get('/templates', [EsignController::class, 'templates'])->name('templates');
|
||||
Route::get('/templates/{templateId}/fields', [EsignController::class, 'templateFields'])->whereNumber('templateId')->name('template-fields');
|
||||
Route::get('/{id}', [EsignController::class, 'detail'])->whereNumber('id')->name('detail');
|
||||
Route::get('/{id}/edit', [EsignController::class, 'edit'])->whereNumber('id')->name('edit');
|
||||
Route::get('/{id}/fields', [EsignController::class, 'fields'])->whereNumber('id')->name('fields');
|
||||
Route::get('/{id}/send', [EsignController::class, 'send'])->whereNumber('id')->name('send');
|
||||
|
||||
@@ -1800,6 +1801,7 @@
|
||||
Route::get('/stats', [EsignApiController::class, 'stats'])->name('stats');
|
||||
Route::get('/list', [EsignApiController::class, 'index'])->name('list');
|
||||
Route::post('/store', [EsignApiController::class, 'store'])->name('store');
|
||||
Route::put('/{id}', [EsignApiController::class, 'update'])->whereNumber('id')->name('update');
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user