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 " >
< div >
< h1 class = " text-2xl font-bold text-gray-800 " > 기안 수정 </ h1 >
< p class = " text-sm text-gray-500 mt-1 " > {{ $approval -> document_number }} </ p >
</ div >
< div class = " flex gap-2 " >
< a href = " { { route('approvals.show', $approval->id ) }} " class = " text-gray-600 hover:text-gray-800 text-sm px-3 py-2 border rounded-lg " >
상세보기
</ a >
< a href = " { { route('approvals.drafts') }} " class = " text-gray-600 hover:text-gray-800 text-sm px-3 py-2 border rounded-lg " >
기안함
</ a >
</ div >
</ div >
@ if ( $approval -> status === 'rejected' )
2026-02-28 14:41:37 +09:00
< div class = " bg-red-50 border-l-4 border-red-400 p-4 mb-6 rounded " style = " max-width: 960px; margin-left: auto; margin-right: auto; " >
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 class = " flex items-center " >
< span class = " text-red-700 font-medium " > 반려됨 </ span >
</ div >
@ php
$rejectedStep = $approval -> steps -> firstWhere ( 'status' , 'rejected' );
@ endphp
@ if ( $rejectedStep )
< p class = " text-sm text-red-600 mt-1 " >
{{ $rejectedStep -> approver_name ? ? '' }} ({{ $rejectedStep -> acted_at ? -> format ( 'Y-m-d H:i' ) }}) :
{{ $rejectedStep -> comment }}
</ p >
@ endif
</ div >
@ endif
2026-03-05 13:50:45 +09:00
{{ -- 반려 이력 ( 재상신 문서인 경우 ) -- }}
@ if ( ! empty ( $approval -> rejection_history ))
< div class = " bg-orange-50 border border-orange-200 p-4 mb-6 rounded-lg " style = " max-width: 960px; margin-left: auto; margin-right: auto; " >
< h4 class = " text-sm font-semibold text-orange-800 mb-2 flex items-center gap-2 " >
이전 반려 이력
< span class = " inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-orange-200 text-orange-700 " > {{ count ( $approval -> rejection_history ) }} 회 </ span >
</ h4 >
< div class = " space-y-2 " >
@ foreach ( $approval -> rejection_history as $history )
< div class = " text-sm border-l-2 border-orange-300 pl-3 py-1 " >
< div class = " flex items-center gap-2 " >
< span class = " text-xs font-medium text-orange-600 " > {{ $history [ 'round' ] ? ? '-' }} 차 </ span >
< span class = " font-medium text-gray-800 " > {{ $history [ 'approver_name' ] ? ? '' }} </ span >
< span class = " text-gray-400 text-xs " > {{ $history [ 'rejected_at' ] ? ? '' }} </ span >
</ div >
< p class = " text-gray-600 mt-0.5 " > {{ $history [ 'comment' ] ? ? '' }} </ p >
</ div >
@ endforeach
</ div >
</ div >
@ endif
2026-02-28 14:41:37 +09:00
< div class = " mx-auto " style = " max-width: 960px; " >
< div class = " bg-white rounded-lg shadow-sm p-6 " >
< h2 class = " text-lg font-semibold text-gray-800 mb-4 " > 문서 내용 </ h2 >
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 >
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 " >
2026-03-06 12:54:53 +09:00
@ foreach ( $forms as $form )
< option value = " { { $form->id }} " {{ $approval -> form_id == $form -> id ? 'selected' : '' }} > {{ $form -> name }} </ option >
@ endforeach
</ select >
</ 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 >
</ 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 " value = " { { $approval->title }} "
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 >
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 }} " {{ ( $approval -> line_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 " {{ $approval -> is_urgent ? 'checked' : '' }}
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 "
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; " > {{ $approval -> body }} </ 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 18:53:42 +09:00
{{ -- 재직증명서 전용 폼 -- }}
@ include ( 'approvals.partials._certificate-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:07:49 +09:00
@ php
$existingFiles = [];
if ( ! empty ( $approval -> attachments )) {
$fileIds = collect ( $approval -> attachments ) -> pluck ( 'id' ) -> filter ();
if ( $fileIds -> isNotEmpty ()) {
$existingFiles = \App\Models\Boards\File :: whereIn ( 'id' , $fileIds )
-> get ()
-> map ( fn ( $f ) => [
'id' => $f -> id ,
'name' => $f -> original_name ,
'size' => $f -> file_size ,
'mime_type' => $f -> mime_type ,
]) -> toArray ();
}
}
@ endphp
2026-03-04 15:14:18 +09:00
@ include ( 'approvals.partials._expense-form' , [
'initialData' => $approval -> content ? ? [],
2026-03-04 20:07:49 +09:00
'initialFiles' => $existingFiles ,
2026-03-04 20:29:25 +09:00
'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' => $approval -> content ? ? [],
'initialFiles' => $existingFiles ,
])
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 = " updateApproval('save') "
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 = " updateApproval('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
{{ $approval -> status === 'rejected' ? '재상신' : '상신' }}
</ button >
@ if ( $approval -> isDeletable ())
< button onclick = " deleteApproval() "
2026-02-28 14:41:37 +09:00
class = " bg-red-100 hover:bg-red-200 text-red-700 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 >
@ endif
</ 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
$initialSteps = $approval -> steps -> map ( fn ( $s ) => [
'user_id' => $s -> approver_id ,
'user_name' => $s -> approver_name ? ? ( $s -> approver ? -> name ? ? '' ),
'department' => $s -> approver_department ? ? '' ,
'position' => $s -> approver_position ? ? '' ,
'step_type' => $s -> step_type ,
]) -> toArray ();
@ endphp
@ include ( 'approvals.partials._approval-line-editor' , [
'lines' => $lines ,
'initialSteps' => $initialSteps ,
'selectedLineId' => $approval -> line_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 >
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-05 18:53:42 +09:00
let isCertForm = 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-02-28 14:18:16 +09:00
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 : {
title : '지출결의서' , icon : '💰' ,
color : 'border-amber-200 bg-amber-50' , titleColor : 'text-amber-800' , textColor : 'text-amber-700' ,
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-emerald-200 bg-emerald-50' , titleColor : 'text-emerald-800' , textColor : 'text-emerald-600' ,
text : '고객에게 제출할 견적서를 작성하는 문서입니다. 품목, 수량, 단가, 공급가액, 부가세를 기재하며, 승인 후 공식 견적서로 사용할 수 있습니다.' ,
},
2026-03-06 12:54:53 +09:00
pr_expense : {
title : '지출품의서' , icon : '📋' ,
color : 'border-orange-200 bg-orange-50' , titleColor : 'text-orange-800' , textColor : 'text-orange-700' ,
text : '지출이 발생하기 전 사전 승인을 받는 문서입니다. 예산 범위 내에서 지출 항목과 금액을 기재하여 사전에 승락을 받습니다.' ,
},
pr_contract : {
title : '계약체결품의서' , icon : '📝' ,
color : 'border-purple-200 bg-purple-50' , titleColor : 'text-purple-800' , textColor : 'text-purple-700' ,
text : '외부 업체와의 계약 체결 전 승인을 받는 문서입니다. 계약 상대방, 계약 내용, 기간, 금액, 주요 조건 등을 명시하여 계약 진행에 대한 사전 승락을 받습니다.' ,
},
pr_purchase : {
title : '구매품의서' , icon : '🛒' ,
color : 'border-blue-200 bg-blue-50' , titleColor : 'text-blue-800' , textColor : 'text-blue-700' ,
text : '물품 구매 전 사전 승인을 받는 문서입니다. 구매할 품목, 수량, 단가, 납품업체 등을 기재하여 구매 진행에 대한 사전 승락을 받습니다.' ,
},
pr_trip : {
title : '출장품의서' , icon : '✈️' ,
color : 'border-green-200 bg-green-50' , titleColor : 'text-green-800' , textColor : 'text-green-700' ,
text : '출장 전 계획 승인을 받는 문서입니다. 출장지, 기간, 업무 내용, 예상 경비(교통비·숙박비·식비 등)를 기재하여 출장 진행에 대한 사전 승락을 받습니다.' ,
},
pr_settlement : {
title : '비용정산품의서' , icon : '🧾' ,
color : 'border-teal-200 bg-teal-50' , titleColor : 'text-teal-800' , textColor : 'text-teal-700' ,
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
expense : '재무' , quotation : '재무' ,
2026-03-06 13:18:44 +09:00
};
const categoryIcons = {
'일반' : '📄' , '인사/근태' : '👤' , '증명서' : '📜' , '품의' : '📋' , '재무' : '💰' ,
};
const categoryOrder = [ '일반' , '인사/근태' , '증명서' , '품의' , '재무' ];
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 ();
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-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 ;
const steps = editorEl . _x_dataStack [ 0 ] . steps ;
const summaryEl = document . getElementById ( 'approval-line-summary' );
2026-02-28 14:55:15 +09:00
if ( summarySortableInstance ) { summarySortableInstance . destroy (); summarySortableInstance = null ; }
2026-02-28 14:41:37 +09:00
if ( ! steps || steps . 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-02-28 14:48:16 +09:00
const typeCounters = { approval : 0 , agreement : 0 , reference : 0 };
2026-02-28 14:41:37 +09:00
const typeLabels = { approval : '결재' , agreement : '합의' , reference : '참조' };
2026-02-28 14:48:16 +09:00
const typeBg = { approval : 'bg-blue-50 border-blue-100' , agreement : 'bg-green-50 border-green-100' , reference : 'bg-gray-100 border-gray-200' };
const typeLabelColor = { approval : 'text-blue-600' , agreement : 'text-green-600' , reference : 'text-gray-500' };
const cards = [];
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 ;
var bg = typeBg [ s . step_type ] || typeBg . reference ;
var labelColor = typeLabelColor [ s . step_type ] || typeLabelColor . reference ;
var stepLabel = s . step_type === 'reference' ? label : count + '차 ' + label ;
var position = s . position || '' ;
cards . 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-02-28 14:48:16 +09:00
summaryEl . className = 'p-3 bg-gray-50 rounded-lg border border-gray-200' ;
2026-02-28 14:55:15 +09:00
summaryEl . innerHTML = '<div id="summary-sortable" class="flex flex-wrap items-center">' + cards . join ( '' ) + '</div>' +
( steps . length > 1 ? '<div class="text-xs text-gray-400 mt-2">드래그하여 순서를 변경할 수 있습니다</div>' : '' );
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 18:53:42 +09:00
const certContainer = document . getElementById ( 'cert-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 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
certContainer . 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
bodyArea . style . display = 'none' ;
isExpenseForm = false ;
2026-03-06 11:28:15 +09:00
isPurchaseRequestForm = false ;
2026-03-05 18:53:42 +09:00
isCertForm = 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-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 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-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 );
// 전용 폼이면 제목만 자동 설정하고 body template 적용 건너뜀
2026-03-07 00:28:58 +09:00
if ( isExpenseForm || isPurchaseRequestForm || isCertForm || isSealUsageForm || isDelegationForm || isBoardMinutesForm || isQuotationForm || isOfficialLetterForm || isLeavePromotion1stForm || isLeavePromotion2ndForm ) {
2026-03-04 15:14: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 ;
}
return ;
}
2026-03-04 14:18:54 +09:00
const template = formBodyTemplates [ formId ];
if ( ! template ) return ;
// edit에서는 항상 confirm (기존 본문이 있을 가능성 높음)
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
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' ) {
const modal = document . getElementById ( 'approval-line-modal' );
if ( modal && modal . style . display !== 'none' ) {
closeApprovalLineModal ();
}
}
});
// 기존 HTML body 자동 감지 → 편집기 자동 활성화 + 결재선 요약 초기화
2026-02-28 14:18:16 +09:00
document . addEventListener ( 'DOMContentLoaded' , function () {
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-05 18:53:42 +09:00
// 재직증명서 기존 데이터 복원
if ( isCertForm ) {
const certContent = @ json ( $approval -> content ? ? []);
if ( certContent . name ) {
document . getElementById ( 'cert-name' ) . value = certContent . name || '' ;
document . getElementById ( 'cert-resident' ) . value = certContent . resident_number || '' ;
document . getElementById ( 'cert-address' ) . value = certContent . address || '' ;
document . getElementById ( 'cert-company' ) . value = certContent . company_name || '' ;
document . getElementById ( 'cert-business-num' ) . value = certContent . business_num || '' ;
document . getElementById ( 'cert-department' ) . value = certContent . department || '' ;
document . getElementById ( 'cert-position' ) . value = certContent . position || '' ;
document . getElementById ( 'cert-hire-date' ) . value = certContent . hire_date || '' ;
2026-03-06 09:19:32 +09:00
document . getElementById ( 'cert-ceo-name' ) . value = certContent . ceo_name || '' ;
document . getElementById ( 'cert-company-address' ) . value = certContent . company_address || '' ;
2026-03-05 18:53:42 +09:00
document . getElementById ( 'cert-issue-date' ) . value = certContent . issue_date || '{{ now()->format("Y-m-d") }}' ;
// 용도 복원
const purposeSelect = document . getElementById ( 'cert-purpose-select' );
const purpose = certContent . purpose || '' ;
const predefined = [ '은행 제출용' , '관공서 제출용' , '비자 신청용' , '대출 신청용' ];
if ( predefined . includes ( purpose )) {
purposeSelect . value = purpose ;
} else if ( purpose ) {
purposeSelect . value = '__custom__' ;
document . getElementById ( 'cert-purpose-custom-wrap' ) . style . display = '' ;
document . getElementById ( 'cert-purpose-custom' ) . value = purpose ;
}
// 사원 셀렉트 복원
if ( certContent . cert_user_id ) {
document . getElementById ( 'cert-user-id' ) . value = certContent . cert_user_id ;
}
}
}
2026-03-06 20:48:01 +09:00
// 사용인감계 기존 데이터 복원
if ( isSealUsageForm ) {
const suContent = @ json ( $approval -> content ? ? []);
if ( suContent . usage_date ) {
document . getElementById ( 'su-usage-date' ) . value = suContent . usage_date || '' ;
document . getElementById ( 'su-purpose' ) . value = suContent . purpose || '' ;
document . getElementById ( 'su-submit-to' ) . value = suContent . submit_to || '' ;
2026-03-06 21:03:15 +09:00
document . getElementById ( 'su-attachment-desc' ) . value = suContent . attachment_desc || '' ;
2026-03-06 20:48:01 +09:00
}
}
2026-03-06 22:25:00 +09:00
// 위임장 기존 데이터 복원
if ( isDelegationForm ) {
const dlContent = @ json ( $approval -> content ? ? []);
if ( dlContent . agent_name ) {
document . getElementById ( 'dl-agent-name' ) . value = dlContent . agent_name || '' ;
document . getElementById ( 'dl-agent-birth' ) . value = dlContent . agent_birth || '' ;
document . getElementById ( 'dl-agent-address' ) . value = dlContent . agent_address || '' ;
document . getElementById ( 'dl-agent-phone' ) . value = dlContent . agent_phone || '' ;
document . getElementById ( 'dl-agent-department' ) . value = dlContent . agent_department || '' ;
document . getElementById ( 'dl-delegation-detail' ) . value = dlContent . delegation_detail || '' ;
document . getElementById ( 'dl-period-start' ) . value = dlContent . period_start || '' ;
document . getElementById ( 'dl-period-end' ) . value = dlContent . period_end || '' ;
document . getElementById ( 'dl-attachments-desc' ) . value = dlContent . attachments_desc || '' ;
}
}
2026-03-06 23:00:22 +09:00
// 이사회의사록 기존 데이터 복원
if ( isBoardMinutesForm ) {
const bmContent = @ json ( $approval -> content ? ? []);
if ( bmContent . meeting_datetime ) {
document . getElementById ( 'bm-meeting-datetime' ) . value = bmContent . meeting_datetime || '' ;
document . getElementById ( 'bm-meeting-place' ) . value = bmContent . meeting_place || '' ;
document . getElementById ( 'bm-total-directors' ) . value = bmContent . total_directors || 0 ;
document . getElementById ( 'bm-present-directors' ) . value = bmContent . present_directors || 0 ;
document . getElementById ( 'bm-total-auditors' ) . value = bmContent . total_auditors || 0 ;
document . getElementById ( 'bm-present-auditors' ) . value = bmContent . present_auditors || 0 ;
document . getElementById ( 'bm-chairman-name' ) . value = bmContent . chairman_name || '' ;
document . getElementById ( 'bm-proceedings' ) . value = bmContent . proceedings || '' ;
document . getElementById ( 'bm-closing-time' ) . value = bmContent . closing_time || '' ;
const bmAlpine = document . getElementById ( 'board-minutes-form-container' ) . _x_dataStack ? . [ 0 ];
if ( bmAlpine ) {
if ( bmContent . agendas && bmContent . agendas . length > 0 ) bmAlpine . agendas = bmContent . agendas ;
if ( bmContent . signers && bmContent . signers . length > 0 ) bmAlpine . signers = bmContent . signers ;
}
}
}
2026-03-06 23:21:49 +09:00
// 견적서 기존 데이터 복원
if ( isQuotationForm ) {
const qtContent = @ json ( $approval -> content ? ? []);
if ( qtContent . client_name ) {
document . getElementById ( 'qt-client-name' ) . value = qtContent . client_name || '' ;
document . getElementById ( 'qt-quote-date' ) . value = qtContent . quote_date || '' ;
document . getElementById ( 'qt-remarks' ) . value = qtContent . remarks || '' ;
if ( qtContent . business_type ) {
const btInput = document . getElementById ( 'qt-business-type-input' );
if ( btInput ) btInput . value = qtContent . business_type ;
}
if ( qtContent . business_item ) {
const biInput = document . getElementById ( 'qt-business-item-input' );
if ( biInput ) biInput . value = qtContent . business_item ;
}
if ( qtContent . phone ) {
const phInput = document . getElementById ( 'qt-phone-input' );
if ( phInput ) phInput . value = qtContent . phone ;
}
if ( qtContent . bank_account ) {
const baInput = document . getElementById ( 'qt-bank-input' );
if ( baInput ) baInput . value = qtContent . bank_account ;
}
const qtAlpine = document . getElementById ( 'quotation-form-container' ) . _x_dataStack ? . [ 0 ];
if ( qtAlpine && qtContent . items && qtContent . items . length > 0 ) {
qtAlpine . items = qtContent . items . map ( i => ({
name : i . name || '' , spec : i . spec || '' , qty : i . qty || 1 ,
unit_price : i . unit_price || 0 , tax : i . tax || 0 , note : i . note || '' ,
}));
}
}
}
2026-03-06 23:38:55 +09:00
// 공문서 기존 데이터 복원
if ( isOfficialLetterForm ) {
const olContent = @ json ( $approval -> content ? ? []);
if ( olContent . recipient ) {
document . getElementById ( 'ol-doc-number' ) . value = olContent . doc_number || '' ;
document . getElementById ( 'ol-doc-date' ) . value = olContent . doc_date || '' ;
document . getElementById ( 'ol-recipient' ) . value = olContent . recipient || '' ;
document . getElementById ( 'ol-reference' ) . value = olContent . reference || '' ;
document . getElementById ( 'ol-subject' ) . value = olContent . subject || '' ;
document . getElementById ( 'ol-body' ) . value = olContent . body || '' ;
document . getElementById ( 'ol-attachments-desc' ) . value = olContent . attachments_desc || '' ;
if ( olContent . phone ) { const el = document . getElementById ( 'ol-phone-input' ); if ( el ) el . value = olContent . phone ; }
if ( olContent . fax ) { const el = document . getElementById ( 'ol-fax-input' ); if ( el ) el . value = olContent . fax ; }
if ( olContent . email ) { const el = document . getElementById ( 'ol-email-input' ); if ( el ) el . value = olContent . email ; }
}
}
2026-03-07 00:28:58 +09:00
// 연차사용촉진 1차 통지서 기존 데이터 복원
if ( isLeavePromotion1stForm ) {
const lp1Content = @ json ( $approval -> content ? ? []);
if ( lp1Content . employee_id ) {
document . getElementById ( 'lp1-user-id' ) . value = lp1Content . employee_id || '' ;
document . getElementById ( 'lp1-department' ) . value = lp1Content . department || '' ;
document . getElementById ( 'lp1-position' ) . value = lp1Content . position || '' ;
document . getElementById ( 'lp1-total-days' ) . value = lp1Content . total_days || 0 ;
document . getElementById ( 'lp1-used-days' ) . value = lp1Content . used_days || 0 ;
document . getElementById ( 'lp1-remaining-days' ) . value = lp1Content . remaining_days || 0 ;
document . getElementById ( 'lp1-deadline' ) . value = lp1Content . deadline || '' ;
}
}
// 연차사용촉진 2차 통지서 기존 데이터 복원
if ( isLeavePromotion2ndForm ) {
const lp2Content = @ json ( $approval -> content ? ? []);
if ( lp2Content . employee_id ) {
document . getElementById ( 'lp2-user-id' ) . value = lp2Content . employee_id || '' ;
document . getElementById ( 'lp2-department' ) . value = lp2Content . department || '' ;
document . getElementById ( 'lp2-position' ) . value = lp2Content . position || '' ;
document . getElementById ( 'lp2-remaining-days' ) . value = lp2Content . remaining_days || 0 ;
if ( lp2Content . designated_dates && lp2Content . designated_dates . length > 0 ) {
const lp2Alpine = document . getElementById ( 'leave-promotion-2nd-form-container' ) . _x_dataStack ? . [ 0 ];
if ( lp2Alpine ) {
lp2Alpine . dates = lp2Content . designated_dates . map ( d => ({ date : d }));
}
}
}
}
2026-03-04 15:14:18 +09:00
// 전용 폼이 아닌 경우에만 Quill 편집기 자동 활성화
2026-03-07 00:28:58 +09:00
if ( ! isExpenseForm && ! isPurchaseRequestForm && ! isCertForm && ! isSealUsageForm && ! isDelegationForm && ! isBoardMinutesForm && ! isQuotationForm && ! isOfficialLetterForm && ! isLeavePromotion1stForm && ! isLeavePromotion2ndForm ) {
2026-03-04 15:14:18 +09:00
const existingBody = document . getElementById ( 'body' ) . value ;
if ( /< [ a - z ][ \s\S ] *>/ i . test ( existingBody )) {
document . getElementById ( 'useEditor' ) . checked = true ;
toggleEditor ();
}
2026-02-28 14:18:16 +09:00
}
2026-02-28 14:41:37 +09:00
// Alpine 초기화 후 기존 결재선 요약 표시
setTimeout ( updateApprovalLineSummary , 200 );
2026-03-04 14:18:54 +09:00
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:18:16 +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 updateApproval ( 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 18:53:42 +09:00
let formContent = {};
let formBody = getBodyContent ();
2026-03-04 20:07:49 +09:00
let attachmentFileIds = [];
2026-03-05 18:53:42 +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 18:53:42 +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 18:53:42 +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 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-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:03:15 +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 : [];
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-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 18:53:42 +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/{{ $approval->id }}' , {
method : 'PUT' ,
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 ) {
showToast ( data . message || '저장에 실패했습니다.' , 'error' );
return ;
}
if ( action === 'submit' ) {
const submitResponse = await fetch ( '/api/admin/approvals/{{ $approval->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' );
}
} catch ( e ) {
showToast ( '서버 오류가 발생했습니다.' , 'error' );
}
}
async function deleteApproval () {
if ( ! confirm ( '이 문서를 삭제하시겠습니까?' )) return ;
try {
const response = await fetch ( '/api/admin/approvals/{{ $approval->id }}' , {
method : 'DELETE' ,
headers : {
'X-CSRF-TOKEN' : '{{ csrf_token() }}' ,
'Accept' : 'application/json' ,
},
});
const data = await response . json ();
if ( data . success ) {
showToast ( '삭제되었습니다.' , 'success' );
setTimeout (() => location . href = '/approval-mgmt/drafts' , 500 );
} else {
showToast ( data . message || '삭제에 실패했습니다.' , 'error' );
}
} 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 = '' ;
}
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>' );
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 buildCertPreviewHtml ( 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>
< 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 >
< 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 } " 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 >
< 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; " >
위 사항을 증명합니다 .
</ 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 >
2026-03-06 09:19:32 +09:00
< p style = " font-size:14px; 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:03:15 +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:03:15 +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:03:15 +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:03:15 +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:03:15 +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:03:15 +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:03:15 +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:03:15 +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
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>(인) ㊞</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><style>body{font-family:"Pretendard","Malgun Gothic",sans-serif;padding:48px 56px;margin:0;}@media print{body{padding:40px 48px;}}</style></head><body>' + content + '</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 = '' ;
}
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 ();
}
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 ();
}
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