Merge remote-tracking branch 'origin/iipbs/as/dev' into iipbs/as/dev

This commit is contained in:
2026-05-19 11:03:19 +09:00
13 changed files with 87 additions and 93 deletions
+18 -19
View File
@@ -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 노출 여부.
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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 ."
},
@@ -18,7 +18,6 @@ export interface ElaprAplnListParams extends PageableParams {
}
export interface ElaprAplnSaveRequest {
aplnId: string;
elaprTmpltId: string;
aprvrUuid?: string;
atrzSeComCd?: string;
@@ -20,7 +20,6 @@ export interface ElaprTmpltSearchParams extends PageableParams {
}
export interface ElaprTmpltSaveRequest {
elaprTmpltId: string;
elaprTmpltNm: string;
elaprTmpltExpln?: string;
docSeComCd?: string;
@@ -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<ElaprTmpltFormValues>({
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({
<DialogTitle>{mode === "create" ? "결재서식 등록" : "결재서식 수정"}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-2">
<Label htmlFor="elaprTmpltId">전자결재서식아이디</Label>
<Input id="elaprTmpltId" disabled {...register("elaprTmpltId", { required: true })} />
</div>
{mode === "edit" ? (
<div className="space-y-2">
<Label htmlFor="elaprTmpltId">전자결재서식아이디</Label>
<Input id="elaprTmpltId" disabled {...register("elaprTmpltId")} />
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="elaprTmpltNm">결재서식명</Label>
<Input id="elaprTmpltNm" {...register("elaprTmpltNm", { required: true })} />
@@ -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}`;
}
-1
View File
@@ -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";
@@ -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<ElaprAplnRow[]>([]);
const [editingRowId, setEditingRowId] = useState<string | null>(null);
const [editingRowKey, setEditingRowKey] = useState<string | null>(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<ElaprAplnRow>) => {
if (editingRowId !== aplnId) return;
(rowKey: string, patch: Partial<ElaprAplnRow>) => {
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,
@@ -1,7 +1,10 @@
import type { ElaprApln } from "@/entities/elapr";
/** 그리드 로컬 행 — API 응답 + UI 보조 필드 */
export interface ElaprAplnRow extends ElaprApln {
export interface ElaprAplnRow extends Omit<ElaprApln, "aplnId"> {
/** React key · DnD · 편집 잠금용 — 서버 aplnId와 분리 */
rowKey: string;
aplnId?: string;
/** 결재자 표시명 — 응답 DTO에 없음, 선택 시 로컬 유지 */
aprvrNm?: string;
isNew?: boolean;
@@ -36,7 +36,7 @@ interface ElaprAplnGridProps {
function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [pickingRowId, setPickingRowId] = useState<string | null>(null);
const [pickingRowKey, setPickingRowKey] = useState<string | null>(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) {
</TableHeader>
<TableBody>
<SortableContext
items={rows.map((r) => r.aplnId)}
items={rows.map((r) => r.rowKey)}
strategy={verticalListSortingStrategy}
>
{rows.map((row) => (
<SortableAplnRow
key={row.aplnId}
key={row.rowKey}
row={row}
isEditing={editingRowId === row.aplnId}
isEditing={editingRowKey === row.rowKey}
isLocked={isEditingLocked}
dragDisabled={isEditingLocked}
onStartEdit={() => 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);
}}
/>
@@ -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({
<Equal className="!size-6" />
</Button>
</TableCell>
<TableCell align="center">{row.aplnId}</TableCell>
<TableCell align="center">{row.isNew ? "-" : (row.aplnId ?? "-")}</TableCell>
<TableCell align="center">
{isEditing ? (
<Input
@@ -86,12 +86,13 @@ function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps)
const handleFormSubmit = async (values: ElaprTmpltFormValues) => {
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,