feat: [esign] 사원 불러오기 기능 추가 및 랜덤 데이터 품질 개선
- 근로계약서 시 '사원 불러오기' 버튼으로 직원 검색/선택 기능 - fillRandomVariables 스마트 기본값 생성 (테스트_ 접두사 제거) - searchEmployees API 엔드포인트 추가
This commit is contained in:
@@ -75,6 +75,45 @@ public function searchPartners(Request $request): JsonResponse
|
||||
return response()->json(['success' => true, 'data' => $data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사원 검색 (근로계약서용)
|
||||
*/
|
||||
public function searchEmployees(Request $request): JsonResponse
|
||||
{
|
||||
$q = trim($request->input('q', ''));
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
|
||||
$query = \App\Models\HR\Employee::where('tenant_id', $tenantId)
|
||||
->where('employee_status', 'active')
|
||||
->with(['user', 'department']);
|
||||
|
||||
if ($q !== '') {
|
||||
$query->where(function ($w) use ($q) {
|
||||
$w->whereHas('user', fn ($u) => $u->where('name', 'like', "%{$q}%")
|
||||
->orWhere('phone', 'like', "%{$q}%")
|
||||
->orWhere('email', 'like', "%{$q}%"))
|
||||
->orWhereHas('department', fn ($d) => $d->where('name', 'like', "%{$q}%"));
|
||||
});
|
||||
}
|
||||
|
||||
$employees = $query->limit(20)->get();
|
||||
|
||||
$data = $employees->map(fn ($emp) => [
|
||||
'id' => $emp->id,
|
||||
'name' => $emp->user?->name,
|
||||
'phone' => $emp->user?->phone,
|
||||
'email' => $emp->user?->email,
|
||||
'department' => $emp->department?->name,
|
||||
'position' => $emp->position_label,
|
||||
'job_title' => $emp->job_title_label,
|
||||
'address' => $emp->address,
|
||||
'hire_date' => $emp->hire_date,
|
||||
'birth_year' => $emp->resident_number ? ('19'.substr($emp->resident_number, 0, 2)) : null,
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 고객(명함 등록 고객) 검색
|
||||
*/
|
||||
|
||||
@@ -355,6 +355,91 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
|
||||
);
|
||||
};
|
||||
|
||||
// ─── EmployeeSearchModal (근로계약서용 사원 검색) ───
|
||||
const EmployeeSearchModal = ({ 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-employees?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((emp, i) => (
|
||||
<button key={emp.id} type="button"
|
||||
onClick={() => { onSelect(emp); 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">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-800">{emp.name}</span>
|
||||
{emp.position && <span className="text-xs text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded">{emp.position}</span>}
|
||||
{emp.job_title && <span className="text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{emp.job_title}</span>}
|
||||
</div>
|
||||
{emp.department && <span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">{emp.department}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
{emp.phone && <span className="text-xs text-gray-400">{emp.phone}</span>}
|
||||
{emp.email && <span className="text-xs text-gray-400">{emp.email}</span>}
|
||||
{emp.hire_date && <span className="text-xs text-gray-400">입사: {emp.hire_date}</span>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── TenantSearchModal ───
|
||||
const TenantSearchModal = ({ open, onClose, onSelect }) => {
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -555,6 +640,8 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
|
||||
};
|
||||
|
||||
const isCustomerContract = form.title === '고객 서비스이용 계약서';
|
||||
const isLaborContract = form.title === '근로계약서';
|
||||
const [employeeModalOpen, setEmployeeModalOpen] = useState(false);
|
||||
|
||||
// 계약번호 자동 채번
|
||||
const fetchContractNumber = async () => {
|
||||
@@ -705,16 +792,96 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 3. 매칭 안 되면 라벨 기반 기본값
|
||||
if (!updated[v.key]) {
|
||||
updated[v.key] = `테스트_${v.label}`;
|
||||
}
|
||||
}
|
||||
// 3. 매칭 안 되면 라벨 기반 스마트 기본값
|
||||
if (!updated[v.key]) {
|
||||
updated[v.key] = guessValueByLabel(v.label);
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// 라벨 텍스트를 분석하여 실제 데이터와 유사한 값을 생성
|
||||
const guessValueByLabel = (label) => {
|
||||
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
|
||||
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
const l = label.toLowerCase();
|
||||
|
||||
// 이름/성명
|
||||
if (/이름|성명|대표자|계약자|서명자|사원명/.test(l))
|
||||
return pick(['김민수','이서연','박지훈','최예린','정우성','한소희','강동원','윤세아']);
|
||||
|
||||
// 주소
|
||||
if (/주소|소재지/.test(l))
|
||||
return pick(['서울특별시 강남구 테헤란로 123, 4층','경기도 성남시 분당구 판교로 789','서울특별시 마포구 월드컵북로 54길 12','인천광역시 연수구 송도과학로 32']);
|
||||
|
||||
// 전화번호/연락처
|
||||
if (/전화|연락처|핸드폰|휴대폰|번호.*전화|mobile|phone/.test(l))
|
||||
return `010-${randInt(1000,9999)}-${randInt(1000,9999)}`;
|
||||
|
||||
// 이메일
|
||||
if (/이메일|email|메일/.test(l))
|
||||
return pick(['test','info','admin','dev']) + '@' + pick(['example.com','test.kr','company.co.kr']);
|
||||
|
||||
// 사업자등록번호
|
||||
if (/사업자.*번호|사업자.*등록/.test(l))
|
||||
return `${randInt(100,999)}-${randInt(10,99)}-${randInt(10000,99999)}`;
|
||||
|
||||
// 주민등록번호/생년월일
|
||||
if (/주민.*번호|resident/.test(l))
|
||||
return `${randInt(70,99)}${String(randInt(1,12)).padStart(2,'0')}${String(randInt(1,28)).padStart(2,'0')}-*******`;
|
||||
if (/생년월일|생년|출생/.test(l))
|
||||
return `${randInt(1970,2000)}.${randInt(1,12)}.${randInt(1,28)}`;
|
||||
|
||||
// 년도
|
||||
if (/년도|연도/.test(l))
|
||||
return `${randInt(2024,2027)}`;
|
||||
|
||||
// 금액/원/급여/수당/보수
|
||||
if (/금액|급여|보수|수당|임금|월급|연봉|원$/.test(l))
|
||||
return pick(['2,200,000','2,500,000','2,800,000','3,000,000','3,500,000','4,000,000']) + '원';
|
||||
|
||||
// 기간
|
||||
if (/기간|시작.*종료|계약.*일/.test(l))
|
||||
return `2026.03.11 ~ 2027.03.10`;
|
||||
|
||||
// 날짜/일자/일시
|
||||
if (/날짜|일자|일시|작성일|계약일|시작일|종료일/.test(l))
|
||||
return `2026.${String(randInt(1,12)).padStart(2,'0')}.${String(randInt(1,28)).padStart(2,'0')}`;
|
||||
|
||||
// 시간
|
||||
if (/시간|시각/.test(l))
|
||||
return `${String(randInt(8,18)).padStart(2,'0')}:00`;
|
||||
|
||||
// 회사/상호/업체
|
||||
if (/회사|상호|업체|법인|사업장/.test(l))
|
||||
return pick(['(주)코드브릿지엑스','(주)블루오션','(주)스마트솔루션','(주)넥스트웨이브','(주)그린테크']);
|
||||
|
||||
// 부서
|
||||
if (/부서|팀|소속/.test(l))
|
||||
return pick(['생산부','영업부','관리부','개발부','총무부','물류부']);
|
||||
|
||||
// 직책/직위/직급
|
||||
if (/직책|직위|직급/.test(l))
|
||||
return pick(['사원','주임','대리','과장','차장','부장']);
|
||||
|
||||
// 직종/업무/업종
|
||||
if (/직종|업종|업무/.test(l))
|
||||
return pick(['생산직','사무직','영업직','기술직','관리직']);
|
||||
|
||||
// 퍼센트/비율
|
||||
if (/비율|퍼센트|%|율/.test(l))
|
||||
return `${randInt(5,30)}%`;
|
||||
|
||||
// 수량/개수
|
||||
if (/수량|개수|인원|명/.test(l))
|
||||
return `${randInt(1,50)}`;
|
||||
|
||||
// 기타: 라벨 자체를 활용한 짧은 값
|
||||
return label;
|
||||
};
|
||||
|
||||
const fillRandomCounterpart = async () => {
|
||||
try {
|
||||
const res = await fetch(`/esign/contracts/search-partners?q=`, {
|
||||
@@ -781,6 +948,46 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
|
||||
}));
|
||||
};
|
||||
|
||||
// 사원 선택 시 근로계약서 변수 자동 채우기
|
||||
const handleEmployeeSelect = (emp) => {
|
||||
const labelMap = {
|
||||
'직원.*주소': emp.address,
|
||||
'출생.*년도': emp.birth_year,
|
||||
'부서': emp.department,
|
||||
'직책': emp.position,
|
||||
'직위': emp.job_title || emp.position,
|
||||
'연락처': emp.phone,
|
||||
'전화.*번호': emp.phone,
|
||||
'이메일': emp.email,
|
||||
'계약자.*이름': emp.name,
|
||||
'사원.*이름': emp.name,
|
||||
'근로자.*이름': emp.name,
|
||||
'근로자.*성명': emp.name,
|
||||
'입사.*일': emp.hire_date,
|
||||
};
|
||||
|
||||
setMetadata(prev => {
|
||||
const updated = { ...prev };
|
||||
templateVars.forEach(v => {
|
||||
for (const [pattern, value] of Object.entries(labelMap)) {
|
||||
if (value && new RegExp(pattern, 'i').test(v.label)) {
|
||||
updated[v.key] = value;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
|
||||
// 상대방(직원) 서명자 정보 자동 채우기
|
||||
setForm(f => ({
|
||||
...f,
|
||||
counterpart_name: emp.name || f.counterpart_name,
|
||||
counterpart_email: emp.email || f.counterpart_email,
|
||||
counterpart_phone: emp.phone || f.counterpart_phone,
|
||||
}));
|
||||
};
|
||||
|
||||
// Step 1 유효성 검사
|
||||
const validateStep1 = () => {
|
||||
const newErrors = {};
|
||||
@@ -1253,6 +1460,14 @@ className="inline-flex items-center gap-1 px-2.5 py-1 bg-white border border-gre
|
||||
</svg>
|
||||
고객 불러오기
|
||||
</button>
|
||||
) : isLaborContract ? (
|
||||
<button type="button" onClick={() => setEmployeeModalOpen(true)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 bg-white border border-blue-300 text-blue-700 rounded-md text-xs font-medium hover:bg-blue-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">
|
||||
@@ -1311,6 +1526,7 @@ className="px-4 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-
|
||||
<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} />
|
||||
<EmployeeSearchModal open={employeeModalOpen} onClose={() => setEmployeeModalOpen(false)} onSelect={handleEmployeeSelect} />
|
||||
{/* 헤더 */}
|
||||
{editLoading && <div className="p-6 text-center text-gray-400">계약 정보를 불러오는 중...</div>}
|
||||
{!editLoading && <>
|
||||
|
||||
@@ -1795,6 +1795,7 @@
|
||||
Route::get('/stamp/image/{tenant}', [EsignApiController::class, 'serveStampImage'])->name('stamp.image');
|
||||
Route::get('/search-partners', [EsignApiController::class, 'searchPartners'])->name('search-partners');
|
||||
Route::get('/search-tenants', [EsignApiController::class, 'searchTenants'])->name('search-tenants');
|
||||
Route::get('/search-employees', [EsignApiController::class, 'searchEmployees'])->name('search-employees');
|
||||
Route::get('/generate-contract-number', [EsignApiController::class, 'generateContractNumber'])->name('generate-contract-number');
|
||||
Route::get('/alimtalk-templates', [EsignApiController::class, 'getAlimtalkTemplates'])->name('alimtalk-templates');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user