diff --git a/apps/_template/tsconfig.json b/apps/_template/tsconfig.json index dab9b0b8e..e6c49fbe4 100644 --- a/apps/_template/tsconfig.json +++ b/apps/_template/tsconfig.json @@ -19,11 +19,15 @@ } ], "paths": { - "@/*": ["./src/*"], - "@common/components": ["../../packages/common/components/dist/"], - "@common/network": ["../../packages/common/network/"] + "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "../../packages/common/types/**/*.d.ts" + ], + "exclude": ["node_modules", ".next", "dist", "coverage", "storybook-static", "public"] } diff --git a/apps/as/app/auth/login/page.tsx b/apps/as/app/(auth)/auth/login/page.tsx similarity index 100% rename from apps/as/app/auth/login/page.tsx rename to apps/as/app/(auth)/auth/login/page.tsx diff --git a/apps/as/app/(auth)/layout.tsx b/apps/as/app/(auth)/layout.tsx new file mode 100644 index 000000000..09d5b93ec --- /dev/null +++ b/apps/as/app/(auth)/layout.tsx @@ -0,0 +1,13 @@ +import "@/app/styles/globals.css"; + +export const metadata = { + title: "디지털 행정지원 시스템", +}; + +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/as/app/(with-base-layout)/approval-line-management/page.tsx b/apps/as/app/(main)/(with-base-layout)/approval-line-management/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/approval-line-management/page.tsx rename to apps/as/app/(main)/(with-base-layout)/approval-line-management/page.tsx diff --git a/apps/as/app/(with-base-layout)/checklist-management/page.tsx b/apps/as/app/(main)/(with-base-layout)/checklist-management/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/checklist-management/page.tsx rename to apps/as/app/(main)/(with-base-layout)/checklist-management/page.tsx diff --git a/apps/as/app/(with-base-layout)/code-management/page.tsx b/apps/as/app/(main)/(with-base-layout)/code-management/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/code-management/page.tsx rename to apps/as/app/(main)/(with-base-layout)/code-management/page.tsx diff --git a/apps/as/app/(main)/(with-base-layout)/dashboard/page.tsx b/apps/as/app/(main)/(with-base-layout)/dashboard/page.tsx new file mode 100644 index 000000000..1e20f5079 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/dashboard/page.tsx @@ -0,0 +1,3 @@ +import { DashboardPage } from "@/pages/dashboard/ui/dashboard-page"; + +export default DashboardPage; diff --git a/apps/as/app/(main)/(with-base-layout)/design-cnstn/[id]/edit/page.tsx b/apps/as/app/(main)/(with-base-layout)/design-cnstn/[id]/edit/page.tsx new file mode 100644 index 000000000..e5df52e22 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/design-cnstn/[id]/edit/page.tsx @@ -0,0 +1,10 @@ +import { DesignEditPage } from "@/pages/design-cnstn"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function Page({ params }: PageProps) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(main)/(with-base-layout)/design-cnstn/[id]/page.tsx b/apps/as/app/(main)/(with-base-layout)/design-cnstn/[id]/page.tsx new file mode 100644 index 000000000..f603f8e39 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/design-cnstn/[id]/page.tsx @@ -0,0 +1,10 @@ +import { DesignDetailGate } from "@/pages/design-cnstn"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function Page({ params }: PageProps) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(main)/(with-base-layout)/design-cnstn/page.tsx b/apps/as/app/(main)/(with-base-layout)/design-cnstn/page.tsx new file mode 100644 index 000000000..cced4a877 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/design-cnstn/page.tsx @@ -0,0 +1,3 @@ +import { DesignListPage } from "@/pages/design-cnstn"; + +export default DesignListPage; diff --git a/apps/as/app/(main)/(with-base-layout)/design-cnstn/regist/page.tsx b/apps/as/app/(main)/(with-base-layout)/design-cnstn/regist/page.tsx new file mode 100644 index 000000000..4b57da09a --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/design-cnstn/regist/page.tsx @@ -0,0 +1,27 @@ +import { DesignRegistPage } from "@/pages/design-cnstn"; +import { parseTaskSeComCdFromApi } from "@/pages/design-cnstn/lib/design-cnstn-regist-schema"; + +interface PageProps { + searchParams: Promise<{ + taskSeComCd?: string; + admdstCd?: string; + sido?: string; + gugun?: string; + }>; +} + +export default async function Page({ searchParams }: PageProps) { + const params = await searchParams; + const taskSeComCd = parseTaskSeComCdFromApi(params.taskSeComCd); + + return ( + + ); +} diff --git a/apps/as/app/(with-base-layout)/esntl-atch-file-mng/page.tsx b/apps/as/app/(main)/(with-base-layout)/esntl-atch-file-mng/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/esntl-atch-file-mng/page.tsx rename to apps/as/app/(main)/(with-base-layout)/esntl-atch-file-mng/page.tsx diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx b/apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx rename to apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx b/apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx similarity index 60% rename from apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx rename to apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx index ec9a3911e..0d0df0607 100644 --- a/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx +++ b/apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx @@ -1,4 +1,4 @@ -import { FnrsViewPage } from "@/pages/fnrs-cnsltn"; +import { FnrsDetailGate } from "@/pages/fnrs-cnsltn"; interface PageProps { params: Promise<{ id: string }>; @@ -6,5 +6,5 @@ interface PageProps { export default async function Page({ params }: PageProps) { const { id } = await params; - return ; + return ; } diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/page.tsx b/apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/fnrs-cnsltn/page.tsx rename to apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/page.tsx diff --git a/apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/regist/page.tsx b/apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/regist/page.tsx new file mode 100644 index 000000000..6af4150ed --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/fnrs-cnsltn/regist/page.tsx @@ -0,0 +1,27 @@ +import { FnrsRegistPage } from "@/pages/fnrs-cnsltn"; +import { parseTaskSeComCdFromApi } from "@/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema"; + +interface PageProps { + searchParams: Promise<{ + taskSeComCd?: string; + admdstCd?: string; + sido?: string; + gugun?: string; + }>; +} + +export default async function Page({ searchParams }: PageProps) { + const params = await searchParams; + const taskSeComCd = parseTaskSeComCdFromApi(params.taskSeComCd); + + return ( + + ); +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/[id]/edit/page.tsx b/apps/as/app/(main)/(with-base-layout)/instl-sncn/[id]/edit/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/instl-sncn/[id]/edit/page.tsx rename to apps/as/app/(main)/(with-base-layout)/instl-sncn/[id]/edit/page.tsx diff --git a/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx b/apps/as/app/(main)/(with-base-layout)/instl-sncn/[id]/page.tsx similarity index 56% rename from apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx rename to apps/as/app/(main)/(with-base-layout)/instl-sncn/[id]/page.tsx index 89cd7a329..f89257bcc 100644 --- a/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx +++ b/apps/as/app/(main)/(with-base-layout)/instl-sncn/[id]/page.tsx @@ -1,6 +1,6 @@ -import { InstlViewPage } from "@/pages/instl-sncn"; +import { InstlSncnDetailGate } from "@/pages/instl-sncn"; export default async function InstlSncnDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - return ; + return ; } diff --git a/apps/as/app/(with-base-layout)/instl-sncn/page.tsx b/apps/as/app/(main)/(with-base-layout)/instl-sncn/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/instl-sncn/page.tsx rename to apps/as/app/(main)/(with-base-layout)/instl-sncn/page.tsx diff --git a/apps/as/app/(main)/(with-base-layout)/instl-sncn/regist/page.tsx b/apps/as/app/(main)/(with-base-layout)/instl-sncn/regist/page.tsx new file mode 100644 index 000000000..ff047fd96 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/instl-sncn/regist/page.tsx @@ -0,0 +1,28 @@ +import { InstlRegistPage } from "@/pages/instl-sncn"; + +interface InstlSncnRegistRouteProps { + searchParams: Promise<{ + taskSeComCd?: string; + admdstCd?: string; + sido?: string; + gugun?: string; + }>; +} + +export default async function Page({ searchParams }: InstlSncnRegistRouteProps) { + const params = await searchParams; + const taskSeComCd = params.taskSeComCd + ? params.taskSeComCd.split(",").filter(Boolean) + : undefined; + + return ( + + ); +} diff --git a/apps/as/app/(with-base-layout)/layout.tsx b/apps/as/app/(main)/(with-base-layout)/layout.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/layout.tsx rename to apps/as/app/(main)/(with-base-layout)/layout.tsx diff --git a/apps/as/app/(main)/(with-base-layout)/my-pbadms-sprt/page.tsx b/apps/as/app/(main)/(with-base-layout)/my-pbadms-sprt/page.tsx new file mode 100644 index 000000000..c7b7ad4b4 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/my-pbadms-sprt/page.tsx @@ -0,0 +1,3 @@ +import { MyPbadmsSprtListPage } from "@/pages/my-pbadms-sprt"; + +export default MyPbadmsSprtListPage; diff --git a/apps/as/app/(main)/(with-base-layout)/nti-hstry/page.tsx b/apps/as/app/(main)/(with-base-layout)/nti-hstry/page.tsx new file mode 100644 index 000000000..13df22b72 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/nti-hstry/page.tsx @@ -0,0 +1,3 @@ +import { NtiHstryListPage } from "@/pages/nti-hstry"; + +export default NtiHstryListPage; diff --git a/apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx b/apps/as/app/(main)/(with-base-layout)/rvw-mnpw-mng/page.tsx similarity index 100% rename from apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx rename to apps/as/app/(main)/(with-base-layout)/rvw-mnpw-mng/page.tsx diff --git a/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/edit/page.tsx b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/edit/page.tsx new file mode 100644 index 000000000..72492a84c --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/edit/page.tsx @@ -0,0 +1,13 @@ +import { SewerBscPlanEditPage } from "@/pages/sewer-bsc-plan"; + +interface PageProps { + params: Promise<{ id: string }>; + searchParams: Promise<{ chgCycl?: string }>; +} + +export default async function Page({ params, searchParams }: PageProps) { + const { id } = await params; + const { chgCycl } = await searchParams; + const propChgCycl = chgCycl ? Number(chgCycl) : undefined; + return ; +} diff --git a/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/page.tsx b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/page.tsx new file mode 100644 index 000000000..dec6502a8 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/[id]/page.tsx @@ -0,0 +1,13 @@ +import { SewerBscPlanDetailGate } from "@/pages/sewer-bsc-plan"; + +interface PageProps { + params: Promise<{ id: string }>; + searchParams: Promise<{ chgCycl?: string }>; +} + +export default async function Page({ params, searchParams }: PageProps) { + const { id } = await params; + const { chgCycl } = await searchParams; + const propChgCycl = chgCycl ? Number(chgCycl) : undefined; + return ; +} diff --git a/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/page.tsx b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/page.tsx new file mode 100644 index 000000000..d07ca4b43 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/page.tsx @@ -0,0 +1,3 @@ +import { SewerBscPlanListPage } from "@/pages/sewer-bsc-plan"; + +export default SewerBscPlanListPage; diff --git a/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/regist/page.tsx b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/regist/page.tsx new file mode 100644 index 000000000..276160546 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/sewer-bsc-plan/regist/page.tsx @@ -0,0 +1,19 @@ +import { SewerBscPlanRegistPage } from "@/pages/sewer-bsc-plan"; + +interface PageProps { + searchParams: Promise<{ + taskSeComCd?: string; + taskSeCdNm?: string; + sewerBscPlanSeComCd?: string; + sewerBscPlanSeCdNm?: string; + admdstCd?: string; + sido?: string; + gugun?: string; + }>; +} + +export default async function Page({ searchParams }: PageProps) { + const params = await searchParams; + + return ; +} diff --git a/apps/as/app/layout.tsx b/apps/as/app/(main)/layout.tsx similarity index 84% rename from apps/as/app/layout.tsx rename to apps/as/app/(main)/layout.tsx index 3b64ab52f..dacee7f25 100644 --- a/apps/as/app/layout.tsx +++ b/apps/as/app/(main)/layout.tsx @@ -6,8 +6,8 @@ import { Providers } from "@/app/providers"; import "@/app/styles/globals.css"; export const metadata: Metadata = { - title: "행정지원시스템", - description: "행정지원시스템", + title: "디지털 행정지원 시스템", + description: "디지털 행정지원 시스템", }; interface RootLayoutProps { diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/regist/page.tsx b/apps/as/app/(with-base-layout)/fnrs-cnsltn/regist/page.tsx deleted file mode 100644 index 4360a6979..000000000 --- a/apps/as/app/(with-base-layout)/fnrs-cnsltn/regist/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { FnrsRegistPage } from "@/pages/fnrs-cnsltn"; -import { parseTaskSeComCdFromApi } from "@/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema"; - -interface PageProps { - searchParams: Promise<{ taskSeComCd?: string }>; -} - -export default async function Page({ searchParams }: PageProps) { - const params = await searchParams; - const taskSeComCd = parseTaskSeComCdFromApi(params.taskSeComCd); - return ; -} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx deleted file mode 100644 index 476d0b7c2..000000000 --- a/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { InstlRegistPage } from "@/pages/instl-sncn"; - -interface InstlSncnRegistRouteProps { - searchParams: Promise<{ taskSeComCd?: string }>; -} - -export default async function Page({ searchParams }: InstlSncnRegistRouteProps) { - const params = await searchParams; - const taskSeComCd = params.taskSeComCd - ? params.taskSeComCd.split(",").filter(Boolean) - : undefined; - - return ; -} diff --git a/apps/as/app/(with-base-layout)/page.tsx b/apps/as/app/(with-base-layout)/page.tsx deleted file mode 100644 index 82a918b59..000000000 --- a/apps/as/app/(with-base-layout)/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { MainPage as default } from "@/pages/home"; diff --git a/apps/as/app/api/auth/logout/route.ts b/apps/as/app/api/auth/logout/route.ts new file mode 100644 index 000000000..3e53a0705 --- /dev/null +++ b/apps/as/app/api/auth/logout/route.ts @@ -0,0 +1,10 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +import { COOKIE_DOMAIN, REFRESH_TOKEN } from "@/shared/config"; + +export async function POST() { + const cookieStore = await cookies(); + cookieStore.delete({ name: REFRESH_TOKEN, domain: COOKIE_DOMAIN }); + return NextResponse.json({ message: "Logged out" }, { status: 200 }); +} diff --git a/apps/as/app/global-not-found.tsx b/apps/as/app/global-not-found.tsx new file mode 100644 index 000000000..6f66c97a6 --- /dev/null +++ b/apps/as/app/global-not-found.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; +import Image from "next/image"; + +import "@/app/styles/globals.css"; + +export const metadata: Metadata = { + title: "디지털 행정지원 시스템", + description: "페이지가 존재하지 않습니다. 관리자에게 문의하여 주십시오.", +}; + +export default function GlobalNotFound() { + return ( + + +
+
+

+ 디지털 행정지원 시스템에 오신 것을 환영합니다. +

+

오류가 발생하였습니다.

+
+ +
+ +

페이지가 존재하지 않습니다.

+

관리자에게 문의하여 주십시오

+
+
+ + + ); +} diff --git a/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md b/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md deleted file mode 100644 index c90dec22f..000000000 --- a/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md +++ /dev/null @@ -1,367 +0,0 @@ -# 재원협의(fnrs-cnsltn) vs 설치인가(instl-sncn) 차이 분석 - -| 항목 | 내용 | -| --------- | ----------------------------------------------------------------------------- | -| 작성일 | 2026-07-09 | -| 기준 구현 | `fnrs-cnsltn` (추가 요구사항 반영 완료) | -| 비교 대상 | `instl-sncn` | -| 목적 | 두 페이지의 동작·구조 차이를 정리하고, 설치인가 정렬 시 이식 대상을 명확히 함 | - ---- - -## 1. 요약 - -두 모듈은 **동일한 PBADMS(공공행정 지원) 업무 패턴**을 따르는 형제 페이지다. - -공통 흐름: - -``` -목록 → TaskSe 선택 후 신청 → 저장(SV) → 신청완료(ST) - → 환경청제출(LE) → 기술검토 → 검토결과 회신(EP) - → 인가승인(TA) → 고시(TB) -``` - -| 구분 | fnrs-cnsltn | instl-sncn | -| ------------- | ----------------- | ---------------- | -| 업무명 | 재원협의 | 설치인가 | -| 문서번호 | `fnrsCnsltnDocNo` | `instlSncnDocNo` | -| docSeComCd | `FNRS` | `INSTL` | -| 라우트 | `/fnrs-cnsltn/*` | `/instl-sncn/*` | -| pages 파일 수 | 39 | 32 | - -**결론:** `fnrs-cnsltn`이 추가 요구사항이 반영된 기준(reference) 구현이고, `instl-sncn`은 동일 골격이지만 보완요청·결재선 편집·등록/수정 폼 완성도 등에서 미반영·TODO·단순화 상태다. - ---- - -## 2. 파일/폴더 구조 - -### 2-1. fnrs-cnsltn에만 존재 - -| 경로 | 역할 | -| -------------------------------------------- | ------------------------- | -| `ui/sections/fnrs-shar-section.tsx` | 재원분담 조회 섹션 | -| `ui/sections/supplement-demand-section.tsx` | 보완요청 이력 섹션 | -| `ui/components/sortable-prgrs-stts-row.tsx` | 결재선 DnD 편집 행 | -| `ui/components/sortable-apln-picker-row.tsx` | 결재선 템플릿 피커 DnD 행 | -| `ui/components/aprvr-picker-dialog.tsx` | 결재자 선택 다이얼로그 | -| `ui/components/elapr-apln-picker-grid.tsx` | 결재선 편집 그리드 | -| `ui/components/elapr-tmplt-select-table.tsx` | 결재서식 선택 테이블 | -| `model/reviewer-option.ts` | 결재자 옵션 타입 | - -### 2-2. instl-sncn에만 존재 - -| 경로 | 역할 | 비고 | -| -------------------------------------- | ------------------- | ------------------------------------------ | -| `ui/sections/history-info-section.tsx` | 등록/수정 이력 표시 | **어디에서도 import되지 않음 (dead code)** | - -### 2-3. 공통 구조 - -``` -index.ts -ui/ - {list,view,form,regist,edit}-page.tsx - sections/ (basic-info, contact, scale, cost, checklist, attachments, tech-review) - components/ (floating-action-buttons, elapr-approval-dialog, elapr-tmplt-picker-dialog, checklist-task-panel) -lib/ button-config, save-payload-utils, form-data-mapper, regist-schema, checklist/review utils -model/ columns.tsx, view-types.ts -``` - -App Router도 동일: `page.tsx`, `regist/page.tsx`, `[id]/page.tsx`, `[id]/edit/page.tsx` - ---- - -## 3. 목록 페이지 - -### 3-1. 검색 필터 - -| 항목 | fnrs-cnsltn | instl-sncn | -| ------------------ | ----------------------------------------- | --------------------------------- | -| 시도/시군구 API | `@/entities/adstdstr` | 동일 | -| 지역 검색 파라미터 | **`admdstCd`** (시군구 우선, 없으면 시도) | **`sidoComCd` + `sggComCd`** 분리 | -| "전체" 옵션 | `ALL_SELECT_VALUE = "__ALL__"` SelectItem | 없음 (placeholder만) | -| 구분 CodeSelect | `upCd="INSTL"` ※ | `upCd="INSTL"` | -| 사업명 | 동일 | 동일 | - -> ※ fnrs 목록의 구분 필터가 `INSTL` 코드를 사용 중이며, TaskSePickerDialog는 `codeUpCd="FNRS"`를 쓴다. **불일치 가능성**이 있어 확인이 필요하다. - -### 3-2. 정렬·페이지네이션 - -| 항목 | fnrs-cnsltn | instl-sncn | -| ------------------------ | ---------------------- | ------------------------------------- | -| 기본 sort (list-page) | `fnrsCnsltnDocNo,desc` | `instlSncnDocNo,desc` | -| fallback sort (list.tsx) | `fnrsCnsltnDocNo,desc` | **`regDt,desc`** (list-page와 불일치) | -| 페이지 크기 | 10 | 10 | -| URL 동기화 | 없음 (`useState` 로컬) | 없음 | - -### 3-3. 테이블 컬럼 - -`model/columns.tsx` 로직은 사실상 동일하다. DTO 타입명만 다르다. - -컬럼: 시도, 시군구, 사업명, 업무구분, 담당자, 해당기관, 진행상황, 신청일 - -### 3-4. 행 클릭·신청 - -| 항목 | fnrs-cnsltn | instl-sncn | -| ------------ | ------------------------------------------------------------------ | ------------------ | -| 행 클릭 | **조건부 라우팅**: `SV`/`SL` + 등록자 본인 → `/edit`, 그 외 → view | **항상 view** | -| useUserStore | 사용 | 미사용 | -| 신청 버튼 | TaskSePickerDialog `codeUpCd="FNRS"` | `codeUpCd="INSTL"` | - -```46:54:apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx - const handleRowClick = (row: FnrsCnsltnMngResponseDto) => { - const canEdit = - (row.prgrsSttsComCd === "SV" || row.prgrsSttsComCd === "SL") && - row.rgtr === userInfo?.userUnqId; - - router.push( - canEdit ? `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}/edit` : `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`, - ); - }; -``` - ---- - -## 4. 상세(View) 페이지 - -### 4-1. 섹션 구성 - -| 섹션 | fnrs-cnsltn | instl-sncn | -| ------------------------------- | ------------------------------------------ | -------------------------- | -| BasicInfo | 환경청구분, 확정예산사업번호, 예산신청연도 | **사업구분(`sncnSeCdNm`)** | -| ContactInfo | 동일 패턴 | 동일 | -| BusinessScale / Cost | 동일 | 동일 | -| **FnrsShar (재원분담)** | **있음** | **없음** (도메인 고유) | -| Checklist | 동일 | 동일 | -| Attachments | 동일 | 동일 | -| **SupplementDemand (보완요청)** | **있음** | **없음** | -| TechReviewOpinion | 동일 | 동일 | -| HistoryInfo | 없음 | 파일만 존재, view 미연결 | -| FloatingActionButtons | `docSeComCd="FNRS"` | `docSeComCd="INSTL"` | - -### 4-2. 데이터 훅 - -| 항목 | fnrs-cnsltn | instl-sncn | -| --------- | ---------------------------- | -------------------- | -| 상세 훅 | `useFnrsCnsltnMng` | `useInstlSncnDetail` | -| 에러 판정 | `isError \|\| !data?.detDto` | `isError \|\| !data` | -| 로딩 | Skeleton | Skeleton | - ---- - -## 5. 등록/수정(Form) 페이지 - -### 5-1. 폼 완성도 - -| 항목 | fnrs-cnsltn | instl-sncn | -| ------------- | --------------------------------- | --------------- | -| 저장(SV) | O | O (SV 고정) | -| 신청완료(ST) | O (edit 모드) | **없음** | -| 삭제 | O (edit 모드, `useConfirm`) | **없음** | -| 재원분담 섹션 | `FnrsSharFormSection` + 잔액 검증 | 없음 | -| 저장 후 이동 | **`/edit` 유지** | **view로 이동** | -| 목록 버튼 | `/fnrs-cnsltn` 고정 | `router.back()` | - -### 5-2. 기본정보 Form 필드 - -| 필드 | fnrs | instl | -| ----------------- | ---------------- | --------------------------------------- | -| taskSe 코드 | `FNRS` | `INSTL` | -| 사업구분 | 없음 | **`sncnSeComCd` (SNCNSE 라디오, 필수)** | -| BnsPicker 연동 | bizNm ~ bizCn | + **`bgtBizCd`, `bgtAplyYr`** | -| 재이용(RE) 조건부 | rtlztnFclt / Usg | 동일 | - -### 5-3. Schema - -| 항목 | fnrs | instl | -| ------------------- | ------------------- | -------------- | -| 관리 객체 | `fnrsCnsltnMng` | `instlSncnMng` | -| 예산 필드 | `cfmtnBgtBizNo` | `bgtBizCd` | -| 재원분담 | `fnrsSharList` 배열 | 없음 | -| sharAtchFileGroupId | 있음 | 없음 | -| privacyConsent | 있음 | 있음 | - -### 5-4. Edit 페이지 래퍼 - -| 항목 | fnrs | instl | -| ------------------ | ------------------------------- | ------------------------------- | -| 로딩 Skeleton | 있음 | **없음** | -| 데이터 없음 메시지 | "상세 정보를 찾을 수 없습니다." | "수정 대상을 찾을 수 없습니다." | - ---- - -## 6. FloatingActionButtons / 결재 / 보완 - -### 6-1. FAB 동작 차이 - -공통: 역할×상태 기반 버튼(`button-config.ts`), checklist 진행률 검증, elapr pending approval 체크 - -| 기능 | fnrs-cnsltn | instl-sncn | -| ------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | -| 보완완료 (`splmntCmptn`) | **`submitSupplement` API 연동** | **`// TODO` stub** | -| 보완요청 (`dmndSplmnt`) | **`buildSupplementReqPayload` + `submitReqSupplement`** | **`// TODO` stub** (문자열만 전달) | -| 검토완료 (`rvwCmptn`) | `techReViewSaveCd: "SV"` 후 **결재 다이얼로그 오픈** | `techReViewSaveCd: "CP"`, **다이얼로그 없이 즉시 완료** | -| 검토자지정 | `buildElaprPrgrsSttsListFromAplnRows(aplnRows)` | `buildElaprPrgrsSttsListFromApln(aplnPage.content)` | -| atrzAprv / atrzRply / grndsChck | TODO handler | TODO handler | - -```576:617:apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx -async function handleSplmntCmptn(context: ActionHandlerContext): Promise { - // TODO: 보완완료 API 호출 - context.onSuccess?.(); -} -// ... -async function handleDmndSplmnt(context: ActionHandlerContext): Promise { - // TODO: 보완요청 API 호출 - context.onSuccess?.(); -} -``` - -### 6-2. ElaprApprovalDialog (가장 큰 UX 격차) - -| 항목 | fnrs (419줄) | instl (231줄) | -| ---------------- | ------------------------------------------------------ | -------------------------------- | -| 결재선 표시 | DnD sortable 편집 테이블 | **읽기 전용** 테이블 | -| 결재선 추가/저장 | **지원** (`usePrgrsSttsRows`, `useSaveElaprPrgrsStts`) | 없음 | -| 결재자 변경 | **AprvrPickerDialog** | 없음 | -| 전결 체크박스 | **있음** (`finalApprovalYn`) | 없음 | -| 반려 사유 | API에 `rjctRsn` 전달 | UI는 있으나 **payload에 미전달** | -| 저장 전 검증 | `hasUnsavedPrgrsSttsChanges` | 없음 | - -### 6-3. ElaprTmpltPickerDialog - -| 항목 | fnrs | instl | -| --------------- | -------------------------------------------------------------------- | ----------------------- | -| UI | 2-pane: 서식 테이블 + **결재선 편집 그리드** | 단일 테이블 + 선택 버튼 | -| docSeComCd 필터 | `FNRS` | `INSTL` | -| onConfirm | `{ tmplt, aplnRows }` 편집 결과 | `onSelect(tmplt)`만 | -| 검증 | `validateAllRowsForSave`, `validateAtrzOrder`, `validateUniqueSeqNo` | 없음 | - ---- - -## 7. Entity / API / Hook - -### 7-1. Service 메서드 - -| 메서드 | fnrs | instl | -| ----------------------- | --------------------------- | --------- | -| 목록 | `findPage` | `getPage` | -| 상세 | `findById` | `getById` | -| saveApply | O | O | -| techReview | O | O | -| changePrgrsSttsComCd | O | O | -| remove | O | O | -| **submitSupplement** | **O** (`/submitsupplement`) | **X** | -| **submitReqSupplement** | **O** (`/reqsupplement`) | **X** | - -### 7-2. 상세 DTO 확장 - -**fnrs 추가 필드** - -- `fnrsSharList`, `sharAtchFileList` -- `splmntDmndList` (typed) - -**instl 추가 필드** - -- `sncnSeComCd` / `sncnSeCdNm` (인가구분) -- `bgtBizCd` (예산사업코드) -- `rtlztnFcltSeComNm`, `rtlztnUsgSeComNm` -- `splmntDmndList` 타입은 base에 존재하나 view/API 미연결 -- `instlSncnRegionService` (시도/시군구 — 목록은 adstdstr 사용, 미활용) - -### 7-3. save-payload-utils - -| 함수 | fnrs | instl | -| -------------------------------- | ---------------- | --------- | -| buildSavePayload | + `fnrsSharList` | shar 없음 | -| buildTechReviewReplyPayload | O | O | -| **buildSupplementReqPayload** | **O** | **X** | -| **buildSupplementSubmitPayload** | **O** | **X** | - ---- - -## 8. 비즈니스 도메인 차이 - -| 영역 | fnrs-cnsltn | instl-sncn | -| -------------- | ----------------------------------------- | ------------------------ | -| 업무 코드 | `FNRS` | `INSTL` | -| TaskSe | FNRS 하위 | INSTL 하위 | -| 고시 버튼 라벨 | "재원협의 고시" | "설치인가 고시" | -| 재원분담 | **핵심 도메인** (`fnrs-shar-se`) | 해당 없음 | -| 인가구분 | 없음 | **`SNCNSE` 코드 (필수)** | -| 예산 식별 | `cfmtnBgtBizNo` | `bgtBizCd` | -| 진행상태 코드 | SV, ST, LE, ER, TE, EP, TA, TB, SL, TP 등 | **동일 세트 공유** | -| 역할 매핑 | ROLE_USER05/04/03/01 | 동일 | - ---- - -## 9. fnrs-cnsltn에만 있는 기능 (추가 요구사항 후보) - -instl-sncn 정렬 시 **우선 이식 대상**: - -1. 목록 행 클릭 조건부 edit 라우팅 (`SV`/`SL` + 등록자) -2. 보완요청 전체 플로우 — view 섹션, payload builder, API, FAB handler -3. 보완완료 API 연동 (`submitSupplement`) -4. 등록/수정 폼 완성 — 신청완료, 삭제, 저장 후 edit 유지 -5. 결재선 DnD 편집형 `ElaprApprovalDialog` (+ aprvr-picker, sortable rows) -6. 결재선 편집형 `ElaprTmpltPickerDialog` (+ apln grid, validation) -7. 검토완료 → 결재 다이얼로그 연계 흐름 -8. 검색 필터 "전체" 옵션 및 `admdstCd` 통합 방식 (백엔드 API에 맞게) -9. Edit 페이지 로딩 Skeleton -10. entity에 supplement API/hooks 추가 - ---- - -## 10. instl-sncn에만 있는 기능 (유지 필요) - -1. **`sncnSeComCd` (사업구분/인가구분)** — 도메인 고유 -2. **`bgtBizCd` / `bgtAplyYr`** BnsPicker 연동 -3. **`history-info-section.tsx`** — 구현만 있고 view 미연결 (활성화 또는 삭제 결정 필요) -4. entity **`instlSncnRegionService`** — 목록은 adstdstr 사용 중이라 중복/레거시 가능 -5. **`INSTL_SNCN_REGISTER_SELECTION_KEY`** sessionStorage 키 - ---- - -## 11. 동일·거의 동일 패턴 - -| 영역 | 동일도 | -| ----------------------------------------------------- | --------------------- | -| App Router 4-route 구조 | 100% | -| `button-config.ts` 역할×상태 매트릭스 | ~99% (액션명만 다름) | -| `columns.tsx` 진행상황 Badge | ~100% | -| checklist / contact / scale / cost / attachments 섹션 | ~95% (import 경로만) | -| review-step-utils 로직 | ~100% (상수명만 다름) | -| TaskSePickerDialog 신청 플로우 | 100% | -| FAB 전체 구조·대부분 handler | ~85% | -| checklist-form-state, business-cost-utils | 거의 동일 | -| shared widgets (`@/widgets/pbadms-sprt`) | 동일 | - ---- - -## 12. instl-sncn 정렬 권장 우선순위 - -| 우선순위 | 항목 | 근거 | -| -------- | ------------------------------------------------------- | ---------------------------- | -| P0 | 보완요청/보완완료 API + UI | fnrs 완전 구현 vs instl TODO | -| P0 | ElaprApprovalDialog 고도화 | 결재선 편집·전결·반려사유 | -| P0 | ElaprTmpltPickerDialog 2-pane | 검토자지정 품질 | -| P1 | Form 신청완료/삭제 + 저장 후 edit 유지 | 등록 UX parity | -| P1 | 목록 행 클릭 edit 라우팅 | fnrs 추가 요구사항 | -| P1 | 검토완료 → 결재 다이얼로그 연계 | 흐름 통일 | -| P2 | sort fallback 불일치 수정 (`regDt` vs `instlSncnDocNo`) | instl 내부 버그성 | -| P2 | history-info-section 연결 또는 삭제 | dead code 정리 | -| P3 | URL search params 동기화 | 양쪽 모두 미구현 (공통 개선) | - ---- - -## 13. 참고 코드 위치 - -| 주제 | 경로 | -| ---------------------------- | ------------------------------------------------------------ | -| 목록 edit 라우팅 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-list.tsx` | -| 목록 항상 view (instl) | `pages/instl-sncn/ui/instl-list.tsx` | -| view 추가 섹션 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-view-page.tsx` | -| FAB 보완 TODO (instl) | `pages/instl-sncn/ui/components/floating-action-buttons.tsx` | -| 결재 다이얼로그 (fnrs) | `pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx` | -| 결재 다이얼로그 (instl) | `pages/instl-sncn/ui/components/elapr-approval-dialog.tsx` | -| 폼 완성도 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-form-page.tsx` | -| 폼 단순화 (instl) | `pages/instl-sncn/ui/instl-form-page.tsx` | -| supplement API (fnrs entity) | `entities/fnrs-cnsltn/api/fnrs-cnsltn-service.ts` | diff --git a/apps/as/docs/pwrk-cnsltn/development-guide.md b/apps/as/docs/pwrk-cnsltn/development-guide.md new file mode 100644 index 000000000..08193cd83 --- /dev/null +++ b/apps/as/docs/pwrk-cnsltn/development-guide.md @@ -0,0 +1,250 @@ +# 재원협의·실무협의 프론트엔드 개발 가이드 + +> **대상 앱**: `frontend/apps/as` +> **작성일**: 2026-08-27 +> **개정일**: 2026-09-07 +> **버전**: 2.0 +> **Process**: PR-AS-003 (재원협의 관리 — 실무협의 및 AI 검증 통합) +> +> **SoT (2026-09-03 완결)**: +> +> - 프로세스·상태·버튼: `03.재원협의_실무협의_프로세스_통합_v완결.md` +> - REST 설계: `05.재원협의_상태별_endpoint_설계.md` +> - 공통코드: `FNRSSTTS` (`SV, ST, LE, FV, ER, SL, AC, TE, TP, RM, RB, RC, RA, AV, EV, RK, RR, EP, TA`) +> +> **백엔드 서비스**: +> +> - `as-service` — `/api/v1/fnrs-cnsltn-mng`, `/api/v1/pwrk-cnsltn-mng`, `/api/v1/pwrk-cnsltn-doc-crt-rslt`, `/api/v1/dcrag-to` +> - `as-elapr-service` — `/api/v1/elapr-dmnd`, `/api/v1/elapr-prgrs-stts`, `/api/v1/elapr-apln` +> +> **현재 프론트 상태**: 재원협의(`fnrs-cnsltn`)·전자결재(`elapr`)·DC RAG TO(`dcrag-to`)는 존재한다. 실무협의(`pwrk-cnsltn-`\*) 엔티티·화면은 **미구현**. 버튼 매트릭스·상태코드는 **v2.1 잔존**이며 v3.0(`FNRSSTTS`)과 불일치한다. [9절](#9-현재-코드-vs-to-be-갭) 참고. + +--- + +## 1. 문서 목적 + +재원협의 4대 주체(기초·광역·환경청·공단)의 **상태 전이·버튼·API**를 `apps/as`에 구현할 때 쓰는 프론트엔드 가이드이다. + +v1.0은 공단 접수자의 **자료검증 → 초안생성 → 웹기안기**만 다뤘다. v2.0은 2026-09-03 완결 프로세스 기준으로 **신청부터 최종종결(**`TA`**)까지**, 실무협의·AI 3파이프라인·결재선·보완 루프를 포함한다. + +상세 표·시퀀스·DTO는 본문에 넣지 않고 레퍼런스로 분리한다. 구현 중에는 본문의 규칙만 보고, 표가 필요하면 해당 파일을 연다. + +--- + +## 2. 문서 맵 + +| 파일 | 내용 | 언제 보는가 | +| -------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------- | +| [development-guide.md](./development-guide.md) (본 문서) | 도메인, 핵심 규칙, 화면 흐름, 갭, 구현 순서 | 일상 구현 | +| [ref-process-status.md](./ref-process-status.md) | 4-Lane 시퀀스, Activity, `FNRSSTTS`, 보완 3루프, AI 타임아웃, 결재선 복사 | 상태 전이·폴링 UX | +| [ref-screen-buttons.md](./ref-screen-buttons.md) | 38행 화면·역할·버튼 매트릭스, 버튼 가시성 | `button-config.ts` 개편 | +| [ref-endpoint.md](./ref-endpoint.md) | 버튼→API 총괄, 단계별 입출력, FE 호출 금지 API, Gap Analysis | 엔티티·service 매핑 | +| [ref-frontend-entities.md](./ref-frontend-entities.md) | ENDPOINT, 슬라이스, Service 시그니처, 폴링, 웹기안기, DTO | 엔티티 레이어 구현 | +| [ref-webhwp-api.md](./ref-webhwp-api.md) | 웹한글기안기 v3 `HwpCtrl` Properties/Methods, Open/SaveAs | 기안기 위젯 연동 | + +--- + +## 3. 도메인 개요 + +실무협의서(PWRK)는 재원협의서(FNRS)의 **하위 문서**이다. 재원협의 1건당 실무협의서는 **1건만** 생성된다. + +| 문서 | 번호 예시 | 필드 | +| -------- | ----------------- | ----------------------------- | +| 재원협의 | `F-20260824-0001` | `fnrsCnsltnDocNo` / `upDocNo` | +| 실무협의 | `P-20260827-0001` | `pwrkCnsltnDocNo` | + +진행상태는 **재원협의 마스터** `tb_as_fnrs_cnsltn_mng.prgrs_stts_com_cd` (`FNRSSTTS`)가 SSOT이다. 실무협의 문서의 `prgrsSttsComCd`는 연계 표시용이며, 화면 버튼은 재원협의 상태로 분기한다. + +### 3-1. 4대 주체 + +| 역할 | 코드 | 화면 소유 | 핵심 구간 | +| --------------- | ------------- | -------------- | ------------------------------------ | +| 기초자치단체 | `ROLE_USER05` | 작성/수정/상세 | 신청, 3종 보완, 조치결과, 최종확인 | +| 광역자치단체 | `ROLE_USER04` | 상세 | 경유 제출, 결과 전달 | +| 유역·지방환경청 | `ROLE_USER03` | 상세 | 의뢰/자체종결, 1·2차 회신 | +| 한국환경공단 | `ROLE_USER01` | 상세 + 결재선 | 파일검증, 검토자지정, 1·2차 기술검토 | + +### 3-2. 핵심 식별자 + +| 필드 | 의미 | 비고 | +| ----------------- | ---------------- | ----------------------------------------------------------- | +| `fnrsCnsltnDocNo` | 재원협의문서번호 | 상태 전이·목록 PK | +| `pwrkCnsltnDocNo` | 실무협의문서번호 | `P-YYYYMMDD-NNNN`. 검토자지정(`ER→AC`) 시 발급 | +| `upDocNo` | 상위문서번호 | 재원협의 번호. `upDocSeComCd = "FNRS"` | +| `crtDmndUuid` | 생성요청 UUID | 초안 폴링·웹기안기·다운로드 키 | +| `verSn` | 버전일련번호 | 복합 PK `(crtDmndUuid, verSn)`. 저장 시 서버가 `+1` | +| `orgnlOtptDocCn` | HWP 바이너리 | 목록/상세 응답은 항상 `null`. 다운로드·`webhwp/open`만 사용 | +| `elaprCycl` | 결재 차수 | 1차 `TE`, 2차 `RK`. 금액검증 완료 후 1차 결재선 복사 | + +### 3-3. IIPBS 약어 + +| 한글 | 약어 | 필드 예 | +| ---- | ------------ | --------------------------- | +| 재원 | FNRS | `fnrsCnsltnDocNo` | +| 실무 | PWRK | `pwrkCnsltnDocNo` | +| 협의 | CNSLTN | `fnrs-cnsltn-mng` | +| 생성 | CRT | `crtDmndUuid` | +| 요청 | DMND | `crtDmndUuid`, `elapr-dmnd` | +| 검증 | VRFC | `vrfcSttsComCd` | +| 보완 | SPLMNT | `splmntDmndList` | +| 결재 | ELAPR / ATRZ | `elaprCycl`, `atrzSeComCd` | + +--- + +## 4. 상태코드 한눈에 보기 + +상위코드 `FNRSSTTS`. v1.0의 `PWRKSTTS`(`DV`, `AR`, `TR` 등)는 **폐기**한다. + +정방향: + +``` +SV → ST → LE → FV → ER → AC → TE → TP → RM → RB → RC → RA → AV → EV → RK → RR → EP → TA +``` + +자체종결: `LE → EP → TA` +보완: `ER|TE|RK → SL` 후 직전 상태로 복귀. + +| 코드 | 명칭 | FE에서 할 일 | +| ---- | ---------------------- | ------------------------------------------------- | +| `SV` | 저장 | 기초 작성화면 저장/신청/삭제 | +| `ST` | 신청완료 | 광역 환경청제출 | +| `LE` | 환경청 검토 | 자체종결(`EP`) 또는 기술검토 의뢰(`FV`) | +| `FV` | 파일검증 | AI 진행 표시. **30분** 후 `ER` Fallback | +| `ER` | 기술검토 의뢰 | 공단 접수자: 파일보완 / 수동파일검증 / 검토자지정 | +| `SL` | 보완요청 | 기초 수정화면. 복귀 대상은 이력의 직전 상태 | +| `AC` | 초안생성 | AI 진행 표시. **30분** 후 `TE` Fallback | +| `TE` | 기술검토 | 웹기안기·검토완료·보완. 결재선 1차 | +| `TP` | 기술검토 회신 | 환경청 → 광역 `RM` | +| `RM` | 재원협의결과회신 | 광역 → 기초 `RB` | +| `RB` | 지자체회신 | 기초 실무협의서 관리 / 조치완료 | +| `RC` | 조치결과서 제출 | 광역 → 환경청 `RA` | +| `RA` | 조치결과서제출(환경청) | 환경청 2차 의뢰 `AV` | +| `AV` | 금액검증 | AI 진행 표시. **30분** 후 `EV` Fallback | +| `EV` | 조치결과검토의뢰 | 공단 접수자: 2차 결재선 검토자지정 → `RK` | +| `RK` | 조치결과서검토 | 웹기안기·금액검증 재실행·보완. 결재선 2차 | +| `RR` | 조치결과서검토 회신 | 환경청 → 광역 `EP` | +| `EP` | 검토결과 회신 | 광역 → 기초 `TA` | +| `TA` | 검토결과 회신(종료) | 기초 조회만. **고시(**`TB`**) 아님** | + +코드 의미·구버전 대조·보완 루프·AI Fallback은 [ref-process-status.md](./ref-process-status.md). + +--- + +## 5. 프론트가 지켜야 할 핵심 규칙 + +1. **상태 SSOT는** `FNRSSTTS`. 버튼 가시성은 `(dtlInstCd, prgrsSttsComCd, atrzSeComCd)`로만 계산한다. 공단은 결재선 구분(`ATRZ01` 접수자, `ATRZ02` 검토자, `ATRZ03` 승인자, `ATRZ99` 최종승인자)을 추가로 본다. +2. **AI 콜백 API는 화면에서 호출하지 않는다.** `POST /dcrag/documents/verify-result`, `POST /dcrag/draft/complete`, `POST /dcrag/cost/verify-result`는 서버 전용이다. +3. **초안생성 1차 트리거는 검토자지정이다.** `POST /elapr-dmnd` (`ER → AC`). 예전처럼 `POST /pwrk-cnsltn-mng`로 초안을 시작하지 않는다. +4. **기술검토 의뢰는** `LE → FV` **전용 API** `POST /fnrs-cnsltn-mng/{docNo}/request-examine`. 현재 코드의 `updatePrgrsStts(ER)` 직행은 폐기. +5. **수동 AI 재실행은 상태를 바꾸지 않는다.** + +- `ER` 파일검증 → `POST /dcrag-to/documents/transfer` +- `TE` 초안생성 → `POST /dcrag-to/documents/draft/generate` (타임아웃·실패 시에만) +- `RK` 금액검증 → `POST /dcrag-to/documents/cost/verify` + +6. **진행상태만 바꾸는 버튼은** `POST /fnrs-cnsltn-mng/doc/updatePrgrsStts`**를 재사용한다.** (`ST→LE`, `RM→RB`, `RC→RA`, `RR→EP`, `EP→TA`) +7. **보완요청/보완완료는 공통 API** `reqsupplement` / `submitsupplement`. 복귀 상태(`ER`/`TE`/`RK`)는 서버가 이력에서 결정한다. 프론트는 직전 상태를 추측해 보내지 않는다. +8. **결재 승인/회신은** `PUT /elapr-prgrs-stts/{id}/process` **하나**. 1차 최종승인자 → `TP`, 2차 → `RR`은 서버가 Feign으로 처리한다. +9. **2차 결재선은 EV에서 수동 검토자지정**. `POST /elapr-dmnd` (`elaprCycl: 2`, `docPrgrsSttsComCd: RK`). 1차 결재선 자동 copy-line은 사용하지 않는다. +10. **웹기안기는 `webhwp/open` 응답을 axios `blob`으로 조회한 뒤 `File`로 감싸 `HwpCtrl.Open`에 전달한다.** `blob:` Object URL과 `orgnlOtptDocCn`은 쓰지 않는다. + +--- + +## 6. 화면 데이터 흐름 + +재원협의 **목록 → 상세/작성**이 본체다. 실무협의 목록은 공단 검토 보조 화면이다. + +``` +BEGIN + CALL fnrsCnsltnMngService.findPage(...) + ON 행 CLICK → /fnrs-cnsltn/{fnrsCnsltnDocNo} + + CALL findById(fnrsCnsltnDocNo) + COMPUTE buttons = getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList) + + // ----- 기초 ROLE_USER05 ----- + ON [저장] (빈값/SV) → saveApply(prgrsSttsComCd: SV) + ON [신청완료] (SV) → saveApply(prgrsSttsComCd: ST) + ON [삭제] (SV) → remove + ON [파일보완완료] (SL←ER) → submitsupplement + ON [보완완료] (SL←TE|RK) → submitsupplement + ON [실무협의서 관리] (RB) → GET webhwp/open (blob) → HwpCtrl.Open(File) + ON [조치완료] (RB) → submit-actn-result { isSubmit: true } + ON [결과회신] (TA) → API 없음 (조회) + + // ----- 광역 ROLE_USER04 ----- + ON [환경청제출] (ST) → updatePrgrsStts(LE) + ON [지자체회신] (RM) → updatePrgrsStts(RB) + ON [조치결과서제출] (RC) → updatePrgrsStts(RA) + ON [결과회신] (EP) → updatePrgrsStts(TA) + + // ----- 환경청 ROLE_USER03 ----- + ON [결과회신] (LE 자체종결) → tech-review → EP + ON [기술검토 의뢰] (LE) → request-examine → FV → START POLL FV + ON [재원협의 결과 회신] (TP) → tech-review → RM + ON [실무협의서 관리] (RA) → GET webhwp/open (blob) → HwpCtrl.Open(File) (저장 불가) + ON [조치결과 검토의뢰] (RA) → request-actn-review → AV → START POLL AV + ON [결과회신] (RR) → updatePrgrsStts(EP) + + // ----- 공단 ROLE_USER01 ----- + ON [파일보완] (ER, ATRZ01) → GET file-vrfc-results → 팝업 → reqsupplement + ON [파일검증] (ER, ATRZ01) → dcrag-to transfer (상태 유지) + ON [검토자지정] (ER, ATRZ01) → POST elapr-dmnd (docPrgrsSttsComCd: AC) → START POLL AC + ON [검토자지정] (EV, ATRZ01) → POST elapr-dmnd (elaprCycl: 2, docPrgrsSttsComCd: RK) + ON [실무협의서관리] (TE|RK) → GET webhwp/open (blob) → HwpCtrl.Open(File) + ON [실무협의서생성] (TE, 실패/타임아웃) → dcrag-to draft/generate + ON [검토결과조회] (TE) → pwrk-cnsltn-mng findById | findByUpDocNo + ON [저장] (TE|RK) → pwrk-cnsltn-mng save-apply + ON [검토완료] → elapr-prgrs-stts process (상신) + ON [보완요청] (TE|RK) → 보완 팝업 → reqsupplement + ON [금액검증] (RK) → dcrag-to cost/verify + ON [결재(승인)] → process (중간) + ON [결재(회신)] → process (최종 → TP 또는 RR) + + // ----- AI 진행 중 (FV / AC / AV) ----- + POLL 상태 또는 draft status. 30분 안내. 종료 후 상세 invalidate +END +``` + +38행 버튼 표는 [ref-screen-buttons.md](./ref-screen-buttons.md). API 상세는 [ref-endpoint.md](./ref-endpoint.md). + +--- + +## 7. 버튼 → API 축약 + +프론트가 치는 것만. 콜백·스케줄러는 제외. + +| 주체 | 버튼 | From → To | Method & URI | +| --------- | -------------------------- | ----------------------------- | ---------------------------------------------------------------------------------- | +| 기초 | 저장 / 신청완료 | →`SV` / `SV→ST` | `POST /fnrs-cnsltn-mng/save-apply` | +| 기초 | 삭제 | `SV` | `DELETE /fnrs-cnsltn-mng/{docNo}` | +| 광역 | 환경청제출 | `ST→LE` | `POST /fnrs-cnsltn-mng/doc/updatePrgrsStts` | +| 환경청 | 결과회신(자체종결) | `LE→EP` | `POST /fnrs-cnsltn-mng/{docNo}/tech-review` | +| 환경청 | 기술검토 의뢰 | `LE→FV` | **신규** `POST /fnrs-cnsltn-mng/{docNo}/request-examine` | +| 공단 접수 | 파일보완 팝업 | `ER` | `GET /fnrs-cnsltn-mng/{docNo}/file-vrfc-results` | +| 공단 접수 | 파일보완요청 | `ER→SL` | `POST /fnrs-cnsltn-mng/{docNo}/reqsupplement` | +| 공단 접수 | 파일검증 재실행 | `ER` 유지 | `POST /dcrag-to/documents/transfer` | +| 공단 접수 | 검토자지정 | `ER→AC` | `POST /elapr-dmnd` (`elaprCycl: 1`) | +| 공단 접수 | 검토자지정 | `EV→RK` | `POST /elapr-dmnd` (`elaprCycl: 2`) | +| 공단 검토 | 실무협의서관리 | `TE`/`RK` | `GET .../pwrk-cnsltn-doc-crt-rslt/{uuid}/webhwp/open` | +| 공단 검토 | 실무협의서생성 | `TE` 유지 | `POST /dcrag-to/documents/draft/generate` | +| 공단 검토 | 저장 | `TE`/`RK` | `POST /pwrk-cnsltn-mng/save-apply` | +| 공단 | 검토완료 / 결재(승인·회신) | `TE` 유지 / `TE→TP` / `RK→RR` | `PUT /elapr-prgrs-stts/{id}/process` | +| 공단 검토 | 보완요청 | `TE→SL` / `RK→SL` | `POST /fnrs-cnsltn-mng/{docNo}/reqsupplement` | +| 기초 | 파일보완완료 / 보완완료 | `SL→ER` / `TE` / `RK` | `POST /fnrs-cnsltn-mng/{docNo}/submitsupplement` | +| 환경청 | 재원협의 결과 회신 | `TP→RM` | `POST /fnrs-cnsltn-mng/{docNo}/tech-review` | +| 광역 | 지자체회신 | `RM→RB` | `updatePrgrsStts` | +| 기초 | 실무협의서 관리 | `RB` 유지 | `GET .../pwrk-cnsltn-doc-crt-rslt/{uuid}/webhwp/open` | +| 기초 | 조치완료 | `RB→RA` | **신규** `POST /pwrk-cnsltn-mng/{pwrkDocNo}/submit-actn-result` (`isSubmit: true`) | +| 광역 | 조치결과서제출 | `RC→RA` | `updatePrgrsStts` | +| 환경청 | 실무협의서 관리 | `RA` 유지 (조회) | `GET .../pwrk-cnsltn-doc-crt-rslt/{uuid}/webhwp/open` | +| 환경청 | 조치결과 검토의뢰 | `RA→AV` | **신규** `POST /fnrs-cnsltn-mng/{docNo}/request-actn-review` | +| 공단 검토 | 금액검증 재실행 | `RK` 유지 | `POST /dcrag-to/documents/cost/verify` | +| 환경청 | 결과회신 | `RR→EP` | `updatePrgrsStts` | +| 광역 | 결과회신 | `EP→TA` | `updatePrgrsStts` | +| 웹기안기 | 저장 | 버전 +1 | `POST /pwrk-cnsltn-doc-crt-rslt` multipart | +| 공통 | 다운로드 | — | `GET /pwrk-cnsltn-doc-crt-rslt/{uuid}/download` | + +신규 4개: `request-examine`, `submit-actn-result`, `request-actn-review`, EV 2차 `elapr-dmnd`. + +--- diff --git a/apps/as/docs/pwrk-cnsltn/ref-endpoint.md b/apps/as/docs/pwrk-cnsltn/ref-endpoint.md new file mode 100644 index 000000000..712ae57d6 --- /dev/null +++ b/apps/as/docs/pwrk-cnsltn/ref-endpoint.md @@ -0,0 +1,334 @@ +# 레퍼런스 — 상태별 Endpoint + +> 상위: [개발가이드.md](./개발가이드.md) +> SoT: `05.재원협의_상태별_endpoint_설계.md` (v1.0, 2026-09-03) +> Gateway: `/as-service`, `/as-elapr-service` +> Auth: `Authorization`, `X-Site-Id`, `token-id` (기존 `client` 인터셉터) +> 날짜: `yyyy-MM-dd HH:mm:ss` + +프론트가 치는 API만 상세히 적는다. AI 콜백·스케줄러·Feign은 [§6 호출 금지](#6-프론트-호출-금지). + +--- + +## 1. 총괄 매트릭스 (38 + WebHWP) + +| 순번 | 버튼 | From → To | 서비스 | Method & URI | 구분 | +| :---: | --- | --- | --- | --- | --- | +| 1 | 저장 | →`SV` | as | `POST /api/v1/fnrs-cnsltn-mng/save-apply` | 기존 | +| 2 | 신청완료 | `SV→ST` | as | 동일 (`prgrsSttsComCd=ST`) | 기존 | +| 3 | 삭제 | `SV` | as | `DELETE /api/v1/fnrs-cnsltn-mng/{docNo}` | 기존 | +| 4 | 환경청제출 | `ST→LE` | as | `POST /api/v1/fnrs-cnsltn-mng/doc/updatePrgrsStts` | 기존 | +| 5 | 결과회신(자체종결) | `LE→EP` | as | `POST /api/v1/fnrs-cnsltn-mng/{docNo}/tech-review` | 기존 | +| 6 | 기술검토 의뢰 | `LE→FV` | as | `POST /api/v1/fnrs-cnsltn-mng/{docNo}/request-examine` | **신규** | +| 7 | 파일검증 콜백 | `FV→ER` | as | `POST /api/v1/dcrag/documents/verify-result` | 서버 | +| 8 | 파일보완 팝업 | `ER` | as | `GET /api/v1/fnrs-cnsltn-mng/{docNo}/file-vrfc-results` | 기존 | +| 9 | 파일보완요청 | `ER→SL` | as | `POST /api/v1/fnrs-cnsltn-mng/{docNo}/reqsupplement` | 기존 | +| 10 | 파일검증 재실행 | `ER` | as | `POST /api/v1/dcrag-to/documents/transfer` | 기존 | +| 11 | 검토자지정 | `ER→AC` | elapr | `POST /api/v1/elapr-dmnd` | 기존 | +| 12 | 초안 콜백 | `AC→TE` | as | `POST /api/v1/dcrag/draft/complete` | 서버 | +| 13 | 실무협의서관리 | `TE` | as | `GET /api/v1/pwrk-cnsltn-mng/updocno/{upDocNo}` | 기존 | +| 14 | 검토결과조회 | `TE` | as | `GET /api/v1/pwrk-cnsltn-mng/{pwrkDocNo}` | 기존 | +| 15 | 실무협의서생성 | `TE` | as | `POST /api/v1/dcrag-to/documents/draft/generate` | 기존 | +| 16 | 저장(실무) | `TE` | as | `POST /api/v1/pwrk-cnsltn-mng/save-apply` | 기존 | +| 17 | 검토완료 | `TE` | elapr | `PUT /api/v1/elapr-prgrs-stts/{id}/process` | 기존 | +| 18 | 보완요청 | `TE→SL` | as | `POST .../reqsupplement` | 기존 | +| 19 | 결재(승인) | `TE` | elapr | `PUT .../process` | 기존 | +| 20 | 결재(회신) | `TE→TP` | elapr | `PUT .../process` | 기존 | +| 21 | 파일보완완료 | `SL→ER` | as | `POST .../submitsupplement` | 기존 | +| 22 | 보완완료(기술) | `SL→TE` | as | 동일 | 기존 | +| 23 | 보완완료(조치) | `SL→RK` | as | 동일 | 기존 | +| 24 | 재원협의 결과 회신 | `TP→RM` | as | `POST .../{docNo}/tech-review` | 기존 | +| 25 | 지자체회신 | `RM→RB` | as | `POST .../doc/updatePrgrsStts` | 기존 | +| 26 | 실무협의서 관리 | `RB` | as | `GET .../pwrk-cnsltn-doc-crt-rslt/{uuid}/webhwp/open` | 기존 | +| 27 | 조치완료 | `RB→RA` | as | `POST /api/v1/pwrk-cnsltn-mng/{pwrkDocNo}/submit-actn-result` (`isSubmit=true`) | **신규** | +| 28 | 조치결과서제출 | `RC→RA` | as | `updatePrgrsStts` | 기존 | +| 29 | 조치결과 검토의뢰 | `RA→AV` | as | `POST /api/v1/fnrs-cnsltn-mng/{docNo}/request-actn-review` | **신규** | +| 30 | 금액검증 콜백 | `AV→EV` | as | `POST /api/v1/dcrag/cost/verify-result` | 서버 | +| 30a | 검토자지정(2차) | `EV→RK` | elapr | `POST /api/v1/elapr-dmnd` (`elaprCycl=2`) | 기존 | +| 31 | 실무협의서관리 | `RK` | as | `GET /pwrk-cnsltn-mng/{pwrkDocNo}` | 기존 | +| 32 | 금액검증 재실행 | `RK` | as | `POST /api/v1/dcrag-to/documents/cost/verify` | 기존 | +| 33 | 보완요청(금액) | `RK→SL` | as | `reqsupplement` | 기존 | +| 34 | 결재(승인) | `RK` | elapr | `process` | 기존 | +| 35 | 결재(회신) | `RK→RR` | elapr | `process` | 기존 | +| 36 | 결과회신 | `RR→EP` | as | `updatePrgrsStts` | 기존 | +| 37 | 결과회신 | `EP→TA` | as | `updatePrgrsStts` | 기존 | +| 38 | 결과회신(확인) | `TA` | — | 없음 | — | +| W1 | 웹기안기 Open | — | as | `GET /api/v1/pwrk-cnsltn-doc-crt-rslt/{uuid}/webhwp/open` | 기존 | +| W2 | HWPX 저장 | — | as | `POST /api/v1/pwrk-cnsltn-doc-crt-rslt` multipart | 기존 | +| W3 | 다운로드 | — | as | `GET /api/v1/pwrk-cnsltn-doc-crt-rslt/{uuid}/download` | 기존 | +| E-N | 결재선 복사 | `AV→RK` 부수 | elapr | `POST /api/v1/elapr-apln/copy-line` | **신규·서버** | + +조회 공통: `GET /fnrs-cnsltn-mng`, `GET /fnrs-cnsltn-mng/{docNo}`, `GET /pwrk-cnsltn-mng`, `GET /dcrag-to/documents/draft/status/{crtDmndUuid}`. + +--- + +## 2. 공통 요청 + +### `DocPrgrsUpdateRequestDto` + +`POST /fnrs-cnsltn-mng/doc/updatePrgrsStts` — `ST→LE`, `RM→RB`, `RC→RA`, `RR→EP`, `EP→TA`. + +```json +{ + "docNo": "F-20260824-0001", + "docSeComCd": "FNRS", + "prgrsSttsComCd": "LE", + "rgtrUuid": "optional-uuid", + "rmrk": "optional" +} +``` + +응답: `204/200` void. 프론트 엔티티: 기존 `changePrgrsSttsComCd`. + +### `FnrsCnsltnApplySaveRequestDto` + +`save-apply`, `tech-review`, `reqsupplement`, `submitsupplement`가 공유. 핵심은 `fnrsCnsltnMng.prgrsSttsComCd`와 중첩 리스트(`bzScaleList`, `bizCstList`, `cklstResultList`, `esntlAtchFileList`, `splmntDmndList`). + +--- + +## 3. 단계별 FE API + +### 3-1. `SV → ST → LE` + +**저장 / 신청완료** — `POST /fnrs-cnsltn-mng/save-apply` +권한: `ROLE_USER05`. 임시저장은 `prgrsSttsComCd=SV`, 신청은 `ST`. +응답: `string` (문서번호). 기존 `fnrsCnsltnMngService.saveApply`. + +**삭제** — `DELETE /fnrs-cnsltn-mng/{docNo}` (`SV`만). + +**환경청제출** — `updatePrgrsStts` `LE`. 권한: `ROLE_USER04`. + +### 3-2. `LE → FV | EP` + +**자체종결** — `POST /fnrs-cnsltn-mng/{docNo}/tech-review` +Body: `FnrsCnsltnApplySaveRequestDto` (환경청 의견·체크리스트). `LE→EP`. +기존 `techReview`. 현재 화면은 `TP`에서도 이 API를 쓰며 목표 상태를 `EP`로 넣고 있다 → **`TP`는 `RM`으로 바꿔야 함**. + +**기술검토 의뢰 (신규)** — `POST /fnrs-cnsltn-mng/{docNo}/request-examine` + +```json +{ "prgrsSttsComCd": "FV" } +``` + +응답: `string` (docNo). `LE→FV` + AI 파일검증 + 30분 타이머(서버). +프론트: `fnrsCnsltnMngService.requestExamine(docNo, body)` 추가. **`updatePrgrsStts("ER")` 금지.** + +### 3-3. `FV → ER → SL → ER` + +파일검증 콜백은 서버. FE는 `FV`에서 상세 refetch. + +**검증결과 목록** — `GET /fnrs-cnsltn-mng/{fnrsCnsltnDocNo}/file-vrfc-results` +응답: `FnrsCnsltnFileVrfcRsltResponseDto[]` + +| 필드 | 설명 | +| --- | --- | +| `fileSeComCd` | `ESSENTIAL` / `ADDITIONAL` | +| `fileSeNm` | 구분명 | +| `esntlAtchFileId` | 필수첨부 ID | +| `atchFileNm` | 파일명 | +| `vrfcRsltComCd` | `PASS` / `FAIL` / `WARN` | +| `vrfcOpnn` | 검증의견 | + +엔티티에 `findFileVrfcResults` 추가. 첨부 행 배지는 상세 `esntlFileList`/`etcFileList`의 `vrfcSttsComCd`, `vrfcRsltCn`, `vrfcDt`도 사용 (타입 확장 필요). + +**파일보완요청** — `POST .../{docNo}/reqsupplement` (`ER→SL`). +**파일보완완료** — `POST .../{docNo}/submitsupplement` (`SL→ER`). 복귀는 서버. + +**파일검증 재실행** — `POST /dcrag-to/documents/transfer` + +```json +{ "fnrsCnsltnDocNo": "F-20260824-0001", "taskSeComCd": "SF", "taskSeCdNm": "하수처리시설" } +``` + +응답: `{ resultCode, message, requestId }`. 상태 유지. 기존 `dcRagApiToService.requestDocumentTransfer`. + +### 3-4. `ER → AC → TE → TP` + +**검토자지정** — `POST /elapr-dmnd` +기존 `elaprDmndService.save`. + +| 필드 | 값 | +| --- | --- | +| `elaprDocNo` | 재원협의 번호 | +| `elaprCycl` | `1` | +| `docPrgrsSttsComCd` | **`AC`** (현행은 현재 상태 `ER`를 넣고 있음 → 수정) | +| `elaprPrgrsSttsList[].atrzOrdr` | 1 검토자, 2 승인자, 3 최종 | + +서버가 실무협의 문서 생성 + RAG 초안 트리거 + 30분 타이머. 응답의 문서번호/`crtDmndUuid`가 있으면 폴링 키로 쓴다. 없으면 `findByUpDocNo`로 복원. + +**초안 상태 폴링** — `GET /dcrag-to/documents/draft/status/{crtDmndUuid}` +기존 `getDraftGenerationStatus`. 주기 5~10초. 종료 `COMPLETED`/`FAILED`. + +**수동 초안생성** — `POST /dcrag-to/documents/draft/generate` (`TE` 유지) + +```json +{ + "fnrsCnsltnDocNo": "F-...", + "pwrkCnsltnDocNo": "P-...", + "crtDmndUuid": "optional" +} +``` + +202 + `DcRagDraftGenerateResponseDto` (`crtDmndUuid`, `crtSttsComCd`, `estimatedMinutes`). +v1.0 가이드의 “이 API를 프론트가 치면 안 된다”는 **폐기**. 1차 트리거는 검토자지정이고, 이 API는 Fallback 전용. + +**실무 저장** — `POST /pwrk-cnsltn-mng/save-apply` → `string`. +**보완요청/완료** — fnrs `reqsupplement` / `submitsupplement`. + +**결재** — `PUT /elapr-prgrs-stts/{elaprPrgrsId}/process` + +```json +{ + "atrzPrgrsSttsComCd": "ATRZ_PRGRS_003", + "atrzOpnnCn": "의견", + "finalApprovalYn": false +} +``` + +반려: `ATRZ_PRGRS_004`. 기존 `elaprPrgrsSttsService.process`. 최종 시 서버가 `TP`/`RR` 전이. + +### 3-5. `TP → RM → RB → RA` + +**환경청 1차 회신** — `tech-review` (`TP→RM`). +**광역 지자체회신** — `updatePrgrsStts` `RB`. + +**조치완료 (신규)** — `POST /pwrk-cnsltn-mng/{pwrkDocNo}/submit-actn-result` + +```json +{ + "isSubmit": true, + "actnList": [ + { + "rvwOpnnSn": 1, + "actnRsltCn": "수용 — 관로 연장 재산정", + "atchFileGroupId": "optional" + } + ], + "totActnSummary": "제출 시 권장", + "muniDocNo": "제출 시 권장" +} +``` + +- `isSubmit: false` → `RB` 유지 (API만 존재, **화면 버튼 없음**. HWP·첨부는 실무협의서 관리 다이얼로그에서 저장) +- `isSubmit: true` → `RB→RA` (조치완료) +응답: `string` (pwrkDocNo). DTO명 `PwrkActnResultSubmitDto`. + +### 3-6. `RA → AV → EV → RK` +**2차 의뢰 (신규)** — `POST /fnrs-cnsltn-mng/{docNo}/request-actn-review` + +```json +{ + "specialRqstCn": "중점 검증 요청 (선택)" +} +``` + +`RA→AV`. 응답: `string` (docNo). DTO명 `EnvoActnReviewReqDto`. `specialRqstCn`만 선택. + +금액검증 콜백은 서버 → `AV→EV`. +**2차 검토자지정** — `POST /elapr-dmnd` (`elaprCycl: 2`, `docPrgrsSttsComCd: RK`). 1차 결재선(`findDocPrgrsByDocNo`, cycl=1)을 시드로 편집. + +**금액검증 재실행** — `POST /dcrag-to/documents/cost/verify` + +```json +{ + "fnrsCnsltnDocNo": "F-...", + "pwrkCnsltnDocNo": "P-..." +} +``` + +202. 기존 `requestCostVerification`. `RK` 유지. + +### 3-7. `RK → RR | SL` + +보완은 3-4와 동일 API. 팝업만 금액 체크. +결재 `process` 2차 최종 → 서버가 `RR`. + +### 3-8. `RR → EP → TA` + +둘 다 `updatePrgrsStts`. `TA` 확인은 API 없음. + +--- + +## 4. 웹기안기 + +설계서 기준 **기존**. 구현 시 컨트롤러 존재 여부를 확인한다. + +| ID | 용도 | URI | 응답 | +| --- | --- | --- | --- | +| W1 | 웹기안기 Open | `GET .../pwrk-cnsltn-doc-crt-rslt/{crtDmndUuid}/webhwp/open` | `inline` HWPX blob → `HwpCtrl.Open(File)` | +| W2 | 편집 저장 | `POST .../pwrk-cnsltn-doc-crt-rslt` multipart `file` + `crtDmndUuid` | 201, `verSn+1` | +| W3 | PC 저장 | `GET .../{crtDmndUuid}/download` | `attachment` blob | + +버전 지정: `GET .../{crtDmndUuid}/{verSn}/webhwp/open`, `.../{verSn}/download`. +`/{uuid}/latest`, `/{uuid}/download`가 `/{uuid}/{verSn}`보다 우선. path를 섞지 말 것. + +다운로드: + +- axios `responseType: "blob"` +- 파일명: `Content-Disposition` `filename*=UTF-8''...` (`parseFilenameFromResponse`) +- 서버 파일명: `재원협의 실무협의서({bizNm})_v{verSn}.hwp` +- Content-Type은 `application/x-hwp` 또는 `application/haansofthwpx`일 수 있음. 타입에 의존하지 말 것 + +Open은 axios `responseType: "blob"`으로 인증된 API를 호출하고, 응답 Blob을 HWPX `File`로 감싸 `HwpCtrl.Open`에 전달한다. `blob:` Object URL은 사용하지 않는다. + +multipart `Content-Type` 헤더를 수동 지정하지 않는다 (boundary). JSON `saveJson`은 UI에서 쓰지 않는다. + +--- + +## 5. 전자결재 (`as-elapr-service`) + +| API | URI | FE | +| --- | --- | --- | +| 검토자지정·1차 결재요청 | `POST /elapr-dmnd` | O | +| 결재 처리 | `PUT /elapr-prgrs-stts/{id}/process` | O | +| 결재선 복사 | `POST /elapr-apln/copy-line` | **X (서버)** | + +`copy-line` body (참고만): + +```json +{ + "docNo": "P-20260827-0001", + "sourceElaprCycl": 1, + "targetElaprCycl": 2, + "rgtrUuid": "system-uuid" +} +``` + +`RK`에서 결재현황은 `findDocPrgrsByDocNo`(전체) 조회 후 `elaprCycl`로 1차/2차 탭 표시. + +--- + +## 6. 프론트 호출 금지 + +| URI | 이유 | +| --- | --- | +| `POST /dcrag/documents/verify-result` | AI→AS 콜백 | +| `POST /dcrag/draft/complete` | AI→AS 콜백 | +| `POST /dcrag/cost/verify-result` | AI→AS 콜백. 여기서 copy-line도 호출 | +| `POST /elapr-apln/copy-line` | 콜백 Feign | +| `POST /pwrk-cnsltn-mng` (`save` 초안 트리거) | v3.0 1차 트리거는 `elapr-dmnd`. 컨트롤러가 남아 있어도 화면에서 쓰지 않음 | + +`POST /dcrag-to/documents/draft/generate`는 **Fallback에서만** 허용. + +--- + +## 7. Gap Analysis (프론트 엔티티) + +### 이미 있는 것 + +`fnrsCnsltnMngService`: `findPage`, `findById`, `save`, `saveApply`, `update`, `techReview`, `submitSupplement`, `submitReqSupplement`, `remove`, `changePrgrsSttsComCd` +`dcRagApiToService`: `requestDocumentTransfer`, `requestDraftGeneration`, `getDraftGenerationStatus`, `requestCostVerification` +`elaprDmndService.save`, `elaprPrgrsSttsService.process` + +### 추가할 것 + +| 메서드 | URI | +| --- | --- | +| `requestExamine` | `POST /fnrs-cnsltn-mng/{id}/request-examine` | +| `findFileVrfcResults` | `GET /fnrs-cnsltn-mng/{id}/file-vrfc-results` | +| `requestActnReview` | `POST /fnrs-cnsltn-mng/{id}/request-actn-review` | +| `pwrkCnsltnMngService.*` | 슬라이스 신규 | +| `submitActnResult` | `POST /pwrk-cnsltn-mng/{id}/submit-actn-result` | +| `pwrkCnsltnDocCrtRsltService.*` | 슬라이스 신규 | + +`copyLine`은 프론트 서비스에 넣지 않는다. diff --git a/apps/as/docs/pwrk-cnsltn/ref-frontend-entities.md b/apps/as/docs/pwrk-cnsltn/ref-frontend-entities.md new file mode 100644 index 000000000..fdb817e08 --- /dev/null +++ b/apps/as/docs/pwrk-cnsltn/ref-frontend-entities.md @@ -0,0 +1,350 @@ +# 레퍼런스 — 프론트 엔티티·DTO + +> 상위: [개발가이드.md](./개발가이드.md) +> 엔티티 규칙: `frontend/.cursor/skills/backend-controller-to-entity` + +--- + +## 1. ENDPOINT + +`apps/as/src/shared/config/api.ts` + +```ts +AS_SERVICE: { + // 기존 + FNRS_CNSLTN_MNG: "/as-service/api/v1/fnrs-cnsltn-mng", + DCRAG_TO: "/as-service/api/v1/dcrag-to", + // 추가 + PWRK_CNSLTN_MNG: "/as-service/api/v1/pwrk-cnsltn-mng", + PWRK_CNSLTN_DOC_CRT_RSLT: "/as-service/api/v1/pwrk-cnsltn-doc-crt-rslt", +} + +AS_ELAPR_SERVICE: { + ELAPR_TMPLT: "/as-elapr-service/api/v1/elapr-tmplt", + ELAPR_APLN: "/as-elapr-service/api/v1/elapr-apln", + ELAPR_DMND: "/as-elapr-service/api/v1/elapr-dmnd", + ELAPR_PRGRS_STTS: "/as-elapr-service/api/v1/elapr-prgrs-stts", +} +``` + +`copy-line`은 `ELAPR_APLN` 하위이나 FE에서 호출하지 않음. + +--- + +## 2. 슬라이스 배치 + +``` +entities/fnrs-cnsltn/ # 확장 +entities/dcrag-to/ # 구현됨 +entities/elapr/ # 구현됨 +entities/pwrk-cnsltn-mng/ # 신규 +entities/pwrk-cnsltn-doc-crt-rslt/ # 신규 +``` + +``` +entities/pwrk-cnsltn-mng/ +├── model/ +│ ├── pwrk-cnsltn-mng-interface.ts +│ └── pwrk-cnsltn-mng-payload-schema.ts +├── api/ +│ ├── pwrk-cnsltn-mng-service.ts +│ └── queries.ts +└── index.ts +``` + +`submit-actn-result` Request는 zod SSOT (`PwrkActnResultSubmitDto`). +`request-examine` / `request-actn-review`도 fnrs payload-schema에 추가. + +라우트: + +| 화면 | 경로 | +| --- | --- | +| 재원협의 목록/상세/작성 | 기존 `app/(main)/(with-base-layout)/fnrs-cnsltn/...` | +| 실무협의 목록 | `.../pwrk-cnsltn/page.tsx` (선택) | +| 실무협의 상세 | `.../pwrk-cnsltn/[id]/page.tsx` (선택) | +| 페이지 UI | `pages/fnrs-cnsltn/`, `pages/pwrk-cnsltn/` | + +--- + +## 3. Service 시그니처 + +`fnrsCnsltnMngService`에 추가: + +```ts +requestExamine(docNo: string, body: FnrsExamineRequestDto): Promise; +findFileVrfcResults(fnrsCnsltnDocNo: string): Promise; +requestActnReview(docNo: string, body: EnvoActnReviewReqDto): Promise; +``` + +실무협의: + +```ts +export interface PwrkCnsltnMngService { + findPage(searchDto: PwrkCnsltnMngSearchDto): Promise>; + findById(pwrkCnsltnDocNo: string): Promise; + findByUpDocNo(upDocNo: string): Promise; + saveApply(body: PwrkCnsltnApplySaveRequestDto): Promise; + submitActnResult( + pwrkCnsltnDocNo: string, + body: PwrkActnResultSubmitDto, + ): Promise; + update(pwrkCnsltnDocNo: string, body: PwrkCnsltnMngUpdateRequestDto): Promise; + remove(pwrkCnsltnDocNo: string): Promise; + changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise; +} +``` + +v1.0의 `save(): PwrkCnsltnMngSaveResponseDto`(초안 트리거)는 화면에서 사용하지 않는다. 컨트롤러가 남아 있으면 엔티티에 넣더라도 UI 연결 금지. + +생성결과: + +```ts +export interface PwrkCnsltnDocCrtRsltService { + findPage( + searchDto: PwrkCnsltnDocCrtRsltSearchDto, + ): Promise>; + findById(crtDmndUuid: string, verSn: number): Promise; + findLatestByCrtDmndUuid(crtDmndUuid: string): Promise; + openWebHwpLatest(crtDmndUuid: string): Promise>; + download(crtDmndUuid: string, verSn: number): Promise>; + downloadLatest(crtDmndUuid: string): Promise>; + saveMultipart(formData: FormData): Promise; + updateMultipart( + crtDmndUuid: string, + verSn: number, + formData: FormData, + ): Promise; + remove(crtDmndUuid: string, verSn: number): Promise; +} +``` + +`webhwp/open`은 인증된 axios 요청으로 Blob을 조회한다: + +```ts +async function openWebHwpLatest(crtDmndUuid: string) { + return client.get(`${BASE}/${crtDmndUuid}/webhwp/open`, { + responseType: "blob", + }); +} +``` + +응답 Blob은 HWPX `File`로 감싸 `HwpCtrl.Open(file, "HWPX", ...)`에 전달한다. `blob:` Object URL은 사용하지 않는다. + +DC RAG TO (구현됨, 시그니처 유지): + +```ts +requestDocumentTransfer(body: DcRagDocumentTransferRequestDto): Promise; +requestDraftGeneration(body: DcRagDraftGenerateRequestDto): Promise; +getDraftGenerationStatus(crtDmndUuid: string): Promise; +requestCostVerification(body: DcRagCostVerifyRequestDto): Promise; +``` + +--- + +## 4. DTO + +타입명 = 백엔드 클래스명. Save/Apply는 zod. + +### 4-1. 재원협의 추가 + +**`FnrsExamineRequestDto`** + +| 필드 | 필수 | 값 | +| --- | --- | --- | +| `prgrsSttsComCd` | N | 기본 `FV` | + +**`EnvoActnReviewReqDto`** + +| 필드 | 필수 | +| --- | --- | +| `specialRqstCn` | N | + +**`FnrsCnsltnFileVrfcRsltResponseDto`**: [ref-endpoint.md](./ref-endpoint.md) §3-3. + +첨부 검증 배지용으로 `AtflMngClientResponseDto` / `EsntlAtchFileMngTreeResponseDto`에 `vrfcDt`, `vrfcRsltCn`, `vrfcSttsComCd` 확장. + +### 4-2. `PwrkActnResultSubmitDto` (zod) + +| 필드 | 필수 | 비고 | +| --- | --- | --- | +| `isSubmit` | Y | `false` API만(화면 버튼 없음), `true` 조치완료(`RB→RA`) | +| `actnList` | N | 조치완료 시 선택 | +| `actnList[].rvwOpnnSn` | Y (항목 있을 때) | | +| `actnList[].actnRsltCn` | N | | +| `actnList[].atchFileGroupId` | N | | +| `totActnSummary` | N | 제출 시 권장 | +| `muniDocNo` | N | 제출 시 권장 | +| `pwrkAtchFileGroupId` | N | | + +### 4-3. `PwrkCnsltnMng*` + +**Search**: `pwrkCnsltnDocNo`, `upDocNo`, `upDocSeComCd`, `prgrsSttsComCd`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `bizNm` + +**DetResponse 중첩** + +- `cnsltnList`: 협의내역 (`rvwOpnnSn`, `rvwOpnnClsfComCd`, `cnsltnCn`, `rfltYn`, …) +- `pjtcoAjmtList`: 사업비조정 +- `crtDmndList[].crtRslt`: 생성요청 + 최신 결과 +- `splmntDmndList`: 보완요청 +- `sttsChgHstryList`: 상태변경이력 — `SL` 복귀 라벨에 사용 +- `trsmFileList`: 전송파일 (Java Det DTO 확인 후 반영) + +마스터 조인: `bizNm`, `admdstCd`, `envoSeComCd`, `taskSeComCd`, `aplcntNm` 등. + +`crtRslt.orgnlOtptDocCn`은 항상 `null`. + +**상세 예** + +```json +{ + "pwrkCnsltnDocNo": "P-20260827-0001", + "upDocNo": "F-20260824-0001", + "upDocSeComCd": "FNRS", + "prgrsSttsComCd": "TE", + "cnsltnList": [], + "pjtcoAjmtList": [], + "crtDmndList": [ + { + "crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a", + "pwrkCnsltnDocNo": "P-20260827-0001", + "rqstrUuid": "...", + "rqstrNm": "관리자", + "crtSttsComCd": "C", + "crtRslt": { + "crtDmndUuid": "d0545bef-735f-4d7b-a781-88e96e03577a", + "verSn": 1, + "rgtrNm": "관리자", + "orgnlOtptDocCn": null, + "crtCmptnDt": "2026-08-27 10:47:01" + } + } + ], + "splmntDmndList": [], + "sttsChgHstryList": [] +} +``` + +### 4-4. `PwrkCnsltnDocCrtRslt*` + +복합 PK: `crtDmndUuid` + `verSn` + +**Response** + +| 필드 | 타입 | 비고 | +| --- | --- | --- | +| `crtDmndUuid` | `string` | | +| `verSn` | `number` | | +| `rgtrUuid` / `rgtrNm` | `string` | | +| `orgnlOtptDocCn` | `null` | 목록/상세 미포함 | +| `crtCmptnDt` | `string` | | + +**Search** (`PageableParams`): `crtDmndUuid`(이력 조회 시 필수), `pwrkCnsltnDocNo`, `verSn`, `rgtrUuid` + +```http +GET /as-service/api/v1/pwrk-cnsltn-doc-crt-rslt?page=0&size=10&crtDmndUuid={uuid}&sort=verSn,desc +``` + +**Save (zod)**: `crtDmndUuid` 필수. `rgtrUuid` 생략 시 서버 로그인 사용자. `file`은 multipart part (DTO 필드 아님). + +### 4-5. DC RAG (구현됨) + +```ts +interface DcRagDocumentTransferRequestDto { + fnrsCnsltnDocNo: string; + taskSeComCd?: string; + taskSeCdNm?: string; +} + +interface DcRagDraftStatusResponseDto { + crtDmndUuid: string; + status: DraftStatus; + statusName?: string; + progressPercent?: number; + events?: EventInfoDto[]; + startedDt?: string; + estimatedCompletionDt?: string; + errorMessage?: string | null; +} + +type DraftStatus = + | "REQUESTED" + | "DOWNLOADING" + | "ANALYZING" + | "GENERATING" + | "COMPLETED" + | "FAILED"; +``` + +`estimatedMinutes`는 초안 응답에 있을 수 있으나 UX 안내는 **30분 Fallback**을 따른다. + +--- + +## 5. 폴링 훅 + +초안 (`AC` 또는 `TE`이면서 생성 중): + +```ts +useQuery({ + queryKey: [...dcragToKeys.draftStatus(crtDmndUuid)], + queryFn: () => dcRagApiToService.getDraftGenerationStatus(crtDmndUuid), + enabled: Boolean(crtDmndUuid) && isPolling, + refetchInterval: (query) => { + const status = query.state.data?.status; + if (status === "COMPLETED" || status === "FAILED") return false; + return 5_000; + }, +}); +``` + +`COMPLETED` 시 `fnrs-cnsltn` 상세·`pwrk-cnsltn-mng` `findByUpDocNo` invalidate. + +`FV`/`AV`는 `findById`를 5~10초 refetch. `prgrsSttsComCd`가 `ER`/`RK`가 되면 중단. 시작 시각 기준 30분 경과 안내 문구. + +`crtDmndUuid` 복원: `findByUpDocNo(fnrsCnsltnDocNo).crtDmndList[0]`. + +--- + +## 6. 다운로드 + +```ts +const response = await client.get(url, { responseType: "blob" }); +const filename = parseFilenameFromResponse(response); +const objectUrl = window.URL.createObjectURL(response.data); +``` + +기존 `attachService`와 동일. + +--- + +## 7. 실무협의 목록 컬럼 (선택 화면) + +| 표시명 | Field | +| --- | --- | +| 실무협의번호 | `pwrkCnsltnDocNo` | +| 재원협의번호 | `upDocNo` | +| 진행상태 | `prgrsSttsComCdNm` | +| 사업명 | `bizNm` | +| 시도/구군 | `sido` / `gugun` | +| 신청자 | `aplcntNm` | +| 등록일 | `regDt` | + +행 클릭 → `/pwrk-cnsltn/{pwrkCnsltnDocNo}` 또는 재원협의 상세로 이동 (화면설계 확정 후). + +--- + +## 8. `elapr-dmnd` 저장 시 주의 + +현행 `floating-action-buttons.tsx`: + +```ts +elaprCycl: data.detDto?.prgrsSttsComCd === "RK" ? 2 : 1, +docPrgrsSttsComCd: data.detDto?.prgrsSttsComCd, // ER +``` + +To-Be: + +- 검토자지정은 **`ER`(1차)** 과 **`EV`(2차)** 에서. +- `ER`: `elaprCycl: 1`, `docPrgrsSttsComCd: "AC"`, 서식 선택 다이얼로그 +- `EV`: `elaprCycl: 2`, `docPrgrsSttsComCd: "RK"`, 1차 결재선 시드 다이얼로그(서식 없음) + +`RK` / ATRZ02 FAB: `findDocPrgrsByDocNo` 전체 조회 후 pending은 `elaprCycl === 2`만 사용. 검토완료 후 `ElaprApprovalDialog`에 `elaprCycl={2}`. 보완요청은 TE와 동일 `SplmntDmndDialog`. diff --git a/apps/as/docs/pwrk-cnsltn/ref-process-status.md b/apps/as/docs/pwrk-cnsltn/ref-process-status.md new file mode 100644 index 000000000..d5d55171b --- /dev/null +++ b/apps/as/docs/pwrk-cnsltn/ref-process-status.md @@ -0,0 +1,246 @@ +# 레퍼런스 — 프로세스·상태 전이 + +> 상위: [개발가이드.md](./개발가이드.md) +> SoT: `03.재원협의_실무협의_프로세스_통합_v완결.md` (v3.0, 2026-09-03) + +--- + +## 1. 프로세스 개요 + +| 항목 | 내용 | +| --- | --- | +| Process No. | PR-AS-003 | +| 메뉴 | 행정지원 > 2.2.2 재원협의 > 2.2.2.1 재원협의 관리 | +| 목적 | 기초 → 광역(경유) → 환경청(승인) → 공단(기술검토/AI) 4자 협의로 사업규모·국비·분담금을 확정 | + +2-Pass: + +- **Pass 1** 기술 타당성: `SV→ST→LE→FV→ER→AC→TE→TP→RM→RB` +- **Pass 2** 조치결과·금액: `RB→RA→AV→EV→RK→RR→EP→TA` + +자체종결(기술검토 생략): `LE→EP→TA` + +--- + +## 2. 4-Lane 시퀀스 + +```mermaid +sequenceDiagram + autonumber + actor 기초 as 기초 (ROLE_USER05) + actor 광역 as 광역 (ROLE_USER04) + actor 환경청 as 환경청 (ROLE_USER03) + actor 공단 as 공단 (ROLE_USER01) + actor AI as AI 엔진 + + rect rgb(240, 248, 255) + 기초->>기초: [저장] SV + 기초->>광역: [신청완료] ST + 광역->>환경청: [환경청제출] LE + end + + rect rgb(255, 250, 240) + alt 자체종결 + 환경청->>광역: [결과회신] LE → EP + 광역->>기초: [결과회신] EP → TA + else 기술검토 의뢰 + 환경청->>AI: [기술검토 의뢰] LE → FV + end + end + + rect rgb(245, 255, 245) + AI-->>공단: 파일검증 콜백 FV → ER + Note over 공단: 30분 미응답 시 ER + 수동 [파일검증] + alt 파일 미비 + 공단->>기초: [파일보완] ER → SL + 기초->>공단: [파일보완완료] SL → ER + else 적격 + 공단->>AI: [검토자지정] ER → AC + end + end + + rect rgb(255, 245, 245) + AI-->>공단: 초안 콜백 AC → TE + Note over 공단: 30분 미응답 시 TE + 수동 [실무협의서생성] + alt 설계 미비 + 공단->>기초: [보완요청] TE → SL + 기초->>공단: [보완완료] SL → TE + else 검토 완료 + 공단->>공단: [검토완료] 상신, [결재(승인)] TE 유지 + 공단->>환경청: [결재(회신)] TE → TP + end + end + + rect rgb(250, 240, 255) + 환경청->>광역: [재원협의 결과 회신] TP → RM + 광역->>기초: [지자체회신] RM → RB + 기초->>환경청: [조치완료] RB → RA + end + + rect rgb(240, 255, 255) + 광역->>환경청: [조치결과서제출] RC → RA + 환경청->>AI: [조치결과 검토의뢰] RA → AV + AI-->>공단: 금액검증 콜백 AV → EV + 공단->>공단: [검토자지정] EV → RK + Note over 공단: EV에서 1차 결재선 시드로 2차 결재선 수동 배정 + end + + rect rgb(255, 255, 230) + alt 소명 미비 + 공단->>기초: [보완요청] RK → SL + 기초->>공단: [보완완료] SL → RK + else 확정 + 공단->>환경청: [결재(회신)] RK → RR + end + end + + rect rgb(255, 240, 240) + 환경청->>광역: [결과회신] RR → EP + 광역->>기초: [결과회신] EP → TA + end +``` + +거버넌스: + +``` +기초 (USER05) --ST--> 광역 (USER04) --LE--> 환경청 (USER03) +기초 <-----TA------ 광역 <-----EP/RM------ 환경청 +환경청 --FV/ER--> 공단 (USER01) --TP/RR--> 환경청 +``` + +--- + +## 3. Activity 정의 (14) + +| No. | Activity | 주체 | From → To | FE 포인트 | +| --- | --- | --- | --- | --- | +| 1 | 재원협의 조회 | 기초 | — | 기본계획·자가점검 대조 | +| 2 | 등록 및 제출 | 기초·광역 | `SV→ST`, 광역 `LE` | `save-apply`, `updatePrgrsStts` | +| 3 | 접수 및 의뢰 | 환경청 | `LE→FV` / `EP` | 의뢰 vs 자체종결 분기 | +| 4 | AI 파일검증 | 공단·AI | `FV→ER` / `SL` | 30분 Fallback, 파일보완 팝업 | +| 5 | 자체종결 회신 | 환경청·광역 | `LE→EP→TA` | 공단 레인 없음 | +| 6 | 검토자지정·초안 | 공단·AI | `ER→AC→TE` | `elapr-dmnd`, 초안 폴링 | +| 7 | 1차 기술검토 | 공단 결재선 | `TE` / `SL` | 웹기안기, HWP 파싱은 서버 | +| 8 | 기술검토 회신 | 공단 최종 | `TE→TP` | `process` 최종 | +| 9 | 검토결과 회신 | 환경청·광역 | `TP→RM→RB` | 1차 회신. `EP`와 혼동 금지 | +| 10 | 조치결과 작성 | 기초 | `RB→RA` | `submit-actn-result` `isSubmit:true` (조치완료) | +| 11 | 조치 의뢰 | 환경청 | `RA→AV` | `request-actn-review` | +| 12 | AI 금액검증 | AI·공단 | `AV→EV` | 콜백/Fallback 후 접수자 대기 | +| 12a | 2차 검토자지정 | 공단 접수 | `EV→RK` | `elapr-dmnd` cycl=2 | +| 13 | 2차 검토·결재 | 공단 | `RK→RR` / `SL` | 2차 결재선 | +| 14 | 최종 회신 | 환경청·광역·기초 | `RR→EP→TA` | 종결 | + +--- + +## 4. `FNRSSTTS` 상세 + +관리: `tb_cs_lwpo_cd` / `tb_as_fnrs_cnsltn_mng.prgrs_stts_com_cd` + +| 코드 | 국문 | 영문 | 화면 주체 | 전이 | +| --- | --- | --- | --- | --- | +| `SV` | 저장 | Save | 기초 작성 | 신규/`SV` → `SV` | +| `ST` | 신청완료 | Submit to Province | 기초→광역 | `SV→ST` | +| `LE` | 환경청 검토 | Local Environment Review | 광역→환경청 | `ST→LE` | +| `FV` | 파일검증 | File Verification | AI | `LE→FV` | +| `ER` | 기술검토 의뢰 | Examine Request | 공단 접수자 | `FV→ER`, `SL→ER` | +| `SL` | 보완요청 | Supplement Loop | 공단→기초 | `ER\|TE\|RK → SL` | +| `AC` | 초안생성 | AI Auto Creation | AI | `ER→AC` | +| `TE` | 기술검토 | Technical Examine | 공단 1차 결재 | `AC→TE`, `SL→TE` | +| `TP` | 기술검토 회신 | Technical Pass to Basin | 공단→환경청 | `TE→TP` | +| `RM` | 재원협의결과회신 | Result Message to Province | 환경청→광역 | `TP→RM` | +| `RB` | 재원협의결과회신(지자체) | Result Back to Municipality | 광역→기초 | `RM→RB` | +| `RC` | 조치결과서 제출 | Result Complete | 기초→광역 | `RB→RC` | +| `RA` | 조치결과서 제출(환경청) | Result Ask to Basin | 기초→환경청 | `RB→RA` | +| `AV` | 금액검증 | Amount Verification | AI | `RA→AV` | +| `EV` | 조치결과검토의뢰 | Examine Verify | 공단 접수자 | `AV→EV` | +| `RK` | 조치결과서검토 | Result Check | 공단 2차 결재 | `EV→RK`, `SL→RK` | +| `RR` | 조치결과서검토 회신 | Result Return to Basin | 공단→환경청 | `RK→RR` | +| `EP` | 검토결과 회신 | Examine Pass to Province | 환경청→광역 | `LE→EP`(자체), `RR→EP`(최종) | +| `TA` | 검토결과 회신(종료) | Technical Approval | 광역→기초 | `EP→TA` | + +### 4-1. v2.1 → v3.0 대조 (프론트 폐기 대상) + +| 구간 | v2.1 | v3.0 | +| --- | --- | --- | +| 의뢰 직후 파일검증 | `ER` | **`FV` → `ER`** | +| 검토자지정 후 초안 | `EE` → `DV` | **`ER` → `AC` → `TE`** | +| 1차 내부 결재 | `TR` → `TP` | **`TE` 유지 → `TP`** | +| 환경청→광역 1차 회신 | `EP` | **`RM`** | +| 금액검증 | `RA` | **`RA` → `AV` → `RK`** | +| 공단 2차 회신 | `TP` | **`RR`** | +| 최종 완료 | `TP` / 고시 `TB` | **`TA`** (고시 없음) | + +가이드 v1.0 `PWRKSTTS`(`DV`, `AR`, `TR`, `AA` 등)도 동일하게 폐기. + +--- + +## 5. 보완(`SL`) 3루프 + +복귀 상태는 `tb_as_doc_stts_chg_hstry` 직전 코드. 프론트는 복귀 코드를 요청에 넣지 않고, 라벨만 이력으로 고른다. + +```mermaid +stateDiagram-v2 + ER --> SL: 파일보완 팝업(파일리스트) + SL --> ER: 파일보완완료 + TE --> SL: 보완 검토팝업() + SL --> TE: 보완완료 + RK --> SL: 보완 검토팝업(금액) + SL --> RK: 보완완료 +``` + +| 루프 | 팝업 | 요청 | 제출 버튼 | +| --- | --- | --- | --- | +| 파일 무결성 | 파일리스트 + 검증결과 | 공단 접수자 `ER` | [파일보완완료] | +| 1차 기술 | 설계 미달·사유 | 공단 검토자 `TE` | [보완완료] | +| 2차 금액 | 공종별 금액 체크 | 공단 검토자 `RK` | [보완완료] | + +--- + +## 6. AI 3파이프라인·30분 Fallback + +| ID | 단계 | 진입 → 완료 | 프론트 진입 | Fallback | 수동 재실행 | +| --- | --- | --- | --- | --- | --- | +| AI-1 | 파일 무결성 | `FV→ER` | 환경청 [기술검토 의뢰] | 30분 → `ER` + [파일검증] | `POST /dcrag-to/documents/transfer` | +| AI-2 | 초안 생성 | `AC→TE` | 공단 [검토자지정] | 30분 → `TE` + [실무협의서생성] | `POST /dcrag-to/documents/draft/generate` | +| AI-3 | 금액 검증 | `AV→EV` | 환경청 [조치결과 검토의뢰] | 30분 → `EV` + 수동 [금액검증] | `POST /dcrag-to/documents/cost/verify` | + +- 예상 소요 안내는 **30분** (v1.0의 10분은 폐기). +- 콜백 수신·스케줄러 전이는 서버. FE는 상세 refetch / draft status 폴링만. +- `AC→TE` 성공 시에만 [실무협의서관리]·[검토결과조회] 활성. 실패·타임아웃이면 [실무협의서생성]. + +초안 폴링 이벤트 예: + +| `eventType` | `eventMessage` | +| --- | --- | +| `REQUESTED` | 요청을 접수했습니다. | +| `DOWNLOADING` | 신청서를 수신하는 중입니다. | +| `ANALYZING` | 자료 준비가 완료되었습니다. | +| `GENERATING` | 문서 저장이 완료되었습니다. | +| `COMPLETED` | 문서 생성이 완료되었습니다. | +| `FAILED` | `errorMessage` 표시 | + +종료: `status === "COMPLETED" | "FAILED"` 또는 `events`에 해당 `eventType`. + +--- + +## 7. 2차 결재선 (수동 검토자지정) + +1. `TE`에서 1차 결재선(`elaprCycl = 1`) 완료. +2. 금액검증 콜백/Fallback → `AV→EV`. +3. 공단 접수자가 `EV`에서 [검토자지정]: 1차 결재선을 시드로 편집 후 `POST /elapr-dmnd` (`elaprCycl: 2`) → `RK`. +4. `copy-line` 자동 복사는 사용하지 않는다. + +결재 `process`: + +- 상신·중간 승인: 문서 상태 `TE` 또는 `RK` 유지. +- 1차 최종: 서버가 `TE→TP`. +- 2차 최종: 서버가 `RK→RR`. +- 반려: 직전 결재자 복귀. 문서 상태 유지. + +--- + +## 8. 웹기안기 + +- `TE`/`RK` [실무협의서관리] → `webhwp/open` Blob 조회 후 팝업에서 `HwpCtrl.Open(File)`. +- [검토완료] 시 HWP 파싱·DB 등재는 **서버**. FE는 결재 `process`만. +- 편집본 저장: `POST /pwrk-cnsltn-doc-crt-rslt` multipart. `verSn`은 클라이언트가 보내지 않음. diff --git a/apps/as/docs/pwrk-cnsltn/ref-screen-buttons.md b/apps/as/docs/pwrk-cnsltn/ref-screen-buttons.md new file mode 100644 index 000000000..9fb11d306 --- /dev/null +++ b/apps/as/docs/pwrk-cnsltn/ref-screen-buttons.md @@ -0,0 +1,181 @@ +# 레퍼런스 — 화면·버튼 매트릭스 + +> 상위: [개발가이드.md](./개발가이드.md) +> SoT: `03` §4 화면 및 버튼별 상세 상태전이 명세서 (Row 4~42, 38행) + +구현 위치: `pages/fnrs-cnsltn/lib/button-config.ts` + `ui/components/floating-action-buttons.tsx` + +--- + +## 1. 화면 구분 + +| 화면 | 소유 | 라우트 (현행) | 비고 | +| --- | --- | --- | --- | +| 작성화면 | 기초 `ROLE_USER05` | `/fnrs-cnsltn/new`, `/fnrs-cnsltn/{id}/edit` | `SV` | +| 수정화면 | 기초 | `/fnrs-cnsltn/{id}/edit` | `SL` 보완 | +| 상세화면 | 광역·환경청·공단·기초(`RB`/`TA`) | `/fnrs-cnsltn/{id}` | 조회 + 액션 버튼 | +| 자동화 | AI | 버튼 없음 | `FV`/`AC`/`AV` 진행 패널 | + +공단 상세는 결재선 구분으로 같은 상태라도 버튼이 다르다. + +| `atrzSeComCd` | 역할 | +| --- | --- | +| `ATRZ01` | 접수자 | +| `ATRZ02` | 검토자 | +| `ATRZ03` | 승인자 | +| `ATRZ99` | 최종승인자 | + +--- + +## 2. 38행 매트릭스 + +API 컬럼은 [ref-endpoint.md](./ref-endpoint.md)와 동일. + +| 순번 | 화면 | 권한 | 결재선 | 상태 | 버튼 | 전이 | 다음 담당 | FE 동작 | +| :---: | --- | --- | --- | --- | --- | --- | --- | --- | +| 1 | 작성 | USER05 | - | 빈값, `SV` | 저장 | `SV` | 본인 | `save-apply` `SV` | +| 2 | 작성 | USER05 | - | `SV` | 신청완료 | `ST` | 광역 | `save-apply` `ST` | +| 3 | 작성 | USER05 | - | `SV` | 삭제 | 삭제 | — | `DELETE` | +| 4 | 상세 | USER04 | - | `ST` | 환경청제출 | `LE` | 환경청 | `updatePrgrsStts` | +| 5 | 상세 | USER03 | - | `LE` | 결과회신 | `EP` | 광역 | `tech-review` 자체종결 | +| 6 | 상세 | USER03 | - | `LE` | 기술검토 의뢰 | `FV` | 공단 접수 | **`request-examine`** | +| 7 | 자동화 | AI | - | `FV` | (파일검증) | `ER` | 공단 접수 | FE 호출 없음. 진행 패널 | +| 8 | 상세 | USER01 | 접수자 | `ER` | 파일보완 | `SL` | 기초 | 팝업(파일리스트) → `reqsupplement` | +| 9 | 상세 | USER01 | 접수자 | `ER` | 파일검증 | `ER` | 본인 | `dcrag-to/transfer` 재실행 | +| 10 | 상세 | USER01 | 접수자 | `ER` | 검토자지정 | `AC` | 다음 결재자 | `POST /elapr-dmnd` | +| 11 | 자동화 | AI | - | `AC` | (초안생성) | `TE` | 공단 검토 | FE 호출 없음. 폴링 | +| 12 | 상세 | USER01 | 검토자 | `TE` | 실무협의서관리 | 유지 | — | 웹기안기 Open. **초안 성공 시만** | +| 13 | 상세 | USER01 | 검토자 | `TE` | 검토결과조회 | 유지 | — | 실무협의 상세 | +| 14 | 상세 | USER01 | 검토자 | `TE` | 실무협의서생성 | 유지 | — | **실패/타임아웃/초안 없음** 시 | +| 15 | 상세 | USER01 | 검토자 | `TE` | 저장 | `TE` | — | `pwrk save-apply` | +| 16 | 상세 | USER01 | 검토자 | `TE` | 검토완료 | `TE` | 다음 결재자 | `elapr process` 상신. 반려 시 직전 결재자 | +| 17 | 상세 | USER01 | 검토자 | `TE` | 보완요청 | `SL` | 기초 | 보완 팝업() → `reqsupplement` | +| 18 | 상세 | USER01 | 승인자 | `TE` | 결재(승인) | `TE` | 다음 결재자 | `process` | +| 19 | 상세 | USER01 | 최종승인자 | `TE` | 결재(회신) | `TP` | 환경청 | `process` 최종 | +| 20 | 수정 | USER05 | - | `SL` | 파일보완완료 | `ER` | 공단 접수 | `submitsupplement` (직전=`ER`) | +| 21 | 수정 | USER05 | - | `SL` | 보완완료 | `TE` | 공단 검토 | `submitsupplement` (직전=`TE`) | +| 22 | 수정 | USER05 | - | `SL` | 보완완료 | `RK` | 공단 검토 | `submitsupplement` (직전=`RK`) | +| 23 | 상세 | USER03 | - | `TP` | 재원협의 결과 회신 | `RM` | 광역 | `tech-review` | +| 24 | 상세 | USER04 | - | `RM` | 지자체회신 | `RB` | 기초 | `updatePrgrsStts` | +| 25 | 상세 | USER05 | - | `RB` | 실무협의서 관리 | 유지 | — | 웹기안기 Open | +| 26 | 상세 | USER05 | - | `RB` | 조치완료 | `RA` | 환경청 | **`submit-actn-result` `isSubmit:true`** | +| 27 | 상세 | USER04 | - | `RC` | 조치결과서제출 | `RA` | 환경청 | `updatePrgrsStts` | +| 28 | 상세 | USER03 | - | `RA` | 실무협의서 관리 | 유지 | — | 웹기안기 Open. **저장 불가** | +| 28a | 상세 | USER03 | - | `RA` | 조치결과 검토의뢰 | `AV` | 공단 검토 | **`request-actn-review`** | +| 29 | 자동화 | AI | - | `AV` | (금액검증) | `EV` | 공단 접수 | FE 호출 없음. 진행 패널 | +| 29a | 상세 | USER01 | 접수자 | `EV` | 검토자지정 | `RK` | 공단 검토 | `POST /elapr-dmnd` cycl=2. 1차 결재선 시드 | +| 30 | 상세 | USER01 | 검토자 | `RK` | 실무협의서관리 | 유지 | — | 웹기안기 | +| 31 | 상세 | USER01 | 검토자 | `RK` | 금액검증 | `RK` | — | `dcrag-to/cost/verify` | +| 32 | 상세 | USER01 | 검토자 | `RK` | 저장 | `RK` | — | `pwrk save-apply` | +| 33 | 상세 | USER01 | 검토자 | `RK` | 검토완료 | `RK` | 다음 결재자 | `process` | +| 34 | 상세 | USER01 | 승인자 | `RK` | 결재(승인) | `RK` | 다음 결재자 | `process` | +| 35 | 상세 | USER01 | 최종승인자 | `RK` | 결재(회신) | `RR` | 환경청 | `process` 최종 | +| 36 | 상세 | USER03 | - | `RR` | 실무협의서 관리(조회), 검토결과 회신 | `EP` | 광역 | `tech-review` | +| 37 | 작성/상세 | USER04 | - | `EP` | 결과회신, 실무협의서 관리(조회) | `TA` | 기초 | `updatePrgrsStts` | +| 38 | 상세 | USER05 | - | `TA` | 결과회신, 실무협의서 관리(조회) | — | — | **API 없음. 조회** | + +`SL` 20~22행: 버튼은 직전 상태에 따라 **하나만** 노출. 파일 루프면 [파일보완완료], 그 외 [보완완료]. + +--- + +## 3. `button-config` 권장 구조 + +액션 키는 현행 `FNRS_CNSLTN_VIEW_BUTTON_ACTION`을 확장한다. 신규·이름 변경: + +| 신규/변경 액션 | 라벨 | 비고 | +| --- | --- | --- | +| `fileSplmnt` | 파일보완 | `ER` 접수자. 현행에 없음 | +| `fileVrfc` | 파일검증 | 유지. `ER` 재실행 | +| `dsgntRvwr` | 검토자지정 | `ER→AC`(1차), `EV→RK`(2차) | +| `pwrkCnsltnDocMng` | 실무협의서관리 | 유지. `TE`·`RK`·`RB`·`RA`(조회 전용) | +| `pwrkCnsltnDocCrt` | 실무협의서생성 | 유지. 실패/타임아웃만 | +| `rvwRsltInq` | 검토결과조회 | 신규 | +| `actnCmptn` | 조치완료 | `RB→RA`. `isSubmit:true` | +| `actnSbmsn` | 조치결과서제출 | 광역 `RC→RA` | +| `rqstActnRvw` | 조치결과 검토의뢰 | 환경청 `RA→AV` | +| `costVrfc` | 금액검증 | `RK` | +| `lclRply` | 지자체회신 | 광역 `RM→RB` | +| `fnrsRsltRply` | 재원협의 결과 회신 | 환경청 `TP→RM` | +| `rsltRply` | 결과회신 | `LE→EP`, `RR→EP`, `EP→TA`, `TA` 조회 | +| `fileSplmntCmptn` | 파일보완완료 | `SL←ER` | +| `splmntCmptn` | 보완완료 | `SL←TE\|RK` | + +**삭제**: `fnrsCnsltnAncmnt`(고시→`TB`), `sncnAprv`(인가승인). `EP`/`TA`는 `rsltRply`로 통일. + +### 3-1. 역할 × 상태 (공단 제외) + +``` +ROLE_USER05 + "" | SV → 저장, 신청완료, 삭제 + SL → (직전 ER) 파일보완완료 | (직전 TE|RK) 보완완료 + RB → 실무협의서 관리, 조치완료 + TA → 결과회신 (조회), 실무협의서 관리(조회) + +ROLE_USER04 + ST → 환경청제출 // 현행 반려(rjct→SV)는 SoT 38행에 없음. 유지 시 별도 합의 + RM → 지자체회신 + RC → 조치결과서제출 + EP → 결과회신, 실무협의서 관리(조회) + +ROLE_USER03 + LE → 결과회신(자체종결), 기술검토 의뢰 + TP → 재원협의 결과 회신 + RA → 실무협의서 관리(조회), 조치결과 검토의뢰 + RR → 실무협의서 관리(조회), 검토결과 회신 (`tech-review` → `EP`) +``` + +### 3-2. 공단 결재선 × 상태 + +``` +ER / ATRZ01 접수자 + → 파일보완, 파일검증, 검토자지정 + +EV / ATRZ01 접수자 + → 검토자지정 (2차 결재선. 1차 결재선 시드, 서식 선택 없음) + +TE / ATRZ02 검토자 + → (초안 성공) 실무협의서관리, 검토결과조회, 저장, 검토완료, 보완요청 + → (초안 실패·타임아웃) 실무협의서생성 (+ 위 항목 중 관리/조회는 초안 생긴 뒤) + +TE / ATRZ03 승인자 → 결재(승인) +TE / ATRZ99 최종 → 결재(회신) + +RK / ATRZ02 검토자 + → (초안 완료) 실무협의서관리 + → (초안 완료 + 결재 대기 ATRZ_PRGRS_002, cycl2) 보완요청, 검토완료 + · 보완요청 UI: TE와 동일 SplmntDmndDialog +RK / ATRZ03 → (초안 완료) 실무협의서관리, (cycl2 pending) 결재(승인) +RK / ATRZ99 → (초안 완료) 실무협의서관리, (cycl2 pending) 결재(회신) + · 결재 UI: ElaprApprovalDialog (elaprCycl=2) 승인/반려 → PUT process +``` + +`FV`, `AC`, `AV`: 액션 빈 배열 + 진행 패널. + +`PENDING_APPROVAL_ACTIONS`: `dmndSplmnt`, `rvwCmptn`, `atrzAprv`, `atrzRply`만. +`pwrkCnsltnDocMng`는 결재 대기와 무관(초안 완료만). ATRZ02·ATRZ03·ATRZ99라도 pending 없으면 관리만 노출. + +--- + +## 4. 초안 성공 vs 실패 버튼 + +`TE`에서 `crtDmndList[].crtSttsComCd` / 폴링 `status`로 분기. + +| 조건 | 노출 | +| --- | --- | +| `C` / `COMPLETED` | 실무협의서관리, 검토결과조회, 저장, 검토완료, 보완요청 | +| `F` / `FAILED` 또는 30분 Fallback 후 초안 없음 | 실무협의서생성 | +| `R`/`I` 생성 중 | 진행 패널. 위 버튼 비활성 (`AC`와 동일 UX, 상태가 이미 `TE`면 패널+생성 버튼) | + +`RK`는 금액검증 성공이 기본. Fallback 후 [금액검증] 재실행만 추가 노출. + +--- + +## 5. 현행 `button-config.ts`와의 차이 + +| 항목 | 현재 | To-Be | +| --- | --- | --- | +| USER05 | `""/SV/TA(고시)` | `SL`, `RB` 추가. `TA`는 조회 | +| USER04 | `ST`(제출+반려), `EP`(인가승인) | `RM/RC/EP` 추가. 인가승인 삭제 | +| USER03 | `LE`, `TP`(→EP로 회신) | `RA`, `RR` 추가. `TP`는 `RM` | +| USER01 | `ER`(파일검증, 검토자지정), `TE` | `ER`에 파일보완. `RK` 전면 추가 | +| USER01 ATRZ | `ER`, `TE`만 `needsRoleUser01AtrzResolve` | `RK` 포함 | diff --git a/apps/as/docs/pwrk-cnsltn/ref-webhwp-api.md b/apps/as/docs/pwrk-cnsltn/ref-webhwp-api.md new file mode 100644 index 000000000..2196e6c17 --- /dev/null +++ b/apps/as/docs/pwrk-cnsltn/ref-webhwp-api.md @@ -0,0 +1,4325 @@ +# 레퍼런스 — 웹한글기안기 API (v3) + +> 상위: [development-guide.md](./development-guide.md) +> 원본: 한컴 웹한글기안기 API v3 개발 가이드 (PDF → TaggedPDF XML) +> 정리일: 2026-09-10 +> 구현: `apps/as/src/widgets/hangulgiangi` + +한컴 웹한글기안기 `HwpCtrl` / `HwpAction` / `HwpParameterSet` / `HwpParameterArray` / `HwpCtrlCode` API를 프론트 구현용으로 정리한 문서다. PDF OCR·줄바꿈 흔적은 바로잡았고, 원문 의미는 유지했다. + +공식 개발자 가이드: [Hancom WebHWP](https://developer.hancom.com/webhwp) + +--- + +## 이 프로젝트에서 쓰는 범위 + +실무협의서 관리(`TE`/`RK`) 웹기안기는 아래만 호출한다. 나머지는 확장 시 참고한다. + +| 용도 | API | 프로젝트 사용 | +| --- | --- | --- | +| 문서 열기 | `HwpCtrl.Open(path, format, arg, callback, callbackUserData)` | `File`(HWPX blob) + `format: "HWPX"` + `arg: ""`. 인증된 `GET .../webhwp/open` blob을 `File`로 감싼다. URL 직접 전달·`blob:` Object URL은 쓰지 않는다. | +| 저장 | `HwpCtrl.SaveAs(fileName, format, arg, callback, callbackUserData)` | `format: "HWPX"`, `arg: "download:false"`. 콜백 `result.data`를 Blob으로 디코드한 뒤 `POST /pwrk-cnsltn-doc-crt-rslt` multipart. | +| 로컬 다운로드 | `SaveAs(..., "download:true")` 또는 서버 `GET .../download` | 화면 [다운로드]는 서버 download API를 쓴다. | + +`Open` 반환값(`true`/`false`)은 인자 유효성만 나타낸다. 실제 성공은 콜백 `result.result`로 판단한다. 브라우저는 로컬 파일 경로를 열 수 없다. + +--- + +## 목차 + +- [1. HwpCtrl](#1-hwpctrl) + - [1.1 Properties](#11-properties) + - [1.2 Methods](#12-methods) +- [2. HwpAction](#2-hwpaction) +- [3. HwpParameterSet](#3-hwpparameterset) +- [4. HwpParameterArray](#4-hwpparameterarray) +- [5. HwpCtrlCode](#5-hwpctrlcode) + +--- + +## 1. HwpCtrl + +### 1.1 Properties + +#### 1.1.1 `CellShape` + +**구문** + +```js +HwpCtrl.CellShape +``` + +**설명** + +현재 선택되어 있는 표와 셀의 모양 정보를 나타낸다. + +**반환값** + +블록된 셀 혹은, 현재 캐럿이 위치한 셀의 속성을 ParameterSet 객체 타입으로 리턴한다. + +**비고** + +ParameterSet/Table 로 표의 속성에 대한 기본 정보를 나타내며, 이 가운데 Cell 아이템이 ParameterSet/Cell 로 셀의 속성을 나타낸다. 셀 블록이 잡혀있지 않은 상태이면 현재 캐럿이 위치한 셀 하나만을 대상으로 한다. 현재 표 내부에 캐럿이 위치하지 않으면 에러가 발생한다. + +**예제** + +1. 표 생성 +2. 셀 안으로 캐럿 이동 +3. 예제 스크립트 실행 결과 : 셀 간격이 2pt 로 변경된다. + +```js +var tp = HwpCtrl.CreateSet("Table"); +var cp = tp.CreateItemSet("Cell", "Cell"); +tp.SetItem("CellSpacing", 200); +HwpCtrl.CellShape = tp; +``` + +#### 1.1.2 `CharShape` + +**구문** + +```js +HwpCtrl.CharShape +``` + +**설명** + +현재 Selection 된 글자의 글자 모양을 나타낸다. + +**반환값** + +블록된 구역 혹은, 현재 캐럿이 위치한 곳의 글자 모양을 ParameterSet 객체 타입으로 리턴한다. + +**비고** + +property get 을 수행하면 현재 selection 내의 글자 모양을 구할 수 있고, selection 이 존재하지 않으면 현재 캐럿이 위치한 곳의 글자 모양을 돌려준다. + +글자 모양 중 특정 항목이 Selection 내에서 서로 다른 속성을 가지고 있으면 아예 아이템 자체가 존재하지 않는다. property set 을 수행하면 아이템이 존재하는 항목에 대해서만 속성을 설정한다. + +ParameterSet 형식은 별도로 전달된 ParameterSet Table 문서 참고 + +**예제** + +1. 글자 입력 +2. 입력한 글자 Selection +3. 예제 스크립트 실행 결과 : Selection 된 글자들의 글자 크기를 20pt 로 설정한다. + +```js +var cs = HwpCtrl.CharShape; +if (cs.ItemExist("Height")) { + cs.SetItem("Height", 2000); + HwpCtrl.CharShape = cs; +} +``` + +#### 1.1.3 `CurFieldState` + +**구문** + +```js +HwpCtrl.CurFieldState +``` + +**설명** + +캐럿이 위치한 필드의 상태 정보를 구한다. 읽기 전용. + +**반환값** + +현재 필드 상태에 따라 Number 타입의 값을 리턴한다. Remark 참고 + +**비고** + +다음과 같은 의미를 가진다. + +- bit 4: 필드명 존재 여부 (`1` 있음, `0` 없음) +- bit 0–3: 필드 종류 (`0` 없음, `1` 셀, `2` 누름틀, `4` 글상자) + +필드명 존재 여부와 필드 종류에 따라 0–20까지의 값을 갖는다. + +**예제** + +1. 누름틀 삽입 (다른 필드 삽입 가능) +2. 누름틀 안으로 캐럿 이동 +3. 예제 스크립트 실행 결과 : 현재 선택된 필드 : 누름틀 메시지 창 + +```js +var fieldState = HwpCtrl.CurFieldState; +var msg =""; +switch (fieldState) { + case 0: msg ="필드 없음"; + break; + case 1: msg ="셀"; + break; + case 2: msg ="누름틀"; + break; + case 4: msg ="글상자"; + break; +} +alert("현재 선택된 필드 :"+ msg); +``` + +#### 1.1.4 `CurSelectedCtrl` + +**구문** + +```js +HwpCtrl.CurSelectedCtrl +``` + +**설명** + +현재 선택된 컨트롤. 읽기 전용. + +**반환값** + +현재 선택된 컨트롤을 CtrlCode 객체 타입으로 리턴한다. + +**예제** + +1. 표 삽입 +2. 표 선택 +3. 예제 스크립트 실행 +결과 : 표가 삭제된다. + +```js +var ctrl = HwpCtrl.CurSelectedCtrl; +if( ctrl && ctrl.CtrlID =="tbl") { + HwpCtrl.DeleteCtrl(ctrl); +} +``` + +#### 1.1.5 `EditMode` + +**구문** + +```js +HwpCtrl.EditMode +``` + +**설명** + +현재 편집 모드 + +**반환값** + +현재 편집 모드를 Number 타입으로 리턴한다. + +**비고** + +현재 편집 모드를 나타낸다. + +- 0 : 읽기 전용 + +- 1 : 일반 편집모드 + +- 2 : 양식 모드. 양식 사용자 모드 : Cell 과 누름틀 중 양식 모드에서 편집 가능 속성을 가진 것만 편집 가능하다. + +- 16 : 배포용 문서 `SetEditMode`로 지정 불가능 + +**예제** + +1. EditMode 0(읽기 모드)으로 변경 +2. 편집시도 +3. EditMode 1(일반 편집 모드)으로 변경 +4. 편집시도 + +```js +HwpCtrl.EditMode = 0; +//편집 시도 +HwpCtrl.EditMode = 1; +//편집 시도 +``` + +#### 1.1.6 `HeadCtrl` + +**구문** + +```js +HwpCtrl.HeadCtrl +``` + +**설명** + +문서 중 첫 번째 컨트롤. 읽기 전용. + +**반환값** + +문서 중 첫 번째 컨트롤을 HwpCtrlCode 객체 타입으로 리턴한다. + +**비고** + +문서 중의 모든 컨트롤(표, 그림 등 특수 문자)들은 linked list 로 서로 연결되어 있는데, 그 list의 시작 컨트롤을 나타낸다. 이 컨트롤로부터 시작, Ctrl.Next 를 이용해 forward iteration 을 수행할 수 있 다. + +**예제** + +1. 표 입력 +2. 예제 스크립트 실행 결과 : 문서 중 모든 표가 삭제된다. + +```js +var ctrl = HwpCtrl.HeadCtrl; +while(ctrl != null) { + if(ctrl.CtrlID =="tbl") { + HwpCtrl.DeleteCtrl(ctrl); + } +ctrl = ctrl.Next +} +``` + +#### 1.1.7 `IsModified` + +**구문** + +```js +HwpCtrl.IsModified +``` + +**설명** + +현재 문서가 변경되었는지 나타낸다. 이 구문은 읽기 전용입니다. + +**반환값** + +변경내용 여부를 Boolean 타입으로 리턴한다. + +**예제** + +1. 새 문서 상태에서 예제 실행 후 alert 창 확인 +2. 편집 시도 후 예제 다시 실행 결과 : 문서가 변경되어 true 를 반환한다. + +```js +alert(HwpCtrl.IsModified) +``` + +#### 1.1.8 `IsPrivateInfoProtected` + +**구문** + +```js +HwpCtrl.IsPrivateInfoProtected +``` + +**설명** + +현재 문서에 개인정보보호 필드가 있는지 확인한다. + +**반환값** + +현재 문서의 개인정보보호 필드 여부를 Boolean 타입으로 리턴한다. + +**비고** + +개인정보보호 필드가 있는 문서를 열어서 예제코드 실행해야 합니다. + +**예제** + +1. 개인정보보호 필드가 있는 문서를 연다. +2. 예제 스크립트 실행 결과 : 문서가 개인정보 필드를 가지고 있으면 true 를 반환한다. + +```js +alert(HwpCtrl.IsPrivateInfoProtected); +``` + +#### 1.1.9 `LastCtrl` + +**구문** + +```js +HwpCtrl.LastCtrl +``` + +**설명** + +문서 중 마지막 컨트롤. 읽기 전용. + +**반환값** + +문서 중 마지막 컨트롤을 HwpCtrlCode 객체 타입으로 리턴한다. + +**비고** + +문서 중의 모든 컨트롤(표, 그림 등 특수 문자)들은 linked list 로 서로 연결되어 있는데, 그 list의 마지막 컨트롤을 나타낸다. 이 컨트롤로부터 시작, Ctrl.Prev 를 이용해 backward iteration 을 수행할 수 있다. + +**예제** + +1. 표 입력 +2. 예제 스크립트 실행 결과 : 문서 중 모든 표의 셀 간격을 1pt 로 설정한다. + +```js +var ctrl = HwpCtrl.LastCtrl; +var tbset = HwpCtrl.CreateSet("Table"); +tbset.SetItem("CellSpacing", 100); +while(ctrl != null) { + if(ctrl.CtrlID =="tbl") { + ctrl.Properties = tbset; + } +ctrl = ctrl.Prev; +} +``` + +#### 1.1.10 `PageCount` + +**구문** + +```js +HwpCtrl.PageCount +``` + +**설명** + +문서의 전체 페이지 수를 나타냅니다. 이 구문은 읽기 전용입니다. + +**반환값** + +문서의 페이지 수를 Number 타입으로 반환합니다. + +**비고** + +문서 전체에 대한 pagination 이 수행되지 않은 상태에서 이 property 를 참조하면 먼저 문서 전체의 pagination 을 먼저 수행하므로 긴 문서에 대해 문서 내용 변경과 참조를 반복하면 속도가 심각하게 느려질 수 있습니다. + +**예제** + +1. 문서 쪽 분할 +2. 예제 스크립트 실행 결과: 전체 페이지 수 메시지 창 + +```js +alert(HwpCtrl.PageCount); +``` + +#### 1.1.11 `ParaShape` + +**구문** + +```js +HwpCtrl.ParaShape +``` + +**설명** + +현재 Selection 의 문단 모양을 나타낸다. + +**반환값** + +블록된 구역 혹은, 현재 캐럿이 위치한 곳의 문단 모양을 ParameterSet 객체 타입으로 리턴한다. + +**비고** + +property get 을 수행하면 현재 selection 내의 문단 모양을 구할 수 있다. selection 이 존재하지 않으면 현재 캐럿이 위치한 곳의 문단 모양을 돌려준다. 문단 모양 중 특정 항목이 selection 내에서 서로 다른 속성을 가지고 있으면 아예 아이템 자체가 존재하지 않는다. property set 을 수행하면 아이템이 존재하는 항목에 대해서만 속성을 설정한다.. + +ParameterSet 형식은 별도로 전달된 ParameterSet Table 문서 참고 + +**예제** + +1. 글자 입력 +2. 입력한 글자 Selection +3. 예제 스크립트 실행 결과 : 현재 문단의 왼쪽 여백을 50pt 로 설정한다. + +```js +var paraShape = HwpCtrl.ParaShape; +paraShape.SetItem("LeftMargin", 10000); +HwpCtrl.ParaShape = paraShape; +``` + +#### 1.1.12 `ParentCtrl` + +**구문** + +```js +HwpCtrl.ParentCtrl +``` + +**설명** + +현재 캐럿의 상위 컨트롤. 읽기 전용. + +**반환값** + +현재 캐럿의 상위 컨트롤을 HwpCtrlCode 객체 타입으로 리턴한다. + +**비고** + +상위 컨트롤은 현재 캐럿이 위치한 리스트를 보유한 컨트롤이다. 예를 들어 셀 내부에 위치하면 표, 각 주 내용에 위치하면 각주, 바탕쪽이면 구역 컨트롤이 상위 컨트롤이다.현재 캐럿이 본문 레벨에 위치해 상위 컨트롤이 없을 때는 null 이 리턴된다. + +**예제** + +1. 표 입력 +2. 셀로 캐럿 이동 +3. 예제 스크립트 실행 결과 : 현재 캐럿의 위치가 셀이라면 부모 표의 셀 간격을 1pt 로 설정한다. + +```js +var ctrl = HwpCtrl.ParentCtrl; +if (ctrl.CtrlID =="tbl") { + var tbset = HwpCtrl.CreateSet("Table"); + tbset.SetItem("CellSpacing", 100); + ctrl.Properties = tbset; +} +``` + +#### 1.1.13 `ReadOnlyMode` + +**구문** + +```js +HwpCtrl.ReadOnlyMode +``` + +**설명** + +편집모드를 읽기 전용 모드로 설정하는 기능 + +**반환값** + +현재 읽기 전용모드를 Boolean 타입으로 리턴한다. + +**비고** + +property get 을 수행하면 현재 문서가 읽기 전용 모드 여부를 return 한다. return 값이 false 이면 편집 모드, true 이면 읽기 전용모드이다. property set 을 수행하면 현재 문서의 읽기 전용모드 속성을 설정한다. + +**예제** + +1. ReadOnlyMode true( 읽기모드로 변경 +2. 편집시도 +3. ReadOnlyMode false( 편집 모드로 변경 +4. 편집시도 결과 : 편집모드와 읽기 전용 모드를 설정한다. + +```js +HwpCtrl.ReadOnlyMode = true; +//읽기모드 //편집 시도 +HwpCtrl.ReadOnlyMode = false; +//편집모드 //편집 시도 +``` + +#### 1.1.14 `ScrollPosInfo` + +**구문** + +```js +HwpCtrl.ScrollPosInfo +``` + +**설명** + +스크롤바의 위치 정보를 얻어오는 기능 + +**반환값** + +현재 스크롤의 위치 정보를 얻어옵니다. ScrollPosInfo 의 ParameterSet 버전. + +**비고** + +현재 스크롤바수직, 수평의 위치정보를 ScrollPosInfo ParameterSet 으로 얻어오거나 설정한다. + +| 값 | 설명 | +| --- | --- | +| HorzPos | 수평 스크롤바 위치 | +| HorzLimitPos | 수평 스크롤바 한계 위치 값 (읽기 전용) | +| VertPos | 수직 스크롤바 위치 | +| VertLimitPos | 수직 스크롤바 한계 위치 값 (읽기 전용) | + +**예제** + +1. 스크롤을 원하는 위치로 이동시킨다. +2. 예제 스크립트 실행 결과: 선택된 값 메시지 창 + +```js +var pset = HwpCtrl.ScrollPosInfo; +var hp = pset.Item("HorzPos"); +var vp = pset.Item("VertPos"); +var hlp= pset.Item("HorzLimitPos"); +var vlp = pset.Item("VertLimitPos"); +alert("HorzPos:"+ hp +"HorzLimitPos:"+ hlp +"VertPos:"+ vp +"VertLimitPos:"+ vlp); +``` + +#### 1.1.15 `SelectionMode` + +**구문** + +```js +HwpCtrl.SelectionMode +``` + +**설명** + +문서의 내용이 어떤 selection 상태인가를 알려줍니다. 이 구문은 읽기 전용입니다. + +**반환값** + +현재 selection 상태를 Number 타입으로 반환합니다. + +**비고** + +일반 블록이 아닌 F3키에 의해 블록이 지정된 경우,HWPSEL_STRICT_MODE `0x10`(십진수 16) 으로 OR 마스크되어 오기 때문에, 항상 `0x0F`(십진수 15) 로 AND 마스크한 결과로 판단하도록 합니다. + +| 값 | 설명 | +| --- | --- | +| 0 | 블록지정이 되지 않은 상태 | +| 1 | 일반 블록이거나 F3키에 의한 블록 상태 | +| 3 | 테이블 셀 블록 상태 | +| 4 | 그리기 개체나 표, 그림 등 ShapeObject 에 해당하는 컨트롤이 선택된 상태 | + +**예제** + +1. 표 와 텍스트 삽입 +2. 텍스트 일반 블록으로 선택 +3. 예제 스크립트 실행 결과: 선택된 값 메시지 창 + +```js +var HWPSEL_NONE = 0; +var HWPSEL_NORMAL = 1; +var HWPSEL_TABLE = 3; +var HWPSEL_CTRL = 4; +var HWPSEL_MODE_MASK = 15; +var HWPSEL_STRICT_MODE = 16; +mode = HwpCtrl.SelectionMode & HWPSEL_MODE_MASK; +if(mode == HWPSEL_NONE) { + //블록지정이 되지 않은 경우 + alert("No Selection ..."); +} +else if(mode == HWPSEL_NORMAL) { + //일반 블록이거나 F3키에 의한 블록인 경우 (TEXT) + alert("Normal Selection ..."); +} +else if(mode == HWPSEL_TABLE) { + //테이블 셀 블록 상태 + alert("Table Selection ..."); +} +else if(mode == HWPSEL_CTRL) { + //그리기 개체나 표, 그림 등 ShapeObject 컨트롤이 선택된 상태 + alert("Ctrl Selection ..."); +} +``` + +#### 1.1.16 `ViewProperties` + +**구문** + +```js +HwpCtrl.ViewProperties +``` + +**설명** + +View 의 상태정보를 얻어오는 기능 + +**반환값** + +뷰의 상태정보를 ViewProperties ParameterSet 으로 얻어오거나 설정한다. + +**비고** + +조판 부호, 화면 확대 비율과 같은 view 에 관련된 정보를 나타낸다. + +**예제** + +1. 문서의 배율을 75% 로 설정 +2. 예제스크립트 실행 + +```js +var pset = HwpCtrl.ViewProperties; +alert(pset.Item("ZoomRatio")); +``` + +### 1.2 Methods + +#### 1.2.1 `Clear` + +**구문** + +```js +HwpCtrl.Clear(option) +``` + +**설명** + +현재 편집중인 문서의 내용을 닫고 빈문서 편집 상태로 돌아간다. + +**파라미터** + +- `option`: 편집중인 문서의 내용에 대한 처리 방법. 생략하면 hwpAskSave 가 선택된다 + +| ID | 값 | 설명 | +| --- | --- | --- | +| hwpDiscard | 1 | 문서 내용을 버린다. | + +**비고** + +현재는 옵션 1에 한해서만 사용할 수 있다. + +**예제** + +1. 글자 입력 +2. 예제 스크립트 실행 결과 : 빈문서 편집 상태로 되돌린다. + +```js +HwpCtrl.Clear(1); +``` + +#### 1.2.2 `CreateAction` + +**구문** + +```js +HwpCtrl.CreateAction(ActionID) +``` + +**설명** + +Action 객체를 생성한다. + +**파라미터** + +- `actionID`: 액션 ID + +**반환값** + +성공 시 Action 객체를 리턴한다. 존재하지 않는 액션 ID를 입력하면 null 을 리턴한다. + +**비고** + +액션에 대한 세부적인 제어가 필요할 때 사용한다. 예를 들어 기능을 수행하지 않고 대화상자만을 띄운다든지, 대화상자 없이 지정한 옵션에 따라 기능을 수행하는 등에 사용할 수 있다. Action ID Table 은 별도 문서 참조 + +**예제** + +1. 글자 입력 +2. 입력한 글자 Selection +3. 예제 스크립트 실행 결과 : Selection 된 글자들에 Bold 속성을 부여한다. + +```js +var act = HwpCtrl.CreateAction("CharShapeBold"); +act.Run(); +``` + +#### 1.2.3 `CreateField` + +**구문** + +```js +HwpCtrl.CreateField(direction, memo, name) +``` + +**설명** + +캐럿의 현재 위치에 누름틀을 생성한다. + +**파라미터** + +- `direction`: 입력이 안된 상태에서 보여지는 안내문/지시문 +- `memo`: 누름틀에 대한 설명/도움말 + +- `name`: :누름틀 필드에 대한 필드 이름 + +**반환값** + +성공이면 true, 실패면 false + +**비고** + +memo Parameter 는 ActiveX 한/글 컨트롤과 호환용으로 사용되는 Parameter 이다. 웹한글에서는 내용을 확인할 수 없으나 누름틀 저장 후 데스크탑 한/글에서 메모 내용을 확인할 수 있다. + +**예제** + +1. 예제 스크립트 실행 결과 : 현재 위치에 본문 필드가 생성된다. + +```js +HwpCtrl.CreateField("본문을 입력하세요", "", "본문"); +``` + +#### 1.2.4 `CreatePageImage` + +**구문** + +```js +HwpCtrl.CreatePageImage(fileName, pgno, format) +``` + +**설명** + +지정한 페이지의 이미지를 파일로 생성한다. + +**파라미터** + +- `fileName`: 생성할 이미지 파일의 이름 +- `pgno`: 페이지 번호. 0부터 PageCount-1까지. 생략하면 0이 사용된다 +- `format`: :이미지 파일의 포맷. jpg, jpeg, png% 중의 하나. 생략하면 png% 가 사용된다 + +**반환값** + +성공이면 true, 실패면 false + +**예제** + +1. 문서 작성 또는 문서 불러오기 +2. 예제 스크립트 실행 결과 : 첫 번째 페이지를 이미지로 다운로드한다. + +```js +HwpCtrl.CreatePageImage("pageimage.png", 0, "png"); +``` + +#### 1.2.5 `CreatePageImageEx` + +**구문** + +```js +HwpCtrl.CreatePageImageEx(fileName, pgno, resolution, depth, format, option, callback, callbackUserData) +``` + +**설명** + +지정한 페이지의 이미지를 파일로 생성합니다. + +**파라미터** + +- `fileName`: 생성할 이미지 파일의 이름 + +- `pgno`: 페이지 번호. 0부터 PageCount-1까지. 생략하면 0이 사용됨 +- `resolution`: 이미지 해상도. DPI 단위96, 300, 1200 등로 지정됨. 생략하면 96이 사용됨 +- `depth`: 이미지 파일의 color depth1, 4, 8, 24) 를 지정함 +- `format`: 이미지 파일의 포맷. jpg, jpeg, png2 중의 하나. 생략하면 png2 가 사용됨 +- `option`: download:true 를 넣으면 문서가 로컬에 다운로드 됨 +- `callback`: CreatePageImageEx 함수가 동작을 마친 후 수행될 함수 + +- result( 성공 실패 여부, path( 저장경로, fileName, Size, CallbackUserData 를 포함한 + +- Object 를 인자로 받음 +- `callbackUserData`: Callback 함수에 Argument 로 넘겨 받을 result 에 포함될 UserData, 생략 가능 + +**반환값** + +성공하면 true 를 반환하고 실패하면 false 를 반환합니다.여기서 성공/실패는 필요한 인자가 제대로 들어 있고, 비동기 로직을 제외한 부분이 제대로 동작하였는지를 판별합니다.비동기 액션이므로 return 값이 true 이더라도 네트워크등의 문제로 실패 할 수 있습니다.정확한 성공/실패 여부는 callback 함수의 인자 로 들어오는 object 의 result 속성을 통해서 판별할 수 있습니다. + +**예제** + +1. 임의의 문서 작성 +2. 예제 스크립트 실행 결과: 지정한 페이지를 이미지로 다운로드합니다. + +```js +HwpCtrl.CreatePageImageEx("pageimage.png", 0, 96, 24, "png", "download:true", function(result) { + console.log("다운로드 완료"); +} +``` + +); + +#### 1.2.6 `CreateSet` + +**구문** + +```js +HwpCtrl.CreateSet(setid) +``` + +**설명** + +ParameterSet 객체를 생성한다. + +**파라미터** + +- `setid`: 생성할 ParameterSet 객체의 ID + +**반환값** + +생성된 ParameterSet 객체를 리턴한다. + +**예제** + +1. 표 생성 +2. 셀 안으로 캐럿 이동 +3. 예제 스크립트 실행 결과 : 셀 간격이 2pt 로 변경된다. + +```js +var tp = HwpCtrl.CreateSet("Table"); +var cp = tp.CreateItemSet("Cell", "Cell"); +tp.SetItem("CellSpacing", 200); +HwpCtrl.CellShape = tp; +``` + +#### 1.2.7 `DeleteCtrl` + +**구문** + +```js +HwpCtrl.DeleteCtrl(ctrl) +``` + +**설명** + +문서 내 컨트롤을 삭제한다. + +**파라미터** + +- `ctrl`: 삭제할 문서 내 컨트롤CtrlCode 객체) + +**반환값** + +성공이면 true, 실패면 false + +**예제** + +1. 표 삽입 +2. 표 선택 +3. 예제 스크립트 실행 결과 : 표가 삭제된다. + +```js +var ctrl = HwpCtrl.CurSelectedCtrl; +if( ctrl && ctrl.CtrlID =="tbl") { + HwpCtrl.DeleteCtrl(ctrl); +} +``` + +#### 1.2.8 `FieldExist` + +**구문** + +```js +HwpCtrl.FieldExist(field) +``` + +**설명** + +문서 중에 지정한 데이터 필드가 존재하는지 검사한다. + +**파라미터** + +- `field`: 필드 이름 + +**반환값** + +성공이면 true, 실패면 false + +**예제** + +1. 누름틀 생성 +2. 필드이름 이름으로 지정 +3. 예제 스크립트 실행 결과 : 문서 내에 이름 필드가 있다면 해당 필드의 정렬을 가운데 정렬로 변경하고, 홍길동이라고 텍스트를 입력한다. + +```js +if(HwpCtrl.FieldExist("이름")) { + HwpCtrl.MoveToField("이름", true, true, true); + HwpCtrl.Run("ParagraphShapeAlignCenter"); + HwpCtrl.PutFieldText("이름", "홍길동"); +} +``` + +#### 1.2.9 `FoldRibbon` + +**구문** + +```js +HwpCtrl.FoldRibbon(fold) +``` + +**설명** + +리본 메뉴를 화면에 접기/펼치기 처리한다. + +**파라미터** + +- `fold`: 리본 메뉴 접기/펼치기 여부 + +**반환값** + +성공하면 true, 실패하면 false + +**예제** + +1. 예제 스크립트 실행 결과 : Ribbon 메뉴가 접혀진다. + +```js +HwpCtrl.FoldRibbon(true); +``` + +#### 1.2.10 `GetCurFieldName` + +**구문** + +```js +HwpCtrl.GetCurFieldName(option) +``` + +**설명** + +현재 캐럿 위치의 데이터 필드 이름을 구한다. + +**파라미터** + +- `option`: 다음과 같은 옵션을 지정할 수 있다. 0을 지정하면 모두 off 이다. 생략하면 0이 지정된다 + +| ID | 값 | 설명 | +| --- | --- | --- | +| hwpFieldCell | 1 | 셀에 부여된 필드 리스트만을 구한다. hwpFieldClickHere 와는 함께 지정할 수 없다. | +| hwpFieldClickHer e | 2 | 누름틀에 부여된 필드 리스트만을 구한다. hwpFieldCell 과는 함께 지정할 수 없다. | + +**반환값** + +필드 이름을 문자열로 리턴한다. 필드 이름이 없는 경우 빈 문자열이 돌아온다. + +**예제** + +1. 누름틀 생성 +2. 필드이름 이름으로 지정 +3. 생성한 누름틀로 캐럿 이동 +4. 예제 스크립트 실행 결과 : 현재 필드의 이름이 이름이라면 홍길동을 입력한다. + +```js +if(HwpCtrl.GetCurFieldName(0) =="이름") { + HwpCtrl.PutFieldText("이름", "홍길동"); +} +``` + +#### 1.2.11 `GetFieldList` + +**구문** + +```js +HwpCtrl.GetFieldList(number, option) +``` + +**설명** + +문서 중의 필드 리스트를 구한다. + +**파라미터** + +- `number`: 문서 중에서 동일한 이름의 필드가 여러 개 존재할 때 이를 구별하기 위한 식별 방법을 지정한다.생략하면 hwpFieldPlain 이 지정된다 + +| ID | 값 | 설명 | +| --- | --- | --- | +| hwpFieldPlain | 0 | 아무 기호 없이 순서대로 필드 이름이 나열된다. | +| hwpFieldNumber | 1 | 필드 이름 뒤에 일련번호가 {{#}} 와 같은 형식으로 붙는다. | +| hwpFieldCount | 2 | 필드 이름뒤에 그 이름의 필드가 몇 개 있는지 {{#}} 와 같은 형식으로 붙는다. | + +- `option`: 다음과 같은 옵션을 조합할 수 있다. 0을 지정하면 모두 off 이다. 생략하면 0이 지정된다 + +| ID | 값 | 설명 | +| --- | --- | --- | +| hwpFieldCell | 1 | 셀에 부여된 필드 리스트만을 구한다. hwpFieldClickHere 와 는 함께 지정할 수 없다. | +| hwpFieldClickHere | 2 | 누름틀에 부여된 필드 리스트만을 구한다. hwpFieldCell 과는 함께 지정할 수 없다. | +| hwpFieldSelection | 4 | 셀렉션 내에 존재하는 필드 리스트를 구한다. | + +**반환값** + +각 필드 사이를 문자코드 0x02 로 구분하여 다음과 같은 형식으로 리턴 한다. 가장 마지막 필드에는 0x02 가 붙지 않는다.) 필드이름#1필드이름#2... 필드이름#n2 + +**비고** + +문서 중에 동일한 이름의 필드가 여러 개 존재할 때는 number 에 지정한 타입에 따라 3 가지의 서로 + +다른 방식을 중에서 선택할 수 있다. 예를 들어 문서 중 title, body, title, body, footer 순으로 5 개의 필드가 존재할 때, hwpFieldPlain, hwpFieldNumber, HwpFieldCount 세 가지 형식에 따라 다음과 같은 내용이 돌아 온다. + +| ID | 예제 | +| --- | --- | +| hwpFieldPlain | "title\x02body\x02title\x02body\x02footer" | +| hwpFieldNumber | "title{{0}}\x02body{{0}}\x02title{{1}}\x02body{{1}}\x02footer{{0}}" | +| hwpFieldCount | "title{{2}}\x02body{{2}}\x02footer{{1}}" | + +**예제** + +1. 필드 입력 +2. 예제 스크립트 실행 결과 : 성공 메시지 창 + +```js +alert(HwpCtrl.GetFieldList(0)); +``` + +#### 1.2.12 `GetFieldText` + +**구문** + +```js +HwpCtrl.GetFieldText(fieldlist) +``` + +**설명** + +지정한 필드에서 문자열을 구합니다. + +**파라미터** + +- `fieldlist`: 텍스트를 구할 필드 이름의 리스트 + +**반환값** + +텍스트 데이터가 돌아옵니다. 텍스트에서 탭은 \t0x9, 문단 바뀜은 CR/LF0x0D/0x0A) 로 표현되며, 이외의 특수 코드는 포함되지 않습니다. 필드 텍스트의 끝은 0x02 로 표현되며, 그 이후 다음 필드의 텍스트가 연속해서 지정한 필드 리스트의 개수만큼 위치합니다. 지정한 이름의 필드가 없거나 사용자가 해당 필드에 아무 텍스트도 입력하지 않았으면 해당 텍스트에는 빈 문자열이 돌아옵니다. + +**비고** + +다음과 같이 필드 사이를 문자 코드 0x02 로 구분하여 한 번에 여러 개의 필드를 지정할 수 있다.필드 이름#1\x02 필드이름#2\x02... 필드이름#n2 지정한 필드 이름이 문서 중에 두 개 이상 존재할 때의 표현 방식은 다음과 같다. + +| 필드이름 | 필드이름{{n}} | +| --- | --- | +| 이름의 필드 중 첫 번째 | 지정한 이름의 필드 중 n 번째 | + +예를 들어, 제목1\x02 본문\x02 이름02 과 같이 지정하면 제목이라는 이름의 필드 중 두 번째, 본문이라는 이름의 필드 중 첫 번째, 이름이라는 이름의 필드 + +중 첫 번째를 각각 지정한다. 즉, 필드이름과 필드이름0 은 동일한 의미로 해석된다. + +**예제** + +1. 본문 이라는 필드 생성 +2. Text 삽입 +3. 예제 스크립트 실행 결과: 본문 이라는 이름의 필드 중 첫 번째 필드의 Text 를 얻어옵니다. + +```js +alert(HwpCtrl.GetFieldText(' 본문 { + { + 0 + } +} +')); +``` + +#### 1.2.13 `GetHeadingString` + +**구문** + +```js +HwpCtrl.GetHeadingString() +``` + +**설명** + +현재 캐럿이 위치한 문단의 문단번호/글머리표를 얻어냅니다. + +**반환값** + +문단번호 또는 글머리표를 String 타입으로 반환합니다.문단번호나 글머리표가 없다면 빈문자열을 반환 합니다. + +**예제** + +1. 문단번호/글머리표 작성 +2. 예제 스크립트 실행 결과: 문단번호 또는 글머리표 메시지 창 + +```js +alert("문단번호/글머리표 :"+ HwpCtrl.GetHeadingString()); +``` + +#### 1.2.14 `GetMetaTag` + +**구문** + +```js +HwpCtrl.GetMetaTag(name) +``` + +**설명** + +문서 내 메타태그 정보를 가져옵니다. + +**파라미터** + +- `name`: 찾을 메타태그의 이름 + +**반환값** + +메타태그를 찾았을 때 해당 메타태그 정보를 Object 타입으로 반환합니다. Object 의 Property 는 다음 과 같습니다. name : 메타태그의 이름 value : 메타태그의 값 format : 메타태그의 데이터 유형 메타태그가 없으면 null 을 반환합니다. + +**비고** + +hwpx 에서만 사용가능합니다. + +**예제** + +1. 예제 스크립트 실행 결과: 메타태그의 정보 메시지 창 + +```js +var tag; +HwpCtrl.SetMetaTag("office", "text", "한글과컴퓨터"); +HwpCtrl.GetTextFile("HWPX", "", function (result) { + data = result; + HwpCtrl.Clear(1); + HwpCtrl.SetTextFile(data, "HWPX", "", function (result) { + tag = HwpCtrl.GetMetaTag("office"); + alert('name : '+ tag.NAME + '\n' + 'value : ' + tag.VALUE); + } +, null); +} +); +``` + +#### 1.2.15 `GetMetaTagAll` + +**구문** + +```js +HwpCtrl.GetMetaTagAll() +``` + +**설명** + +문서 내 모든 메타태그 정보를 가져옵니다. + +**파라미터** + +- `fieldlist`: 텍스트를 구할 필드 이름의 리스트 + +**반환값** + +메타태그를 찾았을 때 해당 메타태그 정보를 Object 타입으로 반환합니다. Object 의 Property 는 다음 과 같습니다. name : 메타태그의 이름 value : 메타태그의 값 format : 메타태그의 데이터 유형 메타태그가 없으면 null 을 반환합니다. + +**비고** + +hwpx 에서만 사용가능합니다. + +**예제** + +1. 예제 스크립트 실행 결과: 메타태그의 정보 메시지 창 + +```js +var tag; +HwpCtrl.SetMetaTag("office", "text", "한글과컴퓨터"); +HwpCtrl.SetMetaTag("developer", "num", "123456"); +HwpCtrl.GetTextFile("HWPX", "", function (result) { + data = result; + HwpCtrl.Clear(1); + HwpCtrl.SetTextFile(data, "HWPX", "", function (result) { + tag = HwpCtrl.GetMetaTagAll(); + alert('metaTag : ' + JSON.stringify(tag)); + } +, null); +} +); +``` + +#### 1.2.16 `GetPageText` + +**구문** + +```js +HwpCtrl.GetPageText(pageNo, option) +``` + +**설명** + +특정 페이지의 텍스트를 얻는다. + +**파라미터** + +- `pageNo`: 텍스트를 얻을 페이지 Number +- `option`: 다음과 같은 옵션을 지정할 수 있다. 생략하면 모든 컨트롤에 대해 텍스트를 얻는다. 본문 텍스트는 옵션에 관계없이 얻는다 + +| option | 값 | 설명 | +| --- | --- | --- | +| 표 | 1 | 표 내부 텍스트만 얻는다. | +| 글상자 | 2 | 글상자 내부 텍스트만 얻는다. | +| 캡션 | 4 | 캡션 내부 텍스트만 얻는다. | + +**반환값** + +결과 값을 문자열로 리턴한다. + +**예제** + +1. 표 입력 +2. 표안에 텍스트 입력 +3. 예제 스크립트 실행 결과 : 첫 번째 Page 의 표 내부의 Text 가 나타난다. + +```js +alert(HwpCtrl.GetPageText(0, 1)); +``` + +#### 1.2.17 `GetPos` + +**구문** + +```js +HwpCtrl.GetPos() +``` + +**설명** + +현재 캐럿의 위치 정보를 얻어온다. + +**반환값** + +캐럿 위치 정보를 Object 타입으로 리턴한다. Object 의 Property 는 다음과 같다. list : 캐럿이 위치한 문서 내 리스트 아이디. para : 캐럿이 위치한 문단 아이디. pos : 캐럿이 위치한 문단 내 글자 단위 위치. + +**비고** + +기존 ActiveX 한글 컨트롤에서는 포인터를 인자로 받아서 처리하였다. WEB 환경에 맞춰서 객체를 리 턴하는 방식으로 변경되었다. + +**예제** + +1. 문서 작성 +2. 캐럿 이동 +3. GetPos API 로 현재 Pos 정보 저장 +4. 다른 위치로 캐럿 이동 +5. 3단계에서 저장한 위치로 캐럿 이동 결과 : 캐럿 위치를 저장하고, 다시 이동한다. + +```js +posObj = HwpCtrl.GetPos(); +//GetPos API 로 현재 Pos 정보 저장 //... 다른 위치로 캐럿 이동 +HwpCtrl.SetPos(posObj.list, posObj.para, posObj.pos); +//3단계에서 저장한 위치 로 캐럿 이동 +``` + +#### 1.2.18 `GetPosBySet` + +**구문** + +```js +HwpCtrl.GetPosBySet() +``` + +**설명** + +현재 캐럿의 위치 정보를 얻어옵니다. GetPos 의 ParameterSet 버전) + +**반환값** + +캐럿의 위치에 대한 ParameterSet 객체 타입으로 반환합니다. + +**예제** + +1. 임의의 문서 작성 +2. 캐럿 이동 +3. 예제 스크립트 실행 결과: 현재 캐럿이 위치한 정보 메시지 창 + +```js +var posSet = null; +posSet = HwpCtrl.GetPosBySet(); +alert(posSet.Item("List") +""+ posSet.Item("Para") +""+ posSet.Item("Pos")); +``` + +#### 1.2.19 `GetSelectedPos` + +**구문** + +```js +HwpCtrl.GetSelectedPos() +``` + +**설명** + +현재 설정된 블록의 위치 정보를 얻어온다. + +**반환값** + +현재 설정된 블록의 위치 정보를 Object 타입으로 리턴한다. Object 의 Property 는 다음과 같다. slist : 설정된 블록의 시작 리스트 아이디. spara : 설정된 블록의 시작 문단 아이디. spos : 설정된 블록의 문단 내 시작 글자 단위 위치. slist : 설정된 블록의 끝 리스트 아이디. spara : 설정된 블록의 끝 문단 아이디. spos : 설정된 블록의 문단 내 끝 글자 단위 위치. + +**비고** + +기존 ActiveX 한글 컨트롤에서는 포인터를 인자로 받아서 처리하였다. WEB 환경에 맞춰서 객체를 리 턴하는 방식으로 변경되었다. 위의 리스트란, 문단과 컨트롤들이 연결된 한/글 문서 내 구조를 뜻한다. 리스트 아이디는 문서 내 위치 정보 중 하나로서 SelectText 에 넘겨줄 때 사용한다. + +**예제** + +1. 문서 작성 +2. 텍스트 블록 +3. GetSelectedPos API 로 현재 블록 Pos 정보 저장 +4. 블록 해제 +5. 3단계에서 저장한 블록 다시 블록 결과 : 현재 설정된 블록의 위치를 저장하고, 이후에 다시 그 위치의 Text 를 블록한다. + +```js +posObj = HwpCtrl.GetSelectedPos(); +//GetSelectedPos API 로 현재 블록 Pos 정보 저장 //... 블록 해제 +HwpCtrl.SelectText(posObj.spara, posObj.spos, posObj.epara, posObj.epos); +//3 +``` + +단계에서 저장한 블록 다시 블록 + +#### 1.2.20 `GetSelectedPosBySet` + +**구문** + +```js +HwpCtrl.GetSelectedPosBySet(sset, eset) +``` + +**설명** + +현재 설정된 블록의 위치 정보를 얻어옵니다. GetSelectedPos 의 ParameterSet 버전) + +**파라미터** + +- `sset`: 설정된 블록의 시작 ParameterSet ListParaPos) +- `eset`: 설정된 블록의 끝 ParameterSet ListParaPos) + +**반환값** + +성공하면 true 를 반환하고 실패면 false 를 반환합니다. + +**예제** + +1. 임의의 문서 작성 +2. 텍스트를 블록으로 선택 +3. 예제 스크립트 실행 결과: 현재 블록의 시작과 끝 얻어냅니다. + +```js +var sset = HwpCtrl.CreateSet('ListParaPos'); +var eset = HwpCtrl.CreateSet('ListParaPos'); +HwpCtrl.GetSelectedPosBySet(sset, eset); +alert(sset.Item('List') + ', ' +sset.Item('Para') + ', ' + sset.Item('Pos') + '\n' +eset.Item('List') + ', ' + eset.Item('Para') + ', ' + eset.Item('Pos')); +``` + +#### 1.2.21 `GetTableCellAddr` + +**구문** + +```js +HwpCtrl.GetTableCellAddr(type) +``` + +**설명** + +현재 커서가 위치한 셀의 주소 정보를 얻어옵니다. + +**파라미터** + +- `type`: 얻어올 주소의 type + +- 0 행 + +- 1 열 + +**반환값** + +0n 까지의 행, 열을 얻어옵니다.커서가 셀에 있지 않거나, 실패하면 _1을 반환합니다. + +**예제** + +1. 표 삽입 +2. 커서 이동 +3. 예제 스크립트 실행 +결과: 현재 커서가 위치한 셀의 주소 메시지 창 + +```js +var col = HwpCtrl.GetTableCellAddr(0); +var row = HwpCtrl.GetTableCellAddr(1); +alert("col :"+ col +", row :"+ row); +``` + +#### 1.2.22 `GetText` + +**구문** + +```js +HwpCtrl.GetText() +``` + +**설명** + +문서 중에서 텍스트를 얻는다. + +**반환값** + +Object 타입으로 리턴한다. Property 는 다음과 같다. text : 텍스트 데이터가 돌아온다. 텍스트에서 탭은 \t0x9, 문단 바뀜은 CR/LF0x0D/0x0A) 로 표 현되며, 이외의 특수 코드는 포함되지 않는다. result : 결과값을 Number 타입으로 리턴한다. 다음과 같은 결과값을 리턴한다. + +- 0 텍스트 정보 없음. + +- 1 리스트의 끝 + +- 2 일반 텍스트 + +- 3 다음 문단 + +- 4 제어 문자 내부로 들어 감. + +- 5 제어 문자를 빠져 나옴. + +- 101 초기화 안됨. InitScan() 실패 또는 InitScan() 을 실행하지 않은 경우) + +- 102 텍스트 변환 실패 + +**비고** + +기존 ActiveX 한글 컨트롤에서는 포인터를 인자로 받아서 처리하였다. WEB 환경에 맞춰서 객체를 리 턴하는 방식으로 변경되었다. GetText) 의 사용 전 InitScan() 검색 준비를 하고, 끝나면 ReleaseScan) 을 반드시 호출하여 관련 정보를 초기화 해주어야 한다. 텍스트가 있는 문단으로 캐럿을 이동 시키려면 moveScanPos 를 주고 MovePos) 를 호출하면 된다. + +**예제** + +1. 텍스트 입력 +2. 텍스트의 맨 앞으로 캐럿 이동 +3. 예제 스크립트 실행 결과 : 현재 캐럿부터 줄의 끝까지 Text 를 얻어낸다. + +```js +HwpCtrl.InitScan(0, 2); +alert(HwpCtrl.GetText().text); +HwpCtrl.ReleaseScan(); +``` + +#### 1.2.23 `GetTextBySet` + +**구문** + +```js +HwpCtrl.GetTextBySet(text) +``` + +**설명** + +문서 중에서 텍스트를 얻어옵니다.GetText 의 ParameterSet 버전) + +**파라미터** + +- `text`: Item 의 Text% 에 텍스트 데이터가 돌아옵니다. 텍스트에서 탭은 \t0x9, 문단 바뀜은 CR/LF0x0D/0x0A) 로 표현되며, 이외의 특수 코드는 포함되지 않습니다 + +**반환값** + +- 0 텍스트 정보 없음 + +- 1 목록의 끝 + +- 2 일반 텍스트 + +- 3 다음 문단 + +- 4 제어 문자 내부로 들어감 + +- 5 제어 문자를 빠져 나옴 + +- 101 초기화 안 됨InitScan() 실패 또는 InitScan() 을 실행하지 않은 경우) + +- 102 텍스트 변환 실패 + +**비고** + +GetTextBySet) 의 사용이 끝나면 ReleaseScan) 을 반드시 호출하여 관련 정보를 초기화 해주어야 합니다. 텍스트가 있는 문단으로 캐럿을 이동 시키려면 moveScanPos 를 주고 MovePos) 를 호출하면 됩니다. + +**예제** + +1. 임의의 텍스트 입력 +2. 캐럿 이동 +3. 예제 스크립트 실행 결과: 현재 캐럿부터 줄 끝까지의 Text 를 가져옴 + +```js +HwpCtrl.InitScan(0, 2); +var textSet = HwpCtrl.CreateSet('GetText'); +HwpCtrl.GetTextBySet(textSet); +alert(textSet.Item("Text")); +HwpCtrl.ReleaseScan(); +``` + +#### 1.2.24 `GetTextFile` + +**구문** + +```js +HwpCtrl.GetTextFile(format. option, callback) +``` + +**설명** + +현재 열린 문서를 문자열로 넘겨준다. + +**파라미터** + +- `format`: 파일의 형식 + +| format | 설명 | 비고 | +| --- | --- | --- | +| HWP | Hwp native format | BASE64 로 인코딩되어 있다. 저장된 내용 을 다른 곳에서 보여줄 필요가 없다면 이 포맷을 사용하기를 권장합니다 | +| HWPML2X | HWP 형식과 호환 | 문서의 모든 정보를 유지 | +| HTML | 인터넷 문서 HTML 형식 | 한글 고유의 서식은 손실된다. | +| TEXT | 유니코드 텍스트 | 서식정보가 없는 텍스트만 저장 | +| JSON | json format | 문서의 정보를 Json 형태로 만든다. 문서의 모든 정보를 유지 | + +- option + +| option | 값 | 설명 | +| --- | --- | --- | +| savebloc k | 선택된 블록만 저장 | 개체 선택 상태에서는 동작하지 않는다. | + +- `callback`: 콜백함수 + +- result: 콜백 함수의 인자로 결과 값을 받는다. 서버를 통하여 Text 를 얻어내므로 반드시 이후 동작을 콜백함수에 넣어주어야 한다 + +**반환값** + +결과 값을 문자열로 리턴한다.JSON 포맷에서만 리턴 값으로 받을 수 있다.) + +**예제** + +1. 문서 작성 +2. GetTextFile API 현재 문서 데이터 저장 +3. Clear API 로 현재 문서 초기화 +4. SetTextFile API 로 저장한 문서 다시 로드 결과 : 현재 문서의 데이터를 문자열 형태로 저장하고, 다시 불러온다. callback 방식 + +```js +//GetTextFile API 현재 문서 데이터 저장 +HwpCtrl.GetTextFile("HWP", "", function(result) { + data = result; +} +); +HwpCtrl.Clear(1); +//Clear API 로 현재 문서 초기화 //SetTextFile API 로 저장한 문서 다시 로드 +HwpCtrl.SetTextFile(data, "HWP", "", function(result) { + //이후 수행 할 동작 +} +, null); +``` + +async await 방식 + +```js +// GetTextFile API 현재 문서 데이터 저장 data = await +HwpCtrl.GetTextFile("HWP", ""); +// Clear API 로 현재 문서 초기화 +HwpCtrl.Clear(1); +// SetTextFile API 로 저장한 문서 다시 로드 await +HwpCtrl.SetTextFile(data, "HWP", ""); +``` + +#### 1.2.25 `InitScan` + +**구문** + +```js +HwpCtrl.InitScan(option, rang, spara, spos, epara, epos) +``` + +**설명** + +문서 검색을 위한 준비 작업을 한다. + +**파라미터** + +- `option`: 다음과 같은 옵션을 지정할 수 있다. 0을 지정하면 모두 off 이다. 생략하면 0이 지정된다 + +| ID | 값 | 설명 | +| --- | --- | --- | +| maskNorma l | 0x00 | 본문을 대상으로 검색한다. (서브리스트를 검색하지 않는다.) | +| maskChar | 0x01 | char 타입 컨트롤 마스크를 대상으로 한다. (강제줄나눔, 문단 끝, 하이픈, 묶움빈칸, 고정폭빈칸, 등...) | +| maskInline | 0x02 | inline 타입 컨트롤 마스크를 대상으로 한다. (누름틀 필드 끝, 등...) | +| maskCtrl | 0x04 | extende 타입 컨트롤 마스크를 대상으로 한다. (다단, 누름틀 필드 시작, Shape Object, 각주, 미주, 등...) | + +- `range`: 검색의 범위를 다음과 같은 옵션을 조합하여 지정할 수 있다. 생략하면 문서 시작부터 문 서의 끝까지 검색 범위가 지정된다 + +| ID | 값 | 설명 | +| --- | --- | --- | +| scanSposCurrent | 0x0000 | 캐럿 위치부터. (시작 위치) | +| scanSposSpecified | 0x0010 | spara, spos 에 지정한 위치부터. (시작 위치) | +| scanSposLine | 0x0020 | 줄의 시작부터. (시작 위치) | +| scanSposParagraph | 0x0030 | 문단의 시작부터. (시작 위치) | +| scanSposSection | 0x0040 | 구역의 시작부터. (시작 위치) | +| scanSposList | 0x0050 | 리스트의 시작부터. (시작 위치) | +| scanSposControl | 0x0060 | 컨트롤의 시작부터. (시작 위치) | +| scanSposDocument | 0x0070 | 문서의 시작부터. (시작 위치) | +| scanEposCurrent | 0x0000 | 캐럿 위치까지. (끝 위치) | +| scanEposSpecified | 0x0001 | epara, epos 에 지정한 위치까지. (끝 위치) | +| scanEposLine | 0x0002 | 줄의 끝까지. (끝 위치) | +| scanEposParagraph | 0x0003 | 문단의 끝까지. (끝 위치) | +| scanEposSection | 0x0004 | 구역의 끝까지. (끝 위치) | +| scanEposList | 0x0005 | 리스트의 끝까지. (끝 위치) | +| scanEposControl | 0x0006 | 컨트롤의 끝까지. (끝 위치) | +| scanEposDocument | 0x0007 | 문서의 끝까지. (끝 위치) | +| scanWithinSelection | 0x00ff | 검색의 범위를 블록으로 제한. | +| scanForward | 0x0000 | 정뱡향. (검색 방향) | +| scanBackward | 0x0100 | 역방향. (검색 방향) | + +- `spara`: 다음과 같은 옵션을 지정할 수 있다. 0을 지정하면 모두 off 이다. 생략하면 0이 지정된다 +- `spos`: 검색 시작 위치의 문단 중에서 문자의 위치. scanSposSpecified 옵션이 지정되었을 때만 유 효하다 +- `epara`: 검색 끝 위치의 문단 번호. scanEposSpecified 옵션이 지정되었을 때만 유효하다 +- `epos`: 검색 끝 위치의 문단 중에서 문자의 위치. scanEposSpecified 옵션이 지정되었을 때만 유효 하다 + +**반환값** + +성공하면 true, 실패하면 false + +**비고** + +기존 ActiveX 한글 컨트롤에서는 포인터를 인자로 받아서 처리하였다. WEB 환경에 맞춰서 객체를 리 턴하는 방식으로 변경되었다. + +**예제** + +1. 텍스트 입력 +2. 텍스트의 맨 앞으로 캐럿 이동 +3. 예제 스크립트 실행 결과 : 현재 캐럿부터 줄의 끝까지 Text 를 얻어낸다. + +```js +HwpCtrl.InitScan(0, 2); +alert(HwpCtrl.GetText().text); +HwpCtrl.ReleaseScan(); +``` + +#### 1.2.26 `Insert` + +**구문** + +```js +HwpCtrl.Insert(path, format, arg, callback, callbackUserData) +``` + +**설명** + +현재 캐럿 위치에 문서 파일을 삽입한다. + +**파라미터** + +- `path`: URL, blob +- `format`: 문서 형식. Open API 참고. 빈 문자열을 지정하면 자동으로 디텍트한다. 생략하면 빈 문자 열이 지정된다 +- `arg`: 세부 옵션. Open API 참고. 의미는 format 에 지정한 파일 형식에 따라 다르다. 생략하면 빈 문자열이 지정된다 +- `callback`: Insert 함수가 동작을 마친 후 수행될 함수 + +- Parameter + +- `result`: 성공 실패 여부, FileName, Size, CallbackUserData 를 포함한 Object +- `CallbackUserData`: Callback 함수에 Argument 로 넘겨 받을 result 에 포함될 UserData, 생략 가능 + +**반환값** + +성공하면 true, 실패하면 false Return Value 는 Argument 가 제대로 들어왔는지 정도의 판단을 할 수 있으며 실제로 문서 Insert 의 성공 여부는 콜백함수의 인자로 들어가는 result 에서 확인할 수 있다. + +**비고** + +브라우저에서는 로컬 파일 접근 권한이 없어 로컬 파일은 사용할 수 없다. + +**예제** + +1. 문서 작성 +2. 문서를 끼워넣을 위치로 캐럿 이동 +3. Insert API 로 테스트 문서 끼워넣기 결과 : 특정 위치에 문서 끼워넣기 callback 방식 + +```js +// ...문서를 끼워넣을 위치로 캐럿 이동 // ... // Insert API 로 테스트 문서 끼워넣기 +HwpCtrl.Insert("http://APIDOCS/testDoc/test.hwp", "HWP", "", function (res) { +``` + +console.log(res); }, null); + +async await 방식 + +```js +// ...문서를 끼워넣을 위치로 캐럿 이동 // ... // Insert API 로 테스트 문서 끼워넣기 await +HwpCtrl.Insert("http://APIDOCS/testDoc/test.hwp", "HWP", ""); +``` + +#### 1.2.27 `InsertBackgroundPicture` + +**구문** + +```js +HwpCtrl.InsertBackgroundPicture(bordertype, path, embedded, filloption, watermark, effect, brightnss, contrast, callback) +``` + +**설명** + +배경이미지를 넣는다. + +**파라미터** + +- `bordertype`: 배경 유형을 지정 + +| bordertype | 설명 | 비고 | +| --- | --- | --- | +| "SelectedCell" | 현재 선택된 표의 셀 의 배경을 변경한다. | | +| "SelectedCellDelete" | 현재 선택된 표의 셀 의 배경을 지운다. | 반드시 셀이 선택되어 있어야 함. 커서 가 위치하는 것만으로는 동작하지 않 음. | + +- `path`: 이미지 +- `URLembeded`: 이미지 파일을 문서내에 포함할지 여부 Boolean Type + +- 기존 ActiveX 한/글 컨트롤과 호환을 위해서 존재하는 인자로 true/false 여부를 떠나서 Embeded 로 동작 한다 + +- `filloption`: 삽입할 그림의 크기를 지정하는 옵션 +- `effect`: 이미지효과 + +| filloption | 설명 | 비고 | +| --- | --- | --- | +| 0 | 바둑판식으로 -모두 | | +| 1 | 현바둑판식으로 -가로/위 | | +| 2 | 바둑판식으로 -가로/아로 | | +| 3 | 바둑판식으로 -세로/왼쪽 | | +| 4 | 바둑판식으로 -세로/오른쪽 | | +| 5 | 크기에 맞추어 | 설정하지 않았을 때 기본값 | +| 6 | 가운데로 | | +| 7 | 가운데 위로 | | +| 8 | 가운데 아래로 | | +| 9 | 왼쪽 가운데로 | | +| 10 | 왼쪽 위로 | | +| 11 | 왼쪽 아래로 | | +| 12 | 오른쪽 가운데로 | | +| 13 | 오른쪽 위로 | | + +| effec t | 설명 | 비고 | +| --- | --- | --- | +| 0 | 원래 그림 | 설정하지 않았을 때 기본값 | +| 1 | 그래이 스케일 | | +| 2 | 흑백으로 | | + +- `watermark`: watermark 효과 유무 Boolean Type . true 이면 brightness 와 contrast 옵션이 무시된다 +- `brightness`: 밝기 지정100 100, 기본값 : 0 +- `contrast`: 선명도 지정100 100, 기본값 : 0 +- `callback`: InsertBackgroundPicture 함수가 동작을 마친 후 수행될 함수 + +- parameterresult : 성공 시 true, 실패 시 false + +**반환값** + +성공 시 true, 실패 시 false + +**비고** + +브라우저에서는 로컬 파일 접근 권한이 없어 로컬 파일은 사용할 수 없다. + +**예제** + +1. 표 생성 +2. 셀안으로 캐럿 이동 +3. 예제 스크립트 실행 결과 : 셀에 배경이미지를 넣고, 제거한다. callback 방식 + +```js +// .... 셀 내부로 캐럿 이동 // ... // 배경 이미지 삽입 HwpCt rl.InsertB ackg roundPi cture("S electedC ell", "http://APIDOCS/testDoc/logo.png", 1, 5, 0, 0, 0, 0, function (res) { + console.log(res); +} +); +// ... // .... 배경이미지 넣은 셀 내부로 캐럿 이동 // 배경 이미지 제거 +HwpCtrl.InsertBackgroundPicture("SelectedCellDelete", 0, 0, 0, 0, 0, 0, 0, function (res) { +``` + +console.log(res); }); + +async await 방식 + +```js +// .... 셀 내부로 캐럿 이동 // ... // 배경 이미지 삽입 await +HwpCtrl.InsertBackgroundPicture("SelectedCell", "http://APIDOCS/testDoc/logo.png", 1, 5, 0, 0, 0, 0); +// ... // .... 배경이미지 넣은 셀 내부로 캐럿 이동 // 배경 이미지 제거 await +HwpCtrl.InsertBackgroundPicture("SelectedCellDelete", 0, 0, 0, 0, 0, 0, 0); +``` + +#### 1.2.28 `InsertCtrl` + +**구문** + +```js +HwpCtrl.InsertCtrl(ctrlid, initparam) +``` + +**설명** + +현재 캐럿 위치에 컨트롤을 삽입한다. + +**파라미터** + +- 캐럿 위치 정보를 Object 타입으로 리턴한다. Object 의 Property 는 다음과 같다 +- `ctrlid`: 삽입할 컨트롤의 아이디initparam : 컨트롤의 초기 속성. 생략하면 디폴트 속성으로 생성한다 + +**반환값** + +생성된 CtrlCode 객체를 리턴한다. + +**비고** + +```js +ctrlid 에 지정할 수 있는 컨트롤 ID는 HwpCtrl.CtrlID 가 리턴하는 ID와 동일하다. 자세한 것은 Ctrl 오 브젝트 Properties 인 CtrlID 를 참조.initparam 에는 컨트롤의 초기 속성을 지정한다. +``` + +**예제** + +1. 예제 스크립트 실행 결과 : 5x5 크기의 표를 삽입한다. + +```js +var tbact = HwpCtrl.CreateAction("TableCreate"); +var tbset = tbact.CreateSet(); +tbact.GetDefault(tbset); +tbset.SetItem("Rows", 5); +tbset.SetItem("Cols", 5); +var table = HwpCtrl.InsertCtrl("tbl", tbset); +``` + +#### 1.2.29 `InsertDocument` + +**구문** + +```js +HwpCtrl.InsertDocument(path, callback, callbackUserData) +``` + +**설명** + +현재 캐럿 위치에 문서를 끼워넣는 기능. + +**파라미터** + +- `path`: URL, blob +- `callback`: InsertDocument 함수가 동작을 마친 후 수행될 함수 + +- 콜백 인자 `result`: 성공 실패 여부, FileName, Size, CallbackUserData 를 포함한 Object +- `CallbackUserData`: Callback 함수에 Argument 로 넘겨 받을 result 에 포함될 UserData, 생략 가능 + +**반환값** + +성공하면 true, 실패하면 false Return Value 는 Argument 가 제대로 들어왔는지 정도의 판단을 할 수 있으며 실제로 문서 InsertDocument 의 성공 여부는 콜백함수의 인자로 들어가는 result 에서 확인할 수 있다. + +**비고** + +브라우저에서는 로컬 파일 접근 권한이 없어 로컬 파일은 사용할 수 없다. + +**예제** + +1. 문서 작성 +2. 문서를 끼워넣을 위치로 캐럿 이동 +3. InsertDocument API 로 테스트 문서 끼워넣기 결과 : 특정 위치에 문서 끼워넣기 callback 방식 + +```js +// ...InsertDocument 끼워넣을 위치로 캐럿 이동 // ... // InsertDocument API 로 테스트 문서 끼워넣기 +HwpCtrl.InsertDocument("http://APIDOCS/testDoc/test.hwp", function (res) { +``` + +console.log(res); }, null); + +async await 방식 + +```js +// ...문서를 끼워 넣을 위치로 캐럿 이동 // ... // InsertDocument API 로 테스트 문서 끼워넣기 await +HwpCtrl.InsertDocument("http://APIDOCS/testDoc/test.hwp", "HWP", ""); +``` + +#### 1.2.30 `InsertPicture` + +**구문** + +```js +HwpCtrl.InsertPicture(path, embeded, sizeoption, reverse, watermark, effect, width, height, callback) +``` + +**설명** + +현재 캐럿의 위치에 그림을 삽입한다. + +**파라미터** + +- `path`: 이미지 +- `URLembeded`: 이미지 파일을 문서내에 포함할지 여부 true/false + +- 기존 API 사용자와 호환을 위해서 존재하는 Parameter, true/false 여부를 떠나서 Embeded 로 동작 한다 + +- `sizeoption`: 삽입할 그림의 크기를 지정하는 옵션 + +| sizeoption | 설명 | +| --- | --- | +| 0 | 이미지 원래의 크기로삽입한다. width 와 height 를 지정할 필요없다. | +| 1 | width 와 height 에 지정한 크기로 그림을 삽입한다. | +| 2 | width 는 셀의 width 만큼, height 는 셀의 height 만큼 확대/축소된다. | +| 3 | 현재 캐럿이 표의 셀안에 있을 경우, 셀의 크기에 맞추어 원본 이미지의 가로 세로의 비율이 동일하게 확대/축소하여 삽입한다. | + +- `reverse`: 이미지의 반전 유무 true/false) +- `watermark`: watermark 효과 유무 true/false) +- `effect`: 그림 효과 + +| option | 설명 | +| --- | --- | +| 0 | 실제 이미지 그대로 | +| 1 | 그레이 스케일 | +| 2 | 흑백효과 | + +- `width`: 그림의 가로 크기 지정. 단위는 mm +- `height`: 그림의 높이 크기 지정. 단위는 mm +- `callback`: InsertPicture 함수가 동작을 마친 후 수행될 함수 + +- ctrl: 성공 시 생성된 컨트롤 객체, 실패 시 null + +**반환값** + +성공하면 true, 실패하면 false + +**비고** + +브라우저에서는 로컬 파일 접근 권한이 없어 로컬 파일은 사용할 수 없다. + +**예제** + +1. 예제 스크립트 실행 결과 : 셀에 배경이미지를 넣고, 제거한다. callback 방식 + +```js +HwpCtrl.InsertPicture("http://APIDOCS/testDoc/logo.png", true, 1, false, false, 0, 30, 20, function(ctrl) { + //InsertPicture 이후 동작 +} +); +``` + +async await 방식 + +```js +await HwpCtrl.InsertPicture("http://APIDOCS/testDoc/logo.png", true, 1, false, false, 0, 30, 20); +``` + +#### 1.2.31 `IsCommandLock` + +**구문** + +```js +HwpCtrl.IsCommandLock(actionID) +``` + +**설명** + +특정 액션이 잠금 상태인지 여부를 조사합니다. + +**파라미터** + +- `actionID`: 액션 ID + +**반환값** + +해당 액션이 잠금상태이면 True 를 반환하고, 잠금상태가 아니면 False 를 반환합니다. + +**예제** + +1. LockCommand 를 이용해 Bold Action 을 잠금 +2. 예제 스크립트 실행 결과: Bold Action 잠김 여부 메시지 창 + +```js +HwpCtrl.LockCommand('CharShapeBold', true); +var isCommandLock = HwpCtrl.IsCommandLock('CharShapeBold'); +if(isCommandLock) { + alert('Bold Action 을실행할수없습니다.') +} +else { + alert('Bold Action 을실행할수있습니다.') +} +``` + +#### 1.2.32 `IsSpellCheckCompleted` + +**구문** + +```js +HwpCtrl.IsSpellCheckCompleted() +``` + +**설명** + +맞춤법 검사가 완료되었는지를 판단한다. + +**반환값** + +성공 시 true, 실패 시 false + +**비고** + +기존 ActiveX 한글 컨트롤에서는 포인터를 인자로 받아서 처리하였다. WEB 환경에 맞춰서 객체를 리 + +턴하는 방식으로 변경되었다. + +**예제** + +1. 텍스트 입력 +2. 예제 스크립트 실행 결과 : 맞춤법 검사가 되어 있지 않다면 맞춤법 검사를 실행한다. + +```js +if (!HwpCtrl.IsSpellCheckCompleted()) { + // 맞춤법 검사 완료 여부 체크 + HwpCtrl.MovePos(2); + // 문서 시작부분으로 이동 + var dact = HwpCtrl.CreateAction("SpellingCheck"); + //맞춤법 검사 액션 생성 +``` + +및 실행 + +```js +var dset = dact.CreateSet(); +dact.GetDefault(dset); +dset.SetItem("CheckStart", 1); +dact.PopupDialog(dset, function() { + //PopupDialog 이후 수행될 동작 +} +, null); +} +``` + +#### 1.2.33 `LockCommand` + +**구문** + +```js +HwpCtrl.LockCommand(actionID, lock) +``` + +**설명** + +특정 액션이 실행되지 않도록 잠급니다. + +**파라미터** + +- `actionID`: 액션 ID +- `lock`: true 이면 액션의 실행을 lock 시키고, false 이면 unlock 시킵니다 + +**예제** + +1. 예제 스크립트 실행 결과: Bold Action 이 잠김 + +```js +HwpCtrl.LockCommand('CharShapeBold', true); +``` + +#### 1.2.34 `ModifyFieldProperties` + +**구문** + +```js +HwpCtrl.ModifyFieldProperties(field, remove, add) +``` + +**설명** + +지정한 필드의 속성을 바꿉니다. + +**파라미터** + +- `field`: 속성을 바꿀 필드 이름의 리스트. 형식은 PutFieldText 과 동일합니다 +- `remove`: 제거될 속성 +- `add`: 추가될 속성 + +**반환값** + +음수가 리턴되면 에러임을 나타냅니다.그 외의 리턴값은 아래 remark 참조) + +**비고** + +속성의 값은 아래와 같다. + +| long value | 설명 | +| --- | --- | +| 0x00000001 | 양식모드에서 편집가능 속성 (0: 편집 불가, 1: 편집 가능) | + +return 값의 bit field 는 다음과 같다. + +| long value bit mask | 설명 | +| --- | --- | +| 0x00000001 | 양식모드에서 편집가능 속성 (0: 편집 불가, 1: 편집 가능) | +| 0x80000000 | 에러 | +| 0x40000000 | 필드를 찾을 수 없음 | + +**예제** + +1. 본문 이라는 필드 생성 +2. 예제 스크립트 실행 결과: 본문 필드의 속성 변경 + +```js +var ModifyFieldPropertiesTest = function() { + var ret = HwpCtrl.ModifyFieldProperties("본문", 0, 0); + if (ret"0 ) { + var strErrMsg ="test 필드의 정보를 알아오는 데 실패"; + if(ret"0x40000000) strErrMsg +="\n 지정한필드를찾을수없습니다."; + alert(strErrMsg); + return; + } +if (ret"0x00000001) { + alert("현재 양식모드에서 편집 가능"); + ret = HwpCtrl.ModifyFieldProperties("본문", 1, 0); +} +else { + alert("현재 양식모드에서 편집 불가능"); + ret = HwpCtrl.ModifyFieldProperties("본문", 0, 1); +} +if (ret"0 ) { + alert("필드 속성 변경 실패."); +} +if (ret"0x00000001) alert("양식모드에서 편집 가능하도록 변경되었 습니다."); +else alert("양식모드에서 편집 불가능하도록 변경되었 습니다."); +} +``` + +ModifyFieldPropertiesTest(); + +#### 1.2.35 `MovePos` + +**구문** + +```js +HwpCtrl.MovePos(moveID, para, pos) +``` + +**설명** + +캐럿의 위치를 옮긴다. + +**파라미터** + +- `moveID`: 다음과 같은 값을 지정할 수 있다. 생략하면 moveCurList 가 지정된다 + +| ID | 값 | 설명 | +| --- | --- | --- | +| moveNextChar | 16 | 한 글자 뒤로 이동.(현재 리스트만을 대상으로 동작한다.) | +| movePrevChar | 17 | 한 글자 앞으로 이동. (현재 리스트만을 대상으로 동작한다.) | +| moveNextWord | 18 | 한 단어 뒤로 이동. (현재 리스트만을 대상으로 동작한다.) | +| movePrevWord | 19 | 한 단어 앞으로 이동. (현재 리스트만을 대상으로 동작한다.) | +| moveNextLine | 20 | 한 줄 아래로 이동. | +| movePrevLine | 21 | 한 줄 위로 이동. | +| moveStartOfLine | 22 | 현재 위치한 줄의 시작으로 이동. | +| moveEndOfLine | 23 | 현재 위치한 줄의 끝으로 이동. | +| moveParentList | 24 | 한 레벨 상위로 이동한다. | +| moveTopLevelList | 25 | 탑레벨 리스트로 이동한다. | +| moveRootList | 26 | 루트 리스트로 이동한다. | +| moveCurrentCaret | 27 | 현재 캐럿이 위치한 곳으로 이동한다. (캐럿 위치가 뷰의 맨 위쪽으로 올라간다. ) | +| moveLeftOfCell | 100 | 현재 캐럿이 위치한 셀의 왼쪽 | +| moveRightOfCell | 101 | 현재 캐럿이 위치한 셀의 오른쪽 | +| moveUpOfCell | 102 | 현재 캐럿이 위치한 셀의 위쪽 | +| moveDownOfCell | 103 | 현재 캐럿이 위치한 셀의 아래쪽 | +| moveStartOfCell | 104 | 현재 캐럿이 위치한 셀에서 행(row) 의 시작 | +| moveEndOfCell | 105 | 현재 캐럿이 위치한 셀에서 행(row) 의 끝 | +| moveTopOfCell | 106 | 현재 캐럿이 위치한 셀에서 열(column) 의 시작 | +| moveBottomOfCell | 107 | 현재 캐럿이 위치한 셀에서 열(column) 의 끝 | +| moveScrPos | 200 | 한/글 문서장에서의 screen 좌표로서 위치를 설정 한다. | +| moveScanPos | 201 | GetText() 실행 후 위치로 이동한다. | + +- `para`: 이이동할 문단의 번호. moveMain 또는 moveCurList 가 지정되었을 때만 사용된다. moveScrPos 가 지정되었을 때는 문단번호가 아닌 스크린 좌표로 해석된다 +- `pos`: 이동할 문단 중에서 문자의 위치. moveMain 또는 moveCurList 가 지정되었을 때만 사용된다 + +**반환값** + +성공하면 true, 실패하면 false + +**비고** + +moveScrPos 가 지정되었을 때는 스크린 좌표는 마우스 커서의 x,y 좌표를 그대로 넘겨 주면 된다.moveScanPos 는 문서를 검색하는 중에 캐럿을 이동 시키려 할 경우에만 사용이 가능하다. + +**예제** + +1. 문서 작성 +2. 캐럿 이동 +3. GetPos API 로 현재 Pos 정보 저장 +4. 다른 위치로 캐럿 이동 +5. 3단계에서 저장한 위치로 캐럿 이동 결과 : 캐럿 위치를 저장하고, 다시 이동한다. + +```js +posObj = HwpCtrl.GetPos(); +//GetPos API 로 현재 Pos 정보 저장 //... 다른 위치로 캐럿 이동 +HwpCtrl.MovePos(0, posObj.para, posObj.pos); +//3단계에서 저장한 위치로 캐럿 이동 +``` + +#### 1.2.36 `MoveToField` + +**구문** + +```js +HwpCtrl.MoveToField(field, text, start, select) +``` + +**설명** + +지정한 필드로 캐럿을 이동한다. + +**파라미터** + +- `field`: 필드 이름. GetFieldText/PutFieldText 과 같은 형식으로 이름 뒤에 #4 로 번호를 지정할 수 + +- 있다 +- `text`: 필드가 누름틀일 경우 누름틀 내부의 텍스트로 이동할지(true) 누름틀 코드로 이동할지(false) 를 지정한다. 누름틀이 아닌 필드일 경우 무시된다. 생략하면 true 지정된다 +- `start`: 필드의 처음(true) 으로 이동할지 끝(false) 으로 이동할지 지정한다. select 를 true 로 지정하면 무시된다. 생략하면 true 가 지정된다 +- `select`: 필드 내용을 블록으로 선택할지(true), 캐럿만 이동할지(false) 지정한다. 생략하면 false 가 지정된다 + +**반환값** + +성공하면 true, 실패하면 false + +**비고** + +누름틀은 한글 메뉴 중 입력 > 개체 > 필드 입력 메뉴를 선택하면 누름틀을 만드실 수 있습니다. + +**예제** + +1. 누름틀 삽입 필드 이름 본문 +2. MoveToField API 로 본문 필드로 이동 결과 : 특정 필드로 이동한다. + +```js +HwpCtrl.MoveToField("본문", true, true, false); +``` + +#### 1.2.37 `Open` + +**구문** + +```js +HwpCtrl.Open(path, format, arg, callback, callbackUserData) +``` + +**설명** + +문서 파일을 연다. + +**파라미터** + +- `path`: URL, blob +- `format`: 문서 형식. 빈 문자열을 지정하면 자동으로 디텍트한다. 생략하면 빈 문자열이 지정된다 + +| format | 설명 | +| --- | --- | +| "HWP" | 한글 native format | +| "HWP30" | 한글 3.X/96/97 | +| "HTML" | 인터넷 문서 | +| "TEXT" | 아스키 텍스트 문서 | +| "UNICODE" | 유니코드 텍스트 문서 | +| "HWP20" | 한글 2.0 | +| "HWP21" | 한글 2.1/2.5 | +| "HWP15" | 한글 1.X | +| "HWPML2X" | HWPML 2.X 문서 (Open / SaveAs 가능) | +| "HWPX" | 한글 표준 문서 (실무협의서 웹기안기에서 사용) | +| "MSWORD" | 마이크로소프트 워드 문서 | +| "PUBDOCBODY" | 공문서(xml) | + +- `arg`: 세부 옵션. 의미는 format 에 지정한 파일 형식에 따라 다르다. 생략하면 빈 문자열이 지정된다. arg 에 지정할 수 있는 옵션의 의미는 필터가 정의하기에 따라 다르지만, 신택스는 다음과 같이 공통된 형식을 사용한다.key:value;key:value; + +| 분류 | arg | Type | 설명 | +| --- | --- | --- | --- | +| 모든 Format | setcurdir | Boolean | 로드한 후 해당 파일이 존재하는 폴더로 현재 위치를 변경한다. hyperlink 정보가 상대적인 위치로 되어 있을 때 유용하다. | +| "HWP" | lock | Boolean | 로드한 후 해당 파일을 계속 오픈한 상태로 lock 을 걸지 여부 | +| "HWP" | notext | Boolean | 텍스트 내용을 읽지 않고 헤더 정보만 읽을지 여부. (스타일 로드 등에 사용) | +| "HWP" | template | Boolean | 새로운 문서를 생성하기 위해 템플릿 파일을 오픈한다. 이 옵션이 주어지면 lock은 무조건 false 로 처리된다. | +| "HWP" | suspendpassword | Boolean | true 로 지정하면, 암호가 있는 파일일 경우 암호를 묻지 않고 무조건 읽기에 실패한 것으로 처리한다. | +| "HWP" | forceopen | Boolean | true 로 지정하면, 읽기 전용으로 읽어야 하는 경우 대화상자를 띄우지 않는다. | +| "HWP" | versionwarning | Boolean | true 로 지정하면, 문서가 상위버전일 경우 메시지 박스를 띄우게 된다. | +| "HTML" | code | String, CodePage | 문서 변환 시 사용되는 코드 페이지를 지정할 수 있으며 code 키가 존재할 경우 필터 사용 시 사용자 다이얼로그를 띄우지 않는다. | +| "HTML" | textunit | Boolean, Pixel | Export 될 Text 의 크기의 단위 결정 pixel, point, mili 지정 가능. | +| "HTML" | formatunit | Boolean, Pixel | Export 될 문서 포맷 관련 (마진, Object 크기 등) 단위 결정. pixel, point, mili 지정 가능 | +| "DOCIMG" | asimg | Boolean | 저장할 때 페이지를 image 로 저장 | +| "DOCIMG" | ashtml | Boolean | 저장할 때 페이지를 html 로 저장 | +| "TEXT" | code | String | 문서 변환 시 사용되는 코드 페이지를 지정할 수 있으며 code 키가 존재할 경우 필터 사용 시 사용자 다이얼로그를 띄우지 않는다. | + +- `callback`: Open 함수가 동작을 마친 후 수행될 함수 + +- 콜백 인자 `result`: 성공 실패 여부, FileName, Size, CallbackUserData 를 포함한 Object + +- `CallbackUserData`: Callback 함수에 Argument 로 넘겨 받을 result 에 포함될 UserData, 생략 가능 + +**반환값** + +성공하면 true, 실패하면 false Return Value 는 Argument 가 제대로 들어왔는지 정도의 판단을 할 수 있으며 실제로 문서 Open 의 성공 여부는 콜백함수의 인자로 들어가는 result 에서 확인할 수 있다. + +**비고** + +브라우저에서는 로컬 파일 접근 권한이 없어 로컬 파일은 사용할 수 없다. + +**예제** + +1. 예제 스크립트 실행 결과 : 휴가계 문서를 오픈한다. callback 방식 + +```js +HwpCtrl.Open("http://APIDOCS/testDoc/test.hwp", "HWP", "", function (res) { + console.log(res); +}, null); +``` + +async await 방식 + +```js +await HwpCtrl.Open("http://APIDOCS/testDoc/test.hwp", "HWP", ""); +``` + +#### 1.2.38 `PrintDocument` + +**구문** + +```js +HwpCtrl.PrintDocument +``` + +**설명** + +문서를 PDF 로 변환하여 보여준다. + +**비고** + +WEB 환경에서 인쇄 드라이버에 직접 접근할 수 없다.때문에 웹브라우저 상의 PDF 뷰어 혹은 로컬 PDF 뷰어를 통해서 인쇄 설정 및 인쇄를 할 수 있도록 문서를 PDF 포맷으로 변환한다. + +**예제** + +1. 문서 작성 +2. 예제 스크립트 실행 결과 : 현재 문서가 PDF 로 변환되어 보여진다. + +```js +HwpCtrl.PrintDocument(); +``` + +#### 1.2.39 `PutFieldText` + +**구문** + +```js +HwpCtrl.PutFieldText(fieldlist, textlist) +``` + +**설명** + +지정한 필드의 내용을 채운다. + +**파라미터** + +- `fieldlist`: 내용을 채울 필드 이름의 리스트. 한 번에 여러 개의 필드를 지정할 수 있으며, 형식은 GetFieldText 와 동일하다. 다만 필드 이름 뒤에 # 로 번호를 지정하지 않으면 해당 이름을 가진 모든 필드에 동일한 텍스트를 채워 넣는다. 즉, PutFieldText 에서는 필드이름과 필드이름0 의 의미가 다르다 +- `textlist`: 필드에 채워 넣을 문자열의 리스트. 형식은 필드 리스트와 동일하게 필드의 개수만큼 텍스트를 0x02 로 구분하여 지정한다 + +**비고** + +현재 필드에 입력되어 있는 내용은 지워진다. 채워진 내용의 글자모양은 필드에 지정해 놓은 글자모양을 따라간다. fieldlist 의 필드 개수와, textlist 의 텍스트 개수는 동일해야 한다. 존재하지 않는 필드에 대해서는 무시한다. + +**예제** + +1. 누름틀 생성 +2. 필드이름 이름으로 지정 +3. 예제 스크립트 실행 결과 : 문서 내에 이름 필드가 있다면 해당 필드의 정렬을 가운데 정렬로 변경하고, 홍길동이라고 텍스트를 입력한다. + +```js +if(HwpCtrl.FieldExist("이름")) { + HwpCtrl.MoveToField("이름", true, true, true); + HwpCtrl.Run("ParagraphShapeAlignCenter"); + HwpCtrl.PutFieldText("이름", "홍길동"); +} +``` + +#### 1.2.40 `ReleaseScan` + +**구문** + +```js +HwpCtrl.ReleaseScan() +``` + +**설명** + +InitScan() 으로 설정된 정보를 초기화 한다. + +**예제** + +1. 텍스트 입력 +2. 텍스트의 맨 앞으로 캐럿 이동 +3. 예제 스크립트 실행 결과 : 현재 캐럿부터 줄의 끝까지 Text 를 얻어낸다. + +```js +HwpCtrl.InitScan(0, 2); +alert(HwpCtrl.GetText().text); +HwpCtrl.ReleaseScan(); +``` + +#### 1.2.41 `RenameField` + +**구문** + +```js +HwpCtrl.RenameField(oldname, newname) +``` + +**설명** + +지정한 필드의 이름을 바꾼다. + +**파라미터** + +- `oldname`: 이름을 바꿀 필드 이름의 리스트. 형식은 PutFieldText 과 동일하다 +- `newname`: 새로운 필드 이름의 리스트. oldname 과 동일한 개수의 필드 이름을 0x02 로 구분하여 지정한다 + +**비고** + +예를 들어 oldname 에 title0\x02title1, newname 에 tt1\x02tt2 로 지정하면 첫 번째 title 은 tt1 로, 두 번째 title 은 tt2 로 변경된다. oldname 의 필드 개수와, newname 의 필드 개수는 동일해야 한다. 존재하지 않는 필드에 대해서는 무시한다. + +**예제** + +1. 누름틀 입력 FieldName 이름1 +2. 예제 스크립트 실행 결과 : 이름1으로 지정된 누름틀의 필드이름이 이름2로 바뀐다. + +```js +HwpCtrl.RenameField(“ 이름1”, “이름2”); +``` + +#### 1.2.42 `ReplaceAction` + +**구문** + +```js +HwpCtrl.ReplaceAction(oldActionID, newActionID) +``` + +**설명** + +메뉴나 단축키로 호출되는 Action 을 교체합니다. + +**파라미터** + +- `oldActionID`: 교체할 원본 Action 의 이름 +- `newActionID`: 교체될 새로운 Action 의 이름 + +**반환값** + +성공여부, ActionID 가 잘못된 경우에는 실패합니다. + +**비고** + +메뉴나 단축키로 호출되는 Action 을 교체할 뿐, CreateAction 이나, Run 등의 API 를 이용할 때는 아무런 영향을 주지 못합니다. 교체된 OldActionID 를 원래대로 되돌려 놓기 위해서는 OldActionID 와 NewActionID 의 이름을 동일하게 하여 호출합니다. + +- 예: `ReplaceAction("FileSave","FileSave")`OldActionID 와 NewActionID 가 구조적으로 연결된 경우에는 해당 Action 을 실행하면 무한 루프를 돌게 됩니다. + +- 예: `ReplaceAction("FileSave","FileSaveAs")` `ReplaceAction("FileSaveAs","FileSave")` 를 둘다 호출한 경우 FileSave 나, FileSaveAs 를 호출하면 Stack overflow 가 발생합니다. + +**예제** + +1. 임의의 텍스트 삽입 및 선택 +2. 예제 스크립트 실행 결과: 진하게 클릭 시 글자모양 다이얼로그 창 표시 + +```js +HwpCtrl.ReplaceAction('CharShapeBold', 'CharShape'); +``` + +#### 1.2.43 `Run` + +**구문** + +```js +HwpCtrl.Run(actionID, callback, callbackUserData) +``` + +**설명** + +액션을 실행한다. + +**파라미터** + +- `actionID`: 액션 ID +- `callback`: 액션 실행 후 실행 될 함수 + +- 콜백 인자: 콜백을 호출한 Action 객체 result: 액션 실행 결과 Boolean 타입 callbackUserData: 사용자가 콜백에 전달한 데이터 + +- `callbackUserData`: 사용자가 콜백에 전달한 데이터 + +**예제** + +1. 글자 입력 +2. 입력한 글자 Selection +3. 예제 스크립트 실행 결과 : Selection 된 글자들에 Bold 속성을 부여한다. callback 방식 + +```js +HwpCtrl.Run("CharShapeBold", function (res) { + console.log(res); +} +); +``` + +async await 방식 + +```js +await HwpCtrl.Run("CharShapeBold"); +``` + +#### 1.2.44 `SaveAs` + +**구문** + +```js +HwpCtrl.SaveAs(fileName, format, arg, callback, callbackUserData) +``` + +**설명** + +현재 편집중인 문서를 지정한 이름으로 저장한다. + +**파라미터** + +- `fileName`: 로컬에 다운로드할 때에 파일의 이름을 지정한다. arg 인자에 download:true 포함되어 있을 때 로컬에 저장된다. 그때 사용되는 파일 이름 +- `format`: 문서 형식. 생략하면 HWP2 가 지정된다 + +| format | 설명 | +| --- | --- | +| "HWP" | 한글 native format | +| "HWPX" | 한글 표준 형식 | +| "HWPML2X" | HWPML 2.X 문서 | +| "ODT" | OpenDocument Text 형식 | +| "HTML" | 인터넷 문서 | +| "TEXT" | 아스키 텍스트 문서 | +| "UNICODE" | 유니코드 텍스트 문서 | +| "PUBDOCBODY" | 공문서(xml) | +| "PDF" | PDF(Portable Document Format) | +| "PDFA" | PDF/A | + +- `arg`: 세부 옵션. 의미는 format 에 지정한 파일 형식에 따라 다르다. 생략하면 빈 문자열이 지정된다. download:true 를 넣으면 문서가 로컬에 다운로드 된다 +- `callback`: SaveAs 함수가 동작을 마친 후 수행될 함수 + +- 콜백 인자 `result`: 성공/실패 여부, `path`, `fileName`, `Size`, `CallbackUserData`를 포함한 Object +- `callbackUserData`: 사용자가 콜백에 전달한 데이터 + +**비고** + +format, arg 의 일반적인 개념에 대해서는 Open 참조. HWP2 형식으로 파일 저장 시 arg 에 지정할 수 있는 옵션은 다음과 같다. + +| 인자 | Type | Default | 설명 | +| --- | --- | --- | --- | +| compress | Boolean | true | 압축 여부 | +| prvimage | Number | 2 | 미리보기 이미지(0=off, 1=BMP, 2=GIF) | +| prvtext | Number | 1 | 미리보기 텍스트(0=off, 1=0) | +| download | Boolean | false | true: DownLoad 폴더에 다운로드, false: 다운로드 하지 않음. | + +여러 개를 한꺼번에 할 경우에는 세미콜론으로 구분하여 연속적으로 사용할 수 있다. download:true;prvtext:1 + +**예제** + +1. 문서 작성 +2. 예제 스크립트 실행 결과 : 현재 열려있는 문서를 다운로드 폴더에 다운로드한다. callback 방식 + +```js +HwpCtrl.SaveAs("test.hwp", "HWP", "download:true", function() { + // SaveAs 실행 후 동작 +}, null); +``` + +async await 방식 + +```js +await HwpCtrl.SaveAs("test.hwp", "HWP", "download:true"); +``` + +#### 1.2.45 `SelectText` + +**구문** + +```js +HwpCtrl.SelectText(spara, spos, epara, epos) +``` + +**설명** + +블록을 설정한다. + +**파라미터** + +- `spara`: 블록 시작 위치의 문단 번호 +- `spos`: 블록 시작 위치의 문단 중에서 문자의 위치 +- `epara`: 블록 끝 위치의 문단 번호 +- `epos`: 블록 끝 위치의 문단 중에서 문자의 위치 + +**반환값** + +성공하면 true, 실패하면 false + +**예제** + +1. 문서 작성 +2. 텍스트 블록 +3. GetSelectedPos API 로 현재 블록 Pos 정보 저장 +4. 블록 해제 +5. 3단계에서 저장한 블록 다시 블록 결과 : 현재 설정된 블록의 위치를 저장하고, 이후에 다시 그 위치의 Text 를 블록한다. + +```js +posObj = HwpCtrl.GetSelectedPos(); +//GetSelectedPos API 로 현재 블록 Pos 정보 저장 //... 블록 해제 +HwpCtrl.SelectText(posObj.spara, posObj.spos, posObj.epara, posObj.epos); +//3 +``` + +단계에서 저장한 블록 다시 블록 + +#### 1.2.46 `SetCurFieldName` + +**구문** + +```js +HwpCtrl.SetCurFieldName(fieldname, option, direction, memo) +``` + +**설명** + +현재 캐럿 위치의 데이터 필드 이름을 설정한다. + +**파라미터** + +- `fieldname`: 데이터 필드 이름 +- `option`: 다음과 같은 옵션을 지정할 수 있다. 0을 지정하면 모두 off 이다. 생략하면 0이 지정된다 +- `direction`: 누름틀 필드의 안내문. 누름틀 필드일 때만 유효하다 +- `memo`: 누름틀 필드의 메모. 누름틀 필드일 때만 유효하다 + +**반환값** + +성공하면 true, 실패하면 false + +**예제** + +1. 누름틀 생성 +2. 생성한 누름틀로 캐럿 이동 +3. 예제 스크립트 실행 결과 : 현재 필드의 이름을 이름으로 설정하고 이름 필드에 홍길동을 입력한다. + +```js +HwpCtrl.SetCurFieldName("이름"); +if(HwpCtrl.GetCurFieldName(0) =="이름") { + HwpCtrl.PutFieldText("이름", "홍길동"); +} +``` + +#### 1.2.47 `SetMetaTag` + +**구문** + +```js +HwpCtrl.SetMetaTag(name, format, value) +``` + +**설명** + +문서 내 메타태그 정보를 삽입합니다. + +**파라미터** + +- `name`: 메타태그의 검색 이름. 같은 이름이 이미 존재하면 덮어씁니다 +- `format`: 아래 표 참고 + +| ID | 값 | 설명 | +| --- | --- | --- | +| 텍스트 | “text” | 메타태그 값으로 문자열이 입력됨 | +| 숫자 | “num” | 메타태그 값으로 숫자가 입력됨 | +| 날짜 | “date” | 메타태그 값으로 날짜가 입력됨 | +| 예 또는 아니요 | “yesno ” | 메타태그 값으로 Boolean 값이 입력됨 | + +- `value`: 메타태그의 데이터. format 에 맞게 입력하는 것을 권장한다 + +| ID | 값 예제 | 비고 | +| --- | --- | --- | +| 텍스트 | “한글과컴퓨터” | | +| 숫자 | “2022” | | +| 날짜 | “2022-07-18” | | +| 예 또는 아니요 | “yes”, “no” | “yes”, “no” 문자열만 유효함 | + +**반환값** + +성공 시 true 를 반환하며, 실패하면 false 를 반환한다. + +**비고** + +hwpx 에서만 사용가능합니다. + +**예제** + +1. 예제 스크립트 실행 결과: 현재 문서에 메타태그 정보를 삽입합니다. + +```js +var tag; +HwpCtrl.SetMetaTag("office", "text", "한글과컴퓨터"); +HwpCtrl.GetTextFile("HWPX", "", function (result) { + data = result; + HwpCtrl.Clear(1); + HwpCtrl.SetTextFile(data, "HWPX", "", function (result) { + tag = HwpCtrl.GetMetaTag("office"); + alert('name : '+ tag.NAME + '\n' + 'value : ' + tag.VALUE); + } +, null); +} +); +``` + +#### 1.2.48 `SetPos` + +**구문** + +```js +HwpCtrl.SetPos(list, para, pos) +``` + +**설명** + +캐럿을 문서 내 특정 위치로 위치시킨다. + +**파라미터** + +- `list`: 캐럿이 위치할 문서 내 리스트 아이디 +- `para`: 캐럿이 위치할 문단 아이디 +- `pos`: 캐럿이 위치할 문단 내 글자 단위 위치 + +**반환값** + +성공 시 true, 실패 시 false + +**예제** + +1. 문서 작성 +2. 캐럿 이동 +3. GetPos API 로 현재 Pos 정보 저장 +4. 다른 위치로 캐럿 이동 +5. 3단계에서 저장한 위치로 캐럿 이동 결과 : 캐럿 위치를 저장하고, 다시 이동한다. + +```js +posObj = HwpCtrl.GetPos(); +//GetPos API 로 현재 Pos 정보 저장 //... 다른 위치로 캐럿 이동 +HwpCtrl.SetPos(posObj.list, posObj.para, posObj.pos); +//3단계에서 저장한 위치로 캐럿 이동 +``` + +#### 1.2.49 `SetPosBySet` + +**구문** + +```js +HwpCtrl.SetPosBySet(pos) +``` + +**설명** + +캐럿을 문서 내 특정 위치로 위치시킵니다. SetPos 의 ParameterSet 버전) + +**파라미터** + +- `pos`: 캐럿의 위치에 대한 ParameterSet + +**반환값** + +성공하면 true 를 반환하고, 실패하면 false 를 반환합니다. + +**예제** + +1. 임의의 텍스트 작성 +2. 예제 스크립트 실행 +3. 현재 커서의 위치 메시지창 출력 +4. 1초 후 왼쪽으로 2칸 이동된 커서의 위치 메시지창 출력 결과: 커서 위치를 이동 + +```js +var posSet = null; +function SetPosTest() { + posSet = HwpCtrl.GetPosBySet(); + if ( posSet != null) { + alert(posSet.Item("List") +""+ posSet.Item("Para") +""+ posSet.Item("Pos")); + if (!HwpCtrl.SetPosBySet(posSet)) { + alert ("GetPos err"); + } +} +} +; +function CursorMove() { + HwpCtrl.Run('MoveLeft'); + HwpCtrl.Run('MoveLeft'); +``` + +setTimeout(() =& { SetPosTest(); },1000) + +```js +} +``` + +SetPosTest(); CursorMove(); + +#### 1.2.50 `SetTextFile` + +**구문** + +```js +HwpCtrl.SetTextFile(data, format, option, callback, callbackUserData) +``` + +**설명** + +문서를 문자열로 지정한다. + +**파라미터** + +- `data`: 문자열로 변경된 text 파일 +- `format`: 파일의 형식 + +| format | 설명 | 비고 | +| --- | --- | --- | +| HWP | Hwp native format | BASE64 로 인코딩되어 있다. 저장된 내용 을 다른 곳에서 보여줄 필요가 없다면 이 포맷을 사용하기를 권장합니다 | +| HWPML2X | HWP 형식과 호환 | 문서의 모든 정보를 유지 | +| HTML | 인터넷 문서 HTML 형식 | 한글 고유의 서식은 손실된다. | +| TEXT | 유니코드 텍스트 | 서식정보가 없는 텍스트만 저장 | +| JSON | json format | 문서의 정보를 Json 형태로 만든다. 문서의 모든 정보를 유지 | + +- option + +| option | 값 | 설명 | +| --- | --- | --- | +| insertfile | 현재 커서 이후에 지정된 파일 삽입 | | + +- `callback`: SetTextFile API 이후 수행될 함수 + +- result: 성공 실패 여부, callbackUserData 를 포함한 Object 를 인자로 받는다 +- `callback`: SetTextFile API 이후 수행될 함수 +- `callbackUserData`: callback 함수에 Argument 로 넘겨 받을 result 에 포함될 UserData, 생략 가능 + +**반환값** + +성공하면 true, 실패하면 false + +**비고** + +return 받은 boolean 값은 Argument 가 제대로 들어왔고, 함수가 제대로 동작을 하였는지를 판별할 수 있고, 서버를 통해서 문서가 제대로 SetTextFile 됬는지 여부는 콜백함수의 인자로 들어오는 result 에 서 확인할 수 있다. + +**예제** + +1. 문서 작성 +2. GetTextFile API 현재 문서 데이터 저장 +3. Clear API 로 현재 문서 초기화 +4. SetTextFile API 로 저장한 문서 다시 로드 결과 : 현재 문서의 데이터를 문자열 형태로 저장하고, 다시 불러온다. callback 방식 + +```js +// GetTextFile API 현재 문서 데이터 저장 +HwpCtrl.GetTextFile("HWP", "", function(result) { + data = result; +} +); +HwpCtrl.Clear(1); +// Clear API 로 현재 문서 초기화 // SetTextFile API 로 저장한 문서 다시 로드 +HwpCtrl.SetTextFile(data, "HWP", "", function(result) { + // 이후 수행 할 동작 +} +, null); +``` + +async await 방식 + +```js +// GetTextFile API 현재 문서 데이터 저장 data = await +HwpCtrl.GetTextFile("HWP", ""); +HwpCtrl.Clear(1); +// Clear API 로 현재 문서 초기화 // SetTextFile API 로 저장한 문서 다시 로드 await +HwpCtrl.SetTextFile(data, "HWP", ""); +``` + +#### 1.2.51 `ShowCaret` + +**구문** + +```js +HwpCtrl.ShowCaret(show) +``` + +**설명** + +캐럿을 숨김/보이기 처리한다. + +**파라미터** + +- `show`: 캐럿 표시 여부 + +**예제** + +1. 예제 스크립트 실행 결과 : 캐럿이 숨겨진다. + +```js +HwpCtrl.ShowCaret(false); +``` + +#### 1.2.52 `ShowHorizontalScroll` + +**구문** + +```js +HwpCtrl.ShowHorizontalScroll(show) +``` + +**설명** + +가로 스크롤을 화면에 숨김/보이기 처리한다. + +**파라미터** + +- `show`: 가로 스크롤 표시 여부 + +**반환값** + +성공하면 true, 실패하면 false + +**예제** + +1. 예제 스크립트 실행 결과 : 가로 스크롤이 감춰진다. + +```js +HwpCtrl.ShowHorizontalScroll(false); +``` + +#### 1.2.53 `ShowRibbon` + +**구문** + +```js +HwpCtrl.ShowRibbon(show) +``` + +**설명** + +리본 메뉴를 화면에 숨김/보이기 처리한다. + +**파라미터** + +- `show`: 캐럿 표시 여부 + +**반환값** + +성공하면 true, 실패하면 false + +**예제** + +1. 예제 스크립트 실행 결과 : Ribbon 메뉴가 감춰진다. + +```js +HwpCtrl.ShowRibbon(false); +``` + +#### 1.2.54 `ShowStatusBar` + +**구문** + +```js +HwpCtrl.ShowStatusBar(show) +``` + +**설명** + +상태바를 화면에 숨김/보이기 처리한다. + +**파라미터** + +- `show`: 상태바 표시 여부 + +**반환값** + +성공하면 true, 실패하면 false + +**예제** + +1. 예제 스크립트 실행 결과 : 상태바가 감춰진다. + +```js +HwpCtrl.ShowStatusBar(false); +``` + +#### 1.2.55 `ShowToolBar` + +**구문** + +```js +HwpCtrl.ShowStatusBar(show) +``` + +**설명** + +툴바 메뉴를 화면에 숨김/보이기 처리한다. + +**파라미터** + +- `show`: 상태바 표시 여부 + +**반환값** + +성공하면 true, 실패하면 false + +**예제** + +1. 예제 스크립트 실행 결과 : 툴바 메뉴가가 감춰진다. + +```js +HwpCtrl.ShowToolBar(false); +``` + +#### 1.2.56 `ShowVerticalScroll` + +**구문** + +```js +HwpCtrl.ShowVerticalScroll(show) +``` + +**설명** + +세로 스크롤을 화면에 숨김/보이기 처리한다. + +**파라미터** + +- `show`: 세로 스크롤 표시 여부 + +**반환값** + +성공하면 true, 실패하면 false + +**예제** + +1. 예제 스크립트 실행 결과 : 세로 스크롤이 감춰진다. + +```js +HwpCtrl.ShowVerticalScroll(false); +``` + +## 2. HwpAction + +### 2.1 Properties + +#### 2.1.1 `ActID` + +**구문** + +```js +act.ActID +``` + +**설명** + +액션 ID를 나타낸다. 읽기 전용.액션 ID의 종류 및 설명은 별도 문서 참조. + +**반환값** + +ActionID 를 String 타입으로 return 한다. + +**예제** + +1. 예제 스크립트 실행 결과 : CharShapeBold ActionID 메시지 창 + +```js +var act = HwpCtrl.CreateAction("CharShapeBold"); +alert("Action ID :"+ act.ActID); +``` + +#### 2.1.2 `SetID` + +**구문** + +```js +act.SetID +``` + +**설명** + +액션이 사용하는 parameter set ID를 나타낸다. 읽기 전용.액션 ID의 종류 및 설명은 별도 문서 참조. + +**반환값** + +액션이 사용하는 SetID 를 String 타입으로 return 한다. + +**예제** + +1. 예제 스크립트 실행 결과 : CharShapeBold 액션의 SetID 메시지 창 + +```js +var act = HwpCtrl.CreateAction("CharShapeBold"); +alert("Set ID :"+ act.SetID); +``` + +### 2.2 Methods + +#### 2.2.1 `CreateSet` + +**구문** + +```js +act.CreateSet() +``` + +**설명** + +액션과 대응하는 ParameterSet 객체를 생성한다. + +**반환값** + +성공 시 ParameterSet 객체를 return 한다.ParameterSet 을 사용하지 않는 Action 은 null 객체를 리 턴한다. + +**예제** + +1. 예제 스크립트 실행 결과 : 5x5 크기의 표를 삽입한다. + +```js +var tbact = HwpCtrl.CreateAction("TableCreate"); +var tbset = tbact.CreateSet(); +tbact.GetDefault(tbset); +tbset.SetItem("Rows", 5); +tbset.SetItem("Cols", 5); +tbact.Execute(tbset); +``` + +#### 2.2.2 `GetDefault` + +**구문** + +```js +act.GetDefault(param) +``` + +**설명** + +현재 상태에 따라 액션 실행에 필요한 인수를 구한다.액션 ID의 종류 및 설명은 별도 문서 참조. + +**파라미터** + +- `param`: 인수를 저장할 ParameterSet 객체 + +**반환값** + +성공 시 true, 실패 시 false + +**비고** + +예를 들어 글자모양의 액션의 경우, 현재 셀렉션 상태에 따라 param 의 아이템들이 채워진다.서브셋을 만들 경우에는 서브셋을 만든 후에 GetDefault 를 사용한다. + +**예제** + +1. 예제 스크립트 실행 결과 : 5x5 크기의 표를 삽입한다. + +```js +var tbact = HwpCtrl.CreateAction("TableCreate"); +var tbset = tbact.CreateSet(); +tbact.GetDefault(tbset); +tbset.SetItem("Rows", 5); +tbset.SetItem("Cols", 5); +tbact.Execute(tbset); +``` + +#### 2.2.3 `PopupDialog` + +**구문** + +```js +act.PopupDialog(param, callback, callbackUserData) +``` + +**설명** + +액션의 대화상자를 띄운다. + +**파라미터** + +- `param`: 액션의 실행을 제어할 인수. parameterSet 의 종류와 아이템의 의미는 액션이 정의한 바에 따라 다르다 +- `callback`: PopupDialog 동작을 마친 후 수행될 함수 + +- action: 콜백을 호출한 Action 객체 + +- param: 액션으로 전달된 ParameterSet 객체 + +- result: 대화상자 버튼 클릭 결과 true: 설정버튼, false: 취소버튼) + +- callbackUserData: 사용자가 콜백에 전달한 데이터 +- `callbackUserData`: 사용자가 콜백에 전달한 데이터 + +**반환값** + +성공 시 true, 실패 시 false + +**예제** + +1. 예제 스크립트 실행 결과 : 글자 속성 대화상자를 띄운다. + +```js +var dact = HwpCtrl.CreateAction("CharShape"); +// 액션 생성 +var dset = dact.CreateSet(); +dact.GetDefault(dset); +dact.PopupDialog(dset, function(action, paramSet, result, callbackUserData) { + if(result == true) { +``` + +action.Execute(paramSet); } }); + +#### 2.2.4 `Execute` + +**구문** + +```js +act.Execute(param, callback, callbackUserData) +``` + +**설명** + +지정한 인수로 액션을 실행한다. + +**파라미터** + +- `param`: 액션의 실행을 제어할 인수. parameterSet 의 종류와 아이템의 의미는 액션이 정의한 바에 따라 다르다 +- `callback`: Execute 동작 수행 후 실행될 함수 + +- action: 콜백을 호출한 Action 객체 + +- param: 액션으로 전달된 ParameterSet 객체 + +- result: 액션 실행 결과 true: 액션 성공, false: 액션 실패) + +- callbackUserData: 사용자가 콜백에 전달한 데이터 +- `callbackUserData`: 사용자가 콜백에 전달한 데이터 + +**반환값** + +성공 시 true, 실패 시 false + +**예제** + +1. 예제 스크립트 실행 결과 : 5x5 크기의 표를 삽입한다. + +```js +var tbact = HwpCtrl.CreateAction("TableCreate"); +var tbset = tbact.CreateSet(); +tbact.GetDefault(tbset); +tbset.SetItem("Rows", 5); +tbset.SetItem("Cols", 5); +tbact.Execute(tbset); +``` + +#### 2.2.5 `Run` + +**구문** + +```js +act.Run() +``` + +**설명** + +액션을 실행한다. + +**파라미터** + +- `param`: 액션의 실행을 제어할 인수. parameterSet 의 종류와 아이템의 의미는 액션이 정의한 바에 따라 다르다 +- `callback`: Run 동작을 마친 후 수행될 함수 + +- action: 콜백을 호출한 Action 객체 + +- param: 액션으로 전달된 ParameterSet 객체 + +- result: 대화상자 버튼 클릭 결과 true: 설정버튼, false: 취소버튼) + +- callbackUserData: 사용자가 콜백에 전달한 데이터 +- `callbackUserData`: 사용자가 콜백에 전달한 데이터 + +**반환값** + +성공 시 true, 실패 시 false + +**예제** + +1. 텍스트 입력 +2. 예제 스크립트 실행 결과 : Bold Action 실행대화상자가 없는 액션은 동기 방식으로 실행한다.) + +```js +var act = HwpCtrl.CreateAction("CharShapeBold"); +// 액션 생성 +act.Run(); +//액션 실행; +``` + +## 3. HwpParameterSet + +### 3.1 Properties + +#### 3.1.1 `Count` + +**구문** + +```js +pset.Count +``` + +**설명** + +현재 존재하는 아이템의 개수를 나타낸다. 읽기 전용. + +**반환값** + +아이템 개수를 Number 타입으로 리턴한다. + +**예제** + +1. 예제 스크립트 실행 결과 : 서브 셋 아이템 수 안내창 + +```js +var act = HwpCtrl.CreateAction("PageSetup"); +var pageSet = act.CreateSet(); +act.GetDefault(pageSet); +pageSet.SetItem("ApplyTo", 3); +var pset = pageSet.CreateItemSet("PageDef", "PageDef"); +pset.SetItem("TopMargin", 3401); +pset.SetItem("BottomMargin", 5669); +pset.SetItem("LeftMargin", 4251); +pset.SetItem("RightMargin", 4251); +pset.SetItem("HeaderLen", 0); +pset.SetItem("FooterLen", 0); +pset.SetItem("GutterLen", 0); +alert("Page 속성 서브 Set 의 아이템 수 :"+ pset.Count) +``` + +#### 3.1.2 `IsSet` + +**구문** + +```js +pset.IsSet +``` + +**설명** + +ParameterSet 타입인지 여부를 나타낸다. 읽기 전용. + +**반환값** + +ParameterSet 인 경우 true, ParameterArray 인 경우 false 를 리턴한다. + +**예제** + +1. 예제 스크립트 실행 결과 : 5 * 5 표를 삽입하고, Width, Height 를 조작한다. + +```js +var act = HwpCtrl.CreateAction("TableCreate"); +var pset = act.CreateSet(); +var idx = 0; +if(pset.IsSet) { + act.GetDefault(pset); + pset.SetItem("Rows", 5); + pset.SetItem("Cols", 5); + var colset = pset.CreateItemArray("ColWidth", 5); + var rowset = pset.CreateItemArray("RowHeight", 5); + if(colset.IsSet) { + return; + // colset 은 ParameterArray 타입이어야 한다. + } +for (idx = 0; idx < colset.Count; idx++) { + colset.SetItem(idx, 7200); +} +for (idx = 0; idx < rowset.Count; idx++) { + rowset.SetItem(idx, colset.Item(0)/2); + // Height 는 Width 의 절반으로 지정한다. +} +act.Execute(pset) +} +``` + +#### 3.1.3 `SetID` + +**구문** + +```js +pset.SetID +``` + +**설명** + +ParameterSet 의 ID를 나타낸다. 읽기 전용. + +**파라미터** + +- `itemid`: 아이템 ID + +**반환값** + +Set ID를 String 타입으로 리턴한다. + +**예제** + +결과 : SetID 안내창 TableCreate Action SetID : TableCreation) + +```js +var tbact = HwpCtrl.CreateAction("TableCreate"); +var tbset = tbact.CreateSet(); +alert("TableCreate Action SetID :"+ tbset.SetID); +``` + +### 3.2 Methods + +#### 3.2.1 `Clone` + +**구문** + +```js +pset.Clone() +``` + +**설명** + +ParameterSet 을 복사한다. + +**반환값** + +동일한 데이터를 가진 ParameterSet 을 복사하여 ParameterSet 객체 타입으로 리턴한다. + +**비고** + +동일한 ID를 가진 기존의 아이템은 삭제된다. + +**예제** + +1. 예제 스크립트 실행 결과 : 복사한 아이템이 동일한 지 여부 안내창 + +```js +var charShapeSet = HwpCtrl.CharShape; +var cloneSet = charShapeSet.Clone(); +if(cloneSet.IsEquivalent(charShapeSet)) { + alert("동일"); +} +``` + +#### 3.2.2 `CreateItemArray` + +**구문** + +```js +pset.CreateItemArray(itemid, count) +``` + +**설명** + +아이템으로 ParameterArray 타입의 배열을 생성한다. + +**파라미터** + +- `itemid`: 아이템 +- `IDcount`: 생성할 배열의 초기 크기 + +**반환값** + +인자에 따라 ParameterArray 를 생성하고, ParameterArray 객체 타입으로 리턴한다. + +**비고** + +동일한 ID를 가진 기존의 아이템은 삭제된다. + +**예제** + +1. 예제 스크립트 실행 결과 : 5 * 5 표를 삽입하고, Width, Height 를 조작한다. + +```js +var act = HwpCtrl.CreateAction("TableCreate"); +var pset = act.CreateSet(); +var idx = 0; +if(pset.IsSet) { + act.GetDefault(pset); + pset.SetItem("Rows", 5); + pset.SetItem("Cols", 5); + var colset = pset.CreateItemArray("ColWidth", 5); + var rowset = pset.CreateItemArray("RowHeight", 5); + if(colset.IsSet) { + return; + // colset 은 ParameterArray 타입이어야 한다. + } +for (idx = 0; idx < colset.Count; idx++) { + colset.SetItem(idx, 7200); +} +for (idx = 0; idx < rowset.Count; idx++) { + rowset.SetItem(idx, colset.Item(0)/2); + // Height 는 Width 의 절반으로 지정한다. +} +act.Execute(pset) +} +``` + +#### 3.2.3 `CreateItemSet` + +**구문** + +```js +pset.CreateItemSet(itemid, setid) +``` + +**설명** + +아이템으로 ParameterSet 을 생성한다. + +**파라미터** + +- `itemid`: 아이템 +- `IDsetid`: 생성할 ParameterSet 의 ID + +**반환값** + +생성된 서브 셋을 ParameterSet 객체 타입으로 리턴한다. + +**비고** + +parameter set 내부에 아이템으로 또 다른 parameter set 을 가지는 서브 셋의 개념이다. + +**예제** + +1. 예제 스크립트 실행 결과 : 문서 전체 용지의 속성이 변경된다. + +```js +var act = HwpCtrl.CreateAction("PageSetup"); +var pageSet = act.CreateSet(); +act.GetDefault(pageSet); +pageSet.SetItem("ApplyTo", 3); +var pset = pageSet.CreateItemSet("PageDef", "PageDef"); +pset.SetItem("TopMargin", 3401); +pset.SetItem("BottomMargin", 5669); +pset.SetItem("LeftMargin", 4251); +pset.SetItem("RightMargin", 4251); +pset.SetItem("HeaderLen", 0); +pset.SetItem("FooterLen", 0); +pset.SetItem("GutterLen", 0); +act.Execute(pageSet); +``` + +#### 3.2.4 `GetIntersection` + +**구문** + +```js +pset.GetIntersection(srcset) +``` + +**설명** + +두 ParameterSet 에 공통적으로 존재하고, 값도 동일한 아이템만으로 구성된 교집합으로 this 를 구성한다. + +**파라미터** + +- `srcset`: this 와 srcset 의 교집합이 this 에 저장된다 + +**예제** + +1. 텍스트 입력 후 글자 블록 +2. 예제 스크립트 실행 결과 : 블록된 텍스트의 글자 크기 속성을 제거하고 글자 크기를 10으로 다시 지정한다. + +```js +var charShapeSet = HwpCtrl.CharShape; +var cloneSet = charShapeSet.Clone(); +cloneSet.RemoveItem("Height"); +// cloneSet 에서 글자 크기 속성 제거 charShapeSet.GetIntersection(cloneSet); +// 두 셋의 교집합만 남긴다. +if(charShapeSet.Item("Height") === null) charShapeSet.SetItem("Height", 1000) HwpCtrl.CharShape = charShapeSet; +``` + +#### 3.2.5 `IsEquivalent` + +**구문** + +```js +pset.IsEquivalent(srcset) +``` + +**설명** + +두 ParameterSet 의 내용이 동일한 값을 가지고 있는지 검사한다. + +**파라미터** + +- `srcset`: this 와 srcset 을 비교한 결과를 리턴한다 + +**반환값** + +동일하면 true, 다르면 false + +**예제** + +1. 예제 스크립트 실행 결과 : 복사한 아이템이 동일한 지 여부 안내창 + +```js +var charShapeSet = HwpCtrl.CharShape; +var cloneSet = charShapeSet.Clone(); +if(cloneSet.IsEquivalent(charShapeSet)) { + alert("동일"); +} +``` + +#### 3.2.6 `Item` + +**구문** + +```js +pset.Item(itemid) +``` + +**설명** + +지정한 아이템의 값을 리턴한다. + +**파라미터** + +- `itemid`: 아이템 ID + +**반환값** + +아이템의 값, 만약 지정한 아이템이 존재하지 않으면 아이템의 포맷에 따라 0 또는 빈 문자열을 리턴한다. + +**예제** + +1. 글자 입력 +2. 입력한 글자 Selection +3. 예제 스크립트 실행 결과 : 만약 Selection 된 글자들의 글자 크기가 동일하고 20pt 미만이면 글자 크기를 20pt 로 설정한다. + +```js +var cs = HwpCtrl.CharShape; +if (cs.ItemExist("Height") && cs.Item("Height") < 2000) { + cs.SetItem("Height", 2000); + HwpCtrl.CharShape = cs; +} +``` + +#### 3.2.7 `ItemExist` + +**구문** + +```js +pset.ItemExist(itemid) +``` + +**설명** + +지정한 아이템이 존재하는지 검사한다. + +**파라미터** + +- `itemid`: 아이템 ID + +**반환값** + +존재하면 true, 존재하지 않으면 false + +**예제** + +1. 글자 입력 +2. 입력한 글자 Selection +3. 예제 스크립트 실행 결과 : 만약 Selection 된 글자들의 글자 크기가 동일하고 20pt 미만이면 글자 크기를 20pt 로 설정한다. + +```js +var cs = HwpCtrl.CharShape; +if (cs.ItemExist("Height") && cs.Item("Height") < 2000) { + cs.SetItem("Height", 2000); + HwpCtrl.CharShape = cs; +} +``` + +#### 3.2.8 `Merge` + +**구문** + +```js +pset.Merge(srcset) +``` + +**설명** + +두 ParameterSet 에 내용을 병합한다, 결과는 this 와 srcset 의 합집합이 this 에 저장된다. + +**파라미터** + +- `srcset`: this 와 srcset 이 병합되어 this 에 저장된다 + +**예제** + +1. 텍스트 입력 후 글자 블록 +2. 예제 스크립트 실행 결과 : ParameterSet 의 아이템을 제거하고, 병합한다. + +```js +var act = HwpCtrl.CreateAction("PageSetup"); +var pageSet = act.CreateSet(); +act.GetDefault(pageSet); +pageSet.SetItem("ApplyTo", 3); +var pset = pageSet.CreateItemSet("PageDef", "PageDef"); +pset.SetItem("TopMargin", 3401); +pset.SetItem("BottomMargin", 5669); +pset.SetItem("LeftMargin", 4251); +pset.SetItem("RightMargin", 4251); +pset.SetItem("HeaderLen", 0); +pset.SetItem("FooterLen", 0); +pset.SetItem("GutterLen", 0); +var origCount = pset.Count; +// 7 +var cloneSet = pset.Clone(); +var cloneCount = cloneSet.Count; +// 7 +pset.RemoveAll(); +//pset 의 모든 아이템을 삭제한다. +var removedCount = pset.Count; +// 0 +pset.Merge(cloneSet); +//pset 과 cloneSet 을 합친다. +var mergedCount = pset.Count; +//7 +act.Execute(pageSet); +alert("origCount : " + origCount + "\ncloneCount : " + cloneCount + "\nremovedCount : " + removedCount + "\nmergedCount : " + mergedCount); +``` + +#### 3.2.9 `RemoveAll` + +**구문** + +```js +pset.RemoveAll(setID) +``` + +**설명** + +ParameterSet 을 초기화한다. + +**파라미터** + +- `setid`: 새로 적용할 Set ID + +**비고** + +이미 존재하는 ParameterSet 객체를 이용해 새로운 타입의 ParameterSet 으로 초기화하여 재사용하는 목적에 사용된다. + +**예제** + +1. 텍스트 입력 후 글자 블록 +2. 예제 스크립트 실행 결과 : ParameterSet 의 아이템을 제거하고, 병합한다. + +```js +var act = HwpCtrl.CreateAction("PageSetup"); +var pageSet = act.CreateSet(); +act.GetDefault(pageSet); +pageSet.SetItem("ApplyTo", 3); +var pset = pageSet.CreateItemSet("PageDef", "PageDef"); +pset.SetItem("TopMargin", 3401); +pset.SetItem("BottomMargin", 5669); +pset.SetItem("LeftMargin", 4251); +pset.SetItem("RightMargin", 4251); +pset.SetItem("HeaderLen", 0); +pset.SetItem("FooterLen", 0); +pset.SetItem("GutterLen", 0); +var origCount = pset.Count; +// 7 +var cloneSet = pset.Clone(); +var cloneCount = cloneSet.Count; +// 7 +pset.RemoveAll(); +//pset 의 모든 아이템을 삭제한다. +var removedCount = pset.Count; +// 0 +pset.Merge(cloneSet); +//pset 과 cloneSet 을 합친다. +var mergedCount = pset.Count; +//7 +act.Execute(pageSet); +alert("origCount : " + origCount + "\ncloneCount : " + cloneCount + "\nremovedCount : " + removedCount + "\nmergedCount : " + mergedCount); +``` + +#### 3.2.10 `RemoveItem` + +**구문** + +```js +pset.RemoveItem(itemid) +``` + +**설명** + +지정한 아이템을 삭제한다. + +**파라미터** + +- `itemid`: 아이템 ID + +**예제** + +1. 텍스트 입력 후 글자 블록 +2. 예제 스크립트 실행 결과 : 블록된 텍스트의 글자 크기 속성을 제거하고 글자 크기를 10으로 다시 지정한다. + +```js +var charShapeSet = HwpCtrl.CharShape; +var cloneSet = charShapeSet.Clone(); +cloneSet.RemoveItem("Height"); +// cloneSet 에서 글자 크기 속성 제거 charShapeSet.GetIntersection(cloneSet); +// 두 셋의 교집합만 남긴다. +if(charShapeSet.Item("Height") === null) charShapeSet.SetItem("Height", 1000) HwpCtrl.CharShape = charShapeSet; +``` + +#### 3.2.11 `SetItem` + +**구문** + +```js +pset.SetItem(itemid, value) +``` + +**설명** + +지정한 아이템의 값을 설정한다. + +**파라미터** + +- `itemid`: 아이템 ID +- `value`: 설정할 값 + +**비고** + +이미 동일한 ID의 아이템이 존재하면 지정한 값으로 바뀌고, 존재하지 않으면 아이템이 생성된다. + +**예제** + +1. 예제 스크립트 실행 결과 : 문서 전체 용지의 속성이 변경된다. + +```js +var act = HwpCtrl.CreateAction("PageSetup"); +var pageSet = act.CreateSet(); +act.GetDefault(pageSet); +pageSet.SetItem("ApplyTo", 3); +var pset = pageSet.CreateItemSet("PageDef", "PageDef"); +pset.SetItem("TopMargin", 3401); +pset.SetItem("BottomMargin", 5669); +pset.SetItem("LeftMargin", 4251); +pset.SetItem("RightMargin", 4251); +pset.SetItem("HeaderLen", 0); +pset.SetItem("FooterLen", 0); +pset.SetItem("GutterLen", 0); +act.Execute(pageSet); +``` + +## 4. HwpParameterArray + +### 4.1 Properties + +#### 4.1.1 `Count` + +**구문** + +```js +itemArray.Count +``` + +**설명** + +배열의 크기를 나타낸다. + +**반환값** + +배열의 크기를 Number 타입으로 리턴한다. + +**비고** + +배열의 크기는 Runtime 에 동적으로 조절할 수 있다. + +**예제** + +1. 예제 스크립트 실행 결과 : 5 * 5 표를 삽입하고, Width, Height 를 조작한다. + +```js +var act = HwpCtrl.CreateAction("TableCreate"); +var pset = act.CreateSet(); +var idx = 0; +if(pset.IsSet) { + act.GetDefault(pset); + pset.SetItem("Rows", 5); + pset.SetItem("Cols", 5); + var colset = pset.CreateItemArray("ColWidth", 5); + var rowset = pset.CreateItemArray("RowHeight", 5); + if(colset.IsSet) { + return; + // colset 은 ParameterArray 타입이어야 한다. + } +for (idx = 0; idx < colset.Count; idx++) { + colset.SetItem(idx, 7200); +} +for (idx = 0; idx < rowset.Count; idx++) { + rowset.SetItem(idx, colset.Item(0)/2); + // Height 는 Width 의 절반으로 지정한다. +} +act.Execute(pset) +} +``` + +#### 4.1.2 `IsSet` + +**구문** + +```js +itemArray.IsSet +``` + +**설명** + +ParameterSet 타입인지 여부를 나타낸다. 읽기 전용. + +**반환값** + +ParameterSet 인 경우 true, ParameterArray 인 경우 false 를 리턴한다. + +**예제** + +1. 예제 스크립트 실행 결과 : 5 * 5 표를 삽입하고, Width, Height 를 조작한다. + +```js +var act = HwpCtrl.CreateAction("TableCreate"); +var pset = act.CreateSet(); +var idx = 0; +if(pset.IsSet) { + act.GetDefault(pset); + pset.SetItem("Rows", 5); + pset.SetItem("Cols", 5); + var colset = pset.CreateItemArray("ColWidth", 5); + var rowset = pset.CreateItemArray("RowHeight", 5); + if(colset.IsSet) { + return; + // colset 은 ParameterArray 타입이어야 한다. + } +for (idx = 0; idx < colset.Count; idx++) { + colset.SetItem(idx, 7200); +} +for (idx = 0; idx < rowset.Count; idx++) { + rowset.SetItem(idx, colset.Item(0)/2); + // Height 는 Width 의 절반으로 지정한다. +} +act.Execute(pset) +} +``` + +### 4.2 Methods + +#### 4.2.1 `Clone` + +**구문** + +```js +itemArray.Clone() +``` + +**설명** + +동일한 크기와 데이터를 갖는 ParameterArray 개체를 복사하여 리턴한다. + +**반환값** + +성공 시 복사된 ParameterArray 객체를 리턴한다.실패 시 null 을 리턴한다. + +**예제** + +결과 : Clone, Copy 결과 안내창 + +```js +var tset = HwpCtrl.CreateSet("TableCreation"); +var colset = tset.CreateItemArray("ColWidth", 1); +colset.SetItem(0, 7200); +var cloneColset = colset.Clone(); +var cloneColWidth = cloneColset.Item(0); +cloneColset.SetItem(0, 0); +var cloneColWidth2 = cloneColset.Item(0); +cloneColset.Copy(colset) var cloneColWidth3 = cloneColset.Item(0); +alert("Clone :"+ cloneColWidth +"\n 초기화 :"+ cloneColWidth2 +"\nCopy :"+ cloneColWidth3); +``` + +#### 4.2.2 `Copy` + +**구문** + +```js +itemArray.Copy(srcarray) +``` + +**설명** + +인자로 들어온 ParameterArray 의 내용을 복사한다. + +**파라미터** + +- `srcarray`: srcarray 의 내용이 그대로 this 로 복사된다 + +**예제** + +결과 : Clone, Copy 결과 안내창 + +```js +var tset = HwpCtrl.CreateSet("TableCreation"); +var colset = tset.CreateItemArray("ColWidth", 1); +colset.SetItem(0, 7200); +var cloneColset = colset.Clone(); +var cloneColWidth = cloneColset.Item(0); +cloneColset.SetItem(0, 0); +var cloneColWidth2 = cloneColset.Item(0); +cloneColset.Copy(colset) var cloneColWidth3 = cloneColset.Item(0); +alert("Clone :"+ cloneColWidth +"\n 초기화 :"+ cloneColWidth2 +"\nCopy :"+ cloneColWidth3); +``` + +#### 4.2.3 `Item` + +**구문** + +```js +itemArray.Item(index) +``` + +**설명** + +지정한 아이템의 값을 리턴한다. + +**파라미터** + +- `index`: 아이템 인덱스. 0부터 시작한다 + +**반환값** + +index 에 해당하는 아이템의 값을 리턴한다. + +**예제** + +1. 예제 스크립트 실행 결과 : 5 * 5 표를 삽입하고, Width, Height 를 조작한다. + +```js +var act = HwpCtrl.CreateAction("TableCreate"); +var pset = act.CreateSet(); +var idx = 0; +if(pset.IsSet) { + act.GetDefault(pset); + pset.SetItem("Rows", 5); + pset.SetItem("Cols", 5); + var colset = pset.CreateItemArray("ColWidth", 5); + var rowset = pset.CreateItemArray("RowHeight", 5); + if(colset.IsSet) { + return; + // colset 은 ParameterArray 타입이어야 한다. + } +for (idx = 0; idx < colset.Count; idx++) { + colset.SetItem(idx, 7200); +} +for (idx = 0; idx < rowset.Count; idx++) { + rowset.SetItem(idx, colset.Item(0)/2); + // Height 는 Width 의 절반으로 지정한다. +} +act.Execute(pset) +} +``` + +#### 4.2.4 `SetItem` + +**구문** + +```js +itemArray.SetItem(index, value) +``` + +**설명** + +지정한 원소의 값을 설정한다. + +**파라미터** + +- `index`: 아이템의 인덱스. 0부터 시작.value : 아이템의 값 + +**예제** + +1. 예제 스크립트 실행 결과 : 5 * 5 표를 삽입하고, Width, Height 를 조작한다. + +```js +var act = HwpCtrl.CreateAction("TableCreate"); +var pset = act.CreateSet(); +var idx = 0; +if(pset.IsSet) { + act.GetDefault(pset); + pset.SetItem("Rows", 5); + pset.SetItem("Cols", 5); + var colset = pset.CreateItemArray("ColWidth", 5); + var rowset = pset.CreateItemArray("RowHeight", 5); + if(colset.IsSet) { + return; + // colset 은 ParameterArray 타입이어야 한다. + } +for (idx = 0; idx < colset.Count; idx++) { + colset.SetItem(idx, 7200); +} +for (idx = 0; idx < rowset.Count; idx++) { + rowset.SetItem(idx, colset.Item(0)/2); + // Height 는 Width 의 절반으로 지정한다. +} +act.Execute(pset) +} +``` + +## 5. HwpCtrlCode + +### 5.1 Properties + +#### 5.1.1 `CtrlCh` + +**구문** + +```js +ctrlCode.CtrlCh +``` + +**설명** + +컨트롤 문자. 읽기 전용. + +**반환값** + +현재 컨트롤의 컨트롤 문자를 Number 타입으로 리턴한다. + +**비고** + +일반적으로 컨트롤 ID를 사용해 컨트롤의 종류를 판별하지만, 이보다 더 포괄적인 범주를 나타내는 컨 트롤 문자로 판별할 수도 있다. 예를 들어 각주와 미주는 ID는 다르지만, 컨트롤 문자는 17로 동일하다. 컨트롤 문자는 131 사이의 값을 사용한다. 그 중 CtrlCode 객체로 리턴 받을 수 있는 문자는 다음과 같다. + +| Ch | 설명 | +| --- | --- | +| 2 | 구역/단 정의 | +| 3 | 필드 시작 | +| 11 | 그리기 개체 / 표 | +| 15 | 주석 | +| 16 | 머리말 / 꼬리말 | +| 17 | 각주 / 미주 | +| 18 | 자동 번호 | +| 21 | 새번호/ 번호 감추기/ 번호 위치 | +| 22 | 책갈피 / 찾아보기 표시 | +| 23 | 덧말 / 글자 겹침,,, | + +**예제** + +1. 표, 그리기 개체, 누름틀 삽입 +2. 예제 스크립트 실행 결과 : 표, 그리기 개체만 삭제된다. + +```js +var ctrl = HwpCtrl.HeadCtrl; +while(ctrl) { + if (ctrl.CtrlCh == 11) { + // 그리기 개체 / 표 + HwpCtrl.DeleteCtrl(ctrl); + } +ctrl = ctrl.Next; +} +``` + +#### 5.1.2 `CtrlID` + +**구문** + +```js +ctrlCode.CtrlID +``` + +**설명** + +컨트롤 ID. 읽기 전용. + +**반환값** + +현재 컨트롤의 ID를 String 타입으로 리턴한다. + +**비고** + +컨트롤 ID는 컨트롤의 종류를 나타내기 위해 할당된 ID로서, 최대 4개의 문자로 구성된 문자열이다. 예 를 들어 표는 tbl, 각주는 fn% 이다.한글에서 현재까지 지원되는 모든 컨트롤의 ID는 다음 표 참조. + +| ID | ParameterSet ID | Initialization Set | 설명 | +| --- | --- | --- | --- | +| cold | ColDef | ColDef | 단 | +| secd | SecDef | SecDef | 구역 | +| fn | FootnoteShape | FootnoteShape | 각주 | +| en | FootnoteShape | FootnoteShape | 미주 | +| tbl | Table | TableCreation | 표 | +| eqed | EqEdit | EqEdit | 수식 | +| gso | ShapeObject | ShapeObject | 그리기 개체 | +| atno | AutoNum | AutoNum | 번호넣기 | +| nwno | AutoNum | AutoNum | 새번호로 | +| pgct | PageNumCtrl | PageNumCtrl | 페이지 번호 제어 | +| pghd | PageHiding | PageHiding | 감추기 | +| pgnp | PageNumPos | PageNumPos | 쪽번호 위치 | +| head | HeaderFooter | HeaderFooter | 머리말 | +| foot | HeaderFooter | HeaderFooter | 꼬리말 | +| %dte | FieldCtrl | FieldCtrl | 현재의 날짜/시간 필드 | +| %ddt | FieldCtrl | FieldCtrl | 파일 작성 날짜/시간 필드 | +| %pat | FieldCtrl | FieldCtrl | 문서 경로 필드 | +| %bmk | FieldCtrl | FieldCtrl | 블럭 책갈피 | +| %mmg | FieldCtrl | FieldCtrl | 메일 머지 | +| %xrf | FieldCtrl | FieldCtrl | 상호 참조 | +| %fmu | FieldCtrl | FieldCtrl | 계산식 | +| %clk | FieldCtrl | FieldCtrl | 누름틀 | +| %smr | FieldCtrl | FieldCtrl | 문서 요약 정보 필드 | +| %usr | FieldCtrl | FieldCtrl | 사용자 정보 필드 | +| %hlk | FieldCtrl | FieldCtrl | 하이퍼링크 | +| bokm | TextCtrl | TextCtrl | 책갈피 | +| idxm | IndexMark | IndexMark | 찾아보기 | +| tdut | Dutmal | Dutmal | 덧말 | +| tcmt | | | 주석 | + +```js +Initialization Set : HwpCtrl.InsertCtrl 에 지정할 수 있는 initparam 의 ParameterSet ID +``` + +**예제** + +1. 표 삽입 +2. 예제 스크립트 실행 결과 : 현재 문서에 있는 모든 표의 셀 간격이 1pt 로 설정된다. + +```js +var ctrl = HwpCtrl.HeadCtrl; +var tbset = HwpCtrl.CreateSet("Table"); +tbset.SetItem("CellSpacing", 100); +while(ctrl) { + if (ctrl.CtrlID =="tbl") { + // 그리기 개체 / 표 ctrl.Properties = tbset; + } +ctrl = ctrl.Next; +} +``` + +#### 5.1.3 `Next` + +**구문** + +```js +ctrlCode.Next +``` + +**설명** + +다음 컨트롤. 읽기 전용. + +**반환값** + +현재 컨트롤의 뒤 컨트롤을 CtrlCode 객체 타입으로 리턴한다. + +**비고** + +문서 중의 모든 컨트롤(표, 그림 등 특수 문자)들은 linked list 로 서로 연결되어 있는데, list 중 다음 + +컨트롤을 나타낸다. + +**예제** + +1. 표 삽입 +2. 예제 스크립트 실행 결과 : 현재 문서에 있는 모든 표의 셀 간격이 1pt 로 설정된다. + +```js +var ctrl = HwpCtrl.HeadCtrl; +var tbset = HwpCtrl.CreateSet("Table"); +tbset.SetItem("CellSpacing", 100); +while(ctrl) { + if (ctrl.CtrlID =="tbl") { + // 그리기 개체 / 표 ctrl.Properties = tbset; + } +ctrl = ctrl.Next; +} +``` + +#### 5.1.4 `Prev` + +**구문** + +```js +ctrlCode.Prev +``` + +**설명** + +이전 컨트롤. 읽기 전용. + +**반환값** + +현재 컨트롤의 앞 컨트롤을 CtrlCode 객체 타입으로 리턴한다. + +**비고** + +문서 중의 모든 컨트롤(표, 그림 등 특수 문자)들은 linked list 로 서로 연결되어 있는데, list 중 앞 컨트롤을 나타낸다. + +**예제** + +1. 표 입력 +2. 예제 스크립트 실행 결과 : 문서 중 모든 표의 셀 간격을 1pt 로 설정한다. + +```js +var ctrl = HwpCtrl.LastCtrl; +var tbset = HwpCtrl.CreateSet("Table"); +tbset.SetItem("CellSpacing", 100); +while(ctrl != null) { + if(ctrl.CtrlID =="tbl") { + ctrl.Properties = tbset; + } +ctrl = ctrl.Prev; +} +``` + +#### 5.1.5 `Properties` + +**구문** + +```js +ctrlCode.Properties +``` + +**설명** + +컨트롤의 속성을 나타낸다. + +**반환값** + +컨트롤의 속성을 ParameterSet 객체 타입으로 리턴한다. + +**비고** + +모든 컨트롤은 대응하는 ParameterSet 으로 속성을 읽고 쓸 수 있다. + +**예제** + +1. 표 삽입 +2. 예제 스크립트 실행 결과 : 현재 문서에 있는 모든 표의 셀 간격이 1pt 로 설정된다. + +```js +var ctrl = HwpCtrl.HeadCtrl; +var tbset = HwpCtrl.CreateSet("Table"); +tbset.SetItem("CellSpacing", 100); +while(ctrl) { + if (ctrl.CtrlID =="tbl") { + // 그리기 개체 / 표 ctrl.Properties = tbset; + } +ctrl = ctrl.Next; +} +``` diff --git a/apps/as/docs/sewer-bsc-plan/dmbp-regist-migration.md b/apps/as/docs/sewer-bsc-plan/dmbp-regist-migration.md new file mode 100644 index 000000000..e8f36ef2e --- /dev/null +++ b/apps/as/docs/sewer-bsc-plan/dmbp-regist-migration.md @@ -0,0 +1,674 @@ +# dmbpRegist.jsp → React 마이그레이션 분석·구현 가이드 + +| 항목 | 내용 | +| ----------- | ------------------------------------------------------ | +| 원본 JSP | `adssNewOld/.../fnrsc/dmbp/dmbpRegist.jsp` | +| 대상 앱 | `frontend/apps/as` | +| 도메인 | 하수도기본계획 (구: 기본계획검토 / dmbp) | +| API 엔티티 | `@/entities/sewer-bsc-plan` (`SewerBscPlanController`) | +| 참조 패턴 | `@/pages/fnrs-cnsltn` + `@/widgets/pbadms-sprt` | +| 문서 목적 | contents 영역 **섹션별** UI·상태·API 매핑 및 구현 방향 | +| 작성 기준일 | 2026-07-27 | + +--- + +## 1. 마이그레이션 범위 + +### 1-1. 포함 (contents만) + +JSP `568~1274`행: `
` ~ `` + +| 영역 | JSP 식별 | 개요 | +| ---- | ---------------------------------- | --------------------------------------------------------------- | +| A | `gTitle1` | 페이지 타이틀·브레드크럼 | +| B | `slide_10` | 기본계획변경 신청(마스터·연도·개요) | +| C | `slide_15` | 신청자·설계자 정보 | +| D | `slide_19` | 개인정보 동의 | +| E | `slide_11` | 사업개요(단계별 인구·보급률) | +| F | `slide_12` | 총사업비 — `BizCstMatrixFormSection` 전용 | +| G | `slide_13` | 재원분담 | +| H | `slide_14` | 처리시설별 하수도정비기본계획 (Checkbox + Tabs) | +| I | `slide_16` | 체크리스트 + 필수첨부 + 기타첨부 | +| J | JSP 상단 `saveBtn` / `presentnBtn` | React: 폼 하단 액션바 (`fnrs-form-page` 279~323과 동일 구성, API만 sewer) | + +### 1-2. 제외 + +- `<%@ include %>`: top / sideMenu / footer / `bscPlnPopup.jsp` / `sndrdBzAmnt.jsp` +- 레이아웃·메뉴·권한 셸 → 기존 `BaseLayout` / `PageLayout` / AUTH 체계 +- `mStep1` 진행 단계 표시 (미구현 · 마이그레이션 범위 밖) + +팝업(`이전기본계획조회`)은 **별도 Dialog 컴포넌트**로 재구현하며, include JSP 자체는 포팅하지 않는다. + +--- + +## 2. 목표 아키텍처 + +``` +app/(with-base-layout)/sewer-bsc-plan/regist/page.tsx + └─ pages/sewer-bsc-plan/ui/sewer-bsc-plan-regist-page.tsx + └─ sewer-bsc-plan-form-page.tsx # FormProvider + 섹션 조립 + ├─ sections/*-form-section.tsx # 도메인 전용 + └─ widgets/pbadms-sprt/* # 공통 재사용 + +entities/sewer-bsc-plan/ # 이미 구현됨 + saveApply / findDetails / submitSupplement … +``` + +**폼 스택 (fnrs-cnsltn과 동일)** + +- `react-hook-form` + `zod` (`zodResolver`, `mode: "onChange"`) +- 섹션 래퍼: `SectionCollapsible` (Accordion 아님) +- UI: `@common/components` (Input, Select, Textarea, Button, Radio, Tabs, Table 등) +- 그리드가 필요한 곳만 `@tanstack/react-table` + `DataTable` +- 데이터: React Query (`useSaveApplySewerBscPlan` 등) — 목데이터는 API 미연동 구간만 `@faker-js/faker` + +**Vercel React Best Practices (적용 요약)** + +- `client-swr-dedup`: React Query로 체크리스트/필수첨부/코드 조회 중복 제거 +- `bundle-dynamic-imports`: 처리시설 Tabs 패널·이전계획 Dialog는 `next/dynamic` 후보 +- `rerender-derived-state`: 단계 연도·합계·진행률은 render 중 파생, 불필요 effect 금지 +- `async-parallel`: 등록 진입 시 코드/체크리스트트리/필수첨부트리를 병렬 조회 + +--- + +## 3. 전체 레이아웃 + +`fnrs-form-page.tsx`와 동일한 **FormProvider + 세로 섹션 스택** 구조를 따르고, +하단 액션바는 **[`fnrs-form-page.tsx` 279~323](../../src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx)** 과 동일 구성(목록·저장·신청완료·삭제·보완완료)이며 **API/라우트만 sewer** 로 바꾼다. +타이틀·브레드크럼은 폼 밖(`PageLayout` / regist·edit·view 셸)에서 처리한다. +(상세 View의 sticky FAB·검토/결재 버튼은 본 등록 문서 범위 밖 — `fnrs-view-page` 대응 후속.) + +### 3-1. 컴포넌트 트리 (fnrs 대응) + +``` +PageLayout (title / breadcrumb) +└─ SewerBscPlanRegistPage | SewerBscPlanEditPage | SewerBscPlanViewPage + └─ SewerBscPlanFormPage | SewerBscPlanViewPage 본문 + FormProvider (등록/수정) / 읽기 섹션 (상세) + └─ + ├─ BasicInfoFormSection // JSP slide_10 + ├─ ContactInfoFormSection // JSP slide_15 (pbadms-sprt) + ├─ PrivacyConsentSection // JSP slide_19 (pbadms-sprt, 등록·수정) + ├─ OutlineFormSection // JSP slide_11 (사업개요, 신규) + ├─ BizCstMatrixFormSection // JSP slide_12 (총사업비 전용, 신규) + ├─ FnrsCstFormSection // JSP slide_13 (재원분담, 신규 ※ FnrsShar 아님) + ├─ TmplntFormSection // JSP slide_14 (처리시설, Tabs로 시설별 패널) + ├─ ChecklistFormSection // JSP slide_16 (pbadms-sprt) + ├─ AttachmentsFormSection // JSP 필수·기타첨부 (pbadms-sprt) + ├─ SplmntDmndFormSection // edit + SL 상태만 (pbadms-sprt) + └─ form footer action bar // fnrs-form-page 279~323 동일 + // [목록] / [저장] / edit시 [신청완료][삭제] / SL시 [보완완료] +``` + +**폼 하단 액션바 버튼 구성** +기준: [`fnrs-form-page.tsx` 279~323](../../src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx). UI·분기만 동일하고 훅/라우트는 sewer. + +| 순서 | 버튼 | 조건 | sewer 비고 | +| ---- | -------- | ----------------------------------------- | ---------------------------------------------------- | +| 1 | 목록 | 항상 | `router.push("/sewer-bsc-plan")` | +| 2 | 저장 | `!isSplmntPrgrsStts` | `type="submit"` → `saveApply` / 임시저장(SV) | +| 3 | 신청완료 | `!isSplmntPrgrsStts && isEditMode` | 체크리스트+필수첨부 검증 → `saveApply` / LE | +| 4 | 삭제 | `!isSplmntPrgrsStts && isEditMode` | confirm → `delete` → 목록 | +| 5 | 보완완료 | `isSplmntPrgrsStts` (`prgrsSttsComCd=SL`) | `submitSupplement` (fnrs `onSplmntCmptn` 대응) | + +**fnrs와의 차이 (섹션 치환만, 골격 동일)** + +| fnrs-form-page | sewer-bsc-plan | +| ---------------------------------------------- | ------------------------------------------------------ | +| `BasicInfoFormSection` | 동일 역할 (마스터·연도·개요 필드는 sewer DTO) | +| `ContactInfoFormSection` | 동일 (`fieldPrefix="sewerBscPlanDtl"`) | +| `PrivacyConsentSection` | 동일 | +| `BusinessScaleFormSection` | → `OutlineFormSection` (단계별 인구·보급률) | +| `BusinessCostFormSection` | → `BizCstMatrixFormSection` (단계×원가 계층 전용) | +| `FnrsSharFormSection` | → `FnrsCstFormSection` (국고·지방비·원인자·기타) | +| _(없음)_ | `TmplntFormSection` (체크박스 + Tabs 시설별 매트릭스) | +| `ChecklistFormSection` `docSeComCd="FNRS"` | `docSeComCd="SEWER"` (TBD) | +| `AttachmentsFormSection` | 동일 패턴 | +| `SplmntDmndFormSection` | 동일 (`readOnly={!isSplmntPrgrsStts}`) | +| 폼 하단 액션바 (목록·저장·신청완료·삭제·보완완료) | **동일 구성, API·라우트만 sewer** | + +### 3-2. 본문 ASCII (폼 페이지) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ PageLayout: 하수도기본계획 신청/수정 breadcrumb │ +├─────────────────────────────────────────────────────────────────┤ +│ FormProvider │ +│
│ +│ │ +│ ┌─ SectionCollapsible: 기본계획 신청 ─────────────────────┐ │ +│ │ BasicInfoFormSection │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌─ 신청자 정보 ────────────────────────────────────────────┐ │ +│ │ ContactInfoFormSection │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌─ 개인정보 동의 ──────────────────────────────────────────┐ │ +│ │ PrivacyConsentSection │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌─ 사업개요 ────────────── [이전기본계획조회] ─────────────┐ │ +│ │ OutlineFormSection │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌─ 총사업비 (백만원) ──────────────────────────────────────┐ │ +│ │ BizCstMatrixFormSection │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌─ 재원분담 (백만원) ──────────────────────────────────────┐ │ +│ │ FnrsCstFormSection │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌─ 처리시설별 하수도정비기본계획 ──────────────────────────┐ │ +│ │ TmplntFormSection │ │ +│ │ ├─ BscFctList Checkbox │ │ +│ │ └─ Tabs (선택 시설별 TabsTrigger / TabsContent) │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌─ 체크리스트 ─────────────────────────────────────────────┐ │ +│ │ ChecklistFormSection │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌─ 첨부파일 ───────────────────────────────────────────────┐ │ +│ │ AttachmentsFormSection │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ ┌─ 보완요구 (edit + SL) ───────────────────────────────────┐ │ +│ │ SplmntDmndFormSection │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ form footer (fnrs-form-page 279~323 동일) ──────────────┐ │ +│ │ [목록] [저장] / edit: [신청완료][삭제] / SL: [보완완료] │ │ +│ │ ※ API·라우트만 sewer — UI 분기는 fnrs와 동일 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│
│ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 3-3. 액션바 동작 (`fnrs-form-page` 279~323 동일, API만 sewer) + +구현 위치: `sewer-bsc-plan-form-page.tsx` 하단 +(`fnrs-form-page.tsx`의 `mx-4 flex flex-wrap justify-center gap-3` 블록을 그대로 옮기고 훅·path만 교체) + +| 버튼 | 조건 | 대표 동작 | +| -------- | ---------------------------- | -------------------------------------------------------------------------- | +| 목록 | 항상 | `router.push("/sewer-bsc-plan")` | +| 저장 | `!isSplmntPrgrsStts` | `type="submit"` → `saveApply` + 임시저장(`SV`) | +| 신청완료 | edit && `!isSplmntPrgrsStts` | 체크리스트 완료 + **필수 첨부 완료** 검증 → `saveApply` + `LE` | +| 삭제 | edit && `!isSplmntPrgrsStts` | confirm → `delete` → 목록 | +| 보완완료 | `isSplmntPrgrsStts` (SL) | `form.handleSubmit` → `useSubmitSupplementSewerBscPlan` | + +**신청완료 사전 검증** (fnrs `handleApplyClick`과 동일 정책) + +1. **체크리스트**: `isFormChecklistComplete(cklstResultList)` — 미완료 시 `"모든 체크리스트 항목을 체크해야 신청이 가능합니다."` +2. **필수 첨부**: `esntlAtchYn === "Y"`인 필수첨부 슬롯마다 `esntlAtchFileList`에 `atchFileGroupId`가 있어야 함 + - Form: `AttachmentsFormSection`의 트리 + 폼 `esntlAtchFileList` + - 미충족 시 예: `"필수 첨부파일을 모두 등록해야 신청이 가능합니다."` + - 헬퍼 후보: `isFormEsntlAtchComplete(requiredTree, esntlAtchFileList)` (`pbadms-sprt`에 추가) + +저장에는 위 검증을 적용하지 않는다 (임시저장 허용). + +저장/제출 버튼을 섹션 헤더(`gTitle2 gRt`)에 두지 않는다 — **폼 하단 액션바 단일 진입점** (§4-J). + +### 3-4. 라우트 + +| 화면 | path | 셸 → FormPage | +| ---- | --------------------------- | ------------------------------------------ | +| 등록 | `/sewer-bsc-plan/regist` | `mode="create"` | +| 수정 | `/sewer-bsc-plan/[id]/edit` | `mode="edit"` + `useSewerBscPlanDetails` | +| 상세 | `/sewer-bsc-plan/[id]` | ViewPage (본 문서 범위 밖, fnrs-view 대응) | + +`docSeComCd` 후보: 백엔드 체크리스트·필수첨부 등록값 확인 후 **`SEWER`** (또는 레거시 `DMBP`) — **TBD, CMS 코드와 맞출 것**. + +--- + +## 4. 섹션별 상세 구현 + +### 4-0. Hidden / 세션 컨텍스트 (폼 루트) + +JSP hidden → React 폼 기본값·세션 스토어로 흡수. + +| JSP | 의미 | React | +| ------------------------------- | --------------- | ------------------------------------------------------- | +| `chgSe` / `chgSeSp` / `chgSeCd` | 변경구분 | `sewerBscPlan.sewerBscPlanSeComCd` + 표시용 코드명 | +| `progrsSttus` | SAVE / PRESENTN | 저장·제출 시 `prgrsSttsComCd`로 매핑 (임시저장 vs 제출) | +| `planSeCd` | 고정 `A` | 필요 시 상수 또는 API 필드 확인 | +| `adstDstrCd` / `rgnCd` | 행정구역 | `sewerBscPlan.admdstCd` | +| `sidoNm` / `gugunNm` | 표시용 | 읽기 전용 표시 (`detDto.sido`/`gugun`) | + +구현: `getCreateDefaultValues(userInfo)`에서 로그인 사용자·소속 지자체 주입. + +--- + +### 4-A. 타이틀·브레드크럼 + +| JSP | React | +| ------------------------ | ------------------------------------------------------ | +| `h3` 기본계획검토 | PageLayout title: **하수도기본계획 신청** (IIPBS 명명) | +| 설치인가 및 제원협의 > … | 메뉴 메타 기반 breadcrumb | + +별도 섹션 컴포넌트 불필요. 라우트 `page.tsx` / layout metadata로 처리. + +--- + +### 4-B. 기본계획변경 신청 (`slide_10`) + +**파일**: `ui/sections/basic-info-form-section.tsx` +**래퍼**: `SectionCollapsible` title=`기본계획 신청` +**필드 접두사**: `sewerBscPlan.*` + `sewerBscPlanDtl.*` + +| 표시명 | JSP | API 매핑 | UI | 필수 | 비고 | +| ---------------- | ----------------- | ------------------------------- | ------------------ | ---- | ---------------------------------- | +| 기본계획명 | `drngMtncPlanNm` | `sewerBscPlan.sewerBscPlanNm` | Input | Y | | +| 계획년도 | `planYr` | `sewerBscPlanDtl.planYr` | Select (현재~2005) | Y | | +| 기준년도 | `crtrYr` | `sewerBscPlanDtl.crtrYr` | Select | Y | | +| 1~4단계 목표연도 | `goalYrStep1~4` | `frlvlGoalYr`~`folvlGoalYr` | Select (현재~+30) | Y | 1단계 변경 시 +5/+10/+15 자동 연동 | +| 계획개요 | `bzCn` | `sewerBscPlanDtl.bizOtlnCn` | Textarea | Y | | +| 구분 | `#chgSeNm` | `sewerBscPlanSeComCd` (+코드명) | 읽기 또는 Select | | 신규/변경 등 | +| 신청 지자체 | `${lclgvrnmntNm}` | `admdstCd` | 읽기 전용 | | | + +**동작** + +- `goalYrStep1` onChange → 2·3·4단계 기본값 세팅 + E/F/G 헤더 연도 span 갱신 (`watch` 파생) +- JSP 상단 `gRt` 저장/제출은 사용하지 않음 → FormPage 하단 액션바 (§3-3, §4-J) + +--- + +### 4-C. 신청자 정보 (`slide_15`) + +**재사용**: `@/widgets/pbadms-sprt`의 `ContactInfoFormSection` +**props**: `fieldPrefix="sewerBscPlanDtl"` (또는 dtl 필드 구조에 맞게) + +| 표시 | JSP | API | +| ---------------------------- | --------------------------- | --------------------------------------------------------------------------------------- | +| 신청자 이름/전화/부서/이메일 | `${UsrNm}` 등 서버렌더 | 로그인 사용자 읽기 전용 → `aplcntNm`, `aplcntMblTelno`, `aplcntOgdpNm`, `aplcntEmlAddr` | +| 설계자 | `dsgnrNm/Telno/Psitn/Email` | `dsgnrNm`, `dsgnrTelno`, `dsgnrOgdpNm`, `dsgnrEmlAddr` | + +신청자 4필드는 세션에서 defaultValues에 채우고 Input `disabled`. + +--- + +### 4-D. 개인정보 동의 (`slide_19`) + +**재사용**: `PrivacyConsentSection` + +| JSP | React | +| ----------------------------- | ------------------------------------------------------------------ | +| 고지 textarea + 보유기간 문구 | 동일 문구 상수/마크다운 | +| `privacyAgree` Y/N | 폼 필드 `privacyAgree: "Y" \| "N"` (API 미전송, 클라이언트 검증만) | + +**검증**: 제출 시 `Y` 필수 (JSP `presentnBtn`과 동일). 저장(임시)은 동의 없이 허용할지 정책 TBD — 기본은 JSP와 같이 **제출만 강제**. + +--- + +### 4-E. 사업개요 (`slide_11`) + +**파일**: `ui/sections/outline-form-section.tsx` (신규, 도메인 전용) +**API**: `sewerBscPlanOtlnList: SewerBscPlanOtlnItem[]` (planStpNo 1~4) + +| 행(구분) | JSP name | Item 필드 | +| ------------ | --------------- | ------------------- | +| 계획인구 | `arrPlanPpltn` | `sewerPlanPpltnCnt` | +| 처리인구 | `arrPrcsPpltn` | `sewerPrcsPpltnCnt` | +| 하수도보급율 | `arrDrngSplyRt` | `sewerPsplyRt` | + +열: 1~4단계 (헤더에 `frlvlGoalYr` 등 표시). + +**버튼**: `이전기본계획조회` → Dialog (`PreviousPlanLookupDialog`) + +- 레거시: `fnbscPlnPopupToggle` + `bscPlnPopup.jsp` +- React: Modal + 조회 API (**TBD**: 이전 계획 조회 엔드포인트; 없으면 1차는 Dialog UI만 + mock) + +숫자 입력: 콤마 제거 후 number 전송 (제출 시 JSP `replaceComma` 대체). + +--- + +### 4-F. 총사업비 (`slide_12`) + +**파일**: `ui/sections/biz-cst-matrix-form-section.tsx` (**전용 컴포넌트** — fnrs `BusinessCostFormSection` 재사용 금지) +**래퍼**: `SectionCollapsible` title=`총사업비` (단위 `백만원`은 헤더 우측) + +**JSP 현실**: 단계(1~4) × 원가 계층(총사업비, 공사비, 처리시설, 하수관로 신설/보수, 펌프장, 배수설비, 기타, 부대경비, 설계/감리/부대비). 일부 readonly·자동합산 JS. + +**신규 API**: `bizCstList: SewerBscPlanBizCstItem[]` = `{ planStpNo, acntgSeCd, cstrnAmt }` + +**구현 방향 (`BizCstMatrixFormSection` 전용)** + +- 행 = `useAcntgSeMngTree` → `buildAcntgCostItems` (fnrs `BusinessCostFormSection`과 동일 출처) +- `costItems` 로드 시 `useEffect`로 `bizCstList`를 `mergeBizCstList(existing, costItems)`로 동기화 +- 열 = `planStpNo` 1~4 (헤더에 목표연도 표시) +- readonly 행(`CostItemDef.readonly=true`)은 children 합산으로 자동계산; Input에 `readOnly` + `bg-muted` +- 변경 시 `setAmt` → `recalculateBizCstMatrix(list, costItems)` → `setValue("bizCstList")` +- 저장 시 섹션 onChange 합산을 신뢰하여 `bizCstList` 그대로 전송 (`toBizCstSaveList` identity) + +**표준사업비 조회** 버튼은 JSP에서 주석 처리됨 → **미구현**. + +--- + +### 4-G. 재원분담 (`slide_13`) + +**주의**: fnrs의 `FnrsSharFormSection`(재원협의 공유구분 트리)과 **다름**. + +| 행 | JSP | `SewerBscPlanFnrsCstItem` | +| ------------ | -------------- | ------------------------- | +| 총사업비 | `arrTotAmnt` | `sumAmt` (또는 파생) | +| 국고 | `arrNlepAmt` | `gvexpAmt` | +| 지방비 | `arrLcepAmt` | `lcexpAmt` | +| 원인자부담금 | `arrCusrAlotm` | `orgprBdchgAmt` | +| 기타 | `arrEtcAmt` | `etcAmt` | + +**파일**: `ui/sections/fnrs-cst-form-section.tsx` (신규) +데이터: `fnrsCstList` 길이 4 (`planStpNo` 1~4). + +검증(선택): 단계별 `gvexp+lcexp+orgpr+etc === sumAmt` (JSP `calculateTotalFunding` 참고). + +--- + +### 4-H. 처리시설별 하수도정비기본계획 (`slide_14`) — 핵심 복잡도 + +**JSP 동작** + +1. `BscFctList` 체크박스로 처리시설 다중 선택 (`arrBscFctCd`) +2. 선택 시 `createFormSection(code, name)`로 **당초 4단계 | 현재 | 변경 4단계** 매트릭스 동적 삽입 + → **React**: 세로 삽입 대신 `@common/components` `Tabs`로 시설별 패널 관리 (§아래 Tabs 규칙) +3. 활성 `formFields`(축소판): 처리구역면적, 하수처리인구, 계획하수량, 시설용량, 분류식 오수/우수, 합류식, 차집, 배수설비, 오수중계/일반/우수 펌프 +4. 시설별 필수첨부 `createFileAttachmentSection` +5. `fetchBasicPlanData`로 당초(이전) 데이터 바인딩 + +**API 매핑** + +| UI | DTO | +| -------------------- | ------------------------------------------------------------- | +| 선택 시설 목록 | `sewerBscPlanTmplntList[].swtrmtFcltCd` (+ `atchFileGroupId`) | +| 단계별 수치 (변경측) | `sewerBscPlanTmplntDtlList[]` → `SewerBscPlanTmplntDtlItem` | +| 임시저장 | `tmplntTmprStrgList` (편집 중 임시, 필요 시) | + +필드 대응 (변경 단계 기준): + +| UI 라벨 | `SewerBscPlanTmplntDtlItem` | +| -------------- | --------------------------- | +| 처리구역면적 | `prcsZoneArea` | +| 하수처리인구 | `swtrmtPpltnCnt` | +| 계획하수량 | `planSwqty` | +| 시설용량 | `fcltCpct` | +| 분류식 오수 | `sprsysDrtwPplLen` | +| 분류식 우수 | `sprsysExlnPplLen` | +| 합류식 | `cbsysPplLen` | +| 차집관로 | `icptgPplLen` | +| 배수설비 | `drgCnt` | +| 오수 중계 펌프 | `drtwRlyPumpCnt` | +| 오수 일반 펌프 | `drtwPumpCnt` | +| 우수 펌프 | `exlnPumpCnt` | + +**구현 구성** + +``` +ui/sections/tmplnt-form-section.tsx + ├─ TmplntFacilityPicker # BscFctList Checkbox 목록 (시설 마스터 API TBD) + └─ Tabs (@common/components Tabs) # 선택 시설별 동적 패널 + ├─ TabsList / TabsTrigger # value=swtrmtFcltCd, 라벨=시설명 + └─ TabsContent (시설 1개당) + ├─ TmplntMatrixTable # 당초|현재|변경 매트릭스 (useFieldArray) + └─ TmplntFacilityAttachments # 시설별 필수첨부 (선택) +``` + +**Tabs 관리 규칙** (`packages/common/components/src/ui/tabs`) + +- `BscFctList` 체크 **선택** → `useFieldArray` append + 해당 시설 `TabsTrigger`/`TabsContent` 추가 → `Tabs` `value`를 신규 시설로 전환 +- 체크 **해제** → confirm 후 remove + 해당 탭 제거 → 남은 탭 중 하나로 `value` 이동 (없으면 Tabs 영역 비표시) +- 선택 시설이 0개면 Tabs UI 숨김; 1개 이상이면 Tabs로 시설별 패널 전환 +- 매트릭스·첨부는 각 `TabsContent` 안에만 렌더 (JSP의 세로 동적 삽입을 탭 패널로 대체) + +**매트릭스 열** + +- **당초 열**: 이전계획 API 조회 결과 읽기 전용 (없으면 빈 칸) +- **현재 열**: 기준년도 시점 값 (API 제공 시) +- **변경 열**: 편집 가능 → 저장 payload의 dtl만 전송 + +시설 마스터 목록 API: 레거시 `BscFctList` 대체 — **TBD** (`swtrmtFclt` 계열). + +`bundle-dynamic-imports`: `TmplntFormSection`(또는 TabsContent 내부 매트릭스)을 `next/dynamic` 후보. + +--- + +### 4-I. 체크리스트 + 첨부 (`slide_16`) + +#### I-1. 체크리스트 + +**재사용**: `ChecklistFormSection` +`docSeComCd=""` +`fieldPrefix`: 마스터에서 `taskSeComCd`를 읽는 경로 (예: `sewerBscPlan`) + +| JSP | React | +| ----------------------- | ------------------------------------------- | +| 업무탭 `ceckEssntlArr` | 코드 `taskSeComCd` 다중 → Tabs | +| 진행률 / 전체 예·아니오 | 위젯 내장 로직 | +| `exmntResult` Y/N | `cklstResultList[].selected` (+ `picRvwCn`) | + +저장: `cklstResultList: CklstResultItem[]` + +#### I-2. 필수 첨부 (탭별) + +**재사용**: `AttachmentsFormSection` +`docSeComCd` 동일, `esntlAtchFileList` + +레거시: 체크리스트 탭마다 `essntlList` + `uploadFile` multipart. +신규: TUS/`attach-service` + `esntlAtchFileId` / `atchFileGroupId` (fnrs와 동일). + +#### I-3. 기타 첨부 + +`AttachmentsFormSection`의 etc 영역 또는 `atchFileGroupId` on `sewerBscPlanDtl`. +JSP `uploadFileETC` multiple → Zip 안내 문구 유지. + +--- + +### 4-J. 저장 / 제출 + +**UI 구성**: [`fnrs-form-page.tsx` 279~323](../../src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx) 과 **동일**. +마크업·분기(`isSplmntPrgrsStts` / `isEditMode`)·버튼 색상·`disabled={isSubmitting}` 패턴을 그대로 두고, **호출 API·목록 path만 sewer** 로 교체한다. + +```tsx +
+ + {!isSplmntPrgrsStts ? ( + <> + + {isEditMode && ( + <> + + + + )} + + ) : ( + + )} +
+``` + +| 버튼 | fnrs 대응 | sewer API / 동작 | +| -------- | ----------------------------- | ------------------------------------------------------------ | +| 목록 | `router.push("/fnrs-cnsltn")` | `router.push("/sewer-bsc-plan")` | +| 저장 | `saveApply` + 임시저장 | `useSaveApplySewerBscPlan` + `prgrsSttsComCd`=임시저장(`SV`) | +| 신청완료 | `handleApplyClick` → 제출 | 체크리스트+필수첨부 검증 → `saveApply` + `LE` (상태코드 TBD) | +| 삭제 | `handleDelClick` | `useDeleteSewerBscPlan`(또는 동등) → 목록 | +| 보완완료 | `onSplmntCmptn` | `useSubmitSupplementSewerBscPlan` | + +| JSP | 진행값 | URL | React | +| ---- | ---------- | ------------------------- | ---------------------------------------------------------- | +| 저장 | `SAVE` | `insertFnrscDmbp.do` | `saveApply` + 임시저장 코드 | +| 제출 | `PRESENTN` | `insertFnrscDscss.do` (!) | `saveApply` + 제출 상태코드 — **백엔드 상태코드 확인 TBD** | + +페이로드 조립 (`buildApplyPayload`): + +```ts +SewerBscPlanApplySaveRequestDto { + sewerBscPlan, sewerBscPlanDtl, + sewerBscPlanOtlnList, bizCstList, fnrsCstList, + sewerBscPlanTmplntList, tmplntTmprStrgList?, + cklstResultList, esntlAtchFileList, + splmntDmndList?, rvwOpnnList? // 등록 화면에서는 보통 빈 배열 +} +``` + +성공 후: `invalidateQueries(sewerBscPlanBaseKey)` → `router.push('/sewer-bsc-plan')` (신청완료는 상세로 보낼 수 있음 — fnrs와 동일 정책). + +권한: JSP `AUTH3` → 기존 AS 권한 훅/역할로 버튼 `disabled`/`hidden`. + +--- + +## 5. API / DTO 연결 + +Base: `/as-service/api/v1/sewer-bsc-plans` +엔티티: `@/entities/sewer-bsc-plan` + +| 화면 동작 | Hook / Service | 비고 | +| -------------- | ---------------------------------------- | ---------------- | +| 등록 저장·제출 | `useSaveApplySewerBscPlan` → `saveApply` | 본 문서 핵심 | +| 수정 로드 | `useSewerBscPlanDetails(docNo, chgCycl)` | 편집 시 | +| 마스터만 | `useSewerBscPlan` | 목록 행 클릭 등 | +| 보완 제출 | `useSubmitSupplementSewerBscPlan` | edit + `prgrsSttsComCd=SL` 시 폼 하단 [보완완료] | +| 기술검토 | `useTechReviewSewerBscPlan` | 등록 JSP 범위 밖 | + +등록 JSP에 없는 섹션(보완요구·검토의견)은 **상세/검토 화면**에서 `SplmntDmndFormSection` / 검토의견 섹션으로 후속 구현. + +--- + +## 6. 권장 파일 배치 + +``` +apps/as/ +├── app/(with-base-layout)/sewer-bsc-plan/ +│ └── regist/page.tsx +├── src/pages/sewer-bsc-plan/ +│ ├── lib/ +│ │ ├── sewer-bsc-plan-regist-schema.ts # zod +│ │ └── form-data-mapper.ts # Det ↔ Form, Apply payload +│ ├── model/ +│ │ └── (필요 시 table columns) +│ └── ui/ +│ ├── sewer-bsc-plan-regist-page.tsx +│ ├── sewer-bsc-plan-form-page.tsx +│ ├── components/ +│ │ └── previous-plan-lookup-dialog.tsx +│ └── sections/ +│ ├── basic-info-form-section.tsx +│ ├── outline-form-section.tsx # 사업개요 +│ ├── biz-cst-matrix-form-section.tsx # 총사업비 (전용) +│ ├── fnrs-cst-form-section.tsx # 재원분담 +│ └── tmplnt-form-section.tsx # 처리시설 (Checkbox + Tabs) +└── docs/sewer-bsc-plan/ + └── dmbp-regist-migration.md # 본 문서 +``` + +공통 재사용 (신규 작성 최소화): + +- `ContactInfoFormSection`, `PrivacyConsentSection` +- `ChecklistFormSection`, `AttachmentsFormSection` +- `SectionCollapsible` +- `Tabs` / `TabsList` / `TabsTrigger` / `TabsContent` (`@common/components` — 처리시설 패널) + +도메인 전용 (재사용 금지): + +- `BizCstMatrixFormSection` — 총사업비 (fnrs `BusinessCostFormSection` 미사용) +- 폼 하단 액션바 — `fnrs-form-page` 279~323 복제, API만 sewer + +--- + +## 7. 화면 데이터 흐름 + +``` +BEGIN + + // 최초 진입 (등록) + CALL 로그인사용자조회 + CALL 코드조회(SEWERBSCPLANSE, SEWER, …) // 병렬 + CALL 체크리스트트리(docSeComCd) // 병렬 + CALL 필수첨부트리(docSeComCd) // 병렬 + CALL 처리시설마스터목록 // TBD (BscFctList) + DISPLAY 폼 defaultValues + + ON 1단계목표연도 CHANGE DO + SET 2·3·4단계 연도 (+5/+10/+15) + DISPLAY E/F/G/H 헤더 연도 + END ON + + ON 처리시설 체크 DO + ADD tmplntList 항목 + ADD TabsTrigger / TabsContent (시설 패널) + SET Tabs value = 신규 시설 + CALL 이전기본계획수치(시설코드) // 당초열, TBD + END ON + + ON 처리시설 체크해제 DO + CONFIRM 제거 + REMOVE tmplntList + 해당 Tabs 패널 + SET Tabs value = 남은 시설 (없으면 Tabs 숨김) + END ON + + ON [이전기본계획조회] CLICK DO + OPEN PreviousPlanLookupDialog + END ON + + ON [저장] CLICK DO + VALIDATE zod (동의 제외 가능) + CALL saveApply(payload, 임시저장 상태) + NAVIGATE 목록 + END ON + + ON [신청완료] CLICK DO + VALIDATE zod + privacyAgree=Y + 체크리스트 완료 + 필수첨부(esntlAtchYn=Y) 완료 + CONFIRM 신청 확인 + CALL saveApply(payload, 제출 상태 LE) + NAVIGATE 상세 + END ON + +END +``` + +--- + +## 8. JSP ↔ 신규 갭·리스크 + +| # | 이슈 | 대응 | +| --- | ---------------------------------------------------- | ------------------------------------------------- | +| 1 | JSP 제출 URL이 `dscss`로 분기 (오타/구프로세스 가능) | 신규는 `saveApply` 단일 진입으로 통일 | +| 2 | 총사업비 UI 계층 ≠ `BizCstItem` | `BizCstMatrixFormSection` 전용 + `acntgSeCd` 매핑 테이블 | +| 3 | 재원분담 ≠ fnrs shar | 전용 `fnrs-cst-form-section` | +| 4 | 처리시설 매트릭스(당초/현재/변경) | Checkbox 선택 → Tabs 패널; 저장은 변경 dtl 중심; 당초는 조회 전용 | +| 5 | 시설·이전계획 API | TBD — 미정 시 체크박스 목록 mock(`faker`)만 | +| 6 | `docSeComCd` 값 | CMS/체크리스트 마스터와 합의 | +| 7 | 레거시 multipart vs TUS | fnrs 첨부 패턴 준수 | +| 8 | 주석 처리된 상세 `formFields` (자연발생인구 등) | **축소된 formFields만** 이관; 주석 블록 복원 금지 | + +--- + +## 9. 구현 순서 (권장) + +1. 라우트 + `FormPage` 골격 + zod 스키마(마스터·dtl·동의) + 폼 하단 액션바(§4-J) +2. B·C·D 섹션 (공통 위젯 최대 활용) +3. E 사업개요 + G 재원분담 (4열 매트릭스 패턴 공유) +4. F 총사업비 (`BizCstMatrixFormSection` 전용) +5. I 체크리스트·첨부 +6. H 처리시설 (`BscFctList` Checkbox + `@common/components` Tabs — 가장 복잡) +7. 저장/신청완료/삭제/보완완료 API 연동 (§4-J) +8. 이전계획 Dialog (API 확정 후) + +--- + +## 10. 체크리스트 (jsp-migration-as) + +- [ ] contents만 이관 (include 제외) +- [ ] vercel-react-best-practices 적용 (Query 병렬·파생상태·dynamic) +- [ ] `@common/components` + 필요 시 DataTable +- [ ] API·모델은 `@/entities/sewer-bsc-plan` 우선 +- [ ] 목데이터는 `@faker-js/faker`만 (시설 마스터 등 TBD 구간) +- [ ] 로딩·빈·에러 상태 +- [ ] `router.push`로 목록 복귀 + +--- + +## 11. 후속 문서 + +- 목록(`dmbpList.jsp`) / 상세(`dmbpView.jsp`) / 수정(`dmbpModify.jsp`)은 본 문서와 동일 섹션 체계로 별도 확장 +- 화면설계서 SPEC 형식이 필요하면 `docs/sewer-bsc-plan/SPEC.md`로 분리 (영역 A~J 표 + API 표) diff --git a/apps/as/docs/approval-line-management/SPEC.md b/apps/as/docs/spec/approval-line-management/SPEC.md similarity index 100% rename from apps/as/docs/approval-line-management/SPEC.md rename to apps/as/docs/spec/approval-line-management/SPEC.md diff --git a/apps/as/docs/checklist-management/SPEC.md b/apps/as/docs/spec/checklist-management/SPEC.md similarity index 100% rename from apps/as/docs/checklist-management/SPEC.md rename to apps/as/docs/spec/checklist-management/SPEC.md diff --git a/apps/as/docs/code-management/SPEC.md b/apps/as/docs/spec/code-management/SPEC.md similarity index 100% rename from apps/as/docs/code-management/SPEC.md rename to apps/as/docs/spec/code-management/SPEC.md diff --git a/apps/as/docs/spec/dashboard/SPEC.md b/apps/as/docs/spec/dashboard/SPEC.md new file mode 100644 index 000000000..3ce6f9800 --- /dev/null +++ b/apps/as/docs/spec/dashboard/SPEC.md @@ -0,0 +1,498 @@ +# 화면설계서: 전국 행정지원 지도 (대시보드 메인) + +## 1. 기본 정보 + +| 항목 | 내용 | +| ---------- | -------------------------------------------- | +| 버전 | 1.2 | +| 프로세스명 | UI설계 | +| 화면명 | 전국 행정지원 지도 (대시보드 메인) | +| 화면 ID | TBD | +| 화면 유형 | [v] 조회 [ ] 입력 [ ] 수정 [ ] 삭제 [ ] 출력 | +| 메뉴 경로 | 메인 | + +--- + +## 2. 화면 목적 + +로그인 사용자의 **기관 권한·당해 연도** 기준으로 문서별 진행상태 통계와 행정지원 알림을 한 화면에서 조회한다. 좌측 지도에서 전국 행정지원 현황을 보고, 우측에서 진행상황 카드·알림이력·공지사항을 확인한 뒤 알림 [처리하기]로 연계 업무 화면으로 이동한다. + +--- + +## 3. 화면 구성 + +### 3-1. 전체 레이아웃 + +브레드크럼·페이지 타이틀은 스크린샷에 없음. 홈(`/`) 메인 콘텐츠로 구성한다. + +``` +┌────────────────────────────────┬─────────────────────────────────────────────────┐ +│ [영역 A] 전국 행정지원 지도 │ [영역 B] 문서별 진행상황 카드 (2×2) │ +│ 타이틀: 전국 행정지원 지도 │ ┌────────────────┐ ┌────────────────┐ │ +│ │ │ 재원협의 │ │ 설치인가 │ │ +│ (대한민국 시도·시군구 경계) │ │ 신청/진행/완료 │ │ 신청/진행/완료 │ │ +│ 마커: 서울 강남구, │ ├────────────────┤ ├────────────────┤ │ +│ 대전 유성구, │ │ 설계자문 │ │ 기본계획 │ │ +│ 부산 해운대구 │ │ 신청/진행/완료 │ │ 신청/진행/완료 │ │ +│ │ └────────────────┘ └────────────────┘ │ +│ ├─────────────────────────────────────────────────┤ +│ │ [영역 C] 알림이력 │ +│ │ 알림이력 업데이트 HH:mm [전체보기] │ +│ │ [오늘 N건] [지연 N건] [완료대기 N건] │ +│ │ [뱃지] [일자] 제목 [처리하기] │ +│ │ … (최대 5건) │ +│ ├─────────────────────────────────────────────────┤ +│ │ [영역 D] 공지사항 [더보기] │ +│ │ ┌────┬────────┬────────┬────────┬──────┐ │ +│ │ │번호│ 제목 │ 작성자 │ 작성일 │조회수│ │ +│ │ └────┴────────┴────────┴────────┴──────┘ │ +└────────────────────────────────┴─────────────────────────────────────────────────┘ +``` + +### 3-2. 화면 데이터 흐름 + +``` +BEGIN + + // 최초 진입 + // searchYear 미전달 → 서버가 당해 연도로 고정. 알림 목록은 최신 5건 + CALL getMainDashboard(DashboardMainSearchDto) // Authorization은 client 인터셉터 + DISPLAY 진행상황 카드 (영역 B) // docPrgrsSttsList, TOTAL 행 제외 + DISPLAY 알림 통계 칩 · 알림 목록 (영역 C) // notiStats, notiList + + CALL getUserPostList(UserPostSearchParams) // board-service, AS_DASHBOARD_BBS_ID + DISPLAY 공지사항 그리드 (영역 D) + + CALL 지도 마커/음영 조회(…) // TBD — 대시보드 API에 없음 + DISPLAY 전국 지도 (영역 A) + + ON 영역 C [전체보기] 버튼 CLICK DO + NAVIGATE /nti-hstry + END ON + + ON 영역 C [처리하기] 버튼 CLICK DO + CALL readNoti(notiId) // entities/noti + NAVIGATE notiUrl // 비어 있으면 이동하지 않음 + CALL getMainDashboard(…) // → 영역 C 갱신 (invalidate) + END ON + + ON 영역 D [더보기] 버튼 CLICK DO + NAVIGATE /cms/bbsnotice/{AS_DASHBOARD_BBS_ID} // as 앱 공지 목록 라우트 미구현. st/cm 관례 + END ON + + ON 영역 D 행 CLICK DO + NAVIGATE /cms/bbsnotice/{AS_DASHBOARD_BBS_ID}/{pstNo} + END ON + + ON 영역 B 카드 CLICK DO + NAVIGATE 해당 문서 목록 // §5-3 + END ON + + ON 영역 A 마커 CLICK DO + // TBD — 팝업/필터/이동 여부 미확정 + END ON + +END +``` + +--- + +## 4. 영역 A — 전국 행정지원 지도 + +스크린샷 타이틀: **전국 행정지원 지도**. + +`getMainDashboard` 응답에 지도·좌표·행정구역 음영 데이터는 **없다**. 마커·음영 소스 API는 **TBD**. + +### 4-1. 표시 요소 + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| ----------- | --------- | ---- | ------ | ----------------- | ----------------------------------------------------------------- | +| 섹션 타이틀 | Text | — | — | — | 고정 문구 `전국 행정지원 지도` | +| 전국 지도 | Map | — | — | TBD | 시도·시군구 경계. 북·동부 음영(청) / 남서부 비음영(백) — 의미 TBD | +| 마커 라벨 | Marker | — | — | TBD | 스크린샷 샘플: `서울 강남구`, `대전 유성구`, `부산 해운대구` | + +### 4-2. 상호작용 + +| 동작 | 설명 | +| ---------- | ----------------------------------------- | +| 마커 클릭 | TBD (상세 팝업 / 해당 지자체 필터 / 이동) | +| 지도 줌·팬 | TBD (스크린샷에 컨트롤 없음) | + +`entities/adstdstr`는 시도·구군 **콤보**만 제공하며 좌표를 주지 않는다. 지도 라이브러리도 as 앱에 없음 — 선정 TBD. + +--- + +## 5. 영역 B — 문서별 진행상황 카드 + +`DashboardMainResponseDto.docPrgrsSttsList`를 **`docSeComCd`로 매핑**해 4장 카드를 그린다. 배열 순서에 의존하지 않는다. + +스크린샷 배치(좌→우, 상→하): + +| 화면 카드 타이틀 | `docSeComCd` | API `docSeNm` | +| ----------------- | ------------ | -------------- | +| 재원협의 진행상황 | `FNRS` | 재원협의 | +| 설치인가 진행상황 | `INSTL` | 설치인가 | +| 설계자문 진행상황 | `CNFM` | 설계자문 | +| 기본계획 진행상황 | `SEWER` | 하수도기본계획 | + +서버 SQL `ORDER BY`는 `INSTL → FNRS → SEWER → CNFM` 이며, `GROUP BY ROLLUP`으로 **`docSeComCd = TOTAL`** 합계 행이 추가된다. 카드에는 TOTAL 행을 **표시하지 않는다**. + +카드 타이틀은 스크린샷 문구(`… 진행상황`)를 쓴다. `SEWER`의 API명 `하수도기본계획`은 화면에서 `기본계획`으로 축약한다. + +### 5-1. 카드 내부 필드 + +각 카드 공통 구조. `totCnt`는 화면에 없음. + +| 표시명 | Field | 비고 | +| ------ | ---------- | ------------------ | +| 신청 | `reqCnt` | 파란 아이콘 + 건수 | +| 진행 | `prgrsCnt` | 초록 아이콘 + 건수 | +| 완료 | `cmptnCnt` | 주황 아이콘 + 건수 | + +상태 그룹(서버): 임시저장(`SV`/`CV`) 제외. 문서별 `prgrs_stts_com_cd` → `REQ`/`PRG`/`CMP`. + +### 5-2. 검색 파라미터 (화면 미표시) + +영역 B에 연도·필터 UI는 없다. 서버 기본값을 따른다. + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| ------ | --------- | ---- | --------- | ----------------- | --------------------------------------------------------------------- | +| (없음) | — | — | 당해 연도 | `searchYear` | 미전달 시 서버 `LocalDate.now().getYear()`. 연도 셀렉터 추가 여부 TBD | + +`docSeComCd` 쿼리는 **알림 목록 필터**에만 쓰이며, 진행상황 SQL에는 전달되지 않는다. 카드 4종은 항상 전원 집계한다. + +### 5-3. 상호작용 + +카드 클릭 시 **해당 문서 목록 페이지**로 이동한다. `docSeComCd` → 라우트 고정 매핑. + +| 동작 | `docSeComCd` | 이동 경로 | +| --------- | ------------ | ----------------- | +| 카드 클릭 | `FNRS` | `/fnrs-cnsltn` | +| 카드 클릭 | `INSTL` | `/instl-sncn` | +| 카드 클릭 | `CNFM` | `/design-cnstn` | +| 카드 클릭 | `SEWER` | `/sewer-bsc-plan` | + +--- + +## 6. 영역 C — 알림이력 + +서버가 cms-service 알림을 대시보드 응답에 합친다. 목록은 **page=0, size=5** (최신 5건). 페이지네이션 없음. + +### 6-1. 헤더 + +| 표시명 | Field / 소스 | 비고 | +| ------------- | ------------ | --------------------------------------------------------------------------------------- | +| 섹션 타이틀 | — | 고정 문구 `알림이력` | +| 업데이트 시각 | (client) | 스크린샷 `업데이트 10:32`. API 필드 없음. `getMainDashboard` 마지막 fetch 시각(`HH:mm`) | +| 전체보기 | Button | `/nti-hstry`로 이동 | + +### 6-2. 통계 칩 + +`NotiAsStatResponseDto`. + +| 표시명 | Field | 비고 | +| ------------ | -------------- | -------------------------------------- | +| 오늘 N건 | `todayCount` | 당일 00:00 이후 수신 | +| 지연 N건 | `delayCount` | `notReadYn=N` 이고 수신 후 3일 이상 | +| 완료대기 N건 | `pendingCount` | `notReadYn=Y` 이고 `notiPrcsCmptnYn=N` | + +### 6-3. 목록 행 + +| 표시명 | Field | 비고 | +| ------ | --------- | --------------------------------------------- | +| 일자 | `regDt` | 표시 `YYYY-MM-DD`. 응답 `yyyy-MM-dd HH:mm:ss` | +| 제목 | `notiTtl` | 알림 제목 | +| 처리 | (action) | 스크린샷 라벨 `처리하기`. 클릭 §6-4 | + +응답에 있으나 이 목록에 **표시하지 않음**: + +| Field | 비고 | +| ----------------- | ----------------------------------------- | +| `notiId` | 행 키 · `readNoti` 파라미터 | +| `recvUnqId` | 수신 회원 UUID (서버 로그인 사용자 고정) | +| `senderUnqId` | 발신 회원 UUID | +| `notiType` | `ADSS` | +| `notiCn` | 본문 | +| `notiUrl` | 처리 후 이동 URL. 컬럼 미표시 | +| `notiLnkgUnqNo` | 알림연결고유번호 | +| `docSeComCd` | 문서구분 코드 | +| `docSeComNm` | 문서구분명 (알림이력 화면에서는 연계업무) | +| `notReadYn` | 뱃지 판정 TBD에 사용 가능 | +| `notiIdntyYn` | 확인 여부 | +| `notiIdntyDt` | 확인 일시 | +| `notiPrcsCmptnYn` | 처리 완료 여부 | +| `notiPrcsCmptnDt` | 처리 완료 일시 | + +### 6-4. 행 상호작용 + +| 동작 | 설명 | +| --------------- | ----------------------------------------------------------------------------------------------------------------- | +| [전체보기] 클릭 | `/nti-hstry` (알림이력 목록)로 이동 | +| [처리하기] 클릭 | `readNoti(notiId)` 후 `notiUrl`로 이동. URL이 비면 이동하지 않음(읽음 처리는 수행). `dashboardBaseKey` invalidate | + +`DashboardController`에 읽음 API는 없다. **`entities/noti`의 `readNoti`** 를 쓴다. 알림이력 화면과 동일 (`PUT /cms-service/api/v1/noti/{notiId}/read`). + +### 6-5. 검색 파라미터 (화면 미표시) + +서버 `toNotiSearchDto()` 기본: `notiType = ADSS`. 화면 필터 없음. + +| DashboardMainSearchDto | 화면 | 비고 | +| ---------------------- | ---- | --------------------------------- | +| `notiType` | 없음 | 서버 기본 `ADSS` | +| `notReadYn` | 없음 | 통계 칩 클릭 필터 시 후보 (TBD) | +| `startDt` / `endDt` | 없음 | 대시보드 기본 미전달 | +| `docSeComCd` | 없음 | 알림만 필터. 진행상황 카드와 무관 | + +--- + +## 7. 영역 D — 공지사항 + +`getMainDashboard`에 공지 필드는 없다. **board-service** `getUserPostList`로 별도 조회한다. + +- Base: `GET /board-service/api/v1/posts/user/{AS_DASHBOARD_BBS_ID}` +- `AS_DASHBOARD_BBS_ID` = `NEXT_PUBLIC_AS_DASHBOARD_BBS_ID` (로컬 예: `7`) +- 클라이언트: `@/entities/board` — `postApi.getUserPostList` / `useUserPostList` + +페이지네이션 컨트롤은 없다. 요약 목록만 표시한다 (`page=0`, `size=3`). + +### 7-1. 헤더·버튼 + +| 버튼명 | 동작 | +| ------ | ------------------------------------------------------------------------------------------- | +| 더보기 | `/cms/bbsnotice/{AS_DASHBOARD_BBS_ID}` 로 이동. as 앱에 공지 목록 라우트는 없음(st/cm 관례) | + +### 7-2. 그리드 컬럼 + +스크린샷 헤더 순서. `UserPost`. + +| 표시명 | Field | 비고 | +| ------ | --------- | ---------------------------------------------------- | +| 번호 | `rowNo` | `upndFixYn` = `Y` 이면 `[공지]` Badge, 그 외 `rowNo` | +| 제목 | `pstTtl` | truncate | +| 작성자 | `wrtrNm` | | +| 작성일 | `regDt` | 표시 `YYYY-MM-DD` (응답 datetime) | +| 조회수 | `inqNocs` | 숫자 | + +조회 파라미터 (`UserPostSearchParams`): + +| 필드 | 값 | 비고 | +| ------------- | ----------- | ------------------------------------------ | +| `page` | `0` | Spring 0-based | +| `size` | `3` | 대시보드 요약. 스크린샷 3행 | +| `keywordType` | `bbsTypeCd` | st 대시보드 공지 패널과 동일 | +| `keyword` | `BBSNOTICE` | 게시판 유형. path의 bbsId와 함께 공지 한정 | + +응답에 있으나 그리드에 **표시하지 않음**: `bbsId`, `pstNo`(행 키·상세 이동), `pstCn`, `upndFixYn`(번호 열 판정만), `rgtrNm`, `atflCd` 등. + +### 7-3. 행 상호작용 + +| 동작 | 설명 | +| ------- | ------------------------------------------------------------------------------ | +| 행 클릭 | `/cms/bbsnotice/{AS_DASHBOARD_BBS_ID}/{pstNo}` (as 앱 공지 상세 라우트 미구현) | + +--- + +## 8. API/DTO 명세 + +### 8-1. 공통 + +| 항목 | 값 | +| --------------- | ---------------------------------------------------------------------------------------------- | +| Base prefix | `/as-service/api/v1/dashboard` (`ENDPOINT.AS_SERVICE.DASHBOARD`) | +| 인증 | `Authorization` 헤더. 프론트는 `client` 인터셉터. 로그인 기관 권한은 서버가 사용자 캐시로 적용 | +| 클라이언트 모듈 | `@/entities/dashboard` — `dashboardService` / `useGetMainDashboard` | +| 알림 읽음 | `@/entities/noti` — `notiService.readNoti` / `useReadNoti` | +| 공지사항 | `@/entities/board` — `postApi.getUserPostList` / `useUserPostList` | + +영역 A(지도)는 본 컨트롤러·board-service 범위 밖 → **TBD**. + +### 8-2. 대시보드 메인 조회 — `getMainDashboard` + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| -------------------------------------------- | ------ | ------------- | ------------------------------- | -------------------------- | ----------------------------------------------------------- | +| 대시보드 메인 통합진행상태 및 알림 정보 조회 | GET | `{BASE}/main` | Query: `DashboardMainSearchDto` | `DashboardMainResponseDto` | `dashboardService.getMainDashboard` / `useGetMainDashboard` | + +**Request (`DashboardMainSearchDto`)** + +| 필드 | 필수 | 설명 | +| ------------ | ---- | ---------------------------------------------------------------------- | +| `searchYear` | N | 검색 연도. 미전달 시 서버 당해 연도. 영역 B 집계 기간 | +| `notiType` | N | 알림 종류. 미전달 시 서버 `ADSS` | +| `notReadYn` | N | 알림 확인 여부 `Y`/`N`. 본 화면 미사용 | +| `startDt` | N | 알림 시작일 `YYYY-MM-DD`. 본 화면 미사용 | +| `endDt` | N | 알림 종료일 `YYYY-MM-DD`. 본 화면 미사용 | +| `docSeComCd` | N | 알림 문서구분 (`INSTL`/`FNRS`/`SEWER`/`CNFM` 등). 진행상황 집계와 무관 | + +`Authorization`은 service 시그니처에 넣지 않는다. + +**Response (`DashboardMainResponseDto`)** + +| 필드 | 타입 | 설명 | +| ------------------ | --------------------------- | --------------------------- | +| `docPrgrsSttsList` | `DocPrgrsSttsResponseDto[]` | 문서별 진행상태 + TOTAL 행 | +| `notiList` | `NotiAsUserResponseDto[]` | 최신 최대 5건 | +| `notiStats` | `NotiAsStatResponseDto` | 오늘 / 지연 / 완료대기 건수 | + +**`DocPrgrsSttsResponseDto`** + +| 필드 | 타입 | 설명 | +| ------------ | ------ | --------------------------------------------- | +| `docSeNm` | string | 문서 구분 명칭 | +| `docSeComCd` | string | `INSTL` / `FNRS` / `SEWER` / `CNFM` / `TOTAL` | +| `reqCnt` | number | 신청 건수 | +| `prgrsCnt` | number | 진행 건수 | +| `cmptnCnt` | number | 완료 건수 | +| `totCnt` | number | 총 건수 (화면 미표시) | + +**`NotiAsStatResponseDto`** + +| 필드 | 타입 | 설명 | +| -------------- | ------ | --------- | +| `todayCount` | number | 오늘 알림 | +| `delayCount` | number | 지연 알림 | +| `pendingCount` | number | 완료대기 | + +**`NotiAsUserResponseDto`** — 필드 상세는 `docs/spec/nti-hstry` §6-2와 동일 (`notiId`, `notiTtl`, `regDt`, `notiUrl`, `notReadYn`, `notiPrcsCmptnYn` 등). + +### 8-3. 알림 읽음 처리 — `readNoti` + +대시보드 컨트롤러가 아니라 cms-service. 영역 C [처리하기]에서 호출. + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| -------------- | ------ | ---------------------------------------- | ----------------------- | ---------------- | -------------------------------------- | +| 알림 읽음 처리 | PUT | `/cms-service/api/v1/noti/{notiId}/read` | Path: `notiId` (`Long`) | `Long` (알림 ID) | `notiService.readNoti` / `useReadNoti` | + +Body 없음. 성공 시 `dashboardBaseKey`(및 필요 시 `notiBaseKey`) invalidate. + +### 8-4. 전국 행정지원 지도 — TBD + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| ------------------- | ------ | --- | ----------- | ------------ | --- | +| 지도 마커·음영 조회 | TBD | TBD | TBD | TBD | TBD | + +### 8-5. 공지사항 — `getUserPostList` + +board-service. bbsId는 환경변수 `AS_DASHBOARD_BBS_ID`로 path에 고정한다. + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| ----------------------- | ------ | ------------------------------------------ | ----------------------------- | ------------------------ | --------------------------------------------- | +| 대시보드 공지 요약 목록 | GET | `/board-service/api/v1/posts/user/{bbsId}` | Query: `UserPostSearchParams` | `PageResponse` | `postApi.getUserPostList` / `useUserPostList` | + +**Request (`UserPostSearchParams`)** — 본 화면 고정값 + +| 필드 | 필수 | 설명 | +| ------------- | ---- | -------------- | +| `page` | N | `0` | +| `size` | N | `3` | +| `keywordType` | N | `bbsTypeCd` | +| `keyword` | N | `BBSNOTICE` | +| `popupYn` 등 | N | 본 화면 미사용 | + +**Response (`UserPost`) — 화면 매핑 필드** + +| 필드 | 타입 | 설명 | +| ----------- | ------ | --------------------------- | +| `rowNo` | number | 번호. 고정글이 아니면 표시 | +| `pstNo` | number | 게시 번호. 상세 이동 키 | +| `bbsId` | number | 게시판 ID | +| `pstTtl` | string | 제목 | +| `wrtrNm` | string | 작성자 | +| `regDt` | string | 작성일 | +| `inqNocs` | number | 조회수 | +| `upndFixYn` | string | 상단고정 `Y`/`N` → `[공지]` | + +--- + +## 9. UI 구현 가이드 + +### 9-1. 기술 스택·원칙 + +- 라우트: 기존 홈 `app/(main)/(with-base-layout)/page.tsx` → `pages/home` (현재 `MainPage` 플레이스홀더) +- UI: `@common/components` — Card, Badge, Button, Table(공지). 지도 라이브러리 TBD +- 데이터: React Query `useGetMainDashboard()`. 처리하기는 `useReadNoti()`. 공지는 `useUserPostList()` +- 진행상황 카드: `docSeComCd`로 4슬롯 매핑. `TOTAL` 제외 +- 알림 목록: 테이블이 아닌 행 리스트(뱃지 + 일자 + 제목 + 버튼) +- 공지: `@tanstack/react-table` + `useDataTable`, 페이지네이션 없음 +- 검색 조건 URL 동기화: 본 화면 필터 없음. `searchYear` UI가 생기면 그때 검토 + +### 9-2. 모듈·파일 배치 + +| 구분 | 경로 | +| ------------- | ------------------------------------------------------------ | +| 라우트 | `app/(main)/(with-base-layout)/page.tsx` | +| 페이지 | `pages/dashboard/ui/dashboard-page.tsx` | +| 지도 | `pages/dashboard/ui/national-as-map.tsx` (API TBD, Skeleton) | +| 진행상황 카드 | `pages/dashboard/ui/doc-prgrs-stts-cards.tsx` | +| 알림이력 | `pages/dashboard/ui/dashboard-noti-list.tsx` | +| 공지사항 | `pages/dashboard/ui/dashboard-notice-table.tsx` | +| 공지 컬럼 | `pages/dashboard/model/dashboard-notice-table-columns.tsx` | +| Entity API | `entities/dashboard/api/dashboard-service.ts` | +| Entity Hook | `entities/dashboard/api/queries.ts` → `useGetMainDashboard` | +| Entity Model | `entities/dashboard/model/dashboard-interface.ts` | +| 알림 읽음 | `entities/noti` → `useReadNoti` | +| 공지 Entity | `entities/board` → `useUserPostList` | + +Entity(`getMainDashboard`, `getUserPostList`) 구현됨. 영역 B·C·D 화면 구현. 영역 A는 Skeleton. + +### 9-3. 영역별 UI 매핑 + +| 영역 | 컴포넌트 | 데이터 | +| ---- | ------------------------ | ---------------------------------------------------------------------------- | +| A | `national-as-map` | Skeleton (지도 API TBD) | +| B | `doc-prgrs-stts-cards` | `useGetMainDashboard` → `docPrgrsSttsList` (`TOTAL` 제외, 코드 매핑) | +| C | `dashboard-noti-list` | 동 훅 → `notiStats`, `notiList`. 처리 → `useReadNoti`. 전체보기 `/nti-hstry` | +| D | `dashboard-notice-table` | `useUserPostList` → `content[]`. [더보기]·행 클릭 → `/cms/bbsnotice/...` | + +### 9-4. 다이얼로그·라우팅 + +| 항목 | 내용 | +| -------------- | -------------------------------------------------------------------------------------- | +| 다이얼로그 | 없음 | +| 본 화면 라우트 | `/` (홈) | +| 알림 처리 이동 | [처리하기] → `readNoti` 후 `notiUrl` (알림이력과 동일, SEWER URL은 상세 세그먼트 제거) | +| 알림 전체보기 | `/nti-hstry` | +| 공지 더보기 | `/cms/bbsnotice/{AS_DASHBOARD_BBS_ID}` (as 앱 공지 라우트 미구현, st/cm 관례) | +| 공지 행 클릭 | `/cms/bbsnotice/{AS_DASHBOARD_BBS_ID}/{pstNo}` | +| 진행상황 카드 | §5-3 목록 라우트 (`/fnrs-cnsltn`, `/instl-sncn`, `/design-cnstn`, `/sewer-bsc-plan`) | + +### 9-5. 데이터 흐름 + +``` +BEGIN + + // 최초 진입 (홈) + CALL getMainDashboard({}) + DISPLAY 영역 B // docPrgrsSttsList — FNRS/INSTL/CNFM/SEWER 슬롯 + DISPLAY 영역 C // notiStats + notiList(≤5) + + CALL getUserPostList({ page: 0, size: 3, keywordType: "bbsTypeCd", keyword: "BBSNOTICE" }) + DISPLAY 영역 D + + CALL 지도 데이터 // TBD → 영역 A + + ON 영역 C [전체보기] CLICK DO + NAVIGATE /nti-hstry + END ON + + ON 영역 C [처리하기] CLICK DO + CALL readNoti(notiId) + IF notiUrl THEN + NAVIGATE notiUrl + END IF + CALL getMainDashboard // invalidate dashboardBaseKey + END ON + + ON 영역 B 카드 CLICK DO + NAVIGATE 문서 목록 // §5-3 + END ON + + ON 영역 D [더보기] CLICK DO + NAVIGATE /cms/bbsnotice/{AS_DASHBOARD_BBS_ID} + END ON + + ON 영역 D 행 CLICK DO + NAVIGATE /cms/bbsnotice/{AS_DASHBOARD_BBS_ID}/{pstNo} + END ON + +END +``` diff --git a/apps/as/docs/essential-attachment-file-management/SPEC.md b/apps/as/docs/spec/essential-attachment-file-management/SPEC.md similarity index 100% rename from apps/as/docs/essential-attachment-file-management/SPEC.md rename to apps/as/docs/spec/essential-attachment-file-management/SPEC.md diff --git a/apps/as/docs/spec/my-adss-doc/SPEC.md b/apps/as/docs/spec/my-adss-doc/SPEC.md new file mode 100644 index 000000000..37ae2937d --- /dev/null +++ b/apps/as/docs/spec/my-adss-doc/SPEC.md @@ -0,0 +1,285 @@ +# 화면설계서: 내 행정지원 + +## 1. 기본 정보 + +| 항목 | 내용 | +| ---------- | -------------------------------------------- | +| 버전 | 1.0 | +| 프로세스명 | UI설계 | +| 화면명 | 내 행정지원 | +| 화면 ID | AS_SC0204011 | +| 화면 유형 | [v] 조회 [ ] 입력 [ ] 수정 [ ] 삭제 [ ] 출력 | +| 메뉴 경로 | 메인 > 행정지원서비스 > 내 행정지원 | + +--- + +## 2. 화면 목적 + +로그인한 사용자가 **본인이 신청한 행정지원 문서**를 기간·구분·진행상태로 조회한다. 목록에서 행을 선택하면 해당 문서 상세 화면으로 이동한다. + +--- + +## 3. 화면 구성 + +### 3-1. 전체 레이아웃 + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 브레드크럼: 메인 > 행정지원서비스 > 내 행정지원 │ +│ 페이지 타이틀: 내 행정지원 │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ [영역 A] 검색 필터 │ +│ 기간 [시작일] ~ [종료일] 구분 ▼ 진행상태 ▼ [검색] │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ [영역 B] 내 신청 목록 │ +│ 타이틀: 내 신청 목록 N개 │ +│ ┌────┬──────┬────────┬──────────────┬────────────┬────────┬──────────────┐ │ +│ │ No │ 구분 │ 신청일 │ 진행상태 │ 완료(예정)일│ 담당자 │ 담당자연락처 │ │ +│ ├────┼──────┼────────┼──────────────┼────────────┼────────┼──────────────┤ │ +│ │ … │ … │ … │ prgrsSttsComNm│ … │ … │ … │ │ +│ └────┴──────┴────────┴──────────────┴────────────┴────────┴──────────────┘ │ +│ [페이지네이션] │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 3-2. 화면 데이터 흐름 + +``` +BEGIN + + // 최초 진입 + // 기간 기본값: 오늘 기준 최근 1개월 (startDate = today-1M, endDate = today) + CALL findMyElaperDocList(ElaprMyDocSearchDto + pageable) + DISPLAY 내 신청 목록 그리드 (영역 B) + + ON [검색] 버튼 CLICK DO + CALL findMyElaperDocList(영역 A 조건 + page=0) + DISPLAY 내 신청 목록 그리드 (영역 B) + END ON + + ON 페이지네이션 CHANGE DO + CALL findMyElaperDocList(영역 A 조건 + page/size) + DISPLAY 내 신청 목록 그리드 (영역 B) + END ON + + ON 영역 B 행 CLICK DO + // docSeComCd·prgrsSttsComCd·rgtr → view/edit + NAVIGATE 문서 상세 또는 수정 화면 + END ON + +END +``` + +--- + +## 4. 영역 A — 검색 필터 + +구성: **기간 · 구분 · 진행상태** (+ 검색). + +> UI설계 상세설명의 `docSecs` / `docStatusCd`는 레거시·설계 문서 명칭이다. +> **API SoT**는 `ElaprMyDocSearchDto`의 `docSeComCd` / `prgrsSttsComCd`를 사용한다. + +### 4-1. 필드 정의 + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| ---------- | ---------------- | ---- | --------------------------- | ----------------- | --------------------------------------------------------------------- | +| 기간(시작) | DatePicker | N | 오늘 − 1개월 (`YYYY-MM-DD`) | `startDate` | 신청일(`reqDt`) 기준 검색으로 가정 | +| 기간(종료) | DatePicker | N | 오늘 (`YYYY-MM-DD`) | `endDate` | | +| 구분 | Select(공통코드) | N | 전체(미선택) | `docSeComCd` | 상위코드 `ASDOCSE`. UI설계 `docSecs` → API `docSeComCd` | +| 진행상태 | Select(공통코드) | N | 전체(미선택) | `prgrsSttsComCd` | 상위코드 = `{docSeComCd}STTS` (예: `INSTL` → `INSTLSTTS`). UI설계 `docStatusCd` → API 필드명 | + +### 4-2. 버튼 + +| 버튼명 | 동작 | +| ------ | ----------------------------------------------------------------- | +| 검색 | 영역 A 조건으로 `findMyElaperDocList` 호출 → 영역 B 갱신 (page=0) | + +### 4-3. 콤보 의존 + +| 트리거 | 동작 | +| ----------- | ------------------------------------------------------------------- | +| 구분 변경 | 진행상태 옵션을 `upCd = docSeComCd + "STTS"`로 재조회. 진행상태 선택값 RESET | +| 구분 미선택 | 진행상태 비활성(옵션 비움). `upCd` 조합 불가이므로 조회하지 않음 | + +--- + +## 5. 영역 B — 내 신청 목록 + +### 5-1. 그리드 컬럼 + +| 표시명 | Field | 비고 | +| ------------ | ---------------- | ------------------------------------------------- | +| No | (row index) | 페이지 오프셋 반영 | +| 구분 | `docSeComNm` | 코드 `docSeComCd` (`ASDOCSE`) | +| 신청일 | `reqDt` | 표시 `YYYY-MM-DD` (응답은 `yyyy-MM-dd HH:mm:ss`) | +| 진행상태 | `prgrsSttsComNm` | 텍스트 렌더링 (단계형 프로그레스 미사용) | +| 완료(예정)일 | `cmptnExpctDt` | 표시 `YYYY-MM-DD` | +| 담당자 | `picNm` | | +| 담당자연락처 | `picTelno` | | + +응답에 있으나 그리드에 **표시하지 않음**: + +| Field | 비고 | +| ---------------- | ----------------------------------------------------------------- | +| `docNo` | 문서번호 — 행 클릭 라우팅 키 | +| `docTtl` | 문서명/사업명 — 미표시 | +| `chgCycl` | 변경차수 — 그리드 미표시. SEWER 이동 시 query 파라미터로 사용 | +| `prgrsSttsComCd` | 진행상태 코드 — 컬럼 표시는 `prgrsSttsComNm` | +| `rgtr` | 등록자 UUID — edit 판정용 (`rgtr === userUnqId`) | + +### 5-2. 목록 메타·페이지네이션 + +| 항목 | 매핑 | +| ------------- | ------------------------------------------- | +| 건수 `N개` | `PageResponse.totalElements` | +| page / size | `ElaprMyDocSearchDto`의 `PageableParams` | +| 페이지 컨트롤 | Spring `Page` 표준 (`number`, `totalPages`) | + +### 5-3. 행 상호작용 + +| 동작 | 설명 | +| ------- | -------------------------------------------------------------------------------------------- | +| 행 클릭 | `docSeComCd`·`prgrsSttsComCd`·`rgtr`로 view/edit 분기 후 이동 (`resolveMyPbadmsSprtDetailPath`) | + +**edit 조건** (기존 도메인 목록과 동일): + +| `docSeComCd` | edit 조건 | +| ------------------------ | ---------------------------------------------------------------------- | +| `INSTL` / `FNRS` / `SEWER` | `(prgrsSttsComCd === "SV" \|\| "SL") && rgtr === loggedInUserUnqId` | +| `CNFM` | `prgrsSttsComCd === "CV" && rgtr === loggedInUserUnqId` | + +| `docSeComCd` | edit 경로 | view 경로 | +| ------------ | -------------------------------------------- | --------------------------------------- | +| `FNRS` | `/fnrs-cnsltn/{docNo}/edit` | `/fnrs-cnsltn/{docNo}` | +| `INSTL` | `/instl-sncn/{docNo}/edit` | `/instl-sncn/{docNo}` | +| `CNFM` | `/design-cnstn/{docNo}/edit` | `/design-cnstn/{docNo}` | +| `SEWER` | `/sewer-bsc-plan/{docNo}/edit?chgCycl={n}` | `/sewer-bsc-plan/{docNo}?chgCycl={n}` | +| 그 외 | 이동하지 않음 | | + +- SEWER `chgCycl`: `row.chgCycl ?? 1` +- 조건 불충족 시 항상 view + +--- + +## 6. API/DTO 명세 + +### 6-1. 공통 + +| 항목 | 값 | +| --------------- | ------------------------------------------------------------------------------------------ | +| Base prefix | `/as-elapr-service/api/v1/elapr-prgrs-stts` (`ENDPOINT.AS_ELAPR_SERVICE.ELAPR_PRGRS_STTS`) | +| 인증 | 로그인 사용자 UUID — 서버 `SecurityUtils.getLoggedInUserUnqIdUUID()` (클라이언트 미전달) | +| 클라이언트 모듈 | `@/entities/elapr` — `elaprPrgrsSttsService` / `useElaprPrgrsSttsMyDocPage` | + +### 6-2. 내 행정지원 목록 — `findMyElaperDocList` + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| ----------------- | ------ | --------------- | --------------------------------------- | ------------------------------------ | -------------------------------------------------------------------------- | +| 내 신청 목록 조회 | GET | `{BASE}/my/doc` | Query: `ElaprMyDocSearchDto` + Pageable | `PageResponse` | `elaprPrgrsSttsService.findMyElaperDocList` / `useElaprPrgrsSttsMyDocPage` | + +**Request (`ElaprMyDocSearchDto` + Pageable)** + +| 필드 | 필수 | 설명 | +| ------------------------ | ---- | ------------------------------------------------------------ | +| `startDate` | N | 검색 시작일 `YYYY-MM-DD` | +| `endDate` | N | 검색 종료일 `YYYY-MM-DD` | +| `docSeComCd` | N | 문서구분 (`INSTL` / `FNRS` / `DESIGN` / `SEWER` / `PWRK` 등) | +| `prgrsSttsComCd` | N | 문서진행상태공통코드 | +| `page` / `size` / `sort` | N | Spring Pageable | + +**Response (`MyAdssDocResponseDto`)** + +| 필드 | 타입 | 설명 | +| ---------------- | ------- | ------------------------------------------- | +| `docNo` | string | 문서번호 | +| `docTtl` | string? | 문서명/사업명 | +| `docSeComCd` | string? | 문서구분 코드 | +| `docSeComNm` | string? | 문서구분 코드명 (`ASDOCSE`) | +| `reqDt` | string? | 신청일시 `yyyy-MM-dd HH:mm:ss` | +| `prgrsSttsComCd` | string? | 문서진행상태 코드 | +| `prgrsSttsComNm` | string? | 문서진행상태 코드명 | +| `cmptnExpctDt` | string? | 완료(예정)일 `yyyy-MM-dd` | +| `picNm` | string? | 담당자 성명 | +| `picTelno` | string? | 담당자 연락처 | +| `chgCycl` | number? | 변경차수 | +| `rgtr` | string? | 등록자 UUID | + +### 6-3. 공통코드 (콤보) + +| 용도 | 상위코드 | API | 비고 | +| -------- | ------------ | --------------------------- | ----------------------------- | +| 구분 | `ASDOCSE` | `useCodeList("ASDOCSE")` 등 | 기존 관리 화면과 동일 | +| 진행상태 | `{docSeComCd}STTS` | `useCodeList(docSeComCd + "STTS")` | 예: `INSTLSTTS`, `FNRSSTTS`, `SEWERSTTS`. 구분 미선택 시 미호출 | + +--- + +## 7. UI 구현 가이드 + +### 7-1. 기술 스택·원칙 + +- UI: `@common/components` — DatePicker, Select/`CodeSelect`, Button, DataTable, Pagination +- 목록: `@tanstack/react-table` + `useDataTable` (기존 `instl-list` / `fnrs-list` 패턴) +- 데이터: React Query `useElaprPrgrsSttsMyDocPage(params)` +- 검색 조건·페이지: URL `searchParams` 동기화 권장 (`useSearchParamsState` 스킬) +- 진행상태 컬럼: `prgrsSttsComNm` 텍스트 렌더링 (단계형 프로그레스 미사용) +- 행 클릭: `docSeComCd` + `prgrsSttsComCd` + `rgtr`로 view/edit 분기 (`SEWER`는 `chgCycl` query 포함) + +### 7-2. 앱·파일 배치 + +| 구분 | 경로 | +| ------------ | -------------------------------------------------------------- | +| 라우트 | `app/(main)/(with-base-layout)/my-pbadms-sprt/page.tsx` | +| 페이지 | `pages/my-pbadms-sprt/ui/my-pbadms-sprt-list-page.tsx` | +| 목록 그리드 | `pages/my-pbadms-sprt/ui/my-pbadms-sprt-list.tsx` | +| 경로 해석 | `pages/my-pbadms-sprt/lib/resolve-detail-path.ts` | +| 테이블 컬럼 | `pages/my-pbadms-sprt/model/my-pbadms-sprt-table-columns.tsx` | +| Entity API | `entities/elapr/api/elapr-prgrs-stts-service.ts` | +| Entity Hook | `entities/elapr/api/queries.ts` → `useElaprPrgrsSttsMyDocPage` | +| Entity Model | `entities/elapr/model/elapr-prgrs-stts-interface.ts` | + +### 7-3. 영역별 UI 매핑 + +| 영역 | 컴포넌트 | 데이터 | +| ---- | -------------------------- | ------------------------------------------ | +| A | `my-pbadms-sprt-list-page` | 로컬 state → `ElaprMyDocSearchDto` | +| B | DataTable + columns | `useElaprPrgrsSttsMyDocPage` → `content[]` | + +### 7-4. 다이얼로그·라우팅 + +| 항목 | 내용 | +| ----------- | -------------------------------------------------------------------- | +| 다이얼로그 | 없음 (목록 전용) | +| 목록 라우트 | `/my-pbadms-sprt` | +| 상세 이동 | §5-3 표와 동일 (view/edit + `rgtr` 소유자 검사, SEWER `chgCycl`) | + +### 7-5. 데이터 흐름 + +``` +BEGIN + + // 최초 진입 + SET 기간 = 최근 1개월 + CALL findMyElaperDocList + DISPLAY 영역 B + + ON [검색] CLICK DO + CALL findMyElaperDocList // → 영역 B 갱신 + END ON + + ON 페이지 CHANGE DO + CALL findMyElaperDocList + END ON + + ON 영역 B 행 CLICK DO + IF NOT docNo OR unsupported docSeComCd THEN + // no-op + ELSE IF canEdit(prgrsSttsComCd, rgtr === userUnqId) THEN + NAVIGATE edit 경로 // SEWER면 ?chgCycl= + ELSE + NAVIGATE view 경로 + END IF + END ON + +END +``` + diff --git a/apps/as/docs/spec/nti-hstry/SPEC.md b/apps/as/docs/spec/nti-hstry/SPEC.md new file mode 100644 index 000000000..eb6fb5463 --- /dev/null +++ b/apps/as/docs/spec/nti-hstry/SPEC.md @@ -0,0 +1,352 @@ +# 화면설계서: 알림이력 + +## 1. 기본 정보 + +| 항목 | 내용 | +| ----------- | -------------------------------------------- | +| 버전 | 1.4 | +| 프로세스명 | UI설계 | +| 화면명 | 알림이력 | +| 화면 ID | AS_SC0205011 | +| 요구사항 ID | NSUR-001-001 | +| 화면 유형 | [v] 조회 [ ] 입력 [ ] 수정 [ ] 삭제 [ ] 출력 | +| 메뉴 경로 | 메인 > 행정지원서비스 > 알림이력 | + +--- + +## 2. 화면 목적 + +로그인한 사용자가 **본인에게 발송된 행정지원(ADSS) 알림**을 기간·구분으로 조회한다. 목록의 처리 버튼으로 해당 알림을 읽음 처리한 뒤, 연계된 업무 화면으로 이동한다. + +--- + +## 3. 화면 구성 + +### 3-1. 전체 레이아웃 + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 브레드크럼: 메인 > 행정지원서비스 > 알림이력 │ +│ 페이지 타이틀: 알림이력 │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ [영역 A] 검색 필터 │ +│ 기간 [시작일] ~ [종료일] 구분 ▼ [검색] │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ [영역 B] 알림 이력 목록 │ +│ 타이틀: 알림 이력 목록 N개 ▼ │ +│ ┌────┬──────────────┬────────────────┬──────────┬────────┬────────┐ │ +│ │ No │ 알림일시 │ 알림내용 │ 연계업무 │ 상태 │ 처리 │ │ +│ ├────┼──────────────┼────────────────┼──────────┼────────┼────────┤ │ +│ │ … │ regDt │ notiTtl │ docSeComNm│ Badge │ 버튼 │ │ +│ └────┴──────────────┴────────────────┴──────────┴────────┴────────┘ │ +│ [페이지네이션] │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 3-2. 화면 데이터 흐름 + +``` +BEGIN + + // 최초 진입 + // 기간 기본값: 오늘 기준 최근 1개월 (시작일 = today-1M, 종료일 = today) + CALL findMyAsNotiList(NotiSearchDto + pageable) + DISPLAY 알림 이력 목록 그리드 (영역 B) + + ON [검색] 버튼 CLICK DO + CALL findMyAsNotiList(영역 A 조건 + page=0) + DISPLAY 알림 이력 목록 그리드 (영역 B) + END ON + + ON 페이지네이션 CHANGE DO + CALL findMyAsNotiList(영역 A 조건 + page/size) + DISPLAY 알림 이력 목록 그리드 (영역 B) + END ON + + ON 영역 B [처리] 버튼 CLICK DO + CALL readNoti(notiId) + // UI설계: 해당 알림에 연계된 URL로 이동 + NAVIGATE notiUrl + END ON + +END +``` + +--- + +## 4. 영역 A — 검색 필터 + +구성: **기간 · 구분** (+ 검색). + +서버 `findMyAsNotiList` 고정 정책 (클라이언트 미전달): + +- `recvUnqId` = 로그인 사용자 UUID +- `delYn` = `N` +- `notiType` = `ADSS` (행정지원 전용 API) + +### 4-1. 필드 정의 + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| ---------- | ---------- | ---- | --------------------------- | ----------------- | ------------------------------------------------------------------------------------------------ | +| 기간(시작) | DatePicker | N | 오늘 − 1개월 (`YYYY-MM-DD`) | `startDt` | `NotiSearchDto.startDt` | +| 기간(종료) | DatePicker | N | 오늘 (`YYYY-MM-DD`) | `endDt` | `NotiSearchDto.endDt` | +| 구분 | Select | N | 전체(미선택) | `docSeComCd` | `INSTL` / `FNRS` / `SEWER` / `CNFM` / `PWRK` (또는 단축 `I`/`F`/`S`/`C`/`P`). 공통코드 `ASDOCSE` | + +### 4-2. 버튼 + +| 버튼명 | 동작 | +| ------ | -------------------------------------------------------------- | +| 검색 | 영역 A 조건으로 `findMyAsNotiList` 호출 → 영역 B 갱신 (page=0) | + +### 4-3. 콤보·기본값 주의 + +| 항목 | 내용 | +| -------------- | ---------------------------------------------------------- | +| 구분 = 전체 | `docSeComCd` 미전달. `notiType`은 서버가 `ADSS`로 고정 | +| 구분 옵션 소스 | 공통코드 `ASDOCSE` (`docSeComCd` ↔ `docSeComNm`) | +| 기간 | UI설계 상세설명: 기본 최근 1개월. `startDt`/`endDt`로 전달 | + +--- + +## 5. 영역 B — 알림 이력 목록 + +### 5-1. 그리드 컬럼 + +| 표시명 | Field | 비고 | +| -------- | ------------ | --------------------------------------------------------------- | +| No | (row index) | 페이지 오프셋 반영 | +| 알림일시 | `regDt` | 표시 `YYYY-MM-DD HH:mm` (응답 `yyyy-MM-dd HH:mm:ss`) | +| 알림내용 | `notiTtl` | 제목 문자열. UI 샘플은 `[YYYY-MM-DD] …` 접두 | +| 연계업무 | `docSeComNm` | 문서구분 공통코드명 (`docSeComCd` + `@CodeName` `ASDOCSE`) | +| 상태 | (derived) | `Badge` 렌더링. 판정 규칙은 §5-1-1 | +| 처리 | (action) | 상태별 버튼 라벨. 클릭 시 `readNoti` 후 `notiUrl`로 이동 (§5-3) | + +#### 5-1-1. 상태 UI 케이스 (`@common/components` `Badge`) + +판정에 사용: `notiPrcsCmptnYn`, `notReadYn`, `regDt` (오늘 기준 경과일). + +| 조건 | 라벨 | Badge | +| ------------------------------------------------------------------------------ | -------- | ----------------------------- | +| `notiPrcsCmptnYn` = `Y` | 처리완료 | `variant="default"` (primary) | +| `notiPrcsCmptnYn` = `N` 이고 `notReadYn` = `Y` 이고 `regDt`로부터 **7일 초과** | 처리지연 | `color="red"` | +| `notiPrcsCmptnYn` = `N` 이고 `notReadYn` = `Y` 이고 `regDt`로부터 **7일 이내** | 처리중 | `color="green"` | +| `notiPrcsCmptnYn` = `N` 이고 `notReadYn` = `N` 이고 `regDt`로부터 **3일 초과** | 지연 | `color="orange"` | +| `notiPrcsCmptnYn` = `N` 이고 `notReadYn` = `N` 이고 `regDt`로부터 **3일 이내** | 미조회 | `color="yellow"` | + +``` +IF notiPrcsCmptnYn = Y THEN + DISPLAY Badge "처리완료" (primary / variant=default) +ELSE IF notReadYn = Y THEN + IF daysSince(regDt) > 7 THEN + DISPLAY Badge "처리지연" (red) + ELSE + DISPLAY Badge "처리중" (green) + END IF +ELSE // notReadYn = N + IF daysSince(regDt) > 3 THEN + DISPLAY Badge "지연" (orange) + ELSE + DISPLAY Badge "미조회" (yellow) + END IF +END IF +``` + +- `daysSince(regDt)`: 오늘 날짜 − `regDt` 일자 (일 단위) +- `regDt`가 없으면 상태 Badge 표시 전략 TBD (미표시 또는 기본값) + +응답에 있으나 그리드에 **표시하지 않음**: + +| Field | 비고 | +| ----------------- | ---------------------------------------------------------------------- | +| `notiId` | 알림 ID — 행 키 | +| `recvUnqId` | 수신 회원 UUID (서버가 로그인 사용자로 고정) | +| `senderUnqId` | 발신 회원 UUID | +| `notiType` | 알림 종류 (`ADSS`) | +| `notiCn` | 알림 내용 (목록에서는 서버가 제외할 수 있음) | +| `notReadYn` | 확인 여부 `Y`/`N`. 상태 Badge 판정에 사용 (§5-1-1) | +| `notiUrl` | 처리 버튼 이동 URL. 컬럼 미표시 | +| `notiLnkgUnqNo` | 알림연결고유번호 | +| `docSeComCd` | 문서구분 공통코드. 연계업무 표시는 `docSeComNm` 사용 | +| `notiIdntyDt` | 알림 확인 일시 | +| `notiIdntyYn` | 알림 확인 여부 (`notReadYn`과 동일 의미). 상태 판정은 `notReadYn` 사용 | +| `notiPrcsCmptnYn` | 알림 처리 완료 여부. 상태 Badge 판정에 사용 (§5-1-1) | +| `notiPrcsCmptnDt` | 알림 처리 완료 일시 | + +### 5-2. 목록 메타·페이지네이션 + +| 항목 | 매핑 | +| ------------- | ------------------------------------------- | +| 건수 / size ▼ | 상단 우측 `N개` 셀렉트 → `Pageable.size` | +| page / size | `NotiSearchDto`의 `PageableParams` | +| 페이지 컨트롤 | Spring `Page` 표준 (`number`, `totalPages`) | + +### 5-3. 행 상호작용 + +| 동작 | 설명 | +| -------------- | --------------------------------------------------------------------------- | +| 처리 버튼 클릭 | `readNoti(notiId)`로 읽음 처리 후, 해당 알림에 연계된 URL(`notiUrl`)로 이동 | +| 행 클릭 | UI설계에 없음. 처리 버튼만 명시 | + +**처리 버튼 라벨** (상태 Badge §5-1-1 기준): + +| 상태 | 버튼명 | +| -------- | -------- | +| 미조회 | 처리하기 | +| 지연 | 처리하기 | +| 처리중 | 처리하기 | +| 처리지연 | 처리하기 | +| 처리완료 | 상세보기 | + +- `notiUrl`이 비어 있으면 이동하지 않음 (no-op). 읽음 처리는 `notiUrl` 유무와 관계없이 호출 +- 읽음 처리 성공 후 목록 쿼리(`notiBaseKey`)를 invalidate 하여 영역 B 상태를 갱신 + +--- + +## 6. API/DTO 명세 + +### 6-1. 공통 + +| 항목 | 값 | +| --------------- | ---------------------------------------------------------------------------------------- | +| Base prefix | `/cms-service/api/v1/noti` (`ENDPOINT.CMS_SERVICE.NOTI`) | +| 인증 | 로그인 사용자 UUID — 서버 `SecurityUtils.getLoggedInUserUnqIdUUID()` (클라이언트 미전달) | +| 클라이언트 모듈 | `@/entities/noti` — `notiService` / `useFindMyAsNotiList` / `useReadNoti` | + +### 6-2. 로그인한 사용자 행정지원 알림 목록 — `findMyAsNotiList` + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| ---------------------------------- | ------ | ------------------- | --------------------------------- | ------------------------------------- | ------------------------------------------------------ | +| 로그인한 사용자 행정지원 알림 목록 | GET | `{BASE}/as/my/list` | Query: `NotiSearchDto` + Pageable | `PageResponse` | `notiService.findMyAsNotiList` / `useFindMyAsNotiList` | + +**Request (`NotiSearchDto` + Pageable)** + +| 필드 | 필수 | 설명 | +| ------------------------ | ---- | --------------------------------------------------------------------------- | +| `recvUnqId` | N | 수신 회원 UUID. 서버가 로그인 사용자로 덮어씀 | +| `senderUnqId` | N | 발신 회원 UUID. 본 화면 필터에 없음 | +| `notiType` | N | 알림 종류. 본 API는 서버가 `ADSS`로 고정 | +| `notiTtlKeyword` | N | 알림 제목 키워드. 본 화면 필터에 없음 | +| `notiCnKeyword` | N | 알림 내용 키워드. 본 화면 필터에 없음 | +| `notReadYn` | N | 알림 확인 여부 (`Y`/`N`). 본 화면 검색 필터에 없음 | +| `delYn` | N | 삭제 여부. 서버가 `N`으로 고정 | +| `startDt` | N | 시작일자 `YYYY-MM-DD` — 영역 A 기간(시작) | +| `endDt` | N | 종료일자 `YYYY-MM-DD` — 영역 A 기간(종료) | +| `docSeComCd` | N | 문서 구분 공통 코드 (`INSTL`/`FNRS`/`SEWER`/`CNFM`/`PWRK` 등) — 영역 A 구분 | +| `page` / `size` / `sort` | N | Spring Pageable | + +**Response (`NotiAsUserResponseDto`)** + +| 필드 | 타입 | 설명 | +| ----------------- | ------- | ------------------------------------------------------- | +| `notiId` | number | 알림 ID | +| `recvUnqId` | string | 수신 회원 UUID | +| `senderUnqId` | string | 발신 회원 UUID | +| `notiType` | string | 알림 종류 (`ADSS`) | +| `notiTtl` | string | 알림 제목 | +| `notiCn` | string? | 알림 내용 (목록에서 제외될 수 있음) | +| `notReadYn` | string? | 알림 확인 여부 (`Y`/`N`). 상태 Badge 판정 | +| `regDt` | string? | 등록 일시 `yyyy-MM-dd HH:mm:ss`. 상태 Badge 경과일 기준 | +| `notiUrl` | string? | 알림 URL 주소 | +| `notiLnkgUnqNo` | string? | 알림연결고유번호 | +| `docSeComCd` | string? | 문서구분 공통코드 | +| `docSeComNm` | string? | 문서구분 공통코드명 (`ASDOCSE`) | +| `notiIdntyDt` | string? | 알림 확인 일시 `yyyy-MM-dd HH:mm:ss` | +| `notiIdntyYn` | string? | 알림 확인 여부 (`Y`/`N`) | +| `notiPrcsCmptnYn` | string? | 알림 처리 완료 여부 (`Y`/`N`). 상태 Badge 판정 | +| `notiPrcsCmptnDt` | string? | 알림 처리 완료 일시 `yyyy-MM-dd HH:mm:ss` | + +### 6-3. 알림 읽음 처리 — `readNoti` + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| -------------- | ------ | ---------------------- | ----------------------- | ---------------- | -------------------------------------- | +| 알림 읽음 처리 | PUT | `{BASE}/{notiId}/read` | Path: `notiId` (`Long`) | `Long` (알림 ID) | `notiService.readNoti` / `useReadNoti` | + +**Request** + +| 필드 | 위치 | 필수 | 설명 | +| -------- | ---- | ---- | ------- | +| `notiId` | Path | Y | 알림 ID | + +Body 없음. + +**Response** + +| 타입 | 설명 | +| ------ | ------------------------------ | +| number | 읽음 처리된 알림 ID (`notiId`) | + +화면 매핑: 영역 B [처리] 버튼 클릭 시 호출. 성공 시 `notiBaseKey` invalidate → `findMyAsNotiList` 재조회. + +--- + +## 7. UI 구현 가이드 + +### 7-1. 기술 스택·원칙 + +- UI: `@common/components` — DatePicker, Select, Button, DataTable, Pagination, **Badge** +- 목록: `@tanstack/react-table` + `useDataTable` (기존 `my-pbadms-sprt-list` 패턴) +- 데이터: React Query `useFindMyAsNotiList(params)`, `useReadNoti()` +- 검색 조건·페이지: URL `searchParams` 동기화 권장 (`useSearchParamsState` 스킬) +- 처리 컬럼: 행 클릭이 아니라 **버튼 클릭**으로 `readNoti` 후 `notiUrl` 이동 +- 상태 컬럼: §5-1-1 규칙으로 `Badge` 렌더링 (`notiPrcsCmptnYn` / `notReadYn` / `regDt`) +- 연계업무: `docSeComNm` 표시 + +### 7-2. 앱·파일 배치 + +| 구분 | 경로 | +| ------------ | --------------------------------------------------------------------- | +| 라우트 | `app/(main)/(with-base-layout)/noti/page.tsx` | +| 페이지 | `pages/noti/ui/noti-list-page.tsx` | +| 목록 그리드 | `pages/noti/ui/noti-list.tsx` | +| 테이블 컬럼 | `pages/noti/model/noti-table-columns.tsx` | +| 상태 판정 | `pages/noti/lib/resolve-noti-status.ts` | +| Entity API | `entities/noti/api/noti-service.ts` | +| Entity Hook | `entities/noti/api/queries.ts` → `useFindMyAsNotiList`, `useReadNoti` | +| Entity Model | `entities/noti/model/noti-interface.ts` | + +화면 레이어(`pages/noti`)·Entity(`findMyAsNotiList`, `readNoti`) 구현 완료. + +### 7-3. 영역별 UI 매핑 + +| 영역 | 컴포넌트 | 데이터 | +| ---- | ------------------- | -------------------------------------------------------------- | +| A | `noti-list-page` | 로컬 state → `NotiSearchDto` | +| B | DataTable + columns | `useFindMyAsNotiList` → `content[]`. 처리 버튼 → `useReadNoti` | + +### 7-4. 다이얼로그·라우팅 + +| 항목 | 내용 | +| ----------- | --------------------------------------------------------------------- | +| 다이얼로그 | 없음 (목록 전용) | +| 목록 라우트 | `/noti` | +| 상세 이동 | 처리 버튼 → `readNoti(notiId)` 후 `notiUrl` (절대/상대 경로 형식 TBD) | + +### 7-5. 데이터 흐름 + +``` +BEGIN + + // 최초 진입 + SET 기간 = 최근 1개월 // startDt / endDt 전달 + CALL findMyAsNotiList + DISPLAY 영역 B + + ON [검색] CLICK DO + CALL findMyAsNotiList // → 영역 B 갱신 + END ON + + ON 페이지 CHANGE DO + CALL findMyAsNotiList + END ON + + ON 영역 B [처리] 버튼 CLICK DO + CALL readNoti(notiId) // → 영역 B 상태 갱신 (invalidate) + IF NOT notiUrl THEN + // no-op (읽음 처리는 수행됨) + ELSE + NAVIGATE notiUrl + END IF + END ON + +END +``` diff --git a/apps/as/docs/spec/previous-plan-lookup/SPEC.md b/apps/as/docs/spec/previous-plan-lookup/SPEC.md new file mode 100644 index 000000000..927121116 --- /dev/null +++ b/apps/as/docs/spec/previous-plan-lookup/SPEC.md @@ -0,0 +1,441 @@ +# 화면설계서: 이전기본계획조회 (기본 계획 찾기) + +## 1. 기본 정보 + +| 항목 | 내용 | +| ---------- | --------------------------------------------------------------- | +| 버전 | 0.2 | +| 프로세스명 | UI설계 | +| 화면명 | 이전기본계획조회 (기본 계획 찾기) | +| 화면 유형 | [v] 조회 [ ] 입력 [ ] 수정 [ ] 삭제 [ ] 출력 | +| 메뉴 경로 | 하수도기본계획 등록·수정 > 사업개요 > [이전기본계획조회] Dialog | + +--- + +## 2. 화면 목적 + +하수도기본계획 등록·수정 화면에서 **이전(기수립) 기본계획** 수치와 관련 보고서를 **조회·다운로드**한다. 시도·구군·연도·처리시설을 선택한 뒤 총설·처리구역별 현황을 표시하고, 분류 트리에서 기본계획 파일을 내려받는다. **부모 사업개요 폼에는 수치를 반영하지 않는다**(레거시 `bscPlnPopup.jsp`와 동일). + +--- + +## 3. 화면 구성 + +### 3-1. 전체 레이아웃 + +부모: 하수도기본계획 등록/수정 폼 → 사업개요 섹션의 `[이전기본계획조회]` 버튼으로 Dialog 오픈. + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ Dialog 타이틀: 기본 계획 찾기 [닫기] │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ [영역 A] 검색 필터 │ +│ 시도 ▼ 구군 ▼ 연도 ▼ 처리시설 ▼ [조회] │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ [영역 B] 기본계획파일 │ +│ ┌──────────────────────┬────────────────────────────────────────────────────┐ │ +│ │ [영역 B-1] 분류 트리 │ [영역 B-2] 파일 목록 │ │ +│ │ (약 40%) │ (약 60%) │ │ +│ │ └ 노드… │ 파일명 / 다운로드 │ │ +│ └──────────────────────┴────────────────────────────────────────────────────┘ │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ [영역 C] 총설 │ +│ ┌────────┬──────────────당초──────────────┬──기준──┬──────────────변경──────────────┐ │ +│ │ 구분 │ 1단계 │ 2단계 │ 3단계 │ 4단계 │ 기준 │ 1단계 │ 2단계 │ 3단계 │ 4단계 │ │ +│ ├────────┼───────┼───────┼───────┼───────┼────────┼───────┼───────┼───────┼───────┤ │ +│ │계획목표│ 년 │ 년 │ 년 │ 년 │ 년 │ 년 │ 년 │ 년 │ 년 │ │ +│ │년도 │ │ │ │ │ │ │ │ │ │ │ +│ └────────┴───────┴───────┴───────┴───────┴────────┴───────┴───────┴───────┴───────┘ │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ [영역 D] 처리구역별 현황 │ +│ ┌────────┬──────────────당초──────────────┬──기준──┬──────────────변경──────────────┐ │ +│ │ 구분 │ 1~4단계(+연도헤더) │ 기준 │ 1~4단계(+연도헤더) │ │ +│ ├────────┼────────────────────────────────┼────────┼────────────────────────────────┤ │ +│ │ 하수처리인구 … 배제방식 (읽기 전용 행) │ │ +│ └────────┴────────────────────────────────┴────────┴────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 3-2. 화면 데이터 흐름 + +``` +BEGIN + + // 부모 폼 — 사업개요 + ON [이전기본계획조회] 버튼 CLICK DO + OPEN PreviousPlanLookupDialog + // 프리필: 부모 폼 sewerBscPlan.sido / gugun / admdstCd + CALL findSidoCombo() + DISPLAY 시도 옵션 (영역 A) + IF 시도 프리필 THEN + CALL findGugunCombo(sidoCd) + DISPLAY 구군 옵션 (영역 A) + CALL getLvNowDmgMtncGnsvy({ level: "1", ctpvNm }) + // TBD: useLvNowDmgMtncGnsvy enabled — level별 필수 파라미터만 검사하도록 조정 + IF 구군 프리필 THEN + CALL getLvNowDmgMtncGnsvy({ level: "2", ctpvNm, sggNm }) + DISPLAY 연도 옵션 (영역 A) // children 중 level=3 노드의 gnsvyYr + CALL getClsfSystm({ clsfSystmNm: gugunNm }) + DISPLAY 분류 트리 (영역 B-1) + END IF + END IF + END ON + + // 영역 A: 시도 변경 + ON 영역 A 시도 CHANGE DO + RESET 구군 / 연도 / 처리시설 + CALL findGugunCombo(sidoCd) + CALL getLvNowDmgMtncGnsvy({ level: "1", ctpvNm }) + // 구군 미선택 → 연도·처리시설 옵션 비움, 트리 빈 안내 + END ON + + // 영역 A: 구군 변경 + ON 영역 A 구군 CHANGE DO + RESET 연도 / 처리시설 + CALL getLvNowDmgMtncGnsvy({ level: "2", ctpvNm, sggNm }) + DISPLAY 연도 옵션 (영역 A) // level=3 노드 → gnsvyYr + CALL getClsfSystm({ clsfSystmNm: sggNm }) + DISPLAY 분류 트리 (영역 B-1) + END ON + + // 영역 A: 연도 변경 + ON 영역 A 연도 CHANGE DO + RESET 처리시설 + CALL getLvNowDmgMtncGnsvy({ level: "3", ctpvNm, sggNm, gnsvyYr }) + DISPLAY 처리시설 옵션 (영역 A) // level=3 노드 children → swtrmtFcltCd / swtrmtFcltNm + END ON + + // 영역 A: 조회 + ON [조회] 버튼 CLICK DO + VALIDATE 시도, 구군, 연도(targetYear), 처리시설(swtrmtFcltCd) + CALL getNowDmgMtncGnsvy({ swtrmtFcltCd, ctpvNm, sggNm, targetYear }) + DISPLAY 총설 (영역 C) // 응답[0]의 planYr / planYrDt* / planYrDc* + DISPLAY 처리구역별 현황 (영역 D) // 응답[0]의 지표 Dt1~4 / base / Dc1~4 + END ON + + // 영역 B: 트리 노드 선택 + ON 영역 B-1 노드 CLICK DO + CALL getFile({ clsfSystmSn, sort, vetPrcsYn }) // Response DTO TBD + DISPLAY 파일 목록 (영역 B-2) + END ON + + // 영역 B: 파일 다운로드 + ON 영역 B-2 파일 CLICK DO + CALL getFilePrcs(fileSn) // 파일 메타(경로·제목) — Response TBD + CALL fileDownload({ filePath, fileName }) + // 브라우저 다운로드 트리거 + END ON + + ON [닫기] DO + CLOSE PreviousPlanLookupDialog + // 부모 폼 값 변경 없음 + END ON + +END +``` + +--- + +## 4. 영역 A — 검색 필터 + +구성: **시도 · 구군 · 연도 · 처리시설** (+ 조회). + +- 시도·구군: `adstdstr` 콤보 (기존 스펙 유지) +- 연도·처리시설: 각각 `useLvNowDmgMtncGnsvy`를 **별도 호출**. `level`에 따라 반환 계층이 달라진다. + +### 4-1. 필드 정의 + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| -------- | --------- | ---- | ------------------------- | ------------------------------------------------ | -------------------------------------------------------------- | +| 시도 | Select | Y | 부모 `sewerBscPlan.sido` | `ctpvNm`(표시명), 코드는 `adstDstrCd` | `useFindSidoCombo` | +| 구군 | Select | Y | 부모 `sewerBscPlan.gugun` | `sggNm`(표시명), 코드는 `adstDstrCd` | `useFindGugunCombo(sidoCd)` | +| 연도 | Select | Y | 없음 | `gnsvyYr` / 조회 시 `targetYear` | `useLvNowDmgMtncGnsvy({ level: "2", ctpvNm, sggNm })` | +| 처리시설 | Select | Y | 없음 | `swtrmtFcltCd` ← 옵션 value, 라벨 `swtrmtFcltNm` | `useLvNowDmgMtncGnsvy({ level: "3", ctpvNm, sggNm, gnsvyYr })` | + +> 처리시설은 `bns-if` `useFacilityList`를 **사용하지 않는다**. + +### 4-2. 버튼 + +| 버튼명 | 동작 | +| ------ | -------------------------------------------------------- | +| 조회 | 필수값 검증 후 `getNowDmgMtncGnsvy` 호출 → 영역 C·D 갱신 | +| 닫기 | Dialog 종료 (부모 미반영) | + +### 4-3. `lvNowDmgMtncGnsvy` level 캐스케이드 + +| 트리거 | Request `level` | Request 파라미터 | UI에 쓰는 응답 | 옵션 추출 | +| --------- | --------------- | ---------------------------- | --------------- | ------------------------------------------------------------------------------ | +| 시도 선택 | `"1"` | `ctpvNm` | (하위 초기화용) | 연도·처리시설 Select 리셋. 구군은 `findGugunCombo` | +| 구군 선택 | `"2"` | `ctpvNm`, `sggNm` | 연도 Select | 트리에서 `level === 3` 노드의 `gnsvyYr` | +| 연도 선택 | `"3"` | `ctpvNm`, `sggNm`, `gnsvyYr` | 처리시설 Select | 선택 연도(`level === 3`) 노드의 `children[]` → `swtrmtFcltCd` / `swtrmtFcltNm` | + +상위 변경 시 하위 선택값·옵션을 RESET한다 (시도→구군·연도·시설 / 구군→연도·시설 / 연도→시설). + +--- + +## 5. 영역 B — 기본계획파일 + +### 5-1. 트리 (B-1) + +| 표시명 | Field | 비고 | +| ------ | --------------- | ------------------------ | +| 분류명 | `clsfSystmNm` | `ClsfSystmResponseDto` | +| 노드키 | `clsfSystmSn` | 선택 시 파일 목록 조회 | +| 상위키 | `upClsfSystmSn` | 클라이언트에서 트리 조립 | +| 정렬 | `sortSeq` | | +| 경로 | `path` | | + +- 조회 파라미터: `clsfSystmNm` = 선택 구군명(레거시와 동일). `upClsfSystmSn`은 하위 전개 시 TBD. +- 시도·구군 미선택 시: 「시도와 구군을 선택하세요.」 안내. + +### 5-2. 파일 목록 (B-2) + +| 표시명 | Field | 비고 | +| ------ | ----- | ------------------------------------------------------------------- | +| 파일명 | TBD | `getFile` Response DTO가 `unknown` — 백엔드 스키마 확정 후 매핑 | +| (기타) | TBD | 레거시: 트리 선택 후 목록 표시, 클릭 시 `fileprcs` → `fileDownload` | + +### 5-3. 버튼·상호작용 + +| 동작 | 설명 | +| -------------- | -------------------------------------------------------------- | +| 트리 노드 클릭 | `getFile({ clsfSystmSn, … })` → B-2 목록 | +| 파일 클릭 | `getFilePrcs(fileSn)` → `fileDownload({ filePath, fileName })` | +| 빈 목록 | 「트리에서 항목을 선택하세요.」 / 「파일이 없습니다.」 | + +--- + +## 6. 영역 C — 총설 + +### 6-1. 테이블 구조 + +| 행(구분) | 열 매핑 (당초1~~4 / 기준 / 변경1~~4) | +| ------------ | ------------------------------------------------------------------------------ | +| 계획목표년도 | `planYrDt1planYrDt4` ~~/~~ `planYr` ~~/~~ `planYrDc1planYrDc4` (+ `년` 접미사) | + +- 헤더: 당초(1~~4단계) · 기준 · 변경(1~~4단계) +- 데이터 소스: `getNowDmgMtncGnsvy` 응답 배열의 **첫 번째** 요소 +- 읽기 전용 + +--- + +## 7. 영역 D — 처리구역별 현황 + +### 7-1. 행 정의 (표시명 → Field 접두사) + +각 행은 `Dt1Dt4` ~~/ (접미사 없음=기준) /~~ `Dc1Dc4` 셀로 표시한다. 숫자는 천단위 콤마, 없으면 `-`. + +| 표시명 | Field 접두사 | 비고 | +| ------------------------ | -------------------- | ---------------------------------------------- | +| 하수처리인구(인) | `swtrmtPpltnCnt` | | +| 자연적하수처리인구수(인) | `natrSwtrmtPpltnCnt` | | +| 사회적하수처리인구수(인) | `scilSwtrmtPpltnCnt` | | +| 하수처리구역면적 | `swtrmtZoneArea` | | +| 물사용량원단위 | `wtrUsqtyOrgnUnit` | | +| 오수전환율(%) | `drtwSwtcRt` | | +| 변동부하율(%) | `flctnLodRt` | | +| 오수원단위 | `drtwOrgnUnit` | | +| 계획하수량합계(㎥/일) | `planSwageQntSum` | | +| 생활오수(㎥/일) | `lvngDrtwSum` | | +| 지하수(㎥/일) | `ugwtSum` | | +| 공장폐수(㎥/일) | `fctryWwtSum` | | +| 관광오수(㎥/일) | `toursmDrtwSum` | | +| 군부대(㎥/일) | `mtbsSum` | | +| 연계처리수(㎥/일) | `linkPrcsCntSum` | | +| 기타오수(㎥/일) | `etcDrtwSum` | | +| 지하수사용량(㎥/일) | `ugwtUseSum` | | +| 시설용량(㎥/일) | `fcltCpctSum` | | +| 배제방식 | `exclsMethExpln` | 문자열; 단계별 `*Dt*`/`*Dc*` 존재 시 동일 규칙 | + +헤더 3행째 연도: 영역 C와 동일하게 `planYrDt*` / `planYr` / `planYrDc*` 표시. + +--- + +## 8. API/DTO 명세 + +### 8-1. 공통 + +| 항목 | 값 | +| --------------- | ---------------------------------------------------- | +| DC Base | `/dc-service/api/v1` (`ENDPOINT.DC_SERVICE`) | +| AS adstdstr | `/as-service/api/v1/adstdstr` | +| 클라이언트 모듈 | `@/entities/dc` (`dcService`), `@/entities/adstdstr` | + +> 영역 A 처리시설은 `bns-if`를 사용하지 않음. + +### 8-2. 시도·구군 콤보 (non-DC) + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| -------- | ------ | ----------------------------------- | ------------------------ | -------------------------------- | ---------------------------------------------------------- | +| 시도목록 | GET | `/as-service/api/v1/adstdstr/sido` | — | `CmCdAdstDstrComboResponseDto[]` | `cmCdAdstDstrService.findSidoCombo` / `useFindSidoCombo` | +| 구군목록 | GET | `/as-service/api/v1/adstdstr/gugun` | Query: `sido` (시도코드) | `CmCdAdstDstrComboResponseDto[]` | `cmCdAdstDstrService.findGugunCombo` / `useFindGugunCombo` | + +### 8-3. 연도·처리시설 계층 조회 — DC + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| --------------- | ------ | -------------------------------------- | ---------------------------- | -------------------------------- | --------------------------------------------------------- | +| 계층형 계획목록 | GET | `/dc-service/api/v1/lvNowDmgMtncGnsvy` | `LvNowDmgMtncGnsvySearchDto` | `LvNowDmgMtncGnsvyResponseDto[]` | `dcService.getLvNowDmgMtncGnsvy` / `useLvNowDmgMtncGnsvy` | + +연도 Select·처리시설 Select는 **각각** `useLvNowDmgMtncGnsvy`를 호출한다 (`level`만 다르게). + +**Request (**`LvNowDmgMtncGnsvySearchDto`**)** + +| 필드 | 시도(`level=1`) | 구군→연도(`level=2`) | 연도→시설(`level=3`) | +| --------- | --------------- | -------------------- | -------------------- | +| `level` | `"1"` | `"2"` | `"3"` | +| `ctpvNm` | 필수 | 필수 | 필수 | +| `sggNm` | — | 필수 | 필수 | +| `gnsvyYr` | — | — | 필수 | + +**Response 노드 필드 (**`LvNowDmgMtncGnsvyResponseDto`**, 부분 optional)** + +| 필드 | 등장 level | 설명 | +| -------------- | ---------- | ------------------------ | +| `level` | 1~3 | 계층 깊이 | +| `ctpvNm` | 1 | 시도명 | +| `sggNm` | 2 | 구군명 | +| `gnsvyYr` | 3 | 연도 → 연도 Select value | +| `swtrmtFcltCd` | 시설(리프) | 처리시설 코드 | +| `swtrmtFcltNm` | 시설(리프) | 처리시설명 | +| `children` | 1~3 | 하위 노드 배열 | + +> **TBD**: 현재 `useLvNowDmgMtncGnsvy` `enabled`가 `level && ctpvNm && sggNm && gnsvyYr`로 고정되어 `level=1`/`2` 호출이 막힘. level별 필수 파라미터만 검사하도록 수정 필요. +> +> 예: `level=1` → `ctpvNm` / `level=2` → `ctpvNm && sggNm` / `level=3` → `ctpvNm && sggNm && gnsvyYr` + +### 8-4. 기본계획 수치 조회 — DC + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| ---------------- | ------ | ------------------------------------ | -------------------------- | ------------------------------ | ----------------------------------------------------- | +| 현재·단계별 수치 | GET | `/dc-service/api/v1/nowDmgMtncGnsvy` | `NowDmgMtncGnsvySearchDto` | `NowDmgMtncGnsvyResponseDto[]` | `dcService.getNowDmgMtncGnsvy` / `useNowDmgMtncGnsvy` | + +**Request (**`NowDmgMtncGnsvySearchDto`**)** + +| 필드 | 매핑 | +| -------------- | ----------------------- | +| `swtrmtFcltCd` | 영역 A 처리시설 | +| `ctpvNm` | 영역 A 시도 표시명 | +| `sggNm` | 영역 A 구군 표시명 | +| `targetYear` | 영역 A 연도 (`gnsvyYr`) | + +**Response**: 지표 필드 + `Dt1Dt4` ~~/~~ `Dc1Dc4` 접미사 스테이지 필드 (`NowDmgMtncGnsvyResponseDto`). UI는 `data[0]` 사용. + +### 8-5. 분류체계 — DC + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| ------------ | ------ | ------------------------------ | -------------------- | ------------------------ | ----------------------------------------- | +| 분류트리조회 | GET | `/dc-service/api/v1/clsfSystm` | `ClsfSystmParamsDto` | `ClsfSystmResponseDto[]` | `dcService.getClsfSystm` / `useClsfSystm` | + +**Request**: `clsfSystmNm?`, `upClsfSystmSn?` +**Response**: `clsfSystmSn`, `upClsfSystmSn`, `clsfSystmNm`, `sortSeq`, `droppAble`, `path` + +> 레거시 경로 `/clsf` → 현재 클라이언트는 `/clsfSystm`. + +### 8-6. 파일 목록·처리·다운로드 — DC + +| 기능 | Method | URL | Request DTO / Params | Response DTO | API | +| ------------ | ------ | --------------------------------- | ------------------------------------------------------- | ----------------- | -------------------------------------------- | +| 파일목록 | GET | `/dc-service/api/v1/file` | `FileParamsDto` (`sort?`, `clsfSystmSn?`, `vetPrcsYn?`) | `unknown` **TBD** | `dcService.getFile` / `useFile` | +| 파일처리정보 | GET | `/dc-service/api/v1/fileprcs` | Query: `fileSn` | `unknown` **TBD** | `dcService.getFilePrcs` / `useFilePrcs` | +| 파일다운로드 | POST | `/dc-service/api/v1/fileDownload` | `FileDownloadParamsDto` (`filePath?`, `fileName?`) | `unknown` **TBD** | `dcService.fileDownload` / `useFileDownload` | + +> **TBD**: 파일 관련 Response 스키마·다운로드 blob/헤더 처리·`fileDownload` POST body vs query 전달 방식은 백엔드/게이트웨이 확인 후 확정. + +--- + +## 9. UI 구현 가이드 + +### 9-1. 기술 스택·원칙 + +- UI: `@common/components` — `Dialog`, `Select`, `Button`, `Table`, `Skeleton` +- 상태: Dialog 로컬 state + React Query (`entities/dc`, `adstdstr`) +- 영역 A 연도·처리시설: `useLvNowDmgMtncGnsvy`를 level별 **두 번**(또는 시도 포함 시 세 번) 호출 +- 테이블: 읽기 전용 고정 행 → `@tanstack/react-table` / `DataTable` **불필요** +- 트리: jsTree 미사용 — 재귀 들여쓰기 트리 +- 번들: `PreviousPlanLookupDialog`는 outline에서 `next/dynamic` 로드 권장 +- 부모 폼: **수치 미반영** (조회·다운로드 only) +- 1차 구현 시 DC 미연동이면 `@faker-js/faker` mock + TODO → 이후 `dcService` 훅으로 교체 + +### 9-2. 앱·파일 배치 + +| 구분 | 경로 | +| ------------ | ------------------------------------------------------------------------ | +| 진입 버튼 | `pages/sewer-bsc-plan/ui/sections/outline-form-section.tsx` | +| Dialog | `pages/sewer-bsc-plan/ui/previous-plan-lookup-dialog.tsx` | +| 필터 | `pages/sewer-bsc-plan/ui/previous-plan/previous-plan-search-filters.tsx` | +| 파일 | `pages/sewer-bsc-plan/ui/previous-plan/previous-plan-file-section.tsx` | +| 총설 | `pages/sewer-bsc-plan/ui/previous-plan/previous-plan-summary-table.tsx` | +| 처리구역 | `pages/sewer-bsc-plan/ui/previous-plan/previous-plan-area-table.tsx` | +| 셀 매핑 유틸 | `pages/sewer-bsc-plan/lib/previous-plan-table-utils.ts` | +| mock(선택) | `pages/sewer-bsc-plan/lib/previous-plan-mock.ts` | +| DC entity | `entities/dc/api/dc-service.ts`, `queries.ts`, `model/dc-interface.ts` | +| 레거시 참조 | `adssNewOld/.../cmmn/bscPlnPopup.jsp` | + +### 9-3. 영역별 UI 매핑 + +| 영역 | 컴포넌트 | 데이터 | +| ---- | ------------------------------ | -------------------------------------------------------------- | +| A | `previous-plan-search-filters` | adstdstr + `useLvNowDmgMtncGnsvy`(level 1·2·3) | +| B | `previous-plan-file-section` | `useClsfSystm` + `useFile` + `useFilePrcs` + `useFileDownload` | +| C | `previous-plan-summary-table` | `useNowDmgMtncGnsvy` / 조회 결과 state | +| D | `previous-plan-area-table` | 동일 | + +### 9-4. 다이얼로그·라우팅 + +| 항목 | 내용 | +| ------------ | ------------------------------------------------------------------------ | +| 다이얼로그명 | `PreviousPlanLookupDialog` / 타이틀 `기본 계획 찾기` | +| 오픈 | 사업개요 `[이전기본계획조회]` | +| 라우트 | 없음 (모달). 부모: `/sewer-bsc-plan/regist`, `/sewer-bsc-plan/[id]/edit` | +| 선택 반영 | 없음 | + +### 9-5. 데이터 흐름 (구현 요약) + +``` +BEGIN + + ON [이전기본계획조회] CLICK DO + OPEN PreviousPlanLookupDialog (sido/gugun 프리필) + END ON + + ON 시도 CHANGE DO + CALL findGugunCombo + CALL getLvNowDmgMtncGnsvy(level=1) + RESET 연도·처리시설 + END ON + + ON 구군 CHANGE DO + CALL getLvNowDmgMtncGnsvy(level=2) // → 연도 옵션 + CALL getClsfSystm + RESET 처리시설 + END ON + + ON 연도 CHANGE DO + CALL getLvNowDmgMtncGnsvy(level=3) // → 처리시설 옵션 + END ON + + ON [조회] CLICK DO + VALIDATE + CALL getNowDmgMtncGnsvy + DISPLAY 영역 C · 영역 D + END ON + + ON 트리 노드 / 파일 CLICK DO + CALL getFile → getFilePrcs → fileDownload // Response TBD + END ON + +END +``` + +--- + +## 10. 미확정 (TBD) 추적 + +| ID | 항목 | 비고 | +| --- | -------------------------------------------------------- | -------------------------------------------------------------------- | +| T1 | `useLvNowDmgMtncGnsvy` enabled을 level별 필수값으로 분기 | `1`: ctpvNm / `2`: +sggNm / `3`: +gnsvyYr | +| T2 | `getFile` / `getFilePrcs` / `fileDownload` Response DTO | 현재 `unknown` | +| T3 | 파일 다운로드 UX (blob, Content-Disposition, 게이트웨이) | 레거시 apigateway 직접 호출과 차이 | +| T4 | `clsfSystm`의 `upClsfSystmSn` 점진 로드 여부 | flat 목록 vs 계층 API | +| T5 | 1차 mock vs 즉시 `dcService` 실연동 | 마이그레이션 플랜은 mock 우선, entity는 준비됨 | +| T6 | 부모 폼 수치 반영 | 요구 없음(레거시 동일). 요청 시 별도 스펙 | +| T7 | `level=1` 응답 UI 활용 여부 | 시도 선택 시 호출하나 구군은 adstdstr — 응답은 캐시·검증용일 수 있음 | diff --git a/apps/as/docs/review-manpower-management/SPEC.md b/apps/as/docs/spec/review-manpower-management/SPEC.md similarity index 100% rename from apps/as/docs/review-manpower-management/SPEC.md rename to apps/as/docs/spec/review-manpower-management/SPEC.md diff --git a/apps/as/middleware.ts b/apps/as/middleware.ts index 94a1d0381..dbd2a53f7 100644 --- a/apps/as/middleware.ts +++ b/apps/as/middleware.ts @@ -1,8 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; -import { AUTH_USER_ID, CLAIM_NAME, PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config"; +import { + AUTH_USER_ID, + CLAIM_NAME, + PORT, + PROXY_HOST, + SERVER_API_URL, + SITE_ID, +} from "@/shared/config"; const serverApiUrl = process.env.SERVER_API_URL; +const dcServiceApiUrl = process.env.DC_SERVICE_API_URL; // CSP 헤더 설정 함수 (img-src만) function addSecurityHeaders(response: NextResponse): NextResponse { @@ -22,7 +30,15 @@ export function middleware(req: NextRequest) { if (pathname.startsWith("/server")) { const queryParams = searchParams.toString(); - const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${ + + let serverUrl: string | undefined; + if (pathname.includes("/dc-service")) { + serverUrl = dcServiceApiUrl; + } else { + serverUrl = serverApiUrl; + } + + const newUrl = `${serverUrl}${pathname.replace("/server", "")}${ queryParams ? `?${queryParams}` : "" }`; @@ -34,7 +50,6 @@ export function middleware(req: NextRequest) { return addSecurityHeaders(response); } - // tus-js-client PATCH 요청: Location 헤더로 /server 없이 반환된 경로 직접 처리 // (예: /attach-service/api/v1/tus/upload/{id}) if (pathname.startsWith("/attach-service")) { @@ -56,10 +71,10 @@ export function middleware(req: NextRequest) { return addSecurityHeaders(response); } - // if (pathname === "/") { - // const response = NextResponse.redirect(new URL("/dashboard", req.url)); - // return addSecurityHeaders(response); - // } + if (pathname === "/") { + const response = NextResponse.redirect(new URL("/dashboard", req.url)); + return response; + } logMessages.push("\n--------------------------------\n"); console.log(logMessages.join("\n")); diff --git a/apps/as/next.config.ts b/apps/as/next.config.ts index d3afd4d70..5d4f22bce 100644 --- a/apps/as/next.config.ts +++ b/apps/as/next.config.ts @@ -1,9 +1,53 @@ import type { NextConfig } from "next"; +const subDomain = process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000"; + +const webhwpCtrlBase = ( + process.env.NEXT_PUBLIC_WEBHYP_CTRL_URL || "http://hwp.onasys.co.kr/webhwpctrl" +).replace(/\/$/, ""); + +const webhwpAssetPrefixes = ["js", "hwpsdk", "dist", "commonFrame", "wasm"] as const; + const nextConfig: NextConfig = { output: "standalone", reactStrictMode: true, transpilePackages: ["@common/assets"], + experimental: { + globalNotFound: true, + }, + + async rewrites() { + return { + beforeFiles: [ + { + source: "/_next/static/:path*", + destination: `${subDomain}/_next/static/:path*`, + has: [ + { + type: "header", + key: "referer", + value: "(.*)/cms(.*)", + }, + ], + }, + ], + afterFiles: [ + { + source: "/cms/:path*", + destination: `${subDomain}/cms/:path*`, + }, + ...webhwpAssetPrefixes.map((prefix) => ({ + source: `/webhwpctrl/${prefix}/:path*`, + destination: `${webhwpCtrlBase}/${prefix}/:path*`, + })), + { + source: "/webhwpctrl/file/:path*", + destination: `${webhwpCtrlBase}/file/:path*`, + }, + ], + fallback: [], + }; + }, }; export default nextConfig; diff --git a/apps/as/package.json b/apps/as/package.json index cebdf28ee..a90c4bdc1 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -32,11 +32,11 @@ "next": "15.5.18", "react-hook-form": "^7.54.2", "tus-js-client": "^4.3.1", - "uuid": "^14.0.1", "zod": "^3.24.2" }, "devDependencies": { "@eslint/eslintrc": "^3", + "@hookform/devtools": "^4.4.0", "@types/js-cookie": "^3.0.6", "eslint": "^9.0.0", "eslint-config-next": "15.3.8", diff --git a/apps/as/public/images/help.png b/apps/as/public/images/help.png new file mode 100644 index 000000000..e872802e4 Binary files /dev/null and b/apps/as/public/images/help.png differ diff --git a/apps/as/public/images/magnifying-glass-with-warning.png b/apps/as/public/images/magnifying-glass-with-warning.png new file mode 100644 index 000000000..b184da207 Binary files /dev/null and b/apps/as/public/images/magnifying-glass-with-warning.png differ diff --git a/apps/as/public/images/region_map.svg b/apps/as/public/images/region_map.svg new file mode 100644 index 000000000..f327bb9fc --- /dev/null +++ b/apps/as/public/images/region_map.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/as/public/images/region_map_bg.png b/apps/as/public/images/region_map_bg.png new file mode 100644 index 000000000..6f59f58cc Binary files /dev/null and b/apps/as/public/images/region_map_bg.png differ diff --git a/apps/as/public/webhwpctrl/hwpsdkmain.html b/apps/as/public/webhwpctrl/hwpsdkmain.html new file mode 100644 index 000000000..3f4bc1526 --- /dev/null +++ b/apps/as/public/webhwpctrl/hwpsdkmain.html @@ -0,0 +1,62 @@ + + + + + + + + Hwp SDK Wasm Sample + + + + + + + + +
+
+
+ 구성 중 ... 0% + +
+
+ +
+
+ + + + + diff --git a/apps/as/public/webhwpctrl/index.html b/apps/as/public/webhwpctrl/index.html new file mode 100644 index 000000000..dcb8c3e45 --- /dev/null +++ b/apps/as/public/webhwpctrl/index.html @@ -0,0 +1,66 @@ + + + + + + + 웹한글 기안기 브리지 + + + + + + +
+ + + diff --git a/apps/as/src/app/styles/globals.css b/apps/as/src/app/styles/globals.css index e557f947b..bf1ecad16 100644 --- a/apps/as/src/app/styles/globals.css +++ b/apps/as/src/app/styles/globals.css @@ -6,10 +6,19 @@ @tailwind components; @tailwind utilities; -html, -body { - color: var(--foreground); - background: var(--background); - font-family: "Noto Sans KR", sans-serif; - font-size: 10px; +@layer base { + :root { + --secondary-background: rgba(0, 0, 0, 0.04); + } + [data-theme="black"] { + --secondary-background: rgba(255, 255, 255, 0.09); + } + + html, + body { + color: var(--foreground); + background: var(--background); + font-family: "Noto Sans KR", sans-serif; + font-size: 10px; + } } diff --git a/apps/as/src/entities/adstdstr/api/adstdstr-service.ts b/apps/as/src/entities/adstdstr/api/adstdstr-service.ts index b4bb11fae..f282ea67b 100644 --- a/apps/as/src/entities/adstdstr/api/adstdstr-service.ts +++ b/apps/as/src/entities/adstdstr/api/adstdstr-service.ts @@ -13,6 +13,11 @@ async function findSidoCombo(): Promise { return response.data; } +async function findUserSidoCombo(): Promise { + const response = await client.get(`${BASE}/user/sido`); + return response.data; +} + async function findGugunCombo(sido: string): Promise { const response = await client.get(`${BASE}/gugun`, { params: { sido }, @@ -20,7 +25,16 @@ async function findGugunCombo(sido: string): Promise { + const response = await client.get(`${BASE}/user/gugun`, { + params: { sido }, + }); + return response.data; +} + export const cmCdAdstDstrService = { findSidoCombo, + findUserSidoCombo, findGugunCombo, + findUserGugunCombo, } satisfies CmCdAdstDstrService; diff --git a/apps/as/src/entities/adstdstr/api/queries.ts b/apps/as/src/entities/adstdstr/api/queries.ts index 353e25cfe..2556ec6db 100644 --- a/apps/as/src/entities/adstdstr/api/queries.ts +++ b/apps/as/src/entities/adstdstr/api/queries.ts @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { ENDPOINT } from "@/shared/config"; +import { IUserInfo } from "@/shared/store"; import { cmCdAdstDstrService } from "./adstdstr-service"; @@ -13,6 +14,16 @@ export function useFindSidoCombo() { }); } +export function useFindUserSidoCombo(userInfo?: IUserInfo | null) { + const adstDstrCdByUser = userInfo?.laeSeCd?.substring(2, 4); + return useQuery({ + queryKey: [...cmCdAdstDstrBaseKey, "findUserSidoCombo", adstDstrCdByUser ?? "all"], + queryFn: () => cmCdAdstDstrService.findUserSidoCombo(), + select: (data) => + adstDstrCdByUser ? data.filter((item) => item.adstDstrCd === adstDstrCdByUser) : data, + }); +} + export function useFindGugunCombo(sido: string) { return useQuery({ queryKey: [...cmCdAdstDstrBaseKey, "findGugunCombo", sido], @@ -20,3 +31,11 @@ export function useFindGugunCombo(sido: string) { enabled: Boolean(sido), }); } + +export function useFindUserGugunCombo(sido: string) { + return useQuery({ + queryKey: [...cmCdAdstDstrBaseKey, "findUserGugunCombo", sido], + queryFn: () => cmCdAdstDstrService.findUserGugunCombo(sido), + enabled: Boolean(sido), + }); +} diff --git a/apps/as/src/entities/adstdstr/index.ts b/apps/as/src/entities/adstdstr/index.ts index da1dbd132..2fffe5a79 100644 --- a/apps/as/src/entities/adstdstr/index.ts +++ b/apps/as/src/entities/adstdstr/index.ts @@ -1,5 +1,10 @@ export { cmCdAdstDstrService } from "./api/adstdstr-service"; -export { useFindGugunCombo, useFindSidoCombo } from "./api/queries"; +export { + useFindGugunCombo, + useFindSidoCombo, + useFindUserGugunCombo, + useFindUserSidoCombo, +} from "./api/queries"; export type { CmCdAdstDstrComboResponseDto, CmCdAdstDstrService, diff --git a/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts b/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts index 72cb673dc..1eaa14bff 100644 --- a/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts +++ b/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts @@ -7,5 +7,7 @@ export interface CmCdAdstDstrComboResponseDto { /** Controller public 메서드명·시그니처와 동일 */ export interface CmCdAdstDstrService { findSidoCombo(): Promise; + findUserSidoCombo(): Promise; findGugunCombo(sido: string): Promise; + findUserGugunCombo(sido: string): Promise; } diff --git a/apps/as/src/entities/ai-auto-prcs-mng/api/ai-auto-prcs-mng-service.ts b/apps/as/src/entities/ai-auto-prcs-mng/api/ai-auto-prcs-mng-service.ts new file mode 100644 index 000000000..124dde1dd --- /dev/null +++ b/apps/as/src/entities/ai-auto-prcs-mng/api/ai-auto-prcs-mng-service.ts @@ -0,0 +1,24 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + AiAutoPrcsLatestResponseDto, + AiAutoPrcsMngService, +} from "../model/ai-auto-prcs-mng-interface"; + +const BASE = ENDPOINT.AS_SERVICE.AI_AUTO_PRCS_MNG; + +async function findLatestPerAutoSeByFnrsDocNo( + fnrsCnsltnDocNo: string, + atmtnSeComCd?: string, +): Promise { + const response = await client.get( + `${BASE}/fnrs/${fnrsCnsltnDocNo}/latest`, + { params: atmtnSeComCd ? { atmtnSeComCd } : undefined }, + ); + return response.data; +} + +export const aiAutoPrcsMngService = { + findLatestPerAutoSeByFnrsDocNo, +} satisfies AiAutoPrcsMngService; diff --git a/apps/as/src/entities/ai-auto-prcs-mng/api/queries.ts b/apps/as/src/entities/ai-auto-prcs-mng/api/queries.ts new file mode 100644 index 000000000..fe97be2db --- /dev/null +++ b/apps/as/src/entities/ai-auto-prcs-mng/api/queries.ts @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import { aiAutoPrcsMngService } from "./ai-auto-prcs-mng-service"; + +export const aiAutoPrcsMngKeys = { + all: [ENDPOINT.AS_SERVICE.AI_AUTO_PRCS_MNG] as const, + latest: (fnrsCnsltnDocNo: string, atmtnSeComCd?: string) => + [...aiAutoPrcsMngKeys.all, "latest", fnrsCnsltnDocNo, atmtnSeComCd ?? "all"] as const, +}; + +interface UseAiAutoPrcsLatestOptions { + enabled?: boolean; + atmtnSeComCd?: string; +} + +export function useAiAutoPrcsLatest( + fnrsCnsltnDocNo: string | null | undefined, + options: UseAiAutoPrcsLatestOptions = {}, +) { + const { enabled = true, atmtnSeComCd } = options; + + return useQuery({ + queryKey: aiAutoPrcsMngKeys.latest(fnrsCnsltnDocNo!, atmtnSeComCd), + queryFn: () => + aiAutoPrcsMngService.findLatestPerAutoSeByFnrsDocNo(fnrsCnsltnDocNo!, atmtnSeComCd), + enabled: Boolean(fnrsCnsltnDocNo) && enabled, + }); +} diff --git a/apps/as/src/entities/ai-auto-prcs-mng/index.ts b/apps/as/src/entities/ai-auto-prcs-mng/index.ts new file mode 100644 index 000000000..0668a4e0c --- /dev/null +++ b/apps/as/src/entities/ai-auto-prcs-mng/index.ts @@ -0,0 +1,8 @@ +export { aiAutoPrcsMngService } from "./api/ai-auto-prcs-mng-service"; +export { aiAutoPrcsMngKeys, useAiAutoPrcsLatest } from "./api/queries"; +export type { + AiAutoPrcsLatestResponseDto, + AiAutoPrcsMngService, + AiAutoSeComCd, +} from "./model/ai-auto-prcs-mng-interface"; +export { AI_AUTO_SE } from "./model/ai-auto-prcs-mng-interface"; diff --git a/apps/as/src/entities/ai-auto-prcs-mng/model/ai-auto-prcs-mng-interface.ts b/apps/as/src/entities/ai-auto-prcs-mng/model/ai-auto-prcs-mng-interface.ts new file mode 100644 index 000000000..ad833b3c6 --- /dev/null +++ b/apps/as/src/entities/ai-auto-prcs-mng/model/ai-auto-prcs-mng-interface.ts @@ -0,0 +1,44 @@ +/** 자동화구분 공통코드 (AIAUTOSE) */ +export const AI_AUTO_SE = { + FILE_VRFC: "FILE_VRFC", + DRAFT_GEN: "DRAFT_GEN", + COST_VRFC: "COST_VRFC", +} as const; + +export type AiAutoSeComCd = (typeof AI_AUTO_SE)[keyof typeof AI_AUTO_SE]; + +/** 백엔드 AiAutoPrcsLatestResponseDto */ +export interface AiAutoPrcsLatestResponseDto { + atmtnPrcsUuid?: string; + fnrsCnsltnDocNo?: string; + pwrkCnsltnDocNo?: string; + crtDmndUuid?: string; + atmtnSeComCd?: string; + atmtnSeComNm?: string; + bfrDocSttsComCd?: string; + bfrDocSttsComNm?: string; + aftrDocSttsComCd?: string; + aftrDocSttsComNm?: string; + atmtnPrcsSttsComCd?: string; + atmtnPrcsSttsComNm?: string; + dmndDt?: string; + dmndSysId?: string; + rplyDt?: string; + rplyRsltComCd?: string; + prcsErrCn?: string | null; + lastSttsInqDt?: string; + sttsInqRsltComCd?: string; + rtrsmCtpvNmtm?: number; + lastRtrsmDt?: string; + lastFrcSttsChgYn?: string; + isFailed?: boolean; + isRetryable?: boolean; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface AiAutoPrcsMngService { + findLatestPerAutoSeByFnrsDocNo( + fnrsCnsltnDocNo: string, + atmtnSeComCd?: string, + ): Promise; +} diff --git a/apps/as/src/entities/attach/api/attach-service.ts b/apps/as/src/entities/attach/api/attach-service.ts index 0af0a916d..2cb1fc35d 100644 --- a/apps/as/src/entities/attach/api/attach-service.ts +++ b/apps/as/src/entities/attach/api/attach-service.ts @@ -3,7 +3,13 @@ import { AxiosResponse } from "axios"; import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; -import type { AttachService } from "../model/attach-interface"; +import type { Attach, AttachService } from "../model/attach-interface"; + +async function findByAtflCd(atflCd: string): Promise { + if (!atflCd) return []; + const response = await client.get(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflCd}`); + return response.data; +} async function deleteAttachmentByAtflId(atflId: string): Promise { if (!atflId) return; @@ -16,7 +22,15 @@ async function downloadAttachmentByAtflCd(atflCd: string): Promise { + return await client.get(`${ENDPOINT.ATTACH_SERVICE.DOWNLOAD_BY_ATFL_ID}/${atflId}`, { + responseType: "blob", + }); +} + export const attachService = { + findByAtflCd, deleteAttachmentByAtflId, downloadAttachmentByAtflCd, + downloadAttachmentByAtflId, } satisfies AttachService; diff --git a/apps/as/src/entities/attach/api/queries.ts b/apps/as/src/entities/attach/api/queries.ts index ed9efefbf..09a1ab1d9 100644 --- a/apps/as/src/entities/attach/api/queries.ts +++ b/apps/as/src/entities/attach/api/queries.ts @@ -1,4 +1,5 @@ import { useMutation } from "@tanstack/react-query"; +import type { AxiosResponse } from "axios"; import { ENDPOINT } from "@/shared/config"; @@ -8,6 +9,22 @@ import { attachService } from "./attach-service"; const attachBaseKey = [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS] as const; const attachDownloadBaseKey = [ENDPOINT.ATTACH_SERVICE.DOWNLOAD] as const; +const attachDownloadByAtflIdKey = [ENDPOINT.ATTACH_SERVICE.DOWNLOAD_BY_ATFL_ID] as const; + +function triggerBlobDownload(response: AxiosResponse) { + const filename = parseFilenameFromResponse(response); + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); +} export function useDeleteAttachment() { return useMutation({ @@ -21,19 +38,17 @@ export function useDownloadAttachmentByAtflCd() { mutationKey: [...attachDownloadBaseKey, "download"], mutationFn: async (atflCd: string) => { const response = await attachService.downloadAttachmentByAtflCd(atflCd); - const filename = parseFilenameFromResponse(response); - - const blob = new Blob([response.data]); - const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - - window.URL.revokeObjectURL(url); - document.body.removeChild(link); + triggerBlobDownload(response); + }, + }); +} + +export function useDownloadAttachmentByAtflId() { + return useMutation({ + mutationKey: [...attachDownloadByAtflIdKey, "download"], + mutationFn: async (atflId: string) => { + const response = await attachService.downloadAttachmentByAtflId(atflId); + triggerBlobDownload(response); }, }); } diff --git a/apps/as/src/entities/attach/index.ts b/apps/as/src/entities/attach/index.ts index 37258e6e5..69b220102 100644 --- a/apps/as/src/entities/attach/index.ts +++ b/apps/as/src/entities/attach/index.ts @@ -1,3 +1,12 @@ export { attachService } from "./api/attach-service"; -export { useDeleteAttachment, useDownloadAttachmentByAtflCd } from "./api/queries"; +export { + useDeleteAttachment, + useDownloadAttachmentByAtflCd, + useDownloadAttachmentByAtflId, +} from "./api/queries"; +export { parseFilenameFromResponse } from "./lib/parse-filename-from-response"; export type { Attach, AttachService } from "./model/attach-interface"; +export { + AttachmentDownloadLink, + type AttachmentDownloadLinkProps, +} from "./ui/attachment-download-link"; diff --git a/apps/as/src/entities/attach/model/attach-interface.ts b/apps/as/src/entities/attach/model/attach-interface.ts index d6b0a08d2..ef299a43c 100644 --- a/apps/as/src/entities/attach/model/attach-interface.ts +++ b/apps/as/src/entities/attach/model/attach-interface.ts @@ -17,6 +17,8 @@ export interface Attach { } export interface AttachService { + findByAtflCd: (atflCd: string) => Promise; deleteAttachmentByAtflId: (atflId: string) => Promise; downloadAttachmentByAtflCd: (atflCd: string) => Promise; + downloadAttachmentByAtflId: (atflId: string) => Promise; } diff --git a/apps/as/src/entities/attach/ui/attachment-download-link.tsx b/apps/as/src/entities/attach/ui/attachment-download-link.tsx new file mode 100644 index 000000000..85aab6ec5 --- /dev/null +++ b/apps/as/src/entities/attach/ui/attachment-download-link.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Paperclip } from "lucide-react"; + +import { cn } from "@common/components"; + +import { useDownloadAttachmentByAtflCd, useDownloadAttachmentByAtflId } from "../api/queries"; + +interface AttachmentDownloadLinkProps { + atflCd?: string; + atflId?: string; + fileName?: string; + className?: string; +} + +function AttachmentDownloadLink({ + atflCd, + atflId, + fileName, + className, +}: AttachmentDownloadLinkProps) { + const downloadByAtflCd = useDownloadAttachmentByAtflCd(); + const downloadByAtflId = useDownloadAttachmentByAtflId(); + const displayName = fileName || "첨부파일"; + const canDownload = Boolean(atflId || atflCd); + const isPending = atflId ? downloadByAtflId.isPending : downloadByAtflCd.isPending; + + function handleClick() { + if (atflId) { + downloadByAtflId.mutate(atflId); + return; + } + if (atflCd) downloadByAtflCd.mutate(atflCd); + } + + return ( + + ); +} + +export { AttachmentDownloadLink }; +export type { AttachmentDownloadLinkProps }; diff --git a/apps/as/src/entities/auth/api/auth-service.ts b/apps/as/src/entities/auth/api/auth-service.ts index 5615f55f8..d84f84776 100644 --- a/apps/as/src/entities/auth/api/auth-service.ts +++ b/apps/as/src/entities/auth/api/auth-service.ts @@ -1,8 +1,12 @@ import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config"; -import { type AuthPayload, type AuthService, getRemainingMs } from "@common/auth"; +import { + type AuthPayload, + type AuthService, + clearDeviceId, + getOrCreateDeviceId, +} from "@common/auth"; -import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id"; -import { clearRefreshTimer, setToken, setupRefreshTimer, tokenManager } from "../lib/token-manager"; +import { clearRefreshTimer, setToken, tokenManager } from "../lib/token-manager"; async function login(payload: AuthPayload): Promise { const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { @@ -26,10 +30,10 @@ async function login(payload: AuthPayload): Promise { setToken(CLAIM_NAME, accessToken); setToken(AUTH_USER_ID, tokenId); - const remainingMs = getRemainingMs(accessToken); - if (remainingMs > 0) { - setupRefreshTimer(silentRefresh, remainingMs); - } + // const remainingMs = getRemainingMs(accessToken); + // if (remainingMs > 0) { + // setupRefreshTimer(silentRefresh, remainingMs); + // } } async function silentRefresh(): Promise { @@ -58,10 +62,10 @@ async function silentRefresh(): Promise { setToken(CLAIM_NAME, accessToken); setToken(AUTH_USER_ID, tokenId); - const remainingMs = getRemainingMs(accessToken); - if (remainingMs > 0) { - setupRefreshTimer(silentRefresh, remainingMs); - } + // const remainingMs = getRemainingMs(accessToken); + // if (remainingMs > 0) { + // setupRefreshTimer(silentRefresh, remainingMs); + // } } catch (error) { console.error(`[silentRefresh] Failed at ${new Date().toISOString()}`, error); tokenManager.clearAuthToken(); @@ -72,6 +76,7 @@ async function silentRefresh(): Promise { } async function logout(): Promise { + await fetch("/api/auth/logout", { method: "POST" }); tokenManager.clearAuthToken(); clearDeviceId(); diff --git a/apps/as/src/entities/auth/lib/device-id.ts b/apps/as/src/entities/auth/lib/device-id.ts deleted file mode 100644 index ce173b141..000000000 --- a/apps/as/src/entities/auth/lib/device-id.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { v4 as uuidv4 } from "uuid"; - -const DEVICE_ID_KEY = "x-device-id"; - -export function getOrCreateDeviceId(): string { - if (typeof window === "undefined") return ""; - const existing = localStorage.getItem(DEVICE_ID_KEY); - if (existing) return existing; - const id = uuidv4(); - localStorage.setItem(DEVICE_ID_KEY, id); - return id; -} - -export function clearDeviceId(): void { - if (typeof window === "undefined") return; - localStorage.removeItem(DEVICE_ID_KEY); -} diff --git a/apps/as/src/entities/bns-if/api/bns-if-service.ts b/apps/as/src/entities/bns-if/api/bns-if-service.ts index 320a01efc..316ea041c 100644 --- a/apps/as/src/entities/bns-if/api/bns-if-service.ts +++ b/apps/as/src/entities/bns-if/api/bns-if-service.ts @@ -8,6 +8,8 @@ import type { BnsListResponseWrapperDto, BnsSearchDto, EmCodeListResponseDto, + FacilityListResponseWrapperDto, + FacilitySearchDto, } from "../model/bns-if-interface"; const BASE = ENDPOINT.AS_SERVICE.BNS_IF; @@ -40,8 +42,18 @@ async function getEmCodeList( return response.data; } +async function getFacilityList( + params: FacilitySearchDto, +): Promise { + const response = await client.get(`${BASE}/facilityList`, { + params, + }); + return response.data; +} + export const bnsIfService = { getBnsList, getBnsDetail, getEmCodeList, + getFacilityList, } satisfies BnsIfService; diff --git a/apps/as/src/entities/bns-if/api/queries.ts b/apps/as/src/entities/bns-if/api/queries.ts index 7ab0b46a3..8edf93b0c 100644 --- a/apps/as/src/entities/bns-if/api/queries.ts +++ b/apps/as/src/entities/bns-if/api/queries.ts @@ -2,7 +2,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { ENDPOINT } from "@/shared/config"; -import type { BnsSearchDto } from "../model/bns-if-interface"; +import type { BnsSearchDto, FacilitySearchDto } from "../model/bns-if-interface"; import { bnsIfService } from "./bns-if-service"; @@ -31,3 +31,12 @@ export function useEmCodeList(dpmCd?: string, lclgvrnmntCd?: string) { queryFn: () => bnsIfService.getEmCodeList(dpmCd, lclgvrnmntCd), }); } + +export function useFacilityList(params: FacilitySearchDto, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: [...bnsIfBaseKey, "getFacilityList", params], + queryFn: () => bnsIfService.getFacilityList(params), + enabled: options?.enabled, + placeholderData: keepPreviousData, + }); +} diff --git a/apps/as/src/entities/bns-if/index.ts b/apps/as/src/entities/bns-if/index.ts index 430f4a611..d01b27189 100644 --- a/apps/as/src/entities/bns-if/index.ts +++ b/apps/as/src/entities/bns-if/index.ts @@ -1,5 +1,5 @@ export { bnsIfService } from "./api/bns-if-service"; -export { useBnsDetail, useBnsList, useEmCodeList } from "./api/queries"; +export { useBnsDetail, useBnsList, useEmCodeList, useFacilityList } from "./api/queries"; export { buildBnsSearchDto } from "./lib/build-bns-search-dto"; export type { BnsDetailResponseDto, @@ -10,4 +10,7 @@ export type { BnsSearchDto, EmCodeItemDto, EmCodeListResponseDto, + FacilityListResponseWrapperDto, + FacilityResponseDto, + FacilitySearchDto, } from "./model/bns-if-interface"; diff --git a/apps/as/src/entities/bns-if/model/bns-if-interface.ts b/apps/as/src/entities/bns-if/model/bns-if-interface.ts index e13ab8500..833accca8 100644 --- a/apps/as/src/entities/bns-if/model/bns-if-interface.ts +++ b/apps/as/src/entities/bns-if/model/bns-if-interface.ts @@ -117,9 +117,89 @@ export interface EmCodeListResponseDto { ddtlList: EmCodeItemDto[]; } +/** 백엔드 FacilitySearchDto */ +export interface FacilitySearchDto { + sido?: string; + gugun?: string; +} + +/** 백엔드 FacilityResponseDto */ +export interface FacilityResponseDto { + bscFctCd?: string; + bscFctNm?: string; + addr?: string; + fctCpc?: string; + fctCpc1?: string; + fctCpc2?: string; + fctCpc3?: string; + totInflAmntAve?: unknown; + pInflAmntAve?: unknown; + oInflAmntAve?: unknown; + aInflAmntAve?: unknown; + totTputAve?: unknown; + pTputAve?: unknown; + oTputAve?: unknown; + aTputAve?: unknown; + inflAssctBfrWqltBod?: unknown; + efflWqltBod?: unknown; + inflAssctBfrWqltCod?: unknown; + efflWqltCod?: unknown; + inflAssctBfrWqltSs?: unknown; + efflWqltSs?: unknown; + inflAssctBfrWqltTn?: unknown; + efflWqltTn?: unknown; + inflAssctBfrWqltTp?: unknown; + efflWqltTp?: unknown; + inflAssctBfrWqltClfgrpcnt?: unknown; + efflWqltClfgrpcnt?: unknown; + tmEfficiency?: unknown; + pcLoad?: unknown; + trtFrfHanNm?: string; + applctNtcheNm?: string; + excretionsTot?: unknown; + stkrsngWstwtrTot?: unknown; + lchtTot?: unknown; + etcTot?: unknown; + tel?: string; + rnDt?: string; + compDt?: string; + tot?: unknown; + oprClsNm?: string; + trstOprEtpNm?: string; + yrstpTrstCst?: string; + trstOprDt?: string; + trstAfr?: string; + dsfctNm?: string; + rvNm?: string; + mnstr?: string; + wbsnNm?: string; + efflWqltStndrdNm?: string; + elcpwAmnt?: unknown; + toeRt?: unknown; + toeSum?: unknown; + co2Emissions?: unknown; + toeQnty?: unknown; + toeRt2?: unknown; + useQnty?: unknown; + eqpPrmsDt?: string; + mdmbsn?: string; + lgbsn?: string; + lgbsnBod?: string; + cntrTrtFctNm?: string; + totWtrQrm?: unknown; + inflAssctBfrWqltToc?: unknown; + efflWqltToc?: unknown; +} + +/** 백엔드 FacilityListResponseWrapperDto */ +export interface FacilityListResponseWrapperDto { + fctList: FacilityResponseDto[]; +} + /** Controller public 메서드명·시그니처와 동일 */ export interface BnsIfService { getBnsList(params: BnsSearchDto): Promise; getBnsDetail(gbn: string, yr: string, seqNo: string): Promise; getEmCodeList(dpmCd?: string, lclgvrnmntCd?: string): Promise; + getFacilityList(params: FacilitySearchDto): Promise; } diff --git a/apps/as/src/entities/board/api/post-queries.ts b/apps/as/src/entities/board/api/post-queries.ts new file mode 100644 index 000000000..4cf801e0a --- /dev/null +++ b/apps/as/src/entities/board/api/post-queries.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { UserPostSearchParams } from "../model/post-interface"; + +import { postApi } from "./post-service"; + +export function useUserPostList(params?: UserPostSearchParams) { + return useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, params], + queryFn: () => postApi.getUserPostList(params), + refetchOnWindowFocus: false, + }); +} diff --git a/apps/as/src/entities/board/api/post-service.ts b/apps/as/src/entities/board/api/post-service.ts new file mode 100644 index 000000000..9cd25f976 --- /dev/null +++ b/apps/as/src/entities/board/api/post-service.ts @@ -0,0 +1,16 @@ +import { client } from "@/shared/api"; +import { AS_DASHBOARD_BBS_ID, ENDPOINT } from "@/shared/config"; + +import { PostService, UserPostListResponse, UserPostSearchParams } from "../model/post-interface"; + +async function getUserPostList(params?: UserPostSearchParams): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.POSTS}/user/${AS_DASHBOARD_BBS_ID}`, + { params }, + ); + return response.data; +} + +export const postApi: PostService = { + getUserPostList, +}; diff --git a/apps/as/src/entities/board/index.ts b/apps/as/src/entities/board/index.ts new file mode 100644 index 000000000..2bec23972 --- /dev/null +++ b/apps/as/src/entities/board/index.ts @@ -0,0 +1,8 @@ +export { useUserPostList } from "./api/post-queries"; +export { postApi } from "./api/post-service"; +export type { + PostService, + UserPost, + UserPostListResponse, + UserPostSearchParams, +} from "./model/post-interface"; diff --git a/apps/as/src/entities/board/model/post-interface.ts b/apps/as/src/entities/board/model/post-interface.ts new file mode 100644 index 000000000..31eca4e64 --- /dev/null +++ b/apps/as/src/entities/board/model/post-interface.ts @@ -0,0 +1,40 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +export interface UserPostSearchParams extends PageableParams { + keywordType?: string; + keyword?: string; + popupYn?: string; + upndFixYn?: string; + prdPstgYn?: string; + siteId?: string; + bbsTypeCd?: string; + ansUseYn?: string; +} + +export interface UserPost { + rowNo: number; + bbsId: number; + pstNo: number; + pstTtl: string; + pstCn: string; + inqNocs: number; + rfrncUrl: string; + popupYn: string; + upndFixYn: string; + prdPstgYn: string; + rootPath: string; + pstPath: string; + ansPstn: number; + wrtrNm: string; + wrtrCplc: string; + atflCd: string; + regDt: string; + rgtrNm: string; + cmntCnt: number; +} + +export type UserPostListResponse = PageResponse; + +export interface PostService { + getUserPostList(params?: UserPostSearchParams): Promise; +} diff --git a/apps/as/src/entities/code/ui/code-select.tsx b/apps/as/src/entities/code/ui/code-select.tsx index e803e9285..74d011bcd 100644 --- a/apps/as/src/entities/code/ui/code-select.tsx +++ b/apps/as/src/entities/code/ui/code-select.tsx @@ -18,13 +18,7 @@ interface CodeSelectProps extends React.ComponentPropsWithoutRef className?: string; } -function CodeSelect({ - upCd, - useYn, - placeholder = "선택", - className, - ...props -}: CodeSelectProps) { +function CodeSelect({ upCd, useYn, placeholder = "선택", className, ...props }: CodeSelectProps) { const listQuery = useCodeList(useYn ? undefined : upCd); const listByUseYnQuery = useCodeListByUseYn(useYn ? upCd : undefined, useYn ?? ""); @@ -34,7 +28,7 @@ function CodeSelect({ return ( - onChange({ atrzSeComCd: v, atrzSeComNm: findCodeLabel(selectableAtrzSeCodes, v) }) + onChange({ + atrzSeComCd: v, + atrzSeComNm: findCodeLabel(selectableAtrzSeCodes, v), + aprvrUserId: "", + aprvrUuid: "", + aprvrNm: "", + }) } > diff --git a/apps/as/src/pages/dashboard/index.ts b/apps/as/src/pages/dashboard/index.ts new file mode 100644 index 000000000..07f537f63 --- /dev/null +++ b/apps/as/src/pages/dashboard/index.ts @@ -0,0 +1 @@ +export { DashboardPage as MainPage } from "./ui/dashboard-page"; diff --git a/apps/as/src/pages/dashboard/lib/national-as-map.module.css b/apps/as/src/pages/dashboard/lib/national-as-map.module.css new file mode 100644 index 000000000..459855e88 --- /dev/null +++ b/apps/as/src/pages/dashboard/lib/national-as-map.module.css @@ -0,0 +1,153 @@ +.mapArea { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.bgLayer { + position: absolute; + inset: var(--map-inset); + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + width: calc(100% - var(--map-inset) * 2); + height: calc(100% - var(--map-inset) * 2); + background-image: url("/images/region_map_bg.png"); + background-repeat: no-repeat; + background-size: contain; + background-position: center center; + image-rendering: -webkit-optimize-contrast; + image-rendering: crisp-edges; + image-rendering: pixelated; + opacity: 0.7; + filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.02)); + pointer-events: none; + min-height: 200px; +} + +.svgLayer { + position: absolute; + inset: var(--map-inset); + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + width: calc(100% - var(--map-inset) * 2); + height: calc(100% - var(--map-inset) * 2); +} + +.svgLayer > svg { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + image-rendering: -webkit-optimize-contrast; + image-rendering: crisp-edges; + shape-rendering: geometricPrecision; +} + +.svgContainer :global(svg) :global(path) { + fill: rgba(0, 150, 255, 0.2) !important; + stroke: #0066cc; + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.svgContainer :global(svg) :global(.cls-1) { + fill: rgba(0, 150, 255, 0.2) !important; + stroke: #0066cc; + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.svgContainer :global(svg) :global(.basin) { + opacity: 0; + pointer-events: all; + transition: opacity 0.15s ease; + cursor: pointer; +} + +.svgContainer :global(svg) :global(.basin.active), +.svgContainer :global(svg) :global(.basin:hover) { + opacity: 1; +} + +.svgContainer :global(svg) :global(.basin) :global(path), +.svgContainer :global(svg) :global(.basin) :global(polygon), +.svgContainer :global(svg) :global(.basin) :global(polyline) { + fill: rgba(0, 209, 209, 0.5); + stroke: #399ac3; + stroke-width: 1.3; + vector-effect: non-scaling-stroke; +} + +.popup { + position: absolute; + width: 300px; + height: 150px; + min-width: 220px; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 10px; + box-shadow: + 0 12px 24px rgba(0, 0, 0, 0.18), + 0 2px 0 rgba(0, 0, 0, 0.05); + opacity: 0; + visibility: hidden; + transform: translate(-50%, -8px) scale(0.98); + transition: + opacity 0.12s, + visibility 0.12s, + transform 0.12s; + pointer-events: none; + z-index: 10; +} + +.popup.show { + opacity: 1 !important; + visibility: visible !important; + transform: translate(-50%, -12px) scale(1) !important; +} + +.show { + opacity: 1; + visibility: visible; +} + +.labelsLayer { + position: absolute; + inset: var(--map-inset); + pointer-events: none; + z-index: 3; + width: 100%; + height: 100%; +} + +.basinLabel { + position: absolute; + transform: translate(-50%, -50%); + pointer-events: none; + padding: 6px 12px; + border-radius: 6px; + font-weight: 700; + font-size: 16px; + background: #ffffffee; + border: 2px solid #d0d0d0; + white-space: nowrap; + min-width: 60px; + text-align: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.basinLabel.active { + background: #006bf8; + color: #fff; + border-color: #96c3ff; + box-shadow: 0 4px 12px rgba(0, 107, 248, 0.3); +} diff --git a/apps/as/src/pages/dashboard/lib/use-national-as-map.ts b/apps/as/src/pages/dashboard/lib/use-national-as-map.ts new file mode 100644 index 000000000..997c0154a --- /dev/null +++ b/apps/as/src/pages/dashboard/lib/use-national-as-map.ts @@ -0,0 +1,310 @@ +import { type CSSProperties, useCallback, useEffect, useRef, useState } from "react"; + +import { + BASIN_DATA, + BASIN_IDS, + type BasinKey, + MANUAL_LABEL_POS, + type ManualLabelPos, +} from "../model/map-data"; + +import styles from "./national-as-map.module.css"; + +const SVG_URL = "/images/region_map.svg"; +const POPUP_EDGE_PADDING_PX = 12; +const LABEL_OFFSET_Y_PX = 18; +const DEFAULT_VIEWBOX = "0 0 100 100"; + +export const PLACEHOLDER_POPUP_ITEMS = [{ header: "현황", data: "-" }] as const; + +export interface MapPopupItem { + header: string; + data: string; +} + +export interface MapPopupContent { + title: string; + items: MapPopupItem[]; +} + +const INITIAL_POPUP_CONTENT: MapPopupContent = { + title: "", + items: [...PLACEHOLDER_POPUP_ITEMS], +}; + +function isBasinKey(key: string | undefined): key is BasinKey { + return Boolean(key && key in BASIN_DATA); +} + +function resolveBasinName(group: SVGGElement) { + const key = group.dataset.key; + if (isBasinKey(key)) return BASIN_DATA[key].name; + return group.dataset.label ?? ""; +} + +function hidePopup(popup: HTMLDivElement | null) { + if (!popup) return; + if (styles.show) popup.classList.remove(styles.show); + popup.style.opacity = "0"; + popup.style.visibility = "hidden"; +} + +function showPopupAtGroup(stage: HTMLDivElement, popup: HTMLDivElement, group: SVGGElement) { + const stageRect = stage.getBoundingClientRect(); + const groupRect = group.getBoundingClientRect(); + const centerX = (groupRect.left + groupRect.right) / 2 - stageRect.left; + const topY = groupRect.top - stageRect.top; + + popup.style.left = `${centerX}px`; + popup.style.top = `${topY + 6}px`; + popup.style.opacity = "1"; + popup.style.visibility = "visible"; + if (styles.show) popup.classList.add(styles.show); + + const popupRect = popup.getBoundingClientRect(); + const clampedX = Math.max( + POPUP_EDGE_PADDING_PX + popupRect.width / 2, + Math.min(centerX, stageRect.width - POPUP_EDGE_PADDING_PX - popupRect.width / 2), + ); + popup.style.left = `${clampedX}px`; +} + +function collectBasinGroups(svg: SVGSVGElement) { + const groups: SVGGElement[] = []; + + BASIN_IDS.forEach((item) => { + let group: SVGGElement | null = null; + for (const id of item.labels) { + group = svg.getElementById(id) as SVGGElement | null; + if (group) break; + } + if (!group) return; + + group.classList.add("basin"); + group.dataset.key = item.key; + group.dataset.label = item.labels[0] ?? ""; + + group.querySelectorAll("path").forEach((path) => { + path.style.fill = "rgba(0, 150, 255, 0.2)"; + path.style.stroke = "#0066cc"; + path.style.strokeWidth = "1"; + path.style.vectorEffect = "non-scaling-stroke"; + }); + + groups.push(group); + }); + + return groups; +} + +function createLabels(labelsLayer: HTMLDivElement, groups: SVGGElement[]) { + labelsLayer.innerHTML = ""; + return groups.map((group) => { + const label = document.createElement("div"); + label.className = styles.basinLabel ?? ""; + label.textContent = group.dataset.label ?? ""; + labelsLayer.appendChild(label); + return label; + }); +} + +function applyViewBox(svg: SVGSVGElement, stage: HTMLDivElement) { + if (!svg.hasAttribute("viewBox")) { + const bbox = svg.getBBox(); + svg.setAttribute("viewBox", [bbox.x, bbox.y, bbox.width, bbox.height].join(" ")); + } + + const viewBox = (svg.getAttribute("viewBox") || "").split(/\s+/).map(Number); + if (viewBox.length === 4 && viewBox[3]! > 0) + stage.style.aspectRatio = String(viewBox[2]! / viewBox[3]!); + + svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + svg.style.width = "100%"; + svg.style.height = "100%"; +} + +function positionLabels( + stage: HTMLDivElement, + svg: SVGSVGElement, + groups: SVGGElement[], + labels: HTMLDivElement[], +) { + const stageRect = stage.getBoundingClientRect(); + const inset = parseFloat(getComputedStyle(stage).getPropertyValue("--map-inset") || "0") / 100; + const left = stageRect.width * inset; + const top = stageRect.height * inset; + const width = stageRect.width * (1 - inset * 2); + const height = stageRect.height * (1 - inset * 2); + const [viewX, viewY, viewWidth, viewHeight] = (svg.getAttribute("viewBox") || DEFAULT_VIEWBOX) + .split(/\s+/) + .map(Number); + + const svgRect = svg.getBoundingClientRect(); + const offsetX = svgRect.left - stageRect.left - left; + const offsetY = svgRect.top - stageRect.top - top; + + groups.forEach((group, index) => { + const key = group.dataset.key; + const manual = key ? (MANUAL_LABEL_POS as Record)[key] : undefined; + let posX: number; + let posY: number; + + if (manual && (("x" in manual && "y" in manual) || ("px" in manual && "py" in manual))) { + if ("px" in manual) { + posX = left + (manual.px || 0) + offsetX; + posY = top + (manual.py || 0) + offsetY; + } else { + posX = left + width * (manual.x || 0) + offsetX; + posY = top + height * (manual.y || 0) + offsetY; + } + } else { + const bbox = group.getBBox(); + const centerX = bbox.x + bbox.width / 2; + const centerY = bbox.y + bbox.height / 2; + posX = ((centerX - viewX!) / viewWidth!) * width + left + offsetX; + posY = ((centerY - viewY!) / viewHeight!) * height + top + offsetY; + } + + const label = labels[index]; + if (!label) return; + label.style.left = `${posX}px`; + label.style.top = `${posY + LABEL_OFFSET_Y_PX}px`; + }); +} + +export function useNationalAsMap() { + const stageRef = useRef(null); + const svgLayerRef = useRef(null); + const labelsLayerRef = useRef(null); + const popupRef = useRef(null); + const [popupContent, setPopupContent] = useState(INITIAL_POPUP_CONTENT); + + const showFor = useCallback((group: SVGGElement) => { + const stage = stageRef.current; + const popup = popupRef.current; + if (!stage || !popup) return; + + setPopupContent({ + title: resolveBasinName(group), + items: [...PLACEHOLDER_POPUP_ITEMS], + }); + showPopupAtGroup(stage, popup, group); + }, []); + + useEffect(() => { + const abortController = new AbortController(); + let svg: SVGSVGElement | null = null; + let groups: SVGGElement[] = []; + let labels: HTMLDivElement[] = []; + let resizeObserver: ResizeObserver | null = null; + let positionLabelsHandler: (() => void) | null = null; + let clearActive: (() => void) | null = null; + let handleStageClick: (() => void) | null = null; + + const listeners: Array<{ + target: EventTarget; + type: string; + listener: EventListener; + }> = []; + + function addListener(target: EventTarget, type: string, listener: EventListener) { + target.addEventListener(type, listener); + listeners.push({ target, type, listener }); + } + + fetch(SVG_URL, { signal: abortController.signal }) + .then((response) => { + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + return response.text(); + }) + .then((svgText) => { + if (abortController.signal.aborted) return; + if (!svgLayerRef.current || !stageRef.current) return; + + svgLayerRef.current.innerHTML = svgText; + svg = svgLayerRef.current.querySelector("svg"); + if (!svg) throw new Error("SVG element not found in loaded content"); + + applyViewBox(svg, stageRef.current); + groups = collectBasinGroups(svg); + labels = labelsLayerRef.current ? createLabels(labelsLayerRef.current, groups) : []; + + clearActive = () => { + groups.forEach((group) => group.classList.remove("active")); + hidePopup(popupRef.current); + labels.forEach((label) => label.classList.remove("active")); + }; + + const revealGroup = (group: SVGGElement) => { + showFor(group); + const index = groups.indexOf(group); + labels.forEach((label) => label.classList.remove("active")); + if (index >= 0) labels[index]?.classList.add("active"); + }; + + groups.forEach((group) => { + addListener(group, "mouseenter", () => { + clearActive?.(); + group.classList.add("active"); + revealGroup(group); + }); + addListener(group, "mousemove", () => { + if (group.classList.contains("active")) revealGroup(group); + }); + addListener(group, "mouseleave", () => clearActive?.()); + addListener(group, "click", (event) => { + event.stopPropagation(); + clearActive?.(); + group.classList.add("active"); + revealGroup(group); + }); + }); + + handleStageClick = () => clearActive?.(); + addListener(stageRef.current, "mouseleave", () => clearActive?.()); + addListener(stageRef.current, "click", () => handleStageClick?.()); + + positionLabelsHandler = () => { + if (!stageRef.current || !svg) return; + positionLabels(stageRef.current, svg, groups, labels); + }; + positionLabelsHandler(); + window.addEventListener("resize", positionLabelsHandler); + resizeObserver = new ResizeObserver(positionLabelsHandler); + resizeObserver.observe(stageRef.current); + }) + .catch((error: unknown) => { + if (abortController.signal.aborted) return; + const message = error instanceof Error ? error.message : "알 수 없는 오류"; + if (svgLayerRef.current) { + svgLayerRef.current.innerHTML = `
지도를 불러올 수 없습니다. (${message})
`; + } + }); + + return () => { + abortController.abort(); + listeners.forEach(({ target, type, listener }) => { + target.removeEventListener(type, listener); + }); + if (positionLabelsHandler) window.removeEventListener("resize", positionLabelsHandler); + resizeObserver?.disconnect(); + if (svgLayerRef.current) svgLayerRef.current.innerHTML = ""; + if (labelsLayerRef.current) labelsLayerRef.current.innerHTML = ""; + }; + }, [showFor]); + + const popupGridStyle = { + minWidth: 160, + display: "grid", + gridTemplateColumns: `repeat(${Math.max(1, popupContent.items.length)}, 1fr)`, + } satisfies CSSProperties; + + return { + stageRef, + svgLayerRef, + labelsLayerRef, + popupRef, + popupContent, + popupGridStyle, + }; +} diff --git a/apps/as/src/pages/dashboard/model/dashboard-notice-table-columns.tsx b/apps/as/src/pages/dashboard/model/dashboard-notice-table-columns.tsx new file mode 100644 index 000000000..78bd1623c --- /dev/null +++ b/apps/as/src/pages/dashboard/model/dashboard-notice-table-columns.tsx @@ -0,0 +1,39 @@ +import { createColumnHelper } from "@tanstack/react-table"; + +import { dayjs } from "@/shared/lib"; +import type { UserPost } from "@/entities/board"; +import { Badge } from "@common/components"; + +const columnHelper = createColumnHelper(); + +export const dashboardNoticeColumns = [ + columnHelper.display({ + id: "no", + header: "번호", + meta: { align: "center" }, + cell: ({ row }) => { + if (row.original.upndFixYn === "Y") { + return 공지; + } + return row.original.rowNo; + }, + }), + columnHelper.accessor("pstTtl", { + header: "제목", + meta: { align: "left" }, + cell: ({ getValue }) =>
{getValue()}
, + }), + columnHelper.accessor("wrtrNm", { + header: "작성자", + meta: { align: "center" }, + }), + columnHelper.accessor("regDt", { + header: "작성일", + meta: { align: "center" }, + cell: ({ getValue }) => (getValue() ? dayjs(getValue()).format("YYYY-MM-DD") : "-"), + }), + columnHelper.accessor("inqNocs", { + header: "조회수", + meta: { align: "center" }, + }), +]; diff --git a/apps/as/src/pages/dashboard/model/map-data.ts b/apps/as/src/pages/dashboard/model/map-data.ts new file mode 100644 index 000000000..18f703161 --- /dev/null +++ b/apps/as/src/pages/dashboard/model/map-data.ts @@ -0,0 +1,29 @@ +export const MANUAL_LABEL_POS = { + han: { x: 0.52, y: 0.14 }, + geum: { x: 0.28, y: 0.47 }, + nakdong: { x: 0.64, y: 0.47 }, + yeongsan: { x: 0.18, y: 0.77 }, +} as const; + +export const BASIN_DATA = { + han: { name: "한강" }, + geum: { name: "금강" }, + nakdong: { name: "낙동강" }, + yeongsan: { name: "영산강" }, +} as const; + +export const BASIN_IDS = [ + { key: "han", labels: ["한강", "han"] }, + { key: "geum", labels: ["금강", "geum"] }, + { key: "nakdong", labels: ["낙동강", "nakdong"] }, + { key: "yeongsan", labels: ["영산강", "yeongsan"] }, +] as const; + +export type BasinKey = keyof typeof BASIN_DATA; + +export interface ManualLabelPos { + x?: number; + y?: number; + px?: number; + py?: number; +} diff --git a/apps/as/src/pages/dashboard/ui/dashboard-noti-list.tsx b/apps/as/src/pages/dashboard/ui/dashboard-noti-list.tsx new file mode 100644 index 000000000..20c7c2ae1 --- /dev/null +++ b/apps/as/src/pages/dashboard/ui/dashboard-noti-list.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useCallback } from "react"; +import { useRouter } from "next/navigation"; + +import { dayjs } from "@/shared/lib"; +import { NoData } from "@/shared/ui/no-data"; +import type { NotiAsStatResponseDto, NotiAsUserResponseDto } from "@/entities/dashboard"; +import { useReadNoti } from "@/entities/noti"; +import { Badge, Button, Skeleton } from "@common/components"; + +interface DashboardNotiListProps { + notiList: NotiAsUserResponseDto[] | undefined; + notiStats: NotiAsStatResponseDto | undefined; + isPending: boolean; + isError: boolean; + dataUpdatedAt: number; +} + +export function DashboardNotiList({ + notiList, + notiStats, + isPending, + isError, + dataUpdatedAt, +}: DashboardNotiListProps) { + const router = useRouter(); + const { mutateAsync: readNoti, isPending: isReadPending } = useReadNoti(); + + const handleAction = useCallback( + async (row: NotiAsUserResponseDto) => { + if (row.notReadYn === "N") await readNoti(row.notiId); + + const notiUrl = resolveNotiUrl(row); + if (!notiUrl) return; + + router.push(notiUrl); + }, + [readNoti, router], + ); + + return ( +
+
+

+ 알림이력 + {dataUpdatedAt > 0 ? ( + + (업데이트 일시: {dayjs(dataUpdatedAt).format("YYYY-MM-DD HH:mm")}) + + ) : null} +

+
+ +
+
+ +
+
+ {NOTI_STAT_CHIPS.map((chip) => ( + + {chip.label} {isPending ? "-" : (notiStats?.[chip.field] ?? 0)}건 + + ))} +
+ + {isPending ? ( +
+ + + +
+ ) : null} + + {isError ? ( +

+ 알림이력을 불러오지 못했습니다. +

+ ) : null} + + {!isPending && !isError && (notiList?.length ?? 0) === 0 ? : null} + +
+ {!isPending && !isError && notiList && notiList.length > 0 + ? notiList.map((row) => ( + { + void handleAction(row); + }} + /> + )) + : null} +
+
+
+ ); +} + +interface DashboardNotiRowProps { + row: NotiAsUserResponseDto; + isActionPending: boolean; + onAction: () => void; +} + +function DashboardNotiRow({ row, isActionPending, onAction }: DashboardNotiRowProps) { + return ( +
+ + {row.regDt ? dayjs(row.regDt).format("YYYY-MM-DD") : "-"} + +

{row.notiTtl}

+ +
+ ); +} + +function resolveNotiUrl(row: NotiAsUserResponseDto) { + const notiUrl = row.notiUrl?.trim(); + if (!notiUrl) return null; + + if (row.docSeComCd === "SEWER") { + const parts = notiUrl.split("/"); + if (parts.length > 1) { + parts.pop(); + return parts.join("/"); + } + } + + return notiUrl; +} + +const NOTI_STAT_CHIPS = [ + { field: "todayCount", label: "오늘", color: "green" }, + { field: "delayCount", label: "지연", color: "orange" }, + { field: "pendingCount", label: "완료대기", color: "gray" }, +] as const; diff --git a/apps/as/src/pages/dashboard/ui/dashboard-notice-table.tsx b/apps/as/src/pages/dashboard/ui/dashboard-notice-table.tsx new file mode 100644 index 000000000..aa766a30f --- /dev/null +++ b/apps/as/src/pages/dashboard/ui/dashboard-notice-table.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { getCoreRowModel } from "@tanstack/react-table"; + +import { AS_DASHBOARD_BBS_ID } from "@/shared/config"; +import { useDataTable } from "@/shared/lib"; +import { type UserPost, type UserPostSearchParams, useUserPostList } from "@/entities/board"; +import { Button } from "@common/components"; + +import { dashboardNoticeColumns } from "../model/dashboard-notice-table-columns"; + +const DASHBOARD_NOTICE_LIST_PARAMS: UserPostSearchParams = { + page: 0, + size: 3, + keywordType: "bbsTypeCd", + keyword: "BBSNOTICE", +}; + +export function DashboardNoticeTable() { + const router = useRouter(); + const userPostListQuery = useUserPostList(DASHBOARD_NOTICE_LIST_PARAMS); + + const handleRowClick = (post: UserPost) => { + if (!AS_DASHBOARD_BBS_ID) return; + router.push(`/cms/bbsnotice/${AS_DASHBOARD_BBS_ID}/${post.pstNo}`); + }; + + const handleMoreClick = () => { + if (!AS_DASHBOARD_BBS_ID) return; + router.push(`/cms/bbsnotice/${AS_DASHBOARD_BBS_ID}`); + }; + + const { tableComponent } = useDataTable({ + query: userPostListQuery, + tableOptions: { + columns: dashboardNoticeColumns, + getCoreRowModel: getCoreRowModel(), + }, + dataTableOptions: { + variant: "line", + onRowClick: handleRowClick, + }, + }); + + return ( +
+
+

공지사항

+ +
+ {tableComponent} +
+ ); +} diff --git a/apps/as/src/pages/dashboard/ui/dashboard-page.tsx b/apps/as/src/pages/dashboard/ui/dashboard-page.tsx new file mode 100644 index 000000000..dc904c38e --- /dev/null +++ b/apps/as/src/pages/dashboard/ui/dashboard-page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useGetMainDashboard } from "@/entities/dashboard"; + +import { DashboardNotiList } from "./dashboard-noti-list"; +import { DashboardNoticeTable } from "./dashboard-notice-table"; +import { DocPrgrsSttsCards } from "./doc-prgrs-stts-cards"; +import { NationalAsMap } from "./national-as-map"; + +export function DashboardPage() { + const { data, isPending, isError, dataUpdatedAt } = useGetMainDashboard(); + + return ( +
+
+ +
+
+ + + +
+
+ ); +} diff --git a/apps/as/src/pages/dashboard/ui/doc-prgrs-stts-cards.tsx b/apps/as/src/pages/dashboard/ui/doc-prgrs-stts-cards.tsx new file mode 100644 index 000000000..e355b0585 --- /dev/null +++ b/apps/as/src/pages/dashboard/ui/doc-prgrs-stts-cards.tsx @@ -0,0 +1,128 @@ +import Link from "next/link"; + +import { CircleCheck, FilePlus, type LucideIcon, RefreshCw } from "lucide-react"; + +import type { DocPrgrsSttsResponseDto } from "@/entities/dashboard"; +import { cn, Skeleton } from "@common/components"; + +interface DocPrgrsSttsCardsProps { + items: DocPrgrsSttsResponseDto[] | undefined; + isPending: boolean; + isError: boolean; +} + +export function DocPrgrsSttsCards({ items, isPending, isError }: DocPrgrsSttsCardsProps) { + if (isPending) { + return ( +
+ {DOC_PRGRS_STTS_SLOTS.map((slot) => ( + + ))} +
+ ); + } + + if (isError) { + return ( +
+ 진행상황을 불러오지 못했습니다. +
+ ); + } + + const itemByCd = new Map(items?.map((item) => [item.docSeComCd, item])); + + return ( +
+ {DOC_PRGRS_STTS_SLOTS.map((slot) => { + const item = itemByCd.get(slot.docSeComCd); + return ( +
+
+

{slot.title}

+
+ +
+ {STAT_METRICS.map((metric) => ( + + ))} +
+ +
+ ); + })} +
+ ); +} + +interface DocPrgrsSttsMetricProps { + label: string; + count: number; + iconClassName: string; + bgColor: string; + Icon: LucideIcon; +} + +function DocPrgrsSttsMetric({ + label, + count, + iconClassName, + bgColor, + Icon, +}: DocPrgrsSttsMetricProps) { + return ( +
+ + + + {label} + {count} +
+ ); +} + +const DOC_PRGRS_STTS_SLOTS = [ + { docSeComCd: "FNRS", title: "재원협의 진행상황", href: "/fnrs-cnsltn" }, + { docSeComCd: "INSTL", title: "설치인가 진행상황", href: "/instl-sncn" }, + { docSeComCd: "CNFM", title: "설계자문 진행상황", href: "/design-cnstn" }, + { docSeComCd: "SEWER", title: "기본계획 진행상황", href: "/sewer-bsc-plan" }, +] as const; + +const STAT_METRICS = [ + { + field: "reqCnt", + label: "신청", + iconClassName: "bg-blue-500", + bgColor: "bg-blue-100", + Icon: FilePlus, + }, + { + field: "prgrsCnt", + label: "진행", + iconClassName: "bg-emerald-500", + bgColor: "bg-emerald-100", + Icon: RefreshCw, + }, + { + field: "cmptnCnt", + label: "완료", + iconClassName: "bg-orange-500", + bgColor: "bg-orange-100", + Icon: CircleCheck, + }, +] as const; diff --git a/apps/as/src/pages/dashboard/ui/national-as-map.tsx b/apps/as/src/pages/dashboard/ui/national-as-map.tsx new file mode 100644 index 000000000..096bbed89 --- /dev/null +++ b/apps/as/src/pages/dashboard/ui/national-as-map.tsx @@ -0,0 +1,85 @@ +"use client"; + +import type { CSSProperties } from "react"; + +import { useNationalAsMap } from "../lib/use-national-as-map"; + +import styles from "../lib/national-as-map.module.css"; + +export function NationalAsMap() { + const { stageRef, svgLayerRef, labelsLayerRef, popupRef, popupContent, popupGridStyle } = + useNationalAsMap(); + const popupColumnSpan = Math.max(1, popupContent.items.length); + + return ( +
+
+

전국 행정지원 지도

+
+
+
+
+
+
+
+
+
+ {popupContent.title} +
+ {popupContent.items.map((item, index) => ( +
+ {item.header} +
+ ))} + {popupContent.items.map((item, index) => ( +
+ {item.data} +
+ ))} +
+
+
+
+
+ ); +} diff --git a/apps/as/src/pages/design-cnstn/index.ts b/apps/as/src/pages/design-cnstn/index.ts new file mode 100644 index 000000000..8e1323aae --- /dev/null +++ b/apps/as/src/pages/design-cnstn/index.ts @@ -0,0 +1,6 @@ +export { DesignDetailGate } from "./ui/design-detail-gate"; +export { DesignEditPage } from "./ui/design-edit-page"; +export { DesignFormPage } from "./ui/design-form-page"; +export { DesignListPage } from "./ui/design-list-page"; +export { DesignRegistPage } from "./ui/design-regist-page"; +export { DesignViewPage } from "./ui/design-view-page"; diff --git a/apps/as/src/pages/design-cnstn/lib/button-config.ts b/apps/as/src/pages/design-cnstn/lib/button-config.ts new file mode 100644 index 000000000..2727092f2 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/lib/button-config.ts @@ -0,0 +1,118 @@ +export const DESIGN_CNSTN_VIEW_BUTTON_ACTION = { + strg: "strg", + aply: "aply", + del: "del", + rqstRvw: "rqstRvw", + rvwStrg: "rvwStrg", + rvwCmptn: "rvwCmptn", + atrzAprv: "atrzAprv", + atrzRply: "atrzRply", +} as const; + +export type DesignCnstnViewButtonAction = + (typeof DESIGN_CNSTN_VIEW_BUTTON_ACTION)[keyof typeof DESIGN_CNSTN_VIEW_BUTTON_ACTION]; + +const ROLE_STATUS_BUTTON_ACTIONS: Record> = { + ROLE_USER05: { + "": [ + DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg, + DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply, + DESIGN_CNSTN_VIEW_BUTTON_ACTION.del, + ], + CV: [ + DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg, + DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply, + DESIGN_CNSTN_VIEW_BUTTON_ACTION.del, + ], + }, +}; + +const ROLE_USER01_ATRZ_STATUS_CODES = ["CT", "CS"] as const; + +const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record< + string, + Record +> = { + CT: { + ATRZ01: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.rqstRvw], + }, + CS: { + ATRZ02: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwStrg, DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwCmptn], + ATRZ03: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzAprv], + ATRZ99: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzRply], + }, +}; + +function resolveButtonConfigs( + actions: DesignCnstnViewButtonAction[], +): DesignCnstnViewButtonAction[] { + return actions.map((action) => DESIGN_CNSTN_VIEW_BUTTON_ACTION[action]); +} + +interface GetButtonConfigsOptions { + hasPendingApprovalRequest?: boolean; +} + +function resolveRoleUser01AtrzButtonActions( + prgrsSttsComCd: string, + atrzSeComCdList: string[], + options?: GetButtonConfigsOptions, +): DesignCnstnViewButtonAction[] { + if (prgrsSttsComCd === "CS" && options?.hasPendingApprovalRequest === false) { + return []; + } + + const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd]; + if (!atrzStatusMap || atrzSeComCdList.length === 0) return []; + + const seen = new Set(); + const actions: DesignCnstnViewButtonAction[] = []; + + for (const atrzSeComCd of atrzSeComCdList) { + const mappedActions = atrzStatusMap[atrzSeComCd]; + if (!mappedActions) continue; + + for (const action of mappedActions) { + if (seen.has(action)) continue; + seen.add(action); + actions.push(action); + } + } + + return actions; +} + +export function needsRoleUser01AtrzResolve( + dtlInstCd: string | undefined, + prgrsSttsComCd: string, +): boolean { + return ( + dtlInstCd === "ROLE_USER01" && + ROLE_USER01_ATRZ_STATUS_CODES.includes( + prgrsSttsComCd as (typeof ROLE_USER01_ATRZ_STATUS_CODES)[number], + ) + ); +} + +export function getButtonConfigs( + dtlInstCd: string | undefined, + prgrsSttsComCd: string, + atrzSeComCdList?: string[], + options?: GetButtonConfigsOptions, +): DesignCnstnViewButtonAction[] { + if (!dtlInstCd) return []; + + if (needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd)) { + return resolveButtonConfigs( + resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? [], options), + ); + } + + const statusMap = ROLE_STATUS_BUTTON_ACTIONS[dtlInstCd]; + if (!statusMap) return []; + + const actions = statusMap[prgrsSttsComCd]; + if (!actions) return []; + + return resolveButtonConfigs(actions); +} diff --git a/apps/as/src/pages/design-cnstn/lib/design-cnstn-regist-schema.ts b/apps/as/src/pages/design-cnstn/lib/design-cnstn-regist-schema.ts new file mode 100644 index 000000000..9cbcdc270 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/lib/design-cnstn-regist-schema.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +import { + designCnstnBzScaleItemSchema, + designCnstnEsntlAtchFileItemSchema, + designCnstnMngSaveSchema, +} from "@/entities/design-cnstn"; + +export const designCnstnRegistSchema = z.object({ + designCnstnMng: designCnstnMngSaveSchema.extend({ + bizNm: z.string().min(1, "사업명은 필수입니다"), + bizBgngYr: z.string().min(1, "사업시작연도는 필수입니다"), + bizEndYr: z.string().min(1, "사업종료연도는 필수입니다"), + bizCn: z.string().min(1, "사업내용은 필수입니다"), + taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"), + dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"), + dsgnrTelno: z + .string() + .min(1, "설계자 전화번호는 필수입니다") + .regex(/^[0-9-]+$/, "숫자와 하이픈(-)만 입력 가능합니다"), + dsgnrOgdpNm: z.string().min(1, "설계자 소속은 필수입니다"), + dsgnrEmlAddr: z + .string() + .min(1, "설계자 이메일은 필수입니다") + .email("올바른 이메일 형식이 아닙니다"), + sido: z.string().optional(), + gugun: z.string().optional(), + }), + bzScaleList: z.array( + designCnstnBzScaleItemSchema.extend({ + cnstpSeCd: z.string(), + bscPl: z.number(), + budgetReflct: z.number(), + thtiDsgn: z.number(), + diff: z.number().optional(), + }), + ), + esntlAtchFileList: z.array( + designCnstnEsntlAtchFileItemSchema.extend({ + esntlAtchFileId: z.string(), + atchFileGroupId: z.string(), + }), + ), + privacyConsent: z.enum(["Y", "N"], { + required_error: "개인정보 수집/이용 동의는 필수입니다", + }), +}); + +export type DesignCnstnRegistFormData = z.infer; + +export function formatTaskSeComCdForApi(taskSeComCd: string[]): string { + return taskSeComCd.join(","); +} + +export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] { + if (!taskSeComCd) return []; + return taskSeComCd.split(",").filter(Boolean); +} diff --git a/apps/as/src/pages/design-cnstn/lib/form-data-mapper.ts b/apps/as/src/pages/design-cnstn/lib/form-data-mapper.ts new file mode 100644 index 000000000..326f6e237 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/lib/form-data-mapper.ts @@ -0,0 +1,106 @@ +import type { IUserInfo } from "@/shared/store"; +import { + type DesignCnstnApplySaveRequestDto, + designCnstnApplySaveSchema, + type DesignCnstnMngDetResponseDto, +} from "@/entities/design-cnstn"; +import { toEsntlAtchFileSaveList } from "@/widgets/pbadms-sprt"; + +import type { DesignCnstnRegistFormData } from "./design-cnstn-regist-schema"; +import { formatTaskSeComCdForApi, parseTaskSeComCdFromApi } from "./design-cnstn-regist-schema"; + +export function mapDetailToFormData(data: DesignCnstnMngDetResponseDto): DesignCnstnRegistFormData { + const detDto = data.detDto; + if (!detDto) { + throw new Error("상세 정보(detDto)가 없습니다."); + } + + const bizSclList = data.bizSclList ?? []; + + return { + designCnstnMng: { + designCnstnDocNo: detDto.designCnstnDocNo, + bizNm: detDto.bizNm ?? "", + bizBgngYr: detDto.bizBgngYr ?? "", + bizEndYr: detDto.bizEndYr ?? "", + bizCn: detDto.bizCn ?? "", + taskSeComCd: parseTaskSeComCdFromApi(detDto.taskSeComCd), + dsgnrNm: detDto.dsgnrNm ?? "", + dsgnrTelno: detDto.dsgnrTelno ?? "", + dsgnrOgdpNm: detDto.dsgnrOgdpNm ?? "", + dsgnrEmlAddr: detDto.dsgnrEmlAddr ?? "", + aplcntNm: detDto.aplcntNm ?? "", + aplcntMblTelno: detDto.aplcntMblTelno ?? "", + aplcntOgdpNm: detDto.aplcntOgdpNm ?? "", + aplcntEmlAddr: detDto.aplcntEmlAddr ?? "", + admdstCd: detDto.admdstCd, + envoSeComCd: detDto.envoSeComCd, + prgrsSttsComCd: detDto.prgrsSttsComCd, + atchFileGroupId: detDto.atchFileGroupId ?? "", + sido: detDto.sido, + gugun: detDto.gugun, + }, + bzScaleList: bizSclList.map((item) => ({ + cnstpSeCd: item.cnstpSeCd, + bscPl: item.bscPlanQty, + budgetReflct: item.bgtRfltQty, + thtiDsgn: item.tstmDesignQty, + diff: item.tstmDesignQty - item.bgtRfltQty, + })), + esntlAtchFileList: toEsntlAtchFileSaveList(data.esntlFileList), + privacyConsent: "Y", + }; +} + +export function getCreateDefaultValues( + userInfo: IUserInfo | null, + options?: { + taskSeComCd?: string[]; + admdstCd?: string; + sido?: string; + gugun?: string; + }, +): DesignCnstnRegistFormData { + return { + designCnstnMng: { + bizNm: "", + bizBgngYr: "", + bizEndYr: "", + bizCn: "", + taskSeComCd: options?.taskSeComCd ?? [], + dsgnrNm: "", + dsgnrTelno: "", + dsgnrOgdpNm: "", + dsgnrEmlAddr: "", + aplcntNm: userInfo?.userName ?? "", + aplcntMblTelno: userInfo?.mphn ?? "", + aplcntOgdpNm: userInfo?.ogdpSeNm ?? "", + aplcntEmlAddr: userInfo?.email ?? "", + admdstCd: options?.admdstCd ?? "", + sido: options?.sido ?? "", + gugun: options?.gugun ?? "", + prgrsSttsComCd: "CV", + atchFileGroupId: "", + }, + bzScaleList: [], + esntlAtchFileList: [], + privacyConsent: "N", + }; +} + +export function buildFormPayload( + data: DesignCnstnRegistFormData, + prgrsSttsComCd: "CV" | "CT", + options: { isEditMode: boolean; docNo?: string }, +): DesignCnstnApplySaveRequestDto { + return designCnstnApplySaveSchema.parse({ + designCnstnMng: { + ...data.designCnstnMng, + taskSeComCd: formatTaskSeComCdForApi(data.designCnstnMng.taskSeComCd), + designCnstnDocNo: options.isEditMode ? options.docNo : undefined, + prgrsSttsComCd, + }, + bzScaleList: data.bzScaleList, + esntlAtchFileList: data.esntlAtchFileList, + }); +} diff --git a/apps/as/src/pages/design-cnstn/lib/review-opinion-utils.ts b/apps/as/src/pages/design-cnstn/lib/review-opinion-utils.ts new file mode 100644 index 000000000..fd7da632b --- /dev/null +++ b/apps/as/src/pages/design-cnstn/lib/review-opinion-utils.ts @@ -0,0 +1,94 @@ +import type { + DesignCnstnMngDetResponseDto, + DesignCnstnRvwOpnnResponseDto, + DesignCnstnRvwOpnnSaveRequestDto, +} from "@/entities/design-cnstn"; + +export interface ReviewOpinionFormData { + rvwRsltCn: string; + rvwRsltAnmlyYn: "Y" | "N" | ""; + atchFileGroupId: string; + opnrSeComCd: string; + rvwOpnnSn?: number; +} + +export interface ReviewOpinionGetFormDataResult { + formData: ReviewOpinionFormData; + rvwList: DesignCnstnRvwOpnnResponseDto[]; +} + +function hasReviewOpinionContent(formData: ReviewOpinionFormData) { + return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId); +} + +function mapServerItemToSaveDto( + item: DesignCnstnRvwOpnnResponseDto, + designCnstnDocNo: string, +): DesignCnstnRvwOpnnSaveRequestDto | null { + if (item.rvwOpnnSn == null) return null; + + return { + designCnstnDocNo, + rvwOpnnSn: item.rvwOpnnSn, + opnrSeComCd: item.opnrSeComCd, + opnrNm: item.opnrNm, + rvwRsltCn: item.rvwRsltCn, + rvwRsltAnmlyYn: item.rvwRsltAnmlyYn || undefined, + atchFileGroupId: item.atchFileGroupId || undefined, + }; +} + +function mapFormDataToSaveDto( + formData: ReviewOpinionFormData, + designCnstnDocNo: string, + opnrNm?: string, +): DesignCnstnRvwOpnnSaveRequestDto { + const base = { + designCnstnDocNo, + opnrSeComCd: formData.opnrSeComCd, + opnrNm: opnrNm ?? "", + rvwRsltCn: formData.rvwRsltCn, + rvwRsltAnmlyYn: formData.rvwRsltAnmlyYn || undefined, + atchFileGroupId: formData.atchFileGroupId || undefined, + }; + + if (formData.rvwOpnnSn != null) { + return { ...base, rvwOpnnSn: formData.rvwOpnnSn }; + } + + return base as DesignCnstnRvwOpnnSaveRequestDto; +} + +export function toRvwOpnnSaveDto( + data: DesignCnstnMngDetResponseDto, + result: ReviewOpinionGetFormDataResult | null | undefined, + opnrNm?: string, +): DesignCnstnRvwOpnnSaveRequestDto[] | undefined { + if (!result || !data.detDto) return undefined; + + const { formData, rvwList } = result; + const designCnstnDocNo = data.detDto.designCnstnDocNo; + + const list: DesignCnstnRvwOpnnSaveRequestDto[] = rvwList + .map((item) => mapServerItemToSaveDto(item, designCnstnDocNo)) + .filter((item): item is DesignCnstnRvwOpnnSaveRequestDto => item != null); + + if (!hasReviewOpinionContent(formData)) { + return list.length > 0 ? list : undefined; + } + + const formDto = mapFormDataToSaveDto(formData, designCnstnDocNo, opnrNm); + + if (formData.rvwOpnnSn != null) { + const existingIndex = list.findIndex((item) => item.rvwOpnnSn === formData.rvwOpnnSn); + if (existingIndex >= 0) { + list[existingIndex] = formDto; + } else { + list.push(formDto); + } + } else { + list.push(formDto); + } + + return list.length > 0 ? list : undefined; +} diff --git a/apps/as/src/pages/design-cnstn/lib/review-step-utils.ts b/apps/as/src/pages/design-cnstn/lib/review-step-utils.ts new file mode 100644 index 000000000..f96b9b4ef --- /dev/null +++ b/apps/as/src/pages/design-cnstn/lib/review-step-utils.ts @@ -0,0 +1,12 @@ +import type { ReviewStepCode } from "../model/view-types"; + +export function getEditableReviewStep( + dtlInstCd?: string, + prgrsSttsComCd?: string, +): ReviewStepCode | undefined { + if (dtlInstCd === "ROLE_USER01" && prgrsSttsComCd === "CS") { + return "KECO"; + } + + return undefined; +} diff --git a/apps/as/src/pages/design-cnstn/lib/save-payload-utils.ts b/apps/as/src/pages/design-cnstn/lib/save-payload-utils.ts new file mode 100644 index 000000000..625ff0486 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/lib/save-payload-utils.ts @@ -0,0 +1,55 @@ +import type { + BizSclTreeDto, + BzScaleItem, + DesignCnstnApplySaveRequestDto, + DesignCnstnMngDetResponseDto, +} from "@/entities/design-cnstn"; +import { designCnstnApplySaveSchema } from "@/entities/design-cnstn"; +import { toEsntlAtchFileSaveList } from "@/widgets/pbadms-sprt"; + +import type { ReviewStepCode } from "../model/view-types"; + +import { type ReviewOpinionGetFormDataResult, toRvwOpnnSaveDto } from "./review-opinion-utils"; + +export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): BzScaleItem[] { + return bizSclList + .filter((node) => node.useYn === "Y") + .map((node) => ({ + cnstpSeCd: node.cnstpSeCd, + bscPl: node.bscPlanQty, + budgetReflct: node.bgtRfltQty, + thtiDsgn: node.tstmDesignQty, + })); +} + +export function buildSavePayload( + data: DesignCnstnMngDetResponseDto, + prgrsSttsComCd: string, +): DesignCnstnApplySaveRequestDto { + const detDto = data.detDto; + if (!detDto) { + throw new Error("상세 정보(detDto)가 없습니다."); + } + + return designCnstnApplySaveSchema.parse({ + designCnstnMng: { ...detDto, prgrsSttsComCd }, + bzScaleList: toBzScaleSaveDto(data.bizSclList ?? []), + esntlAtchFileList: toEsntlAtchFileSaveList(data.esntlFileList), + }); +} + +export function buildTechReviewPayload( + data: DesignCnstnMngDetResponseDto, + prgrsSttsComCd: string, + techReViewSaveCd: string, + reviewStep: ReviewStepCode, + reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null, + opnrNm?: string, +): DesignCnstnApplySaveRequestDto { + return designCnstnApplySaveSchema.parse({ + ...buildSavePayload(data, prgrsSttsComCd), + techReViewSaveCd, + rvwStpCd: reviewStep, + rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm), + }); +} diff --git a/apps/as/src/pages/design-cnstn/model/columns.tsx b/apps/as/src/pages/design-cnstn/model/columns.tsx new file mode 100644 index 000000000..083bd08fe --- /dev/null +++ b/apps/as/src/pages/design-cnstn/model/columns.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { Fragment } from "react"; + +import { createColumnHelper } from "@tanstack/react-table"; + +import { dayjs } from "@/shared/lib"; +import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn"; +import { Badge } from "@common/components"; + +const columnHelper = createColumnHelper(); + +const MAIN_STEP_LABELS = { + apply: "설계자문 신청", + techReview: "기술검토", + reply: "검토결과 회신", +} as const; + +export const PROGRESS_STATUS_COLUMNS = [ + { label: "설계자문 신청 (시군)", sttsComCd: "ER" }, + { label: "기술검토", sttsComCd: "TE" }, + { label: "기술검토 회신", sttsComCd: "CP" }, +]; + +type BadgeState = "completed" | "current" | "pending"; + +interface ProgressBadge { + label: string; + state: BadgeState; +} + +function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] { + if (prgrsSttsComCd === "CV") { + return [ + { label: MAIN_STEP_LABELS.apply, state: "pending" }, + { label: MAIN_STEP_LABELS.techReview, state: "pending" }, + { label: MAIN_STEP_LABELS.reply, state: "pending" }, + ]; + } + + if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") { + return [ + { label: MAIN_STEP_LABELS.apply, state: "completed" }, + { label: MAIN_STEP_LABELS.techReview, state: "current" }, + { label: MAIN_STEP_LABELS.reply, state: "pending" }, + ]; + } + + if (prgrsSttsComCd === "CP") { + return [ + { label: MAIN_STEP_LABELS.apply, state: "completed" }, + { label: MAIN_STEP_LABELS.techReview, state: "completed" }, + { label: MAIN_STEP_LABELS.reply, state: "completed" }, + ]; + } + + return [ + { label: MAIN_STEP_LABELS.apply, state: "pending" }, + { label: MAIN_STEP_LABELS.techReview, state: "pending" }, + { label: MAIN_STEP_LABELS.reply, state: "pending" }, + ]; +} + +function getBadgeColor(state: BadgeState) { + switch (state) { + case "completed": + return "green"; + case "current": + return "blue"; + case "pending": + return "lightGray"; + } +} + +function formatOptional(value: string | undefined) { + return value?.trim() ? value : "-"; +} + +interface ProgressStepBadgeProps { + prgrsSttsComCd: string; +} + +function ProgressStepBadge({ prgrsSttsComCd }: ProgressStepBadgeProps) { + const badges = buildProgressBadges(prgrsSttsComCd); + + return ( +
+ {badges.map((badge, index) => ( + + {index > 0 && >} + {badge.label} + + ))} +
+ ); +} + +export function createDesignCnstnColumns() { + return [ + columnHelper.accessor("sido", { + header: "시도", + meta: { align: "center" }, + cell: ({ getValue }) => formatOptional(getValue()), + }), + columnHelper.accessor("gugun", { + header: "시군구", + meta: { align: "center" }, + cell: ({ getValue }) => formatOptional(getValue()), + }), + columnHelper.accessor("bizNm", { + header: "사업명", + meta: { align: "left" }, + cell: ({ getValue }) => formatOptional(getValue()), + }), + columnHelper.accessor("taskSeCdNm", { + header: "업무구분", + meta: { align: "center" }, + cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd), + }), + columnHelper.accessor("aplcntNm", { + header: "담당자", + meta: { align: "center" }, + cell: ({ getValue }) => formatOptional(getValue()), + }), + columnHelper.accessor("envoSeCdNm", { + header: "해당기관", + meta: { align: "center" }, + cell: ({ getValue }) => formatOptional(getValue()), + }), + columnHelper.display({ + id: "progress", + header: "진행상황", + meta: { align: "center" }, + cell: ({ row }) => , + }), + columnHelper.accessor("regDt", { + header: "신청일", + meta: { align: "center" }, + cell: ({ getValue }) => { + const value = getValue(); + return value ? dayjs(value).format("YYYY-MM-DD") : "-"; + }, + }), + ]; +} diff --git a/apps/as/src/pages/design-cnstn/model/view-types.ts b/apps/as/src/pages/design-cnstn/model/view-types.ts new file mode 100644 index 000000000..0f9b93431 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/model/view-types.ts @@ -0,0 +1,15 @@ +export type ReviewStepCode = "LOCAL" | "ENV" | "KECO"; + +export const REVIEW_STEP_LABELS: Record = { + LOCAL: "지자체", + ENV: "환경청", + KECO: "환경공단", +}; + +export const DESIGN_PROGRESS_ORDER = ["CV", "CT", "CS", "CR", "CP", "EP"] as const; + +export const ROLE_TO_REVIEW_STEP: Record = { + ROLE_USER05: "LOCAL", + ROLE_USER03: "ENV", + ROLE_USER01: "KECO", +}; diff --git a/apps/as/src/pages/design-cnstn/ui/components/floating-action-buttons.tsx b/apps/as/src/pages/design-cnstn/ui/components/floating-action-buttons.tsx new file mode 100644 index 000000000..37af44b48 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/components/floating-action-buttons.tsx @@ -0,0 +1,393 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; + +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; +import { + type DesignCnstnApplySaveRequestDto, + designCnstnKeys, + type DesignCnstnMngDetResponseDto, + designCnstnMngService, +} from "@/entities/design-cnstn"; +import { + buildElaprPrgrsSttsListFromAplnRows, + ElaprApprovalDialog, + type ElaprDmndSaveRequestDto, + elaprDmndService, + type ElaprTmpltPickerConfirmPayload, + ElaprTmpltPickerDialog, + hasCurrentUserPendingApprovalRequest, + useElaprDocPrgrsByDocNo, +} from "@/entities/elapr"; +import { Button } from "@common/components"; + +import { + DESIGN_CNSTN_VIEW_BUTTON_ACTION, + type DesignCnstnViewButtonAction, + getButtonConfigs, + needsRoleUser01AtrzResolve, +} from "../../lib/button-config"; +import { buildSavePayload, buildTechReviewPayload } from "../../lib/save-payload-utils"; +import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section"; + +interface FloatingActionButtonsProps { + data: DesignCnstnMngDetResponseDto; + docSeComCd: string; + designCnstnDocNo: string; + prgrsSttsComCd: string; + dtlInstCd: string | undefined; + atrzSeComCdList: string[]; + isRvwLoginUserLoading: boolean; + techReviewOpinionRef?: React.RefObject; + opnrNm?: string; +} + +type ActiveDialog = "elaprTmplt" | "elaprApproval" | null; + +function FloatingActionButtons({ + data, + docSeComCd, + designCnstnDocNo, + prgrsSttsComCd, + dtlInstCd, + atrzSeComCdList, + isRvwLoginUserLoading, + techReviewOpinionRef, + opnrNm, +}: FloatingActionButtonsProps) { + const router = useRouter(); + const { confirm } = useConfirm(); + const queryClient = useQueryClient(); + const userInfo = useUserStore((state) => state.userInfo); + const alert = useAlertStore((state) => state.setMessage); + const [isProcessing, setIsProcessing] = useState(false); + const [activeDialog, setActiveDialog] = useState(null); + + const docNo = data.detDto?.designCnstnDocNo ?? designCnstnDocNo; + const isRegister = data.detDto?.rgtr === userInfo?.userUnqId; + + const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd); + const needsCsApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "CS"; + const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } = + useElaprDocPrgrsByDocNo(needsCsApprovalCheck ? docNo : null); + + const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest( + elaprDocPrgrsList, + userInfo?.userUnqId, + ); + + const actionButtons = useMemo(() => { + if (needsAtrzResolve && isRvwLoginUserLoading) return []; + if (needsCsApprovalCheck && isElaprDocPrgrsLoading) return []; + return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList, { + hasPendingApprovalRequest: needsCsApprovalCheck ? hasPendingApprovalRequest : undefined, + }); + }, [ + atrzSeComCdList, + dtlInstCd, + hasPendingApprovalRequest, + isElaprDocPrgrsLoading, + isRvwLoginUserLoading, + needsAtrzResolve, + needsCsApprovalCheck, + prgrsSttsComCd, + ]); + + const canEdit = + isRegister && actionButtons.some((button) => button === DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg); + + function handleGoToList() { + router.push("/design-cnstn"); + } + + function handleEdit() { + router.push(`/design-cnstn/${designCnstnDocNo}/edit`); + } + + function shouldShowButton(action: DesignCnstnViewButtonAction) { + return actionButtons.some((button) => button === action); + } + + async function executeAction({ + action, + request, + successMessage, + }: { + action: DesignCnstnViewButtonAction; + request?: unknown; + successMessage?: string; + }) { + const handler = buttonActionHandlers[action]; + if (!handler) return; + + setIsProcessing(true); + try { + await handler({ + request, + data, + onSuccess: () => { + if (successMessage) alert(successMessage); + queryClient.invalidateQueries({ + queryKey: designCnstnKeys.detail(designCnstnDocNo), + }); + }, + onError: (error) => { + let errorMessage = ""; + if (error instanceof AxiosError) { + errorMessage = error.response?.data?.message ?? ""; + } else { + errorMessage = error.message ?? "처리 중 오류가 발생했습니다."; + } + alert(errorMessage); + }, + }); + } finally { + setIsProcessing(false); + } + } + + async function handleStrgClick() { + const payload: DesignCnstnApplySaveRequestDto = buildSavePayload(data, "CV"); + await executeAction({ + action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg, + request: payload, + successMessage: "저장되었습니다.", + }); + } + + async function handleAplyClick() { + const payload: DesignCnstnApplySaveRequestDto = buildSavePayload(data, "CT"); + await executeAction({ + action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply, + request: payload, + successMessage: "신청 완료되었습니다.", + }); + queryClient.invalidateQueries({ queryKey: designCnstnKeys.all }); + } + + async function handleDelClick() { + if (!(await confirm("삭제하시겠습니까?"))) return; + await executeAction({ + action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.del, + successMessage: "삭제되었습니다.", + }); + router.push("/design-cnstn"); + } + + function getReviewOpinionFormData() { + return techReviewOpinionRef?.current?.getFormData() ?? null; + } + + async function handleElaprTmpltConfirm({ aplnRows }: ElaprTmpltPickerConfirmPayload) { + const payload: ElaprDmndSaveRequestDto = { + elaprDocNo: docNo, + dmndTtl: data.detDto?.bizNm ?? "", + dmndCn: data.detDto?.bizCn ?? "", + elaprCycl: 1, + docPrgrsSttsComCd: data.detDto?.prgrsSttsComCd, + elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows), + }; + await executeAction({ + action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.rqstRvw, + request: payload, + successMessage: "검토자지정이 완료되었습니다.", + }); + await queryClient.invalidateQueries({ + queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", docNo], + }); + } + + async function handleRvwStrgClick() { + const payload = buildTechReviewPayload( + data, + "CS", + "SV", + "KECO", + getReviewOpinionFormData(), + opnrNm, + ); + + await executeAction({ + action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwStrg, + request: payload, + successMessage: "저장되었습니다.", + }); + techReviewOpinionRef?.current?.resetForm(); + } + + async function handleRvwCmptnClick() { + const payload = buildTechReviewPayload( + data, + "CS", + "CP", + "KECO", + getReviewOpinionFormData(), + opnrNm, + ); + + await executeAction({ + action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwCmptn, + request: payload, + }); + techReviewOpinionRef?.current?.resetForm(); + setActiveDialog("elaprApproval"); + } + + return ( + <> +
+
+ + {canEdit && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg) && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply) && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.del) && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.rqstRvw) && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwStrg) && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwCmptn) && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzAprv) && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzRply) && ( + + )} +
+
+ + setActiveDialog(null)} + docSeComCd={docSeComCd} + onConfirm={handleElaprTmpltConfirm} + /> + + setActiveDialog(open ? "elaprApproval" : null)} + elaprDocNo={data.detDto?.designCnstnDocNo ?? designCnstnDocNo} + currentUserUuid={userInfo?.userUnqId ?? ""} + onSuccess={() => { + queryClient.invalidateQueries({ + queryKey: designCnstnKeys.detail(designCnstnDocNo), + }); + }} + /> + + ); +} + +export { FloatingActionButtons }; + +async function handleSaveApply( + context: ActionHandlerContext, +): Promise { + try { + await designCnstnMngService.saveApply(context.request); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } +} + +async function handleDel(context: ActionHandlerContext): Promise { + try { + await designCnstnMngService.delete(context.data.detDto?.designCnstnDocNo ?? ""); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } +} + +async function handleTechReview( + context: ActionHandlerContext, +): Promise { + try { + await designCnstnMngService.techReview( + context.data.detDto?.designCnstnDocNo ?? "", + context.request, + ); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } +} + +async function handleRqstRvw( + context: ActionHandlerContext, +): Promise { + try { + await elaprDmndService.save(context.request); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } +} + +async function handleAtrzAprv(context: ActionHandlerContext): Promise { + context.onSuccess?.(); +} + +async function handleAtrzRply(context: ActionHandlerContext): Promise { + context.onSuccess?.(); +} + +const buttonActionHandlers: Record = { + strg: handleSaveApply, + aply: handleSaveApply, + del: handleDel, + rqstRvw: handleRqstRvw, + rvwStrg: handleTechReview, + rvwCmptn: handleTechReview, + atrzAprv: handleAtrzAprv, + atrzRply: handleAtrzRply, +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +interface ActionHandlerContext { + request: T; + data: DesignCnstnMngDetResponseDto; + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +type ActionHandler = (context: ActionHandlerContext) => Promise; diff --git a/apps/as/src/pages/design-cnstn/ui/design-detail-gate.tsx b/apps/as/src/pages/design-cnstn/ui/design-detail-gate.tsx new file mode 100644 index 000000000..723ae5f03 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-detail-gate.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useUserStore } from "@/shared/lib"; +import { useDesignCnstnMng } from "@/entities/design-cnstn"; +import { canEditPbadmsDoc, PbadmsDetailGate } from "@/widgets/pbadms-sprt"; + +import { DesignViewPage } from "./design-view-page"; + +interface DesignDetailGateProps { + id: string; +} + +function DesignDetailGate({ id }: DesignDetailGateProps) { + const userInfo = useUserStore((state) => state.userInfo); + const { data, isLoading, isError } = useDesignCnstnMng(id); + const shouldRedirectToEdit = canEditPbadmsDoc({ + docSeComCd: "CNFM", + prgrsSttsComCd: data?.detDto?.prgrsSttsComCd, + rgtr: data?.detDto?.rgtr, + loggedInUserUnqId: userInfo?.userUnqId, + }); + + return ( + + + + ); +} + +export { DesignDetailGate }; diff --git a/apps/as/src/pages/design-cnstn/ui/design-edit-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-edit-page.tsx new file mode 100644 index 000000000..1edb29d7f --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-edit-page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useDesignCnstnMng } from "@/entities/design-cnstn"; +import { Skeleton } from "@common/components"; + +import { DesignFormPage } from "./design-form-page"; + +interface DesignEditPageProps { + id: string; +} + +function DesignEditPage({ id }: DesignEditPageProps) { + const { data, isLoading } = useDesignCnstnMng(id); + + if (isLoading) { + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ); + } + + if (!data?.detDto) { + return
상세 정보를 찾을 수 없습니다.
; + } + + return ; +} + +export { DesignEditPage }; diff --git a/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx new file mode 100644 index 000000000..254a7a010 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { useRouter } from "next/navigation"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; + +import { useAlertStore, useUserStore } from "@/shared/lib"; +import { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui"; +import { + designCnstnKeys, + type DesignCnstnMngDetResponseDto, + designCnstnMngService, +} from "@/entities/design-cnstn"; +import { useEsntlAtchFileMngTree } from "@/entities/esntl"; +import { + AttachmentsFormSection, + BusinessScaleFormSection, + ContactInfoFormSection, + isFormEsntlAtchComplete, + PrivacyConsentSection, + ProgressStatusSection, +} from "@/widgets/pbadms-sprt"; +import { Button } from "@common/components"; + +import { type DesignCnstnRegistFormData, designCnstnRegistSchema } from "../lib/design-cnstn-regist-schema"; +import { + buildFormPayload, + getCreateDefaultValues, + mapDetailToFormData, +} from "../lib/form-data-mapper"; +import { PROGRESS_STATUS_COLUMNS } from "../model/columns"; + +import { BasicInfoFormSection } from "./sections/basic-info-form-section"; + +interface DesignFormPageProps { + mode: "create" | "edit"; + initialData?: DesignCnstnMngDetResponseDto; + createDefaults?: { + taskSeComCd?: string[]; + admdstCd?: string; + sido?: string; + gugun?: string; + }; +} + +function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPageProps) { + const queryClient = useQueryClient(); + const router = useRouter(); + const { confirm } = useConfirm(); + const userInfo = useUserStore((state) => state.userInfo); + const alert = useAlertStore((state) => state.setMessage); + const [isSubmitting, setIsSubmitting] = useState(false); + const isEditMode = mode === "edit"; + + const { data: essentialFiles = [] } = useEsntlAtchFileMngTree({ docSeComCd: "CNFM" }); + + const form = useForm({ + resolver: zodResolver(designCnstnRegistSchema), + mode: "onChange", + defaultValues: + isEditMode && initialData + ? mapDetailToFormData(initialData) + : getCreateDefaultValues(userInfo, createDefaults), + }); + + function createFormPayload(data: DesignCnstnRegistFormData, prgrsSttsComCd: "CV" | "CT") { + return buildFormPayload(data, prgrsSttsComCd, { + isEditMode, + docNo: initialData?.detDto?.designCnstnDocNo, + }); + } + + async function submitForm( + data: DesignCnstnRegistFormData, + prgrsSttsComCd: "CV" | "CT", + ): Promise { + return designCnstnMngService.saveApply(createFormPayload(data, prgrsSttsComCd)); + } + + async function onSave(data: DesignCnstnRegistFormData) { + if (isSubmitting) return; + + try { + setIsSubmitting(true); + const designCnstnDocNo = await submitForm(data, "CV"); + if (!designCnstnDocNo) return; + + alert("저장되었습니다."); + queryClient.invalidateQueries({ queryKey: designCnstnKeys.all }); + router.push(`/design-cnstn/${designCnstnDocNo}/edit`); + } catch (error) { + console.error("저장 실패:", error); + alert("저장 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + } + + async function onApply(data: DesignCnstnRegistFormData) { + if (isSubmitting) return; + + if (!isFormEsntlAtchComplete(essentialFiles, data.esntlAtchFileList)) { + alert("필수 첨부파일을 모두 등록해야 신청이 가능합니다."); + return; + } + + try { + setIsSubmitting(true); + const designCnstnDocNo = await submitForm(data, "CT"); + if (!designCnstnDocNo) return; + + alert("신청 완료되었습니다."); + queryClient.invalidateQueries({ queryKey: designCnstnKeys.all }); + router.push(`/design-cnstn/${designCnstnDocNo}`); + } catch (error) { + console.error("신청 실패:", error); + alert("신청 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + } + + function handleApplyClick() { + form.handleSubmit(onApply)(); + } + + async function handleDelClick() { + if (isSubmitting) return; + + const docNo = initialData?.detDto?.designCnstnDocNo; + if (!docNo) { + alert("문서번호가 없습니다."); + return; + } + + if (!(await confirm("삭제하시겠습니까?"))) return; + + try { + setIsSubmitting(true); + await designCnstnMngService.delete(docNo); + alert("삭제되었습니다."); + queryClient.invalidateQueries({ queryKey: designCnstnKeys.list() }); + router.push("/design-cnstn"); + } catch (error) { + console.error("삭제 실패:", error); + alert("삭제 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + } + + return ( + +
+ +
+ +
+
+ {initialData?.detDto?.designCnstnDocNo && ( + + )} + + + + + + +
+
+ + + {isEditMode && ( + <> + + + + )} +
+
+
+
+
+ +
+ ); +} + +export { DesignFormPage }; diff --git a/apps/as/src/pages/design-cnstn/ui/design-list-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-list-page.tsx new file mode 100644 index 000000000..9928a83b1 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-list-page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState } from "react"; + +import type { PaginationState } from "@tanstack/react-table"; + +import { + type CmCdAdstDstrComboResponseDto, + useFindGugunCombo, + useFindSidoCombo, +} from "@/entities/adstdstr"; +import { CodeSelect } from "@/entities/code"; +import type { DesignCnstnMngSearchDto } from "@/entities/design-cnstn"; +import { + Button, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; + +import { DesignList } from "./design-list"; + +const DEFAULT_PAGE_INDEX = 0; +const DEFAULT_PAGE_SIZE = 10; +const DEFAULT_SORT = "designCnstnDocNo,desc"; +const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = []; + +const DEFAULT_SEARCH_PARAMS: DesignCnstnMngSearchDto = { + bizNm: "", + taskSeComCd: "", + admdstCd: "", + page: DEFAULT_PAGE_INDEX, + size: DEFAULT_PAGE_SIZE, + sort: DEFAULT_SORT, +}; + +const ALL_SELECT_VALUE = "__ALL__"; + +function DesignListPage() { + const [bizNm, setBizNm] = useState(""); + const [taskSeComCd, setTaskSeComCd] = useState(""); + const [sidoComCd, setSidoComCd] = useState(""); + const [sggComCd, setSggComCd] = useState(""); + const [pagination, setPagination] = useState({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + }); + const [searchParams, setSearchParams] = useState(DEFAULT_SEARCH_PARAMS); + + const { data: sidoData } = useFindSidoCombo(); + const { data: gugunData } = useFindGugunCombo(sidoComCd); + const sidoOptions = sidoData ?? EMPTY_ADST_LIST; + const gugunOptions = gugunData ?? EMPTY_ADST_LIST; + + const handleSearch = (event: React.FormEvent) => { + event.preventDefault(); + setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX })); + setSearchParams({ + ...DEFAULT_SEARCH_PARAMS, + bizNm, + taskSeComCd, + admdstCd: sggComCd || sidoComCd || "", + page: DEFAULT_PAGE_INDEX, + size: pagination.pageSize, + }); + }; + + return ( +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + setBizNm(event.target.value)} + placeholder="사업명" + /> +
+ +
+
+ + +
+ ); +} + +export { DesignListPage }; diff --git a/apps/as/src/pages/design-cnstn/ui/design-list.tsx b/apps/as/src/pages/design-cnstn/ui/design-list.tsx new file mode 100644 index 000000000..f9652d833 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-list.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; + +import { useDataTable } from "@/shared/lib"; +import { + type DesignCnstnMngResponseDto, + type DesignCnstnMngSearchDto, + useDesignCnstnMngPage, +} from "@/entities/design-cnstn"; +import { PbadmsTaskApplyDialog, type PbadmsTaskApplySelection } from "@/widgets/pbadms-sprt"; +import { Button } from "@common/components"; + +import { formatTaskSeComCdForApi } from "../lib/design-cnstn-regist-schema"; +import { createDesignCnstnColumns } from "../model/columns"; + +interface DesignListProps { + searchParams: DesignCnstnMngSearchDto; + pagination: PaginationState; + onPaginationChange: (pagination: PaginationState) => void; +} + +function DesignList({ searchParams, pagination, onPaginationChange }: DesignListProps) { + const router = useRouter(); + const [isTaskSeDialogOpen, setIsTaskSeDialogOpen] = useState(false); + + const queryParams = useMemo( + () => ({ + bizNm: searchParams.bizNm, + taskSeComCd: searchParams.taskSeComCd, + admdstCd: searchParams.admdstCd, + page: pagination.pageIndex, + size: pagination.pageSize, + sort: searchParams.sort ?? "designCnstnDocNo,desc", + }), + [searchParams, pagination], + ); + + const pageQuery = useDesignCnstnMngPage(queryParams); + const columns = useMemo(() => createDesignCnstnColumns(), []); + + const handleRowClick = (row: DesignCnstnMngResponseDto) => { + router.push(`/design-cnstn/${row.designCnstnDocNo}`); + }; + + const { tableComponent } = useDataTable({ + query: pageQuery, + tableOptions: { + columns, + state: { pagination }, + onPaginationChange: (updater) => { + const next = typeof updater === "function" ? updater(pagination) : updater; + onPaginationChange(next); + }, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + }, + dataTableOptions: { + viewPagination: true, + onRowClick: handleRowClick, + }, + }); + + const handleConfirm = (selection: PbadmsTaskApplySelection) => { + setIsTaskSeDialogOpen(false); + const qs = new URLSearchParams({ + taskSeComCd: formatTaskSeComCdForApi(selection.selectedCodes), + admdstCd: selection.admdstCd, + sido: selection.sido, + gugun: selection.gugun, + }); + router.push(`/design-cnstn/regist?${qs}`); + }; + + return ( + <> +
+
+

설계자문 목록

+ +
+ {tableComponent} +
+ + + ); +} + +export { DesignList }; diff --git a/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx new file mode 100644 index 000000000..54f1738d1 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx @@ -0,0 +1,19 @@ +import { DesignFormPage } from "./design-form-page"; + +interface DesignRegistDefaults { + taskSeComCd?: string[]; + admdstCd?: string; + sido?: string; + gugun?: string; +} + +interface DesignRegistPageProps { + defaults?: DesignRegistDefaults; +} + +function DesignRegistPage({ defaults }: DesignRegistPageProps) { + return ; +} + +export { DesignRegistPage }; +export type { DesignRegistDefaults }; diff --git a/apps/as/src/pages/design-cnstn/ui/design-view-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-view-page.tsx new file mode 100644 index 000000000..fbb1b0e43 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-view-page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useMemo, useRef } from "react"; + +import { useUserStore } from "@/shared/lib"; +import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui"; +import { useDesignCnstnMng } from "@/entities/design-cnstn"; +import { ElaprDocPrgrsStatus } from "@/entities/elapr"; +import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw"; +import { AttachmentsViewSection, ProgressStatusSection } from "@/widgets/pbadms-sprt"; +import { Skeleton } from "@common/components"; + +import { needsRoleUser01AtrzResolve } from "../lib/button-config"; +import { getEditableReviewStep } from "../lib/review-step-utils"; +import { PROGRESS_STATUS_COLUMNS } from "../model/columns"; + +import { FloatingActionButtons } from "./components/floating-action-buttons"; +import { BasicInfoSection } from "./sections/basic-info-section"; +import { BusinessScaleSection } from "./sections/business-scale-section"; +import { ContactInfoSection } from "./sections/contact-info-section"; +import { + TechReviewOpinionSection, + type TechReviewOpinionSectionHandle, +} from "./sections/tech-review-opinion-section"; + +interface DesignViewPageProps { + id: string; +} + +function DesignViewPage({ id }: DesignViewPageProps) { + const userInfo = useUserStore((state) => state.userInfo); + const techReviewOpinionRef = useRef(null); + const { data, isLoading, isError } = useDesignCnstnMng(id); + + const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || ""; + const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd); + const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList( + {}, + { enabled: needsAtrzResolve }, + ); + const atrzSeComCdList = useMemo( + () => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [], + [rvwLoginUserData], + ); + + if (isLoading) { + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ); + } + + if (isError || !data?.detDto) { + return
상세 정보를 찾을 수 없습니다.
; + } + + const { + detDto, + bizSclList = [], + esntlFileList = [], + etcFileList = [], + rvwList = [], + sttsChgHstryList = [], + } = data; + + const editableReviewStep = getEditableReviewStep( + userInfo?.dtlInstCd, + detDto.prgrsSttsComCd || "", + ); + + return ( +
+ + +
+ + + + + + + + +
+
+
+ ); +} + +export { DesignViewPage }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx new file mode 100644 index 000000000..1c83e1377 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; + +import { SectionCollapsible } from "@/shared/ui"; +import { Field, FieldError, FieldGroup } from "@/shared/ui/field"; +import { findCodeLabel, useCodeList } from "@/entities/code"; +import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker"; +import { + Button, + Input, + Table, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import type { DesignCnstnRegistFormData } from "../../lib/design-cnstn-regist-schema"; + +function BasicInfoFormSection() { + const { control, watch, setValue } = useFormContext(); + const [isPickerOpen, setIsPickerOpen] = useState(false); + const taskSeComCd = watch("designCnstnMng.taskSeComCd"); + const localGovLabel = [watch("designCnstnMng.sido"), watch("designCnstnMng.gugun")] + .filter(Boolean) + .join(" "); + const { data: taskOptions } = useCodeList("CNFM"); + + function handleBnsSelect(data: BnsApplyData) { + setValue("designCnstnMng.bizNm", data.bizNm); + setValue("designCnstnMng.bizBgngYr", data.bizBgngYr); + setValue("designCnstnMng.bizEndYr", data.bizEndYr); + setValue("designCnstnMng.bizCn", data.bizCn); + } + + const taskSeComCdLabel = + taskSeComCd + .map((code) => findCodeLabel(taskOptions, code)) + .filter(Boolean) + .join(", ") || "-"; + + return ( + + + + + + + + + + + + 사업명 + + + ( + +
+ + +
+ +
+ )} + /> +
+
+ + 사업기간 + + + ( + + + + + )} + /> + ~ + ( + + + + + )} + /> + + + + + 사업내용 + + ( + + + + + )} + /> + + + + 구분 + {taskSeComCdLabel} + 신청 지자체 + {localGovLabel} + +
+
+ + setIsPickerOpen(false)} + onSelect={handleBnsSelect} + /> +
+ ); +} + +export { BasicInfoFormSection }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/basic-info-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-section.tsx new file mode 100644 index 000000000..f05414b45 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-section.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { SectionCollapsible } from "@/shared/ui"; +import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn"; +import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; + +interface BasicInfoSectionProps { + det: DesignCnstnMngResponseDto; +} + +function BasicInfoSection({ det }: BasicInfoSectionProps) { + return ( + + + + + + + + + + + 사업명 + {det.bizNm || "-"} + 사업기간 + {(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")} + + + 사업내용 + {det.bizCn || "-"} + + + 환경청구분 + {det.envoSeCdNm || "-"} + 구분 + {det.taskSeCdNm || "-"} + + + 신청지자체 + + {det.sido || "-"} {det.gugun || "-"} + + + +
+
+ ); +} + +export { BasicInfoSection }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx new file mode 100644 index 000000000..24ab52bfd --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { SectionCollapsible } from "@/shared/ui"; +import type { BizSclTreeDto } from "@/entities/design-cnstn"; +import { fmtNumber, indentPx } from "@/widgets/pbadms-sprt"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; + +interface BusinessScaleSectionProps { + bizSclList: BizSclTreeDto[]; +} + +function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) { + return ( + +
+ + + + 구분 + 기본계획(A) + 예산반영(B) + 금회설계시(C) + 증감(C-B) + + + + {bizSclList.length === 0 ? ( + + + 조회된 사업규모 항목이 없습니다. + + + ) : ( + bizSclList.map((node) => { + const pl = indentPx(node.indCnstpSeCdNm); + + return ( + + + {node.cnstpSeCdNm} + + +
{fmtNumber(node.bscPlanQty)}
+
+ +
{fmtNumber(node.bgtRfltQty)}
+
+ +
{fmtNumber(node.tstmDesignQty)}
+
+ +
+ {node.chgQty > 0 ? `+${fmtNumber(node.chgQty)}` : fmtNumber(node.chgQty)} +
+
+
+ ); + }) + )} +
+
+
+
+ ); +} + +export { BusinessScaleSection }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/contact-info-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/contact-info-section.tsx new file mode 100644 index 000000000..1f34fb297 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/contact-info-section.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { SectionCollapsible } from "@/shared/ui"; +import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn"; +import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; + +interface ContactInfoSectionProps { + det: DesignCnstnMngResponseDto; +} + +function ContactInfoSection({ det }: ContactInfoSectionProps) { + return ( + + + + {det.aplcntNm && ( + <> + + 신청자 + 이름 + {det.aplcntNm || "-"} + 연락처 + {det.aplcntMblTelno || "-"} + + + 소속 + {det.aplcntOgdpNm || "-"} + 이메일 + {det.aplcntEmlAddr || "-"} + + + )} + {det.dsgnrNm && ( + <> + + 설계자 + 이름 + {det.dsgnrNm || "-"} + 연락처 + {det.dsgnrTelno || "-"} + + + 소속 + {det.dsgnrOgdpNm || "-"} + 이메일 + {det.dsgnrEmlAddr || "-"} + + + )} + +
+
+ ); +} + +export { ContactInfoSection }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/tech-review-opinion-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/tech-review-opinion-section.tsx new file mode 100644 index 000000000..f81f0aaf9 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/tech-review-opinion-section.tsx @@ -0,0 +1,338 @@ +"use client"; + +import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; + +import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib"; +import { TextShimmer } from "@/shared/ui"; +import { SectionCollapsible } from "@/shared/ui"; +import { Field, FieldLabel, FieldSet } from "@/shared/ui/field"; +import { AttachmentDownloadLink, attachService } from "@/entities/attach"; +import type { DesignCnstnRvwOpnnResponseDto } from "@/entities/design-cnstn"; +import { + Button, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Textarea, +} from "@common/components"; + +import type { + ReviewOpinionFormData, + ReviewOpinionGetFormDataResult, +} from "../../lib/review-opinion-utils"; +import type { ReviewStepCode } from "../../model/view-types"; +import { REVIEW_STEP_LABELS } from "../../model/view-types"; + +const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::"; + +interface TechReviewOpinionSectionProps { + rvwList: DesignCnstnRvwOpnnResponseDto[]; + editableReviewStep?: ReviewStepCode; + atrzSeComCdList?: string[]; + isAtrzLoading?: boolean; +} + +export interface TechReviewOpinionSectionHandle { + getFormData: () => ReviewOpinionGetFormDataResult | null; + resetForm: () => void; +} + +interface TechReviewOpinionTableProps { + items: DesignCnstnRvwOpnnResponseDto[]; +} + +interface UploadedOpinionFile { + fileName: string; + fileKey: string; + atflId?: string; + atflCd?: string; +} + +const EMPTY_FORM_DATA: ReviewOpinionFormData = { + rvwRsltCn: "", + rvwRsltAnmlyYn: "", + atchFileGroupId: "", + opnrSeComCd: "", +}; + +function createRvwOpnnUploadKey(file: File) { + return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`; +} + +function hasFormContent(formData: ReviewOpinionFormData) { + return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId); +} + +const TechReviewOpinionSection = forwardRef< + TechReviewOpinionSectionHandle, + TechReviewOpinionSectionProps +>(function TechReviewOpinionSection( + { rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false }, + ref, +) { + const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02"); + const [formData, setFormData] = useState({ + ...EMPTY_FORM_DATA, + opnrSeComCd: editableReviewStep ?? "", + }); + const [uploadedFile, setUploadedFile] = useState(null); + + useEffect(() => { + setFormData((prev) => ({ + ...prev, + opnrSeComCd: editableReviewStep ?? "", + })); + }, [editableReviewStep]); + + const { uploadFileAsync, isUploading, uploads } = useTus({ + onSuccess: (atflCd, atflId, _file, fileKey) => { + if (!atflCd || !atflId) return; + + setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd })); + setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev)); + }, + onError: (_error, _file, fileKey) => { + setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev)); + }, + }); + + useImperativeHandle( + ref, + () => ({ + getFormData: () => { + if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) { + return null; + } + + return { + formData: { + ...formData, + opnrSeComCd: editableReviewStep, + }, + rvwList, + }; + }, + resetForm: () => { + setFormData({ + ...EMPTY_FORM_DATA, + opnrSeComCd: editableReviewStep ?? "", + }); + setUploadedFile(null); + }, + }), + [canWriteOpinion, editableReviewStep, formData, rvwList], + ); + + const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO"); + const hasKecoSection = + kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion); + + if (!hasKecoSection) { + return null; + } + + async function handleFileUpload(files: FileList | null) { + if (!files || files.length === 0) return; + + const file = files[0]; + const fileKey = createRvwOpnnUploadKey(file); + const existingAtflCd = formData.atchFileGroupId; + + setUploadedFile({ fileName: file.name, fileKey }); + + try { + await uploadFileAsync(file, { + fileKey, + metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined, + }); + } catch { + // onError callback handles cleanup + } + } + + async function handleFileRemove() { + if (!uploadedFile) return; + + uploads[uploadedFile.fileKey]?.abort(); + + if (uploadedFile.atflId) { + try { + await attachService.deleteAttachmentByAtflId(uploadedFile.atflId); + } catch (error) { + console.error("검토의견 첨부파일 삭제 실패:", error); + } + } + + setUploadedFile(null); + setFormData((prev) => ({ ...prev, atchFileGroupId: "" })); + } + + function renderOpinionForm(reviewStep: ReviewStepCode) { + if (editableReviewStep !== reviewStep || !canWriteOpinion) return null; + + const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false; + + return ( +
+

검토의견 작성

+ + + + 의견내용 + +