diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md index b872b8e17..579a11b58 100644 --- a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md @@ -24,12 +24,15 @@ | 구분 | 시나리오 | 기대 결과 | | --- | --- | --- | +| 최초 조회 | 처리분구관리 메뉴 최초 진입 | 별도 조회 버튼 클릭 없이 `useYn=Y` 전체 목록 1회 조회 | | 조회 | 전체/시도/시군구 조건 조회 | `useYn=Y`와 선택된 이름만 query에 포함 | -| 추가 | 시도·시군구 미선택/선택 후 추가 | 미선택 차단, 선택 시 행정구역 자동 입력 | -| 수정 | 미선택/삭제대기/정상 행 수정 | 잘못된 대상 차단, 처리분구명만 변경 | +| 추가 | 시도·시군구 미선택/선택 후 추가 | 미선택 차단, 선택 시 행정구역 자동 입력된 신규 행 생성·처리분구명 셀 포커스 | +| 신규 행 입력 | 신규 행 처리분구명 입력 후 저장 | 변경상태 열 없이 셀 직접 입력, 빈 이름·동일 행정구역 중복 차단, CREATE 요청 생성 | +| 수정 | 미선택/삭제대기/정상 행 선택 후 수정 | 잘못된 대상 차단, 정상 행의 처리분구명 셀만 입력 상태로 전환, 입력값 변경 시 UPDATE 요청 생성 | | 중복 | 같은 행정구역에 동일 이름 입력 | 저장 전 오류 표시 | | 삭제 | 신규/미참조/참조 중 기존 행 삭제 | 로컬 제거/삭제대기/참조 오류 표시 | | 저장 | 변경 없음/변경 있음/서버 실패 | 차단/변경만 전송/상태와 오류 유지 | +| 저장 후 목록 | 추가·수정·삭제대기 저장 성공 | 마지막 조회조건 유지, 추가·수정 결과 즉시 표시, 삭제 행 제거, 나머지 목록 유지 | | 재조회 | 미저장 변경이 있는 상태 | 폐기 확인 후 조회 | | 정렬 | 각 헤더를 세 번 선택 | 오름차순 → 내림차순 → 기본 순서로 순환하고 선택 행 유지 | | 검색조건 | 시도·시군구 필터 표시 | 두 셀렉트가 겹치거나 눌리지 않고 일정 간격 유지 | diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md index 16606209f..c0fe7242b 100644 --- a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md @@ -54,6 +54,12 @@ | 2026-07-23 | 조사관리 상세현황 사용자 최종 확인 | 전체 행 선택, 관로·맨홀 탭, 목록·상세 배치와 상세 집계 표시 확인 완료. 조사관리 상세현황 화면 단계 완료 판정 | | 2026-07-23 | git status·문서 정합성 점검 | 추적 코드 8개·신규 코드 15개와 FE 문서 4개를 대조하고 작업카드·구현·검증 문서의 단계 상태와 실제 파일 목록을 동기화 | | 2026-07-23 | 사업보고서 다운로드 정적 확인 | 첨부 ID가 있는 보고서 셀의 파란 밑줄 링크, 행 클릭 전파 차단, 기존 첨부서비스 Blob 다운로드, 샘플 미연결 안내 확인 | +| 2026-07-27 | FE-0 신규 행 인라인 입력 정적 확인 | 변경상태 표시 열 제거, 선택 행정구역 신규 행 생성, 처리분구명 셀 입력, 내부 CREATE 상태 유지 확인 | +| 2026-07-27 | FE-0 기존 행 인라인 수정 정적 확인 | 선택 행 수정 버튼, 처리분구명 셀 입력 전환, 원본 대비 `modified/clean` 상태 복원, 수정 모달 제거 확인 | +| 2026-07-27 | FE-0 최초 자동 조회 정적 확인 | 최초 Effect에서 조건 없는 활성 목록 조회, 개발 모드 Effect 재실행 중복 요청 방지 ref 확인 | +| 2026-07-27 | FE-0 저장 후 목록 유지 정적 확인 | 임시 선택값과 적용 조회조건 분리, 성공 즉시 저장 상태 정규화·삭제 행 제거, 적용 조건 서버 재조회 확인 | +| 2026-07-27 | FE-0 실 API CRUD 사용자 확인 | 최초·조건 조회, 그리드 추가·수정, 무참조 삭제와 저장 후 최신 목록 표시까지 정상 동작 확인 | +| 2026-07-27 | FE-0 TypeScript 재실행 | pnpm 비대화형 모듈 정리 중단 및 로컬 compiler 파일 `EPERM`으로 결과 미확보 | ## 3. FE-0 검증 결과 @@ -66,6 +72,8 @@ - 공통 `UiTable`의 오름차순·내림차순·기본 순환 상태를 사용하고 표시 목록 기준으로 선택 키를 유지하도록 구현했다. - 생성 CSS 누락 영향을 받지 않도록 검색조건 필터의 전체 열·간격·폭을 인라인 값으로 고정하고, 조회 버튼을 캡처 차이만큼 이동했다. - 주요 버튼의 흰색 글자 스타일을 통일했다. +- 신규 행은 추가 모달 없이 그리드에 생성하며 처리분구명 셀에서 직접 입력한다. 변경상태는 화면 열에서 제거하고 일괄 저장 변환을 위한 내부 상태로 유지한다. +- 기존 행은 선택 후 `수정`을 누르면 처리분구명 셀에서 직접 편집한다. 입력값이 원래 이름과 같아지면 수정 상태를 해제하고, 삭제대기 행은 편집 진입을 차단한다. ## 4. 선행 오류와 차단 사유 @@ -90,7 +98,7 @@ - 백엔드 응답 필드와 `409` 오류 body를 구현 후 대조해야 한다. - 메뉴 원천 등록과 화면 진입은 해결됐지만 최종 권한별 노출·직접 URL 접근 제어는 통합 검증에서 확인해야 한다. -- FE-0 화면·UI는 사용자 확인으로 완료됐지만 CRUD 실 API의 성공·오류·409 처리는 백엔드 구현 후 확인해야 한다. +- FE-0 정상 CRUD 실 API는 사용자 확인으로 완료했다. 참조 중 삭제·중복의 실제 409 응답은 별도 통합 검증으로 유지한다. - 프로덕션 빌드는 다음 프론트 단계 시작 전 또는 사용자가 허용한 시점에 다시 실행한다. - FE-1 multipart 필드명과 응답 DTO는 백엔드 구현 시 최종 대조해야 한다. - TypeScript 실행 결과를 확보하지 못했으므로 사용자 개발 서버의 Next 컴파일 결과를 FE-1 단계 종료 전에 확인해야 한다. diff --git a/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md b/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md index 47623158c..068713867 100644 --- a/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md +++ b/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md @@ -53,8 +53,8 @@ ## 5. FE-0 화면 흐름 1. `/oper/trtdst`에서 시도·시군구 조건을 선택하고 조회한다. -2. 조회 결과에서 행을 선택해 처리분구명만 수정한다. -3. 시도·시군구를 모두 선택한 상태에서 신규 처리분구를 추가한다. +2. 조회 결과에서 행을 선택하고 `수정`을 누르면 해당 행의 처리분구명 셀이 입력 상태로 전환되며, 그리드에서 직접 수정한다. +3. 시도·시군구를 모두 선택한 상태에서 `추가`를 누르면 빈 신규 행을 즉시 만들고 처리분구명 셀에서 직접 입력한다. 4. 신규 행은 즉시 제거할 수 있고, 기존 행은 참조 확인 후 삭제대기로 전환한다. 5. 저장 시 전체 활성 행의 필수값·행정구역별 중복을 검증한 뒤 변경 행만 일괄 전송한다. 6. 성공하면 서버 목록을 재조회하며 실패하면 미저장 행 상태와 오류 메시지를 유지한다. diff --git a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md index a71959009..18b397d5a 100644 --- a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md @@ -40,18 +40,23 @@ - 기존 업무형 레이아웃 안에 `/oper/trtdst` 라우트를 추가했다. - 공통 시도·시군구 코드와 `SelectBox`를 사용해 조회조건을 구성했다. -- `UiTable`에 NO, 시도, 시군구, 처리분구명, 변경상태를 표시한다. -- `UiModal`에서 신규 처리분구명 또는 선택 행의 처리분구명만 편집한다. -- 추가·수정·삭제대기 행은 색과 상태명으로 구분하며 삭제대기는 취소할 수 있다. +- 화면 최초 진입 시 조건 없는 활성 처리분구 목록을 자동 조회하며, 개발 모드의 Effect 재실행에도 최초 요청은 한 번만 수행한다. +- 선택박스의 임시 행정구역과 마지막으로 실행한 조회조건을 분리해, 저장 후에는 마지막 조회조건으로 최신 목록을 다시 읽는다. +- `UiTable`에 NO, 시도, 시군구, 처리분구명만 표시하고 변경상태는 저장 요청 생성에 필요한 내부 상태로만 유지한다. +- 추가는 시도·시군구 선택 후 빈 신규 행을 즉시 만들고 처리분구명 셀에서 직접 입력한다. +- 수정은 기존 행을 선택하고 `수정`을 누르면 해당 처리분구명 셀을 입력 상태로 전환하며 별도 `UiModal`을 사용하지 않는다. +- 추가·수정·삭제대기 행은 행 배경색으로 구분하며 삭제대기는 취소할 수 있다. - API 오류와 성공 결과를 화면 안의 메시지 영역에 유지한다. ### 4.2 상태와 검증 - 행 상태는 `clean | added | modified | deletePending`이다. -- 추가는 시도·시군구 선택을 필수로 하며 현재 행정구역 이름을 자동 입력한다. +- 추가는 시도·시군구 선택을 필수로 하며 현재 행정구역 이름을 자동 입력한 신규 행을 즉시 선택한다. - 빈 처리분구명과 동일 행정구역 내 공백/대소문자 정규화 중복을 차단한다. - 미저장 변경이 있는 상태에서 재조회하면 폐기 여부를 확인한다. - 신규 행 삭제는 로컬 제거, 기존 행 삭제는 참조 확인 후 삭제대기로 처리한다. +- 일괄 저장 성공 즉시 추가·수정 행을 정상 상태로 전환하고 삭제대기 행을 화면에서 제거한 뒤 서버 재조회 결과로 동기화한다. +- 저장 후 서버 재조회가 실패하거나 비정상적으로 빈 목록을 반환해도 반영된 로컬 목록을 유지하며, 실제 활성 행이 모두 삭제된 경우에만 빈 목록을 표시한다. ### 4.3 API @@ -85,7 +90,9 @@ ### 4.6 사용자 화면 확인 후 UI 마무리 -- NO, 시도, 시군구, 처리분구명, 변경상태 헤더에 공통 `UiTable`의 `sort`·`onSortChange` 계약을 적용했다. +- NO, 시도, 시군구, 처리분구명 헤더에 공통 `UiTable`의 `sort`·`onSortChange` 계약을 적용했다. +- 공통 `UiTable`에는 기존 출력 동작에 영향을 주지 않는 선택적 `renderCell` 계약을 추가하고, 처리분구관리 신규 행과 수정 버튼으로 활성화한 기존 행의 처리분구명 셀에 인라인 입력을 적용했다. +- 기존 행의 입력값이 원래 이름과 달라지면 `modified`, 다시 같아지면 `clean`으로 즉시 환원해 변경 행만 일괄 저장한다. - 백엔드 정렬 파라미터가 확정되지 않은 FE-0 단계이므로 현재 조회된 활성 처리분구 전체 목록을 한글·숫자 자연순으로 클라이언트 정렬한다. - 정렬 뒤에도 선택 행의 `localKey`를 기준으로 선택 인덱스를 다시 계산해 수정·삭제 대상이 바뀌지 않게 했다. - 최초 보완의 `col-span-12`, `gap-10`은 신규 `src/features` 경로의 클래스가 생성 CSS에 포함되지 않아 사용자 화면에서 적용되지 않았다. diff --git a/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx b/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx index c50585cc3..a56d05c4f 100644 --- a/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx +++ b/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx @@ -2,18 +2,17 @@ import LoadingOverlay from "@/components/ui/LoadingBar"; import useStore from "@/store/useStore"; -import { Button, Input, Label } from "@common/components/ui"; +import { Button, Input } from "@common/components/ui"; import { SearchDiv, SearchFormLeft, SearchFormRight, SelectBox, - UiModal, UiTable, } from "@common/business_components/ui"; import type { UiTableSort } from "@common/business_components/ui"; import ListDiv from "@common/business_components/ui/list_div"; -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getTrtdstReferences, getTrtdstSettings, @@ -29,10 +28,9 @@ import { /** 처리분구 목록 표에 표시할 공통 열 정의다. */ const TRTDST_TABLE_HEAD = [ { id: "rowNumber", title: "NO", widthPercent: 8, sortable: true }, - { id: "ctpvNm", title: "시도", widthPercent: 20, sortable: true }, - { id: "sggNm", title: "시군구", widthPercent: 20, sortable: true }, - { id: "trtdstNm", title: "처리분구명", widthPercent: 37, sortable: true }, - { id: "rowStatusLabel", title: "변경상태", widthPercent: 15, sortable: true }, + { id: "ctpvNm", title: "시도", widthPercent: 22, sortable: true }, + { id: "sggNm", title: "시군구", widthPercent: 22, sortable: true }, + { id: "trtdstNm", title: "처리분구명", widthPercent: 48, sortable: true }, ]; /** 시도·시군구 선택박스에서 공통으로 사용하는 전체 옵션이다. */ @@ -45,12 +43,18 @@ const TRTDST_COLLATOR = new Intl.Collator("ko", { numeric: true, sensitivity: "b const PRIMARY_ACTION_BUTTON_CLASS = "rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white"; -/** 추가 또는 수정 모달의 현재 동작을 구분한다. */ -type EditorMode = "add" | "edit"; - /** 화면 상단에 노출하는 성공·오류 메시지 상태다. */ type Notice = { type: "success" | "error"; text: string } | null; +/** 조회 버튼으로 확정한 처리분구 목록의 행정구역 조건이다. */ +type AppliedRegion = { + ctpvNm?: string; + sggNm?: string; +}; + +/** 최초 진입 시 사용하는 조건 없는 전체 조회 기준이다. */ +const EMPTY_APPLIED_REGION: AppliedRegion = {}; + /** 브라우저와 SSR 양쪽에서 충돌 가능성이 낮은 로컬 행 키를 만든다. */ const createLocalKey = () => typeof crypto !== "undefined" && "randomUUID" in crypto @@ -73,6 +77,33 @@ const toTableRows = (items: TrtdstSettingDto[]): TrtdstTableRow[] => originalTrtdstNm: String(item.trtdstNm ?? "").trim(), })); +/** 저장 성공 직후 삭제 행을 제거하고 추가·수정 행을 서버 반영 상태로 정규화한다. */ +const toSavedTableRows = ( + rows: TrtdstTableRow[], + savedItems: TrtdstSettingDto[] = [], +): TrtdstTableRow[] => + rows + .filter((row) => row.rowStatus !== "deletePending") + .map((row, index) => { + const saved = savedItems.find((item) => + row.trtdstId + ? String(item.trtdstId) === row.trtdstId + : String(item.ctpvNm).trim() === row.ctpvNm + && String(item.sggNm).trim() === row.sggNm + && String(item.trtdstNm).trim() === row.trtdstNm.trim()); + const trtdstNm = String(saved?.trtdstNm ?? row.trtdstNm).trim(); + return { + ...row, + ...saved, + trtdstId: String(saved?.trtdstId ?? row.trtdstId), + trtdstNm, + rowNumber: index + 1, + rowStatus: "clean", + rowStatusLabel: TRTDST_ROW_STATUS_LABEL.clean, + originalTrtdstNm: trtdstNm, + }; + }); + /** 알 수 없는 API 오류에서도 서버 메시지 또는 안전한 기본 문구를 반환한다. */ const getErrorMessage = (error: unknown, fallback: string) => error instanceof Error && error.message.trim() ? error.message : fallback; @@ -94,9 +125,12 @@ export function TrtdstManagementPage() { const [isLoading, setIsLoading] = useState(false); // API 실패와 저장 성공 결과를 화면 안에서 유지해 사용자가 원인을 확인할 수 있게 한다. const [notice, setNotice] = useState(null); - // 처리분구명 추가·수정을 동일한 모달로 제공하기 위한 편집 상태다. - const [editorMode, setEditorMode] = useState(null); - const [editorName, setEditorName] = useState(""); + // 수정 버튼으로 편집을 시작한 기존 행을 로컬 키로 추적한다. + const [editingRowKey, setEditingRowKey] = useState(null); + // 선택박스의 임시 값과 분리해 저장 후에도 마지막으로 실행한 조회조건을 재사용한다. + const [appliedRegion, setAppliedRegion] = useState(EMPTY_APPLIED_REGION); + // 개발 모드의 Effect 재실행에서도 최초 전체 조회를 한 번만 요청하기 위해 state 대신 ref로 기록한다. + const initialLoadRequestedRef = useRef(false); /** 현재 시도에 속한 시군구만 선택 후보로 제공한다. */ const availableSgg = useMemo(() => { @@ -144,51 +178,57 @@ export function TrtdstManagementPage() { [rows], ); - /** 현재 조회조건으로 서버 목록을 읽고 화면 변경 상태를 초기화한다. */ - const fetchRows = async () => { + /** 확정된 조회조건으로 서버 목록을 읽고 화면 변경 상태를 초기화한다. */ + const fetchRows = useCallback(async (region: AppliedRegion) => { setIsLoading(true); setNotice(null); try { const items = await getTrtdstSettings({ - ctpvNm: selectedRegion.ctpvNm || undefined, - sggNm: selectedRegion.sggNm || undefined, + ctpvNm: region.ctpvNm, + sggNm: region.sggNm, useYn: "Y", }); setRows(toTableRows(items)); setSelectedRowKey(null); + setEditingRowKey(null); + return true; } catch (error) { - setRows([]); setSelectedRowKey(null); + setEditingRowKey(null); setNotice({ type: "error", text: getErrorMessage(error, "처리분구 목록 조회 중 오류가 발생했습니다."), }); + return false; } finally { setIsLoading(false); } - }; + }, []); + + /** 처리분구관리 최초 진입 시 조건 없는 활성 처리분구 목록을 자동 조회한다. */ + useEffect(() => { + if (initialLoadRequestedRef.current) return; + initialLoadRequestedRef.current = true; + void fetchRows(EMPTY_APPLIED_REGION); + }, [fetchRows]); /** 조회 전 미저장 변경 유실 여부를 확인한 뒤 목록을 다시 읽는다. */ const handleSearch = async () => { if (hasChanges && !window.confirm("저장하지 않은 변경사항이 있습니다. 버리고 다시 조회하시겠습니까?")) { return; } - await fetchRows(); + const nextRegion: AppliedRegion = { + ctpvNm: selectedRegion.ctpvNm || undefined, + sggNm: selectedRegion.sggNm || undefined, + }; + if (await fetchRows(nextRegion)) { + setAppliedRegion(nextRegion); + } }; - /** 선택 행 또는 현재 행정구역을 기준으로 처리분구명 편집 모달을 연다. */ - const openEditor = (mode: EditorMode) => { + /** 선택한 처리분구의 명칭 셀을 그리드 안에서 편집할 수 있게 전환한다. */ + const handleEdit = () => { setNotice(null); - if (mode === "add") { - if (!selectedRegion.ctpvNm || !selectedRegion.sggNm) { - setNotice({ type: "error", text: "추가할 시도와 시군구를 먼저 선택해주세요." }); - return; - } - setEditorName(""); - setEditorMode("add"); - return; - } - if (!selectedRow) { setNotice({ type: "error", text: "수정할 처리분구를 선택해주세요." }); return; @@ -197,69 +237,54 @@ export function TrtdstManagementPage() { setNotice({ type: "error", text: "삭제대기 행은 삭제취소 후 수정할 수 있습니다." }); return; } - setEditorName(selectedRow.trtdstNm); - setEditorMode("edit"); + setEditingRowKey(selectedRow.localKey); }; - /** 모달의 처리분구명을 화면 행에 반영하고 중복 입력을 차단한다. */ - const applyEditor = () => { - const nextName = editorName.trim(); - if (!nextName) { - setNotice({ type: "error", text: "처리분구명을 입력해주세요." }); + /** 선택한 행정구역으로 빈 신규 행을 만들고 처리분구명은 그리드에서 직접 입력받는다. */ + const handleAdd = () => { + setNotice(null); + if (!selectedRegion.ctpvNm || !selectedRegion.sggNm) { + setNotice({ type: "error", text: "추가할 시도와 시군구를 먼저 선택해주세요." }); return; } - const targetRegion = editorMode === "edit" && selectedRow - ? { ctpvNm: selectedRow.ctpvNm, sggNm: selectedRow.sggNm } - : selectedRegion; - const hasDuplicate = rows.some((row) => - row.rowStatus !== "deletePending" - && row.localKey !== selectedRow?.localKey - && row.ctpvNm === targetRegion.ctpvNm - && row.sggNm === targetRegion.sggNm - && row.trtdstNm.trim().toLocaleLowerCase("ko-KR") === nextName.toLocaleLowerCase("ko-KR")); - if (hasDuplicate) { - setNotice({ type: "error", text: "같은 행정구역에 동일한 처리분구명이 있습니다." }); - return; - } + const localKey = createLocalKey(); + setRows((current) => [ + ...current, + { + trtdstId: "", + ctpvNm: selectedRegion.ctpvNm, + sggNm: selectedRegion.sggNm, + trtdstNm: "", + useYn: "Y", + localKey, + rowNumber: current.length + 1, + rowStatus: "added", + rowStatusLabel: TRTDST_ROW_STATUS_LABEL.added, + originalTrtdstNm: "", + }, + ]); + setSelectedRowKey(localKey); + setEditingRowKey(null); + }; - if (editorMode === "add") { - const localKey = createLocalKey(); - setRows((current) => [ - ...current, - { - trtdstId: "", - ctpvNm: targetRegion.ctpvNm, - sggNm: targetRegion.sggNm, - trtdstNm: nextName, - useYn: "Y", - localKey, - rowNumber: current.length + 1, - rowStatus: "added", - rowStatusLabel: TRTDST_ROW_STATUS_LABEL.added, - originalTrtdstNm: "", - }, - ]); - setSelectedRowKey(localKey); - } else if (selectedRow) { - setRows((current) => current.map((row) => { - if (row.localKey !== selectedRow.localKey) return row; - const rowStatus = row.rowStatus === "added" - ? "added" - : nextName === row.originalTrtdstNm - ? "clean" - : "modified"; - return { - ...row, - trtdstNm: nextName, - rowStatus, - rowStatusLabel: TRTDST_ROW_STATUS_LABEL[rowStatus], - }; - })); - } - - setEditorMode(null); - setEditorName(""); + /** 추가 또는 수정 중인 행의 처리분구명을 변경 추적 상태와 함께 갱신한다. */ + const handleRowNameChange = (localKey: string, trtdstNm: string) => { + const nextName = trtdstNm.slice(0, 100); + setRows((current) => current.map((row) => { + if (row.localKey !== localKey) return row; + const rowStatus = row.rowStatus === "added" + ? "added" + : nextName.trim() === row.originalTrtdstNm + ? "clean" + : "modified"; + return { + ...row, + trtdstNm: nextName, + rowStatus, + rowStatusLabel: TRTDST_ROW_STATUS_LABEL[rowStatus], + }; + })); setNotice(null); }; @@ -275,6 +300,7 @@ export function TrtdstManagementPage() { .filter((row) => row.localKey !== selectedRow.localKey) .map((row, index) => ({ ...row, rowNumber: index + 1 }))); setSelectedRowKey(null); + setEditingRowKey(null); return; } if (selectedRow.rowStatus === "deletePending") { @@ -286,6 +312,7 @@ export function TrtdstManagementPage() { rowStatusLabel: TRTDST_ROW_STATUS_LABEL[restoredStatus], } : row)); + setEditingRowKey(null); return; } @@ -305,6 +332,7 @@ export function TrtdstManagementPage() { setRows((current) => current.map((row) => row.localKey === selectedRow.localKey ? { ...row, rowStatus: "deletePending", rowStatusLabel: TRTDST_ROW_STATUS_LABEL.deletePending } : row)); + setEditingRowKey(null); } catch (error) { setNotice({ type: "error", @@ -333,14 +361,19 @@ export function TrtdstManagementPage() { try { const result = await saveTrtdstSettings({ items }); const summary = `추가 ${Number(result?.createdCount ?? 0)}건, 수정 ${Number(result?.updatedCount ?? 0)}건, 삭제 ${Number(result?.deletedCount ?? 0)}건이 반영되었습니다.`; + const savedRows = toSavedTableRows(rows, result?.items); + setRows(savedRows); + setSelectedRowKey(null); + setEditingRowKey(null); try { const refreshed = await getTrtdstSettings({ - ctpvNm: selectedRegion.ctpvNm || undefined, - sggNm: selectedRegion.sggNm || undefined, + ctpvNm: appliedRegion.ctpvNm, + sggNm: appliedRegion.sggNm, useYn: "Y", }); - setRows(toTableRows(refreshed)); - setSelectedRowKey(null); + if (refreshed.length > 0 || savedRows.length === 0) { + setRows(toTableRows(refreshed)); + } setNotice({ type: "success", text: summary }); } catch (refreshError) { setNotice({ @@ -358,16 +391,6 @@ export function TrtdstManagementPage() { } }; - /** 모달 하단에서 저장 또는 취소를 선택하는 공통 버튼 영역이다. */ - const editorFooter = ( -
- - -
- ); - return ( <> @@ -438,8 +461,8 @@ export function TrtdstManagementPage() {

처리분구 목록 총 {rows.length.toLocaleString("ko-KR")}건

- - + + @@ -466,6 +489,22 @@ export function TrtdstManagementPage() { onPageChange={() => undefined} sort={sort} onSortChange={setSort} + renderCell={(row, headId) => { + const isNameEditable = row.rowStatus === "added" || row.localKey === editingRowKey; + if (headId !== "trtdstNm" || !isNameEditable) return undefined; + + return ( + handleRowNameChange(row.localKey, event.target.value)} + /> + ); + }} rowClassName={(row) => row.rowStatus === "deletePending" ? "!bg-red-50 text-gray-400 line-through" : row.rowStatus === "added" @@ -475,42 +514,6 @@ export function TrtdstManagementPage() { : ""} /> - - { - if (!open) setEditorMode(null); - }} - bodyClass="max-w-lg" - headerTitle={editorMode === "add" ? "처리분구 추가" : "처리분구명 수정"} - headerTitleClass="text-xl font-semibold text-gray-800" - footerContent={editorFooter} - body={( -
-
- {editorMode === "edit" && selectedRow - ? `${selectedRow.ctpvNm} ${selectedRow.sggNm}` - : `${selectedRegion.ctpvNm} ${selectedRegion.sggNm}`} -
-
- - setEditorName(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - applyEditor(); - } - }} - /> -
-
- )} - /> ); } diff --git a/packages/common/business_components/src/ui/table/index.tsx b/packages/common/business_components/src/ui/table/index.tsx index ec447a2de..5505217e5 100644 --- a/packages/common/business_components/src/ui/table/index.tsx +++ b/packages/common/business_components/src/ui/table/index.tsx @@ -31,6 +31,7 @@ type UiTableProps = { onPageChange: (page: number) => void; cellClassName?: (row: T, headId: string, rowIndex: number) => string; rowClassName?: (row: T, rowIndex: number) => string; + renderCell?: (row: T, headId: string, rowIndex: number) => React.ReactNode | undefined; className?: string; resetPageNo?: boolean; children?: React.ReactNode | undefined; @@ -58,6 +59,7 @@ export function UiTable>({ onPageChange, cellClassName, rowClassName, + renderCell, className = "", resetPageNo = false, boldArray = [], @@ -221,6 +223,9 @@ export function UiTable>({ key={`cell-${head.id}-${index}`} > {(() => { + const renderedCell = renderCell?.(el, head.id, index); + if (renderedCell !== undefined) return renderedCell; + const cellValue = (el as Record)[head.id]; return typeof cellValue !== "number" ? String(cellValue ?? "") @@ -248,6 +253,9 @@ export function UiTable>({ key={`cell-${head.id}-${index}`} > {(() => { + const renderedCell = renderCell?.(el, head.id, index); + if (renderedCell !== undefined) return renderedCell; + const cellValue = (el as Record)[head.id]; return typeof cellValue === "object" && cellValue !== null &&