전자결재 구성 매뉴얼 — Dee Editor 로 결재 양식·결재 흐름 만들기

대상: Dee Editor 의 결재 양식 필드 엔진(approvalform 확장팩) 으로 전자결재 화면을 만들려는 개발자
예제: 배포본 demo/approval/ (결재함 · 작성 · 보기 · 관리자) — 이 문서는 그 예제가 어떻게 구성돼 있고, 같은 것을 어떻게 만드는지 를 설명합니다
기준: Dee Editor v2.6.6.1 · 2026-09-16 · 함께 볼 것: 매뉴얼 §9-H(플러그인 API)

0. 한눈에 보기

전자결재는 세 가지가 맞물립니다.

층무엇예제에서서버로 옮길 때
양식결재 문서 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.jsaf-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

1. 파일 구성

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 만 읽습니다.

2. 양식 만들기 — 필드 규약

양식은 보통 HTML 입니다. 값을 꺼낼 칸에만 표시를 답니다.

표시뜻예
id="af-<이름>"칸의 손잡이. DOM API 로 값을 넣고 뺄 때 쓴다. 문서 안에서 유일id="af-dept"
data-af-field="<키>"값 객체(fields)의 키. 반복 행은 <행>[].<열>data-af-field="dept" · data-af-field="items[].supply"
data-af-typetext(기본) · 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">

2.1 가장 작은 양식

<div class="af-doc"><h2>휴가신청서</h2>
  <table>
    <tr><td class="h">성명</td><td id="af-name" data-af-field="name" data-af-required>&nbsp;</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>&nbsp;</td></tr>
    <tr><td class="h">종료</td><td id="af-to"   data-af-field="to"   data-af-type="date" data-af-required>&nbsp;</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, 숫자는 숫자로 정규화됩니다.

2.2 반복 행 — 내역·기간처럼 줄이 늘어나는 것

<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">&nbsp;</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>

2.3 수식

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)

2.4 정규화 규칙 (값이 fields 에 들어가는 모양)

type받는 입력fields 값검사
number1,234 · ₩1,234 · 1234원 · -3.5숫자숫자가 아니면 af.errNumber
date2026-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아무거나문자열(앞뒤 공백 제거, &nbsp; 는 빈 칸)

빈 칸은 null 입니다. data-af-required 인 칸이 null·''·false 면 af.errRequired.

2.5 서명란 — 결재선을 따라가는 표

예제의 서명란은 결재선의 한 단계가 칸 하나입니다. 칸의 손잡이는 역할 이름이 아니라 사람 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">&nbsp;</td><td class="s" id="af-sign-u201" data-af-field="sign.u201">&nbsp;</td></tr></table>

2.6 양식 CSS

양식 HTML 안에 <style> 을 넣지 않습니다(저장 시 정제가 걷어냅니다). 화면에서 editor.setContentCSS(AFForms.CSS) 로 싣고, 열람 화면·인쇄도 같은 CSS 를 씁니다. 예제 CSS 는 .af-doc 아래에만 걸립니다.

2.7 새 양식 추가하기

   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>&nbsp;</td>' +
         '<td class="h">날짜</td><td id="af-date" data-af-field="date" data-af-type="date" data-af-required>&nbsp;</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>&nbsp;</td></tr>' +
         '</table></div>';
     },
   },

양식 카드는 AFForms.list() 로 자동으로 늘어납니다.

3. 작성 화면 — 에디터 붙이기

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);                     // 서명란 = 기안자 + 결재선

3.1 결재요청

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() 을 다시 부르면 고쳐진 칸의 표시는 사라집니다.

3.2 휴가신청서의 잔여 표시·기간 점검 (예제)

approvalform:change 마다 AFCalc.remainFor(me.id, year) 로 올해 잔여를 계산해 "이 신청 뒤 N일" 을 보여 줍니다. 규정은 회사마다 달라 초과해도 막지는 않습니다. 기간 줄마다 날짜를 다 적었는데 0일이면(주말·공휴일만 든 기간, 종료일이 시작일보다 앞섬) 이유를 표 아래에 알려 줍니다.

4. 결재 흐름 — 상태 기계

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) 한 곳에서 갈라 넣도록 자리를 두었습니다. 결재자는 본문을 고치지 못합니다(고치려면 반려 → 기안자가 수정).

4.1 보기 화면 — 서명 찍기

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 는 결재요청 때 값 그대로입니다.

5. 저장과 집계

5.1 저장 단위

{ 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 만 봅니다.

5.2 집계 (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 }작성 화면의 잔여 표시

잔여는 "부여 − Σ승인된 연차·반차" 로 매번 다시 셉니다. 문서에 누적하지 않으므로 반려·삭제해도 숫자가 맞습니다.

5.3 관리자 화면

예산 편성(연도·부서·계정과목별 입력 → 집행·예정·잔여·집행률), 휴가 부여(사용자·연도별 일수), 설정(계정과목 목록, 공휴일 목록, 부여 기본값). 설정의 공휴일은 작성 화면의 approvalForm.holidays 로 넘어가 WORKDAYS 가 씁니다.

6. 플러그인 API 요약

호출뜻
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).

7. 서버로 옮길 때

8. 자주 걸리는 것

증상원인조치
값을 넣었는데 수식 칸이 그대로다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). 이미 깨진 문서는 양식을 다시 싣는다

9. 시험

양식 규약이나 집계 규칙을 바꾸면 두 페이지를 먼저 돌립니다.