From e8d494a081cd6ef89e9b82b43d15e991a72184be 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 16:26:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EB=B2=95=EC=9D=B8=EB=8F=84=EC=9E=A5=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EB=93=B1=EB=A1=9D=20+?= =?UTF-8?q?=20=EC=83=88=20=EA=B3=84=EC=95=BD=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 대시보드 설정 탭 추가 (법인도장 등록/미리보기/삭제 UI) - tenant_settings 테이블에 esign/company_stamp 키로 저장 - 새 계약 생성 시 등록된 도장 자동 적용 (creator signer) - 계약 생성 페이지에서 개별 도장 업로드 UI 제거 Co-Authored-By: Claude Opus 4.6 --- .../Controllers/ESign/EsignApiController.php | 113 ++++++++++++++++- resources/views/esign/create.blade.php | 56 +++----- resources/views/esign/dashboard.blade.php | 120 +++++++++++++++++- routes/web.php | 4 + 4 files changed, 245 insertions(+), 48 deletions(-) diff --git a/app/Http/Controllers/ESign/EsignApiController.php b/app/Http/Controllers/ESign/EsignApiController.php index 5a655805..212112ab 100644 --- a/app/Http/Controllers/ESign/EsignApiController.php +++ b/app/Http/Controllers/ESign/EsignApiController.php @@ -11,6 +11,7 @@ use App\Models\ESign\EsignSigner; use App\Models\ESign\EsignSignField; use App\Models\ESign\EsignAuditLog; +use App\Models\Tenants\TenantSetting; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -20,6 +21,101 @@ class EsignApiController extends Controller { + /** + * 법인도장 조회 + */ + public function getStamp(): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $setting = TenantSetting::where('tenant_id', $tenantId) + ->where('setting_group', 'esign') + ->where('setting_key', 'company_stamp') + ->first(); + + if (!$setting || empty($setting->setting_value['image_path'])) { + return response()->json(['success' => true, 'data' => null]); + } + + $path = $setting->setting_value['image_path']; + $exists = Storage::disk('local')->exists($path); + + return response()->json([ + 'success' => true, + 'data' => $exists ? [ + 'image_path' => $path, + 'image_url' => '/storage/' . $path, + ] : null, + ]); + } + + /** + * 법인도장 업로드 + */ + public function uploadStamp(Request $request): JsonResponse + { + $request->validate([ + 'stamp_image' => 'required|image|max:5120', + ]); + + $tenantId = session('selected_tenant_id', 1); + $file = $request->file('stamp_image'); + + $path = "esign/{$tenantId}/stamps/company_stamp.png"; + + // 기존 파일이 있으면 삭제 + if (Storage::disk('local')->exists($path)) { + Storage::disk('local')->delete($path); + } + + Storage::disk('local')->put($path, file_get_contents($file->getRealPath())); + + TenantSetting::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'setting_group' => 'esign', + 'setting_key' => 'company_stamp', + ], + [ + 'setting_value' => ['image_path' => $path], + 'updated_by' => auth()->id(), + ] + ); + + return response()->json([ + 'success' => true, + 'message' => '법인도장이 등록되었습니다.', + 'data' => [ + 'image_path' => $path, + 'image_url' => '/storage/' . $path, + ], + ]); + } + + /** + * 법인도장 삭제 + */ + public function deleteStamp(): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $setting = TenantSetting::where('tenant_id', $tenantId) + ->where('setting_group', 'esign') + ->where('setting_key', 'company_stamp') + ->first(); + + if ($setting) { + $path = $setting->setting_value['image_path'] ?? null; + if ($path && Storage::disk('local')->exists($path)) { + Storage::disk('local')->delete($path); + } + $setting->delete(); + } + + return response()->json([ + 'success' => true, + 'message' => '법인도장이 삭제되었습니다.', + ]); + } + /** * 상태별 통계 */ @@ -99,7 +195,6 @@ public function store(Request $request): JsonResponse 'metadata' => 'nullable|array', 'metadata.*' => 'nullable|string|max:500', 'file' => 'nullable|file|mimes:pdf,doc,docx|max:20480', - 'creator_stamp_image' => 'nullable|string', ]); $tenantId = session('selected_tenant_id', 1); @@ -179,18 +274,22 @@ public function store(Request $request): JsonResponse ]); } - // 법인도장 이미지 처리 - if ($request->input('creator_stamp_image')) { + // 법인도장 자동 적용: tenant_settings에 등록된 도장이 있으면 creator signer에 설정 + $stampSetting = TenantSetting::where('tenant_id', $tenantId) + ->where('setting_group', 'esign') + ->where('setting_key', 'company_stamp') + ->first(); + + if ($stampSetting && !empty($stampSetting->setting_value['image_path'])) { $creatorSigner = EsignSigner::withoutGlobalScopes() ->where('contract_id', $contract->id) ->where('role', 'creator') ->first(); if ($creatorSigner) { - $imageData = base64_decode($request->input('creator_stamp_image')); - $imagePath = "esign/{$tenantId}/signatures/{$contract->id}_{$creatorSigner->id}_stamp.png"; - Storage::disk('local')->put($imagePath, $imageData); - $creatorSigner->update(['signature_image_path' => $imagePath]); + $creatorSigner->update([ + 'signature_image_path' => $stampSetting->setting_value['image_path'], + ]); } } diff --git a/resources/views/esign/create.blade.php b/resources/views/esign/create.blade.php index c1f6ea7b..18661c26 100644 --- a/resources/views/esign/create.blade.php +++ b/resources/views/esign/create.blade.php @@ -56,8 +56,7 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus: counterpart_name: '', counterpart_email: '', counterpart_phone: '', }); const [file, setFile] = useState(null); - const [stampImage, setStampImage] = useState(null); - const [stampPreview, setStampPreview] = useState(null); + const [registeredStamp, setRegisteredStamp] = useState(null); // { image_path, image_url } const [submitting, setSubmitting] = useState(false); const [errors, setErrors] = useState({}); const [templates, setTemplates] = useState([]); @@ -66,27 +65,12 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus: const [templateSearch, setTemplateSearch] = useState(''); const [metadata, setMetadata] = useState({}); const fileRef = useRef(null); - const stampFileRef = useRef(null); - - const handleStampSelect = (e) => { - const selected = e.target.files[0]; - if (!selected) return; - const reader = new FileReader(); - reader.onload = () => { - const base64 = reader.result.split(',')[1]; - setStampImage(base64); - setStampPreview(reader.result); - }; - reader.readAsDataURL(selected); - }; - - const removeStamp = () => { - setStampImage(null); - setStampPreview(null); - if (stampFileRef.current) stampFileRef.current.value = ''; - }; useEffect(() => { + fetch('/esign/contracts/stamp', { headers: { 'Accept': 'application/json' } }) + .then(r => r.json()) + .then(json => { if (json.success) setRegisteredStamp(json.data); }) + .catch(() => {}); fetch('/esign/contracts/templates', { headers: { 'Accept': 'application/json' } }) .then(r => r.json()) .then(json => { if (json.success) setTemplates(json.data); }) @@ -153,8 +137,6 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus: Object.entries(metadata).forEach(([k, v]) => { fd.append(`metadata[${k}]`, v || ''); }); } - if (stampImage) fd.append('creator_stamp_image', stampImage); - try { fd.append('_token', csrfToken); fd.append('signers[0][name]', form.creator_name); @@ -251,34 +233,28 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus: - {/* 법인도장 등록 */} -
+ {/* 법인도장 (대시보드에서 관리) */} +
- + - 법인도장 - 발송 시 작성자 서명을 자동 처리합니다 + 법인도장 + 발송 시 작성자 서명을 자동 처리합니다
- {stampPreview ? ( + {registeredStamp ? (
- 법인도장 미리보기 + { + const [stamp, setStamp] = useState(null); // { image_path, image_url } + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [deleting, setDeleting] = useState(false); + const fileRef = React.useRef(null); + + const fetchStamp = async () => { + setLoading(true); + try { + const res = await fetch('/esign/contracts/stamp', { headers: getHeaders() }); + const json = await res.json(); + if (json.success) setStamp(json.data); + } catch (e) { console.error(e); } + setLoading(false); + }; + + useEffect(() => { fetchStamp(); }, []); + + const handleUpload = async (e) => { + const file = e.target.files[0]; + if (!file) return; + setUploading(true); + try { + const fd = new FormData(); + fd.append('stamp_image', file); + const res = await fetch('/esign/contracts/stamp', { + method: 'POST', + headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }, + body: fd, + }); + const json = await res.json(); + if (json.success) { + setStamp(json.data); + } else { + alert(json.message || '업로드에 실패했습니다.'); + } + } catch (e) { alert('업로드 중 오류가 발생했습니다.'); } + setUploading(false); + if (fileRef.current) fileRef.current.value = ''; + }; + + const handleDelete = async () => { + if (!confirm('법인도장을 삭제하시겠습니까?')) return; + setDeleting(true); + try { + const res = await fetch('/esign/contracts/stamp', { + method: 'DELETE', + headers: { ...getHeaders(), 'Content-Type': 'application/json' }, + }); + const json = await res.json(); + if (json.success) setStamp(null); + else alert(json.message || '삭제에 실패했습니다.'); + } catch (e) { alert('삭제 중 오류가 발생했습니다.'); } + setDeleting(false); + }; + + if (loading) return
로딩 중...
; + + return ( +
+
+

법인도장 관리

+

등록된 법인도장은 새 계약 생성 시 자동으로 적용됩니다.

+ + {stamp ? ( +
+
+ + +
+
+ +
+ ) : ( +
+ + + + +

등록된 법인도장이 없습니다.

+ + +

PNG, JPG 형식 (최대 5MB)

+
+ )} +
+
+ ); +}; + // ─── App ─── const App = () => { - const [tab, setTab] = useState('contracts'); // 'contracts' | 'trash' + const [tab, setTab] = useState(() => { + const hash = window.location.hash.replace('#', ''); + return ['contracts', 'trash', 'settings'].includes(hash) ? hash : 'contracts'; + }); // 'contracts' | 'trash' | 'settings' const [stats, setStats] = useState({}); const [trashCount, setTrashCount] = useState(0); @@ -435,11 +545,19 @@ className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px {trashCount} )} +
{/* 탭 콘텐츠 */} {tab === 'contracts' && } {tab === 'trash' && } + {tab === 'settings' && }
); }; diff --git a/routes/web.php b/routes/web.php index a8f1fcb6..5bb8f3cf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1407,6 +1407,10 @@ // 내부 API 라우트 (Finance 패턴과 동일 - 직접 DB 접근) Route::prefix('contracts')->name('contracts.')->group(function () { + Route::get('/stamp', [EsignApiController::class, 'getStamp'])->name('stamp.get'); + Route::post('/stamp', [EsignApiController::class, 'uploadStamp'])->name('stamp.upload'); + Route::delete('/stamp', [EsignApiController::class, 'deleteStamp'])->name('stamp.delete'); + Route::get('/stats', [EsignApiController::class, 'stats'])->name('stats'); Route::get('/list', [EsignApiController::class, 'index'])->name('list'); Route::post('/store', [EsignApiController::class, 'store'])->name('store');