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/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/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,