diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..ea1cd8dc1 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,88 @@ +{ + // IntelliSense를 사용하여 가능한 특성에 대해 알아보세요. + // 기존 특성에 대한 설명을 보려면 가리킵니다. + // 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요. + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "dev:as 디버그", + "runtimeExecutable": "pnpm", + "runtimeArgs": [ + "run", + "dev:as" + ], + "skipFiles": [ + "/**" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "env": { + "NODE_OPTIONS": "--inspect" + } + }, + { + "type": "node", + "request": "launch", + "name": "dev:cs 디버그", + "runtimeExecutable": "pnpm", + "runtimeArgs": [ + "run", + "dev:cs" + ], + "skipFiles": [ + "/**" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "env": { + "NODE_OPTIONS": "--inspect" + } + }, + { + "type": "node", + "request": "attach", + "name": "dev:cs 서버 연결 (attach)", + "port": 9229, + "restart": true, + "skipFiles": [ + "/**" + ], + "sourceMaps": true, + "resolveSourceMapLocations": [ + "${workspaceFolder}/apps/cs/**", + "!**/node_modules/**" + ] + }, + { + "type": "chrome", + "request": "launch", + "name": "dev:cs 클라이언트 디버그 (Chrome)", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}/apps/cs", + "sourceMaps": true + }, + { + "type": "node", + "request": "launch", + "name": "dev:st 디버그", + "runtimeExecutable": "pnpm", + "runtimeArgs": [ + "run", + "dev:st" + ], + "skipFiles": [ + "/**" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "env": { + "NODE_OPTIONS": "--inspect" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..f60bf453a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "chat.tools.terminal.autoApprove": { + "pnpm": true + } +} \ No newline at end of file diff --git a/apps/as/app/(with-base-layout)/instl-sncn/[id]/modify/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/[id]/modify/page.tsx new file mode 100644 index 000000000..e4718eceb --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn/[id]/modify/page.tsx @@ -0,0 +1,10 @@ +import { CnfmModifyPage } from "@/pages/instl-sncn"; + +export default async function InstlSncnModifyPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx new file mode 100644 index 000000000..3f25c4a3e --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx @@ -0,0 +1,10 @@ +import { CnfmViewPage } from "@/pages/instl-sncn"; + +export default async function InstlSncnViewPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/page.tsx new file mode 100644 index 000000000..7cd7bf82e --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn/page.tsx @@ -0,0 +1,3 @@ +import { CnfmListPage } from "@/pages/instl-sncn"; + +export default CnfmListPage; diff --git a/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx new file mode 100644 index 000000000..f2e0cd185 --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx @@ -0,0 +1,3 @@ +import { CnfmRegistPage } from "@/pages/instl-sncn"; + +export default CnfmRegistPage; diff --git a/apps/as/docs/approval-line-management/SPEC.md b/apps/as/docs/approval-line-management/SPEC.md index 8d76274ff..9af80580b 100644 --- a/apps/as/docs/approval-line-management/SPEC.md +++ b/apps/as/docs/approval-line-management/SPEC.md @@ -80,11 +80,11 @@ BEGIN // 영역 B: 행 추가 (인라인 신규 행) ON [행 추가] 버튼 CLICK DO VALIDATE 서식 선택됨 - VALIDATE 편집 중인 행 없음 (editingRowId == null) + VALIDATE 편집 중인 행 없음 (editingRowKey == null) ADD 영역 B-2 그리드 맨 아래 편집 가능 행 - SET editingRowId = 신규 행 임시 키 + SET editingRowKey = rowKey (draft-{timestamp}) SET 편집상태 = 신규 - SET aplnId = 클라이언트 임시 ID (저장 시 서버 등록용 ID로 전송) + SET aplnId = 미설정 (POST 저장 시 서버 fn_gen_apln_id 발급) SET atrzSeqNo = 현재 행 수 + 1 SET atrzSeComCd, aprvrUuid = 빈 값 DISABLE 나머지 행 편집 · [행 추가] · 드래그 · [결재선 저장](정책에 따라) @@ -182,13 +182,13 @@ END ### 4-2. 그리드 컬럼 -| 컬럼명 | Field | 타입 | 비고 | -| ------------------ | -------------- | ------ | ---------------------------------------- | -| 전자결재서식아이디 | `elaprTmpltId` | string | 등록 시 클라이언트 또는 정책에 따라 발급 | -| 결재서식명 | `elaprTmpltNm` | string | | -| 문서구분 | `docSeComNm` | string | 코드 `docSeComCd`, 상위코드 `ASDOCSECD` | -| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` | -| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 | +| 컬럼명 | Field | 타입 | 비고 | +| ------------------ | -------------- | ------ | --------------------------------------- | +| 전자결재서식아이디 | `elaprTmpltId` | string | 등록 시 서버(DB 함수) 자동 발급 | +| 결재서식명 | `elaprTmpltNm` | string | | +| 문서구분 | `docSeComNm` | string | 코드 `docSeComCd`, 상위코드 `ASDOCSECD` | +| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` | +| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 | - 행 클릭 시: `elaprTmpltId` 선택 → 영역 B 라벨·B-2 그리드 갱신. - 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`). @@ -203,11 +203,11 @@ END ### 4-4. 다이얼로그(개요) -| 다이얼로그명 | 트리거 | 입력 항목 | 비고 | -| ------------------ | ----------- | ----------------------------------------------- | ---------------------- | -| 결재서식 등록 | [서식 등록] | `elaprTmpltId`, `elaprTmpltNm`, `docSeComCd` | `elaprTmpltExpln` 선택 | -| 결재서식 수정 | 행별 [수정] | `elaprTmpltNm`, `docSeComCd`, `elaprTmpltExpln` | ID는 변경 불가 | -| 결재서식 삭제 확인 | 행별 [삭제] | (확인/취소) | | +| 다이얼로그명 | 트리거 | 입력 항목 | 비고 | +| ------------------ | ----------- | ----------------------------------------------- | ----------------------------------------- | +| 결재서식 등록 | [서식 등록] | `elaprTmpltNm`, `docSeComCd` | `elaprTmpltExpln` 선택, ID는 저장 후 응답 | +| 결재서식 수정 | 행별 [수정] | `elaprTmpltNm`, `docSeComCd`, `elaprTmpltExpln` | ID는 변경 불가 | +| 결재서식 삭제 확인 | 행별 [삭제] | (확인/취소) | | --- @@ -230,7 +230,7 @@ END | 컬럼명 | Field | 입력 UI (편집 모드) | 비고 | | ------------- | ------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- | | (드래그 핸들) | — | — | `useSortable` 연동 | -| 결재선아이디 | `aplnId` | 읽기 전용 | 신규 행: 임시 ID 표시, 저장 후 서버 ID | +| 결재선아이디 | `aplnId` | 읽기 전용 | 신규 행: `—`, 저장 후 서버 `aplnId` 표시 | | 결재순서번호 | `atrzSeqNo` | 숫자 Input | 드래그 시 자동 재부여 가능 | | 결재구분 | `atrzSeComCd` | Select (`ATRZSECD` 공통코드) | 표시명 `atrzSeComNm` (조회 행) | | 결재자 | `aprvrUuid` | 읽기 전용 이름 + **[선택]** 버튼 | 응답에 성명 없음 — 선택 시 로컬 `aprvrNm` 유지 | @@ -303,7 +303,7 @@ END | `docSeComNm` | string? | 문서구분명 | | `regDt` | string? | 등록일시 | -**ElaprTmpltSaveRequest**: `elaprTmpltId`_, `elaprTmpltNm`_, `elaprTmpltExpln?`, `docSeComCd?` +**ElaprTmpltSaveRequest**: `elaprTmpltNm`\_, `elaprTmpltExpln?`, `docSeComCd?` **ElaprTmpltUpdateRequest**: `elaprTmpltNm`\*, `elaprTmpltExpln?`, `docSeComCd?` @@ -333,7 +333,7 @@ END | `atrzSeqNo` | number? | 결재순서번호 | | `regDt` | string? | 등록일시 | -**ElaprAplnSaveRequest**: `aplnId`_, `elaprTmpltId`_, `aprvrUuid?`, `atrzSeComCd?`, `atrzSeqNo?` +**ElaprAplnSaveRequest**: `elaprTmpltId`\_, `aprvrUuid?`, `atrzSeComCd?`, `atrzSeqNo?` **ElaprAplnUpdateRequest**: `elaprTmpltId`\*, `aprvrUuid?`, `atrzSeComCd?`, `atrzSeqNo?` @@ -398,7 +398,6 @@ END ### 7-5. 미확정 / 검증 필요 - 검토인력 API 경로·DTO. -- `aplnId` / `elaprTmpltId` 발급 정책(클라이언트 생성 규칙). - `@dnd-kit` + react-table 행 인라인 Input 동시 적용 POC. - 결재자 성명 표시: `aprvrUuid` → 사용자명 조회 API. - 영역 A·B 페이지네이션·검색 UI 노출 여부. diff --git a/apps/as/middleware.ts b/apps/as/middleware.ts index d9239dc40..0b7be3348 100644 --- a/apps/as/middleware.ts +++ b/apps/as/middleware.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config"; -const serverApiUrl = process.env.SERVER_API_URL ?? "http://localhost:3000"; +const serverApiUrl = process.env.SERVER_API_URL; // CSP 헤더 설정 함수 (img-src만) function addSecurityHeaders(response: NextResponse): NextResponse { diff --git a/apps/as/package.json b/apps/as/package.json index 38bbc9fdd..389b5bc05 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 3003", "build": "next build", - "start": "next start", + "start": "next start -p 3003", "lint": "next lint", "lint:fsd": "steiger ." }, diff --git a/apps/as/src/entities/elapr/model/elapr-apln-interface.ts b/apps/as/src/entities/elapr/model/elapr-apln-interface.ts index 1aed3c1ce..3d328d3f4 100644 --- a/apps/as/src/entities/elapr/model/elapr-apln-interface.ts +++ b/apps/as/src/entities/elapr/model/elapr-apln-interface.ts @@ -18,7 +18,6 @@ export interface ElaprAplnListParams extends PageableParams { } export interface ElaprAplnSaveRequest { - aplnId: string; elaprTmpltId: string; aprvrUuid?: string; atrzSeComCd?: string; diff --git a/apps/as/src/entities/elapr/model/elapr-tmplt-interface.ts b/apps/as/src/entities/elapr/model/elapr-tmplt-interface.ts index 351b954c9..4d87d97e8 100644 --- a/apps/as/src/entities/elapr/model/elapr-tmplt-interface.ts +++ b/apps/as/src/entities/elapr/model/elapr-tmplt-interface.ts @@ -20,7 +20,6 @@ export interface ElaprTmpltSearchParams extends PageableParams { } export interface ElaprTmpltSaveRequest { - elaprTmpltId: string; elaprTmpltNm: string; elaprTmpltExpln?: string; docSeComCd?: string; diff --git a/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts b/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts new file mode 100644 index 000000000..29d15fcf0 --- /dev/null +++ b/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts @@ -0,0 +1,24 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + InstlSncnMng, + InstlSncnMngSearchParams, + InstlSncnMngService, + SpringPage, +} from "../model/instl-sncn-interface"; + +export class InstlSncnApiService implements InstlSncnMngService { + public async getPage(params: InstlSncnMngSearchParams): Promise> { + const response = await client.get>(ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN, { + params, + }); + return response.data; + } + + public async getById(id: string): Promise { + const response = await client.get(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`); + return response.data; + } +} + +export const InstlSncnApi = new InstlSncnApiService(); diff --git a/apps/as/src/entities/instl-sncn/index.ts b/apps/as/src/entities/instl-sncn/index.ts new file mode 100644 index 000000000..8fdbb442f --- /dev/null +++ b/apps/as/src/entities/instl-sncn/index.ts @@ -0,0 +1,6 @@ +export { InstlSncnApi } from "./api/instl-sncn-service"; +export type { + InstlSncnMng, + InstlSncnMngSearchParams, + SpringPage, +} from "./model/instl-sncn-interface"; diff --git a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts new file mode 100644 index 000000000..00a51b76b --- /dev/null +++ b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts @@ -0,0 +1,58 @@ +export interface SpringPage { + content: T[]; + totalElements: number; + totalPages: number; + number: number; + size: number; + first: boolean; + last: boolean; + numberOfElements: number; +} + +export interface InstlSncnMngSearchParams { + bizNm?: string; + taskSeComCd?: string; + sidoComCd?: string; + sggComCd?: string; + page?: number; + size?: number; + sort?: string; +} + +export interface InstlSncnMng { + instlSncnDocNo: string; + dongId: string; + sidoNm?: string; + sggNm?: string; + chrgrNm?: string; + instNm?: string; + envoSeComCd: string; + envoSeCdNm: string; + taskSeComCd: string; + taskSeCdNm: string; + bgtBizCd: string; + bgtAplyYr: string; + bizNm: string; + bizBgngYr: string; + bizEndYr: string; + bizCn: string; + aplcntNm: string; + aplcntMblTelno: string; + aplcntOgdpNm: string; + aplcntEmlAddr: string; + dsgnrNm: string; + dsgnrTelno: string; + dsgnrOgdpNm: string; + dsgnrEmlAddr: string; + atchFileGroupId: string; + prgrsSttsComCd: string; + prgrsSttsCdNm: string; + regDt: string; + mdfcnDt: string; + rgtrNm: string; +} + +export interface InstlSncnMngService { + getPage: (params: InstlSncnMngSearchParams) => Promise>; + getById: (id: string) => Promise; +} diff --git a/apps/as/src/features/elapr-tmplt-dialog/ui/elapr-tmplt-form-dialog.tsx b/apps/as/src/features/elapr-tmplt-dialog/ui/elapr-tmplt-form-dialog.tsx index 0836fc3b6..79053b026 100644 --- a/apps/as/src/features/elapr-tmplt-dialog/ui/elapr-tmplt-form-dialog.tsx +++ b/apps/as/src/features/elapr-tmplt-dialog/ui/elapr-tmplt-form-dialog.tsx @@ -3,7 +3,6 @@ import { useEffect } from "react"; import { useForm } from "react-hook-form"; -import { generateClientId } from "@/shared/lib"; import { CodeSelect } from "@/entities/code"; import type { ElaprTmplt } from "@/entities/elapr"; import { @@ -19,7 +18,7 @@ import { } from "@common/components"; export interface ElaprTmpltFormValues { - elaprTmpltId: string; + elaprTmpltId?: string; elaprTmpltNm: string; docSeComCd: string; elaprTmpltExpln: string; @@ -44,7 +43,6 @@ function ElaprTmpltFormDialog({ }: ElaprTmpltFormDialogProps) { const { register, handleSubmit, reset, setValue, watch } = useForm({ defaultValues: { - elaprTmpltId: "", elaprTmpltNm: "", docSeComCd: "", elaprTmpltExpln: "", @@ -67,8 +65,6 @@ function ElaprTmpltFormDialog({ } reset({ - // TBD: elaprTmpltId 발급 정책 — 현재 클라이언트 임시 ID - elaprTmpltId: generateClientId("TMPL"), elaprTmpltNm: "", docSeComCd: "", elaprTmpltExpln: "", @@ -82,10 +78,12 @@ function ElaprTmpltFormDialog({ {mode === "create" ? "결재서식 등록" : "결재서식 수정"}
-
- - -
+ {mode === "edit" ? ( +
+ + +
+ ) : null}
diff --git a/apps/as/src/pages/instl-sncn/index.ts b/apps/as/src/pages/instl-sncn/index.ts new file mode 100644 index 000000000..570133ba4 --- /dev/null +++ b/apps/as/src/pages/instl-sncn/index.ts @@ -0,0 +1,4 @@ +export { CnfmListPage } from "./ui/cnfm-list-page"; +export { CnfmViewPage } from "./ui/cnfm-view-page"; +export { CnfmRegistPage } from "./ui/cnfm-regist-page"; +export { CnfmModifyPage } from "./ui/cnfm-modify-page"; diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx b/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx new file mode 100644 index 000000000..8b0bb9f38 --- /dev/null +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { useQuery } from "@tanstack/react-query"; + +import { dayjs } from "@/shared/lib"; +import { InstlSncnApi, type InstlSncnMngSearchParams } from "@/entities/instl-sncn"; + +import "./cnfm-page.css"; + +const TASK_OPTIONS = [ + { value: "", label: "전체" }, + { value: "SF", label: "하수처리시설" }, + { value: "SP", label: "하수관로" }, + { value: "RE", label: "재이용수" }, +]; + +const SIDO_OPTIONS = [ + { value: "", label: "전체" }, + { value: "11", label: "서울특별시" }, + { value: "26", label: "부산광역시" }, + { value: "27", label: "대구광역시" }, + { value: "28", label: "인천광역시" }, + { value: "29", label: "광주광역시" }, + { value: "30", label: "대전광역시" }, + { value: "31", label: "울산광역시" }, + { value: "36", label: "세종특별자치시" }, + { value: "41", label: "경기도" }, + { value: "43", label: "충청북도" }, + { value: "44", label: "충청남도" }, + { value: "45", label: "전라북도" }, + { value: "46", label: "전라남도" }, + { value: "47", label: "경상북도" }, + { value: "48", label: "경상남도" }, + { value: "50", label: "제주특별자치도" }, +]; + +type StepCode = "SV" | "ST" | "ER" | "TE" | "TR" | "TP" | "TA"; + +const STEPS: { code: StepCode; label: string }[] = [ + { code: "SV", label: "저장" }, + { code: "ST", label: "신청완료" }, + { code: "ER", label: "환경청 검토" }, + { code: "TE", label: "기술검토" }, + { code: "TR", label: "검토결과 회신" }, + { code: "TP", label: "기술평가" }, + { code: "TA", label: "최종승인" }, +]; + +const PAGE_GROUP_SIZE = 5; + +function CnfmListPage() { + const router = useRouter(); + const [bizNm, setBizNm] = useState(""); + const [taskSeComCd, setTaskSeComCd] = useState(""); + const [sidoComCd, setSidoComCd] = useState(""); + const [sggComCd, setSggComCd] = useState(""); + const [search, setSearch] = useState({ + bizNm: "", + taskSeComCd: "", + sidoComCd: "", + sggComCd: "", + }); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const query = useQuery({ + queryKey: ["instl-sncn-list", search, pageIndex, pageSize], + queryFn: () => + InstlSncnApi.getPage({ + ...search, + page: pageIndex, + size: pageSize, + sort: "regDt,desc", + }), + }); + + const totalPages = query.data?.totalPages ?? 0; + + const pageGroupStart = Math.floor(pageIndex / PAGE_GROUP_SIZE) * PAGE_GROUP_SIZE; + const pageNumbers = useMemo(() => { + const end = Math.min(pageGroupStart + PAGE_GROUP_SIZE, totalPages); + return Array.from({ length: end - pageGroupStart }, (_, i) => pageGroupStart + i); + }, [pageGroupStart, totalPages]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setPageIndex(0); + setSearch({ bizNm, taskSeComCd, sidoComCd, sggComCd }); + }; + + return ( +
+ {/* 검색 영역 */} +
+ +
+ + +
+ +
+ + +
+ +
+ + setBizNm(e.target.value)} + placeholder="사업명을 입력하세요" + /> +
+ +
+ + +
+ + + +
+ + {/* 목록 영역 */} +
+
+

설치인가 목록

+
+ + +
+
+ +
+ + + + + + + + + + + + + + + {query.isLoading && ( + + + + )} + {!query.isLoading && (query.data?.content?.length ?? 0) === 0 && ( + + + + )} + {(query.data?.content ?? []).map((row) => { + const currentStepIndex = STEPS.findIndex((s) => s.code === row.prgrsSttsComCd); + return ( + router.push(`/instl-sncn/${row.instlSncnDocNo}`)} + > + + + + + + + + + + ); + })} + +
시도시군구사업명구분담당자해당기관진행상황시행일
조회 중입니다...
조회 결과가 없습니다.
{row.sidoNm || "-"}{row.sggNm || "-"}{row.bizNm || "-"}{row.taskSeCdNm || row.taskSeComCd || "-"}{row.chrgrNm || row.aplcntNm || "-"}{row.instNm || row.aplcntOgdpNm || "-"} +
+
+ {STEPS.slice(0, 4).map((step, index) => { + const state = + index < currentStepIndex + ? "completed" + : index === currentStepIndex + ? "current" + : "pending"; + return ( + + + {step.label} + + ); + })} +
+
+ {STEPS.slice(4).map((step, i) => { + const index = i + 4; + const state = + index < currentStepIndex + ? "completed" + : index === currentStepIndex + ? "current" + : "pending"; + return ( + + + {step.label} + + ); + })} +
+
+
{row.regDt ? dayjs(row.regDt).format("YYYY-MM-DD") : "-"}
+
+ + {/* 페이지네이션 */} +
+ + + + {pageNumbers.map((pageNo) => ( + + ))} + + + +
+
+
+ ); +} + +export { CnfmListPage }; diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-modify-page.tsx b/apps/as/src/pages/instl-sncn/ui/cnfm-modify-page.tsx new file mode 100644 index 000000000..854d2515d --- /dev/null +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-modify-page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { Button, Input } from "@common/components"; + +import { InstlSncnApi } from "@/entities/instl-sncn"; + +import "./cnfm-page.css"; + +function CnfmModifyPage({ id }: { id: string }) { + const router = useRouter(); + const query = useQuery({ + queryKey: ["instl-sncn-modify", id], + queryFn: () => InstlSncnApi.getById(id), + }); + + if (query.isLoading) { + return
수정 정보를 불러오는 중입니다...
; + } + + if (!query.data) { + return
수정 대상을 찾을 수 없습니다.
; + } + + const data = query.data; + + return ( +
+
+

설치인가 수정

+

+ 상세 조회 API 데이터를 기반으로 수정 화면을 구성했습니다. +

+
+ +
+

기본 정보

+ + + + + + + + + + + + + + + + + + + + + +
문서번호{data.instlSncnDocNo}진행상태{data.prgrsSttsCdNm || data.prgrsSttsComCd || "-"}
사업명 + + 사업내용 + +
설계자명 + + 설계자 연락처 + +
+
+ +
+ + +
+
+ ); +} + +export { CnfmModifyPage }; diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-page.css b/apps/as/src/pages/instl-sncn/ui/cnfm-page.css new file mode 100644 index 000000000..49d7a6bce --- /dev/null +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-page.css @@ -0,0 +1,490 @@ +/* ============================ + 페이지 레이아웃 +============================= */ +.cnfm-page-wrap { + padding: 2rem 2.4rem; +} + +/* ============================ + 카드 +============================= */ +.cnfm-card { + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #ffffff; + padding: 1.6rem 2rem; +} + +.cnfm-card.mt-4 { + margin-top: 1.6rem; +} + +/* ============================ + 검색 바 +============================= */ +.cnfm-search-bar { + display: flex; + align-items: center; + gap: 1.2rem; + flex-wrap: wrap; +} + +.cnfm-search-field { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.cnfm-search-field--grow { + flex: 1; + min-width: 18rem; +} + +.cnfm-search-label { + font-size: 1.3rem; + font-weight: 600; + color: #374151; + white-space: nowrap; +} + +.cnfm-input { + height: 3.6rem; + border: 1px solid #d1d5db; + border-radius: 6px; + padding: 0 1rem; + font-size: 1.3rem; + color: #111827; + width: 100%; + outline: none; + background: #fff; +} + +.cnfm-input:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); +} + +.cnfm-select { + height: 3.6rem; + border: 1px solid #d1d5db; + border-radius: 6px; + padding: 0 0.8rem; + font-size: 1.3rem; + color: #111827; + background: #fff; + min-width: 11rem; + cursor: pointer; + outline: none; +} + +.cnfm-select--wide { + min-width: 16rem; +} + +.cnfm-select:focus { + border-color: #3b82f6; +} + +.cnfm-btn-search { + height: 3.6rem; + padding: 0 2rem; + background: #3b82f6; + color: #fff; + font-size: 1.3rem; + font-weight: 600; + border: none; + border-radius: 6px; + cursor: pointer; + white-space: nowrap; +} + +.cnfm-btn-search:hover { + background: #2563eb; +} + +/* ============================ + 목록 헤더 +============================= */ +.cnfm-list-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.2rem; +} + +.cnfm-list-title { + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; +} + +.cnfm-list-actions { + display: flex; + align-items: center; + gap: 0.8rem; +} + +.cnfm-btn-register { + height: 3.4rem; + padding: 0 1.6rem; + background: #22c55e; + color: #fff; + font-size: 1.3rem; + font-weight: 600; + border: none; + border-radius: 6px; + cursor: pointer; + white-space: nowrap; +} + +.cnfm-btn-register:hover { + background: #16a34a; +} + +/* ============================ + 테이블 +============================= */ +.cnfm-table-wrap { + overflow-x: auto; +} + +.cnfm-table { + width: 100%; + border-collapse: collapse; + font-size: 1.3rem; +} + +.cnfm-table th, +.cnfm-table td { + border-bottom: 1px solid #e5e7eb; + padding: 1.2rem 0.8rem; + text-align: center; + vertical-align: middle; +} + +.cnfm-table thead th { + background: #f8fafc; + color: #374151; + font-weight: 600; + font-size: 1.25rem; + border-bottom: 2px solid #e2e8f0; +} + +.cnfm-table tbody td { + color: #111827; +} + +.cnfm-td-empty { + padding: 3rem 0; + color: #9ca3af; + font-size: 1.3rem; +} + +.cnfm-row-clickable { + cursor: pointer; +} + +.cnfm-row-clickable:hover { + background: #f8fafc; +} + +/* ============================ + 진행상황 스텝 배지 +============================= */ +.cnfm-step-wrap { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: flex-start; +} + +.cnfm-step-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: nowrap; +} + +.cnfm-step-badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border-radius: 9999px; + padding: 0.3rem 0.9rem; + font-size: 1.15rem; + font-weight: 500; + white-space: nowrap; + border: 1px solid transparent; +} + +.cnfm-step-badge--completed { + background: #dbeafe; + border-color: #93c5fd; + color: #1d4ed8; +} + +.cnfm-step-badge--current { + background: #fed7aa; + border-color: #fb923c; + color: #c2410c; + font-weight: 700; +} + +.cnfm-step-badge--pending { + background: #f3f4f6; + border-color: #d1d5db; + color: #9ca3af; +} + +.cnfm-step-dot { + display: inline-block; + width: 0.7rem; + height: 0.7rem; + border-radius: 50%; + flex-shrink: 0; +} + +.cnfm-step-dot--completed { + background: #3b82f6; +} + +.cnfm-step-dot--current { + background: #f97316; +} + +.cnfm-step-dot--pending { + background: #d1d5db; +} + +/* ============================ + 페이지네이션 +============================= */ +.cnfm-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 0.4rem; + margin-top: 1.6rem; + padding-bottom: 0.4rem; +} + +.cnfm-page-btn { + min-width: 3.2rem; + height: 3.2rem; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #fff; + font-size: 1.3rem; + color: #374151; + cursor: pointer; + transition: all 0.15s; + padding: 0 0.6rem; +} + +.cnfm-page-btn:hover:not(:disabled) { + border-color: #3b82f6; + color: #3b82f6; +} + +.cnfm-page-btn.active { + border-color: #3b82f6; + background: #3b82f6; + color: #fff; + font-weight: 700; +} + +.cnfm-page-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ============================ + 폼 테이블 (상세/등록 페이지용) +============================= */ +.cnfm-form-table { + width: 100%; + border-collapse: collapse; + font-size: 1.3rem; +} + +.cnfm-form-table th, +.cnfm-form-table td { + border: 1px solid #e5e7eb; + padding: 0.9rem 1rem; +} + +.cnfm-form-table th { + width: 16rem; + background: #f9fafb; + text-align: left; + font-weight: 700; +} + +.cnfm-form-table td { + background: #fff; +} + +.cnfm-action-row { + display: flex; + justify-content: center; + gap: 1rem; + margin-top: 1.8rem; +} + +/* ============================ + 기존 호환 클래스 유지 +============================= */ +.cnfm-section-title { + font-size: 1.8rem; + font-weight: 700; + color: #1f2937; +} + +@media (max-width: 1100px) { + .cnfm-search-bar { + flex-direction: column; + align-items: stretch; + } + .cnfm-search-field { + flex-direction: column; + align-items: flex-start; + } + .cnfm-search-field--grow { + width: 100%; + } +} + + +.cnfm-section-title { + font-size: 1.8rem; + font-weight: 700; + color: #1f2937; +} + +.cnfm-search-grid { + display: grid; + grid-template-columns: 8rem 1fr 6rem 16rem auto auto; + gap: 1.2rem; + align-items: center; +} + +.cnfm-table-wrap { + overflow-x: auto; +} + +.cnfm-table { + width: 100%; + border-collapse: collapse; + font-size: 1.3rem; +} + +.cnfm-table th, +.cnfm-table td { + border: 1px solid #e5e7eb; + padding: 1rem 0.8rem; + text-align: center; + vertical-align: middle; +} + +.cnfm-table thead th { + background: #f3f4f6; + color: #111827; + font-weight: 700; +} + +.cnfm-row-clickable { + cursor: pointer; +} + +.cnfm-row-clickable:hover { + background: #f9fafb; +} + +.cnfm-step { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; + justify-content: center; +} + +.cnfm-step-badge { + border-radius: 9999px; + border: 1px solid #d1d5db; + background: #f9fafb; + color: #6b7280; + font-size: 1.1rem; + padding: 0.25rem 0.65rem; +} + +.cnfm-step-badge.completed { + border-color: #93c5fd; + background: #dbeafe; + color: #1d4ed8; +} + +.cnfm-step-badge.current { + border-color: #16a34a; + background: #dcfce7; + color: #166534; + font-weight: 700; +} + +.cnfm-pagination { + display: flex; + justify-content: center; + gap: 0.6rem; + margin-top: 1.2rem; +} + +.cnfm-pagination button { + min-width: 3.2rem; + height: 3.2rem; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + font-size: 1.3rem; +} + +.cnfm-pagination button.active { + border-color: #2563eb; + background: #2563eb; + color: #fff; + font-weight: 700; +} + +.cnfm-form-table { + width: 100%; + border-collapse: collapse; + font-size: 1.3rem; +} + +.cnfm-form-table th, +.cnfm-form-table td { + border: 1px solid #e5e7eb; + padding: 0.9rem 1rem; +} + +.cnfm-form-table th { + width: 16rem; + background: #f9fafb; + text-align: left; + font-weight: 700; +} + +.cnfm-form-table td { + background: #fff; +} + +.cnfm-action-row { + display: flex; + justify-content: center; + gap: 1rem; + margin-top: 1.8rem; +} + +@media (max-width: 1100px) { + .cnfm-search-grid { + grid-template-columns: 1fr; + } +} diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx new file mode 100644 index 000000000..0b2af414a --- /dev/null +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Button, Input } from "@common/components"; + +import "./cnfm-page.css"; + +function CnfmRegistPage() { + const router = useRouter(); + + return ( +
+
+

설치인가 신청

+

+ AS-IS 화면 설계(신청 화면)를 반영한 입력 레이아웃입니다. +

+
+ +
+

기본 정보

+ + + + + + + + + + + + + + + +
사업명 + + 사업구분 + +
사업기간(시작) + + 사업기간(종료) + +
+
+ +
+

신청자 / 설계자 정보

+ + + + + + + + + + + + + + + +
신청자명 + + 신청자 연락처 + +
설계자명 + + 설계자 소속 + +
+
+ +
+ + + +
+
+ ); +} + +export { CnfmRegistPage }; diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-view-page.tsx b/apps/as/src/pages/instl-sncn/ui/cnfm-view-page.tsx new file mode 100644 index 000000000..6593ee011 --- /dev/null +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-view-page.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@common/components"; + +import { InstlSncnApi } from "@/entities/instl-sncn"; +import { dayjs } from "@/shared/lib"; + +import "./cnfm-page.css"; + +function CnfmViewPage({ id }: { id: string }) { + const router = useRouter(); + + const query = useQuery({ + queryKey: ["instl-sncn-detail", id], + queryFn: () => InstlSncnApi.getById(id), + }); + + if (query.isLoading) { + return
상세 정보를 불러오는 중입니다...
; + } + + if (!query.data) { + return
상세 정보를 찾을 수 없습니다.
; + } + + const data = query.data; + + return ( +
+
+

설치인가 상세보기

+

+ 문서번호: {data.instlSncnDocNo} +

+
+ +
+

기본 정보

+ + + + + + + + + + + + + + + + + + + + + + + + + +
사업명{data.bizNm || "-"}업무구분{data.taskSeCdNm || data.taskSeComCd || "-"}
사업기간 + {(data.bizBgngYr || "-") + " ~ " + (data.bizEndYr || "-")} + 진행상태{data.prgrsSttsCdNm || data.prgrsSttsComCd || "-"}
행정동ID{data.dongId || "-"}예산사업코드{data.bgtBizCd || "-"}
사업내용{data.bizCn || "-"}
+
+ +
+

담당자 정보

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
신청자명{data.aplcntNm || "-"}신청자 연락처{data.aplcntMblTelno || "-"}
신청자 소속{data.aplcntOgdpNm || "-"}신청자 이메일{data.aplcntEmlAddr || "-"}
설계자명{data.dsgnrNm || "-"}설계자 연락처{data.dsgnrTelno || "-"}
설계자 소속{data.dsgnrOgdpNm || "-"}설계자 이메일{data.dsgnrEmlAddr || "-"}
+
+ +
+

이력 정보

+ + + + + + + + + + + + + + + +
등록일시{data.regDt ? dayjs(data.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}수정일시{data.mdfcnDt ? dayjs(data.mdfcnDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
등록자명{data.rgtrNm || "-"}첨부파일그룹ID{data.atchFileGroupId || "-"}
+
+ +
+ + +
+
+ ); +} + +export { CnfmViewPage }; diff --git a/apps/as/src/shared/lib/generate-client-id.ts b/apps/as/src/shared/lib/generate-client-id.ts deleted file mode 100644 index f62cebe5f..000000000 --- a/apps/as/src/shared/lib/generate-client-id.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** TBD: elaprTmpltId / aplnId 서버 발급 정책 확정 후 클라이언트 생성 규칙 교체 */ -export function generateClientId(prefix: string): string { - const suffix = crypto.randomUUID().replace(/-/g, "").slice(0, 8).toUpperCase(); - return `${prefix}-${suffix}`; -} diff --git a/apps/as/src/shared/lib/index.ts b/apps/as/src/shared/lib/index.ts index faae909e3..1c38f3c97 100644 --- a/apps/as/src/shared/lib/index.ts +++ b/apps/as/src/shared/lib/index.ts @@ -1,6 +1,5 @@ export { getCookie, removeCookie, setCookie } from "./cookie"; export { dayjs } from "./dayjs"; -export { generateClientId } from "./generate-client-id"; export { matchQueryStatus } from "./match-query-status"; export { queryClient } from "./query-client"; export { useAlertStore } from "./use-alert-store"; diff --git a/apps/as/src/widgets/elapr-apln-grid/lib/use-apln-rows.ts b/apps/as/src/widgets/elapr-apln-grid/lib/use-apln-rows.ts index 48ff7e076..719d5f907 100644 --- a/apps/as/src/widgets/elapr-apln-grid/lib/use-apln-rows.ts +++ b/apps/as/src/widgets/elapr-apln-grid/lib/use-apln-rows.ts @@ -4,13 +4,12 @@ import { useCallback, useEffect, useState } from "react"; import { arrayMove } from "@dnd-kit/sortable"; -import { generateClientId } from "@/shared/lib"; import type { ElaprApln } from "@/entities/elapr"; import type { ElaprAplnRow } from "../model/types"; function toRow(item: ElaprApln): ElaprAplnRow { - return { ...item, isNew: false, isDirty: false }; + return { ...item, rowKey: item.aplnId, isNew: false, isDirty: false }; } interface UseAplnRowsOptions { @@ -18,32 +17,36 @@ interface UseAplnRowsOptions { serverRows: ElaprApln[] | undefined; } +export function createDraftRowKey(): string { + return `draft-${Date.now()}`; +} + export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { const [rows, setRows] = useState([]); - const [editingRowId, setEditingRowId] = useState(null); + const [editingRowKey, setEditingRowKey] = useState(null); useEffect(() => { if (!elaprTmpltId) { setRows([]); - setEditingRowId(null); + setEditingRowKey(null); return; } setRows((serverRows ?? []).map(toRow)); - setEditingRowId(null); + setEditingRowKey(null); }, [elaprTmpltId, serverRows]); - const isEditingLocked = editingRowId != null; + const isEditingLocked = editingRowKey != null; const addRow = useCallback(() => { if (!elaprTmpltId || isEditingLocked) return; - const tempId = generateClientId("APLN"); + const rowKey = createDraftRowKey(); const nextSeq = rows.length + 1; setRows((prev) => [ ...prev, { - aplnId: tempId, + rowKey, elaprTmpltId, atrzSeqNo: nextSeq, atrzSeComCd: "", @@ -53,40 +56,40 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { isDirty: true, }, ]); - setEditingRowId(tempId); + setEditingRowKey(rowKey); }, [elaprTmpltId, isEditingLocked, rows.length]); const startEdit = useCallback( - (aplnId: string) => { + (rowKey: string) => { if (isEditingLocked) return; - setEditingRowId(aplnId); + setEditingRowKey(rowKey); setRows((prev) => - prev.map((row) => (row.aplnId === aplnId ? { ...row, snapshot: { ...row } } : row)), + prev.map((row) => (row.rowKey === rowKey ? { ...row, snapshot: { ...row } } : row)), ); }, [isEditingLocked], ); const updateRow = useCallback( - (aplnId: string, patch: Partial) => { - if (editingRowId !== aplnId) return; + (rowKey: string, patch: Partial) => { + if (editingRowKey !== rowKey) return; setRows((prev) => - prev.map((row) => (row.aplnId === aplnId ? { ...row, ...patch, isDirty: true } : row)), + prev.map((row) => (row.rowKey === rowKey ? { ...row, ...patch, isDirty: true } : row)), ); }, - [editingRowId], + [editingRowKey], ); const cancelEdit = useCallback(() => { - if (!editingRowId) return; + if (!editingRowKey) return; setRows((prev) => { - const target = prev.find((r) => r.aplnId === editingRowId); + const target = prev.find((r) => r.rowKey === editingRowKey); if (target?.isNew) { - return prev.filter((r) => r.aplnId !== editingRowId); + return prev.filter((r) => r.rowKey !== editingRowKey); } return prev.map((row) => { - if (row.aplnId !== editingRowId) return row; + if (row.rowKey !== editingRowKey) return row; if (row.snapshot) { const { snapshot: _s, isNew: _n, isDirty: _d, ...restored } = row.snapshot; return { ...restored, isNew: false, isDirty: false }; @@ -94,27 +97,27 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { return { ...row, isDirty: false }; }); }); - setEditingRowId(null); - }, [editingRowId]); + setEditingRowKey(null); + }, [editingRowKey]); const clearEditing = useCallback(() => { - setEditingRowId(null); + setEditingRowKey(null); }, []); - const removeRowLocal = useCallback((aplnId: string) => { - setRows((prev) => prev.filter((r) => r.aplnId !== aplnId)); - setEditingRowId((id) => (id === aplnId ? null : id)); + const removeRowLocal = useCallback((rowKey: string) => { + setRows((prev) => prev.filter((r) => r.rowKey !== rowKey)); + setEditingRowKey((key) => (key === rowKey ? null : key)); }, []); const reorderRows = useCallback( - (activeId: string, overId: string): ElaprAplnRow[] => { + (activeKey: string, overKey: string): ElaprAplnRow[] => { if (isEditingLocked) return []; let newRows: ElaprAplnRow[] = []; setRows((prev) => { - const oldIndex = prev.findIndex((r) => r.aplnId === activeId); - const newIndex = prev.findIndex((r) => r.aplnId === overId); + const oldIndex = prev.findIndex((r) => r.rowKey === activeKey); + const newIndex = prev.findIndex((r) => r.rowKey === overKey); if (oldIndex < 0 || newIndex < 0) return prev; const moved = arrayMove(prev, oldIndex, newIndex).map((row, idx) => ({ @@ -134,7 +137,7 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { return { rows, setRows, - editingRowId, + editingRowKey, isEditingLocked, addRow, startEdit, diff --git a/apps/as/src/widgets/elapr-apln-grid/model/types.ts b/apps/as/src/widgets/elapr-apln-grid/model/types.ts index 7950d4ea8..b2066fc78 100644 --- a/apps/as/src/widgets/elapr-apln-grid/model/types.ts +++ b/apps/as/src/widgets/elapr-apln-grid/model/types.ts @@ -1,7 +1,10 @@ import type { ElaprApln } from "@/entities/elapr"; /** 그리드 로컬 행 — API 응답 + UI 보조 필드 */ -export interface ElaprAplnRow extends ElaprApln { +export interface ElaprAplnRow extends Omit { + /** React key · DnD · 편집 잠금용 — 서버 aplnId와 분리 */ + rowKey: string; + aplnId?: string; /** 결재자 표시명 — 응답 DTO에 없음, 선택 시 로컬 유지 */ aprvrNm?: string; isNew?: boolean; diff --git a/apps/as/src/widgets/elapr-apln-grid/ui/elapr-apln-grid.tsx b/apps/as/src/widgets/elapr-apln-grid/ui/elapr-apln-grid.tsx index df573656a..f8c7d1571 100644 --- a/apps/as/src/widgets/elapr-apln-grid/ui/elapr-apln-grid.tsx +++ b/apps/as/src/widgets/elapr-apln-grid/ui/elapr-apln-grid.tsx @@ -36,7 +36,7 @@ interface ElaprAplnGridProps { function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { const [pickerOpen, setPickerOpen] = useState(false); - const [pickingRowId, setPickingRowId] = useState(null); + const [pickingRowKey, setPickingRowKey] = useState(null); const alert = useAlertStore((state) => state.setMessage); @@ -53,7 +53,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { const { rows, - editingRowId, + editingRowKey, isEditingLocked, addRow, startEdit, @@ -73,7 +73,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; - const payloadRows = reorderRows(String(active.id), String(over.id)); + reorderRows(String(active.id), String(over.id)); }; const handleRowSave = async (row: ElaprAplnRow) => { @@ -82,7 +82,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { alert(fieldError); return; } - const dupError = validateUniqueSeqNo(rows.map((r) => (r.aplnId === row.aplnId ? row : r))); + const dupError = validateUniqueSeqNo(rows.map((r) => (r.rowKey === row.rowKey ? row : r))); if (dupError) { alert(dupError); return; @@ -92,13 +92,12 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { if (row.isNew) { await createMutation.mutateAsync({ - aplnId: row.aplnId, elaprTmpltId, aprvrUuid: row.aprvrUuid, atrzSeComCd: row.atrzSeComCd, atrzSeqNo: row.atrzSeqNo, }); - } else { + } else if (row.aplnId) { await updateMutation.mutateAsync({ aplnId: row.aplnId, body: { @@ -118,11 +117,11 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { if (!window.confirm("선택한 결재선을 삭제하시겠습니까?")) return; if (row.isNew) { - removeRowLocal(row.aplnId); + removeRowLocal(row.rowKey); return; } - if (!elaprTmpltId) return; + if (!elaprTmpltId || !row.aplnId) return; await deleteMutation.mutateAsync({ aplnId: row.aplnId, elaprTmpltId }); await aplnQuery.refetch(); @@ -148,13 +147,12 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { if (row.isNew) { await createMutation.mutateAsync({ - aplnId: row.aplnId, elaprTmpltId, aprvrUuid: row.aprvrUuid, atrzSeComCd: row.atrzSeComCd, atrzSeqNo: row.atrzSeqNo, }); - } else { + } else if (row.aplnId) { await updateMutation.mutateAsync({ aplnId: row.aplnId, body: { @@ -172,12 +170,12 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { }; const handleReviewerSelect = (reviewer: ReviewerOption) => { - if (!pickingRowId) return; - updateRow(pickingRowId, { + if (!pickingRowKey) return; + updateRow(pickingRowKey, { aprvrUuid: reviewer.uuid, aprvrNm: reviewer.name, }); - setPickingRowId(null); + setPickingRowKey(null); }; return ( @@ -229,23 +227,23 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { r.aplnId)} + items={rows.map((r) => r.rowKey)} strategy={verticalListSortingStrategy} > {rows.map((row) => ( startEdit(row.aplnId)} + onStartEdit={() => startEdit(row.rowKey)} onSave={() => handleRowSave(row)} onCancel={cancelEdit} onDelete={() => handleRowDelete(row)} - onChange={(patch) => updateRow(row.aplnId, patch)} + onChange={(patch) => updateRow(row.rowKey, patch)} onPickAprvr={() => { - setPickingRowId(row.aplnId); + setPickingRowKey(row.rowKey); setPickerOpen(true); }} /> diff --git a/apps/as/src/widgets/elapr-apln-grid/ui/sortable-apln-row.tsx b/apps/as/src/widgets/elapr-apln-grid/ui/sortable-apln-row.tsx index a7c0c25af..f05d6ee64 100644 --- a/apps/as/src/widgets/elapr-apln-grid/ui/sortable-apln-row.tsx +++ b/apps/as/src/widgets/elapr-apln-grid/ui/sortable-apln-row.tsx @@ -37,7 +37,7 @@ function SortableAplnRow({ const { data: atrzSeCodes } = useCodeList("ATRZSECD"); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: row.aplnId, + id: row.rowKey, disabled: dragDisabled, }); @@ -65,7 +65,7 @@ function SortableAplnRow({ - {row.aplnId} + {row.isNew ? "-" : (row.aplnId ?? "-")} {isEditing ? ( { if (dialogMode === "create") { - await createMutation.mutateAsync({ - elaprTmpltId: values.elaprTmpltId, + const created = await createMutation.mutateAsync({ elaprTmpltNm: values.elaprTmpltNm, docSeComCd: values.docSeComCd || undefined, elaprTmpltExpln: values.elaprTmpltExpln || undefined, }); + onSelectTmplt(created); + setRowSelection({ [created.elaprTmpltId]: true }); } else if (dialogMode === "edit" && editingTmplt) { await updateMutation.mutateAsync({ elaprTmpltId: editingTmplt.elaprTmpltId,