전자결재는 세 가지가 맞물립니다.
| 층 | 무엇 | 예제에서 | 서버로 옮길 때 |
|---|---|---|---|
| 양식 | 결재 문서 HTML 에 필드 표시(id="af-…" · data-af-*)를 단 것 | js/af-forms.js — 기안문 · 지출결의서 · 휴가신청서 | 그대로 (문자열이든 서버에서 내려주든) |
| 필드 엔진 | 표시를 읽어 값을 꺼내고(extract), 수식 칸을 채우고(recalc), 검사하고(validate), 행을 늘리는(addRow) 플러그인 | dist/plugins/approvalform.min.js | 그대로 |
| 흐름·저장·집계 | 결재선 상태 기계, 문서 저장, 예산·휴가 집계 | js/af-flow.js · js/af-store.js(localStorage) · js/af-calc.js | af-store.js 의 함수만 fetch 로 바꾼다 |
핵심 원칙 하나: 에디터는 HTML 을 만들고, 집계는 값(fields)만 본다. 결재요청 시점에 { html, fields } 한 쌍을 저장하고, 그 뒤로 HTML 에는 서명만 덧붙습니다. 집계 코드는 HTML 을 파싱하지 않습니다.
작성(write.html) 보기(view.html) 관리자(admin.html)
양식 고르기 → 결재선 → 본문 편집 읽기 전용 에디터 + 승인/반려 예산·휴가 집계 · 설정
loadApprovalForm → 수식 자동 계산 stampSigns → 서명란에 이름·날짜 AFCalc.expense / leave
validateApprovalForm → 결재요청 AFFlow.approve / reject (fields 만 센다)
collect(): { html: getSafeHTML(), fields: getApprovalFields() } → AFStore.save
demo/approval/
├── index.html 결재함 — 기안함·결재함·반려함·완료함 (에디터 번들을 읽지 않는다)
├── write.html 작성·수정 — 양식 카드 → 결재선 → 에디터 → 결재요청
├── view.html 보기 — 읽기 전용 에디터, 내 차례면 승인/반려
├── admin.html 관리자 — 예산 편성·집행, 휴가 부여·사용, 설정(계정과목·공휴일·부여일수)
└── js/
├── af-forms.js 양식 3종 HTML + 서명란(syncSignTable) + 기안자 미리 채우기(prefill)
├── af-flow.js 결재 흐름 상태 기계 (draft → in_progress → approved / rejected → draft)
├── af-store.js 저장소 (localStorage) — 문서·예산·부여·설정·현재 사용자
├── af-calc.js 집계 — expense(계정과목별) · leave(신청자별)
└── af-ui.js 공통 머리글·사용자 전환·서식 도우미
에디터 쪽
dist/webeditor.min.js 코어 (플러그인 'format' 'table' 'insert' 사용)
dist/plugins/approvalform.min.js 결재 양식 필드 엔진 (opt-in)
페이지마다 읽는 순서: webeditor.min.js → plugins/approvalform.min.js → js/af-*.js. 결재함(index.html)은 에디터를 쓰지 않으므로 af-*.js 만 읽습니다.
양식은 보통 HTML 입니다. 값을 꺼낼 칸에만 표시를 답니다.
| 표시 | 뜻 | 예 |
|---|---|---|
id="af-<이름>" | 칸의 손잡이. DOM API 로 값을 넣고 뺄 때 쓴다. 문서 안에서 유일 | id="af-dept" |
data-af-field="<키>" | 값 객체(fields)의 키. 반복 행은 <행>[].<열> | data-af-field="dept" · data-af-field="items[].supply" |
data-af-type | text(기본) · number · date · check · select — 정규화·검사 규칙이 달라진다 | data-af-type="date" |
data-af-required | 비어 있으면 검사 실패 | |
data-af-options="a,b,c" | select 의 선택지. 목록 밖 값은 검사 실패 | data-af-options="연차,반차,병가" |
data-af-formula="…" | 계산 칸. contenteditable="false" 와 함께 쓴다(사람이 못 고침, 회색) | data-af-formula="SUM(items[].total)" |
<tr id="af-<행>-<번호>" data-af-row="<행>"> | 반복 행. 안의 칸은 id="af-<행>-<번호>-<열>" | <tr id="af-items-1" data-af-row="items"> |
<div class="af-doc"><h2>휴가신청서</h2>
<table>
<tr><td class="h">성명</td><td id="af-name" data-af-field="name" data-af-required> </td></tr>
<tr><td class="h">종류</td><td id="af-type" data-af-field="leaveType" data-af-type="select" data-af-options="연차,반차,병가" data-af-required>연차</td></tr>
<tr><td class="h">시작</td><td id="af-from" data-af-field="from" data-af-type="date" data-af-required> </td></tr>
<tr><td class="h">종료</td><td id="af-to" data-af-field="to" data-af-type="date" data-af-required> </td></tr>
<tr><td class="h">일수</td><td id="af-days" data-af-field="days" data-af-type="number"
data-af-formula="WORKDAYS(from, to) * IF(leaveType == '반차', 0.5, 1)" contenteditable="false">0</td></tr>
</table>
</div>
getApprovalFields() 는 이 문서에서 { name, leaveType, from, to, days } 를 돌려줍니다. 날짜는 YYYY-MM-DD, 숫자는 숫자로 정규화됩니다.
<table>
<thead><tr><th>계정과목</th><th>적요</th><th>공급가액</th><th>부가세</th><th>합계</th></tr></thead>
<tbody>
<tr id="af-items-1" data-af-row="items">
<td id="af-items-1-account" data-af-field="items[].account" data-af-type="select" data-af-options="여비교통비,접대비" data-af-required>여비교통비</td>
<td id="af-items-1-desc" data-af-field="items[].desc"> </td>
<td id="af-items-1-supply" data-af-field="items[].supply" data-af-type="number" data-af-required>0</td>
<td id="af-items-1-vat" data-af-field="items[].vat" data-af-type="number" data-af-formula="ROUND(items[].supply * 0.1, 0)" contenteditable="false"></td>
<td id="af-items-1-total" data-af-field="items[].total" data-af-type="number" data-af-formula="items[].supply + items[].vat" contenteditable="false"></td>
</tr>
</tbody>
<tfoot><tr><td colspan="4">합계</td>
<td id="af-sumTotal" data-af-field="sumTotal" data-af-type="number" data-af-formula="SUM(items[].total)" contenteditable="false"></td></tr></tfoot>
</table>
editor.addApprovalRow('items') 가 마지막 줄을 복제해 번호를 올리고(af-items-2-…) 필드 칸을 비웁니다. 툴바의 [양식 행 추가]/[양식 행 삭제] 버튼(afAddRow · afRemoveRow)은 커서가 있는 행 묶음에 같은 일을 합니다.items[].supply * 0.1)은 자기 행의 값을 봅니다. 행 밖 수식(SUM(items[].total))은 열 전체를 봅니다.items: [{ account, desc, supply, vat, total }, …] 로 나옵니다. 사용자가 행을 지워도 배열이 따라 줄어듭니다.periods[]: from · to · days). 연속되지 않은 날짜는 줄을 나눠 적고, days = SUM(periods[].days) 로 합칩니다.eval 을 쓰지 않는 자체 파서입니다. 문서가 결재선을 타고 돌아다니므로 스크립트가 실행될 여지를 두지 않습니다.
| 지원 | 예 |
|---|---|
산술 + - * / %, 비교 == != < <= > >=, 논리 && || !, 괄호, 숫자, '문자열' | items[].supply * 0.1 · leaveType == '반차' |
| 필드 참조 | dept · items[].supply(행 안: 자기 행 / 행 밖: 열 전체) |
SUM COUNT MIN MAX ABS ROUND(x, 자릿수) | ROUND(SUM(items[].supply) * 0.1, 0) |
IF(조건, 참, 거짓) | IF(days > 3, '팀장 승인', '') |
DAYS(from, to) 달력일 · WORKDAYS(from, to) 영업일(주말·공휴일 제외) | WORKDAYS(periods[].from, periods[].to) |
approvalForm: { holidays: ['2026-10-05', …], weekend: [0, 6] }. 예제는 관리자 › 설정의 공휴일 목록을 그대로 넘깁니다.af.errFormula 로 잡힙니다.| type | 받는 입력 | fields 값 | 검사 |
|---|---|---|---|
number | 1,234 · ₩1,234 · 1234원 · -3.5 | 숫자 | 숫자가 아니면 af.errNumber |
date | 2026-09-10 · 2026.9.10 · 2026/09/10 · 2026년 9월 10일 · 20260910 · 9/10 · 09-10(올해) | '2026-09-10' | 못 읽으면 af.errDate. 2월 30일 같은 날짜도 오류 |
check | ☑ ✓ ✔ √ V v ■ ● ◉ 가 있으면 참 | true/false | |
select | 선택지 중 하나 | 문자열 | 목록 밖이면 af.errOption |
text | 아무거나 | 문자열(앞뒤 공백 제거, 는 빈 칸) |
빈 칸은 null 입니다. data-af-required 인 칸이 null·''·false 면 af.errRequired.
예제의 서명란은 결재선의 한 단계가 칸 하나입니다. 칸의 손잡이는 역할 이름이 아니라 사람 id(af-sign-<userId>)라, 팀장이 둘이거나 순서가 바뀌어도 뷰어가 정확한 칸에 찍습니다.
<table class="sign"><tr><td class="h" rowspan="2">결재</td><th>기안<br><small>김담당</small></th><th>팀장<br><small>이팀장</small></th></tr>
<tr><td class="s" id="af-sign-author" data-af-field="sign.author"> </td><td class="s" id="af-sign-u201" data-af-field="sign.u201"> </td></tr></table>
AFForms.syncSignTable(editor, doc) 가 본문의 서명 표를 통째로 다시 그립니다. 이미 찍힌 승인 스탬프는 같은 사람 칸이 남아 있으면 유지합니다.<p id="af-reject-note"> 로 반려자·시각·사유를 본문에 남깁니다(인쇄·PDF 에 실림). 다시 결재요청하면 지웁니다.양식 HTML 안에 <style> 을 넣지 않습니다(저장 시 정제가 걷어냅니다). 화면에서 editor.setContentCSS(AFForms.CSS) 로 싣고, 열람 화면·인쇄도 같은 CSS 를 씁니다. 예제 CSS 는 .af-doc 아래에만 걸립니다.
js/af-forms.js 의 FORMS 에 항목을 더합니다. overtime: {
id: 'overtime', name: '초과근무 신청서', desc: '날짜·시간 → 시간 수 자동', icon: '⏰', signs: ['담당', '팀장'],
html: function () {
return '<div class="af-doc"><h2>초과근무 신청서</h2>' + signTable() +
'<table>' +
'<tr><td class="h">성명</td><td id="af-name" data-af-field="name" data-af-required> </td>' +
'<td class="h">날짜</td><td id="af-date" data-af-field="date" data-af-type="date" data-af-required> </td></tr>' +
'<tr><td class="h">시작</td><td id="af-start" data-af-field="start" data-af-type="number" data-af-required>18</td>' +
'<td class="h">종료</td><td id="af-end" data-af-field="end" data-af-type="number" data-af-required>21</td></tr>' +
'<tr><td class="h">시간</td><td id="af-hours" data-af-field="hours" data-af-type="number" data-af-formula="end - start" contenteditable="false" colspan="3">0</td></tr>' +
'<tr><td class="h">사유</td><td id="af-reason" data-af-field="reason" colspan="3" data-af-required> </td></tr>' +
'</table></div>';
},
},
signTable() 을 제목 아래에 넣습니다 — 결재선이 정해지면 syncSignTable 이 채웁니다.prefill() 에 set('af-name', user.name) 같은 줄을 더합니다(같은 id 를 쓰면 이미 됩니다).js/af-flow.js 의 defaultLine() 에서 formId 별로 순서를 정합니다.js/af-calc.js 에 함수를 더하고 관리자 화면에 탭을 붙입니다(§5).양식 카드는 AFForms.list() 로 자동으로 늘어납니다.
editor = new WebEditor('#editor', {
height: 640, menubar: false,
toolbar: [['undo', 'redo', '|', 'bold', 'underline', 'alignLeft', 'alignCenter', 'alignRight', '|', 'afAddRow', 'afRemoveRow', '|', 'printContent']],
plugins: ['format', 'table', 'insert', 'approvalform'],
approvalForm: { debounce: 150, holidays: settings.holidays },
/* 표 구조를 바꾸는 명령은 막는다 — 행·칸이 사라지면 필드를 못 꺼낸다 */
onBeforeCommand: function (cmd) {
if (/^(deleteTable|deleteRow|deleteColumn|mergeCells|splitCell|insertTable)$/.test(cmd)) { notice('행은 [양식 행 추가]/[양식 행 삭제]로 다루세요.', true); return false; }
},
pastePlainText: true,
guard: false, privacy: false, profanity: { words: [] }, // 금칙어 · 개인정보 검사 끔 — 내부 문서
});
editor.setContentCSS(AFForms.CSS);
editor.on('approvalform:change', function (d) { /* d.fields · d.changed · d.errors — 잔여 표시 등 */ });
editor.loadApprovalForm(AFForms.get('leave').html()); // 양식 싣기 + 감지 시작
AFForms.prefill(editor, 'leave', me); // 기안자·부서·날짜 미리 채우기 (DOM API)
AFForms.syncSignTable(editor, doc); // 서명란 = 기안자 + 결재선
menubar:false · 툴바 최소: 양식은 서식을 자유롭게 바꾸는 문서가 아닙니다.pastePlainText:true: 다른 문서에서 표를 통째로 붙여 넣어 필드 표시가 깨지는 것을 막습니다.guard:false · privacy:false · profanity:{ words: [] }: 금칙어 · 개인정보 검사를 끕니다. 결재 문서는 내부 문서라 이름 · 연락처 · 계좌번호 · 금액을 그대로 보고하는 일이 많습니다. 셋을 함께 주어야 전역 금칙어 목록(window.WebEditorBannedWords)이 있는 화면에 붙여도 걸리지 않습니다. 검사가 필요한 회사는 이 줄을 빼고 결재요청 전에 editor.guardBeforeSubmit() 을 부릅니다.onBeforeCommand 로 표 구조 명령을 막습니다. 행은 반드시 플러그인의 addRow/removeRow 로.editor.getAPIModelById('af-dept').setText(...)). editor.body 를 직접 만지면 감지가 어긋납니다. 여러 칸을 한꺼번에 채울 때는 editor.silently(fn) 안에서 하고 마지막에 editor.render().function collect() {
doc.html = editor.getSafeHTML(); // 정제된 HTML (스크립트·이벤트 속성 제거)
doc.fields = editor.getApprovalFields(); // 값 객체
return doc;
}
function submitDoc() {
var v = editor.validateApprovalForm(); // { ok, errors:[{ id, field, reason, detail }] }
if (!v.ok) { /* errors 를 보여 준다 — reason 은 i18n 키(af.errRequired 등), editor.t(reason) 로 문구 */ return; }
// 금칙어 · 개인정보 검사는 하지 않는다(guard:false — 내부 문서). 검사가 필요하면 여기서 editor.guardBeforeSubmit()
collect(); AFFlow.submit(doc); AFStore.save(doc);
location.href = 'view.html?id=' + doc.id;
}
검사에 걸린 칸은 .we-af-invalid 로 붉게 표시됩니다. validateApprovalForm() 을 다시 부르면 고쳐진 칸의 표시는 사라집니다.
approvalform:change 마다 AFCalc.remainFor(me.id, year) 로 올해 잔여를 계산해 "이 신청 뒤 N일" 을 보여 줍니다. 규정은 회사마다 달라 초과해도 막지는 않습니다. 기간 줄마다 날짜를 다 적었는데 0일이면(주말·공휴일만 든 기간, 종료일이 시작일보다 앞섬) 이유를 표 아래에 알려 줍니다.
draft ──결재요청──► in_progress ──마지막 승인──► approved
▲ │ 반려(현재 결재자)
└── 수정·다시 결재요청 ◄──┴──────────────────► rejected
| 함수 | 하는 일 |
|---|---|
AFFlow.newDoc(formId, author) | 빈 문서. 결재선은 defaultLine() — 기안자 부서 팀장 → 부서장 → (대표) |
AFFlow.submit(doc) | 결재선 전원 pending, status=in_progress(결재선이 비면 바로 approved). fields 는 이때 확정 |
AFFlow.currentApprover(doc) / canAct(doc, user) | 내 차례인가 |
AFFlow.approve(doc, actor) | 내 칸 approved + 시각. 남은 pending 이 없으면 approved |
AFFlow.reject(doc, actor, reason) | 내 칸 rejected + 사유, status=rejected |
AFFlow.canEdit(doc, user) | 기안자이고 draft/rejected 일 때만 |
AFFlow.reopen(doc) | 반려 문서를 draft 로 — 결재선 상태 초기화 |
순차 결재만 구현돼 있습니다. 병렬·전결·대결은 next(doc, actor, action) 한 곳에서 갈라 넣도록 자리를 두었습니다. 결재자는 본문을 고치지 못합니다(고치려면 반려 → 기안자가 수정).
editor.loadApprovalForm(doc.html, { readOnly: true });
function stampSigns() {
editor.silently(function () {
set('author', doc.author.name, doc.submittedAt); // af-sign-author
doc.line.forEach(function (s) {
if (s.state === 'approved') set(s.id, s.name, s.at); // af-sign-<userId>
if (s.state === 'rejected') set(s.id, '반려', s.at);
});
AFForms.syncRejectNote(editor, doc);
editor.render();
});
}
function approve() { AFFlow.approve(doc, me); stampSigns(); doc.html = editor.getSafeHTML(); AFStore.save(doc); }
승인·반려 뒤 html 만 다시 저장합니다. fields 는 결재요청 때 값 그대로입니다.
{ id, formId, formVersion, title,
author: { id, name, dept },
status: 'draft' | 'in_progress' | 'approved' | 'rejected',
line: [{ id, name, role, state: 'pending'|'approved'|'rejected', at?, reason? }],
html, // 결재요청 시점 본문 + 이후 서명
fields, // 결재요청 시점 값 객체 — 집계는 이것만 본다
submittedAt?, approvedAt?, createdAt, updatedAt }
예제 저장소(js/af-store.js)는 localStorage 입니다. 컬렉션은 docs · budgets(연도|부서|계정과목 → 금액) · grants(연도|사용자 → 부여일수) · settings(계정과목·공휴일·부여 기본값·연도). 서버로 옮길 때는 이 파일의 함수(list · get · save · remove · budget · grant · settings)만 fetch 로 바꿉니다 — 화면·흐름·집계는 이 API 만 봅니다.
js/af-calc.js)| 함수 | 결과 | 규칙 |
|---|---|---|
AFCalc.expense({ year, dept }) | 계정과목별 { budget, spent, pending, remain, rate, docs, months } | 결재 완료 = 집행, 진행 중 = 예정. 반려·기안 중은 세지 않는다. 예산은 AFStore.budget(year, dept, account)(부서 값이 없으면 *) |
AFCalc.leave({ year, dept }) | 신청자별 { granted, used, pending, remain, sick, family, official, docs } | 연차·반차만 잔여에서 뺀다. 병가·경조·공가는 세되 빼지 않는다. 연도는 첫 휴가 기간의 시작일 |
AFCalc.remainFor(userId, year) | 그 사람의 { granted, used, pending, remain } | 작성 화면의 잔여 표시 |
잔여는 "부여 − Σ승인된 연차·반차" 로 매번 다시 셉니다. 문서에 누적하지 않으므로 반려·삭제해도 숫자가 맞습니다.
예산 편성(연도·부서·계정과목별 입력 → 집행·예정·잔여·집행률), 휴가 부여(사용자·연도별 일수), 설정(계정과목 목록, 공휴일 목록, 부여 기본값). 설정의 공휴일은 작성 화면의 approvalForm.holidays 로 넘어가 WORKDAYS 가 씁니다.
| 호출 | 뜻 |
|---|---|
editor.loadApprovalForm(html, { readOnly }) | 양식 HTML 을 싣고 감지 시작. readOnly:true 면 편집 잠금 |
editor.attachApprovalForm(opts) / detachApprovalForm() | 이미 실린 본문에 감지만 시작/중지 |
editor.getApprovalFields() | 값 객체 (정규화됨) |
editor.validateApprovalForm() | { ok, errors:[{ id, field, reason, detail }] } — 필수·형식·선택지·중복 id·수식 오류 |
editor.recalcApprovalForm() | 수식 전부 다시 계산 |
editor.addApprovalRow(rowName?) / removeApprovalRow(rowName?) | 반복 행 늘리기/줄이기. 이름을 주면 커서 위치와 무관 |
editor.on('approvalform:change', ({ fields, changed, errors }) => …) | 값이 바뀌고 수식이 다시 계산될 때마다 |
옵션 approvalForm: { debounce, weekend, holidays } | 감지 지연(ms), 주말 요일(0=일), 공휴일 목록 |
툴바 명령 afAddRow · afRemoveRow | 커서가 있는 행 묶음에 행 추가/삭제 |
오류 reason 은 i18n 키입니다: af.errRequired · af.errNumber · af.errDate · af.errOption · af.errDupId · af.errFormula. 화면 문구는 editor.t(reason).
af-store.js 의 함수를 REST 로 바꿉니다. 문서 하나가 { html, fields, line, status, … } 한 덩이이므로 POST /docs · PUT /docs/:id · GET /docs?box=todo 정도면 됩니다. fields 는 JSON 컬럼(또는 별도 표)에 두어 집계 쿼리가 HTML 을 보지 않게 합니다.canAct · canEdit 판정을 서버에서도 다시 합니다(화면 판정만 믿지 않음).getSafeHTML() 로 보내지만, 서버에서 저장 전에 다시 정제합니다(XSS 필터 병행 구성은 보안_XSS필터링_요구사항_검토.md §3). 서명 칸 등 id="af-…" · data-af-* · class="af-doc …" 는 허용 목록에 넣어야 합니다.formVersion 이 있습니다. 양식 HTML 을 바꾸면 버전을 올리고, 옛 문서는 저장된 html 그대로 보여 줍니다(양식을 다시 그리지 않음). 집계 코드는 옛 fields 모양도 읽게 둡니다(휴가 from·to ↔ periods[] 처럼).approvalForm.holidays 로 넘깁니다.printContent 그대로. 서명·반려 사유가 본문에 있으므로 별도 처리가 없습니다.| 증상 | 원인 | 조치 |
|---|---|---|
| 값을 넣었는데 수식 칸이 그대로다 | editor.body 를 직접 고쳤거나, silently 안에서 넣고 render() 를 안 불렀다 | DOM API(getAPIModelById(...).setText)로 넣고 editor.render() |
| 행을 추가했는데 그 행의 수식이 빈 채다 | 행 id 가 af-<행>-<번호> 규약이 아니거나 칸 id 가 …-<번호>-<열> 이 아니다 | 규약대로 id 를 붙인다. 번호는 1부터 |
| 휴가 일수가 0 | 기간이 주말·공휴일만 이거나(추석 연휴 등), 종료일이 시작일보다 앞서거나, 날짜 형식을 못 읽었다 | 작성 화면의 표 아래 안내를 본다. 공휴일은 관리자 › 설정 |
검사에서 af.errDupId | 양식을 복사·붙여넣기해 같은 id 가 둘이 됐다 | pastePlainText:true · 표 구조 명령 차단(§3) |
| 서명이 엉뚱한 칸에 찍힌다 | 서명 칸 id 가 역할 이름(af-sign-팀장)인 옛 양식 | 사람 id(af-sign-<userId>)로. 뷰어는 역할 이름으로 폴백한다 |
| 집계가 옛 문서를 빠뜨린다 | fields 모양이 바뀌었다 | 집계 코드에 옛 모양 폴백(AFForms.leavePeriods 참고) |
| 표를 지우거나 합쳤더니 값이 안 나온다 | 필드 칸이 사라졌다 | 표 구조 명령을 막는다(onBeforeCommand). 이미 깨진 문서는 양식을 다시 싣는다 |
tests/test-approvalform.html — 필드 엔진(정규화·수식·반복 행·검사) 67건tests/test-approval-flow.html — 결재 흐름·저장·집계 45건 (불연속 휴가 기간 문서 포함)양식 규약이나 집계 규칙을 바꾸면 두 페이지를 먼저 돌립니다.