처리분구 인라인 편집 및 저장 후 목록 동기화
This commit is contained in:
+5
-2
@@ -24,12 +24,15 @@
|
||||
|
||||
| 구분 | 시나리오 | 기대 결과 |
|
||||
| --- | --- | --- |
|
||||
| 최초 조회 | 처리분구관리 메뉴 최초 진입 | 별도 조회 버튼 클릭 없이 `useYn=Y` 전체 목록 1회 조회 |
|
||||
| 조회 | 전체/시도/시군구 조건 조회 | `useYn=Y`와 선택된 이름만 query에 포함 |
|
||||
| 추가 | 시도·시군구 미선택/선택 후 추가 | 미선택 차단, 선택 시 행정구역 자동 입력 |
|
||||
| 수정 | 미선택/삭제대기/정상 행 수정 | 잘못된 대상 차단, 처리분구명만 변경 |
|
||||
| 추가 | 시도·시군구 미선택/선택 후 추가 | 미선택 차단, 선택 시 행정구역 자동 입력된 신규 행 생성·처리분구명 셀 포커스 |
|
||||
| 신규 행 입력 | 신규 행 처리분구명 입력 후 저장 | 변경상태 열 없이 셀 직접 입력, 빈 이름·동일 행정구역 중복 차단, CREATE 요청 생성 |
|
||||
| 수정 | 미선택/삭제대기/정상 행 선택 후 수정 | 잘못된 대상 차단, 정상 행의 처리분구명 셀만 입력 상태로 전환, 입력값 변경 시 UPDATE 요청 생성 |
|
||||
| 중복 | 같은 행정구역에 동일 이름 입력 | 저장 전 오류 표시 |
|
||||
| 삭제 | 신규/미참조/참조 중 기존 행 삭제 | 로컬 제거/삭제대기/참조 오류 표시 |
|
||||
| 저장 | 변경 없음/변경 있음/서버 실패 | 차단/변경만 전송/상태와 오류 유지 |
|
||||
| 저장 후 목록 | 추가·수정·삭제대기 저장 성공 | 마지막 조회조건 유지, 추가·수정 결과 즉시 표시, 삭제 행 제거, 나머지 목록 유지 |
|
||||
| 재조회 | 미저장 변경이 있는 상태 | 폐기 확인 후 조회 |
|
||||
| 정렬 | 각 헤더를 세 번 선택 | 오름차순 → 내림차순 → 기본 순서로 순환하고 선택 행 유지 |
|
||||
| 검색조건 | 시도·시군구 필터 표시 | 두 셀렉트가 겹치거나 눌리지 않고 일정 간격 유지 |
|
||||
|
||||
+9
-1
@@ -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 단계 종료 전에 확인해야 한다.
|
||||
|
||||
@@ -53,8 +53,8 @@
|
||||
## 5. FE-0 화면 흐름
|
||||
|
||||
1. `/oper/trtdst`에서 시도·시군구 조건을 선택하고 조회한다.
|
||||
2. 조회 결과에서 행을 선택해 처리분구명만 수정한다.
|
||||
3. 시도·시군구를 모두 선택한 상태에서 신규 처리분구를 추가한다.
|
||||
2. 조회 결과에서 행을 선택하고 `수정`을 누르면 해당 행의 처리분구명 셀이 입력 상태로 전환되며, 그리드에서 직접 수정한다.
|
||||
3. 시도·시군구를 모두 선택한 상태에서 `추가`를 누르면 빈 신규 행을 즉시 만들고 처리분구명 셀에서 직접 입력한다.
|
||||
4. 신규 행은 즉시 제거할 수 있고, 기존 행은 참조 확인 후 삭제대기로 전환한다.
|
||||
5. 저장 시 전체 활성 행의 필수값·행정구역별 중복을 검증한 뒤 변경 행만 일괄 전송한다.
|
||||
6. 성공하면 서버 목록을 재조회하며 실패하면 미저장 행 상태와 오류 메시지를 유지한다.
|
||||
|
||||
+12
-5
@@ -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에 포함되지 않아 사용자 화면에서 적용되지 않았다.
|
||||
|
||||
@@ -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<Notice>(null);
|
||||
// 처리분구명 추가·수정을 동일한 모달로 제공하기 위한 편집 상태다.
|
||||
const [editorMode, setEditorMode] = useState<EditorMode | null>(null);
|
||||
const [editorName, setEditorName] = useState("");
|
||||
// 수정 버튼으로 편집을 시작한 기존 행을 로컬 키로 추적한다.
|
||||
const [editingRowKey, setEditingRowKey] = useState<string | null>(null);
|
||||
// 선택박스의 임시 값과 분리해 저장 후에도 마지막으로 실행한 조회조건을 재사용한다.
|
||||
const [appliedRegion, setAppliedRegion] = useState<AppliedRegion>(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 = (
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setEditorMode(null)}>취소</Button>
|
||||
<Button className="bg-blue-600 text-white hover:bg-blue-700" onClick={applyEditor}>
|
||||
확인
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
@@ -438,8 +461,8 @@ export function TrtdstManagementPage() {
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl font-medium">처리분구 목록 <span className="text-base text-gray-500">총 {rows.length.toLocaleString("ko-KR")}건</span></h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button className={PRIMARY_ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => openEditor("edit")}>수정</Button>
|
||||
<Button className={PRIMARY_ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => openEditor("add")}>추가</Button>
|
||||
<Button className={PRIMARY_ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={handleEdit}>수정</Button>
|
||||
<Button className={PRIMARY_ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={handleAdd}>추가</Button>
|
||||
<Button className={PRIMARY_ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleDelete()}>
|
||||
{selectedRow?.rowStatus === "deletePending" ? "삭제취소" : "삭제"}
|
||||
</Button>
|
||||
@@ -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 (
|
||||
<Input
|
||||
aria-label={`${row.ctpvNm} ${row.sggNm} 처리분구명`}
|
||||
value={row.trtdstNm}
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
className="h-9 bg-white text-center"
|
||||
placeholder="처리분구명 입력"
|
||||
onChange={(event) => 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() {
|
||||
: ""}
|
||||
/>
|
||||
</ListDiv>
|
||||
|
||||
<UiModal
|
||||
open={editorMode !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditorMode(null);
|
||||
}}
|
||||
bodyClass="max-w-lg"
|
||||
headerTitle={editorMode === "add" ? "처리분구 추가" : "처리분구명 수정"}
|
||||
headerTitleClass="text-xl font-semibold text-gray-800"
|
||||
footerContent={editorFooter}
|
||||
body={(
|
||||
<div className="space-y-4 py-3">
|
||||
<div className="rounded bg-gray-50 px-4 py-3 text-sm text-gray-600">
|
||||
{editorMode === "edit" && selectedRow
|
||||
? `${selectedRow.ctpvNm} ${selectedRow.sggNm}`
|
||||
: `${selectedRegion.ctpvNm} ${selectedRegion.sggNm}`}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="trtdst-name" className="w-24 text-base">처리분구명</Label>
|
||||
<Input
|
||||
id="trtdst-name"
|
||||
value={editorName}
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
onChange={(event) => setEditorName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
applyEditor();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type UiTableProps<T extends object> = {
|
||||
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<T extends object = Record<string, unknown>>({
|
||||
onPageChange,
|
||||
cellClassName,
|
||||
rowClassName,
|
||||
renderCell,
|
||||
className = "",
|
||||
resetPageNo = false,
|
||||
boldArray = [],
|
||||
@@ -221,6 +223,9 @@ export function UiTable<T extends object = Record<string, unknown>>({
|
||||
key={`cell-${head.id}-${index}`}
|
||||
>
|
||||
{(() => {
|
||||
const renderedCell = renderCell?.(el, head.id, index);
|
||||
if (renderedCell !== undefined) return renderedCell;
|
||||
|
||||
const cellValue = (el as Record<string, unknown>)[head.id];
|
||||
return typeof cellValue !== "number"
|
||||
? String(cellValue ?? "")
|
||||
@@ -248,6 +253,9 @@ export function UiTable<T extends object = Record<string, unknown>>({
|
||||
key={`cell-${head.id}-${index}`}
|
||||
>
|
||||
{(() => {
|
||||
const renderedCell = renderCell?.(el, head.id, index);
|
||||
if (renderedCell !== undefined) return renderedCell;
|
||||
|
||||
const cellValue = (el as Record<string, unknown>)[head.id];
|
||||
return typeof cellValue === "object" &&
|
||||
cellValue !== null &&
|
||||
|
||||
Reference in New Issue
Block a user