feat:고객 서비스이용 계약서 - 고객 불러오기/계약번호 자동채번/기본값

- 테넌트(고객) 검색 API 추가 (searchTenants)
- 계약번호 자동 채번 API 추가 (CONTRACT-YYYYMMDD-N 형식)
- 고객 서비스이용 계약서 선택 시 "고객 불러오기" 버튼 표시
- 고객 선택 시 상호/사업자등록번호/주소/전화번호 자동 채움
- 총개발비 기본값 20,000,000 / 월구독료 기본값 500,000 자동 세팅
- TenantSearchModal 컴포넌트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-19 23:23:06 +09:00
parent abf424d10e
commit bcc95ffafa
3 changed files with 236 additions and 9 deletions

View File

@@ -13,6 +13,7 @@
use App\Models\ESign\EsignSigner;
use App\Models\ESign\EsignSignField;
use App\Models\ESign\EsignAuditLog;
use App\Models\Tenants\Tenant;
use App\Models\Tenants\TenantSetting;
use App\Services\Barobill\BarobillService;
use App\Services\GoogleCloudStorageService;
@@ -73,6 +74,65 @@ public function searchPartners(Request $request): JsonResponse
return response()->json(['success' => true, 'data' => $data]);
}
/**
* 고객(테넌트) 검색
*/
public function searchTenants(Request $request): JsonResponse
{
$q = trim($request->input('q', ''));
$query = Tenant::whereNull('deleted_at');
if ($q !== '') {
$query->where(function ($w) use ($q) {
$w->where('company_name', 'like', "%{$q}%")
->orWhere('business_num', 'like', "%{$q}%")
->orWhere('phone', 'like', "%{$q}%")
->orWhere('ceo_name', 'like', "%{$q}%");
});
}
$tenants = $query->orderBy('company_name')->limit(20)->get();
$data = $tenants->map(fn($t) => [
'id' => $t->id,
'company_name' => $t->company_name,
'business_num' => $t->business_num,
'ceo_name' => $t->ceo_name,
'address' => $t->address,
'phone' => $t->phone,
'email' => $t->email,
]);
return response()->json(['success' => true, 'data' => $data]);
}
/**
* 계약번호 자동 채번 (CONTRACT-YYYY-MMDD-N)
*/
public function generateContractNumber(): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$today = now()->format('Ymd');
$prefix = "CONTRACT-{$today}-";
$lastContract = EsignContract::where('tenant_id', $tenantId)
->where('contract_code', 'like', "{$prefix}%")
->orderByRaw("CAST(SUBSTRING(contract_code, ?) AS UNSIGNED) DESC", [strlen($prefix) + 1])
->first();
$seq = 1;
if ($lastContract) {
$lastSeq = (int) str_replace($prefix, '', $lastContract->contract_code);
$seq = $lastSeq + 1;
}
return response()->json([
'success' => true,
'data' => ['contract_number' => $prefix . $seq],
]);
}
/**
* 법인도장 조회
*/

View File

