feat:법인도장 대시보드 등록 + 새 계약 자동 적용

- 대시보드 설정 탭 추가 (법인도장 등록/미리보기/삭제 UI)
- tenant_settings 테이블에 esign/company_stamp 키로 저장
- 새 계약 생성 시 등록된 도장 자동 적용 (creator signer)
- 계약 생성 페이지에서 개별 도장 업로드 UI 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-13 16:26:28 +09:00
parent 24c6927b56
commit e8d494a081
4 changed files with 245 additions and 48 deletions

View File

@@ -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'],
]);
}
}

View File

@@ -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:
<SignerRow prefix="creator" title="작성자" subtitle="" color="#3B82F6"
form={form} errors={errors} onChange={handleChange} />
{/* 법인도장 등록 */}
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
{/* 법인도장 (대시보드에서 관리) */}
<div className={`rounded-lg p-3 border ${registeredStamp ? 'bg-blue-50 border-blue-200' : 'bg-gray-50 border-gray-200'}`}>
<div className="flex items-center gap-2 mb-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3B82F6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={registeredStamp ? '#3B82F6' : '#9CA3AF'} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="3"/>
<circle cx="12" cy="12" r="4"/>
</svg>
<span className="text-xs font-semibold text-blue-800">법인도장</span>
<span className="text-[11px] text-blue-500">발송 작성자 서명을 자동 처리합니다</span>
<span className={`text-xs font-semibold ${registeredStamp ? 'text-blue-800' : 'text-gray-600'}`}>법인도장</span>
<span className="text-[11px] text-gray-400">발송 작성자 서명을 자동 처리합니다</span>
</div>
{stampPreview ? (
{registeredStamp ? (
<div className="flex items-center gap-3">
<img src={stampPreview} alt="법인도장 미리보기" className="h-16 w-16 object-contain border border-blue-300 rounded bg-white p-1" />
<img src={registeredStamp.image_url + '?t=' + Date.now()} alt="법인도장" className="h-16 w-16 object-contain border border-blue-300 rounded bg-white p-1" />
<div className="flex-1">
<p className="text-xs text-blue-700">법인도장이 등록되었습니다.</p>
<p className="text-xs text-blue-700">등록된 법인도장이 자동 적용됩니다.</p>
<a href="/esign#settings" className="text-[11px] text-blue-500 hover:underline">대시보드에서 변경</a>
</div>
<button type="button" onClick={removeStamp}
className="w-7 h-7 flex items-center justify-center rounded-full text-red-400 hover:bg-red-50 hover:text-red-600 transition-colors" title="삭제">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
) : (
<div>
<input ref={stampFileRef} type="file" accept="image/*" onChange={handleStampSelect} className="hidden" />
<button type="button" onClick={() => stampFileRef.current?.click()}
className="px-3 py-1.5 bg-white border border-blue-300 rounded-md text-xs font-medium text-blue-700 hover:bg-blue-100 transition-colors">
법인도장 이미지 등록
</button>
<p className="text-xs text-gray-500 mb-1">등록된 법인도장이 없습니다.</p>
<a href="/esign#settings" className="text-xs text-blue-600 hover:underline">대시보드에서 법인도장을 먼저 등록해 주세요</a>
</div>
)}
</div>

View File

@@ -365,9 +365,119 @@ className={`px-3 py-1 text-sm rounded ${p === pagination.current_page ? 'bg-blue
);
};
// ─── 설정 탭 ───
const SettingsTab = () => {
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 <div className="text-center py-8 text-gray-400">로딩 ...</div>;
return (
<div className="max-w-xl">
<div className="bg-white rounded-lg border p-5">
<h2 className="text-sm font-semibold text-gray-900 mb-1">법인도장 관리</h2>
<p className="text-xs text-gray-400 mb-4">등록된 법인도장은 계약 생성 자동으로 적용됩니다.</p>
{stamp ? (
<div className="flex items-start gap-4">
<div className="border rounded-lg p-3 bg-gray-50">
<img src={stamp.image_url + '?t=' + Date.now()} alt="법인도장" className="h-24 w-24 object-contain" />
</div>
<div className="flex-1 pt-2">
<p className="text-sm text-green-700 font-medium mb-1">법인도장이 등록되어 있습니다.</p>
<p className="text-xs text-gray-400 mb-3"> 계약 생성 작성자 서명에 자동 적용됩니다.</p>
<div className="flex gap-2">
<button onClick={() => fileRef.current?.click()} disabled={uploading}
className="px-3 py-1.5 border border-gray-300 rounded-md text-xs font-medium text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-50">
{uploading ? '교체 중...' : '이미지 교체'}
</button>
<button onClick={handleDelete} disabled={deleting}
className="px-3 py-1.5 border border-red-300 rounded-md text-xs font-medium text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50">
{deleting ? '삭제 중...' : '삭제'}
</button>
</div>
</div>
<input ref={fileRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" />
</div>
) : (
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mx-auto text-gray-300 mb-2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="3"/>
<circle cx="12" cy="12" r="4"/>
</svg>
<p className="text-sm text-gray-500 mb-3">등록된 법인도장이 없습니다.</p>
<button onClick={() => fileRef.current?.click()} disabled={uploading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors disabled:opacity-50">
{uploading ? '업로드 중...' : '법인도장 이미지 등록'}
</button>
<input ref={fileRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" />
<p className="text-xs text-gray-400 mt-2">PNG, JPG 형식 (최대 5MB)</p>
</div>
)}
</div>
</div>
);
};
// ─── 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
<span className={`text-xs px-1.5 py-0.5 rounded-full ${tab === 'trash' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-500'}`}>{trashCount}</span>
)}
</button>
<button onClick={() => setTab('settings')}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px inline-flex items-center gap-1.5 ${tab === 'settings' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>
</svg>
설정
</button>
</div>
{/* 탭 콘텐츠 */}
{tab === 'contracts' && <ContractList onRefreshStats={refreshAll} />}
{tab === 'trash' && <TrashList onRefreshStats={refreshAll} />}
{tab === 'settings' && <SettingsTab />}
</div>
);
};

View File

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