feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
@ extends ( 'layouts.app' )
@ section ( 'title' , '기안 작성' )
@ section ( 'content' )
< div class = " flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6 " >
< h1 class = " text-2xl font-bold text-gray-800 " > 기안 작성 </ h1 >
< a href = " { { route('approvals.drafts') }} " class = " text-gray-600 hover:text-gray-800 text-sm " >
& larr ; 기안함으로 돌아가기
</ a >
</ div >
2026-03-04 15:19:00 +09:00
< div >
2026-02-28 14:41:37 +09:00
< div class = " bg-white rounded-lg shadow-sm p-6 " >
< h2 class = " text-lg font-semibold text-gray-800 mb-4 " > 문서 내용 </ h2 >
2026-03-06 13:18:44 +09:00
{{ -- 양식 선택 ( 2 단계 : 분류 → 양식 ) + 설명 카드 -- }}
2026-02-28 14:41:37 +09:00
< div class = " mb-4 " >
< label class = " block text-sm font-medium text-gray-700 mb-1 " > 양식 < span class = " text-red-500 " >*</ span ></ label >
2026-03-06 12:54:53 +09:00
< div class = " flex gap-3 " style = " align-items: flex-start; " >
< div style = " width: 30%; min-width: 180px; " class = " shrink-0 " >
2026-03-06 13:18:44 +09:00
< select id = " form_category " class = " w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium " >
</ select >
< select id = " form_id " class = " mt-2 w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 " >
@ foreach ( $forms as $form )
< option value = " { { $form->id }} " > {{ $form -> name }} </ option >
@ endforeach
</ select >
2026-03-06 12:54:53 +09:00
< button type = " button " id = " expense-load-btn " style = " display: none; " onclick = " openExpenseLoadModal() "
class = " mt-2 w-full px-3 py-2 bg-amber-50 text-amber-700 hover:bg-amber-100 border border-amber-200 rounded-lg text-sm font-medium transition inline-flex items-center justify-center gap-1 " >
< svg class = " w-4 h-4 " fill = " none " stroke = " currentColor " viewBox = " 0 0 24 24 " >
< path stroke - linecap = " round " stroke - linejoin = " round " stroke - width = " 2 " d = " M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12 " />
</ svg >
불러오기
</ button >
</ div >
< div id = " form-description-card " style = " flex: 1; display: none; " >
< div class = " rounded-lg border p-3 text-sm transition-all " id = " form-desc-inner " >
< div class = " flex items-start gap-2 " >
< div id = " form-desc-icon " class = " shrink-0 mt-0.5 " ></ div >
< div >
< div id = " form-desc-title " class = " font-semibold text-sm mb-1 " ></ div >
< div id = " form-desc-text " class = " text-xs leading-relaxed " ></ div >
</ div >
</ div >
</ div >
</ div >
2026-03-05 10:46:13 +09:00
</ div >
2026-02-28 14:41:37 +09:00
</ div >
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
2026-02-28 14:41:37 +09:00
{{ -- 제목 -- }}
< div class = " mb-4 " >
< label class = " block text-sm font-medium text-gray-700 mb-1 " > 제목 < span class = " text-red-500 " >*</ span ></ label >
< input type = " text " id = " title " maxlength = " 200 " placeholder = " 결재 제목을 입력하세요 "
class = " w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 " >
</ div >
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
2026-02-28 14:48:16 +09:00
{{ -- 결재선 -- }}
< div class = " mb-4 " >
< div class = " flex items-center justify-between mb-1 " >
< label class = " block text-sm font-medium text-gray-700 " > 결재선 </ label >
2026-03-04 14:18:54 +09:00
< div class = " flex items-center gap-2 " >
< select id = " quick-line-select " onchange = " applyQuickLine(this.value) "
class = " px-2 py-1.5 border border-gray-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500 "
style = " min-width: 160px; " >
< option value = " " > 결재선 선택 </ option >
@ foreach ( $lines as $line )
< option value = " { { $line->id }} " {{ ( $defaultLine ? -> id ? ? '' ) == $line -> id ? 'selected' : '' }} >
{{ $line -> name }} ({{ count ( $line -> steps ? ? []) }} 단계 )
</ option >
@ endforeach
</ select >
< button type = " button " onclick = " openApprovalLineModal() "
class = " px-3 py-1.5 bg-blue-50 text-blue-600 hover:bg-blue-100 rounded-lg text-xs font-medium transition whitespace-nowrap " >
세부 설정
</ button >
</ div >
2026-02-28 14:48:16 +09:00
</ div >
< div id = " approval-line-summary " class = " p-3 bg-gray-50 rounded-lg border border-gray-200 flex items-center " >
< span class = " text-sm text-gray-400 " > 결재선이 설정되지 않았습니다 .</ span >
</ div >
</ div >
2026-02-28 14:41:37 +09:00
{{ -- 긴급 여부 -- }}
< div class = " mb-4 " >
< label class = " inline-flex items-center gap-2 cursor-pointer " >
< input type = " checkbox " id = " is_urgent " class = " rounded border-gray-300 text-red-600 focus:ring-red-500 " >
< span class = " text-sm text-gray-700 " > 긴급 </ span >
</ label >
</ div >
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
2026-03-04 15:14:18 +09:00
{{ -- 본문 ( 일반 양식 ) -- }}
< div id = " body-area " class = " mb-4 " >
2026-02-28 14:41:37 +09:00
< label class = " block text-sm font-medium text-gray-700 mb-1 " >
본문
< label class = " inline-flex items-center gap-1 ml-3 cursor-pointer " >
< input type = " checkbox " id = " useEditor " onchange = " toggleEditor() "
class = " rounded border-gray-300 text-blue-600 focus:ring-blue-500 " >
< span class = " text-xs text-gray-500 font-normal " > 편집기 </ span >
2026-02-28 14:18:16 +09:00
</ label >
2026-02-28 14:41:37 +09:00
</ label >
< textarea id = " body " rows = " 12 " placeholder = " 기안 내용을 입력하세요... "
class = " w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 "
style = " min-height: 300px; " ></ textarea >
< div id = " quill-container " style = " display: none; min-height: 300px; " ></ div >
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
</ div >
2026-03-05 15:57:36 +09:00
{{ -- 휴가 / 근태신청 / 사유서 전용 폼 -- }}
@ include ( 'approvals.partials._leave-form' , [
'employees' => $employees ? ? collect (),
])
2026-03-05 18:53:42 +09:00
{{ -- 재직증명서 전용 폼 -- }}
@ include ( 'approvals.partials._certificate-form' , [
'employees' => $employees ? ? collect (),
])
2026-03-05 23:41:20 +09:00
{{ -- 경력증명서 전용 폼 -- }}
@ include ( 'approvals.partials._career-cert-form' , [
'employees' => $employees ? ? collect (),
])
2026-03-05 23:57:42 +09:00
{{ -- 위촉증명서 전용 폼 -- }}
@ include ( 'approvals.partials._appointment-cert-form' , [
'employees' => $employees ? ? collect (),
])
2026-03-06 00:13:17 +09:00
{{ -- 사직서 전용 폼 -- }}
@ include ( 'approvals.partials._resignation-form' , [
'employees' => $employees ? ? collect (),
])
2026-03-06 20:48:01 +09:00
{{ -- 사용인감계 전용 폼 -- }}
@ include ( 'approvals.partials._seal-usage-form' , [
'tenantInfo' => $tenantInfo ? ? [],
])
2026-03-06 22:25:00 +09:00
{{ -- 위임장 전용 폼 -- }}
@ include ( 'approvals.partials._delegation-form' , [
'tenantInfo' => $tenantInfo ? ? [],
])
2026-03-06 23:00:22 +09:00
{{ -- 이사회의사록 전용 폼 -- }}
@ include ( 'approvals.partials._board-minutes-form' , [
'tenantInfo' => $tenantInfo ? ? [],
])
2026-03-06 23:38:55 +09:00
{{ -- 공문서 전용 폼 -- }}
@ include ( 'approvals.partials._official-letter-form' , [
'tenantInfo' => $tenantInfo ? ? [],
])
2026-03-07 00:28:58 +09:00
{{ -- 연차사용촉진 1 차 통지서 -- }}
@ include ( 'approvals.partials._leave-promotion-1st-form' , [
'tenantInfo' => $tenantInfo ? ? [],
'employees' => $employees ? ? collect (),
])
{{ -- 연차사용촉진 2 차 통지서 -- }}
@ include ( 'approvals.partials._leave-promotion-2nd-form' , [
'tenantInfo' => $tenantInfo ? ? [],
'employees' => $employees ? ? collect (),
])
2026-03-06 23:21:49 +09:00
{{ -- 견적서 전용 폼 -- }}
@ include ( 'approvals.partials._quotation-form' , [
'tenantInfo' => $tenantInfo ? ? [],
])
2026-03-04 15:14:18 +09:00
{{ -- 지출결의서 전용 폼 -- }}
2026-03-04 20:29:25 +09:00
@ include ( 'approvals.partials._expense-form' , [
'initialData' => [],
'cards' => $cards ? ? collect (),
'accounts' => $accounts ? ? collect (),
])
2026-03-04 15:14:18 +09:00
2026-03-06 11:28:15 +09:00
{{ -- 품의서 전용 폼 -- }}
@ include ( 'approvals.partials._purchase-request-form' , [
'initialData' => [],
])
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
{{ -- 액션 버튼 -- }}
2026-02-28 14:41:37 +09:00
< div class = " border-t pt-4 mt-4 flex gap-2 justify-end " >
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
< button onclick = " saveApproval('draft') "
2026-02-28 14:41:37 +09:00
class = " bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition text-sm font-medium " >
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
임시저장
</ button >
< button onclick = " saveApproval('submit') "
2026-02-28 14:41:37 +09:00
class = " bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition text-sm font-medium " >
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
상신
</ button >
</ div >
</ div >
</ div >
2026-02-28 14:41:37 +09:00
{{ -- 결재선 모달 -- }}
< div id = " approval-line-modal " style = " display: none; " class = " fixed inset-0 z-50 " >
< div class = " absolute inset-0 bg-black/50 " onclick = " closeApprovalLineModal() " ></ div >
< div class = " relative flex items-center justify-center min-h-full p-4 " >
< div class = " bg-white rounded-xl shadow-2xl w-full overflow-hidden relative " style = " max-width: 720px; " >
< button type = " button " onclick = " closeApprovalLineModal() "
class = " absolute top-3 right-3 z-10 p-1 text-gray-400 hover:text-gray-600 transition bg-white rounded-full shadow-sm " >
< svg class = " w-5 h-5 " fill = " none " stroke = " currentColor " viewBox = " 0 0 24 24 " >
< path stroke - linecap = " round " stroke - linejoin = " round " stroke - width = " 2 " d = " M6 18L18 6M6 6l12 12 " />
</ svg >
</ button >
< div class = " overflow-y-auto " style = " max-height: 70vh; " >
@ php
$defaultLine = collect ( $lines ) -> firstWhere ( 'is_default' , true );
@ endphp
@ include ( 'approvals.partials._approval-line-editor' , [
'lines' => $lines ,
'initialSteps' => $defaultLine ? -> steps ? ? [],
'selectedLineId' => $defaultLine ? -> id ? ? '' ,
])
</ div >
< div class = " px-5 py-3 border-t border-gray-200 flex justify-end " >
< button type = " button " onclick = " closeApprovalLineModal() "
class = " px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition " >
확인
</ button >
</ div >
</ div >
</ div >
</ div >
2026-03-05 10:26:55 +09:00
{{ -- 지출결의서 불러오기 모달 -- }}
< div id = " expense-load-modal " style = " display: none; " class = " fixed inset-0 z-50 " >
< div class = " absolute inset-0 bg-black/50 " onclick = " closeExpenseLoadModal() " ></ div >
< div class = " relative flex items-center justify-center min-h-full p-4 " >
< div class = " bg-white rounded-xl shadow-2xl w-full overflow-hidden relative " style = " max-width: 640px; " >
< div class = " px-5 py-4 border-b border-gray-200 flex items-center justify-between " >
< h3 class = " text-lg font-semibold text-gray-800 " > 지출결의서 불러오기 </ h3 >
< button type = " button " onclick = " closeExpenseLoadModal() "
class = " p-1 text-gray-400 hover:text-gray-600 transition " >
< svg class = " w-5 h-5 " fill = " none " stroke = " currentColor " viewBox = " 0 0 24 24 " >
< path stroke - linecap = " round " stroke - linejoin = " round " stroke - width = " 2 " d = " M6 18L18 6M6 6l12 12 " />
</ svg >
</ button >
</ div >
< div class = " overflow-y-auto " style = " max-height: 60vh; " >
< div id = " expense-load-list " class = " p-4 " >
< div class = " text-center text-sm text-gray-400 py-8 " > 불러오는 중 ...</ div >
</ div >
</ div >
</ div >
</ div >
</ div >
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
@ endsection
2026-02-28 14:18:16 +09:00
@ push ( 'styles' )
2026-02-28 14:21:01 +09:00
< link href = " https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css " rel = " stylesheet " >
2026-02-28 14:18:16 +09:00
< style >
#quill-container .ql-editor { min-height: 260px; font-size: 0.875rem; }
#quill-container .ql-toolbar { border-radius: 0.5rem 0.5rem 0 0; border-color: #d1d5db; }
#quill-container .ql-container { border-radius: 0 0 0.5rem 0.5rem; border-color: #d1d5db; }
2026-02-28 14:55:15 +09:00
#summary-sortable .step-card {
cursor : grab ;
position : relative ;
margin - right : 24 px ;
user - select : none ;
transition : transform 0.15 s ease , box - shadow 0.15 s ease ;
}
#summary-sortable .step-card:last-child { margin-right: 0; }
#summary-sortable .step-card:not(:last-child)::after {
content : '→' ;
position : absolute ;
right : - 18 px ;
top : 50 % ;
transform : translateY ( - 50 % );
color : #d1d5db;
font - size : 14 px ;
pointer - events : none ;
}
#summary-sortable .step-card:hover {
box - shadow : 0 2 px 8 px rgba ( 0 , 0 , 0 , 0.08 );
transform : translateY ( - 1 px );
}
#summary-sortable .step-card:active { cursor: grabbing; }
#summary-sortable .step-card.sortable-ghost { opacity: 0.3; }
#summary-sortable .step-card.sortable-chosen {
box - shadow : 0 4 px 12 px rgba ( 0 , 0 , 0 , 0.15 );
transform : translateY ( - 2 px );
}
2026-02-28 14:18:16 +09:00
</ style >
@ endpush
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
@ push ( 'scripts' )
2026-02-28 14:21:01 +09:00
< script src = " https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js " ></ script >
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
< script >
2026-02-28 14:18:16 +09:00
let quillInstance = null ;
2026-02-28 14:55:15 +09:00
var summarySortableInstance = null ;
2026-03-04 14:18:54 +09:00
const formBodyTemplates = @ json ( $forms -> pluck ( 'body_template' , 'id' ));
2026-03-04 15:14:18 +09:00
const formCodes = @ json ( $forms -> pluck ( 'code' , 'id' ));
2026-03-04 14:18:54 +09:00
const linesData = @ json ( $lines );
2026-03-04 15:14:18 +09:00
let isExpenseForm = false ;
2026-03-06 11:28:15 +09:00
let isPurchaseRequestForm = false ;
2026-03-06 12:54:53 +09:00
const formDescriptions = {
2026-03-06 13:08:09 +09:00
BUSINESS_DRAFT : {
title : '업무기안서' , icon : '📄' ,
color : 'border-slate-200 bg-slate-50' , titleColor : 'text-slate-800' , textColor : 'text-slate-600' ,
text : '업무 추진에 필요한 사항을 기안하여 결재를 받는 기본 문서입니다. 프로젝트 계획, 업무 협조 요청, 내부 제안 등 정형화된 양식이 없는 일반적인 업무 보고·요청에 사용합니다.' ,
},
2026-03-06 23:38:55 +09:00
official_letter : {
title : '공문서' , icon : '📨' ,
color : 'border-blue-200 bg-blue-50' , titleColor : 'text-blue-800' , textColor : 'text-blue-600' ,
text : '외부 기관이나 거래처에 발송하는 공식 문서입니다. 문서번호, 수신처, 제목, 본문, 붙임 서류를 기재하며, 승인 후 회사 직인이 날인된 공문서로 사용합니다.' ,
},
2026-03-06 13:08:09 +09:00
leave : {
title : '휴가신청' , icon : '🏖️' ,
color : 'border-sky-200 bg-sky-50' , titleColor : 'text-sky-800' , textColor : 'text-sky-600' ,
text : '연차, 반차, 병가, 경조사 등 휴가를 사전에 신청하는 문서입니다. 휴가 유형, 기간, 사유를 기재하며, 승인 후 근태에 자동 반영됩니다.' ,
},
attendance_request : {
title : '근태신청' , icon : '🕐' ,
color : 'border-indigo-200 bg-indigo-50' , titleColor : 'text-indigo-800' , textColor : 'text-indigo-600' ,
text : '외근, 출장, 조퇴, 지각 등 정상 근무 외 근태 변경 사항을 신청하는 문서입니다. 근태 유형과 해당 일시를 기재하여 승인을 받습니다.' ,
},
reason_report : {
title : '사유서' , icon : '✏️' ,
color : 'border-rose-200 bg-rose-50' , titleColor : 'text-rose-800' , textColor : 'text-rose-600' ,
text : '지각, 결근, 조퇴 등 근태 이상 사항에 대한 사유를 소명하는 문서입니다. 사유 발생일과 상세 사유를 기재하여 사후 보고합니다.' ,
},
2026-03-06 12:54:53 +09:00
expense : {
2026-03-06 13:08:09 +09:00
title : '지출결의서' , icon : '💰' ,
color : 'border-amber-200 bg-amber-50' , titleColor : 'text-amber-800' , textColor : 'text-amber-700' ,
2026-03-06 12:54:53 +09:00
text : '이미 발생한 지출에 대해 사후 보고하는 문서입니다. 법인카드 사용 내역, 계좌이체 등 실제 지출이 완료된 건에 대해 증빙자료와 함께 결재를 요청합니다.' ,
},
2026-03-06 13:08:09 +09:00
employment_cert : {
title : '재직증명서' , icon : '🏢' ,
color : 'border-cyan-200 bg-cyan-50' , titleColor : 'text-cyan-800' , textColor : 'text-cyan-600' ,
text : '현재 재직 중임을 증명하는 공식 문서입니다. 은행 제출, 관공서 제출, 비자 신청 등의 용도로 발급하며, 승인 후 PDF로 출력할 수 있습니다.' ,
},
career_cert : {
title : '경력증명서' , icon : '📊' ,
color : 'border-violet-200 bg-violet-50' , titleColor : 'text-violet-800' , textColor : 'text-violet-600' ,
text : '재직 또는 퇴직 사원의 경력 사항을 증명하는 문서입니다. 근무 기간, 부서, 직위 등을 포함하며, 승인 후 PDF로 출력할 수 있습니다.' ,
},
appointment_cert : {
title : '위촉증명서' , icon : '🤝' ,
color : 'border-emerald-200 bg-emerald-50' , titleColor : 'text-emerald-800' , textColor : 'text-emerald-600' ,
text : '위촉직(프리랜서, 자문위원 등)의 위촉 사실을 증명하는 문서입니다. 위촉 기간, 담당 업무 등을 포함하며, 승인 후 PDF로 출력할 수 있습니다.' ,
},
resignation : {
title : '사직서' , icon : '📮' ,
color : 'border-gray-300 bg-gray-50' , titleColor : 'text-gray-800' , textColor : 'text-gray-600' ,
text : '퇴직 의사를 공식적으로 표명하는 문서입니다. 사직 사유와 희망 퇴직일을 기재하며, 결재 완료 후 퇴직 절차가 진행됩니다.' ,
},
2026-03-06 20:48:01 +09:00
seal_usage : {
title : '사용인감계' , icon : '🔏' ,
color : 'border-rose-200 bg-rose-50' , titleColor : 'text-rose-800' , textColor : 'text-rose-600' ,
text : '법인인감, 사용인감 등 회사 인감의 사용을 신청하는 문서입니다. 인감 종류, 용도, 제출처를 기재하며, 승인 후 인감을 사용할 수 있습니다.' ,
},
2026-03-06 22:25:00 +09:00
delegation : {
title : '위임장' , icon : '📜' ,
color : 'border-amber-200 bg-amber-50' , titleColor : 'text-amber-800' , textColor : 'text-amber-600' ,
text : '법인의 업무를 대리인에게 위임하는 문서입니다. 위임인(회사), 수임인(대리인), 위임사항, 위임기간을 기재하며, 승인 후 위임장으로 사용할 수 있습니다.' ,
},
2026-03-06 23:00:22 +09:00
board_minutes : {
title : '이사회의사록' , icon : '📋' ,
color : 'border-slate-300 bg-slate-50' , titleColor : 'text-slate-800' , textColor : 'text-slate-600' ,
text : '이사회 개최 내용을 기록하는 공식 문서입니다. 일시, 장소, 출석현황, 의안, 의사경과, 기명날인 등을 기재하며, 법적 효력을 갖는 회의록입니다.' ,
},
2026-03-07 00:28:58 +09:00
leave_promotion_1st : {
title : '연차사용촉진 통지서 (1차)' , icon : '📅' ,
color : 'border-orange-200 bg-orange-50' , titleColor : 'text-orange-800' , textColor : 'text-orange-600' ,
text : '근로기준법 제61조에 따른 연차 사용 촉진 1차 통지서입니다. 잔여 연차 현황을 안내하고, 사용계획 제출기한을 지정하여 직원에게 통보합니다.' ,
},
leave_promotion_2nd : {
title : '연차사용촉진 통지서 (2차)' , icon : '🚨' ,
color : 'border-red-200 bg-red-50' , titleColor : 'text-red-800' , textColor : 'text-red-600' ,
text : '1차 통지 후에도 사용 시기를 제출하지 않은 직원에게 회사가 휴가일을 지정하는 2차 통지서입니다. 근로기준법 제61조에 따른 법적 효력이 있습니다.' ,
},
2026-03-06 23:21:49 +09:00
quotation : {
title : '견적서' , icon : '💵' ,
color : 'border-green-200 bg-green-50' , titleColor : 'text-green-800' , textColor : 'text-green-600' ,
text : '고객에게 제공할 물품/서비스의 가격을 견적하는 문서입니다. 품목, 수량, 단가, 공급가액, 세액을 기재하며, 승인 후 견적서로 사용할 수 있습니다.' ,
},
2026-03-06 12:54:53 +09:00
pr_expense : {
2026-03-06 13:08:09 +09:00
title : '지출품의서' , icon : '📋' ,
color : 'border-orange-200 bg-orange-50' , titleColor : 'text-orange-800' , textColor : 'text-orange-700' ,
2026-03-06 12:54:53 +09:00
text : '지출이 발생하기 전 사전 승인을 받는 문서입니다. 예산 범위 내에서 지출 항목과 금액을 기재하여 사전에 승락을 받습니다.' ,
},
pr_contract : {
2026-03-06 13:08:09 +09:00
title : '계약체결품의서' , icon : '📝' ,
color : 'border-purple-200 bg-purple-50' , titleColor : 'text-purple-800' , textColor : 'text-purple-700' ,
2026-03-06 12:54:53 +09:00
text : '외부 업체와의 계약 체결 전 승인을 받는 문서입니다. 계약 상대방, 계약 내용, 기간, 금액, 주요 조건 등을 명시하여 계약 진행에 대한 사전 승락을 받습니다.' ,
},
pr_purchase : {
2026-03-06 13:08:09 +09:00
title : '구매품의서' , icon : '🛒' ,
color : 'border-blue-200 bg-blue-50' , titleColor : 'text-blue-800' , textColor : 'text-blue-700' ,
2026-03-06 12:54:53 +09:00
text : '물품 구매 전 사전 승인을 받는 문서입니다. 구매할 품목, 수량, 단가, 납품업체 등을 기재하여 구매 진행에 대한 사전 승락을 받습니다.' ,
},
pr_trip : {
2026-03-06 13:08:09 +09:00
title : '출장품의서' , icon : '✈️' ,
color : 'border-green-200 bg-green-50' , titleColor : 'text-green-800' , textColor : 'text-green-700' ,
2026-03-06 12:54:53 +09:00
text : '출장 전 계획 승인을 받는 문서입니다. 출장지, 기간, 업무 내용, 예상 경비(교통비·숙박비·식비 등)를 기재하여 출장 진행에 대한 사전 승락을 받습니다.' ,
},
pr_settlement : {
2026-03-06 13:08:09 +09:00
title : '비용정산품의서' , icon : '🧾' ,
color : 'border-teal-200 bg-teal-50' , titleColor : 'text-teal-800' , textColor : 'text-teal-700' ,
2026-03-06 12:54:53 +09:00
text : '업무 수행 중 발생한 비용의 정산 승인을 받는 문서입니다. 사용일자별 항목과 금액을 기재하고, 법인카드 사용 또는 개인 선지출 여부를 명시합니다.' ,
},
};
2026-03-06 13:18:44 +09:00
// 2단계 분류 정의 (코드 → 카테고리)
const formCategoryMap = {
2026-03-06 23:38:55 +09:00
BUSINESS_DRAFT : '일반' , official_letter : '일반' ,
2026-03-07 00:28:58 +09:00
leave : '인사/근태' , attendance_request : '인사/근태' , resignation : '인사/근태' , reason_report : '인사/근태' , delegation : '인사/근태' , board_minutes : '인사/근태' , leave_promotion_1st : '인사/근태' , leave_promotion_2nd : '인사/근태' ,
2026-03-06 20:48:01 +09:00
employment_cert : '증명서' , career_cert : '증명서' , appointment_cert : '증명서' , seal_usage : '증명서' ,
2026-03-06 13:18:44 +09:00
pr_expense : '품의' , pr_contract : '품의' , pr_purchase : '품의' , pr_trip : '품의' , pr_settlement : '품의' ,
2026-03-06 23:21:49 +09:00
quotation : '재무' , expense : '재무' ,
2026-03-06 13:18:44 +09:00
};
const categoryIcons = {
'일반' : '📄' , '인사/근태' : '👤' , '증명서' : '📜' , '품의' : '📋' , '재무' : '💰' ,
};
const categoryOrder = [ '일반' , '인사/근태' , '증명서' , '품의' , '재무' ];
// formId → code 역맵, formId → name
const formNames = @ json ( $forms -> pluck ( 'name' , 'id' ));
function buildCategoryOptions () {
const catSelect = document . getElementById ( 'form_category' );
const formSelect = document . getElementById ( 'form_id' );
const usedCategories = new Set ();
// form_id의 옵션에서 사용 가능한 카테고리 수집
Array . from ( formSelect . options ) . forEach ( opt => {
const code = formCodes [ opt . value ];
const cat = formCategoryMap [ code ];
if ( cat ) usedCategories . add ( cat );
});
catSelect . innerHTML = '' ;
categoryOrder . forEach ( cat => {
if ( ! usedCategories . has ( cat )) return ;
const opt = document . createElement ( 'option' );
opt . value = cat ;
opt . textContent = ( categoryIcons [ cat ] || '' ) + ' ' + cat ;
catSelect . appendChild ( opt );
});
}
function filterFormsByCategory ( category ) {
const formSelect = document . getElementById ( 'form_id' );
const currentVal = formSelect . value ;
let firstMatch = null ;
let hasCurrentInCategory = false ;
Array . from ( formSelect . options ) . forEach ( opt => {
const code = formCodes [ opt . value ];
const cat = formCategoryMap [ code ] || '일반' ;
const show = cat === category ;
opt . style . display = show ? '' : 'none' ;
opt . disabled = ! show ;
if ( show && ! firstMatch ) firstMatch = opt . value ;
if ( show && opt . value === currentVal ) hasCurrentInCategory = true ;
});
if ( ! hasCurrentInCategory && firstMatch ) {
formSelect . value = firstMatch ;
}
formSelect . dispatchEvent ( new Event ( 'change' ));
}
function selectCategoryByFormId ( formId ) {
const code = formCodes [ formId ];
const cat = formCategoryMap [ code ];
if ( cat ) {
document . getElementById ( 'form_category' ) . value = cat ;
filterFormsByCategory ( cat );
}
}
2026-03-06 12:54:53 +09:00
function updateFormDescription ( formId ) {
const code = formCodes [ formId ];
const desc = formDescriptions [ code ];
const card = document . getElementById ( 'form-description-card' );
if ( ! desc ) { card . style . display = 'none' ; return ; }
const inner = document . getElementById ( 'form-desc-inner' );
inner . className = 'rounded-lg border p-3 text-sm transition-all ' + desc . color ;
document . getElementById ( 'form-desc-icon' ) . innerHTML = '<span style="font-size: 1.25rem;">' + desc . icon + '</span>' ;
document . getElementById ( 'form-desc-title' ) . className = 'font-semibold text-sm mb-1 ' + desc . titleColor ;
document . getElementById ( 'form-desc-title' ) . textContent = desc . title ;
document . getElementById ( 'form-desc-text' ) . className = 'text-xs leading-relaxed ' + desc . textColor ;
document . getElementById ( 'form-desc-text' ) . textContent = desc . text ;
card . style . display = '' ;
}
2026-03-05 15:57:36 +09:00
let isLeaveForm = false ;
2026-03-05 18:53:42 +09:00
let isCertForm = false ;
2026-03-05 23:41:20 +09:00
let isCareerCertForm = false ;
2026-03-05 23:57:42 +09:00
let isAppointmentCertForm = false ;
2026-03-06 00:13:17 +09:00
let isResignationForm = false ;
2026-03-06 20:48:01 +09:00
let isSealUsageForm = false ;
2026-03-06 22:25:00 +09:00
let isDelegationForm = false ;
2026-03-06 23:00:22 +09:00
let isBoardMinutesForm = false ;
2026-03-06 23:21:49 +09:00
let isQuotationForm = false ;
2026-03-06 23:38:55 +09:00
let isOfficialLetterForm = false ;
2026-03-07 00:28:58 +09:00
let isLeavePromotion1stForm = false ;
let isLeavePromotion2ndForm = false ;
2026-03-05 15:57:36 +09:00
// 양식코드별 표시할 유형 목록
const leaveTypesByFormCode = {
leave : [
{ value : 'annual' , label : '연차' },
{ value : 'half_am' , label : '오전반차' },
{ value : 'half_pm' , label : '오후반차' },
{ value : 'sick' , label : '병가' },
{ value : 'family' , label : '경조사' },
{ value : 'maternity' , label : '출산' },
{ value : 'parental' , label : '육아' },
],
attendance_request : [
{ value : 'business_trip' , label : '출장' },
{ value : 'remote' , label : '재택근무' },
{ value : 'field_work' , label : '외근' },
{ value : 'early_leave' , label : '조퇴' },
],
reason_report : [
{ value : 'late_reason' , label : '지각사유서' },
{ value : 'absent_reason' , label : '결근사유서' },
],
};
function populateLeaveTypes ( formCode ) {
const select = document . getElementById ( 'leave-type' );
select . innerHTML = '' ;
const types = leaveTypesByFormCode [ formCode ] || [];
types . forEach ( t => {
const opt = document . createElement ( 'option' );
opt . value = t . value ;
opt . textContent = t . label ;
select . appendChild ( opt );
});
}
function getLeaveFormData () {
return {
user_id : parseInt ( document . getElementById ( 'leave-user-id' ) . value ),
leave_type : document . getElementById ( 'leave-type' ) . value ,
start_date : document . getElementById ( 'leave-start-date' ) . value ,
end_date : document . getElementById ( 'leave-end-date' ) . value ,
reason : document . getElementById ( 'leave-reason' ) . value . trim () || null ,
};
}
function buildLeaveBody ( data ) {
const typeSelect = document . getElementById ( 'leave-type' );
const userSelect = document . getElementById ( 'leave-user-id' );
const typeName = typeSelect . options [ typeSelect . selectedIndex ] ? . text || data . leave_type ;
const userName = userSelect . options [ userSelect . selectedIndex ] ? . text || '' ;
const rows = [
[ '신청자' , userName ],
[ '유형' , typeName ],
];
2026-03-06 17:58:59 +09:00
const fmtDate = ( d ) => ( d || '' ) . replace ( 'T' , ' ' );
2026-03-05 15:57:36 +09:00
if ( data . start_date === data . end_date ) {
2026-03-06 17:58:59 +09:00
rows . push ([ '대상일' , fmtDate ( data . start_date )]);
2026-03-05 15:57:36 +09:00
} else {
2026-03-06 17:58:59 +09:00
rows . push ([ '기간' , fmtDate ( data . start_date ) + ' ~ ' + fmtDate ( data . end_date )]);
2026-03-05 15:57:36 +09:00
}
if ( data . reason ) {
rows . push ([ '사유' , data . reason ]);
}
let html = '<p>아래와 같이 신청합니다.</p>' ;
html += '<table style="border-collapse:collapse; width:100%; margin-top:12px; font-size:14px;">' ;
rows . forEach (([ label , value ]) => {
html += '<tr><th style="padding:8px 12px; background:#f8f9fa; border:1px solid #dee2e6; text-align:left; width:120px; font-weight:600;">'
+ escapeHtml ( label ) + '</th><td style="padding:8px 12px; border:1px solid #dee2e6;">'
+ escapeHtml ( value ) + '</td></tr>' ;
});
html += '</table>' ;
return html ;
}
2026-02-28 14:18:16 +09:00
2026-03-04 14:21:07 +09:00
function escapeHtml ( str ) {
if ( ! str ) return '' ;
const div = document . createElement ( 'div' );
div . appendChild ( document . createTextNode ( str ));
return div . innerHTML ;
}
2026-02-28 14:18:16 +09:00
function toggleEditor () {
const useEditor = document . getElementById ( 'useEditor' ) . checked ;
const textarea = document . getElementById ( 'body' );
const container = document . getElementById ( 'quill-container' );
if ( useEditor ) {
container . style . display = '' ;
textarea . style . display = 'none' ;
if ( ! quillInstance ) {
quillInstance = new Quill ( '#quill-container' , {
theme : 'snow' ,
modules : {
toolbar : [
[{ 'header' : [ 1 , 2 , 3 , false ] }],
[ 'bold' , 'italic' , 'underline' , 'strike' ],
[{ 'color' : [] }, { 'background' : [] }],
[{ 'list' : 'ordered' }, { 'list' : 'bullet' }],
[{ 'align' : [] }],
[ 'blockquote' , 'code-block' ],
[ 'link' ],
[ 'clean' ],
],
},
placeholder : '기안 내용을 입력하세요...' ,
});
}
const text = textarea . value . trim ();
if ( text ) {
if ( /< [ a - z ][ \s\S ] *>/ i . test ( text )) {
quillInstance . root . innerHTML = text ;
} else {
quillInstance . setText ( text );
}
}
} else {
container . style . display = 'none' ;
textarea . style . display = '' ;
if ( quillInstance ) {
const html = quillInstance . root . innerHTML ;
textarea . value = ( html === '<p><br></p>' ) ? '' : html ;
}
}
}
function getBodyContent () {
const useEditor = document . getElementById ( 'useEditor' ) ? . checked ;
if ( useEditor && quillInstance ) {
const html = quillInstance . root . innerHTML ;
return ( html === '<p><br></p>' ) ? '' : html ;
}
return document . getElementById ( 'body' ) . value ;
}
2026-02-28 14:41:37 +09:00
function openApprovalLineModal () {
document . getElementById ( 'approval-line-modal' ) . style . display = '' ;
document . body . style . overflow = 'hidden' ;
}
function closeApprovalLineModal () {
document . getElementById ( 'approval-line-modal' ) . style . display = 'none' ;
document . body . style . overflow = '' ;
updateApprovalLineSummary ();
2026-03-04 14:18:54 +09:00
// 모달 내부 결재선 선택과 외부 드롭다운 동기화
const editorEl = document . getElementById ( 'approval-line-editor' );
if ( editorEl && editorEl . _x_dataStack ) {
document . getElementById ( 'quick-line-select' ) . value = editorEl . _x_dataStack [ 0 ] . selectedLineId || '' ;
}
2026-02-28 14:41:37 +09:00
}
function updateApprovalLineSummary () {
const editorEl = document . getElementById ( 'approval-line-editor' );
if ( ! editorEl || ! editorEl . _x_dataStack ) return ;
2026-03-07 20:57:57 +09:00
const alpineData = editorEl . _x_dataStack [ 0 ];
const steps = alpineData . steps ;
const references = alpineData . references || [];
2026-02-28 14:41:37 +09:00
const summaryEl = document . getElementById ( 'approval-line-summary' );
2026-02-28 14:55:15 +09:00
if ( summarySortableInstance ) { summarySortableInstance . destroy (); summarySortableInstance = null ; }
2026-03-07 20:57:57 +09:00
if (( ! steps || steps . length === 0 ) && references . length === 0 ) {
2026-02-28 14:48:16 +09:00
summaryEl . className = 'p-3 bg-gray-50 rounded-lg border border-gray-200 flex items-center' ;
summaryEl . innerHTML = '<span class="text-sm text-gray-400">결재선이 설정되지 않았습니다.</span>' ;
2026-02-28 14:41:37 +09:00
return ;
}
2026-03-07 20:57:57 +09:00
const typeCounters = { approval : 0 , agreement : 0 };
const typeLabels = { approval : '결재' , agreement : '합의' };
const typeBg = { approval : 'bg-blue-50 border-blue-100' , agreement : 'bg-green-50 border-green-100' };
const typeLabelColor = { approval : 'text-blue-600' , agreement : 'text-green-600' };
2026-02-28 14:48:16 +09:00
2026-03-07 20:57:57 +09:00
// 결재선 카드
const approvalCards = [];
2026-02-28 14:48:16 +09:00
steps . forEach ( function ( s , i ) {
typeCounters [ s . step_type ] = ( typeCounters [ s . step_type ] || 0 ) + 1 ;
var count = typeCounters [ s . step_type ];
var label = typeLabels [ s . step_type ] || s . step_type ;
2026-03-07 20:57:57 +09:00
var bg = typeBg [ s . step_type ] || 'bg-blue-50 border-blue-100' ;
var labelColor = typeLabelColor [ s . step_type ] || 'text-blue-600' ;
var stepLabel = count + '차 ' + label ;
2026-02-28 14:48:16 +09:00
var position = s . position || '' ;
2026-03-07 20:57:57 +09:00
approvalCards . push (
2026-02-28 14:55:15 +09:00
'<div class="step-card text-center px-3 py-2 rounded-lg border ' + bg + '" data-index="' + i + '" style="min-width: 72px;">' +
2026-03-04 14:21:07 +09:00
'<div class="text-xs font-medium ' + labelColor + '">' + escapeHtml ( stepLabel ) + '</div>' +
( position ? '<div class="text-xs text-gray-400 mt-0.5">' + escapeHtml ( position ) + '</div>' : '' ) +
'<div class="text-sm font-semibold text-gray-800 mt-0.5">' + escapeHtml ( s . user_name ) + '</div>' +
2026-02-28 14:48:16 +09:00
'</div>'
);
2026-02-28 14:41:37 +09:00
});
2026-03-07 20:57:57 +09:00
// 참조선 카드
const refCards = [];
references . forEach ( function ( r ) {
var position = r . position || '' ;
refCards . push (
'<div class="text-center px-3 py-2 rounded-lg border bg-gray-50 border-gray-200" style="min-width: 72px;">' +
'<div class="text-xs font-medium text-gray-500">참조</div>' +
( position ? '<div class="text-xs text-gray-400 mt-0.5">' + escapeHtml ( position ) + '</div>' : '' ) +
'<div class="text-sm font-semibold text-gray-800 mt-0.5">' + escapeHtml ( r . user_name ) + '</div>' +
'</div>'
);
});
// 가로 2분할 레이아웃
var html = '<div class="grid gap-3" style="grid-template-columns: ' + ( refCards . length > 0 ? '1fr 1fr' : '1fr' ) + ';">' ;
// 좌측: 결재선
html += '<div>' ;
html += '<div class="flex items-center gap-1.5 mb-2"><svg class="w-3.5 h-3.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span class="text-xs font-semibold text-gray-600">결재선</span><span class="text-xs text-gray-400">(' + steps . length + '명)</span></div>' ;
if ( approvalCards . length > 0 ) {
html += '<div id="summary-sortable" class="flex flex-wrap gap-2">' + approvalCards . join ( '' ) + '</div>' ;
if ( steps . length > 1 ) html += '<div class="text-xs text-gray-400 mt-1.5">드래그하여 순서 변경</div>' ;
} else {
html += '<div class="text-xs text-gray-400 py-2">결재자가 없습니다</div>' ;
}
html += '</div>' ;
// 우측: 참조선
if ( refCards . length > 0 ) {
html += '<div class="border-l border-gray-200 pl-3">' ;
html += '<div class="flex items-center gap-1.5 mb-2"><svg class="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg><span class="text-xs font-semibold text-gray-600">참조</span><span class="text-xs text-gray-400">(' + references . length + '명)</span></div>' ;
html += '<div class="flex flex-wrap gap-2">' + refCards . join ( '' ) + '</div>' ;
html += '</div>' ;
}
html += '</div>' ;
2026-02-28 14:48:16 +09:00
summaryEl . className = 'p-3 bg-gray-50 rounded-lg border border-gray-200' ;
2026-03-07 20:57:57 +09:00
summaryEl . innerHTML = html ;
2026-02-28 14:55:15 +09:00
initSummarySortable ();
}
function initSummarySortable () {
if ( summarySortableInstance ) { summarySortableInstance . destroy (); summarySortableInstance = null ; }
var el = document . getElementById ( 'summary-sortable' );
if ( ! el || typeof Sortable === 'undefined' ) return ;
summarySortableInstance = Sortable . create ( el , {
animation : 150 ,
ghostClass : 'sortable-ghost' ,
chosenClass : 'sortable-chosen' ,
onEnd : function ( evt ) {
if ( evt . oldIndex === evt . newIndex ) return ;
var editorEl = document . getElementById ( 'approval-line-editor' );
if ( ! editorEl || ! editorEl . _x_dataStack ) return ;
var steps = editorEl . _x_dataStack [ 0 ] . steps ;
var item = steps . splice ( evt . oldIndex , 1 )[ 0 ];
steps . splice ( evt . newIndex , 0 , item );
updateApprovalLineSummary ();
}
});
2026-02-28 14:41:37 +09:00
}
2026-03-04 14:18:54 +09:00
function applyQuickLine ( lineId ) {
const editorEl = document . getElementById ( 'approval-line-editor' );
if ( ! editorEl || ! editorEl . _x_dataStack ) return ;
const alpineData = editorEl . _x_dataStack [ 0 ];
if ( ! lineId ) {
alpineData . selectedLineId = '' ;
alpineData . steps = [];
} else {
alpineData . selectedLineId = lineId ;
alpineData . loadLine ();
}
setTimeout ( updateApprovalLineSummary , 100 );
}
2026-03-04 15:14:18 +09:00
function switchFormMode ( formId ) {
const code = formCodes [ formId ];
const expenseContainer = document . getElementById ( 'expense-form-container' );
2026-03-06 11:28:15 +09:00
const purchaseRequestContainer = document . getElementById ( 'purchase-request-form-container' );
2026-03-05 15:57:36 +09:00
const leaveContainer = document . getElementById ( 'leave-form-container' );
2026-03-05 18:53:42 +09:00
const certContainer = document . getElementById ( 'cert-form-container' );
2026-03-05 23:41:20 +09:00
const careerCertContainer = document . getElementById ( 'career-cert-form-container' );
2026-03-05 23:57:42 +09:00
const appointmentCertContainer = document . getElementById ( 'appointment-cert-form-container' );
2026-03-06 00:13:17 +09:00
const resignationContainer = document . getElementById ( 'resignation-form-container' );
2026-03-06 20:48:01 +09:00
const sealUsageContainer = document . getElementById ( 'seal-usage-form-container' );
2026-03-06 22:25:00 +09:00
const delegationContainer = document . getElementById ( 'delegation-form-container' );
2026-03-06 23:00:22 +09:00
const boardMinutesContainer = document . getElementById ( 'board-minutes-form-container' );
2026-03-06 23:21:49 +09:00
const quotationContainer = document . getElementById ( 'quotation-form-container' );
2026-03-06 23:38:55 +09:00
const officialLetterContainer = document . getElementById ( 'official-letter-form-container' );
2026-03-07 00:28:58 +09:00
const lp1Container = document . getElementById ( 'leave-promotion-1st-form-container' );
const lp2Container = document . getElementById ( 'leave-promotion-2nd-form-container' );
2026-03-04 15:14:18 +09:00
const bodyArea = document . getElementById ( 'body-area' );
2026-03-05 10:46:13 +09:00
const expenseLoadBtn = document . getElementById ( 'expense-load-btn' );
2026-03-04 15:14:18 +09:00
2026-03-05 15:57:36 +09:00
const leaveFormCodes = [ 'leave' , 'attendance_request' , 'reason_report' ];
2026-03-05 18:53:42 +09:00
// 모두 숨기기
expenseContainer . style . display = 'none' ;
2026-03-06 11:28:15 +09:00
purchaseRequestContainer . style . display = 'none' ;
2026-03-05 18:53:42 +09:00
leaveContainer . style . display = 'none' ;
certContainer . style . display = 'none' ;
2026-03-05 23:41:20 +09:00
careerCertContainer . style . display = 'none' ;
2026-03-05 23:57:42 +09:00
appointmentCertContainer . style . display = 'none' ;
2026-03-06 00:13:17 +09:00
resignationContainer . style . display = 'none' ;
2026-03-06 20:48:01 +09:00
sealUsageContainer . style . display = 'none' ;
2026-03-06 22:25:00 +09:00
delegationContainer . style . display = 'none' ;
2026-03-06 23:00:22 +09:00
boardMinutesContainer . style . display = 'none' ;
2026-03-06 23:21:49 +09:00
quotationContainer . style . display = 'none' ;
2026-03-06 23:38:55 +09:00
officialLetterContainer . style . display = 'none' ;
2026-03-07 00:28:58 +09:00
lp1Container . style . display = 'none' ;
lp2Container . style . display = 'none' ;
2026-03-05 18:53:42 +09:00
expenseLoadBtn . style . display = 'none' ;
bodyArea . style . display = 'none' ;
isExpenseForm = false ;
2026-03-06 11:28:15 +09:00
isPurchaseRequestForm = false ;
2026-03-05 18:53:42 +09:00
isLeaveForm = false ;
isCertForm = false ;
2026-03-05 23:41:20 +09:00
isCareerCertForm = false ;
2026-03-05 23:57:42 +09:00
isAppointmentCertForm = false ;
2026-03-06 00:13:17 +09:00
isResignationForm = false ;
2026-03-06 20:48:01 +09:00
isSealUsageForm = false ;
2026-03-06 22:25:00 +09:00
isDelegationForm = false ;
2026-03-06 23:00:22 +09:00
isBoardMinutesForm = false ;
2026-03-06 23:21:49 +09:00
isQuotationForm = false ;
2026-03-06 23:38:55 +09:00
isOfficialLetterForm = false ;
2026-03-07 00:28:58 +09:00
isLeavePromotion1stForm = false ;
isLeavePromotion2ndForm = false ;
2026-03-05 18:53:42 +09:00
2026-03-04 15:14:18 +09:00
if ( code === 'expense' ) {
isExpenseForm = true ;
expenseContainer . style . display = '' ;
2026-03-05 10:46:13 +09:00
expenseLoadBtn . style . display = '' ;
2026-03-06 11:40:50 +09:00
} else if ( code && code . startsWith ( 'pr_' )) {
2026-03-06 11:28:15 +09:00
isPurchaseRequestForm = true ;
purchaseRequestContainer . style . display = '' ;
2026-03-06 11:40:50 +09:00
setTimeout (() => {
const prData = purchaseRequestContainer . _x_dataStack ? . [ 0 ];
if ( prData ) prData . setPrType ( code );
}, 50 );
2026-03-05 15:57:36 +09:00
} else if ( leaveFormCodes . includes ( code )) {
isLeaveForm = true ;
leaveContainer . style . display = '' ;
populateLeaveTypes ( code );
const startEl = document . getElementById ( 'leave-start-date' );
const endEl = document . getElementById ( 'leave-end-date' );
2026-03-06 17:44:27 +09:00
const startLabel = document . getElementById ( 'leave-start-label' );
2026-03-06 17:46:38 +09:00
const endLabel = document . getElementById ( 'leave-end-label' );
2026-03-06 17:44:27 +09:00
2026-03-06 17:46:38 +09:00
// 근태신청은 시작일/종료일에 시간 선택 가능
2026-03-06 17:44:27 +09:00
if ( code === 'attendance_request' ) {
startEl . type = 'datetime-local' ;
2026-03-06 17:46:38 +09:00
endEl . type = 'datetime-local' ;
2026-03-06 17:44:27 +09:00
if ( startLabel ) startLabel . textContent = '시작일시' ;
2026-03-06 17:46:38 +09:00
if ( endLabel ) endLabel . textContent = '종료일시' ;
2026-03-06 17:44:27 +09:00
if ( ! startEl . value ) startEl . value = new Date () . toISOString () . slice ( 0 , 16 );
2026-03-06 17:46:38 +09:00
if ( ! endEl . value ) endEl . value = new Date () . toISOString () . slice ( 0 , 16 );
2026-03-06 17:44:27 +09:00
} else {
startEl . type = 'date' ;
2026-03-06 17:46:38 +09:00
endEl . type = 'date' ;
2026-03-06 17:44:27 +09:00
if ( startLabel ) startLabel . textContent = '시작일' ;
2026-03-06 17:46:38 +09:00
if ( endLabel ) endLabel . textContent = '종료일' ;
const today = new Date () . toISOString () . slice ( 0 , 10 );
if ( ! startEl . value ) startEl . value = today ;
if ( ! endEl . value ) endEl . value = today ;
2026-03-06 17:44:27 +09:00
}
2026-03-05 18:53:42 +09:00
} else if ( code === 'employment_cert' ) {
isCertForm = true ;
certContainer . style . display = '' ;
const certUserId = document . getElementById ( 'cert-user-id' ) . value ;
if ( certUserId ) loadCertInfo ( certUserId );
2026-03-05 23:41:20 +09:00
} else if ( code === 'career_cert' ) {
isCareerCertForm = true ;
careerCertContainer . style . display = '' ;
const ccUserId = document . getElementById ( 'cc-user-id' ) . value ;
if ( ccUserId ) loadCareerCertInfo ( ccUserId );
2026-03-05 23:57:42 +09:00
} else if ( code === 'appointment_cert' ) {
isAppointmentCertForm = true ;
appointmentCertContainer . style . display = '' ;
const acUserId = document . getElementById ( 'ac-user-id' ) . value ;
if ( acUserId ) loadAppointmentCertInfo ( acUserId );
2026-03-06 00:13:17 +09:00
} else if ( code === 'resignation' ) {
isResignationForm = true ;
resignationContainer . style . display = '' ;
const rgUserId = document . getElementById ( 'rg-user-id' ) . value ;
if ( rgUserId ) loadResignationInfo ( rgUserId );
2026-03-06 20:48:01 +09:00
} else if ( code === 'seal_usage' ) {
isSealUsageForm = true ;
sealUsageContainer . style . display = '' ;
2026-03-06 22:25:00 +09:00
} else if ( code === 'delegation' ) {
isDelegationForm = true ;
delegationContainer . style . display = '' ;
2026-03-06 23:00:22 +09:00
} else if ( code === 'board_minutes' ) {
isBoardMinutesForm = true ;
boardMinutesContainer . style . display = '' ;
2026-03-06 23:21:49 +09:00
} else if ( code === 'quotation' ) {
isQuotationForm = true ;
quotationContainer . style . display = '' ;
2026-03-06 23:38:55 +09:00
} else if ( code === 'official_letter' ) {
isOfficialLetterForm = true ;
officialLetterContainer . style . display = '' ;
2026-03-07 00:28:58 +09:00
} else if ( code === 'leave_promotion_1st' ) {
isLeavePromotion1stForm = true ;
lp1Container . style . display = '' ;
} else if ( code === 'leave_promotion_2nd' ) {
isLeavePromotion2ndForm = true ;
lp2Container . style . display = '' ;
2026-03-04 15:14:18 +09:00
} else {
bodyArea . style . display = '' ;
}
}
2026-03-04 14:18:54 +09:00
function applyBodyTemplate ( formId ) {
2026-03-04 15:14:18 +09:00
// 먼저 폼 모드 전환
switchFormMode ( formId );
2026-03-05 23:46:38 +09:00
// 전용 폼이면 제목을 양식명으로 설정하고 body template 적용 건너뜀
2026-03-07 00:28:58 +09:00
if ( isExpenseForm || isPurchaseRequestForm || isLeaveForm || isCertForm || isCareerCertForm || isAppointmentCertForm || isResignationForm || isSealUsageForm || isDelegationForm || isBoardMinutesForm || isQuotationForm || isOfficialLetterForm || isLeavePromotion1stForm || isLeavePromotion2ndForm ) {
2026-03-04 15:14:18 +09:00
const titleEl = document . getElementById ( 'title' );
2026-03-05 23:46:38 +09:00
const formSelect = document . getElementById ( 'form_id' );
titleEl . value = formSelect . options [ formSelect . selectedIndex ] . text ;
2026-03-04 15:14:18 +09:00
return ;
}
2026-03-04 14:18:54 +09:00
const template = formBodyTemplates [ formId ];
if ( ! template ) return ;
const bodyContent = getBodyContent ();
if ( bodyContent . trim ()) {
if ( ! confirm ( '현재 본문 내용을 양식 템플릿으로 교체하시겠습니까?' )) return ;
}
2026-03-04 14:51:18 +09:00
// 제목 자동 설정 (비어있을 때만)
const titleEl = document . getElementById ( 'title' );
if ( ! titleEl . value . trim ()) {
const formSelect = document . getElementById ( 'form_id' );
titleEl . value = formSelect . options [ formSelect . selectedIndex ] . text ;
}
2026-03-04 14:18:54 +09:00
// 편집기 활성화 후 HTML 삽입
const useEditorEl = document . getElementById ( 'useEditor' );
if ( ! useEditorEl . checked ) {
useEditorEl . checked = true ;
toggleEditor ();
}
if ( quillInstance ) {
quillInstance . root . innerHTML = template ;
}
document . getElementById ( 'body' ) . value = template ;
}
2026-02-28 14:41:37 +09:00
document . addEventListener ( 'keydown' , function ( e ) {
if ( e . key === 'Escape' ) {
2026-03-05 10:26:55 +09:00
const expModal = document . getElementById ( 'expense-load-modal' );
if ( expModal && expModal . style . display !== 'none' ) {
closeExpenseLoadModal ();
return ;
}
2026-02-28 14:41:37 +09:00
const modal = document . getElementById ( 'approval-line-modal' );
if ( modal && modal . style . display !== 'none' ) {
closeApprovalLineModal ();
}
}
});
document . addEventListener ( 'DOMContentLoaded' , function () {
setTimeout ( updateApprovalLineSummary , 200 );
2026-03-04 14:18:54 +09:00
2026-03-06 13:18:44 +09:00
// 2단계 분류 초기화
buildCategoryOptions ();
2026-03-06 12:54:53 +09:00
const initialFormId = document . getElementById ( 'form_id' ) . value ;
2026-03-06 13:18:44 +09:00
selectCategoryByFormId ( initialFormId );
2026-03-06 12:54:53 +09:00
switchFormMode ( initialFormId );
updateFormDescription ( initialFormId );
2026-03-04 15:14:18 +09:00
2026-03-06 13:18:44 +09:00
// 분류 변경 → 양식 필터링
document . getElementById ( 'form_category' ) . addEventListener ( 'change' , function () {
filterFormsByCategory ( this . value );
});
2026-03-06 12:54:53 +09:00
// 양식 변경 시 본문 템플릿 자동 채움 + 설명 카드 업데이트
2026-03-04 14:18:54 +09:00
document . getElementById ( 'form_id' ) . addEventListener ( 'change' , function () {
applyBodyTemplate ( this . value );
2026-03-06 12:54:53 +09:00
updateFormDescription ( this . value );
2026-03-04 14:18:54 +09:00
});
2026-02-28 14:41:37 +09:00
});
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
async function saveApproval ( action ) {
const title = document . getElementById ( 'title' ) . value . trim ();
if ( ! title ) {
showToast ( '제목을 입력해주세요.' , 'warning' );
return ;
}
2026-02-28 00:45:08 +09:00
const editorEl = document . getElementById ( 'approval-line-editor' );
const steps = editorEl . _x_dataStack [ 0 ] . getStepsData ();
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
if ( action === 'submit' && steps . filter ( s => s . step_type !== 'reference' ) . length === 0 ) {
showToast ( '결재자를 1명 이상 추가해주세요.' , 'warning' );
return ;
}
2026-03-05 15:57:36 +09:00
let formContent = {};
let formBody = getBodyContent ();
2026-03-04 20:07:49 +09:00
let attachmentFileIds = [];
2026-03-05 15:57:36 +09:00
2026-03-04 15:14:18 +09:00
if ( isExpenseForm ) {
const expenseEl = document . getElementById ( 'expense-form-container' );
if ( expenseEl && expenseEl . _x_dataStack ) {
2026-03-05 15:57:36 +09:00
formContent = expenseEl . _x_dataStack [ 0 ] . getFormData ();
2026-03-04 20:07:49 +09:00
attachmentFileIds = expenseEl . _x_dataStack [ 0 ] . getFileIds ();
2026-03-04 15:14:18 +09:00
}
2026-03-05 15:57:36 +09:00
formBody = null ;
2026-03-06 11:28:15 +09:00
} else if ( isPurchaseRequestForm ) {
const prEl = document . getElementById ( 'purchase-request-form-container' );
if ( prEl && prEl . _x_dataStack ) {
formContent = prEl . _x_dataStack [ 0 ] . getFormData ();
attachmentFileIds = prEl . _x_dataStack [ 0 ] . getFileIds ();
}
formBody = null ;
2026-03-05 15:57:36 +09:00
} else if ( isLeaveForm ) {
const leaveData = getLeaveFormData ();
if ( ! leaveData . leave_type ) {
showToast ( '유형을 선택해주세요.' , 'warning' );
return ;
}
if ( ! leaveData . start_date || ! leaveData . end_date ) {
showToast ( '시작일과 종료일을 입력해주세요.' , 'warning' );
return ;
}
if ( leaveData . start_date > leaveData . end_date ) {
showToast ( '종료일이 시작일보다 이전입니다.' , 'warning' );
return ;
}
formContent = leaveData ;
formBody = buildLeaveBody ( leaveData );
2026-03-05 18:53:42 +09:00
} else if ( isCertForm ) {
const purpose = getCertPurpose ();
if ( ! purpose ) {
showToast ( '사용용도를 입력해주세요.' , 'warning' );
return ;
}
formContent = {
cert_user_id : document . getElementById ( 'cert-user-id' ) . value ,
name : document . getElementById ( 'cert-name' ) . value ,
resident_number : document . getElementById ( 'cert-resident' ) . value ,
address : document . getElementById ( 'cert-address' ) . value ,
department : document . getElementById ( 'cert-department' ) . value ,
position : document . getElementById ( 'cert-position' ) . value ,
hire_date : document . getElementById ( 'cert-hire-date' ) . value ,
company_name : document . getElementById ( 'cert-company' ) . value ,
business_num : document . getElementById ( 'cert-business-num' ) . value ,
2026-03-06 09:19:32 +09:00
ceo_name : document . getElementById ( 'cert-ceo-name' ) . value ,
company_address : document . getElementById ( 'cert-company-address' ) . value ,
2026-03-05 18:53:42 +09:00
purpose : purpose ,
issue_date : document . getElementById ( 'cert-issue-date' ) . value ,
};
formBody = null ;
2026-03-05 23:41:20 +09:00
} else if ( isCareerCertForm ) {
const purpose = getCareerCertPurpose ();
if ( ! purpose ) {
showToast ( '사용용도를 입력해주세요.' , 'warning' );
return ;
}
formContent = {
cert_user_id : document . getElementById ( 'cc-user-id' ) . value ,
name : document . getElementById ( 'cc-name' ) . value ,
2026-03-06 15:58:51 +09:00
resident_number : document . getElementById ( 'cc-resident' ) . value ,
2026-03-05 23:41:20 +09:00
birth_date : document . getElementById ( 'cc-birth-date' ) . value ,
address : document . getElementById ( 'cc-address' ) . value ,
department : document . getElementById ( 'cc-department' ) . value ,
position : document . getElementById ( 'cc-position' ) . value ,
hire_date : document . getElementById ( 'cc-hire-date' ) . value ,
resign_date : document . getElementById ( 'cc-resign-date' ) . value ,
job_description : document . getElementById ( 'cc-job-description' ) . value ,
company_name : document . getElementById ( 'cc-company' ) . value ,
business_num : document . getElementById ( 'cc-business-num' ) . value ,
ceo_name : document . getElementById ( 'cc-ceo-name' ) . value ,
phone : document . getElementById ( 'cc-phone' ) . value ,
company_address : document . getElementById ( 'cc-company-address' ) . value ,
purpose : purpose ,
issue_date : document . getElementById ( 'cc-issue-date' ) . value ,
};
formBody = null ;
2026-03-05 23:57:42 +09:00
} else if ( isAppointmentCertForm ) {
const purpose = getAppointmentCertPurpose ();
if ( ! purpose ) {
showToast ( '용도를 입력해주세요.' , 'warning' );
return ;
}
formContent = {
cert_user_id : document . getElementById ( 'ac-user-id' ) . value ,
name : document . getElementById ( 'ac-name' ) . value ,
resident_number : document . getElementById ( 'ac-resident' ) . value ,
department : document . getElementById ( 'ac-department' ) . value ,
phone : document . getElementById ( 'ac-phone' ) . value ,
hire_date : document . getElementById ( 'ac-hire-date' ) . value ,
resign_date : document . getElementById ( 'ac-resign-date' ) . value ,
contract_type : document . getElementById ( 'ac-contract-type' ) . value ,
company_name : document . getElementById ( 'ac-company-name' ) . value ,
ceo_name : document . getElementById ( 'ac-ceo-name' ) . value ,
purpose : purpose ,
issue_date : document . getElementById ( 'ac-issue-date' ) . value ,
};
formBody = null ;
2026-03-06 20:48:01 +09:00
} else if ( isSealUsageForm ) {
const suPurpose = document . getElementById ( 'su-purpose' ) . value . trim ();
if ( ! suPurpose ) {
showToast ( '용도를 입력해주세요.' , 'warning' );
return ;
}
const suSubmitTo = document . getElementById ( 'su-submit-to' ) . value . trim ();
if ( ! suSubmitTo ) {
showToast ( '제출처를 입력해주세요.' , 'warning' );
return ;
}
formContent = {
usage_date : document . getElementById ( 'su-usage-date' ) . value ,
purpose : suPurpose ,
submit_to : suSubmitTo ,
2026-03-06 21:09:27 +09:00
attachment_desc : document . getElementById ( 'su-attachment-desc' ) . value . trim (),
2026-03-06 20:48:01 +09:00
company_name : document . getElementById ( 'su-company-name' ) . value ,
business_num : document . getElementById ( 'su-business-num' ) . value ,
ceo_name : document . getElementById ( 'su-ceo-name' ) . value ,
company_address : document . getElementById ( 'su-company-address' ) . value ,
};
formBody = null ;
2026-03-06 23:00:22 +09:00
} else if ( isBoardMinutesForm ) {
const bmDatetime = document . getElementById ( 'bm-meeting-datetime' ) . value ;
if ( ! bmDatetime ) {
showToast ( '일시를 입력해주세요.' , 'warning' );
return ;
}
const bmPlace = document . getElementById ( 'bm-meeting-place' ) . value . trim ();
if ( ! bmPlace ) {
showToast ( '장소를 입력해주세요.' , 'warning' );
return ;
}
const bmChairman = document . getElementById ( 'bm-chairman-name' ) . value . trim ();
if ( ! bmChairman ) {
showToast ( '의장(대표이사) 성명을 입력해주세요.' , 'warning' );
return ;
}
const bmContainer = document . getElementById ( 'board-minutes-form-container' );
const bmAlpine = bmContainer . _x_dataStack ? . [ 0 ];
const bmAgendas = bmAlpine ? bmAlpine . agendas : [];
const bmSigners = bmAlpine ? bmAlpine . signers : [];
if ( bmAgendas . length === 0 || ! bmAgendas [ 0 ] . title . trim ()) {
showToast ( '의안을 1건 이상 입력해주세요.' , 'warning' );
return ;
}
formContent = {
meeting_datetime : bmDatetime ,
meeting_place : bmPlace ,
total_directors : parseInt ( document . getElementById ( 'bm-total-directors' ) . value ) || 0 ,
present_directors : parseInt ( document . getElementById ( 'bm-present-directors' ) . value ) || 0 ,
total_auditors : parseInt ( document . getElementById ( 'bm-total-auditors' ) . value ) || 0 ,
present_auditors : parseInt ( document . getElementById ( 'bm-present-auditors' ) . value ) || 0 ,
chairman_name : bmChairman ,
agendas : bmAgendas . filter ( a => a . title . trim ()) . map ( a => ({ no : a . no , title : a . title . trim (), result : a . result . trim () })),
proceedings : document . getElementById ( 'bm-proceedings' ) . value . trim (),
closing_time : document . getElementById ( 'bm-closing-time' ) . value ,
signers : bmSigners . filter ( s => s . name . trim ()) . map ( s => ({ role : s . role . trim (), name : s . name . trim () })),
company_name : document . getElementById ( 'bm-company-name' ) . value ,
business_num : document . getElementById ( 'bm-business-num' ) . value ,
ceo_name : document . getElementById ( 'bm-ceo-name' ) . value ,
company_address : document . getElementById ( 'bm-company-address' ) . value ,
meeting_date : bmDatetime . split ( 'T' )[ 0 ],
};
formBody = null ;
2026-03-06 23:21:49 +09:00
} else if ( isQuotationForm ) {
const qtClientName = document . getElementById ( 'qt-client-name' ) . value . trim ();
if ( ! qtClientName ) {
showToast ( '수신(고객명)을 입력해주세요.' , 'warning' );
return ;
}
const qtQuoteDate = document . getElementById ( 'qt-quote-date' ) . value ;
if ( ! qtQuoteDate ) {
showToast ( '견적일자를 입력해주세요.' , 'warning' );
return ;
}
const qtContainer = document . getElementById ( 'quotation-form-container' );
const qtAlpine = qtContainer . _x_dataStack ? . [ 0 ];
const qtItems = qtAlpine ? qtAlpine . items : [];
if ( qtItems . length === 0 || ! qtItems [ 0 ] . name . trim ()) {
showToast ( '품목을 1건 이상 입력해주세요.' , 'warning' );
return ;
}
const mappedItems = qtItems . filter ( i => i . name . trim ()) . map ( i => ({
name : i . name . trim (),
spec : ( i . spec || '' ) . trim (),
qty : parseInt ( i . qty ) || 0 ,
unit_price : parseInt ( i . unit_price ) || 0 ,
supply_amount : ( parseInt ( i . qty ) || 0 ) * ( parseInt ( i . unit_price ) || 0 ),
tax : qtAlpine . itemTax ( i ),
note : ( i . note || '' ) . trim (),
}));
formContent = {
client_name : qtClientName ,
quote_date : qtQuoteDate ,
company_name : document . getElementById ( 'qt-company-name' ) . value ,
business_num : document . getElementById ( 'qt-business-num' ) . value ,
ceo_name : document . getElementById ( 'qt-ceo-name' ) . value ,
company_address : document . getElementById ( 'qt-company-address' ) . value ,
business_type : document . getElementById ( 'qt-business-type-input' ) ? . value || document . getElementById ( 'qt-business-type' ) . value ,
business_item : document . getElementById ( 'qt-business-item-input' ) ? . value || document . getElementById ( 'qt-business-item' ) . value ,
phone : document . getElementById ( 'qt-phone-input' ) ? . value || document . getElementById ( 'qt-phone' ) . value ,
bank_account : document . getElementById ( 'qt-bank-input' ) ? . value || document . getElementById ( 'qt-bank-account' ) . value ,
items : mappedItems ,
total_supply : qtAlpine ? qtAlpine . totalSupply () : 0 ,
total_tax : qtAlpine ? qtAlpine . totalTax () : 0 ,
total_amount : qtAlpine ? qtAlpine . totalAmount () : 0 ,
remarks : document . getElementById ( 'qt-remarks' ) . value . trim (),
};
formBody = null ;
2026-03-06 23:38:55 +09:00
} else if ( isOfficialLetterForm ) {
const olRecipient = document . getElementById ( 'ol-recipient' ) . value . trim ();
if ( ! olRecipient ) { showToast ( '수신처를 입력해주세요.' , 'warning' ); return ; }
const olSubject = document . getElementById ( 'ol-subject' ) . value . trim ();
if ( ! olSubject ) { showToast ( '제목을 입력해주세요.' , 'warning' ); return ; }
const olBody = document . getElementById ( 'ol-body' ) . value . trim ();
if ( ! olBody ) { showToast ( '본문을 입력해주세요.' , 'warning' ); return ; }
formContent = {
doc_number : document . getElementById ( 'ol-doc-number' ) . value . trim (),
doc_date : document . getElementById ( 'ol-doc-date' ) . value ,
recipient : olRecipient ,
reference : document . getElementById ( 'ol-reference' ) . value . trim (),
subject : olSubject ,
body : olBody ,
attachments_desc : document . getElementById ( 'ol-attachments-desc' ) . value . trim (),
company_name : document . getElementById ( 'ol-company-name' ) . value ,
ceo_name : document . getElementById ( 'ol-ceo-name' ) . value ,
company_address : document . getElementById ( 'ol-company-address' ) . value ,
phone : document . getElementById ( 'ol-phone-input' ) ? . value || document . getElementById ( 'ol-phone' ) . value ,
fax : document . getElementById ( 'ol-fax-input' ) ? . value || document . getElementById ( 'ol-fax' ) . value ,
email : document . getElementById ( 'ol-email-input' ) ? . value || document . getElementById ( 'ol-email' ) . value ,
};
formBody = null ;
2026-03-07 00:28:58 +09:00
} else if ( isLeavePromotion1stForm ) {
const lp1UserId = document . getElementById ( 'lp1-user-id' ) . value ;
if ( ! lp1UserId ) { showToast ( '대상 직원을 선택해주세요.' , 'warning' ); return ; }
const lp1Deadline = document . getElementById ( 'lp1-deadline' ) . value ;
if ( ! lp1Deadline ) { showToast ( '사용계획 제출기한을 입력해주세요.' , 'warning' ); return ; }
formContent = getLp1Data ();
formBody = null ;
} else if ( isLeavePromotion2ndForm ) {
const lp2UserId = document . getElementById ( 'lp2-user-id' ) . value ;
if ( ! lp2UserId ) { showToast ( '대상 직원을 선택해주세요.' , 'warning' ); return ; }
const lp2Dates = getLp2Dates ();
if ( lp2Dates . length === 0 ) { showToast ( '지정 휴가일을 1건 이상 입력해주세요.' , 'warning' ); return ; }
formContent = getLp2Data ();
formBody = null ;
2026-03-06 22:25:00 +09:00
} else if ( isDelegationForm ) {
const dlAgentName = document . getElementById ( 'dl-agent-name' ) . value . trim ();
if ( ! dlAgentName ) {
showToast ( '수임인(대리인) 성명을 입력해주세요.' , 'warning' );
return ;
}
formContent = {
company_name : document . getElementById ( 'dl-company-name' ) . value ,
business_num : document . getElementById ( 'dl-business-num' ) . value ,
ceo_name : document . getElementById ( 'dl-ceo-name' ) . value ,
company_address : document . getElementById ( 'dl-company-address' ) . value ,
agent_name : dlAgentName ,
agent_birth : document . getElementById ( 'dl-agent-birth' ) . value ,
agent_address : document . getElementById ( 'dl-agent-address' ) . value . trim (),
agent_phone : document . getElementById ( 'dl-agent-phone' ) . value . trim (),
agent_department : document . getElementById ( 'dl-agent-department' ) . value . trim (),
delegation_detail : document . getElementById ( 'dl-delegation-detail' ) . value . trim (),
period_start : document . getElementById ( 'dl-period-start' ) . value ,
period_end : document . getElementById ( 'dl-period-end' ) . value ,
attachments_desc : document . getElementById ( 'dl-attachments-desc' ) . value . trim (),
};
formBody = null ;
2026-03-06 00:13:17 +09:00
} else if ( isResignationForm ) {
const reason = getResignationReason ();
if ( ! reason ) {
showToast ( '사직사유를 입력해주세요.' , 'warning' );
return ;
}
const resignDate = document . getElementById ( 'rg-resign-date' ) . value ;
if ( ! resignDate ) {
showToast ( '퇴사(예정)일을 입력해주세요.' , 'warning' );
return ;
}
formContent = {
cert_user_id : document . getElementById ( 'rg-user-id' ) . value ,
name : document . getElementById ( 'rg-name' ) . value ,
resident_number : document . getElementById ( 'rg-resident' ) . value ,
department : document . getElementById ( 'rg-department' ) . value ,
position : document . getElementById ( 'rg-position' ) . value ,
hire_date : document . getElementById ( 'rg-hire-date' ) . value ,
resign_date : resignDate ,
address : document . getElementById ( 'rg-address' ) . value ,
reason : reason ,
company_name : document . getElementById ( 'rg-company-name' ) . value ,
ceo_name : document . getElementById ( 'rg-ceo-name' ) . value ,
issue_date : document . getElementById ( 'rg-issue-date' ) . value ,
};
formBody = null ;
2026-03-04 15:14:18 +09:00
}
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
const payload = {
form_id : document . getElementById ( 'form_id' ) . value ,
title : title ,
2026-03-05 15:57:36 +09:00
body : formBody ,
content : formContent ,
2026-03-04 20:07:49 +09:00
attachment_file_ids : attachmentFileIds ,
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
is_urgent : document . getElementById ( 'is_urgent' ) . checked ,
steps : steps ,
};
try {
const response = await fetch ( '/api/admin/approvals' , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
'X-CSRF-TOKEN' : '{{ csrf_token() }}' ,
'Accept' : 'application/json' ,
},
body : JSON . stringify ( payload ),
});
const data = await response . json ();
if ( ! data . success && ! data . data ) {
showToast ( data . message || '저장에 실패했습니다.' , 'error' );
return ;
}
if ( action === 'submit' ) {
const submitResponse = await fetch ( `/api/admin/approvals/${data.data.id}/submit` , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
'X-CSRF-TOKEN' : '{{ csrf_token() }}' ,
'Accept' : 'application/json' ,
},
});
const submitData = await submitResponse . json ();
if ( submitData . success ) {
showToast ( '결재가 상신되었습니다.' , 'success' );
setTimeout (() => location . href = '/approval-mgmt/drafts' , 500 );
} else {
showToast ( submitData . message || '상신에 실패했습니다.' , 'error' );
}
} else {
showToast ( '임시저장되었습니다.' , 'success' );
setTimeout (() => location . href = `/approval-mgmt/${data.data.id}/edit` , 500 );
}
} catch ( e ) {
showToast ( '서버 오류가 발생했습니다.' , 'error' );
}
}
2026-03-05 10:26:55 +09:00
// =========================================================================
// 지출결의서 불러오기
// =========================================================================
const expenseTypeLabels = {
corporate_card : '법인카드' , transfer : '송금' , auto_transfer : '자동이체 출금' ,
cash_advance : '현금/가지급정산' ,
};
const statusColors = {
draft : 'bg-gray-100 text-gray-600' , pending : 'bg-blue-100 text-blue-600' ,
approved : 'bg-green-100 text-green-600' , rejected : 'bg-red-100 text-red-600' ,
cancelled : 'bg-yellow-100 text-yellow-600' ,
};
async function openExpenseLoadModal () {
const modal = document . getElementById ( 'expense-load-modal' );
const list = document . getElementById ( 'expense-load-list' );
modal . style . display = '' ;
document . body . style . overflow = 'hidden' ;
list . innerHTML = '<div class="text-center text-sm text-gray-400 py-8">불러오는 중...</div>' ;
try {
const res = await fetch ( '/api/admin/approvals/expense-history' , {
headers : { 'Accept' : 'application/json' },
});
const json = await res . json ();
if ( ! json . success || ! json . data . length ) {
list . innerHTML = '<div class="text-center text-sm text-gray-400 py-8">이전 지출결의서가 없습니다.</div>' ;
return ;
}
list . innerHTML = json . data . map ( item => {
const amount = parseInt ( item . total_amount || 0 ) . toLocaleString ( 'ko-KR' );
const typeLabel = expenseTypeLabels [ item . expense_type ] || item . expense_type ;
const color = statusColors [ item . status ] || statusColors . draft ;
return ` < div class = " flex items-center justify-between p-3 border border-gray-200 rounded-lg hover:bg-blue-50 cursor-pointer transition mb-2 " onclick = " loadExpenseData( ${ item.id } ) " >
< div class = " flex-1 min-w-0 " >
< div class = " flex items-center gap-2 " >
< span class = " text-sm font-medium text-gray-800 truncate " > $ { escapeHtml ( item . title )} </ span >
< span class = " shrink-0 px-1.5 py-0.5 rounded text-xs font-medium ${ color}">${escapeHtml(item.status_label) } </span>
</ div >
< div class = " flex items-center gap-3 mt-1 text-xs text-gray-500 " >
< span > $ { escapeHtml ( item . created_at )} </ span >
< span > $ { escapeHtml ( typeLabel )} </ span >
< span class = " font-medium text-gray-700 " > $ { amount } 원 </ span >
</ div >
</ div >
< svg class = " w-4 h-4 text-gray-400 shrink-0 ml-2 " fill = " none " stroke = " currentColor " viewBox = " 0 0 24 24 " >
< path stroke - linecap = " round " stroke - linejoin = " round " stroke - width = " 2 " d = " M9 5l7 7-7 7 " />
</ svg >
</ div > ` ;
}) . join ( '' );
} catch ( e ) {
list . innerHTML = '<div class="text-center text-sm text-red-400 py-8">불러오기 실패</div>' ;
}
}
function closeExpenseLoadModal () {
document . getElementById ( 'expense-load-modal' ) . style . display = 'none' ;
document . body . style . overflow = '' ;
}
async function loadExpenseData ( approvalId ) {
try {
const res = await fetch ( `/api/admin/approvals/${approvalId}` , {
headers : { 'Accept' : 'application/json' },
});
const json = await res . json ();
if ( ! json . success || ! json . data ? . content ) {
showToast ( '데이터를 불러올 수 없습니다.' , 'error' );
return ;
}
const content = json . data . content ;
const expenseEl = document . getElementById ( 'expense-form-container' );
if ( ! expenseEl || ! expenseEl . _x_dataStack ) return ;
const alpine = expenseEl . _x_dataStack [ 0 ];
const today = new Date () . toISOString () . slice ( 0 , 10 );
// 폼 데이터 복사 (날짜는 오늘로 초기화)
alpine . formData . expense_type = content . expense_type || 'corporate_card' ;
alpine . formData . tax_invoice = content . tax_invoice || 'normal' ;
alpine . formData . write_date = today ;
alpine . formData . approval_date = today ;
alpine . formData . department = content . department || '경리부' ;
alpine . formData . writer_name = content . writer_name || '' ;
alpine . formData . attachment_memo = content . attachment_memo || '' ;
alpine . formData . selected_card = content . selected_card || null ;
alpine . formData . selected_account = content . selected_account || null ;
// 내역 항목 복사 (날짜는 오늘로)
if ( content . items && content . items . length > 0 ) {
let keyCounter = alpine . formData . items . length + 100 ;
alpine . formData . items = content . items . map ( item => ({
_key : ++ keyCounter ,
date : today ,
description : item . description || '' ,
amount : parseInt ( item . amount ) || 0 ,
vendor : item . vendor || '' ,
bank : item . bank || '' ,
account_no : item . account_no || '' ,
depositor : item . depositor || '' ,
remark : item . remark || '' ,
}));
}
// 제목 설정
const titleEl = document . getElementById ( 'title' );
if ( ! titleEl . value . trim ()) {
titleEl . value = json . data . title || '지출결의서' ;
}
closeExpenseLoadModal ();
showToast ( '지출결의서를 불러왔습니다. 내용을 확인 후 수정해주세요.' , 'success' );
} catch ( e ) {
showToast ( '불러오기 실패' , 'error' );
}
}
2026-03-05 18:53:42 +09:00
// =========================================================================
// 재직증명서 관련 함수
// =========================================================================
async function loadCertInfo ( userId ) {
if ( ! userId ) return ;
try {
const resp = await fetch ( `/api/admin/approvals/cert-info/${userId}` , {
headers : { 'Accept' : 'application/json' , 'X-CSRF-TOKEN' : '{{ csrf_token() }}' }
});
const data = await resp . json ();
if ( data . success ) {
const d = data . data ;
document . getElementById ( 'cert-name' ) . value = d . name || '' ;
document . getElementById ( 'cert-resident' ) . value = d . resident_number || '' ;
document . getElementById ( 'cert-address' ) . value = d . address || '' ;
document . getElementById ( 'cert-company' ) . value = d . company_name || '' ;
document . getElementById ( 'cert-business-num' ) . value = d . business_num || '' ;
document . getElementById ( 'cert-department' ) . value = d . department || '' ;
document . getElementById ( 'cert-position' ) . value = d . position || '' ;
document . getElementById ( 'cert-hire-date' ) . value = d . hire_date ? d . hire_date + ' ~' : '' ;
2026-03-06 09:19:32 +09:00
document . getElementById ( 'cert-ceo-name' ) . value = d . ceo_name || '' ;
document . getElementById ( 'cert-company-address' ) . value = d . company_address || '' ;
2026-03-05 18:53:42 +09:00
} else {
showToast ( data . message || '사원 정보를 불러올 수 없습니다.' , 'error' );
}
} catch ( e ) {
showToast ( '사원 정보 조회 중 오류가 발생했습니다.' , 'error' );
}
}
function onCertPurposeChange () {
const sel = document . getElementById ( 'cert-purpose-select' );
const customWrap = document . getElementById ( 'cert-purpose-custom-wrap' );
if ( sel . value === '__custom__' ) {
customWrap . style . display = '' ;
document . getElementById ( 'cert-purpose-custom' ) . focus ();
} else {
customWrap . style . display = 'none' ;
}
}
function getCertPurpose () {
const sel = document . getElementById ( 'cert-purpose-select' );
if ( sel . value === '__custom__' ) {
return document . getElementById ( 'cert-purpose-custom' ) . value . trim ();
}
return sel . value ;
}
2026-03-05 19:13:54 +09:00
function openCertPreview () {
const name = document . getElementById ( 'cert-name' ) . value || '-' ;
const resident = document . getElementById ( 'cert-resident' ) . value || '-' ;
const address = document . getElementById ( 'cert-address' ) . value || '-' ;
const company = document . getElementById ( 'cert-company' ) . value || '-' ;
const businessNum = document . getElementById ( 'cert-business-num' ) . value || '-' ;
const department = document . getElementById ( 'cert-department' ) . value || '-' ;
const position = document . getElementById ( 'cert-position' ) . value || '-' ;
const hireDate = document . getElementById ( 'cert-hire-date' ) . value || '-' ;
const purpose = getCertPurpose () || '-' ;
const issueDate = document . getElementById ( 'cert-issue-date' ) . value || '-' ;
2026-03-06 09:19:32 +09:00
const ceoName = document . getElementById ( 'cert-ceo-name' ) . value || '-' ;
2026-03-05 19:13:54 +09:00
const issueDateFormatted = issueDate !== '-' ? issueDate . replace ( /-/ g , function ( m , i ) { return i === 4 ? '년 ' : '월 ' ; }) + '일' : '-' ;
const el = document . getElementById ( 'cert-preview-content' );
2026-03-06 09:19:32 +09:00
el . innerHTML = buildCertPreviewHtml ({ name , resident , address , company , businessNum , department , position , hireDate , purpose , issueDateFormatted , ceoName });
2026-03-05 19:13:54 +09:00
document . getElementById ( 'cert-preview-modal' ) . style . display = '' ;
document . body . style . overflow = 'hidden' ;
}
function closeCertPreview () {
document . getElementById ( 'cert-preview-modal' ) . style . display = 'none' ;
document . body . style . overflow = '' ;
}
2026-03-05 10:26:55 +09:00
2026-03-05 19:13:54 +09:00
function printCertPreview () {
const content = document . getElementById ( 'cert-preview-content' ) . innerHTML ;
const win = window . open ( '' , '_blank' , 'width=800,height=1000' );
win . document . write ( '<html><head><title>재직증명서</title>' );
2026-03-06 10:25:55 +09:00
win . document . write ( '<style>@page{size:A4;margin:0;} body{font-family:"Pretendard","Malgun Gothic",sans-serif;margin:0;padding:0;} .cert-page{padding:80px 56px 60px;box-sizing:border-box;} table{border-collapse:collapse;width:100%;} th,td{border:1px solid #333;padding:14px 16px;font-size:15px;} th{background:#f8f9fa;font-weight:600;text-align:left;white-space:nowrap;width:130px;}</style>' );
win . document . write ( '</head><body><div class="cert-page">' );
2026-03-05 19:13:54 +09:00
win . document . write ( content );
2026-03-06 10:25:55 +09:00
win . document . write ( '</div></body></html>' );
2026-03-05 19:13:54 +09:00
win . document . close ();
win . onload = function () { win . print (); };
}
2026-03-05 23:41:20 +09:00
// =========================================================================
// 경력증명서 관련 함수
// =========================================================================
async function loadCareerCertInfo ( userId ) {
if ( ! userId ) return ;
try {
const resp = await fetch ( `/api/admin/approvals/career-cert-info/${userId}` , {
headers : { 'Accept' : 'application/json' , 'X-CSRF-TOKEN' : '{{ csrf_token() }}' }
});
const data = await resp . json ();
if ( data . success ) {
const d = data . data ;
document . getElementById ( 'cc-name' ) . value = d . name || '' ;
2026-03-06 15:58:51 +09:00
document . getElementById ( 'cc-resident' ) . value = d . resident_number || '' ;
2026-03-05 23:41:20 +09:00
document . getElementById ( 'cc-birth-date' ) . value = d . birth_date || '' ;
document . getElementById ( 'cc-address' ) . value = d . address || '' ;
document . getElementById ( 'cc-company' ) . value = d . company_name || '' ;
document . getElementById ( 'cc-business-num' ) . value = d . business_num || '' ;
document . getElementById ( 'cc-ceo-name' ) . value = d . ceo_name || '' ;
document . getElementById ( 'cc-phone' ) . value = d . phone || '' ;
document . getElementById ( 'cc-company-address' ) . value = d . company_address || '' ;
document . getElementById ( 'cc-department' ) . value = d . department || '' ;
document . getElementById ( 'cc-position' ) . value = d . position || '' ;
document . getElementById ( 'cc-hire-date' ) . value = d . hire_date || '' ;
document . getElementById ( 'cc-resign-date' ) . value = d . resign_date || '' ;
document . getElementById ( 'cc-job-description' ) . value = d . job_description || '' ;
} else {
showToast ( data . message || '사원 정보를 불러올 수 없습니다.' , 'error' );
}
} catch ( e ) {
showToast ( '사원 정보 조회 중 오류가 발생했습니다.' , 'error' );
}
}
function onCareerCertPurposeChange () {
const sel = document . getElementById ( 'cc-purpose-select' );
const customWrap = document . getElementById ( 'cc-purpose-custom-wrap' );
if ( sel . value === '__custom__' ) {
customWrap . style . display = '' ;
document . getElementById ( 'cc-purpose-custom' ) . focus ();
} else {
customWrap . style . display = 'none' ;
}
}
function getCareerCertPurpose () {
const sel = document . getElementById ( 'cc-purpose-select' );
if ( sel . value === '__custom__' ) {
return document . getElementById ( 'cc-purpose-custom' ) . value . trim ();
}
return sel . value ;
}
function openCareerCertPreview () {
const name = document . getElementById ( 'cc-name' ) . value || '-' ;
2026-03-06 15:58:51 +09:00
const residentNumber = document . getElementById ( 'cc-resident' ) . value || '-' ;
2026-03-05 23:41:20 +09:00
const birthDate = document . getElementById ( 'cc-birth-date' ) . value || '-' ;
const address = document . getElementById ( 'cc-address' ) . value || '-' ;
const company = document . getElementById ( 'cc-company' ) . value || '-' ;
const businessNum = document . getElementById ( 'cc-business-num' ) . value || '-' ;
const ceoName = document . getElementById ( 'cc-ceo-name' ) . value || '-' ;
const phone = document . getElementById ( 'cc-phone' ) . value || '-' ;
const companyAddress = document . getElementById ( 'cc-company-address' ) . value || '-' ;
const department = document . getElementById ( 'cc-department' ) . value || '-' ;
const position = document . getElementById ( 'cc-position' ) . value || '-' ;
const hireDate = document . getElementById ( 'cc-hire-date' ) . value || '-' ;
const resignDate = document . getElementById ( 'cc-resign-date' ) . value || '현재' ;
const jobDescription = document . getElementById ( 'cc-job-description' ) . value || '-' ;
const purpose = getCareerCertPurpose () || '-' ;
const issueDate = document . getElementById ( 'cc-issue-date' ) . value || '-' ;
const issueDateFormatted = issueDate !== '-' ? issueDate . replace ( /-/ g , function ( m , i ) { return i === 4 ? '년 ' : '월 ' ; }) + '일' : '-' ;
const el = document . getElementById ( 'career-cert-preview-content' );
2026-03-06 15:58:51 +09:00
el . innerHTML = buildCareerCertPreviewHtml ({ name , residentNumber , birthDate , address , company , businessNum , ceoName , phone , companyAddress , department , position , hireDate , resignDate , jobDescription , purpose , issueDateFormatted });
2026-03-05 23:41:20 +09:00
document . getElementById ( 'career-cert-preview-modal' ) . style . display = '' ;
document . body . style . overflow = 'hidden' ;
}
function closeCareerCertPreview () {
document . getElementById ( 'career-cert-preview-modal' ) . style . display = 'none' ;
document . body . style . overflow = '' ;
}
function printCareerCertPreview () {
const content = document . getElementById ( 'career-cert-preview-content' ) . innerHTML ;
const win = window . open ( '' , '_blank' , 'width=800,height=1000' );
win . document . write ( '<html><head><title>경력증명서</title>' );
win . document . write ( '<style>body{font-family:"Pretendard","Malgun Gothic",sans-serif;padding:48px 56px;margin:0;} table{border-collapse:collapse;width:100%;} th,td{border:1px solid #333;padding:10px 14px;font-size:14px;} th{background:#f8f9fa;font-weight:600;text-align:left;white-space:nowrap;width:120px;} @media print{body{padding:40px 48px;}}</style>' );
win . document . write ( '</head><body>' );
win . document . write ( content );
win . document . write ( '</body></html>' );
win . document . close ();
win . onload = function () { win . print (); };
}
function buildCareerCertPreviewHtml ( d ) {
const e = ( s ) => { const div = document . createElement ( 'div' ); div . textContent = s ; return div . innerHTML ; };
const thStyle = 'border:1px solid #333; padding:10px 14px; background:#f8f9fa; font-weight:600; text-align:left; white-space:nowrap; width:120px; font-size:14px;' ;
const tdStyle = 'border:1px solid #333; padding:10px 14px; font-size:14px;' ;
return `
< h1 style = " text-align:center; font-size:28px; font-weight:700; letter-spacing:12px; margin-bottom:40px; " > 경 력 증 명 서 </ h1 >
< h3 style = " font-size:15px; font-weight:600; margin:24px 0 10px; color:#333; " > 1. 인적사항 </ h3 >
< table style = " border-collapse:collapse; width:100%; margin-bottom:20px; " >
< tr >
< th style = " ${ thStyle } " > 성 명 </ th >
< td style = " ${ tdStyle}">${e(d.name) } </td>
2026-03-06 15:58:51 +09:00
< th style = " ${ thStyle } " > 주민등록번호 </ th >
< td style = " ${ tdStyle}">${e(d.residentNumber) } </td>
</ tr >
< tr >
2026-03-05 23:41:20 +09:00
< th style = " ${ thStyle } " > 생년월일 </ th >
< td style = " ${ tdStyle}">${e(d.birthDate) } </td>
2026-03-06 15:58:51 +09:00
< th style = " ${ thStyle } " ></ th >
< td style = " ${ tdStyle } " ></ td >
2026-03-05 23:41:20 +09:00
</ tr >
< tr >
< th style = " ${ thStyle } " > 주 소 </ th >
< td style = " ${ tdStyle } " colspan = " 3 " > $ { e ( d . address )} </ td >
</ tr >
</ table >
< h3 style = " font-size:15px; font-weight:600; margin:24px 0 10px; color:#333; " > 2. 경력사항 </ h3 >
< table style = " border-collapse:collapse; width:100%; margin-bottom:20px; " >
< tr >
< th style = " ${ thStyle } " > 회 사 명 </ th >
< td style = " ${ tdStyle}">${e(d.company) } </td>
< th style = " ${ thStyle } " > 사업자번호 </ th >
< td style = " ${ tdStyle}">${e(d.businessNum) } </td>
</ tr >
< tr >
< th style = " ${ thStyle } " > 대 표 자 </ th >
< td style = " ${ tdStyle}">${e(d.ceoName) } </td>
< th style = " ${ thStyle } " > 대표전화 </ th >
< td style = " ${ tdStyle}">${e(d.phone) } </td>
</ tr >
< tr >
< th style = " ${ thStyle } " > 소 재 지 </ th >
< td style = " ${ tdStyle } " colspan = " 3 " > $ { e ( d . companyAddress )} </ td >
</ tr >
< tr >
< th style = " ${ thStyle } " > 소속부서 </ th >
< td style = " ${ tdStyle}">${e(d.department) } </td>
< th style = " ${ thStyle } " > 직위 / 직급 </ th >
< td style = " ${ tdStyle}">${e(d.position) } </td>
</ tr >
< tr >
< th style = " ${ thStyle } " > 근무기간 </ th >
< td style = " ${ tdStyle } " colspan = " 3 " > $ { e ( d . hireDate )} ~ $ { e ( d . resignDate )} </ td >
</ tr >
< tr >
< th style = " ${ thStyle } " > 담당업무 </ th >
< td style = " ${ tdStyle } " colspan = " 3 " > $ { e ( d . jobDescription )} </ td >
</ tr >
</ table >
< h3 style = " font-size:15px; font-weight:600; margin:24px 0 10px; color:#333; " > 3. 발급정보 </ h3 >
< table style = " border-collapse:collapse; width:100%; margin-bottom:32px; " >
< tr >
< th style = " ${ thStyle } " > 사용용도 </ th >
< td style = " ${ tdStyle } " colspan = " 3 " > $ { e ( d . purpose )} </ td >
</ tr >
</ table >
< p style = " text-align:center; font-size:15px; line-height:2; margin:36px 0; " >
2026-03-06 10:35:42 +09:00
$ { d . resignDate && d . resignDate !== '-' && d . resignDate !== '현재' ? '위 사람은 당사에 재직(근무) 하였음을 증명합니다.' : '위 사람은 당사에서 재직(근무) 하고 있음을 증명합니다.' }
2026-03-05 23:41:20 +09:00
</ p >
< p style = " text-align:center; font-size:15px; font-weight:500; margin-bottom:48px; " >
$ { e ( d . issueDateFormatted )}
</ p >
< div style = " text-align:center; margin-top:32px; " >
< p style = " font-size:16px; font-weight:600; margin-bottom:8px; " > $ { e ( d . company )} </ p >
< p style = " font-size:14px; color:#555; " > 대표이사 & nbsp ; & nbsp ; $ { e ( d . ceoName )} & nbsp ; & nbsp ; ( 인 ) </ p >
</ div >
` ;
}
2026-03-05 23:57:42 +09:00
// =========================================================================
// 위촉증명서 관련 함수
// =========================================================================
async function loadAppointmentCertInfo ( userId ) {
if ( ! userId ) return ;
try {
const resp = await fetch ( `/api/admin/approvals/appointment-cert-info/${userId}` , {
headers : { 'Accept' : 'application/json' , 'X-CSRF-TOKEN' : '{{ csrf_token() }}' }
});
const data = await resp . json ();
if ( data . success ) {
const d = data . data ;
document . getElementById ( 'ac-name' ) . value = d . name || '' ;
document . getElementById ( 'ac-resident' ) . value = d . resident_number || '' ;
document . getElementById ( 'ac-department' ) . value = d . department || '' ;
document . getElementById ( 'ac-phone' ) . value = d . phone || '' ;
document . getElementById ( 'ac-hire-date' ) . value = d . hire_date || '' ;
document . getElementById ( 'ac-resign-date' ) . value = d . resign_date || '' ;
document . getElementById ( 'ac-contract-type' ) . value = d . contract_type || '' ;
document . getElementById ( 'ac-company-name' ) . value = d . company_name || '' ;
document . getElementById ( 'ac-ceo-name' ) . value = d . ceo_name || '' ;
} else {
showToast ( data . message || '사원 정보를 불러올 수 없습니다.' , 'error' );
}
} catch ( e ) {
showToast ( '사원 정보 조회 중 오류가 발생했습니다.' , 'error' );
}
}
function onAppointmentCertPurposeChange () {
const sel = document . getElementById ( 'ac-purpose-select' );
const customWrap = document . getElementById ( 'ac-purpose-custom-wrap' );
if ( sel . value === '__custom__' ) {
customWrap . style . display = '' ;
document . getElementById ( 'ac-purpose-custom' ) . focus ();
} else {
customWrap . style . display = 'none' ;
}
}
function getAppointmentCertPurpose () {
const sel = document . getElementById ( 'ac-purpose-select' );
if ( sel . value === '__custom__' ) {
return document . getElementById ( 'ac-purpose-custom' ) . value . trim ();
}
return sel . value ;
}
function openAppointmentCertPreview () {
const name = document . getElementById ( 'ac-name' ) . value || '-' ;
const resident = document . getElementById ( 'ac-resident' ) . value || '-' ;
const department = document . getElementById ( 'ac-department' ) . value || '-' ;
const phone = document . getElementById ( 'ac-phone' ) . value || '-' ;
const hireDate = document . getElementById ( 'ac-hire-date' ) . value || '-' ;
const resignDate = document . getElementById ( 'ac-resign-date' ) . value || '현재' ;
const contractType = document . getElementById ( 'ac-contract-type' ) . value || '-' ;
const purpose = getAppointmentCertPurpose () || '-' ;
const issueDate = document . getElementById ( 'ac-issue-date' ) . value || '-' ;
const issueDateFormatted = issueDate !== '-' ? issueDate . replace ( /-/ g , function ( m , i ) { return i === 4 ? '년 ' : '월 ' ; }) + '일' : '-' ;
const company = document . getElementById ( 'ac-company-name' ) . value || '-' ;
const ceoName = document . getElementById ( 'ac-ceo-name' ) . value || '-' ;
const el = document . getElementById ( 'appointment-cert-preview-content' );
el . innerHTML = buildAppointmentCertPreviewHtml ({ name , resident , department , phone , hireDate , resignDate , contractType , purpose , issueDateFormatted , company , ceoName });
document . getElementById ( 'appointment-cert-preview-modal' ) . style . display = '' ;
document . body . style . overflow = 'hidden' ;
}
function closeAppointmentCertPreview () {
document . getElementById ( 'appointment-cert-preview-modal' ) . style . display = 'none' ;
document . body . style . overflow = '' ;
}
function printAppointmentCertPreview () {
const content = document . getElementById ( 'appointment-cert-preview-content' ) . innerHTML ;
const win = window . open ( '' , '_blank' , 'width=800,height=1000' );
win . document . write ( '<html><head><title>위촉증명서</title>' );
2026-03-06 10:56:07 +09:00
win . document . write ( '<style>@page{size:A4;margin:0;} body{font-family:"Pretendard","Malgun Gothic",sans-serif;margin:0;padding:0;} .cert-page{padding:100px 56px 60px;box-sizing:border-box;} table{border-collapse:collapse;width:100%;table-layout:fixed;} th,td{border:1px solid #333;padding:16px 14px;font-size:15px;} th{background:#f8f9fa;font-weight:600;text-align:left;white-space:nowrap;}</style>' );
2026-03-06 10:08:55 +09:00
win . document . write ( '</head><body><div class="cert-page">' );
2026-03-05 23:57:42 +09:00
win . document . write ( content );
2026-03-06 10:08:55 +09:00
win . document . write ( '</div></body></html>' );
2026-03-05 23:57:42 +09:00
win . document . close ();
win . onload = function () { win . print (); };
}
function buildAppointmentCertPreviewHtml ( d ) {
const e = ( s ) => { const div = document . createElement ( 'div' ); div . textContent = s ; return div . innerHTML ; };
2026-03-06 10:56:07 +09:00
const thStyle = 'border:1px solid #333; padding:16px 14px; background:#f8f9fa; font-weight:600; text-align:left; white-space:nowrap; font-size:15px;' ;
const tdStyle = 'border:1px solid #333; padding:16px 14px; font-size:15px; white-space:nowrap;' ;
2026-03-05 23:57:42 +09:00
return `
2026-03-06 10:19:04 +09:00
< h1 style = " text-align:center; font-size:30px; font-weight:700; letter-spacing:14px; margin-bottom:60px; " > 위 촉 증 명 서 </ h1 >
2026-03-05 23:57:42 +09:00
2026-03-06 10:56:07 +09:00
< table style = " border-collapse:collapse; width:100%; margin-bottom:60px; table-layout:fixed; " >
< colgroup >< col style = " width:18% " >< col style = " width:32% " >< col style = " width:18% " >< col style = " width:32% " ></ colgroup >
2026-03-05 23:57:42 +09:00
< tr >
< th style = " ${ thStyle } " > 성 명 </ th >
< td style = " ${ tdStyle } " colspan = " 3 " > $ { e ( d . name )} </ td >
</ tr >
< tr >
< th style = " ${ thStyle } " > 주민등록번호 </ th >
< td style = " ${ tdStyle } " colspan = " 3 " > $ { e ( d . resident )} </ td >
</ tr >
< tr >
< th style = " ${ thStyle } " > 소 속 </ th >
< td style = " ${ tdStyle}">${e(d.department) } </td>
< th style = " ${ thStyle } " > 연 락 처 </ th >
< td style = " ${ tdStyle}">${e(d.phone) } </td>
</ tr >
< tr >
2026-03-06 10:56:07 +09:00
< th style = " ${ thStyle } " > 위촉기간 </ th >
2026-03-05 23:57:42 +09:00
< td style = " ${ tdStyle}">${e(d.hireDate) } ~ ${ e(d.resignDate) } </td>
< th style = " ${ thStyle } " > 계약자격 </ th >
< td style = " ${ tdStyle}">${e(d.contractType) } </td>
</ tr >
< tr >
< th style = " ${ thStyle } " > 용 도 </ th >
< td style = " ${ tdStyle } " colspan = " 3 " > $ { e ( d . purpose )} </ td >
</ tr >
</ table >
2026-03-06 10:19:04 +09:00
< p style = " text-align:center; font-size:16px; line-height:2; margin:80px 0; " >
2026-03-05 23:57:42 +09:00
위와 같이 위촉하였음을 증명합니다 .
</ p >
2026-03-06 10:19:04 +09:00
< p style = " text-align:center; font-size:16px; font-weight:500; margin-bottom:80px; " >
2026-03-05 23:57:42 +09:00
$ { e ( d . issueDateFormatted )}
</ p >
2026-03-06 10:08:55 +09:00
< div style = " text-align:center; margin-top:60px; " >
2026-03-06 10:19:04 +09:00
< p style = " font-size:18px; font-weight:600; margin-bottom:10px; " > $ { e ( d . company )} </ p >
< p style = " font-size:15px; color:#555; " > 대표이사 & nbsp ; & nbsp ; $ { e ( d . ceoName )} & nbsp ; & nbsp ; ( 인 ) </ p >
2026-03-05 23:57:42 +09:00
</ div >
` ;
}
2026-03-06 00:13:17 +09:00
// =========================================================================
// 사직서 관련 함수
// =========================================================================
async function loadResignationInfo ( userId ) {
if ( ! userId ) return ;
try {
const resp = await fetch ( `/api/admin/approvals/resignation-info/${userId}` , {
headers : { 'Accept' : 'application/json' , 'X-CSRF-TOKEN' : '{{ csrf_token() }}' }
});
const data = await resp . json ();
if ( data . success ) {
const d = data . data ;
document . getElementById ( 'rg-name' ) . value = d . name || '' ;
document . getElementById ( 'rg-resident' ) . value = d . resident_number || '' ;
document . getElementById ( 'rg-department' ) . value = d . department || '' ;
document . getElementById ( 'rg-position' ) . value = d . position || '' ;
document . getElementById ( 'rg-hire-date' ) . value = d . hire_date || '' ;
document . getElementById ( 'rg-address' ) . value = d . address || '' ;
document . getElementById ( 'rg-company-name' ) . value = d . company_name || '' ;
document . getElementById ( 'rg-ceo-name' ) . value = d . ceo_name || '' ;
} else {
showToast ( data . message || '사원 정보를 불러올 수 없습니다.' , 'error' );
}
} catch ( e ) {
showToast ( '사원 정보 조회 중 오류가 발생했습니다.' , 'error' );
}
}
function onResignationReasonChange () {
const sel = document . getElementById ( 'rg-reason-select' );
const customWrap = document . getElementById ( 'rg-reason-custom-wrap' );
if ( sel . value === '__custom__' ) {
customWrap . style . display = '' ;
document . getElementById ( 'rg-reason-custom' ) . focus ();
} else {
customWrap . style . display = 'none' ;
}
}
function getResignationReason () {
const sel = document . getElementById ( 'rg-reason-select' );
if ( sel . value === '__custom__' ) {
return document . getElementById ( 'rg-reason-custom' ) . value . trim ();
}
return sel . value ;
}
function openResignationPreview () {
const department = document . getElementById ( 'rg-department' ) . value || '-' ;
const position = document . getElementById ( 'rg-position' ) . value || '-' ;
const name = document . getElementById ( 'rg-name' ) . value || '-' ;
const resident = document . getElementById ( 'rg-resident' ) . value || '-' ;
const hireDate = document . getElementById ( 'rg-hire-date' ) . value || '-' ;
const resignDate = document . getElementById ( 'rg-resign-date' ) . value || '-' ;
const address = document . getElementById ( 'rg-address' ) . value || '-' ;
const reason = getResignationReason () || '-' ;
const issueDate = document . getElementById ( 'rg-issue-date' ) . value || '-' ;
const issueDateFormatted = issueDate !== '-' ? issueDate . replace ( /-/ g , function ( m , i ) { return i === 4 ? '년 ' : '월 ' ; }) + '일' : '-' ;
const company = document . getElementById ( 'rg-company-name' ) . value || '-' ;
const ceoName = document . getElementById ( 'rg-ceo-name' ) . value || '-' ;
const el = document . getElementById ( 'resignation-preview-content' );
el . innerHTML = buildResignationPreviewHtml ({ department , position , name , resident , hireDate , resignDate , address , reason , issueDateFormatted , company , ceoName });
document . getElementById ( 'resignation-preview-modal' ) . style . display = '' ;
document . body . style . overflow = 'hidden' ;
}
function closeResignationPreview () {
document . getElementById ( 'resignation-preview-modal' ) . style . display = 'none' ;
document . body . style . overflow = '' ;
}
function printResignationPreview () {
const content = document . getElementById ( 'resignation-preview-content' ) . innerHTML ;
const win = window . open ( '' , '_blank' , 'width=800,height=1000' );
win . document . write ( '<html><head><title>사직서</title>' );
2026-03-06 10:40:30 +09:00
win . document . write ( '<style>@page{size:A4;margin:0;} body{font-family:"Pretendard","Malgun Gothic",sans-serif;margin:0;padding:0;} .cert-page{padding:100px 56px 60px;box-sizing:border-box;} table{border-collapse:collapse;width:100%;} th,td{border:1px solid #333;padding:16px 18px;font-size:16px;} th{background:#f8f9fa;font-weight:600;text-align:left;white-space:nowrap;width:140px;}</style>' );
win . document . write ( '</head><body><div class="cert-page">' );
2026-03-06 00:13:17 +09:00
win . document . write ( content );
2026-03-06 10:40:30 +09:00
win . document . write ( '</div></body></html>' );
2026-03-06 00:13:17 +09:00
win . document . close ();
win . onload = function () { win . print (); };
}
function buildResignationPreviewHtml ( d ) {
const e = ( s ) => { const div = document . createElement ( 'div' ); div . textContent = s ; return div . innerHTML ; };
2026-03-06 10:40:30 +09:00
const thStyle = 'border:1px solid #333; padding:16px 18px; background:#f8f9fa; font-weight:600; text-align:left; white-space:nowrap; width:140px; font-size:16px;' ;
const tdStyle = 'border:1px solid #333; padding:16px 18px; font-size:16px;' ;
2026-03-06 00:13:17 +09:00
return `
2026-03-06 10:40:30 +09:00
< h1 style = " text-align:center; font-size:30px; font-weight:700; letter-spacing:14px; margin-bottom:60px; " > 사 직 서 </ h1 >
2026-03-06 00:13:17 +09:00
2026-03-06 10:40:30 +09:00
< table style = " border-collapse:collapse; width:100%; margin-bottom:60px; " >
2026-03-06 00:13:17 +09:00
< tr >
< th style = " ${ thStyle } " > 소 속 </ th >
< td style = " ${ tdStyle}">${e(d.department) } </td>
< th style = " ${ thStyle } " > 직 위 </ th >
< td style = " ${ tdStyle}">${e(d.position) } </td>
</ tr >
< tr >
< th style = " ${ thStyle } " > 성 명 </ th >
< td style = " ${ tdStyle}">${e(d.name) } </td>
< th style = " ${ thStyle } " > 주민등록번호 </ th >
< td style = " ${ tdStyle}">${e(d.resident) } </td>
</ tr >
< tr >
< th style = " ${ thStyle } " > 입사일 </ th >
< td style = " ${ tdStyle}">${e(d.hireDate) } </td>
< th style = " ${ thStyle } " > 퇴사 ( 예정 ) 일 </ th >
< td style = " ${ tdStyle}">${e(d.resignDate) } </td>
</ tr >
< tr >
< th style = " ${ thStyle } " > 주 소 </ th >
< td style = " ${ tdStyle } " colspan = " 3 " > $ { e ( d . address )} </ td >
</ tr >
< tr >
< th style = " ${ thStyle } " > 사 유 </ th >
< td style = " ${ tdStyle } " colspan = " 3 " > $ { e ( d . reason )} </ td >
</ tr >
</ table >
2026-03-06 10:40:30 +09:00
< p style = " text-align:center; font-size:16px; line-height:2; margin:80px 0; " >
2026-03-06 00:13:17 +09:00
상기 본인은 위 사유로 인하여 사직하고자 < br >
이에 사직서를 제출하오니 허가하여 주시기 바랍니다 .
</ p >
2026-03-06 10:40:30 +09:00
< p style = " text-align:center; font-size:16px; font-weight:500; margin-bottom:40px; " >
2026-03-06 00:13:17 +09:00
$ { e ( d . issueDateFormatted )}
</ p >
2026-03-06 10:40:30 +09:00
< p style = " text-align:center; font-size:15px; margin-bottom:80px; " >
2026-03-06 00:13:17 +09:00
신청인 & nbsp ; & nbsp ; $ { e ( d . name )} & nbsp ; & nbsp ; ( 인 )
</ p >
2026-03-06 10:40:30 +09:00
< div style = " text-align:center; margin-top:60px; " >
< p style = " font-size:18px; font-weight:600; " > $ { e ( d . company )} & nbsp ; & nbsp ; 대표이사 귀하 </ p >
2026-03-06 00:13:17 +09:00
</ div >
` ;
}
2026-03-05 19:13:54 +09:00
function buildCertPreviewHtml ( d ) {
const e = ( s ) => { const div = document . createElement ( 'div' ); div . textContent = s ; return div . innerHTML ; };
2026-03-06 10:25:55 +09:00
const thStyle = 'border:1px solid #333; padding:14px 16px; background:#f8f9fa; font-weight:600; text-align:left; white-space:nowrap; width:130px; font-size:15px;' ;
const tdStyle = 'border:1px solid #333; padding:14px 16px; font-size:15px;' ;
2026-03-05 19:13:54 +09:00
return `
2026-03-06 10:25:55 +09:00
< h1 style = " text-align:center; font-size:30px; font-weight:700; letter-spacing:14px; margin-bottom:50px; " > 재 직 증 명 서 </ h1 >
2026-03-05 19:13:54 +09:00
2026-03-06 10:25:55 +09:00
< h3 style = " font-size:16px; font-weight:600; margin:30px 0 12px; color:#333; " > 1. 인적사항 </ h3 >
< table style = " border-collapse:collapse; width:100%; margin-bottom:24px; " >
2026-03-05 19:13:54 +09:00
< tr >
< th style = " ${ thStyle } " > 성 명 </ th >
< td style = " ${ tdStyle}">${e(d.name) } </td>
< th style = " ${ thStyle } " > 주민등록번호 </ th >
< td style = " ${ tdStyle}">${e(d.resident) } </td>
</ tr >
< tr >
< th style = " ${ thStyle } " > 주 소 </ th >
< td style = " ${ tdStyle } " colspan = " 3 " > $ { e ( d . address )} </ td >
</ tr >
</ table >
2026-03-06 10:25:55 +09:00
< h3 style = " font-size:16px; font-weight:600; margin:30px 0 12px; color:#333; " > 2. 재직사항 </ h3 >
< table style = " border-collapse:collapse; width:100%; margin-bottom:24px; " >
2026-03-05 19:13:54 +09:00
< tr >
< th style = " ${ thStyle } " > 회 사 명 </ th >
< td style = " ${ tdStyle } " colspan = " 3 " > $ { e ( d . company )} </ td >
</ tr >
< tr >
< th style = " ${ thStyle } " > 사업자번호 </ th >
< td style = " ${ tdStyle } " colspan = " 3 " > $ { e ( d . businessNum )} </ td >
</ tr >
< tr >
< th style = " ${ thStyle } " > 근무부서 </ th >
< td style = " ${ tdStyle}">${e(d.department) } </td>
< th style = " ${ thStyle } " > 직 급 </ th >
< td style = " ${ tdStyle}">${e(d.position) } </td>
</ tr >
< tr >
< th style = " ${ thStyle } " > 재직기간 </ th >
< td style = " ${ tdStyle } " colspan = " 3 " > $ { e ( d . hireDate )} </ td >
</ tr >
</ table >
2026-03-06 10:25:55 +09:00
< h3 style = " font-size:16px; font-weight:600; margin:30px 0 12px; color:#333; " > 3. 발급정보 </ h3 >
< table style = " border-collapse:collapse; width:100%; margin-bottom:40px; " >
2026-03-05 19:13:54 +09:00
< tr >
< th style = " ${ thStyle } " > 사용용도 </ th >
< td style = " ${ tdStyle } " colspan = " 3 " > $ { e ( d . purpose )} </ td >
</ tr >
</ table >
2026-03-06 10:25:55 +09:00
< p style = " text-align:center; font-size:16px; line-height:2; margin:50px 0; " >
2026-03-05 19:13:54 +09:00
위 사항을 증명합니다 .
</ p >
2026-03-06 10:25:55 +09:00
< p style = " text-align:center; font-size:16px; font-weight:500; margin-bottom:60px; " >
2026-03-05 19:13:54 +09:00
$ { e ( d . issueDateFormatted )}
</ p >
2026-03-06 10:25:55 +09:00
< div style = " text-align:center; margin-top:40px; " >
< p style = " font-size:18px; font-weight:600; margin-bottom:8px; " > $ { e ( d . company )} </ p >
< p style = " font-size:15px; color:#555; " > 대표이사 & nbsp ; & nbsp ; $ { e ( d . ceoName )} & nbsp ; & nbsp ; ( 인 ) </ p >
2026-03-05 19:13:54 +09:00
</ div >
` ;
}
2026-03-06 20:48:01 +09:00
// =========================================================================
// 사용인감계 관련 함수
// =========================================================================
function buildSealUsagePreviewHtml ( data ) {
const e = ( s ) => s ? String ( s ) . replace ( /&/ g , '&' ) . replace ( /</ g , '<' ) . replace ( />/ g , '>' ) : '-' ;
2026-03-06 21:09:27 +09:00
const dateObj = data . usage_date ? new Date ( data . usage_date + 'T00:00:00' ) : new Date ();
const y = dateObj . getFullYear ();
const m = String ( dateObj . getMonth () + 1 ) . padStart ( 2 , ' ' );
const d = String ( dateObj . getDate ()) . padStart ( 2 , ' ' );
2026-03-06 20:48:01 +09:00
return `
2026-03-06 21:09:27 +09:00
< div style = " text-align:center; margin-bottom:40px; " >
< h1 style = " font-size:26px; font-weight:700; letter-spacing:14px; margin:0; " > 사 용 인 감 계 </ h1 >
2026-03-06 20:48:01 +09:00
</ div >
2026-03-06 21:09:27 +09:00
<!-- 인감 비교란 -->
< table style = " width:360px; margin:0 auto 32px; border-collapse:collapse; " >
2026-03-06 20:48:01 +09:00
< tr >
2026-03-06 21:09:27 +09:00
< td style = " border:1px solid #333; padding:8px; text-align:center; font-weight:600; font-size:13px; width:180px; background:#f8f8f8; " > 법인인감 </ td >
< td style = " border:1px solid #333; padding:8px; text-align:center; font-weight:600; font-size:13px; width:180px; background:#f8f8f8; " > 사용인감 </ td >
2026-03-06 20:48:01 +09:00
</ tr >
< tr >
2026-03-06 21:09:27 +09:00
< td style = " border:1px solid #333; height:140px; text-align:center; vertical-align:middle; " >
< span style = " color:#bbb; font-size:11px; " > ( 인감 날인 ) </ span >
</ td >
< td style = " border:1px solid #333; height:140px; text-align:center; vertical-align:middle; " >
< span style = " color:#bbb; font-size:11px; " > ( 인감 날인 ) </ span >
</ td >
2026-03-06 20:48:01 +09:00
</ tr >
</ table >
2026-03-06 21:09:27 +09:00
<!-- 용도 / 제출처 -->
< div style = " font-size:13px; line-height:2; margin-bottom:8px; " >
< p style = " margin:0; " >< span style = " font-weight:600; " > 용도 :</ span > $ { e ( data . purpose )} </ p >
< p style = " margin:0; " >< span style = " font-weight:600; " > 제출처 :</ span > $ { e ( data . submit_to )} </ p >
</ div >
<!-- 확약 문구 -->
< div style = " margin:28px 0; padding:16px 20px; border:1px solid #ccc; border-radius:4px; background:#fafafa; " >
< p style = " font-size:12px; line-height:1.8; margin:0; color:#333; " >
위 사용인감은 당사에서 사용하는 인감입니다 . 당사는 위 인감사용으로 인한 모든 책임을 질 것을 확약하고 사용인감계를 제출합니다 .
</ p >
</ div >
<!-- 첨부서류 -->
$ { data . attachment_desc ? `<p style="font-size:12px; margin:0 0 24px; color:#333;"><span style="font-weight:600;">첨부서류:</span> ${e(data.attachment_desc)}</p>` : '' }
<!-- 일자 -->
< p style = " text-align:center; font-size:14px; margin:32px 0 28px; letter-spacing:2px; " > $ { y } 년 $ { m } 월 $ { d } 일 </ p >
2026-03-06 20:48:01 +09:00
2026-03-06 21:09:27 +09:00
<!-- 회사 정보 -->
< div style = " margin-top:20px; font-size:12px; line-height:2; " >
< p style = " margin:0; " >< span style = " display:inline-block; width:110px; font-weight:600; letter-spacing:6px; " > 상 호 </ span >: $ { e ( data . company_name )} </ p >
< p style = " margin:0; " >< span style = " display:inline-block; width:110px; font-weight:600; " > 사업자등록번호 </ span >: $ { e ( data . business_num )} </ p >
< p style = " margin:0; " >< span style = " display:inline-block; width:110px; font-weight:600; letter-spacing:6px; " > 주 소 </ span >: $ { e ( data . company_address )} </ p >
< p style = " margin:0; " >< span style = " display:inline-block; width:110px; font-weight:600; " > 대표이사 </ span >: $ { e ( data . ceo_name )} </ p >
2026-03-06 20:48:01 +09:00
</ div >
` ;
}
function openSealUsagePreview () {
const data = {
usage_date : document . getElementById ( 'su-usage-date' ) . value ,
purpose : document . getElementById ( 'su-purpose' ) . value ,
submit_to : document . getElementById ( 'su-submit-to' ) . value ,
2026-03-06 21:09:27 +09:00
attachment_desc : document . getElementById ( 'su-attachment-desc' ) . value ,
2026-03-06 20:48:01 +09:00
company_name : document . getElementById ( 'su-company-name' ) . value ,
business_num : document . getElementById ( 'su-business-num' ) . value ,
ceo_name : document . getElementById ( 'su-ceo-name' ) . value ,
company_address : document . getElementById ( 'su-company-address' ) . value ,
};
document . getElementById ( 'seal-usage-preview-content' ) . innerHTML = buildSealUsagePreviewHtml ( data );
document . getElementById ( 'seal-usage-preview-modal' ) . style . display = '' ;
document . body . style . overflow = 'hidden' ;
}
function closeSealUsagePreview () {
document . getElementById ( 'seal-usage-preview-modal' ) . style . display = 'none' ;
document . body . style . overflow = '' ;
}
function printSealUsagePreview () {
const content = document . getElementById ( 'seal-usage-preview-content' ) . innerHTML ;
const win = window . open ( '' , '_blank' );
win . document . write ( '<html><head><title>사용인감계</title>' );
win . document . write ( '<style>body{font-family:"Pretendard","Malgun Gothic",sans-serif;padding:48px 56px;margin:0;}@media print{body{padding:40px 48px;}}</style>' );
win . document . write ( '</head><body>' );
win . document . write ( content );
win . document . write ( '</body></html>' );
win . document . close ();
win . print ();
}
2026-03-06 22:25:00 +09:00
// ── 위임장 미리보기 ──
function buildDelegationPreviewHtml ( data ) {
return `
< div style = " text-align: center; margin-bottom: 36px; " >
< h1 style = " font-size: 28px; font-weight: 800; letter-spacing: 8px; " > 위 임 장 </ h1 >
</ div >
< table style = " width: 100%; border-collapse: collapse; font-size: 13px; margin-bottom: 24px; " >
< tr >
< td style = " border: 1px solid #333; background: #f5f5f5; padding: 8px 12px; font-weight: 600; width: 100px; text-align: center; " rowspan = " 4 " > 위임인 </ td >
< td style = " border: 1px solid #333; background: #f5f5f5; padding: 8px 12px; font-weight: 600; width: 100px; text-align: center; " > 법인명 </ td >
< td style = " border: 1px solid #333; padding: 8px 12px; " colspan = " 3 " > $ { data . company_name || '' } </ td >
</ tr >
< tr >
< td style = " border: 1px solid #333; background: #f5f5f5; padding: 8px 12px; font-weight: 600; text-align: center; " > 사업자등록번호 </ td >
< td style = " border: 1px solid #333; padding: 8px 12px; " colspan = " 3 " > $ { data . business_num || '' } </ td >
</ tr >
< tr >
< td style = " border: 1px solid #333; background: #f5f5f5; padding: 8px 12px; font-weight: 600; text-align: center; " > 주소 </ td >
< td style = " border: 1px solid #333; padding: 8px 12px; " colspan = " 3 " > $ { data . company_address || '' } </ td >
</ tr >
< tr >
< td style = " border: 1px solid #333; background: #f5f5f5; padding: 8px 12px; font-weight: 600; text-align: center; " > 대표자 </ td >
< td style = " border: 1px solid #333; padding: 8px 12px; " colspan = " 3 " > $ { data . ceo_name || '' } </ td >
</ tr >
< tr >
< td style = " border: 1px solid #333; background: #f5f5f5; padding: 8px 12px; font-weight: 600; text-align: center; " rowspan = " 5 " > 수임인 </ td >
< td style = " border: 1px solid #333; background: #f5f5f5; padding: 8px 12px; font-weight: 600; text-align: center; " > 성명 </ td >
< td style = " border: 1px solid #333; padding: 8px 12px; " colspan = " 3 " > $ { data . agent_name || '' } </ td >
</ tr >
< tr >
< td style = " border: 1px solid #333; background: #f5f5f5; padding: 8px 12px; font-weight: 600; text-align: center; " > 생년월일 </ td >
< td style = " border: 1px solid #333; padding: 8px 12px; " colspan = " 3 " > $ { data . agent_birth || '' } </ td >
</ tr >
< tr >
< td style = " border: 1px solid #333; background: #f5f5f5; padding: 8px 12px; font-weight: 600; text-align: center; " > 주소 </ td >
< td style = " border: 1px solid #333; padding: 8px 12px; " colspan = " 3 " > $ { data . agent_address || '' } </ td >
</ tr >
< tr >
< td style = " border: 1px solid #333; background: #f5f5f5; padding: 8px 12px; font-weight: 600; text-align: center; " > 연락처 </ td >
< td style = " border: 1px solid #333; padding: 8px 12px; " colspan = " 3 " > $ { data . agent_phone || '' } </ td >
</ tr >
< tr >
< td style = " border: 1px solid #333; background: #f5f5f5; padding: 8px 12px; font-weight: 600; text-align: center; " > 소속 </ td >
< td style = " border: 1px solid #333; padding: 8px 12px; " colspan = " 3 " > $ { data . agent_department || '' } </ td >
</ tr >
</ table >
< div style = " margin-bottom: 20px; " >
< div style = " font-weight: 600; font-size: 14px; margin-bottom: 8px; border-bottom: 2px solid #333; padding-bottom: 4px; " > 위임사항 </ div >
< div style = " font-size: 13px; line-height: 1.8; white-space: pre-wrap; padding: 8px 4px; " > $ { data . delegation_detail || '' } </ div >
</ div >
< div style = " margin-bottom: 20px; " >
< div style = " font-weight: 600; font-size: 14px; margin-bottom: 8px; border-bottom: 2px solid #333; padding-bottom: 4px; " > 위임기간 </ div >
< div style = " font-size: 13px; padding: 8px 4px; " > $ { data . period_start || '' } ~ $ { data . period_end || '' } </ div >
</ div >
$ { data . attachments_desc ? `
< div style = " margin-bottom: 20px; " >
< div style = " font-weight: 600; font-size: 14px; margin-bottom: 8px; border-bottom: 2px solid #333; padding-bottom: 4px; " > 첨부서류 </ div >
< div style = " font-size: 13px; line-height: 1.8; white-space: pre-wrap; padding: 8px 4px; " > $ { data . attachments_desc } </ div >
</ div > ` : '' }
< div style = " text-align: center; margin-top: 40px; font-size: 13px; " >
< p > 위와 같이 권한을 위임합니다 .</ p >
< p style = " margin-top: 24px; font-size: 14px; font-weight: 600; " > $ { data . company_name || '' } </ p >
< p style = " margin-top: 4px; " > 대표이사 $ { data . ceo_name || '' } ( 인 ) </ p >
</ div >
` ;
}
function openDelegationPreview () {
const data = {
company_name : document . getElementById ( 'dl-company-name' ) . value ,
business_num : document . getElementById ( 'dl-business-num' ) . value ,
ceo_name : document . getElementById ( 'dl-ceo-name' ) . value ,
company_address : document . getElementById ( 'dl-company-address' ) . value ,
agent_name : document . getElementById ( 'dl-agent-name' ) . value ,
agent_birth : document . getElementById ( 'dl-agent-birth' ) . value ,
agent_address : document . getElementById ( 'dl-agent-address' ) . value ,
agent_phone : document . getElementById ( 'dl-agent-phone' ) . value ,
agent_department : document . getElementById ( 'dl-agent-department' ) . value ,
delegation_detail : document . getElementById ( 'dl-delegation-detail' ) . value ,
period_start : document . getElementById ( 'dl-period-start' ) . value ,
period_end : document . getElementById ( 'dl-period-end' ) . value ,
attachments_desc : document . getElementById ( 'dl-attachments-desc' ) . value ,
};
document . getElementById ( 'delegation-preview-content' ) . innerHTML = buildDelegationPreviewHtml ( data );
document . getElementById ( 'delegation-preview-modal' ) . style . display = '' ;
document . body . style . overflow = 'hidden' ;
}
function closeDelegationPreview () {
document . getElementById ( 'delegation-preview-modal' ) . style . display = 'none' ;
document . body . style . overflow = '' ;
}
2026-03-06 23:00:22 +09:00
// ── 이사회의사록 미리보기 ──
function buildBoardMinutesPreviewHtml ( data ) {
const dt = data . meeting_datetime || '' ;
let dateStr = '' ;
if ( dt ) {
const d = new Date ( dt );
const year = d . getFullYear ();
const month = d . getMonth () + 1 ;
const day = d . getDate ();
const hour = d . getHours ();
const min = String ( d . getMinutes ()) . padStart ( 2 , '0' );
const ampm = hour < 12 ? '오전' : '오후' ;
const h12 = hour % 12 || 12 ;
dateStr = `${year}년 ${month}월 ${day}일 ${ampm} ${h12}시 ${min}분` ;
}
const agendas = data . agendas || [];
let agendasHtml = agendas . map ( a =>
` < div style = " margin-bottom: 12px; " >
< div style = " font-weight: 600; margin-bottom: 4px; " > [ 제 $ { a . no } 호 의안 : $ { a . title }] </ div >
$ { a . result ? `<div style="padding-left: 16px;">* ${a.result}</div>` : '' }
</ div > `
) . join ( '' );
const signers = data . signers || [];
let signersHtml = signers . map ( s =>
` < div style = " display: flex; gap: 40px; margin-bottom: 8px; justify-content: center; " >
< span style = " min-width: 120px; text-align: right; " > $ { s . role } :</ span >
< span style = " min-width: 100px; " > $ { s . name } </ span >
< span > ( 인 ) & #12958;</span>
</ div > `
) . join ( '' );
return `
< div style = " text-align: center; margin-bottom: 36px; " >
< h1 style = " font-size: 26px; font-weight: 800; letter-spacing: 6px; " > 이 사 회 의 사 록 </ h1 >
</ div >
< div style = " font-size: 13px; line-height: 2; " >
< p >< strong > 1. 일 시 :</ strong > $ { dateStr } </ p >
< p >< strong > 2. 장 소 :</ strong > $ { data . meeting_place || '' } </ p >
< p >< strong > 3. 출석이사 및 감사 :</ strong ></ p >
< div style = " padding-left: 24px; " >
< p >* 이사 총수 : $ { data . total_directors || 0 } 명 ( 출석이사 : $ { data . present_directors || 0 } 명 ) </ p >
< p >* 감사 총수 : $ { data . total_auditors || 0 } 명 ( 출석감사 : $ { data . present_auditors || 0 } 명 ) </ p >
</ div >
< p style = " margin-top: 8px; " >< strong > 4. 의 안 :</ strong ></ p >
< div style = " padding-left: 24px; margin-bottom: 8px; " >
$ { agendas . map ( a => `<p>${a.title}</p>` ) . join ( '' )}
</ div >
< p >< strong > 5. 의사 경과 및 결과 :</ strong ></ p >
< div style = " padding-left: 24px; margin-bottom: 8px; " >
$ { data . proceedings ? `<p>${data.proceedings}</p>` : `<p>의장(대표이사 ${data.chairman_name || ''})은 위와 같이 법정 수에 달하는 이사가 출석하였으므로 본 이사회가 적법하게 성립되었음을 선언하고, 다음의 의안을 상정하여 승인을 구하다.</p>` }
$ { agendasHtml }
</ div >
< p >< strong > 6. 폐 회 :</ strong ></ p >
< div style = " padding-left: 24px; " >
< p > 의장은 이상으로써 의안 전부의 심의를 종료하였으므로 폐회를 선언하다 .</ p >
$ { data . closing_time ? `<p>(폐회 시각: ${data.closing_time})</p>` : '' }
</ div >
</ div >
< div style = " margin-top: 32px; font-size: 13px; line-height: 1.8; text-align: center; " >
< p > 위 의사의 경과와 결과를 명확히 하기 위하여 이 의사록을 작성하고 , </ p >
< p > 의장과 출석한 이사 및 감사가 아래와 같이 기명날인한다 .</ p >
< p style = " margin-top: 20px; font-size: 14px; " > $ { data . meeting_date || '' } </ p >
< p style = " margin-top: 8px; font-size: 15px; font-weight: 700; " > $ { data . company_name || '' } </ p >
</ div >
< div style = " margin-top: 32px; font-size: 13px; " >
$ { signersHtml }
</ div >
` ;
}
function openBoardMinutesPreview () {
const bmContainer = document . getElementById ( 'board-minutes-form-container' );
const bmAlpine = bmContainer . _x_dataStack ? . [ 0 ];
const data = {
meeting_datetime : document . getElementById ( 'bm-meeting-datetime' ) . value ,
meeting_place : document . getElementById ( 'bm-meeting-place' ) . value ,
total_directors : parseInt ( document . getElementById ( 'bm-total-directors' ) . value ) || 0 ,
present_directors : parseInt ( document . getElementById ( 'bm-present-directors' ) . value ) || 0 ,
total_auditors : parseInt ( document . getElementById ( 'bm-total-auditors' ) . value ) || 0 ,
present_auditors : parseInt ( document . getElementById ( 'bm-present-auditors' ) . value ) || 0 ,
chairman_name : document . getElementById ( 'bm-chairman-name' ) . value ,
agendas : bmAlpine ? bmAlpine . agendas . filter ( a => a . title . trim ()) : [],
proceedings : document . getElementById ( 'bm-proceedings' ) . value ,
closing_time : document . getElementById ( 'bm-closing-time' ) . value ,
signers : bmAlpine ? bmAlpine . signers . filter ( s => s . name . trim ()) : [],
company_name : document . getElementById ( 'bm-company-name' ) . value ,
meeting_date : ( document . getElementById ( 'bm-meeting-datetime' ) . value || '' ) . split ( 'T' )[ 0 ],
};
document . getElementById ( 'board-minutes-preview-content' ) . innerHTML = buildBoardMinutesPreviewHtml ( data );
document . getElementById ( 'board-minutes-preview-modal' ) . style . display = '' ;
document . body . style . overflow = 'hidden' ;
}
function closeBoardMinutesPreview () {
document . getElementById ( 'board-minutes-preview-modal' ) . style . display = 'none' ;
document . body . style . overflow = '' ;
}
function printBoardMinutesPreview () {
const content = document . getElementById ( 'board-minutes-preview-content' ) . innerHTML ;
const win = window . open ( '' , '_blank' );
win . document . write ( '<html><head><title>이사회의사록</title>' );
win . document . write ( '<style>body{font-family:"Pretendard","Malgun Gothic",sans-serif;padding:48px 56px;margin:0;}@media print{body{padding:40px 48px;}}</style>' );
win . document . write ( '</head><body>' );
win . document . write ( content );
win . document . write ( '</body></html>' );
win . document . close ();
win . print ();
}
2026-03-06 23:21:49 +09:00
// ─── 견적서 미리보기 ───
function buildQuotationPreviewHtml ( data ) {
const fmt = n => ( n || 0 ) . toLocaleString ( 'ko-KR' );
let itemsHtml = '' ;
( data . items || []) . forEach (( item , idx ) => {
itemsHtml += ` < tr style = " border-bottom:1px solid #ddd; " >
< td style = " padding:6px 8px;text-align:center;font-size:12px; " > $ { idx + 1 } </ td >
< td style = " padding:6px 8px;font-size:12px; " > $ { item . name || '' } </ td >
< td style = " padding:6px 8px;font-size:12px;color:#555; " > $ { item . spec || '' } </ td >
< td style = " padding:6px 8px;text-align:right;font-size:12px; " > $ { item . qty || 0 } </ td >
< td style = " padding:6px 8px;text-align:right;font-size:12px; " > $ { fmt ( item . unit_price )} </ td >
< td style = " padding:6px 8px;text-align:right;font-size:12px;font-weight:600; " > $ { fmt ( item . supply_amount )} </ td >
< td style = " padding:6px 8px;text-align:right;font-size:12px;color:#555; " > $ { fmt ( item . tax )} </ td >
< td style = " padding:6px 8px;font-size:12px;color:#555; " > $ { item . note || '' } </ td >
</ tr > ` ;
});
return `
< div style = " text-align:center;margin-bottom:32px; " >
< h1 style = " font-size:28px;font-weight:800;letter-spacing:8px;margin:0; " > 견 적 서 </ h1 >
</ div >
< div style = " display:flex;justify-content:space-between;margin-bottom:24px; " >
< div style = " font-size:14px; " >
< p style = " margin:0 0 4px; " >< strong > $ { data . client_name || '' } </ strong > 귀하 </ p >
< p style = " margin:0;font-size:12px;color:#555; " > 아래와 같이 견적합니다 .</ p >
</ div >
< div style = " font-size:12px;text-align:right;color:#555; " >
< p style = " margin:0; " > 견적일자 : $ { data . quote_date || '' } </ p >
</ div >
</ div >
< div style = " background:#EFF6FF;border:2px solid #3B82F6;border-radius:8px;padding:16px 24px;text-align:center;margin-bottom:24px; " >
< p style = " margin:0 0 4px;font-size:13px;color:#555; " > 견적금액 </ p >
< p style = " margin:0;font-size:24px;font-weight:800;color:#1E40AF; " > ₩ $ { fmt ( data . total_amount )} </ p >
< p style = " margin:4px 0 0;font-size:11px;color:#666; " > ( 공급가액 $ { fmt ( data . total_supply )} + 부가세 $ { fmt ( data . total_tax )}) </ p >
</ div >
< table style = " width:100%;border-collapse:collapse;margin-bottom:16px;font-size:12px; " >
< caption style = " text-align:left;font-weight:700;font-size:13px;margin-bottom:8px; " > 공급자 </ caption >
< tbody >
< tr style = " border-bottom:1px solid #eee; " >
< td style = " padding:6px 8px;background:#F9FAFB;width:100px;font-weight:600;font-size:11px; " > 사업자등록번호 </ td >
< td style = " padding:6px 8px; " > $ { data . business_num || '' } </ td >
< td style = " padding:6px 8px;background:#F9FAFB;width:60px;font-weight:600;font-size:11px; " > 상호 </ td >
< td style = " padding:6px 8px; " > $ { data . company_name || '' } </ td >
< td style = " padding:6px 8px;background:#F9FAFB;width:60px;font-weight:600;font-size:11px; " > 대표자 </ td >
< td style = " padding:6px 8px; " > $ { data . ceo_name || '' } </ td >
</ tr >
< tr style = " border-bottom:1px solid #eee; " >
< td style = " padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px; " > 소재지 </ td >
< td colspan = " 5 " style = " padding:6px 8px; " > $ { data . company_address || '' } </ td >
</ tr >
< tr style = " border-bottom:1px solid #eee; " >
< td style = " padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px; " > 업태 </ td >
< td style = " padding:6px 8px; " > $ { data . business_type || '' } </ td >
< td style = " padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px; " > 업종 </ td >
< td colspan = " 3 " style = " padding:6px 8px; " > $ { data . business_item || '' } </ td >
</ tr >
< tr >
< td style = " padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px; " > 연락처 </ td >
< td style = " padding:6px 8px; " > $ { data . phone || '' } </ td >
< td style = " padding:6px 8px;background:#F9FAFB;font-weight:600;font-size:11px; " > 계좌 </ td >
< td colspan = " 3 " style = " padding:6px 8px; " > $ { data . bank_account || '' } </ td >
</ tr >
</ tbody >
</ table >
< table style = " width:100%;border-collapse:collapse;margin-bottom:16px; " >
< thead >
< tr style = " background:#F9FAFB;border-bottom:2px solid #ccc; " >
< th style = " padding:8px;text-align:center;font-size:11px;width:32px; " > No </ th >
< th style = " padding:8px;text-align:left;font-size:11px; " > 품명 </ th >
< th style = " padding:8px;text-align:left;font-size:11px; " > 규격 </ th >
< th style = " padding:8px;text-align:right;font-size:11px; " > 수량 </ th >
< th style = " padding:8px;text-align:right;font-size:11px; " > 단가 </ th >
< th style = " padding:8px;text-align:right;font-size:11px; " > 공급가액 </ th >
< th style = " padding:8px;text-align:right;font-size:11px; " > 세액 </ th >
< th style = " padding:8px;text-align:left;font-size:11px; " > 비고 </ th >
</ tr >
</ thead >
< tbody > $ { itemsHtml } </ tbody >
< tfoot >
< tr style = " border-top:2px solid #999;background:#F9FAFB; " >
< td colspan = " 5 " style = " padding:8px;text-align:right;font-weight:700;font-size:12px; " > 합 계 </ td >
< td style = " padding:8px;text-align:right;font-weight:700;font-size:12px;color:#1E40AF; " > $ { fmt ( data . total_supply )} </ td >
< td style = " padding:8px;text-align:right;font-weight:700;font-size:12px;color:#1E40AF; " > $ { fmt ( data . total_tax )} </ td >
< td ></ td >
</ tr >
</ tfoot >
</ table >
$ { data . remarks ? `<div style="margin-top:16px;padding:12px 16px;background:#FFFBEB;border:1px solid #FDE68A;border-radius:6px;font-size:12px;"><strong style="font-size:11px;color:#92400E;">특이사항</strong><p style="margin:4px 0 0;white-space:pre-wrap;">${data.remarks}</p></div>` : '' }
` ;
}
function openQuotationPreview () {
const qtContainer = document . getElementById ( 'quotation-form-container' );
const qtAlpine = qtContainer . _x_dataStack ? . [ 0 ];
const qtItems = qtAlpine ? qtAlpine . items . filter ( i => i . name . trim ()) . map ( i => ({
name : i . name . trim (), spec : ( i . spec || '' ) . trim (), qty : parseInt ( i . qty ) || 0 , unit_price : parseInt ( i . unit_price ) || 0 ,
supply_amount : ( parseInt ( i . qty ) || 0 ) * ( parseInt ( i . unit_price ) || 0 ), tax : qtAlpine . itemTax ( i ), note : ( i . note || '' ) . trim (),
})) : [];
const data = {
client_name : document . getElementById ( 'qt-client-name' ) . value . trim (),
quote_date : document . getElementById ( 'qt-quote-date' ) . value ,
company_name : document . getElementById ( 'qt-company-name' ) . value ,
business_num : document . getElementById ( 'qt-business-num' ) . value ,
ceo_name : document . getElementById ( 'qt-ceo-name' ) . value ,
company_address : document . getElementById ( 'qt-company-address' ) . value ,
business_type : document . getElementById ( 'qt-business-type-input' ) ? . value || '' ,
business_item : document . getElementById ( 'qt-business-item-input' ) ? . value || '' ,
phone : document . getElementById ( 'qt-phone-input' ) ? . value || '' ,
bank_account : document . getElementById ( 'qt-bank-input' ) ? . value || '' ,
items : qtItems ,
total_supply : qtAlpine ? qtAlpine . totalSupply () : 0 ,
total_tax : qtAlpine ? qtAlpine . totalTax () : 0 ,
total_amount : qtAlpine ? qtAlpine . totalAmount () : 0 ,
remarks : document . getElementById ( 'qt-remarks' ) . value . trim (),
};
document . getElementById ( 'quotation-preview-content' ) . innerHTML = buildQuotationPreviewHtml ( data );
document . getElementById ( 'quotation-preview-modal' ) . style . display = '' ;
document . body . style . overflow = 'hidden' ;
}
function closeQuotationPreview () {
document . getElementById ( 'quotation-preview-modal' ) . style . display = 'none' ;
document . body . style . overflow = '' ;
}
function printQuotationPreview () {
const content = document . getElementById ( 'quotation-preview-content' ) . innerHTML ;
const win = window . open ( '' , '_blank' );
win . document . write ( '<html><head><title>견적서</title>' );
win . document . write ( '<style>body{font-family:"Pretendard","Malgun Gothic",sans-serif;padding:48px 56px;margin:0;}@media print{body{padding:40px 48px;}}</style>' );
win . document . write ( '</head><body>' );
win . document . write ( content );
win . document . write ( '</body></html>' );
win . document . close ();
win . print ();
}
2026-03-06 23:38:55 +09:00
// ─── 공문서 미리보기 ───
function buildOfficialLetterPreviewHtml ( data ) {
const bodyHtml = ( data . body || '' ) . replace ( /&/ g , '&' ) . replace ( /</ g , '<' ) . replace ( />/ g , '>' ) . replace ( / \n / g , '<br>' );
const attachHtml = ( data . attachments_desc || '' ) . replace ( /&/ g , '&' ) . replace ( /</ g , '<' ) . replace ( />/ g , '>' ) . replace ( / \n / g , '<br>' );
return `
< div style = " text-align:center;margin-bottom:32px; " >
< h1 style = " font-size:22px;font-weight:800;margin:0; " > $ { data . company_name || '' } </ h1 >
</ div >
< div style = " border-bottom:2px solid #333;padding-bottom:16px;margin-bottom:24px;font-size:13px;line-height:2; " >
< div >< span style = " display:inline-block;width:80px;letter-spacing:12px;font-weight:600; " > 문서번호 </ span > : $ { data . doc_number || '' } </ div >
< div >< span style = " display:inline-block;width:80px;letter-spacing:18px;font-weight:600; " > 일자 </ span > : $ { data . doc_date || '' } </ div >
< div >< span style = " display:inline-block;width:80px;letter-spacing:18px;font-weight:600; " > 수신 </ span > : $ { data . recipient || '' } </ div >
$ { data . reference ? `<div><span style="display:inline-block;width:80px;letter-spacing:18px;font-weight:600;">참조</span> : ${data.reference}</div>` : '' }
< div >< span style = " display:inline-block;width:80px;letter-spacing:18px;font-weight:600; " > 제목 </ span > : < strong > $ { data . subject || '' } </ strong ></ div >
</ div >
< div style = " font-size:13px;line-height:1.8;margin-bottom:24px;min-height:200px; " > $ { bodyHtml } </ div >
$ { data . attachments_desc ? `<div style="font-size:13px;line-height:1.6;margin-bottom:24px;padding-top:12px;border-top:1px solid #ddd;"><strong>붙임 :</strong><br>${attachHtml}</div>` : '' }
< div style = " text-align:center;margin:40px 0 16px;font-size:14px; " >
< span > $ { data . company_name || '' } </ span >& nbsp ; & nbsp ; & nbsp ; & nbsp ;
< span > 대표이사 & nbsp ; & nbsp ; & nbsp ; $ { data . ceo_name || '' } </ span >& nbsp ; & nbsp ;
< span style = " color:#999; " > [ 직인날인 ] </ span >
</ div >
< div style = " border-top:1px solid #ccc;padding-top:8px;font-size:10px;color:#888;line-height:1.6; " >
$ { data . company_address ? `<div>${data.company_address}</div>` : '' }
< div > $ { data . phone ? '전화 ' + data . phone : '' } $ { data . fax ? ' / 팩스 ' + data . fax : '' } $ { data . email ? ' / 이메일 ' + data . email : '' } </ div >
</ div > ` ;
}
function openOfficialLetterPreview () {
const data = {
doc_number : document . getElementById ( 'ol-doc-number' ) . value . trim (),
doc_date : document . getElementById ( 'ol-doc-date' ) . value ,
recipient : document . getElementById ( 'ol-recipient' ) . value . trim (),
reference : document . getElementById ( 'ol-reference' ) . value . trim (),
subject : document . getElementById ( 'ol-subject' ) . value . trim (),
body : document . getElementById ( 'ol-body' ) . value . trim (),
attachments_desc : document . getElementById ( 'ol-attachments-desc' ) . value . trim (),
company_name : document . getElementById ( 'ol-company-name' ) . value ,
ceo_name : document . getElementById ( 'ol-ceo-name' ) . value ,
company_address : document . getElementById ( 'ol-company-address' ) . value ,
phone : document . getElementById ( 'ol-phone-input' ) ? . value || '' ,
fax : document . getElementById ( 'ol-fax-input' ) ? . value || '' ,
email : document . getElementById ( 'ol-email-input' ) ? . value || '' ,
};
document . getElementById ( 'official-letter-preview-content' ) . innerHTML = buildOfficialLetterPreviewHtml ( data );
document . getElementById ( 'official-letter-preview-modal' ) . style . display = '' ;
document . body . style . overflow = 'hidden' ;
}
function closeOfficialLetterPreview () {
document . getElementById ( 'official-letter-preview-modal' ) . style . display = 'none' ;
document . body . style . overflow = '' ;
}
function printOfficialLetterPreview () {
const content = document . getElementById ( 'official-letter-preview-content' ) . innerHTML ;
const win = window . open ( '' , '_blank' );
win . document . write ( '<html><head><title>공문서</title>' );
win . document . write ( '<style>body{font-family:"Pretendard","Malgun Gothic",sans-serif;padding:48px 56px;margin:0;}@media print{body{padding:40px 48px;}}</style>' );
win . document . write ( '</head><body>' );
win . document . write ( content );
win . document . write ( '</body></html>' );
win . document . close ();
win . print ();
}
2026-03-06 22:25:00 +09:00
function printDelegationPreview () {
const content = document . getElementById ( 'delegation-preview-content' ) . innerHTML ;
const win = window . open ( '' , '_blank' );
win . document . write ( '<html><head><title>위임장</title>' );
win . document . write ( '<style>body{font-family:"Pretendard","Malgun Gothic",sans-serif;padding:48px 56px;margin:0;}@media print{body{padding:40px 48px;}}</style>' );
win . document . write ( '</head><body>' );
win . document . write ( content );
win . document . write ( '</body></html>' );
win . document . close ();
win . print ();
}
feat: [approval] 결재관리 Phase 1 MVP 구현
- 모델 4개: Approval, ApprovalStep, ApprovalForm, ApprovalLine
- ApprovalService: 목록/CRUD/워크플로우(상신/승인/반려/회수) 비즈니스 로직
- ApprovalApiController: JSON API 엔드포인트 (기안함/결재함/완료함/참조함)
- ApprovalController: Blade 뷰 컨트롤러 (HX-Redirect 처리)
- 뷰 8개: drafts, pending, completed, references, create, edit, show
- partials: _status-badge, _step-progress, _approval-line-editor
- api.php/web.php 라우트 등록
2026-02-27 23:17:17 +09:00
</ script >
@ endpush