@@ -352,6 +352,87 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
);
};
// ─── TenantSearchModal ───
const TenantSearchModal = ({ open, onClose, onSelect }) => {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [activeIdx, setActiveIdx] = useState(-1);
const inputRef = useRef(null);
const debounceRef = useRef(null);
const doSearch = useCallback((q) => {
setLoading(true);
fetch(`/esign/contracts/search-tenants?q=${encodeURIComponent(q)}`, {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
})
.then(r => r.json())
.then(json => { if (json.success) { setResults(json.data); setActiveIdx(-1); } })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!open) return;
setQuery(''); setResults([]); setActiveIdx(-1);
doSearch('');
setTimeout(() => inputRef.current?.focus(), 100);
}, [open, doSearch]);
const handleInput = (val) => {
setQuery(val);
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(val), 250);
};
const handleKeyDown = (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(results.length - 1, i + 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(0, i - 1)); }
else if (e.key === 'Enter' && activeIdx >= 0 && results[activeIdx]) { e.preventDefault(); onSelect(results[activeIdx]); onClose(); }
else if (e.key === 'Escape') { onClose(); }
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/40" />
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 flex flex-col" style={{ maxHeight: 'min(480px, 80vh)' }} onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b flex-shrink-0">
<h3 className="text-sm font-semibold text-gray-900">고객 검색</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg">&times;</button>
</div>
<div className="px-5 py-3 flex-shrink-0">
<input ref={inputRef} type="text" value={query}
onChange={e => handleInput(e.target.value)} onKeyDown={handleKeyDown}
placeholder="상호, 사업자등록번호, 대표자명, 전화번호로 검색..."
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
</div>
<div className="px-5 pb-4 overflow-y-auto flex-1 min-h-0">
{loading && <p className="text-xs text-gray-400 py-3 text-center">검색 ...</p>}
{!loading && results.length === 0 && <p className="text-xs text-gray-400 py-3 text-center">검색 결과가 없습니다.</p>}
{!loading && results.map((t, i) => (
<button key={t.id} type="button"
onClick={() => { onSelect(t); onClose(); }}
onMouseEnter={() => setActiveIdx(i)}
className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i === activeIdx ? 'bg-blue-50 ring-1 ring-blue-200' : 'hover:bg-gray-50'}`}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-800">{t.company_name}</span>
{t.ceo_name && <span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">대표: {t.ceo_name}</span>}
</div>
<div className="flex items-center gap-3 mt-0.5">
{t.business_num && <span className="text-xs text-gray-400">{t.business_num}</span>}
{t.phone && <span className="text-xs text-gray-400">{t.phone}</span>}
{t.address && <span className="text-xs text-gray-400 truncate" style={{ maxWidth: 200 }}>{t.address}</span>}
</div>
</button>
))}
</div>
</div>
</div>
);
};
// ─── App (Wizard) ───
const App = () => {
const [step, setStep] = useState(1);
@@ -373,6 +454,7 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
const [templateItems, setTemplateItems] = useState([]);
const [metadata, setMetadata] = useState({});
const [partnerModalOpen, setPartnerModalOpen] = useState(false);
const [tenantModalOpen, setTenantModalOpen] = useState(false);
const fileRef = useRef(null);
const hasTemplates = templates.length > 0;
@@ -422,6 +504,73 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
} catch (_) { setTemplateItems([]); }
};
const isCustomerContract = form.title === '고객 서비스이용 계약서';
// 계약번호 자동 채번
const fetchContractNumber = async () => {
try {
const res = await fetch('/esign/contracts/generate-contract-number', {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
});
const json = await res.json();
if (json.success) return json.data.contract_number;
} catch (_) {}
return '';
};
// 계약 제목 변경 시 기본값 세팅
const applyTitleDefaults = async (title) => {
if (title === '고객 서비스이용 계약서') {
const contractNum = await fetchContractNumber();
setMetadata(prev => ({
...prev,
...(contractNum && !prev.contract_number ? { contract_number: contractNum } : {}),
...(!prev.total_dev_cost ? { total_dev_cost: '20,000,000' } : {}),
...(!prev.monthly_fee ? { monthly_fee: '500,000' } : {}),
}));
}
};
// 고객(테넌트) 선택 핸들러
const handleTenantSelect = (tenant) => {
const keyMap = {
company_name: tenant.company_name,
biz_no: tenant.business_num,
address: tenant.address,
phone: tenant.phone,
};
const labelMap = {
'상호': tenant.company_name,
'사업자등록증': tenant.business_num,
'사업자등록번호': tenant.business_num,
'주소': tenant.address,
'전화': tenant.phone,
'전화번호': tenant.phone,
};
setMetadata(prev => {
const updated = { ...prev };
templateVars.forEach(v => {
if (keyMap[v.key] !== undefined && keyMap[v.key]) {
updated[v.key] = keyMap[v.key];
return;
}
if (labelMap[v.label] !== undefined && labelMap[v.label]) {
updated[v.key] = labelMap[v.label];
}
});
return updated;
});
// 상대방 서명자 정보도 채우기
setForm(f => ({
...f,
counterpart_name: tenant.company_name || f.counterpart_name,
counterpart_email: tenant.email || f.counterpart_email,
counterpart_phone: tenant.phone || f.counterpart_phone,
}));
};
const fillRandomCounterpart = async () => {
try {
const res = await fetch(`/esign/contracts/search-partners?q=`, {
@@ -506,6 +655,7 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
if (!validateStep1()) return;
if (hasTemplates) { setStep(2); } else { handleSubmit(); }
} else if (step === 2) {
applyTitleDefaults(form.title);
setStep(3);
}
};
@@ -568,8 +718,12 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
<select value={titleType} onChange={e => {
const val = e.target.value;
setTitleType(val);
if (val !== '__custom__') handleChange('title', val);
else handleChange('title', '');
if (val !== '__custom__') {
handleChange('title', val);
applyTitleDefaults(val);
} else {
handleChange('title', '');
}
}}
className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none mb-1">
{TITLE_PRESETS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
@@ -829,13 +983,23 @@ className="px-5 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-
<div>
<div className="flex items-center justify-between mb-1.5">
<div className="text-[11px] font-medium text-gray-500">직접 입력 항목</div>
<button type="button" onClick={() => setPartnerModalOpen(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 bg-white border border-amber-300 text-amber-700 rounded-md text-xs font-medium hover:bg-amber-50 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
영업파트너 불러오기
</button>
{isCustomerContract ? (
<button type="button" onClick={() => setTenantModalOpen(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 bg-white border border-green-300 text-green-700 rounded-md text-xs font-medium hover:bg-green-50 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
고객 불러오기
</button>
) : (
<button type="button" onClick={() => setPartnerModalOpen(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 bg-white border border-amber-300 text-amber-700 rounded-md text-xs font-medium hover:bg-amber-50 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
영업파트너 불러오기
</button>
)}
</div>
<div className="space-y-3">
{templateVars.map(v => (
@@ -884,6 +1048,7 @@ className="px-4 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-
return (
<div className="p-4 sm:p-6 max-w-3xl mx-auto">
<PartnerSearchModal open={partnerModalOpen} onClose={() => setPartnerModalOpen(false)} onSelect={handlePartnerSelect} />
<TenantSearchModal open={tenantModalOpen} onClose={() => setTenantModalOpen(false)} onSelect={handleTenantSelect} />
{/* 헤더 */}
<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">&larr;</a>

View File

@@ -1426,6 +1426,8 @@
Route::post('/stamp', [EsignApiController::class, 'uploadStamp'])->name('stamp.upload');
Route::delete('/stamp', [EsignApiController::class, 'deleteStamp'])->name('stamp.delete');
Route::get('/search-partners', [EsignApiController::class, 'searchPartners'])->name('search-partners');
Route::get('/search-tenants', [EsignApiController::class, 'searchTenants'])->name('search-tenants');
Route::get('/generate-contract-number', [EsignApiController::class, 'generateContractNumber'])->name('generate-contract-number');
Route::get('/stats', [EsignApiController::class, 'stats'])->name('stats');
Route::get('/list', [EsignApiController::class, 'index'])->name('list');