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:
@@ -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],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 법인도장 조회
|
||||
*/
|
||||
|
||||
@@ -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">×</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">←</a>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user