From bcc95ffafaaf697a875a1ad413409953688891b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 19 Feb 2026 23:23:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EA=B3=A0=EA=B0=9D=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=EC=9D=B4=EC=9A=A9=20=EA=B3=84=EC=95=BD=EC=84=9C=20-?= =?UTF-8?q?=20=EA=B3=A0=EA=B0=9D=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0/?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EB=B2=88=ED=98=B8=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EC=B1=84=EB=B2=88/=EA=B8=B0=EB=B3=B8=EA=B0=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테넌트(고객) 검색 API 추가 (searchTenants) - 계약번호 자동 채번 API 추가 (CONTRACT-YYYYMMDD-N 형식) - 고객 서비스이용 계약서 선택 시 "고객 불러오기" 버튼 표시 - 고객 선택 시 상호/사업자등록번호/주소/전화번호 자동 채움 - 총개발비 기본값 20,000,000 / 월구독료 기본값 500,000 자동 세팅 - TenantSearchModal 컴포넌트 추가 Co-Authored-By: Claude Opus 4.6 --- .../Controllers/ESign/EsignApiController.php | 60 ++++++ resources/views/esign/create.blade.php | 183 +++++++++++++++++- routes/web.php | 2 + 3 files changed, 236 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/ESign/EsignApiController.php b/app/Http/Controllers/ESign/EsignApiController.php index ad071884..4f811856 100644 --- a/app/Http/Controllers/ESign/EsignApiController.php +++ b/app/Http/Controllers/ESign/EsignApiController.php @@ -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], + ]); + } + /** * 법인도장 조회 */ diff --git a/resources/views/esign/create.blade.php b/resources/views/esign/create.blade.php index 51ff4677..912b0c01 100644 --- a/resources/views/esign/create.blade.php +++ b/resources/views/esign/create.blade.php @@ -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 ( +
+
+
e.stopPropagation()}> +
+

고객 검색

+ +
+
+ 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" /> +
+
+ {loading &&

검색 중...

} + {!loading && results.length === 0 &&

검색 결과가 없습니다.

} + {!loading && results.map((t, i) => ( + + ))} +
+
+
+ ); +}; + // ─── 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 =