-
setKeyword(e.target.value)}
- />
-
+
+
+ {isPending ? (
+
+
+
+
+ ) : isError ? (
+
+ 검토인력 목록을 불러오지 못했습니다.
+
+ ) : items.length === 0 ? (
+
+ 배치된 검토인력이 없습니다.
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+ 성명
+ 부서
+ 직위
+ 직급
+ 선택
+
+
+
+ {items.map((item) => (
+
+
+ {item.userUuidNm || item.userUuid}
+
+ {item.ognzNm || "-"}
+ {item.jbpsSeNm || "-"}
+ {item.jbgdSeNm || "-"}
+
+
+
+
+ ))}
+
+
+ )}
+
-
diff --git a/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx b/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx
index e3647d441..c2393715b 100644
--- a/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx
+++ b/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx
@@ -4,14 +4,10 @@ import { useState } from "react";
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
+import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
-import {
- useCreateElaprApln,
- useDeleteElaprApln,
- useElaprAplnPage,
- useUpdateElaprApln,
-} from "@/entities/elapr";
+import { useElaprAplnPage, useSaveMultiElaprApln } from "@/entities/elapr";
import {
Button,
Skeleton,
@@ -22,12 +18,18 @@ import {
TableRow,
} from "@common/components";
-import { assignAtrzSeqNumbers } from "../lib/assign-atrz-seq";
+import { buildSaveMultiPayload } from "../lib/build-save-multi-payload";
+import { hasUnsavedChanges } from "../lib/has-unsaved-changes";
import { useAplnRows } from "../lib/use-apln-rows";
import { useDndSensors } from "../lib/use-dnd-sensors";
-import { validateAplnRow, validateAtrzOrder } from "../lib/validators";
+import {
+ validateAllRowsForSave,
+ validateAplnRow,
+ validateAtrzOrder,
+ validateUniqueSeqNo,
+} from "../lib/validators";
import type { ElaprAplnRow } from "../model/apln-row-types";
-import type { ReviewerOption } from "../model/mock-reviewers";
+import type { ReviewerOption } from "../model/reviewer-option";
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
import { SortableAplnRow } from "./sortable-apln-row";
@@ -37,6 +39,7 @@ interface ElaprAplnGridProps {
}
function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
+ const { confirm } = useConfirm();
const [pickerOpen, setPickerOpen] = useState(false);
const [pickingRowKey, setPickingRowKey] = useState
(null);
@@ -49,20 +52,19 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
sort: "atrzSeqNo,asc",
});
- const createMutation = useCreateElaprApln();
- const updateMutation = useUpdateElaprApln();
- const deleteMutation = useDeleteElaprApln();
+ const saveMultiMutation = useSaveMultiElaprApln();
const {
rows,
+ visibleRows,
editingRowKey,
isEditingLocked,
addRow,
startEdit,
updateRow,
cancelEdit,
- clearEditing,
- removeRowLocal,
+ commitEdit,
+ markDeleted,
reorderRows,
} = useAplnRows({
elaprTmpltId,
@@ -71,116 +73,65 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
const sensors = useDndSensors();
const isDisabled = !elaprTmpltId;
+ const serverRows = aplnQuery.data?.content ?? [];
+ const canSave = hasUnsavedChanges(rows, serverRows);
+ const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
- const { orderError } = reorderRows(String(active.id), String(over.id));
- if (orderError) {
- alert(orderError);
- }
+ reorderRows(String(active.id), String(over.id));
};
const handleRowChange = (row: ElaprAplnRow, patch: Partial) => {
updateRow(row.rowKey, patch);
};
- const handleRowSave = async (row: ElaprAplnRow) => {
+ const handleRowSave = (row: ElaprAplnRow) => {
const fieldError = validateAplnRow(row);
if (fieldError) {
alert(fieldError);
return;
}
- const rowsWithSeq = assignAtrzSeqNumbers(rows.map((r) => (r.rowKey === row.rowKey ? row : r)));
- const savedRow = rowsWithSeq.find((r) => r.rowKey === row.rowKey);
- if (!savedRow) return;
-
- const orderError = validateAtrzOrder(rowsWithSeq);
- if (orderError) {
- alert(orderError);
- return;
- }
-
- if (!elaprTmpltId) return;
-
- if (savedRow.isNew) {
- await createMutation.mutateAsync({
- elaprTmpltId,
- aprvrUuid: savedRow.aprvrUuid,
- atrzSeComCd: savedRow.atrzSeComCd,
- atrzSeqNo: savedRow.atrzSeqNo,
- });
- } else if (savedRow.aplnId) {
- await updateMutation.mutateAsync({
- aplnId: savedRow.aplnId,
- body: {
- elaprTmpltId,
- aprvrUuid: savedRow.aprvrUuid,
- atrzSeComCd: savedRow.atrzSeComCd,
- atrzSeqNo: savedRow.atrzSeqNo,
- },
- });
- }
-
- clearEditing();
- await aplnQuery.refetch();
+ commitEdit(row.rowKey);
};
const handleRowDelete = async (row: ElaprAplnRow) => {
- if (!window.confirm("선택한 결재선을 삭제하시겠습니까?")) return;
+ markDeleted(row.rowKey);
+ };
- if (row.isNew) {
- removeRowLocal(row.rowKey);
+ const handleSaveAll = async () => {
+ if (!elaprTmpltId) return;
+
+ const rowsError = validateAllRowsForSave(rows, isEditingLocked);
+ if (rowsError) {
+ alert(rowsError);
return;
}
- if (!elaprTmpltId || !row.aplnId) return;
-
- await deleteMutation.mutateAsync({ aplnId: row.aplnId, elaprTmpltId });
- await aplnQuery.refetch();
- };
-
- const handleBulkSave = async () => {
- if (!elaprTmpltId || isEditingLocked) return;
-
- const rowsWithSeq = assignAtrzSeqNumbers(rows);
- const orderError = validateAtrzOrder(rowsWithSeq);
+ const orderError = validateAtrzOrder(rows);
if (orderError) {
alert(orderError);
return;
}
- const dirtyRows = rowsWithSeq.filter((r) => r.isDirty || r.isNew);
-
- for (const row of dirtyRows) {
- const fieldError = validateAplnRow(row);
- if (fieldError) {
- alert(fieldError);
- return;
- }
-
- if (row.isNew) {
- await createMutation.mutateAsync({
- elaprTmpltId,
- aprvrUuid: row.aprvrUuid,
- atrzSeComCd: row.atrzSeComCd,
- atrzSeqNo: row.atrzSeqNo,
- });
- } else if (row.aplnId) {
- await updateMutation.mutateAsync({
- aplnId: row.aplnId,
- body: {
- elaprTmpltId,
- aprvrUuid: row.aprvrUuid,
- atrzSeComCd: row.atrzSeComCd,
- atrzSeqNo: row.atrzSeqNo,
- },
- });
- }
+ const seqError = validateUniqueSeqNo(rows);
+ if (seqError) {
+ alert(seqError);
+ return;
}
+ const payload = buildSaveMultiPayload(rows, elaprTmpltId, serverRows);
+ if (payload.length === 0) {
+ alert("저장할 변경 사항이 없습니다.");
+ return;
+ }
+
+ if (!(await confirm("결재선을 저장하시겠습니까?"))) return;
+
+ await saveMultiMutation.mutateAsync({ body: payload, elaprTmpltId });
alert("결재선이 저장되었습니다.");
await aplnQuery.refetch();
};
@@ -201,13 +152,17 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
- + 행 추가
+ 결재선 추가
-
+
결재선 저장
@@ -222,13 +177,22 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
상단 목록에서 결재서식을 선택해 주세요.
- ) : rows.length === 0 ? (
+ ) : visibleRows.length === 0 ? (
- 등록된 결재선이 없습니다. [행 추가]로 결재자를 등록하세요.
+ 등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요.
) : (
+
+
+
+
+
+
+
+
+
@@ -242,10 +206,10 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
r.rowKey)}
+ items={visibleRows.map((r) => r.rowKey)}
strategy={verticalListSortingStrategy}
>
- {rows.map((row, index) => (
+ {visibleRows.map((row, index) => (
diff --git a/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-list.tsx b/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-list.tsx
index ae2b97e4c..7955f8b6b 100644
--- a/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-list.tsx
+++ b/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-list.tsx
@@ -2,6 +2,7 @@
import { useMemo, useState } from "react";
+import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
@@ -24,6 +25,7 @@ interface ElaprTmpltListProps {
}
function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps) {
+ const { confirm } = useConfirm();
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
const [editingTmplt, setEditingTmplt] = useState(null);
const [rowSelection, setRowSelection] = useState({});
@@ -46,14 +48,14 @@ function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps)
setDialogMode("edit");
},
onDelete: async (row) => {
- if (!window.confirm("선택한 결재서식을 삭제하시겠습니까?")) return;
+ if (!(await confirm("선택한 결재서식을 삭제하시겠습니까?"))) return;
await deleteMutation.mutateAsync(row.elaprTmpltId);
if (selectedTmpltId === row.elaprTmpltId) {
onSelectTmplt(null);
}
},
}),
- [deleteMutation, onSelectTmplt, selectedTmpltId],
+ [confirm, deleteMutation, onSelectTmplt, selectedTmpltId],
);
const { tableComponent } = useDataTable({
@@ -112,7 +114,7 @@ function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps)
전자결재선 관리 목록
- setDialogMode("create")}>
+ setDialogMode("create")}>
서식 등록
diff --git a/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx b/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx
index c9fc44af6..fcb315bdb 100644
--- a/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx
+++ b/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx
@@ -36,7 +36,7 @@ function SortableAplnRow({
onChange,
onPickAprvr,
}: SortableAplnRowProps) {
- const { data: atrzSeCodes } = useCodeList("ATRZSECD");
+ const { data: atrzSeCodes } = useCodeList("ATRZSE");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: row.rowKey,
@@ -50,7 +50,7 @@ function SortableAplnRow({
};
const rowDisabled = isLocked && !isEditing;
- const atrzSeLabel = row.atrzSeComNm ?? findCodeLabel(atrzSeCodes, row.atrzSeComCd);
+ const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd);
return (
@@ -72,7 +72,7 @@ function SortableAplnRow({
{isEditing ? (
@@ -103,7 +103,7 @@ function SortableAplnRow({
{isEditing ? (
<>
- 저장
+ 확인
취소
@@ -111,10 +111,22 @@ function SortableAplnRow({
>
) : (
<>
-
+
수정
-
+
삭제
>
diff --git a/apps/as/src/pages/auth/login/ui/login-page.tsx b/apps/as/src/pages/auth/login/ui/login-page.tsx
index 60faf66bb..be1c65e13 100644
--- a/apps/as/src/pages/auth/login/ui/login-page.tsx
+++ b/apps/as/src/pages/auth/login/ui/login-page.tsx
@@ -3,7 +3,7 @@
import { useState } from "react";
import Image from "next/image";
-import { LoginApi } from "@/entities/auth";
+import { authService } from "@/entities/auth";
import { images } from "@common/assets";
import "./login.css";
@@ -20,7 +20,7 @@ function LoginPage() {
const password = formData.get("password") as string;
try {
- await LoginApi.login({ userId, password });
+ await authService.login({ userId, password });
window.location.href = "/";
} catch (error) {
const message = error instanceof Error ? error.message : "로그인에 실패했습니다.";
diff --git a/apps/as/src/pages/checklist-management/index.ts b/apps/as/src/pages/checklist-management/index.ts
new file mode 100644
index 000000000..fbd074ed9
--- /dev/null
+++ b/apps/as/src/pages/checklist-management/index.ts
@@ -0,0 +1 @@
+export { ChecklistManagementPage } from "./ui/checklist-management-page";
diff --git a/apps/as/src/pages/checklist-management/model/build-rvw-display-rows.ts b/apps/as/src/pages/checklist-management/model/build-rvw-display-rows.ts
new file mode 100644
index 000000000..f82fc9a96
--- /dev/null
+++ b/apps/as/src/pages/checklist-management/model/build-rvw-display-rows.ts
@@ -0,0 +1,90 @@
+import type { CklstRvwRow } from "./cklst-rvw-row-types";
+
+export const MAX_RVW_CN_LV_NO = 3;
+
+export interface CklstRvwDisplayRow {
+ row: CklstRvwRow;
+ depth: number;
+}
+
+export function formatRvwCnLvNo(lvNo?: number): string {
+ if (lvNo === 3) return "3단계";
+ if (lvNo === 2) return "2단계";
+ return "1단계";
+}
+
+export function canAddChildRow(lvNo?: number): boolean {
+ return (lvNo ?? 1) < MAX_RVW_CN_LV_NO;
+}
+
+const TREE_BRANCH_ENTITY = {
+ horizontal: "─",
+ vertical: "│",
+ branch: "├",
+} as const;
+
+const TREE_BRANCH_ENTITY_CHARS: Record = {
+ [TREE_BRANCH_ENTITY.horizontal]: "\u2500",
+ [TREE_BRANCH_ENTITY.vertical]: "\u2502",
+ [TREE_BRANCH_ENTITY.branch]: "\u251C",
+};
+
+function decodeTreeBranchEntityCodes(value: string): string {
+ return Object.entries(TREE_BRANCH_ENTITY_CHARS).reduce(
+ (text, [entity, char]) => text.replaceAll(entity, char),
+ value,
+ );
+}
+
+export function formatTreeBranchPrefix(depth: number): string {
+ if (depth === 1)
+ return decodeTreeBranchEntityCodes(
+ `${TREE_BRANCH_ENTITY.branch}${TREE_BRANCH_ENTITY.horizontal}`,
+ );
+ if (depth === 2) {
+ return decodeTreeBranchEntityCodes(
+ `${TREE_BRANCH_ENTITY.vertical} ${TREE_BRANCH_ENTITY.branch}${TREE_BRANCH_ENTITY.horizontal}`,
+ );
+ }
+ return "";
+}
+
+function compareSortSeq(a: CklstRvwRow, b: CklstRvwRow): number {
+ return (a.sortSeq ?? 0) - (b.sortSeq ?? 0);
+}
+
+export function buildRvwDisplayRows(rows: CklstRvwRow[]): CklstRvwDisplayRow[] {
+ const result: CklstRvwDisplayRow[] = [];
+
+ const roots = rows
+ .filter((row) => (row.rvwCnLvNo ?? 1) === 1 && row.upCklstRvwSn == null)
+ .sort(compareSortSeq);
+
+ function appendSubtree(parent: CklstRvwRow, depth: number) {
+ const children = rows
+ .filter((row) => row.upCklstRvwSn != null && row.upCklstRvwSn === parent.cklstRvwSn)
+ .sort(compareSortSeq);
+
+ for (const child of children) {
+ result.push({ row: child, depth });
+ if (depth < MAX_RVW_CN_LV_NO - 1) {
+ appendSubtree(child, depth + 1);
+ }
+ }
+ }
+
+ for (const root of roots) {
+ result.push({ row: root, depth: 0 });
+ appendSubtree(root, 1);
+ }
+
+ const orphans = rows.filter(
+ (row) => !result.some((display) => display.row.rowKey === row.rowKey),
+ );
+
+ for (const orphan of orphans.sort(compareSortSeq)) {
+ result.push({ row: orphan, depth: Math.max(0, (orphan.rvwCnLvNo ?? 1) - 1) });
+ }
+
+ return result;
+}
diff --git a/apps/as/src/pages/checklist-management/model/cklst-artcl-columns.tsx b/apps/as/src/pages/checklist-management/model/cklst-artcl-columns.tsx
new file mode 100644
index 000000000..cc519945e
--- /dev/null
+++ b/apps/as/src/pages/checklist-management/model/cklst-artcl-columns.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { createColumnHelper } from "@tanstack/react-table";
+
+import type { CklstArtcl } from "@/entities/cklst";
+import { Button } from "@common/components";
+
+const columnHelper = createColumnHelper();
+
+interface CreateCklstArtclColumnsOptions {
+ onEdit: (row: CklstArtcl) => void;
+ onDelete: (row: CklstArtcl) => void;
+}
+
+export function createCklstArtclColumns({ onEdit, onDelete }: CreateCklstArtclColumnsOptions) {
+ return [
+ columnHelper.accessor("cklstArtclSn", {
+ header: "점검항목코드",
+ meta: { align: "center" },
+ }),
+ columnHelper.accessor("cklstArtclNm", {
+ header: "점검항목명",
+ cell: ({ getValue }) => getValue() ?? "-",
+ }),
+ columnHelper.accessor("useYn", {
+ header: "사용여부",
+ cell: ({ getValue }) => {
+ const value = getValue();
+ return value === "Y" ? "예" : value === "N" ? "아니오" : "-";
+ },
+ }),
+ columnHelper.accessor("regDt", {
+ header: "등록일시",
+ cell: ({ getValue }) => getValue() ?? "-",
+ }),
+ columnHelper.display({
+ id: "actions",
+ header: "관리",
+ cell: ({ row }) => (
+
+ onEdit(row.original)}>
+ 관리
+
+ onDelete(row.original)}>
+ 삭제
+
+
+ ),
+ }),
+ ];
+}
diff --git a/apps/as/src/pages/checklist-management/model/cklst-mng-columns.tsx b/apps/as/src/pages/checklist-management/model/cklst-mng-columns.tsx
new file mode 100644
index 000000000..fd74d47ce
--- /dev/null
+++ b/apps/as/src/pages/checklist-management/model/cklst-mng-columns.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { createColumnHelper } from "@tanstack/react-table";
+
+import type { CklstMng } from "@/entities/cklst";
+import { Button } from "@common/components";
+
+type CklstMngRow = CklstMng & {
+ docSeComNm?: string;
+ taskSeCdNm?: string;
+};
+
+const columnHelper = createColumnHelper();
+
+interface CreateCklstMngColumnsOptions {
+ onEdit: (row: CklstMngRow) => void;
+ onDelete: (row: CklstMngRow) => void;
+}
+
+export function createCklstMngColumns({ onEdit, onDelete }: CreateCklstMngColumnsOptions) {
+ return [
+ columnHelper.accessor("cklstSn", {
+ header: "점검표일련번호",
+ meta: { align: "center" },
+ }),
+ columnHelper.accessor("cklstNm", {
+ header: "점검표명",
+ meta: { align: "left" },
+ }),
+ columnHelper.accessor("docSeComNm", {
+ header: "문서구분",
+ cell: ({ getValue, row }) => getValue() ?? row.original.docSeComCd ?? "-",
+ }),
+ columnHelper.accessor("taskSeCdNm", {
+ header: "업무구분",
+ cell: ({ getValue, row }) => getValue() ?? row.original.taskSeComCd ?? "-",
+ }),
+ columnHelper.accessor("useYn", {
+ header: "사용여부",
+ cell: ({ getValue }) => {
+ const value = getValue();
+ return value === "Y" ? "예" : value === "N" ? "아니오" : "-";
+ },
+ }),
+ columnHelper.accessor("regDt", {
+ header: "등록일시",
+ cell: ({ getValue }) => getValue() ?? "-",
+ }),
+ columnHelper.display({
+ id: "actions",
+ header: "관리",
+ cell: ({ row }) => (
+ e.stopPropagation()}>
+ onEdit(row.original)}>
+ 관리
+
+ onDelete(row.original)}>
+ 삭제
+
+
+ ),
+ }),
+ ];
+}
diff --git a/apps/as/src/pages/checklist-management/model/cklst-rvw-row-types.ts b/apps/as/src/pages/checklist-management/model/cklst-rvw-row-types.ts
new file mode 100644
index 000000000..d77ecc200
--- /dev/null
+++ b/apps/as/src/pages/checklist-management/model/cklst-rvw-row-types.ts
@@ -0,0 +1,8 @@
+import type { CklstRvw } from "@/entities/cklst";
+
+export interface CklstRvwRow extends Omit {
+ cklstRvwSn?: number;
+ rowKey: string;
+ isNew: boolean;
+ isDirty: boolean;
+}
diff --git a/apps/as/src/pages/checklist-management/ui/checklist-management-page.tsx b/apps/as/src/pages/checklist-management/ui/checklist-management-page.tsx
new file mode 100644
index 000000000..d49d7ed6a
--- /dev/null
+++ b/apps/as/src/pages/checklist-management/ui/checklist-management-page.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { useState } from "react";
+
+import type { CklstMng } from "@/entities/cklst";
+
+import { CklstArtclList } from "./cklst-artcl-list";
+import { CklstMngList } from "./cklst-mng-list";
+import { CklstRvwGrid } from "./cklst-rvw-grid";
+
+function ChecklistManagementPage() {
+ const [selectedCklst, setSelectedCklst] = useState(null);
+
+ return (
+
+ );
+}
+
+export { ChecklistManagementPage };
diff --git a/apps/as/src/pages/checklist-management/ui/cklst-artcl-form-dialog.tsx b/apps/as/src/pages/checklist-management/ui/cklst-artcl-form-dialog.tsx
new file mode 100644
index 000000000..0ce48cd1a
--- /dev/null
+++ b/apps/as/src/pages/checklist-management/ui/cklst-artcl-form-dialog.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+
+import type { CklstArtcl } from "@/entities/cklst";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Input,
+ Label,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@common/components";
+
+export interface CklstArtclFormValues {
+ cklstArtclNm: string;
+ useYn: "Y" | "N";
+}
+
+interface CklstArtclFormDialogProps {
+ mode: "create" | "edit";
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ initial?: CklstArtcl | null;
+ isSubmitting?: boolean;
+ onSubmit: (values: CklstArtclFormValues) => void;
+}
+
+function CklstArtclFormDialog({
+ mode,
+ open,
+ onOpenChange,
+ initial,
+ isSubmitting,
+ onSubmit,
+}: CklstArtclFormDialogProps) {
+ const { register, handleSubmit, reset, setValue, watch } = useForm({
+ defaultValues: {
+ cklstArtclNm: "",
+ useYn: "Y",
+ },
+ });
+
+ const useYn = watch("useYn");
+
+ useEffect(() => {
+ if (!open) return;
+
+ if (mode === "edit" && initial) {
+ reset({
+ cklstArtclNm: initial.cklstArtclNm ?? "",
+ useYn: initial.useYn ?? "Y",
+ });
+ return;
+ }
+
+ reset({
+ cklstArtclNm: "",
+ useYn: "Y",
+ });
+ }, [open, mode, initial, reset]);
+
+ return (
+
+ );
+}
+
+export { CklstArtclFormDialog };
diff --git a/apps/as/src/pages/checklist-management/ui/cklst-artcl-list.tsx b/apps/as/src/pages/checklist-management/ui/cklst-artcl-list.tsx
new file mode 100644
index 000000000..480ab9508
--- /dev/null
+++ b/apps/as/src/pages/checklist-management/ui/cklst-artcl-list.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { useMemo, useState } from "react";
+
+import { useConfirm } from "@frontend-opensource/use-react-hooks";
+import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
+
+import { matchQueryStatus } from "@/shared/lib";
+import { NoData } from "@/shared/ui/no-data";
+import {
+ type CklstArtcl,
+ useCklstArtclList,
+ useCreateCklstArtcl,
+ useDeleteCklstArtcl,
+ useUpdateCklstArtcl,
+} from "@/entities/cklst";
+import { Button, DataTable, Skeleton } from "@common/components";
+
+import { createCklstArtclColumns } from "../model/cklst-artcl-columns";
+
+import { CklstArtclFormDialog, type CklstArtclFormValues } from "./cklst-artcl-form-dialog";
+
+interface CklstArtclListProps {
+ cklstSn?: number;
+}
+
+function CklstArtclList({ cklstSn }: CklstArtclListProps) {
+ const { confirm } = useConfirm();
+ const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
+ const [editingArtcl, setEditingArtcl] = useState(null);
+
+ const artclQuery = useCklstArtclList(cklstSn);
+ const createMutation = useCreateCklstArtcl();
+ const updateMutation = useUpdateCklstArtcl();
+ const deleteMutation = useDeleteCklstArtcl();
+
+ const columns = useMemo(
+ () =>
+ createCklstArtclColumns({
+ onEdit: (row) => {
+ setEditingArtcl(row);
+ setDialogMode("edit");
+ },
+ onDelete: async (row) => {
+ if (!(await confirm("선택한 점검항목을 삭제하시겠습니까?"))) return;
+ await deleteMutation.mutateAsync({
+ cklstArtclSn: row.cklstArtclSn,
+ cklstSn: row.cklstSn,
+ });
+ },
+ }),
+ [confirm, deleteMutation],
+ );
+
+ const table = useReactTable({
+ data: artclQuery.data ?? [],
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getRowId: (row) => `${row.cklstArtclSn}-${row.cklstSn}`,
+ });
+
+ const tableComponent = matchQueryStatus(artclQuery, {
+ Pending: (
+
+
+
+
+ ),
+ Empty: ,
+ Success: () => ,
+ });
+
+ const handleFormSubmit = async (values: CklstArtclFormValues) => {
+ if (!cklstSn) return;
+
+ if (dialogMode === "create") {
+ await createMutation.mutateAsync({
+ cklstSn,
+ cklstArtclNm: values.cklstArtclNm,
+ useYn: values.useYn,
+ });
+ } else if (dialogMode === "edit" && editingArtcl) {
+ await updateMutation.mutateAsync({
+ cklstArtclSn: editingArtcl.cklstArtclSn,
+ cklstSn: editingArtcl.cklstSn,
+ body: {
+ cklstArtclNm: values.cklstArtclNm,
+ useYn: values.useYn,
+ },
+ });
+ }
+
+ setDialogMode(null);
+ setEditingArtcl(null);
+ };
+
+ const isDisabled = !cklstSn;
+
+ return (
+
+
+
점검항목 관리
+ setDialogMode("create")}
+ >
+ 점검항목 추가
+
+
+ {isDisabled ? (
+
+ 점검표를 선택하면 점검항목을 관리할 수 있습니다.
+
+ ) : (
+ tableComponent
+ )}
+ {dialogMode && (
+ {
+ if (!open) {
+ setDialogMode(null);
+ setEditingArtcl(null);
+ }
+ }}
+ initial={dialogMode === "edit" ? editingArtcl : null}
+ isSubmitting={createMutation.isPending || updateMutation.isPending}
+ onSubmit={handleFormSubmit}
+ />
+ )}
+
+ );
+}
+
+export { CklstArtclList };
diff --git a/apps/as/src/pages/checklist-management/ui/cklst-mng-form-dialog.tsx b/apps/as/src/pages/checklist-management/ui/cklst-mng-form-dialog.tsx
new file mode 100644
index 000000000..8ff919256
--- /dev/null
+++ b/apps/as/src/pages/checklist-management/ui/cklst-mng-form-dialog.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+
+import type { CklstMng } from "@/entities/cklst";
+import { CodeSelect } from "@/entities/code";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Input,
+ Label,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@common/components";
+
+export interface CklstMngFormValues {
+ cklstNm: string;
+ docSeComCd: string;
+ taskSeComCd: string;
+ useYn: "Y" | "N";
+}
+
+interface CklstMngFormDialogProps {
+ mode: "create" | "edit";
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ initial?: CklstMng | null;
+ isSubmitting?: boolean;
+ onSubmit: (values: CklstMngFormValues) => void;
+}
+
+function CklstMngFormDialog({
+ mode,
+ open,
+ onOpenChange,
+ initial,
+ isSubmitting,
+ onSubmit,
+}: CklstMngFormDialogProps) {
+ const { register, handleSubmit, reset, setValue, watch } = useForm({
+ defaultValues: {
+ cklstNm: "",
+ docSeComCd: "",
+ taskSeComCd: "",
+ useYn: "Y",
+ },
+ });
+
+ const docSeComCd = watch("docSeComCd");
+ const useYn = watch("useYn");
+
+ useEffect(() => {
+ if (!open) return;
+
+ if (mode === "edit" && initial) {
+ reset({
+ cklstNm: initial.cklstNm,
+ docSeComCd: initial.docSeComCd ?? "",
+ taskSeComCd: initial.taskSeComCd,
+ useYn: initial.useYn ?? "Y",
+ });
+ return;
+ }
+
+ reset({
+ cklstNm: "",
+ docSeComCd: "",
+ taskSeComCd: "",
+ useYn: "Y",
+ });
+ }, [open, mode, initial, reset]);
+
+ useEffect(() => {
+ if (mode === "create" && docSeComCd) {
+ setValue("taskSeComCd", "");
+ }
+ }, [docSeComCd, mode, setValue]);
+
+ return (
+
+ );
+}
+
+export { CklstMngFormDialog };
diff --git a/apps/as/src/pages/checklist-management/ui/cklst-mng-list.tsx b/apps/as/src/pages/checklist-management/ui/cklst-mng-list.tsx
new file mode 100644
index 000000000..921295ac2
--- /dev/null
+++ b/apps/as/src/pages/checklist-management/ui/cklst-mng-list.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import { useMemo, useState } from "react";
+
+import { useConfirm } from "@frontend-opensource/use-react-hooks";
+import { getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
+
+import { useDataTable } from "@/shared/lib";
+import {
+ type CklstMng,
+ useCklstMngPage,
+ useCreateCklstMng,
+ useDeleteCklstMng,
+ useUpdateCklstMng,
+} from "@/entities/cklst";
+import { Button } from "@common/components";
+
+import { createCklstMngColumns } from "../model/cklst-mng-columns";
+
+import { CklstMngFormDialog, type CklstMngFormValues } from "./cklst-mng-form-dialog";
+
+interface CklstMngListProps {
+ selectedCklstSn: number | null;
+ onSelectCklst: (cklst: CklstMng | null) => void;
+}
+
+function CklstMngList({ selectedCklstSn, onSelectCklst }: CklstMngListProps) {
+ const { confirm } = useConfirm();
+ const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
+ const [editingCklst, setEditingCklst] = useState(null);
+ const [rowSelection, setRowSelection] = useState(() =>
+ selectedCklstSn != null ? { [String(selectedCklstSn)]: true } : {},
+ );
+
+ const mngQuery = useCklstMngPage({
+ page: 0,
+ size: 50,
+ sort: "regDt,desc",
+ });
+
+ const createMutation = useCreateCklstMng();
+ const updateMutation = useUpdateCklstMng();
+ const deleteMutation = useDeleteCklstMng();
+
+ const columns = useMemo(
+ () =>
+ createCklstMngColumns({
+ onEdit: (row) => {
+ setEditingCklst(row);
+ setDialogMode("edit");
+ },
+ onDelete: async (row) => {
+ if (!(await confirm("선택한 점검표를 삭제하시겠습니까?"))) return;
+ await deleteMutation.mutateAsync(row.cklstSn);
+ if (selectedCklstSn === row.cklstSn) {
+ onSelectCklst(null);
+ setRowSelection({});
+ }
+ },
+ }),
+ [confirm, deleteMutation, onSelectCklst, selectedCklstSn],
+ );
+
+ const { tableComponent } = useDataTable({
+ query: mngQuery,
+ tableOptions: {
+ columns,
+ state: { rowSelection },
+ enableRowSelection: true,
+ enableMultiRowSelection: false,
+ getRowId: (row) => String(row.cklstSn),
+ onRowSelectionChange: (updater) => {
+ const next = typeof updater === "function" ? updater(rowSelection) : updater;
+ setRowSelection(next);
+ const selectedId = Object.keys(next)[0];
+ if (!selectedId) {
+ onSelectCklst(null);
+ return;
+ }
+ const selected = mngQuery.data?.content.find((c) => String(c.cklstSn) === selectedId);
+ onSelectCklst(selected ?? null);
+ },
+ getCoreRowModel: getCoreRowModel(),
+ },
+ dataTableOptions: {
+ onRowClick: (row) => {
+ setRowSelection({ [String(row.cklstSn)]: true });
+ onSelectCklst(row);
+ },
+ },
+ });
+
+ const handleFormSubmit = async (values: CklstMngFormValues) => {
+ const body = {
+ cklstNm: values.cklstNm,
+ taskSeComCd: values.taskSeComCd,
+ useYn: values.useYn,
+ docSeComCd: values.docSeComCd || undefined,
+ };
+
+ if (dialogMode === "create") {
+ const cklstSn = await createMutation.mutateAsync(body);
+ const { data } = await mngQuery.refetch();
+ const created = data?.content.find((c) => c.cklstSn === cklstSn);
+ if (created) {
+ onSelectCklst(created);
+ setRowSelection({ [String(created.cklstSn)]: true });
+ }
+ } else if (dialogMode === "edit" && editingCklst) {
+ await updateMutation.mutateAsync({ cklstSn: editingCklst.cklstSn, body });
+ const { data } = await mngQuery.refetch();
+ const updated = data?.content.find((c) => c.cklstSn === editingCklst.cklstSn);
+ if (updated && selectedCklstSn === editingCklst.cklstSn) {
+ onSelectCklst(updated);
+ }
+ }
+
+ setDialogMode(null);
+ setEditingCklst(null);
+ };
+
+ return (
+
+
+
점검표 관리
+ setDialogMode("create")}>
+ 점검표 추가
+
+
+ {tableComponent}
+ {dialogMode && (
+ {
+ if (!open) {
+ setDialogMode(null);
+ setEditingCklst(null);
+ }
+ }}
+ initial={dialogMode === "edit" ? editingCklst : null}
+ isSubmitting={createMutation.isPending || updateMutation.isPending}
+ onSubmit={handleFormSubmit}
+ />
+ )}
+
+ );
+}
+
+export { CklstMngList };
diff --git a/apps/as/src/pages/checklist-management/ui/cklst-preview-dialog.tsx b/apps/as/src/pages/checklist-management/ui/cklst-preview-dialog.tsx
new file mode 100644
index 000000000..42f2edcf8
--- /dev/null
+++ b/apps/as/src/pages/checklist-management/ui/cklst-preview-dialog.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import type { CklstArtcl } from "@/entities/cklst";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ Label,
+ RadioGroup,
+ RadioGroupItem,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@common/components";
+
+import { buildRvwDisplayRows, formatTreeBranchPrefix } from "../model/build-rvw-display-rows";
+import type { CklstRvwRow } from "../model/cklst-rvw-row-types";
+
+interface CklstPreviewDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ cklstNm: string;
+ rows: CklstRvwRow[];
+ artclOptions: CklstArtcl[];
+}
+
+function CklstPreviewDialog({
+ open,
+ onOpenChange,
+ cklstNm,
+ rows,
+ artclOptions,
+}: CklstPreviewDialogProps) {
+ const displayRows = buildRvwDisplayRows(rows);
+
+ function getArtclLabel(cklstArtclSn: number): string {
+ const artcl = artclOptions.find((item) => item.cklstArtclSn === cklstArtclSn);
+ return artcl?.cklstArtclNm ?? String(cklstArtclSn);
+ }
+
+ return (
+
+ );
+}
+
+export { CklstPreviewDialog };
diff --git a/apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx b/apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx
new file mode 100644
index 000000000..8eca3ce21
--- /dev/null
+++ b/apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx
@@ -0,0 +1,324 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+
+import { useConfirm } from "@frontend-opensource/use-react-hooks";
+
+import { useAlertStore } from "@/shared/lib";
+import type { CklstMng, CklstRvw } from "@/entities/cklst";
+import {
+ useCklstArtclList,
+ useCklstRvwPage,
+ useCreateCklstRvw,
+ useDeleteCklstRvw,
+ useUpdateCklstRvw,
+} from "@/entities/cklst";
+import {
+ Button,
+ Skeleton,
+ Table,
+ TableBody,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@common/components";
+
+import { buildRvwDisplayRows, MAX_RVW_CN_LV_NO } from "../model/build-rvw-display-rows";
+import type { CklstRvwRow } from "../model/cklst-rvw-row-types";
+
+import { CklstPreviewDialog } from "./cklst-preview-dialog";
+import { CklstRvwRowComponent } from "./cklst-rvw-row";
+
+interface CklstRvwGridProps {
+ selectedCklst: CklstMng | null;
+}
+
+function toRow(item: CklstRvw): CklstRvwRow {
+ return {
+ ...item,
+ rowKey: `${item.cklstRvwSn}-${item.cklstArtclSn}-${item.cklstSn}`,
+ isNew: false,
+ isDirty: false,
+ };
+}
+
+function createDraftRowKey(): string {
+ return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
+}
+
+function validateRvwRow(row: CklstRvwRow): string | null {
+ if (!row.cklstArtclSn) return "점검항목을 선택하세요.";
+ if (!row.rvwCn?.trim()) return "주요 검토내용을 입력하세요.";
+ return null;
+}
+
+function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
+ const { confirm } = useConfirm();
+ const [rows, setRows] = useState([]);
+ const [previewOpen, setPreviewOpen] = useState(false);
+
+ const alert = useAlertStore((state) => state.setMessage);
+ const cklstSn = selectedCklst?.cklstSn ?? null;
+
+ const rvwQuery = useCklstRvwPage({
+ cklstSn: cklstSn ?? undefined,
+ page: 0,
+ size: 500,
+ sort: "sortSeq,asc",
+ });
+
+ const artclQuery = useCklstArtclList(cklstSn);
+ const createMutation = useCreateCklstRvw();
+ const updateMutation = useUpdateCklstRvw();
+ const deleteMutation = useDeleteCklstRvw();
+
+ const artclOptions = useMemo(
+ () => (artclQuery.data ?? []).filter((artcl) => artcl.useYn !== "N"),
+ [artclQuery.data],
+ );
+
+ useEffect(() => {
+ if (!cklstSn) {
+ setRows([]);
+ return;
+ }
+ setRows((rvwQuery.data?.content ?? []).map(toRow));
+ }, [cklstSn, rvwQuery.data?.content]);
+
+ const displayRows = useMemo(() => buildRvwDisplayRows(rows), [rows]);
+
+ const getDirtyRows = useCallback(() => rows.filter((row) => row.isDirty || row.isNew), [rows]);
+
+ const updateRow = useCallback((rowKey: string, patch: Partial) => {
+ setRows((prev) =>
+ prev.map((row) => (row.rowKey === rowKey ? { ...row, ...patch, isDirty: true } : row)),
+ );
+ }, []);
+
+ const removeRowLocal = useCallback((rowKey: string) => {
+ setRows((prev) => prev.filter((row) => row.rowKey !== rowKey));
+ }, []);
+
+ const addRootRow = useCallback(() => {
+ if (!cklstSn) return;
+
+ const maxSort = rows.reduce((max, row) => Math.max(max, row.sortSeq ?? 0), 0);
+
+ setRows((prev) => [
+ ...prev,
+ {
+ rowKey: createDraftRowKey(),
+ cklstSn,
+ cklstArtclSn: artclOptions[0]?.cklstArtclSn ?? 0,
+ rvwCnLvNo: 1,
+ rvwCn: "",
+ useYn: "Y",
+ sortSeq: maxSort + 1,
+ isNew: true,
+ isDirty: true,
+ },
+ ]);
+ }, [artclOptions, cklstSn, rows]);
+
+ const addChildRow = useCallback(
+ (parent: CklstRvwRow) => {
+ if (!cklstSn || parent.isNew || parent.cklstRvwSn == null) {
+ alert("상위 행을 먼저 저장한 후 하위 항목을 추가할 수 있습니다.");
+ return;
+ }
+
+ const parentLv = parent.rvwCnLvNo ?? 1;
+ if (parentLv >= MAX_RVW_CN_LV_NO) {
+ alert("최대 3단계까지 추가할 수 있습니다.");
+ return;
+ }
+
+ const maxSort = rows.reduce((max, row) => Math.max(max, row.sortSeq ?? 0), 0);
+
+ setRows((prev) => [
+ ...prev,
+ {
+ rowKey: createDraftRowKey(),
+ cklstSn,
+ cklstArtclSn: parent.cklstArtclSn,
+ upCklstRvwSn: parent.cklstRvwSn,
+ rvwCnLvNo: parentLv + 1,
+ rvwCn: "",
+ useYn: "Y",
+ sortSeq: maxSort + 1,
+ isNew: true,
+ isDirty: true,
+ },
+ ]);
+ },
+ [alert, cklstSn, rows],
+ );
+
+ const handleDelete = async (row: CklstRvwRow) => {
+ if (
+ !(await confirm(
+ "선택한 항목을 삭제하시겠습니까?\n삭제한 항목은 되돌릴 수 없으며, 하위 항목까지 모두 삭제됩니다.",
+ ))
+ )
+ return;
+
+ if (row.isNew) {
+ removeRowLocal(row.rowKey);
+ return;
+ }
+
+ const children = rows.filter((r) => r.upCklstRvwSn === row.cklstRvwSn);
+ if (children.length > 0) {
+ for (const child of children) {
+ await deleteMutation.mutateAsync({
+ cklstRvwSn: child.cklstRvwSn!,
+ cklstArtclSn: child.cklstArtclSn,
+ cklstSn: child.cklstSn,
+ });
+ }
+ }
+
+ await deleteMutation.mutateAsync({
+ cklstRvwSn: row.cklstRvwSn!,
+ cklstArtclSn: row.cklstArtclSn,
+ cklstSn: row.cklstSn,
+ });
+ await rvwQuery.refetch();
+ };
+
+ const handleSave = async () => {
+ if (!cklstSn) return;
+
+ const dirty = getDirtyRows().sort((a, b) => (a.rvwCnLvNo ?? 1) - (b.rvwCnLvNo ?? 1));
+
+ for (const row of dirty) {
+ const fieldError = validateRvwRow(row);
+ if (fieldError) {
+ alert(fieldError);
+ return;
+ }
+
+ if (!(await confirm("체크리스트 항목을 저장하시겠습니까?"))) return;
+
+ if (row.isNew) {
+ await createMutation.mutateAsync({
+ cklstSn: row.cklstSn,
+ cklstArtclSn: row.cklstArtclSn,
+ upCklstRvwSn: row.upCklstRvwSn,
+ rvwCnLvNo: row.rvwCnLvNo,
+ rvwCn: row.rvwCn,
+ useYn: row.useYn,
+ sortSeq: row.sortSeq,
+ bchckUseYn: "Y",
+ });
+ } else if (row.cklstRvwSn != null) {
+ await updateMutation.mutateAsync({
+ cklstRvwSn: row.cklstRvwSn,
+ cklstArtclSn: row.cklstArtclSn,
+ cklstSn: row.cklstSn,
+ body: {
+ upCklstRvwSn: row.upCklstRvwSn,
+ rvwCnLvNo: row.rvwCnLvNo,
+ rvwCn: row.rvwCn,
+ useYn: row.useYn,
+ sortSeq: row.sortSeq,
+ bchckUseYn: row.bchckUseYn,
+ },
+ });
+ }
+ }
+
+ alert("체크리스트 항목이 저장되었습니다.");
+ await rvwQuery.refetch();
+ };
+
+ const isDisabled = !selectedCklst;
+ const isSaving = createMutation.isPending || updateMutation.isPending || deleteMutation.isPending;
+
+ return (
+
+
+
체크리스트 항목
+
+ setPreviewOpen(true)}
+ >
+ 미리보기
+
+
+ 체크리스트 추가
+
+
+ 저장
+
+
+
+
+ {rvwQuery.isPending && cklstSn ? (
+
+
+
+
+ ) : isDisabled ? (
+
+ 점검표를 선택하면 체크리스트 항목을 편집할 수 있습니다.
+
+ ) : displayRows.length === 0 ? (
+
+ 등록된 항목이 없습니다. [추가]로 1단계 항목을 등록하세요.
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+ 점검항목
+ 주요 검토내용
+ 사용 여부
+ 구분
+ 관리
+
+
+
+ {displayRows.map(({ row, depth }) => (
+ updateRow(row.rowKey, patch)}
+ onAddChild={() => addChildRow(row)}
+ onDelete={() => handleDelete(row)}
+ />
+ ))}
+
+
+ )}
+
+ {selectedCklst && (
+
+ )}
+
+ );
+}
+
+export { CklstRvwGrid };
diff --git a/apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx b/apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx
new file mode 100644
index 000000000..9ea9094ce
--- /dev/null
+++ b/apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import type { CklstArtcl } from "@/entities/cklst";
+import {
+ Button,
+ Input,
+ Label,
+ RadioGroup,
+ RadioGroupItem,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ TableCell,
+ TableRow,
+} from "@common/components";
+
+import {
+ canAddChildRow,
+ formatRvwCnLvNo,
+ formatTreeBranchPrefix,
+} from "../model/build-rvw-display-rows";
+import type { CklstRvwRow } from "../model/cklst-rvw-row-types";
+
+interface CklstRvwRowProps {
+ row: CklstRvwRow;
+ depth: number;
+ artclOptions: CklstArtcl[];
+ onChange: (patch: Partial) => void;
+ onAddChild: () => void;
+ onDelete: () => void;
+}
+
+function CklstRvwRowComponent({
+ row,
+ depth,
+ artclOptions,
+ onChange,
+ onAddChild,
+ onDelete,
+}: CklstRvwRowProps) {
+ const levelLabel = formatRvwCnLvNo(row.rvwCnLvNo);
+ const branchPrefix = formatTreeBranchPrefix(depth);
+
+ return (
+
+
+
+ {branchPrefix ? (
+ {branchPrefix}
+ ) : null}
+
+
+
+
+ onChange({ rvwCn: e.target.value, isDirty: true })}
+ placeholder="주요 검토내용"
+ />
+
+
+ onChange({ useYn: v as "Y" | "N", isDirty: true })}
+ className="flex justify-center gap-4"
+ >
+
+
+
+
+
+
+
+
+
+
+ {levelLabel}
+
+
+
+ 하위추가
+
+
+ 삭제
+
+
+
+
+ );
+}
+
+export { CklstRvwRowComponent };
diff --git a/apps/as/src/pages/cnstp-code-management/index.ts b/apps/as/src/pages/cnstp-code-management/index.ts
new file mode 100644
index 000000000..2ed1cb4ba
--- /dev/null
+++ b/apps/as/src/pages/cnstp-code-management/index.ts
@@ -0,0 +1 @@
+export { CnstpCodeManagementPage } from "./ui/cnstp-code-management-page";
diff --git a/apps/as/src/pages/cnstp-code-management/lib/build-save-multi-payload.ts b/apps/as/src/pages/cnstp-code-management/lib/build-save-multi-payload.ts
new file mode 100644
index 000000000..20ec66853
--- /dev/null
+++ b/apps/as/src/pages/cnstp-code-management/lib/build-save-multi-payload.ts
@@ -0,0 +1,62 @@
+import { CNSTP_SE_SAVE_MODE, type CnstpSeMngSaveRequestDto } from "@/entities/cnstp";
+
+import type { CnstpSeRow } from "../model/cnstp-se-row-types";
+
+import { getVisibleRows } from "./row-state";
+
+function compareCdLvNoDesc(a: CnstpSeRow, b: CnstpSeRow): number {
+ return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1);
+}
+
+function compareCdLvNoAsc(a: CnstpSeRow, b: CnstpSeRow): number {
+ return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1);
+}
+
+export function buildSaveMultiPayload(rows: CnstpSeRow[]): CnstpSeMngSaveRequestDto[] {
+ const payload: CnstpSeMngSaveRequestDto[] = [];
+
+ const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc);
+
+ for (const row of deletedRows) {
+ payload.push({
+ mode: CNSTP_SE_SAVE_MODE.DELETE,
+ cnstpSeCd: row.cnstpSeCd,
+ cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
+ upCnstpCd: row.upCnstpCd,
+ cdLvNo: row.cdLvNo,
+ useYn: row.useYn,
+ expln: row.expln?.trim() || undefined,
+ });
+ }
+
+ const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc);
+
+ for (const row of visibleRows) {
+ if (row.isNew) {
+ payload.push({
+ mode: CNSTP_SE_SAVE_MODE.CREATE,
+ cnstpSeCd: row.cnstpSeCd.trim(),
+ cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
+ upCnstpCd: row.upCnstpCd,
+ cdLvNo: row.cdLvNo,
+ useYn: row.useYn,
+ expln: row.expln?.trim() || undefined,
+ });
+ continue;
+ }
+
+ if (row.isDirty) {
+ payload.push({
+ mode: CNSTP_SE_SAVE_MODE.UPDATE,
+ cnstpSeCd: row.cnstpSeCd.trim(),
+ cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
+ upCnstpCd: row.upCnstpCd,
+ cdLvNo: row.cdLvNo,
+ useYn: row.useYn,
+ expln: row.expln?.trim() || undefined,
+ });
+ }
+ }
+
+ return payload;
+}
diff --git a/apps/as/src/pages/cnstp-code-management/lib/cnstp-se-grid.ts b/apps/as/src/pages/cnstp-code-management/lib/cnstp-se-grid.ts
new file mode 100644
index 000000000..6b1bd99f9
--- /dev/null
+++ b/apps/as/src/pages/cnstp-code-management/lib/cnstp-se-grid.ts
@@ -0,0 +1,245 @@
+import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
+
+import { buildCnstpDisplayRows } from "../model/build-cnstp-display-rows";
+import type {
+ CnstpDisplaySlot,
+ CnstpSeBaseEntry,
+ CnstpSeGridState,
+} from "../model/cnstp-se-grid-types";
+import type { CnstpSeRow } from "../model/cnstp-se-row-types";
+
+export function createLoadedRowKey(index: number, cnstpSeCd: string): string {
+ return `loaded-${index}-${cnstpSeCd}`;
+}
+
+export function createDraftRowKey(): string {
+ return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
+}
+
+export function createEmptyGridState(): CnstpSeGridState {
+ return {
+ baseByKey: {},
+ structure: [],
+ drafts: {},
+ patches: {},
+ deletedKeys: new Set(),
+ };
+}
+
+function toInitialRow(entry: CnstpSeBaseEntry): CnstpSeRow {
+ return {
+ ...entry,
+ isNew: false,
+ isDirty: false,
+ isDeleted: false,
+ };
+}
+
+export function buildStructureFromBaseRows(
+ baseByKey: Record,
+): CnstpDisplaySlot[] {
+ const rows = Object.values(baseByKey).map(toInitialRow);
+ const display = buildCnstpDisplayRows(rows);
+ const cdToRowKey = new Map(rows.map((row) => [row.cnstpSeCd, row.rowKey]));
+
+ return display.map(({ row, depth }) => ({
+ rowKey: row.rowKey,
+ depth,
+ parentRowKey: row.upCnstpCd?.trim() ? (cdToRowKey.get(row.upCnstpCd.trim()) ?? null) : null,
+ }));
+}
+
+export function hydrateGridFromServer(content: CnstpSeMngResponseDto[]): CnstpSeGridState {
+ const baseByKey: Record = {};
+
+ content.forEach((item, index) => {
+ const rowKey = createLoadedRowKey(index, item.cnstpSeCd);
+ baseByKey[rowKey] = { ...item, rowKey };
+ });
+
+ return {
+ baseByKey,
+ structure: buildStructureFromBaseRows(baseByKey),
+ drafts: {},
+ patches: {},
+ deletedKeys: new Set(),
+ };
+}
+
+export function resolveParentCnstpSeCd(
+ state: CnstpSeGridState,
+ parentRowKey: string | null,
+): string | undefined {
+ if (!parentRowKey) return undefined;
+ const parent = mergeCnstpRow(state, parentRowKey);
+ const cd = parent?.cnstpSeCd?.trim();
+ return cd || undefined;
+}
+
+export function mergeCnstpRow(state: CnstpSeGridState, rowKey: string): CnstpSeRow | null {
+ const draft = state.drafts[rowKey];
+ if (draft) {
+ return {
+ rowKey,
+ cnstpSeCd: draft.cnstpSeCd,
+ cnstpSeCdNm: draft.cnstpSeCdNm,
+ expln: draft.expln,
+ useYn: draft.useYn,
+ cdLvNo: draft.cdLvNo,
+ upCnstpCd: resolveParentCnstpSeCd(state, draft.parentRowKey),
+ isNew: true,
+ isDirty: true,
+ isDeleted: false,
+ };
+ }
+
+ const base = state.baseByKey[rowKey];
+ if (!base) return null;
+
+ const patch = state.patches[rowKey] ?? {};
+ const hasPatch = Object.keys(patch).length > 0;
+
+ return {
+ ...base,
+ ...patch,
+ rowKey,
+ isNew: false,
+ isDirty: hasPatch,
+ isDeleted: state.deletedKeys.has(rowKey),
+ };
+}
+
+export function mergeAllCnstpRows(state: CnstpSeGridState): CnstpSeRow[] {
+ const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]);
+
+ return [...keys]
+ .map((rowKey) => mergeCnstpRow(state, rowKey))
+ .filter((row): row is CnstpSeRow => row != null);
+}
+
+export function hasCnstpGridPendingChanges(state: CnstpSeGridState): boolean {
+ return (
+ state.deletedKeys.size > 0 ||
+ Object.keys(state.drafts).length > 0 ||
+ Object.keys(state.patches).length > 0
+ );
+}
+
+export function collectDescendantRowKeys(
+ structure: CnstpDisplaySlot[],
+ rootRowKey: string,
+): Set {
+ const result = new Set([rootRowKey]);
+ const queue = [rootRowKey];
+
+ while (queue.length > 0) {
+ const parentKey = queue.shift()!;
+ for (const slot of structure) {
+ if (slot.parentRowKey === parentKey && !result.has(slot.rowKey)) {
+ result.add(slot.rowKey);
+ queue.push(slot.rowKey);
+ }
+ }
+ }
+
+ return result;
+}
+
+export function insertRootSlot(
+ structure: CnstpDisplaySlot[],
+ slot: CnstpDisplaySlot,
+): CnstpDisplaySlot[] {
+ const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null);
+ if (firstRootIndex === -1) return [slot, ...structure];
+ return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)];
+}
+
+export function insertChildSlot(
+ structure: CnstpDisplaySlot[],
+ parentRowKey: string,
+ slot: CnstpDisplaySlot,
+): CnstpDisplaySlot[] {
+ const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey);
+ if (parentIndex === -1) return [...structure, slot];
+
+ const insertAt = parentIndex + 1;
+ return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)];
+}
+
+export function removeSlotsFromStructure(
+ structure: CnstpDisplaySlot[],
+ rowKeysToRemove: Set,
+): CnstpDisplaySlot[] {
+ return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey));
+}
+
+export function patchCnstpGridRow(
+ state: CnstpSeGridState,
+ rowKey: string,
+ patch: Partial,
+): CnstpSeGridState {
+ const draft = state.drafts[rowKey];
+ if (draft) {
+ return {
+ ...state,
+ drafts: {
+ ...state.drafts,
+ [rowKey]: {
+ ...draft,
+ ...(patch.cnstpSeCd !== undefined ? { cnstpSeCd: patch.cnstpSeCd } : {}),
+ ...(patch.cnstpSeCdNm !== undefined ? { cnstpSeCdNm: patch.cnstpSeCdNm ?? "" } : {}),
+ ...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
+ ...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
+ },
+ },
+ };
+ }
+
+ const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch;
+
+ return {
+ ...state,
+ patches: {
+ ...state.patches,
+ [rowKey]: { ...state.patches[rowKey], ...dtoPatch },
+ },
+ };
+}
+
+export function markCnstpRowsDeleted(
+ state: CnstpSeGridState,
+ rootRowKey: string,
+): CnstpSeGridState {
+ const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey);
+ const deletedKeys = new Set(state.deletedKeys);
+ for (const key of keysToDelete) {
+ deletedKeys.add(key);
+ }
+ return { ...state, deletedKeys };
+}
+
+export function validateDraftParentCnstpSeCd(
+ state: CnstpSeGridState,
+ rowKey: string,
+): string | null {
+ const draft = state.drafts[rowKey];
+ if (!draft?.parentRowKey) return null;
+
+ const parent = mergeCnstpRow(state, draft.parentRowKey);
+ if (!parent?.cnstpSeCd?.trim()) {
+ return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요.";
+ }
+
+ return null;
+}
+
+export function removeDraftFromGrid(state: CnstpSeGridState, rowKey: string): CnstpSeGridState {
+ const { [rowKey]: _removed, ...restDrafts } = state.drafts;
+ const keysToRemove = collectDescendantRowKeys(state.structure, rowKey);
+
+ return {
+ ...state,
+ drafts: restDrafts,
+ structure: removeSlotsFromStructure(state.structure, keysToRemove),
+ };
+}
diff --git a/apps/as/src/pages/cnstp-code-management/lib/row-state.ts b/apps/as/src/pages/cnstp-code-management/lib/row-state.ts
new file mode 100644
index 000000000..b7a98aca7
--- /dev/null
+++ b/apps/as/src/pages/cnstp-code-management/lib/row-state.ts
@@ -0,0 +1,5 @@
+import type { CnstpSeRow } from "../model/cnstp-se-row-types";
+
+export function getVisibleRows(rows: CnstpSeRow[]): CnstpSeRow[] {
+ return rows.filter((row) => !row.isDeleted);
+}
diff --git a/apps/as/src/pages/cnstp-code-management/lib/use-cnstp-se-rows.ts b/apps/as/src/pages/cnstp-code-management/lib/use-cnstp-se-rows.ts
new file mode 100644
index 000000000..7e4320c8f
--- /dev/null
+++ b/apps/as/src/pages/cnstp-code-management/lib/use-cnstp-se-rows.ts
@@ -0,0 +1,167 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
+
+import type { CnstpSeGridState } from "../model/cnstp-se-grid-types";
+import type { CnstpSeRow } from "../model/cnstp-se-row-types";
+
+import { buildSaveMultiPayload } from "./build-save-multi-payload";
+import {
+ createDraftRowKey,
+ createEmptyGridState,
+ hasCnstpGridPendingChanges,
+ hydrateGridFromServer,
+ insertChildSlot,
+ insertRootSlot,
+ markCnstpRowsDeleted,
+ mergeAllCnstpRows,
+ mergeCnstpRow,
+ patchCnstpGridRow,
+ removeDraftFromGrid,
+ validateDraftParentCnstpSeCd,
+} from "./cnstp-se-grid";
+
+function validateCnstpSeRow(row: CnstpSeRow): string | null {
+ if (!row.cnstpSeCd.trim()) return "코드를 입력하세요.";
+ const parentCd = row.upCnstpCd?.trim();
+ if (parentCd && row.cnstpSeCd.trim() === parentCd) {
+ return "상위 코드와 동일한 코드는 사용할 수 없습니다.";
+ }
+ return null;
+}
+
+interface UseCnstpSeRowsOptions {
+ serverContent: CnstpSeMngResponseDto[] | undefined;
+ onAlert: (message: string) => void;
+ onSave: (payload: ReturnType) => Promise;
+}
+
+export function useCnstpSeRows({ serverContent, onAlert, onSave }: UseCnstpSeRowsOptions) {
+ const [grid, setGrid] = useState(createEmptyGridState);
+ const hasPendingRef = useRef(false);
+
+ hasPendingRef.current = hasCnstpGridPendingChanges(grid);
+
+ useEffect(() => {
+ if (serverContent == null) return;
+ if (hasPendingRef.current) return;
+ setGrid(hydrateGridFromServer(serverContent));
+ }, [serverContent]);
+
+ const displayRows = useMemo(() => {
+ return grid.structure
+ .filter((slot) => !grid.deletedKeys.has(slot.rowKey))
+ .map((slot) => {
+ const row = mergeCnstpRow(grid, slot.rowKey);
+ return row ? { row, depth: slot.depth } : null;
+ })
+ .filter((item): item is { row: CnstpSeRow; depth: number } => item != null);
+ }, [grid]);
+
+ const canSave = hasCnstpGridPendingChanges(grid);
+
+ const updateRow = useCallback((rowKey: string, patch: Partial) => {
+ setGrid((prev) => patchCnstpGridRow(prev, rowKey, patch));
+ }, []);
+
+ const addRootRow = useCallback(() => {
+ const rowKey = createDraftRowKey();
+ setGrid((prev) => ({
+ ...prev,
+ drafts: {
+ ...prev.drafts,
+ [rowKey]: {
+ rowKey,
+ parentRowKey: null,
+ cdLvNo: 1,
+ cnstpSeCd: "",
+ cnstpSeCdNm: "",
+ expln: "",
+ useYn: "Y",
+ },
+ },
+ structure: insertRootSlot(prev.structure, { rowKey, depth: 0, parentRowKey: null }),
+ }));
+ }, []);
+
+ const addChildRow = useCallback((parent: CnstpSeRow) => {
+ const rowKey = createDraftRowKey();
+
+ setGrid((prev) => {
+ const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey);
+ const depth = (parentSlot?.depth ?? 0) + 1;
+
+ return {
+ ...prev,
+ drafts: {
+ ...prev.drafts,
+ [rowKey]: {
+ rowKey,
+ parentRowKey: parent.rowKey,
+ cdLvNo: (parent.cdLvNo ?? 1) + 1,
+ cnstpSeCd: "",
+ cnstpSeCdNm: "",
+ expln: "",
+ useYn: "Y",
+ },
+ },
+ structure: insertChildSlot(prev.structure, parent.rowKey, {
+ rowKey,
+ depth,
+ parentRowKey: parent.rowKey,
+ }),
+ };
+ });
+ }, []);
+
+ const removeDraft = useCallback((rowKey: string) => {
+ setGrid((prev) => removeDraftFromGrid(prev, rowKey));
+ }, []);
+
+ const markDeleted = useCallback((rowKey: string) => {
+ setGrid((prev) => markCnstpRowsDeleted(prev, rowKey));
+ }, []);
+
+ const resetFromServer = useCallback((content: CnstpSeMngResponseDto[]) => {
+ setGrid(hydrateGridFromServer(content));
+ }, []);
+
+ const save = useCallback(async () => {
+ const mergedRows = mergeAllCnstpRows(grid);
+ const payload = buildSaveMultiPayload(mergedRows);
+ if (payload.length === 0) return false;
+
+ for (const row of mergedRows) {
+ if (!row.isDeleted && (row.isNew || row.isDirty)) {
+ const parentCodeError = validateDraftParentCnstpSeCd(grid, row.rowKey);
+ if (parentCodeError) {
+ onAlert(parentCodeError);
+ return false;
+ }
+
+ const fieldError = validateCnstpSeRow(row);
+ if (fieldError) {
+ onAlert(fieldError);
+ return false;
+ }
+ }
+ }
+
+ await onSave(payload);
+ return true;
+ }, [grid, onAlert, onSave]);
+
+ return {
+ displayRows,
+ canSave,
+ updateRow,
+ addRootRow,
+ addChildRow,
+ removeDraft,
+ markDeleted,
+ resetFromServer,
+ save,
+ };
+}
diff --git a/apps/as/src/pages/cnstp-code-management/model/build-cnstp-display-rows.ts b/apps/as/src/pages/cnstp-code-management/model/build-cnstp-display-rows.ts
new file mode 100644
index 000000000..1667b2aa4
--- /dev/null
+++ b/apps/as/src/pages/cnstp-code-management/model/build-cnstp-display-rows.ts
@@ -0,0 +1,77 @@
+import type { CnstpSeRow } from "./cnstp-se-row-types";
+
+export interface CnstpSeDisplayRow {
+ row: CnstpSeRow;
+ depth: number;
+}
+
+export function formatCdLvNo(lvNo?: number): string {
+ return `${lvNo ?? 1}단계`;
+}
+
+export function canAddChildRow(): boolean {
+ return true;
+}
+
+export function formatTreeBranchPrefix(depth: number): string {
+ if (depth <= 0) return "";
+ const vertical = "\u2502 ";
+ const branch = "\u251C\u2500";
+ return vertical.repeat(depth - 1) + branch;
+}
+
+function isRootRow(row: CnstpSeRow): boolean {
+ return (row.cdLvNo ?? 1) === 1 && !row.upCnstpCd?.trim();
+}
+
+function rowOrderIndex(rows: CnstpSeRow[], row: CnstpSeRow): number {
+ return rows.findIndex((candidate) => candidate.rowKey === row.rowKey);
+}
+
+function pushDisplayRow(
+ result: CnstpSeDisplayRow[],
+ displayedRowKeys: Set,
+ row: CnstpSeRow,
+ depth: number,
+) {
+ if (displayedRowKeys.has(row.rowKey)) return;
+ displayedRowKeys.add(row.rowKey);
+ result.push({ row, depth });
+}
+
+export function buildCnstpDisplayRows(rows: CnstpSeRow[]): CnstpSeDisplayRow[] {
+ const result: CnstpSeDisplayRow[] = [];
+ const displayedRowKeys = new Set();
+
+ const roots = rows.filter(isRootRow);
+
+ function appendSubtree(parent: CnstpSeRow, depth: number) {
+ const children = rows
+ .filter(
+ (row) =>
+ row.rowKey !== parent.rowKey &&
+ row.upCnstpCd != null &&
+ row.upCnstpCd === parent.cnstpSeCd &&
+ row.cnstpSeCd !== parent.cnstpSeCd,
+ )
+ .sort((a, b) => rowOrderIndex(rows, a) - rowOrderIndex(rows, b));
+
+ for (const child of children) {
+ pushDisplayRow(result, displayedRowKeys, child, depth);
+ appendSubtree(child, depth + 1);
+ }
+ }
+
+ for (const root of roots) {
+ pushDisplayRow(result, displayedRowKeys, root, 0);
+ appendSubtree(root, 1);
+ }
+
+ const orphans = rows.filter((row) => !displayedRowKeys.has(row.rowKey));
+
+ for (const orphan of orphans) {
+ pushDisplayRow(result, displayedRowKeys, orphan, Math.max(0, (orphan.cdLvNo ?? 1) - 1));
+ }
+
+ return result;
+}
diff --git a/apps/as/src/pages/cnstp-code-management/model/cnstp-se-grid-types.ts b/apps/as/src/pages/cnstp-code-management/model/cnstp-se-grid-types.ts
new file mode 100644
index 000000000..7cff8922a
--- /dev/null
+++ b/apps/as/src/pages/cnstp-code-management/model/cnstp-se-grid-types.ts
@@ -0,0 +1,29 @@
+import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
+
+export interface CnstpDisplaySlot {
+ rowKey: string;
+ depth: number;
+ parentRowKey: string | null;
+}
+
+export interface CnstpSeDraft {
+ rowKey: string;
+ parentRowKey: string | null;
+ cdLvNo: number;
+ cnstpSeCd: string;
+ cnstpSeCdNm: string;
+ expln: string;
+ useYn: string;
+}
+
+export interface CnstpSeBaseEntry extends CnstpSeMngResponseDto {
+ rowKey: string;
+}
+
+export interface CnstpSeGridState {
+ baseByKey: Record;
+ structure: CnstpDisplaySlot[];
+ drafts: Record;
+ patches: Record