문서 패키지 셀프서빙
집행부가 대시보드에서 동의서 패키지를 직접 준비하고 발행하는 기능입니다.
제품 단위가 개별 동의서에서 문서 패키지로 옮겨가면서, 구 “동의서 서식 워크스페이스”(/[unionId]/vote/workspace)를 대체했습니다.
전체 흐름
워크스페이스 생성 → 문서(hwp) 업로드 → 검토 요청 → 운영팀 승인(어드민)
→ 상세 정보 입력·확정 → 내보내기 마법사(5단계) → 발행 → 관리 탭에서 추적발행하면 기존 assignment_package + vote 모델로 실제 생성됩니다. 즉 마법사는 “패키지를 만드는 준비 공간”이고, 발행 이후는 기존 패키지 관리 화면과 같은 데이터입니다.
핵심 개념 3가지
| 개념 | 설명 |
|---|---|
워크스페이스 (package_workspace) | 패키지 1개를 준비하는 공간. 발행 후 전체가 영구 잠김(수정·재발행 불가) |
문서 (package_workspace_document) | 워크스페이스 안의 동의서 슬롯. 업로드 → 운영팀 검토 → 상세 입력 → 준비 완료 파이프라인 |
내보내기 드래프트 (package_export_draft) | 마법사 5단계의 임시 저장. 워크스페이스당 1개(1:1) |
화면
| 경로 | 화면 |
|---|---|
/[unionId]/package | 만들기 / 관리 2탭 (?tab=) |
/[unionId]/package/workspace/[workspaceId] | 워크스페이스 상세 — 문서 파이프라인 + 사이드패널 |
/[unionId]/package/workspace/[workspaceId]/export | 내보내기 마법사 (?step=) |
주의: 상세 경로는 변경 금지입니다. 백엔드가 검토 승인·반려 알림톡에서
package/workspace/{workspace_id}딥링크를 보내므로 경로를 바꾸면 링크가 깨집니다.
코드: apps/dashboard/src/app/(user-only)/[unionId]/package/
API: packages/api/lib/dashboard/index.ts 의 // --- Package workspace --- 섹션 (base v1/dashboard/region/{regionId}/package/workspace)
스웨거 태그: DASHBOARD_PACKAGE_WORKSPACE
권한
전 엔드포인트가 조회까지 EDIT 권한을 요구합니다(authorized_user_id_by_region_for_edit_vote_management).
그래서 package/layout.tsx 가드는 전자문서 구간(READ)과 달리 VOTE_MANAGEMENT + EDIT 입니다. READ만 가진 사용자를 들여보내면 목록 조회부터 403이 나 빈 화면이 됩니다.
함정 (반드시 읽을 것)
이 기능을 만지기 전에 아래를 먼저 확인하세요. 전부 실제로 밟았거나 리뷰에서 잡힌 것입니다.
1. isExportable 이 두 곳에 있고 의미가 다르다
| 응답 | 포함 조건 |
|---|---|
GET /{workspaceId} (워크스페이스 상세) | 문서≥1 + 전부 ready |
GET /{workspaceId}/export-draft | 위 + 전부 fieldsPlaced |
발행은 운영팀 필드 배치가 안 끝나면 PACKAGE_DOCUMENT_FIELDS_NOT_PLACED(409)로 막힙니다.
발행 버튼 활성 판정은 반드시 export-draft 쪽 값을 써야 합니다. 상세 쪽 값으로 판정하면 버튼이 켜졌는데 409가 납니다.
2. document_status_counts 는 키가 enum 인 유일한 응답 필드다
fetcher(objectKeySnakeToCamel, packages/utils-ts/object.ts)가 응답 객체의 키를 재귀적으로 전부 camelCase로 바꿉니다. 그래서 6개 상태 중 review_pending 키만 reviewPending으로 도착합니다(나머지는 언더스코어가 없어 무사).
방치하면 counts[PackageWorkspaceDocumentStatus.REVIEW_PENDING]이 조용히 undefined가 되어 만들기 탭 상태 칩 판정이 틀어집니다. API 레이어의 normalizePackageDocumentStatusCounts가 두 키를 모두 보며 흡수합니다.
3. last_completed_step 은 되돌아간다
1단계를 다시 저장하면 서버가 basic으로 리셋합니다. 매 렌더에서 이 값으로 현재 단계를 결정하면 4단계에 있던 사용자가 1단계 저장 직후 앞으로 끌려갑니다.
진입 시 한 번만 시작 위치 계산에 쓰고, 이후 이동은 URL(?step=)이 단일 소스입니다. 되돌아갈 수 있는 범위는 로컬에 누적합니다.
4. 마법사 2단계 ↔ 3단계가 비대칭이다
- 2단계에서 전자증명서를 고르면 서버가 간편인증을 조용히 해제한다
- 3단계에서 증명서가 선택된 채 간편인증을 켜면 409
PACKAGE_EXPORT_SETTING_CONFLICT
상호 배타 UI로 처리하고, 2단계 저장 후 드래프트를 refetch해야 합니다(mutation이 getPackageExportDraft를 무효화).
5. 일괄 검토 요청은 uploaded 문서만 담아야 한다
도메인 전이가 uploaded → review_pending만 허용합니다. rejected 문서는 재업로드로 uploaded를 거쳐야 합니다.
하나라도 불가 상태가 섞이면 PACKAGE_DOCUMENT_INVALID_STATUS(409)로 전체가 롤백됩니다(all-or-nothing).
6. 재업로드는 검토요청 행을 새로 만든다
구 플로우의 re_upload_hwp(기존 행 변경)와 달리 새 DRAFT 행이 append됩니다. 그래서 문서 하나가 reviewRequests 여러 건을 갖고, 이력은 요청들을 최신순으로 펼쳐서 보여야 합니다.
반려 사유를 고를 때는 comment 유무가 아니라 action === 'reject' 로 필터해야 합니다. 안 그러면 옛 승인 메모가 “반려 사유”로 노출됩니다.
7. 일시는 KST naive datetime 이다
서버가 타임존 오프셋 없는 문자열을 주고받습니다. API 레이어가 Date를 받아 formatDateToLocalISO로 직렬화하므로 toISOString()을 쓰면 일정이 9시간 밀립니다.
변환 헬퍼는 package/_utils/dateTime.ts에 모아 뒀습니다.
또 서버가 검사하지 않는 순서 제약이 있어 프론트에서 막습니다:
- 마법사 1단계:
startedAt < deadline(서버 미검사) - 문서 상세:
resultPublishedAt >= deadline(서버 미검사 — 마감 전 공개는 진행 중 결과 유출)
8. 운영팀 재검토는 확정을 해제한다
운영팀이 재검토로 전환하면 서버가 confirmedAt을 null로 되돌립니다. ready 문서가 reviewing으로 복귀할 수 있으므로 상태는 항상 서버 응답을 신뢰해야 하고, 사이드패널이 문서 스냅샷을 들고 있으면 안 됩니다(id로 매번 다시 찾음).
9. 실시간 반영이 없다
승인·반려는 운영팀 어드민에서 일어납니다. 화면 재진입·새로고침 시 최신 상태가 조회됩니다.
상태 값
문서 PackageWorkspaceDocumentStatus
| enum | 배지 | 다음 액션 |
|---|---|---|
uploaded | 검토 필요 | 검토 요청 |
review_pending | 검토 대기 | (운영팀 착수 대기) |
reviewing | 검토 중 | (운영팀 진행 중) |
rejected | 반려됨 | 수정본 업로드 → 검토 요청 |
approved | 승인됨 · 입력 필요 | 상세 정보 입력 |
ready | 준비 완료 | (잠금 — 수정은 운영팀 문의) |
배지 색은 “누구 차례인가” 로 맞췄습니다 — warning=집행부 차례, neutral=운영팀 차례, error=반려, info=완료.
워크스페이스 PackageWorkspaceStatus: draft(준비 중) / exported(발행 완료·잠금)
마법사 단계 PackageExportStep: basic / certificate / simple_identity_verification / document_order / final_confirm
에러 코드
모두 {error_code, message} 형식이고 message는 한글 그대로 노출 가능합니다.
| HTTP | error_code | 상황 |
|---|---|---|
| 409 | PACKAGE_WORKSPACE_ALREADY_EXPORTED | 발행 완료된 워크스페이스 수정 |
| 409 | PACKAGE_DOCUMENT_INVALID_STATUS | 현재 상태에서 불가한 전환 |
| 409 | PACKAGE_DOCUMENT_NOT_APPROVED | 승인 전 문서에 상세 정보 입력 |
| 409 | PACKAGE_DOCUMENT_ALREADY_CONFIRMED | 확정된 문서 수정 |
| 400 | PACKAGE_DOCUMENT_DETAILS_INVALID | 제목 공백 / 일정 역전 |
| 409 | PACKAGE_EXPORT_SETTING_CONFLICT | 전자증명서 + 간편인증 동시 설정 |
| 409 | PACKAGE_EXPORT_NOT_READY | 준비 안 된 문서 포함 / 문서 순서 부분 목록 |
| 409 | PACKAGE_DOCUMENT_FIELDS_NOT_PLACED | 운영팀 필드 배치 미완료 문서 포함 발행 |
| 403 | PACKAGE_REGION_MISMATCH | region과 워크스페이스 불일치 |
PACKAGE_REGION_MISMATCH는 403 입니다(핸드오프 문서에는 404로 묶여 있었습니다). 미존재·soft-delete 리소스는 404REPOSITORY_NOT_FOUND입니다.
발행 이후
발행하면 문서마다 vote가 생성되고 전체 명부(토지등소유자 전체) 기준으로 대상자가 등록됩니다.
간편인증·전자증명서 assignment는 서버가 순서상 맨 앞에 배치하므로, 마법사 4단계는 동의서끼리의 순서만 보냅니다.
발송(문자·알림톡)은 발행에 포함되지 않습니다. 패키지 상세의 문자 발송 CTA로 이어집니다.
모든 문서 미제출자에게 문자는 vote_participations 필터를 프리셋하는데, 백엔드가 이 필터를 AND로 합성하므로(and_(*vote_conditions)) “하나라도 제출한 사람은 빠지고 전부 미제출인 소유자만” 잡힙니다. 라벨이 그 의미로 되어 있습니다.
알려진 미완성
- 앱 화면 미리보기: 승인 최종본 PDF만 렌더하고, 입력한 상세 정보 오버레이는 미구현
- 문서 이름 변경: 백엔드 도메인에
rename()이 있으나 HTTP 엔드포인트가 없음 - 관측: 발행(
exportPackage)은 되돌릴 수 없는 액션인데Logger.addAction계측이 없음 - 드로어 포커스 트랩: 사이드패널에 dialog 시맨틱·ESC·포커스 이동은 있으나 Tab 순환 트랩은 없음(공용 유틸 부재)
- 화면 중복:
/[unionId]/vote(전자 문서)와 관리 탭이 같은 두 섹션을 렌더