-
- setFinalApprovalYn(checked === true)}
- />
-
-
diff --git a/apps/as/src/entities/elapr/ui/elapr-doc-prgrs-status.tsx b/apps/as/src/entities/elapr/ui/elapr-doc-prgrs-status.tsx
new file mode 100644
index 000000000..731e39a07
--- /dev/null
+++ b/apps/as/src/entities/elapr/ui/elapr-doc-prgrs-status.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { Fragment } from "react";
+
+import { ChevronRight } from "lucide-react";
+
+import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
+import { cn, Skeleton } from "@common/components";
+
+import { useElaprDocPrgrsByDocNo } from "../api/queries";
+import { getApproverStatus, sortByAtrzSeqNo } from "../lib/approval-utils";
+
+const REJECT_STATUS_CD = "ATRZ_PRGRS_004";
+
+interface ElaprDocPrgrsStatusProps {
+ elaprDocNo: string | null | undefined;
+}
+
+function ElaprDocPrgrsStep({ item }: { item: ElaprDocPrgrsClientResponseDto }) {
+ const status = getApproverStatus(item.atrzPrgrsSttsComCd);
+ const isRejected = item.atrzPrgrsSttsComCd === REJECT_STATUS_CD;
+ const rjctRsn = item.rjctRsn?.trim();
+ const metaParts = [item.ognzNm, item.jbpsSeNm].filter(Boolean);
+
+ const statusBadge = (
+
+ {status.label}
+
+ );
+
+ return (
+
+ {isRejected && rjctRsn ? (
+
+ {statusBadge}
+
+ {rjctRsn}
+
+
+ ) : (
+ statusBadge
+ )}
+
+
+ [{item.atrzSeComNm || "-"}]
+ {item.aprvrNm || item.aprvrUuid || "-"}
+
+ {metaParts.length > 0 && (
+
{metaParts.join(" · ")}
+ )}
+
+
+ );
+}
+
+function ElaprDocPrgrsStatus({ elaprDocNo }: ElaprDocPrgrsStatusProps) {
+ const { data, isLoading } = useElaprDocPrgrsByDocNo(elaprDocNo);
+ const steps = sortByAtrzSeqNo(data ?? []);
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (steps.length === 0) {
+ return
결재 정보가 없습니다.
;
+ }
+
+ return (
+
+ {steps.map((item, index) => (
+
+
+ {index > 0 && }
+
+
+
+ ))}
+
+ );
+}
+
+export { ElaprDocPrgrsStatus };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-picker-dialog.tsx b/apps/as/src/entities/elapr/ui/elapr-tmplt-picker-dialog.tsx
similarity index 89%
rename from apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-picker-dialog.tsx
rename to apps/as/src/entities/elapr/ui/elapr-tmplt-picker-dialog.tsx
index 3f59f602b..aa70932e9 100644
--- a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-picker-dialog.tsx
+++ b/apps/as/src/entities/elapr/ui/elapr-tmplt-picker-dialog.tsx
@@ -1,163 +1,166 @@
-"use client";
-
-import { useCallback, useState } from "react";
-
-import { useAlertStore } from "@/shared/lib";
-import {
- type ElaprAplnRow,
- type ElaprTmplt,
- getVisibleRows,
- useElaprTmpltPage,
- validateAllRowsForSave,
- validateAtrzOrder,
- validateUniqueSeqNo,
-} from "@/entities/elapr";
-import {
- Button,
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@common/components";
-
-import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
-import { ElaprTmpltSelectTable } from "./elapr-tmplt-select-table";
-
-const PAGE_SIZE = 100;
-
-interface ElaprTmpltPickerConfirmPayload {
- tmplt: ElaprTmplt;
- aplnRows: ElaprAplnRow[];
-}
-
-interface ElaprTmpltPickerDialogProps {
- open: boolean;
- onClose: () => void;
- onSelect?: (tmplt: ElaprTmplt) => void;
- onConfirm?: (payload: ElaprTmpltPickerConfirmPayload) => void;
-}
-
-function ElaprTmpltPickerDialog({
- open,
- onClose,
- onSelect,
- onConfirm,
-}: ElaprTmpltPickerDialogProps) {
- const alert = useAlertStore((state) => state.setMessage);
- const [selectedTmplt, setSelectedTmplt] = useState
(null);
- const [aplnRows, setAplnRows] = useState([]);
- const [isEditingLocked, setIsEditingLocked] = useState(false);
-
- const tmpltQuery = useElaprTmpltPage({
- page: 0,
- size: PAGE_SIZE,
- sort: "regDt,desc",
- docSeComCd: "FNRS",
- });
-
- const handleRowsChange = useCallback((rows: ElaprAplnRow[]) => {
- setAplnRows(rows);
- }, []);
-
- const handleEditingLockedChange = useCallback((locked: boolean) => {
- setIsEditingLocked(locked);
- }, []);
-
- function handleSelectTmplt(tmplt: ElaprTmplt | null) {
- setSelectedTmplt(tmplt);
- setAplnRows([]);
- setIsEditingLocked(false);
- }
-
- function handleOpenChange(isOpen: boolean) {
- if (!isOpen) {
- setSelectedTmplt(null);
- setAplnRows([]);
- setIsEditingLocked(false);
- onClose();
- }
- }
-
- function handleConfirm() {
- if (!selectedTmplt) {
- alert("결재서식을 선택해 주세요.");
- return;
- }
-
- const rowsError = validateAllRowsForSave(aplnRows, isEditingLocked);
- if (rowsError) {
- alert(rowsError);
- return;
- }
-
- const orderError = validateAtrzOrder(aplnRows);
- if (orderError) {
- alert(orderError);
- return;
- }
-
- const seqError = validateUniqueSeqNo(aplnRows);
- if (seqError) {
- alert(seqError);
- return;
- }
-
- const visibleRows = getVisibleRows(aplnRows);
-
- const payload: ElaprTmpltPickerConfirmPayload = {
- tmplt: selectedTmplt,
- aplnRows: visibleRows,
- };
-
- if (onConfirm) {
- onConfirm(payload);
- } else {
- onSelect?.(selectedTmplt);
- }
-
- setSelectedTmplt(null);
- setAplnRows([]);
- setIsEditingLocked(false);
- onClose();
- }
-
- return (
-
- );
-}
-
-export { ElaprTmpltPickerDialog };
-export type { ElaprTmpltPickerConfirmPayload };
+"use client";
+
+import { useCallback, useState } from "react";
+
+import { useAlertStore } from "@/shared/lib";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@common/components";
+
+import { useElaprTmpltPage } from "../api/queries";
+import {
+ getVisibleRows,
+ validateAllRowsForSave,
+ validateAtrzOrder,
+ validateUniqueSeqNo,
+} from "../lib/apln-validators";
+import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
+import type { ElaprTmplt } from "../model/elapr-tmplt-interface";
+
+import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
+import { ElaprTmpltSelectTable } from "./elapr-tmplt-select-table";
+
+const PAGE_SIZE = 100;
+
+interface ElaprTmpltPickerConfirmPayload {
+ tmplt: ElaprTmplt;
+ aplnRows: ElaprAplnRow[];
+}
+
+interface ElaprTmpltPickerDialogProps {
+ open: boolean;
+ onClose: () => void;
+ docSeComCd: string;
+ onSelect?: (tmplt: ElaprTmplt) => void;
+ onConfirm?: (payload: ElaprTmpltPickerConfirmPayload) => void;
+}
+
+function ElaprTmpltPickerDialog({
+ open,
+ onClose,
+ docSeComCd,
+ onSelect,
+ onConfirm,
+}: ElaprTmpltPickerDialogProps) {
+ const alert = useAlertStore((state) => state.setMessage);
+ const [selectedTmplt, setSelectedTmplt] = useState(null);
+ const [aplnRows, setAplnRows] = useState([]);
+ const [isEditingLocked, setIsEditingLocked] = useState(false);
+
+ const tmpltQuery = useElaprTmpltPage({
+ page: 0,
+ size: PAGE_SIZE,
+ sort: "regDt,desc",
+ docSeComCd,
+ });
+
+ const handleRowsChange = useCallback((rows: ElaprAplnRow[]) => {
+ setAplnRows(rows);
+ }, []);
+
+ const handleEditingLockedChange = useCallback((locked: boolean) => {
+ setIsEditingLocked(locked);
+ }, []);
+
+ function handleSelectTmplt(tmplt: ElaprTmplt | null) {
+ setSelectedTmplt(tmplt);
+ setAplnRows([]);
+ setIsEditingLocked(false);
+ }
+
+ function handleOpenChange(isOpen: boolean) {
+ if (!isOpen) {
+ setSelectedTmplt(null);
+ setAplnRows([]);
+ setIsEditingLocked(false);
+ onClose();
+ }
+ }
+
+ function handleConfirm() {
+ if (!selectedTmplt) {
+ alert("결재서식을 선택해 주세요.");
+ return;
+ }
+
+ const rowsError = validateAllRowsForSave(aplnRows, isEditingLocked);
+ if (rowsError) {
+ alert(rowsError);
+ return;
+ }
+
+ const orderError = validateAtrzOrder(aplnRows);
+ if (orderError) {
+ alert(orderError);
+ return;
+ }
+
+ const seqError = validateUniqueSeqNo(aplnRows);
+ if (seqError) {
+ alert(seqError);
+ return;
+ }
+
+ const visibleRows = getVisibleRows(aplnRows);
+
+ const payload: ElaprTmpltPickerConfirmPayload = {
+ tmplt: selectedTmplt,
+ aplnRows: visibleRows,
+ };
+
+ if (onConfirm) {
+ onConfirm(payload);
+ } else {
+ onSelect?.(selectedTmplt);
+ }
+
+ setSelectedTmplt(null);
+ setAplnRows([]);
+ setIsEditingLocked(false);
+ onClose();
+ }
+
+ return (
+
+ );
+}
+
+export { ElaprTmpltPickerDialog };
+export type { ElaprTmpltPickerConfirmPayload };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-select-table.tsx b/apps/as/src/entities/elapr/ui/elapr-tmplt-select-table.tsx
similarity index 85%
rename from apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-select-table.tsx
rename to apps/as/src/entities/elapr/ui/elapr-tmplt-select-table.tsx
index 50bdb33b9..b93ead98d 100644
--- a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-tmplt-select-table.tsx
+++ b/apps/as/src/entities/elapr/ui/elapr-tmplt-select-table.tsx
@@ -7,9 +7,10 @@ import { createColumnHelper, getCoreRowModel, type RowSelectionState } from "@ta
import { useDataTable } from "@/shared/lib";
import type { PageResponse } from "@/shared/model";
-import type { ElaprTmplt } from "@/entities/elapr";
import { Skeleton } from "@common/components";
+import type { ElaprTmplt } from "../model/elapr-tmplt-interface";
+
const columnHelper = createColumnHelper();
interface ElaprTmpltSelectTableProps {
@@ -35,18 +36,10 @@ function ElaprTmpltSelectTable({
const columns = useMemo(
() => [
- columnHelper.accessor("elaprTmpltId", {
- header: "전자결재서식아이디",
- meta: { align: "left" },
- }),
columnHelper.accessor("elaprTmpltNm", {
header: "결재서식명",
meta: { align: "left" },
}),
- columnHelper.accessor("docSeComNm", {
- header: "문서구분",
- cell: ({ getValue, row }) => getValue() ?? row.original.docSeComCd ?? "—",
- }),
],
[],
);
diff --git a/apps/as/src/pages/instl-sncn/ui/components/sortable-apln-picker-row.tsx b/apps/as/src/entities/elapr/ui/sortable-apln-picker-row.tsx
similarity index 72%
rename from apps/as/src/pages/instl-sncn/ui/components/sortable-apln-picker-row.tsx
rename to apps/as/src/entities/elapr/ui/sortable-apln-picker-row.tsx
index 46af147ca..90d8380b7 100644
--- a/apps/as/src/pages/instl-sncn/ui/components/sortable-apln-picker-row.tsx
+++ b/apps/as/src/entities/elapr/ui/sortable-apln-picker-row.tsx
@@ -4,9 +4,19 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { MoveVertical } from "lucide-react";
-import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
-import type { ElaprAplnRow } from "@/entities/elapr";
-import { Button, TableCell, TableRow } from "@common/components";
+import { findCodeLabel, useCodeList } from "@/entities/code";
+import {
+ Button,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ TableCell,
+ TableRow,
+} from "@common/components";
+
+import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
interface SortableAplnPickerRowProps {
row: ElaprAplnRow;
@@ -36,6 +46,7 @@ function SortableAplnPickerRow({
onPickAprvr,
}: SortableAplnPickerRowProps) {
const { data: atrzSeCodes } = useCodeList("ATRZSE");
+ const selectableAtrzSeCodes = atrzSeCodes?.filter((code) => code.lwpoCd !== "ATRZ01");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: row.rowKey,
@@ -66,31 +77,35 @@ function SortableAplnPickerRow({
- {row.isNew ? "-" : (row.aplnId ?? "-")}
{displaySeqNo}
{isEditing ? (
-
- onChange({
- atrzSeComCd: v,
- atrzSeComNm: findCodeLabel(atrzSeCodes, v),
- })
+ onChange({ atrzSeComCd: v, atrzSeComNm: findCodeLabel(selectableAtrzSeCodes, v) })
}
- placeholder="결재구분"
- />
+ >
+
+
+
+
+ {selectableAtrzSeCodes?.map((code) => (
+
+ {code.lwpoCdNm}
+
+ ))}
+
+
) : (
atrzSeLabel || "-"
)}
-
-
{row.aprvrNm || row.aprvrUuid || "-"}
+
+
{row.aprvrNm || row.aprvrUuid || ""}
{isEditing && (
-
+
+ {isDcarbable && !rowDisabled && (
+ onToggleDcarb(checked === true)}
+ aria-label="전결자 지정"
+ />
+ )}
+
{isRejectedRow && rjctRsn ? (
diff --git a/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts b/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts
index 38ce8e69a..2bef72628 100644
--- a/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts
+++ b/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts
@@ -65,6 +65,22 @@ async function techReview(id: string, payload: InstlSncnApplySaveRequestDto): Pr
await client.post(`${BASE}/${id}/tech-review`, payload);
}
+async function submitSupplement(
+ id: string,
+ payload: InstlSncnApplySaveRequestDto,
+): Promise {
+ const response = await client.post(`${BASE}/${id}/submitsupplement`, payload);
+ return response.data;
+}
+
+async function submitReqSupplement(
+ id: string,
+ payload: InstlSncnApplySaveRequestDto,
+): Promise {
+ const response = await client.post(`${BASE}/${id}/reqsupplement`, payload);
+ return response.data;
+}
+
async function remove(id: string): Promise {
await client.delete(`${BASE}/${id}`);
}
@@ -80,6 +96,8 @@ export const instlSncnService = {
saveApply,
update,
techReview,
+ submitSupplement,
+ submitReqSupplement,
remove,
changePrgrsSttsComCd,
} satisfies InstlSncnMngService;
diff --git a/apps/as/src/entities/instl-sncn/api/queries.ts b/apps/as/src/entities/instl-sncn/api/queries.ts
index ac651be47..f9d94dbff 100644
--- a/apps/as/src/entities/instl-sncn/api/queries.ts
+++ b/apps/as/src/entities/instl-sncn/api/queries.ts
@@ -99,6 +99,32 @@ export function useInstlSncnTechReview() {
});
}
+export function useInstlSncnSubmitSupplement() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ id, payload }: { id: string; payload: InstlSncnApplySaveRequestDto }) =>
+ instlSncnService.submitSupplement(id, payload),
+ onSuccess: (_, variables) => {
+ queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
+ queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) });
+ },
+ });
+}
+
+export function useInstlSncnSubmitReqSupplement() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ id, payload }: { id: string; payload: InstlSncnApplySaveRequestDto }) =>
+ instlSncnService.submitReqSupplement(id, payload),
+ onSuccess: (_, variables) => {
+ queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
+ queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) });
+ },
+ });
+}
+
export function useInstlSncnRemove() {
const queryClient = useQueryClient();
diff --git a/apps/as/src/entities/instl-sncn/index.ts b/apps/as/src/entities/instl-sncn/index.ts
index 9547dd6db..946e756f1 100644
--- a/apps/as/src/entities/instl-sncn/index.ts
+++ b/apps/as/src/entities/instl-sncn/index.ts
@@ -5,6 +5,8 @@ export {
useInstlSncnList,
useInstlSncnSidoCombo,
useInstlSncnSigunguCombo,
+ useInstlSncnSubmitReqSupplement,
+ useInstlSncnSubmitSupplement,
useInstlSncnTechReview,
} from "./api/queries";
export {
@@ -28,6 +30,7 @@ export {
type InstlSncnMngSearchParams,
type InstlSncnRvwOpnnResponseDto,
type InstlSncnRvwOpnnSaveRequestDto,
+ type InstlSncnSplmntDmndResponseDto,
type InstlSncnSplmntDmndSaveDto,
type ReviewStepCode,
} from "./model/instl-sncn-interface";
diff --git a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts
index 500cfb842..6ead54c13 100644
--- a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts
+++ b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts
@@ -15,6 +15,7 @@ import type {
PbadmsMngDetResponseBase,
RvwOpnnResponseExtension,
RvwOpnnSaveBase,
+ SplmntDmndResponseExtension,
SplmntDmndSaveBase,
} from "@/shared/model";
@@ -185,20 +186,26 @@ export interface InstlSncnRvwOpnnSaveRequestDto extends RvwOpnnSaveBase {
/** 설치인가 보완요구 저장 DTO(배열 원소) */
export type InstlSncnSplmntDmndSaveDto = SplmntDmndSaveBase;
+/** 설치인가 보완요구 응답 DTO */
+export type InstlSncnSplmntDmndResponseDto = InstlSncnSplmntDmndSaveDto &
+ SplmntDmndResponseExtension & {
+ instlSncnDocNo?: string;
+ };
+
/** 설치인가 신청 - 통합 저장 요청 DTO */
export interface InstlSncnApplySaveRequestDto extends PbadmsApplySaveHeader {
/** 설치인가관리 정보 */
instlSncnMng: InstlSncnMngSaveRequestDto;
/** 사업규모 목록 */
- bzScaleList: InstlSncnBzScaleSaveDto[];
+ bzScaleList?: InstlSncnBzScaleSaveDto[];
/** 총사업비 목록 */
- bizCstList: InstlSncnBizCstSaveRequestDto[];
+ bizCstList?: InstlSncnBizCstSaveRequestDto[];
/** 체크리스트 검토결과 목록 */
- cklstResultList: InstlSncnCklstResultSaveDto[];
+ cklstResultList?: InstlSncnCklstResultSaveDto[];
/** 체크리스트 검토결과 맵 (rvwStpComCd -> 목록) */
cklstResultMap?: Record;
/** 필수첨부파일 목록 */
- esntlAtchFileList: InstlSncnEsntlAtchFileSaveDto[];
+ esntlAtchFileList?: InstlSncnEsntlAtchFileSaveDto[];
/** 검토의견 목록 */
rvwOpnnList?: InstlSncnRvwOpnnSaveRequestDto[];
/** 보완요구 목록 */
@@ -222,8 +229,11 @@ export type InstlSncnRvwOpnnResponseDto = InstlSncnRvwOpnnSaveRequestDto &
};
/** 설치인가관리 상세 응답 DTO */
-export interface InstlSncnMngDetResponse
- extends PbadmsMngDetResponseBase {
+export interface InstlSncnMngDetResponse extends PbadmsMngDetResponseBase<
+ InstlSncnMngResponseDto,
+ InstlSncnRvwOpnnResponseDto,
+ InstlSncnSplmntDmndResponseDto
+> {
detDto: InstlSncnMngResponseDto;
bizSclList: BizSclTreeDto[];
bizCstList: BizCstTreeDto[];
@@ -239,6 +249,8 @@ export interface InstlSncnMngService {
saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise;
update: (id: string, payload: InstlSncnMngUpdateRequestDto) => Promise;
techReview: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise;
+ submitSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise;
+ submitReqSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise;
remove: (id: string) => Promise;
changePrgrsSttsComCd: (payload: DocPrgrsUpdateRequestDto) => Promise;
}
diff --git a/apps/as/src/pages/approval-line-management/model/columns.tsx b/apps/as/src/pages/approval-line-management/model/columns.tsx
index a7beb668b..35dcc4a74 100644
--- a/apps/as/src/pages/approval-line-management/model/columns.tsx
+++ b/apps/as/src/pages/approval-line-management/model/columns.tsx
@@ -14,10 +14,6 @@ interface CreateTmpltColumnsOptions {
export function createTmpltColumns({ onEdit, onDelete }: CreateTmpltColumnsOptions) {
return [
- columnHelper.accessor("elaprTmpltId", {
- header: "전자결재서식아이디",
- meta: { align: "left" },
- }),
columnHelper.accessor("elaprTmpltNm", {
header: "결재서식명",
meta: { align: "left" },
diff --git a/apps/as/src/pages/approval-line-management/ui/approval-line-management-page.tsx b/apps/as/src/pages/approval-line-management/ui/approval-line-management-page.tsx
index 11b9373ac..38b018809 100644
--- a/apps/as/src/pages/approval-line-management/ui/approval-line-management-page.tsx
+++ b/apps/as/src/pages/approval-line-management/ui/approval-line-management-page.tsx
@@ -11,7 +11,7 @@ function ApprovalLineManagementPage() {
const [selectedTmplt, setSelectedTmplt] = useState(null);
return (
-
+
-
-
+
-
+
+
- 결재선아이디
결재순서
결재구분
+ 결재자 ID
결재자
등록일시
관리
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 94d28be22..b0a23ee1e 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
@@ -4,9 +4,18 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Equal } from "lucide-react";
-import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
+import { findCodeLabel, useCodeList } from "@/entities/code";
import type { ElaprAplnRow } from "@/entities/elapr";
-import { Button, TableCell, TableRow } from "@common/components";
+import {
+ Button,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ TableCell,
+ TableRow,
+} from "@common/components";
interface SortableAplnRowProps {
row: ElaprAplnRow;
@@ -36,6 +45,7 @@ function SortableAplnRow({
onPickAprvr,
}: SortableAplnRowProps) {
const { data: atrzSeCodes } = useCodeList("ATRZSE");
+ const selectableAtrzSeCodes = atrzSeCodes?.filter((code) => code.lwpoCd !== "ATRZ01");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: row.rowKey,
@@ -49,7 +59,7 @@ function SortableAplnRow({
};
const rowDisabled = isLocked && !isEditing;
- const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd);
+ const atrzSeLabel = row.atrzSeComNm || findCodeLabel(selectableAtrzSeCodes, row.atrzSeComCd);
return (
@@ -66,31 +76,36 @@ function SortableAplnRow({
- {row.isNew ? "-" : (row.aplnId ?? "-")}
{displaySeqNo}
{isEditing ? (
-
- onChange({
- atrzSeComCd: v,
- atrzSeComNm: findCodeLabel(atrzSeCodes, v),
- })
+ onChange({ atrzSeComCd: v, atrzSeComNm: findCodeLabel(selectableAtrzSeCodes, v) })
}
- placeholder="결재구분"
- />
+ >
+
+
+
+
+ {selectableAtrzSeCodes?.map((code) => (
+
+ {code.lwpoCdNm}
+
+ ))}
+
+
) : (
atrzSeLabel || "-"
)}
+ {row.aprvrUserId ?? "-"}
-
-
{row.aprvrNm || row.aprvrUuid || "-"}
+
+
{row.aprvrNm || row.aprvrUuid || ""}
{isEditing && (
-
+
선택
)}
diff --git a/apps/as/src/pages/design-cnstn/index.ts b/apps/as/src/pages/design-cnstn/index.ts
new file mode 100644
index 000000000..fcaaab3ed
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/index.ts
@@ -0,0 +1,5 @@
+export { DesignEditPage } from "./ui/design-edit-page";
+export { DesignFormPage } from "./ui/design-form-page";
+export { DesignListPage } from "./ui/design-list-page";
+export { DesignRegistPage } from "./ui/design-regist-page";
+export { DesignViewPage } from "./ui/design-view-page";
diff --git a/apps/as/src/pages/design-cnstn/lib/button-config.ts b/apps/as/src/pages/design-cnstn/lib/button-config.ts
new file mode 100644
index 000000000..2727092f2
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/lib/button-config.ts
@@ -0,0 +1,118 @@
+export const DESIGN_CNSTN_VIEW_BUTTON_ACTION = {
+ strg: "strg",
+ aply: "aply",
+ del: "del",
+ rqstRvw: "rqstRvw",
+ rvwStrg: "rvwStrg",
+ rvwCmptn: "rvwCmptn",
+ atrzAprv: "atrzAprv",
+ atrzRply: "atrzRply",
+} as const;
+
+export type DesignCnstnViewButtonAction =
+ (typeof DESIGN_CNSTN_VIEW_BUTTON_ACTION)[keyof typeof DESIGN_CNSTN_VIEW_BUTTON_ACTION];
+
+const ROLE_STATUS_BUTTON_ACTIONS: Record> = {
+ ROLE_USER05: {
+ "": [
+ DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg,
+ DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply,
+ DESIGN_CNSTN_VIEW_BUTTON_ACTION.del,
+ ],
+ CV: [
+ DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg,
+ DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply,
+ DESIGN_CNSTN_VIEW_BUTTON_ACTION.del,
+ ],
+ },
+};
+
+const ROLE_USER01_ATRZ_STATUS_CODES = ["CT", "CS"] as const;
+
+const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
+ string,
+ Record
+> = {
+ CT: {
+ ATRZ01: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.rqstRvw],
+ },
+ CS: {
+ ATRZ02: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwStrg, DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwCmptn],
+ ATRZ03: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzAprv],
+ ATRZ99: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzRply],
+ },
+};
+
+function resolveButtonConfigs(
+ actions: DesignCnstnViewButtonAction[],
+): DesignCnstnViewButtonAction[] {
+ return actions.map((action) => DESIGN_CNSTN_VIEW_BUTTON_ACTION[action]);
+}
+
+interface GetButtonConfigsOptions {
+ hasPendingApprovalRequest?: boolean;
+}
+
+function resolveRoleUser01AtrzButtonActions(
+ prgrsSttsComCd: string,
+ atrzSeComCdList: string[],
+ options?: GetButtonConfigsOptions,
+): DesignCnstnViewButtonAction[] {
+ if (prgrsSttsComCd === "CS" && options?.hasPendingApprovalRequest === false) {
+ return [];
+ }
+
+ const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd];
+ if (!atrzStatusMap || atrzSeComCdList.length === 0) return [];
+
+ const seen = new Set();
+ const actions: DesignCnstnViewButtonAction[] = [];
+
+ for (const atrzSeComCd of atrzSeComCdList) {
+ const mappedActions = atrzStatusMap[atrzSeComCd];
+ if (!mappedActions) continue;
+
+ for (const action of mappedActions) {
+ if (seen.has(action)) continue;
+ seen.add(action);
+ actions.push(action);
+ }
+ }
+
+ return actions;
+}
+
+export function needsRoleUser01AtrzResolve(
+ dtlInstCd: string | undefined,
+ prgrsSttsComCd: string,
+): boolean {
+ return (
+ dtlInstCd === "ROLE_USER01" &&
+ ROLE_USER01_ATRZ_STATUS_CODES.includes(
+ prgrsSttsComCd as (typeof ROLE_USER01_ATRZ_STATUS_CODES)[number],
+ )
+ );
+}
+
+export function getButtonConfigs(
+ dtlInstCd: string | undefined,
+ prgrsSttsComCd: string,
+ atrzSeComCdList?: string[],
+ options?: GetButtonConfigsOptions,
+): DesignCnstnViewButtonAction[] {
+ if (!dtlInstCd) return [];
+
+ if (needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd)) {
+ return resolveButtonConfigs(
+ resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? [], options),
+ );
+ }
+
+ const statusMap = ROLE_STATUS_BUTTON_ACTIONS[dtlInstCd];
+ if (!statusMap) return [];
+
+ const actions = statusMap[prgrsSttsComCd];
+ if (!actions) return [];
+
+ return resolveButtonConfigs(actions);
+}
diff --git a/apps/as/src/pages/design-cnstn/lib/design-cnstn-regist-schema.ts b/apps/as/src/pages/design-cnstn/lib/design-cnstn-regist-schema.ts
new file mode 100644
index 000000000..3eecde578
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/lib/design-cnstn-regist-schema.ts
@@ -0,0 +1,68 @@
+import { z } from "zod";
+
+export const designCnstnRegistSchema = z.object({
+ designCnstnMng: z.object({
+ bizNm: z.string().min(1, "사업명은 필수입니다"),
+ bizBgngYr: z.string().min(1, "사업시작연도는 필수입니다"),
+ bizEndYr: z.string().min(1, "사업종료연도는 필수입니다"),
+ bizCn: z.string().min(1, "사업내용은 필수입니다"),
+ taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"),
+ dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
+ dsgnrTelno: z
+ .string()
+ .min(1, "설계자 전화번호는 필수입니다")
+ .regex(/^[0-9-]+$/, "숫자와 하이픈(-)만 입력 가능합니다"),
+ dsgnrOgdpNm: z.string().min(1, "설계자 소속은 필수입니다"),
+ dsgnrEmlAddr: z
+ .string()
+ .min(1, "설계자 이메일은 필수입니다")
+ .email("올바른 이메일 형식이 아닙니다"),
+ aplcntNm: z.string().optional(),
+ aplcntMblTelno: z.string().optional(),
+ aplcntOgdpNm: z.string().optional(),
+ aplcntEmlAddr: z.string().optional(),
+ designCnstnDocNo: z.string().optional(),
+ admdstCd: z.string().optional(),
+ envoSeComCd: z.string().optional(),
+ prgrsSttsComCd: z.string().optional(),
+ atchFileGroupId: z.string().optional(),
+ envoSeCdNm: z.string().optional(),
+ taskSeCdNm: z.string().optional(),
+ prgrsSttsCdNm: z.string().optional(),
+ sido: z.string().optional(),
+ gugun: z.string().optional(),
+ regDt: z.string().optional(),
+ rgtr: z.string().optional(),
+ rgtrNm: z.string().optional(),
+ mdfcnDt: z.string().optional(),
+ }),
+ bzScaleList: z.array(
+ z.object({
+ cnstpSeCd: z.string(),
+ bscPl: z.number(),
+ budgetReflct: z.number(),
+ thtiDsgn: z.number(),
+ diff: z.number().optional(),
+ }),
+ ),
+ esntlAtchFileList: z.array(
+ z.object({
+ esntlAtchFileId: z.string(),
+ atchFileGroupId: z.string(),
+ }),
+ ),
+ privacyConsent: z.enum(["Y", "N"], {
+ required_error: "개인정보 수집/이용 동의는 필수입니다",
+ }),
+});
+
+export type DesignCnstnRegistFormData = z.infer;
+
+export function formatTaskSeComCdForApi(taskSeComCd: string[]): string {
+ return taskSeComCd.join(",");
+}
+
+export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
+ if (!taskSeComCd) return [];
+ return taskSeComCd.split(",").filter(Boolean);
+}
diff --git a/apps/as/src/pages/design-cnstn/lib/form-data-mapper.ts b/apps/as/src/pages/design-cnstn/lib/form-data-mapper.ts
new file mode 100644
index 000000000..a34888317
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/lib/form-data-mapper.ts
@@ -0,0 +1,97 @@
+import type { IUserInfo } from "@/shared/store";
+import type { DesignCnstnMngDetResponseDto } from "@/entities/design-cnstn";
+
+import type { DesignCnstnRegistFormData } from "./design-cnstn-regist-schema";
+import { parseTaskSeComCdFromApi } from "./design-cnstn-regist-schema";
+
+function mapEsntlAtchFileList(esntlFileList: DesignCnstnMngDetResponseDto["esntlFileList"]) {
+ const esntlMap = new Map();
+
+ for (const item of esntlFileList ?? []) {
+ if (!item.esntlAtchFileId || !item.atflCd) continue;
+ esntlMap.set(item.esntlAtchFileId, item.atflCd);
+ }
+
+ return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
+ esntlAtchFileId,
+ atchFileGroupId,
+ }));
+}
+
+export function mapDetailToFormData(data: DesignCnstnMngDetResponseDto): DesignCnstnRegistFormData {
+ const detDto = data.detDto;
+ if (!detDto) {
+ throw new Error("상세 정보(detDto)가 없습니다.");
+ }
+
+ const bizSclList = data.bizSclList ?? [];
+
+ return {
+ designCnstnMng: {
+ designCnstnDocNo: detDto.designCnstnDocNo,
+ bizNm: detDto.bizNm ?? "",
+ bizBgngYr: detDto.bizBgngYr ?? "",
+ bizEndYr: detDto.bizEndYr ?? "",
+ bizCn: detDto.bizCn ?? "",
+ taskSeComCd: parseTaskSeComCdFromApi(detDto.taskSeComCd),
+ dsgnrNm: detDto.dsgnrNm ?? "",
+ dsgnrTelno: detDto.dsgnrTelno ?? "",
+ dsgnrOgdpNm: detDto.dsgnrOgdpNm ?? "",
+ dsgnrEmlAddr: detDto.dsgnrEmlAddr ?? "",
+ aplcntNm: detDto.aplcntNm ?? "",
+ aplcntMblTelno: detDto.aplcntMblTelno ?? "",
+ aplcntOgdpNm: detDto.aplcntOgdpNm ?? "",
+ aplcntEmlAddr: detDto.aplcntEmlAddr ?? "",
+ admdstCd: detDto.admdstCd,
+ envoSeComCd: detDto.envoSeComCd,
+ prgrsSttsComCd: detDto.prgrsSttsComCd,
+ atchFileGroupId: detDto.atchFileGroupId ?? "",
+ envoSeCdNm: detDto.envoSeCdNm,
+ taskSeCdNm: detDto.taskSeCdNm,
+ prgrsSttsCdNm: detDto.prgrsSttsCdNm,
+ sido: detDto.sido,
+ gugun: detDto.gugun,
+ regDt: detDto.regDt,
+ rgtr: detDto.rgtr,
+ rgtrNm: detDto.rgtrNm,
+ mdfcnDt: detDto.mdfcnDt,
+ },
+ bzScaleList: bizSclList.map((item) => ({
+ cnstpSeCd: item.cnstpSeCd,
+ bscPl: item.bscPlanQty,
+ budgetReflct: item.bgtRfltQty,
+ thtiDsgn: item.tstmDesignQty,
+ diff: item.tstmDesignQty - item.bgtRfltQty,
+ })),
+ esntlAtchFileList: mapEsntlAtchFileList(data.esntlFileList),
+ privacyConsent: "Y",
+ };
+}
+
+export function getCreateDefaultValues(
+ userInfo: IUserInfo | null,
+ taskSeComCd?: string[],
+): DesignCnstnRegistFormData {
+ return {
+ designCnstnMng: {
+ bizNm: "",
+ bizBgngYr: "",
+ bizEndYr: "",
+ bizCn: "",
+ taskSeComCd: taskSeComCd ?? [],
+ dsgnrNm: "",
+ dsgnrTelno: "",
+ dsgnrOgdpNm: "",
+ dsgnrEmlAddr: "",
+ aplcntNm: userInfo?.userName ?? "",
+ aplcntMblTelno: userInfo?.mphn ?? "",
+ aplcntOgdpNm: userInfo?.ogdpSeNm ?? "",
+ aplcntEmlAddr: userInfo?.email ?? "",
+ prgrsSttsComCd: "CV",
+ atchFileGroupId: "",
+ },
+ bzScaleList: [],
+ esntlAtchFileList: [],
+ privacyConsent: "N",
+ };
+}
diff --git a/apps/as/src/pages/design-cnstn/lib/review-opinion-utils.ts b/apps/as/src/pages/design-cnstn/lib/review-opinion-utils.ts
new file mode 100644
index 000000000..fd7da632b
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/lib/review-opinion-utils.ts
@@ -0,0 +1,94 @@
+import type {
+ DesignCnstnMngDetResponseDto,
+ DesignCnstnRvwOpnnResponseDto,
+ DesignCnstnRvwOpnnSaveRequestDto,
+} from "@/entities/design-cnstn";
+
+export interface ReviewOpinionFormData {
+ rvwRsltCn: string;
+ rvwRsltAnmlyYn: "Y" | "N" | "";
+ atchFileGroupId: string;
+ opnrSeComCd: string;
+ rvwOpnnSn?: number;
+}
+
+export interface ReviewOpinionGetFormDataResult {
+ formData: ReviewOpinionFormData;
+ rvwList: DesignCnstnRvwOpnnResponseDto[];
+}
+
+function hasReviewOpinionContent(formData: ReviewOpinionFormData) {
+ return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
+}
+
+function mapServerItemToSaveDto(
+ item: DesignCnstnRvwOpnnResponseDto,
+ designCnstnDocNo: string,
+): DesignCnstnRvwOpnnSaveRequestDto | null {
+ if (item.rvwOpnnSn == null) return null;
+
+ return {
+ designCnstnDocNo,
+ rvwOpnnSn: item.rvwOpnnSn,
+ opnrSeComCd: item.opnrSeComCd,
+ opnrNm: item.opnrNm,
+ rvwRsltCn: item.rvwRsltCn,
+ rvwRsltAnmlyYn: item.rvwRsltAnmlyYn || undefined,
+ atchFileGroupId: item.atchFileGroupId || undefined,
+ };
+}
+
+function mapFormDataToSaveDto(
+ formData: ReviewOpinionFormData,
+ designCnstnDocNo: string,
+ opnrNm?: string,
+): DesignCnstnRvwOpnnSaveRequestDto {
+ const base = {
+ designCnstnDocNo,
+ opnrSeComCd: formData.opnrSeComCd,
+ opnrNm: opnrNm ?? "",
+ rvwRsltCn: formData.rvwRsltCn,
+ rvwRsltAnmlyYn: formData.rvwRsltAnmlyYn || undefined,
+ atchFileGroupId: formData.atchFileGroupId || undefined,
+ };
+
+ if (formData.rvwOpnnSn != null) {
+ return { ...base, rvwOpnnSn: formData.rvwOpnnSn };
+ }
+
+ return base as DesignCnstnRvwOpnnSaveRequestDto;
+}
+
+export function toRvwOpnnSaveDto(
+ data: DesignCnstnMngDetResponseDto,
+ result: ReviewOpinionGetFormDataResult | null | undefined,
+ opnrNm?: string,
+): DesignCnstnRvwOpnnSaveRequestDto[] | undefined {
+ if (!result || !data.detDto) return undefined;
+
+ const { formData, rvwList } = result;
+ const designCnstnDocNo = data.detDto.designCnstnDocNo;
+
+ const list: DesignCnstnRvwOpnnSaveRequestDto[] = rvwList
+ .map((item) => mapServerItemToSaveDto(item, designCnstnDocNo))
+ .filter((item): item is DesignCnstnRvwOpnnSaveRequestDto => item != null);
+
+ if (!hasReviewOpinionContent(formData)) {
+ return list.length > 0 ? list : undefined;
+ }
+
+ const formDto = mapFormDataToSaveDto(formData, designCnstnDocNo, opnrNm);
+
+ if (formData.rvwOpnnSn != null) {
+ const existingIndex = list.findIndex((item) => item.rvwOpnnSn === formData.rvwOpnnSn);
+ if (existingIndex >= 0) {
+ list[existingIndex] = formDto;
+ } else {
+ list.push(formDto);
+ }
+ } else {
+ list.push(formDto);
+ }
+
+ return list.length > 0 ? list : undefined;
+}
diff --git a/apps/as/src/pages/design-cnstn/lib/review-step-utils.ts b/apps/as/src/pages/design-cnstn/lib/review-step-utils.ts
new file mode 100644
index 000000000..f96b9b4ef
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/lib/review-step-utils.ts
@@ -0,0 +1,12 @@
+import type { ReviewStepCode } from "../model/view-types";
+
+export function getEditableReviewStep(
+ dtlInstCd?: string,
+ prgrsSttsComCd?: string,
+): ReviewStepCode | undefined {
+ if (dtlInstCd === "ROLE_USER01" && prgrsSttsComCd === "CS") {
+ return "KECO";
+ }
+
+ return undefined;
+}
diff --git a/apps/as/src/pages/design-cnstn/lib/save-payload-utils.ts b/apps/as/src/pages/design-cnstn/lib/save-payload-utils.ts
new file mode 100644
index 000000000..48fff8b26
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/lib/save-payload-utils.ts
@@ -0,0 +1,71 @@
+import type {
+ BizSclTreeDto,
+ BzScaleItem,
+ DesignCnstnApplySaveRequestDto,
+ DesignCnstnMngDetResponseDto,
+ EsntlAtchFileItem,
+ EsntlAtchFileMngTreeResponseDto,
+} from "@/entities/design-cnstn";
+
+import type { ReviewStepCode } from "../model/view-types";
+
+import { type ReviewOpinionGetFormDataResult, toRvwOpnnSaveDto } from "./review-opinion-utils";
+
+export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): BzScaleItem[] {
+ return bizSclList
+ .filter((node) => node.useYn === "Y")
+ .map((node) => ({
+ cnstpSeCd: node.cnstpSeCd,
+ bscPl: node.bscPlanQty,
+ budgetReflct: node.bgtRfltQty,
+ thtiDsgn: node.tstmDesignQty,
+ }));
+}
+
+export function toEsntlAtchFileSaveDto(
+ esntlFileList: EsntlAtchFileMngTreeResponseDto[],
+): EsntlAtchFileItem[] {
+ const esntlMap = new Map();
+
+ for (const item of esntlFileList) {
+ if (!item.esntlAtchFileId || !item.atflCd) continue;
+ esntlMap.set(item.esntlAtchFileId, item.atflCd);
+ }
+
+ return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
+ esntlAtchFileId,
+ atchFileGroupId,
+ }));
+}
+
+export function buildSavePayload(
+ data: DesignCnstnMngDetResponseDto,
+ prgrsSttsComCd: string,
+): DesignCnstnApplySaveRequestDto {
+ const detDto = data.detDto;
+ if (!detDto) {
+ throw new Error("상세 정보(detDto)가 없습니다.");
+ }
+
+ return {
+ designCnstnMng: { ...detDto, prgrsSttsComCd },
+ bzScaleList: toBzScaleSaveDto(data.bizSclList ?? []),
+ esntlAtchFileList: toEsntlAtchFileSaveDto(data.esntlFileList ?? []),
+ };
+}
+
+export function buildTechReviewPayload(
+ data: DesignCnstnMngDetResponseDto,
+ prgrsSttsComCd: string,
+ techReViewSaveCd: string,
+ reviewStep: ReviewStepCode,
+ reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null,
+ opnrNm?: string,
+): DesignCnstnApplySaveRequestDto {
+ return {
+ ...buildSavePayload(data, prgrsSttsComCd),
+ techReViewSaveCd,
+ rvwStpCd: reviewStep,
+ rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
+ };
+}
diff --git a/apps/as/src/pages/design-cnstn/model/columns.tsx b/apps/as/src/pages/design-cnstn/model/columns.tsx
new file mode 100644
index 000000000..6cfdb7f70
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/model/columns.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import { Fragment } from "react";
+
+import { createColumnHelper } from "@tanstack/react-table";
+
+import { dayjs } from "@/shared/lib";
+import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
+import { Badge } from "@common/components";
+
+const columnHelper = createColumnHelper();
+
+const MAIN_STEP_LABELS = {
+ save: "저장",
+ apply: "신청완료",
+ review: "설계자문 검토",
+ reply: "설계검토 회신",
+} as const;
+
+const REVIEW_STATUS_CODES = new Set(["CS", "CR"]);
+
+type BadgeState = "completed" | "current" | "pending" | "action";
+
+interface ProgressBadge {
+ label: string;
+ state: BadgeState;
+}
+
+function buildProgressBadges(prgrsSttsComCd: string, prgrsSttsCdNm: string): ProgressBadge[] {
+ const reviewLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.review;
+ const replyLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.reply;
+
+ if (prgrsSttsComCd === "CV") {
+ return [
+ { label: MAIN_STEP_LABELS.save, state: "current" },
+ { label: MAIN_STEP_LABELS.apply, state: "pending" },
+ { label: MAIN_STEP_LABELS.review, state: "pending" },
+ { label: MAIN_STEP_LABELS.reply, state: "pending" },
+ ];
+ }
+
+ if (prgrsSttsComCd === "CT") {
+ return [
+ { label: MAIN_STEP_LABELS.save, state: "completed" },
+ { label: MAIN_STEP_LABELS.apply, state: "current" },
+ { label: MAIN_STEP_LABELS.review, state: "pending" },
+ { label: MAIN_STEP_LABELS.reply, state: "pending" },
+ ];
+ }
+
+ if (REVIEW_STATUS_CODES.has(prgrsSttsComCd)) {
+ return [
+ { label: MAIN_STEP_LABELS.save, state: "completed" },
+ { label: MAIN_STEP_LABELS.apply, state: "completed" },
+ { label: reviewLabel, state: "action" },
+ { label: MAIN_STEP_LABELS.reply, state: "pending" },
+ ];
+ }
+
+ if (prgrsSttsComCd === "CP" || prgrsSttsComCd === "EP") {
+ return [
+ { label: MAIN_STEP_LABELS.save, state: "completed" },
+ { label: MAIN_STEP_LABELS.apply, state: "completed" },
+ { label: MAIN_STEP_LABELS.review, state: "completed" },
+ { label: replyLabel, state: "action" },
+ ];
+ }
+
+ return [{ label: prgrsSttsCdNm || prgrsSttsComCd || "-", state: "current" }];
+}
+
+function getBadgeColor(state: BadgeState) {
+ switch (state) {
+ case "completed":
+ return "green";
+ case "current":
+ return "blue";
+ case "pending":
+ return "lightGray";
+ case "action":
+ return "orange";
+ }
+}
+
+function formatOptional(value: string | undefined) {
+ return value?.trim() ? value : "-";
+}
+
+interface ProgressStepBadgeProps {
+ prgrsSttsComCd: string;
+ prgrsSttsCdNm: string;
+}
+
+function ProgressStepBadge({ prgrsSttsComCd, prgrsSttsCdNm }: ProgressStepBadgeProps) {
+ const badges = buildProgressBadges(prgrsSttsComCd, prgrsSttsCdNm);
+
+ return (
+
+ {badges.map((badge, index) => (
+
+ {index > 0 && >}
+ {badge.label}
+
+ ))}
+
+ );
+}
+
+export function createDesignCnstnColumns() {
+ return [
+ columnHelper.accessor("sido", {
+ header: "시도",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("gugun", {
+ header: "시군구",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("bizNm", {
+ header: "사업명",
+ meta: { align: "left" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("taskSeCdNm", {
+ header: "업무구분",
+ meta: { align: "center" },
+ cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
+ }),
+ columnHelper.accessor("aplcntNm", {
+ header: "담당자",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("envoSeCdNm", {
+ header: "해당기관",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.display({
+ id: "progress",
+ header: "진행상황",
+ meta: { align: "center" },
+ cell: ({ row }) => (
+
+ ),
+ }),
+ columnHelper.accessor("regDt", {
+ header: "신청일",
+ meta: { align: "center" },
+ cell: ({ getValue }) => {
+ const value = getValue();
+ return value ? dayjs(value).format("YYYY-MM-DD") : "-";
+ },
+ }),
+ ];
+}
diff --git a/apps/as/src/pages/design-cnstn/model/view-types.ts b/apps/as/src/pages/design-cnstn/model/view-types.ts
new file mode 100644
index 000000000..0f9b93431
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/model/view-types.ts
@@ -0,0 +1,15 @@
+export type ReviewStepCode = "LOCAL" | "ENV" | "KECO";
+
+export const REVIEW_STEP_LABELS: Record = {
+ LOCAL: "지자체",
+ ENV: "환경청",
+ KECO: "환경공단",
+};
+
+export const DESIGN_PROGRESS_ORDER = ["CV", "CT", "CS", "CR", "CP", "EP"] as const;
+
+export const ROLE_TO_REVIEW_STEP: Record = {
+ ROLE_USER05: "LOCAL",
+ ROLE_USER03: "ENV",
+ ROLE_USER01: "KECO",
+};
diff --git a/apps/as/src/pages/design-cnstn/ui/components/floating-action-buttons.tsx b/apps/as/src/pages/design-cnstn/ui/components/floating-action-buttons.tsx
new file mode 100644
index 000000000..9159fcdae
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/ui/components/floating-action-buttons.tsx
@@ -0,0 +1,391 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+
+import { useConfirm } from "@frontend-opensource/use-react-hooks";
+import { useQueryClient } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+import { ENDPOINT } from "@/shared/config";
+import { useAlertStore, useUserStore } from "@/shared/lib";
+import {
+ type DesignCnstnApplySaveRequestDto,
+ designCnstnMngBaseKey,
+ type DesignCnstnMngDetResponseDto,
+ designCnstnMngService,
+} from "@/entities/design-cnstn";
+import {
+ buildElaprPrgrsSttsListFromAplnRows,
+ ElaprApprovalDialog,
+ type ElaprDmndSaveRequestDto,
+ elaprDmndService,
+ type ElaprTmpltPickerConfirmPayload,
+ ElaprTmpltPickerDialog,
+ hasCurrentUserPendingApprovalRequest,
+ useElaprDocPrgrsByDocNo,
+} from "@/entities/elapr";
+import { Button } from "@common/components";
+
+import {
+ DESIGN_CNSTN_VIEW_BUTTON_ACTION,
+ type DesignCnstnViewButtonAction,
+ getButtonConfigs,
+ needsRoleUser01AtrzResolve,
+} from "../../lib/button-config";
+import { buildSavePayload, buildTechReviewPayload } from "../../lib/save-payload-utils";
+import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
+
+interface FloatingActionButtonsProps {
+ data: DesignCnstnMngDetResponseDto;
+ docSeComCd: string;
+ designCnstnDocNo: string;
+ prgrsSttsComCd: string;
+ dtlInstCd: string | undefined;
+ atrzSeComCdList: string[];
+ isRvwLoginUserLoading: boolean;
+ techReviewOpinionRef?: React.RefObject;
+ opnrNm?: string;
+}
+
+type ActiveDialog = "elaprTmplt" | "elaprApproval" | null;
+
+function FloatingActionButtons({
+ data,
+ docSeComCd,
+ designCnstnDocNo,
+ prgrsSttsComCd,
+ dtlInstCd,
+ atrzSeComCdList,
+ isRvwLoginUserLoading,
+ techReviewOpinionRef,
+ opnrNm,
+}: FloatingActionButtonsProps) {
+ const router = useRouter();
+ const { confirm } = useConfirm();
+ const queryClient = useQueryClient();
+ const userInfo = useUserStore((state) => state.userInfo);
+ const alert = useAlertStore((state) => state.setMessage);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [activeDialog, setActiveDialog] = useState(null);
+
+ const docNo = data.detDto?.designCnstnDocNo ?? designCnstnDocNo;
+ const isRegister = data.detDto?.rgtr === userInfo?.userUnqId;
+
+ const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
+ const needsCsApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "CS";
+ const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
+ useElaprDocPrgrsByDocNo(needsCsApprovalCheck ? docNo : null);
+
+ const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
+ elaprDocPrgrsList,
+ userInfo?.userUnqId,
+ );
+
+ const actionButtons = useMemo(() => {
+ if (needsAtrzResolve && isRvwLoginUserLoading) return [];
+ if (needsCsApprovalCheck && isElaprDocPrgrsLoading) return [];
+ return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList, {
+ hasPendingApprovalRequest: needsCsApprovalCheck ? hasPendingApprovalRequest : undefined,
+ });
+ }, [
+ atrzSeComCdList,
+ dtlInstCd,
+ hasPendingApprovalRequest,
+ isElaprDocPrgrsLoading,
+ isRvwLoginUserLoading,
+ needsAtrzResolve,
+ needsCsApprovalCheck,
+ prgrsSttsComCd,
+ ]);
+
+ const canEdit =
+ isRegister && actionButtons.some((button) => button === DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg);
+
+ function handleGoToList() {
+ router.push("/design-cnstn");
+ }
+
+ function handleEdit() {
+ router.push(`/design-cnstn/${designCnstnDocNo}/edit`);
+ }
+
+ function shouldShowButton(action: DesignCnstnViewButtonAction) {
+ return actionButtons.some((button) => button === action);
+ }
+
+ async function executeAction({
+ action,
+ request,
+ successMessage,
+ }: {
+ action: DesignCnstnViewButtonAction;
+ request?: unknown;
+ successMessage?: string;
+ }) {
+ const handler = buttonActionHandlers[action];
+ if (!handler) return;
+
+ setIsProcessing(true);
+ try {
+ await handler({
+ request,
+ data,
+ onSuccess: () => {
+ if (successMessage) alert(successMessage);
+ queryClient.invalidateQueries({
+ queryKey: [...designCnstnMngBaseKey, "findById", designCnstnDocNo],
+ });
+ },
+ onError: (error) => {
+ let errorMessage = "";
+ if (error instanceof AxiosError) {
+ errorMessage = error.response?.data?.message ?? "";
+ } else {
+ errorMessage = error.message ?? "처리 중 오류가 발생했습니다.";
+ }
+ alert(errorMessage);
+ },
+ });
+ } finally {
+ setIsProcessing(false);
+ }
+ }
+
+ async function handleStrgClick() {
+ const payload: DesignCnstnApplySaveRequestDto = buildSavePayload(data, "CV");
+ await executeAction({
+ action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg,
+ request: payload,
+ successMessage: "저장되었습니다.",
+ });
+ }
+
+ async function handleAplyClick() {
+ const payload: DesignCnstnApplySaveRequestDto = buildSavePayload(data, "CT");
+ await executeAction({
+ action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply,
+ request: payload,
+ successMessage: "신청 완료되었습니다.",
+ });
+ queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
+ }
+
+ async function handleDelClick() {
+ if (!(await confirm("삭제하시겠습니까?"))) return;
+ await executeAction({
+ action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.del,
+ successMessage: "삭제되었습니다.",
+ });
+ router.push("/design-cnstn");
+ }
+
+ function getReviewOpinionFormData() {
+ return techReviewOpinionRef?.current?.getFormData() ?? null;
+ }
+
+ async function handleElaprTmpltConfirm({ aplnRows }: ElaprTmpltPickerConfirmPayload) {
+ const payload: ElaprDmndSaveRequestDto = {
+ elaprDocNo: docNo,
+ dmndTtl: data.detDto?.bizNm ?? "",
+ dmndCn: data.detDto?.bizCn ?? "",
+ elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
+ };
+ await executeAction({
+ action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.rqstRvw,
+ request: payload,
+ successMessage: "검토자지정이 완료되었습니다.",
+ });
+ await queryClient.invalidateQueries({
+ queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", docNo],
+ });
+ }
+
+ async function handleRvwStrgClick() {
+ const payload = buildTechReviewPayload(
+ data,
+ "CS",
+ "SV",
+ "KECO",
+ getReviewOpinionFormData(),
+ opnrNm,
+ );
+
+ await executeAction({
+ action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwStrg,
+ request: payload,
+ successMessage: "저장되었습니다.",
+ });
+ techReviewOpinionRef?.current?.resetForm();
+ }
+
+ async function handleRvwCmptnClick() {
+ const payload = buildTechReviewPayload(
+ data,
+ "CS",
+ "CP",
+ "KECO",
+ getReviewOpinionFormData(),
+ opnrNm,
+ );
+
+ await executeAction({
+ action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwCmptn,
+ request: payload,
+ });
+ techReviewOpinionRef?.current?.resetForm();
+ setActiveDialog("elaprApproval");
+ }
+
+ return (
+ <>
+
+
+
+ 목록
+
+ {canEdit && (
+
+ 수정
+
+ )}
+ {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg) && (
+
+ 저장
+
+ )}
+ {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply) && (
+
+ 신청완료
+
+ )}
+ {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.del) && (
+
+ 삭제
+
+ )}
+ {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.rqstRvw) && (
+ setActiveDialog("elaprTmplt")} disabled={isProcessing}>
+ 검토자지정
+
+ )}
+ {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwStrg) && (
+
+ 저장
+
+ )}
+ {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwCmptn) && (
+
+ 검토완료
+
+ )}
+ {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzAprv) && (
+ setActiveDialog("elaprApproval")} disabled={isProcessing}>
+ 결재(승인)
+
+ )}
+ {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzRply) && (
+ setActiveDialog("elaprApproval")} disabled={isProcessing}>
+ 결재(회신)
+
+ )}
+
+
+
+ setActiveDialog(null)}
+ docSeComCd={docSeComCd}
+ onConfirm={handleElaprTmpltConfirm}
+ />
+
+ setActiveDialog(open ? "elaprApproval" : null)}
+ elaprDocNo={data.detDto?.designCnstnDocNo ?? designCnstnDocNo}
+ currentUserUuid={userInfo?.userUnqId ?? ""}
+ onSuccess={() => {
+ queryClient.invalidateQueries({
+ queryKey: [...designCnstnMngBaseKey, "findById", designCnstnDocNo],
+ });
+ }}
+ />
+ >
+ );
+}
+
+export { FloatingActionButtons };
+
+async function handleSaveApply(
+ context: ActionHandlerContext,
+): Promise {
+ try {
+ await designCnstnMngService.saveApply(context.request);
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleDel(context: ActionHandlerContext): Promise {
+ try {
+ await designCnstnMngService.delete(context.data.detDto?.designCnstnDocNo ?? "");
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleTechReview(
+ context: ActionHandlerContext,
+): Promise {
+ try {
+ await designCnstnMngService.techReview(
+ context.data.detDto?.designCnstnDocNo ?? "",
+ context.request,
+ );
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleRqstRvw(
+ context: ActionHandlerContext,
+): Promise {
+ try {
+ await elaprDmndService.save(context.request);
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleAtrzAprv(context: ActionHandlerContext): Promise {
+ context.onSuccess?.();
+}
+
+async function handleAtrzRply(context: ActionHandlerContext): Promise {
+ context.onSuccess?.();
+}
+
+const buttonActionHandlers: Record = {
+ strg: handleSaveApply,
+ aply: handleSaveApply,
+ del: handleDel,
+ rqstRvw: handleRqstRvw,
+ rvwStrg: handleTechReview,
+ rvwCmptn: handleTechReview,
+ atrzAprv: handleAtrzAprv,
+ atrzRply: handleAtrzRply,
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+interface ActionHandlerContext {
+ request: T;
+ data: DesignCnstnMngDetResponseDto;
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+}
+
+type ActionHandler = (context: ActionHandlerContext) => Promise;
diff --git a/apps/as/src/pages/design-cnstn/ui/design-edit-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-edit-page.tsx
new file mode 100644
index 000000000..af2e71ace
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/ui/design-edit-page.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { useDesignCnstnMng } from "@/entities/design-cnstn";
+import { Skeleton } from "@common/components";
+
+import { DesignFormPage } from "./design-form-page";
+
+interface DesignEditPageProps {
+ id: string;
+}
+
+function DesignEditPage({ id }: DesignEditPageProps) {
+ const { data, isLoading } = useDesignCnstnMng(id);
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (!data?.detDto) {
+ return 상세 정보를 찾을 수 없습니다.
;
+ }
+
+ return ;
+}
+
+export { DesignEditPage };
diff --git a/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx
new file mode 100644
index 000000000..6c09a5320
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx
@@ -0,0 +1,209 @@
+"use client";
+
+import { useState } from "react";
+import { FormProvider, useForm } from "react-hook-form";
+import { useRouter } from "next/navigation";
+
+import { useConfirm } from "@frontend-opensource/use-react-hooks";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useQueryClient } from "@tanstack/react-query";
+
+import { useAlertStore, useUserStore } from "@/shared/lib";
+import {
+ type DesignCnstnApplySaveRequestDto,
+ designCnstnMngBaseKey,
+ type DesignCnstnMngDetResponseDto,
+ designCnstnMngService,
+} from "@/entities/design-cnstn";
+import {
+ AttachmentsFormSection,
+ BusinessScaleFormSection,
+ ContactInfoFormSection,
+ PrivacyConsentSection,
+} from "@/widgets/pbadms-sprt";
+import { Button } from "@common/components";
+
+import {
+ type DesignCnstnRegistFormData,
+ designCnstnRegistSchema,
+ formatTaskSeComCdForApi,
+} from "../lib/design-cnstn-regist-schema";
+import { getCreateDefaultValues, mapDetailToFormData } from "../lib/form-data-mapper";
+
+import { BasicInfoFormSection } from "./sections/basic-info-form-section";
+
+interface DesignFormPageProps {
+ id?: string;
+ mode: "create" | "edit";
+ initialData?: DesignCnstnMngDetResponseDto;
+ taskSeComCd?: string[];
+}
+
+function DesignFormPage({ id, mode, initialData, taskSeComCd }: DesignFormPageProps) {
+ const queryClient = useQueryClient();
+ const router = useRouter();
+ const { confirm } = useConfirm();
+ const userInfo = useUserStore((state) => state.userInfo);
+ const alert = useAlertStore((state) => state.setMessage);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const isEditMode = mode === "edit";
+
+ const form = useForm({
+ resolver: zodResolver(designCnstnRegistSchema),
+ mode: "onChange",
+ defaultValues:
+ isEditMode && initialData
+ ? mapDetailToFormData(initialData)
+ : getCreateDefaultValues(userInfo, taskSeComCd),
+ });
+
+ function buildFormPayload(
+ data: DesignCnstnRegistFormData,
+ prgrsSttsComCd: "CV" | "CT",
+ ): DesignCnstnApplySaveRequestDto {
+ const { taskSeComCd: formTaskSeComCd, ...designCnstnMngRest } = data.designCnstnMng;
+
+ return {
+ designCnstnMng: {
+ ...designCnstnMngRest,
+ taskSeComCd: formatTaskSeComCdForApi(formTaskSeComCd),
+ designCnstnDocNo: isEditMode ? initialData?.detDto?.designCnstnDocNo : undefined,
+ prgrsSttsComCd,
+ },
+ bzScaleList: data.bzScaleList,
+ esntlAtchFileList: data.esntlAtchFileList,
+ };
+ }
+
+ async function submitForm(
+ data: DesignCnstnRegistFormData,
+ prgrsSttsComCd: "CV" | "CT",
+ ): Promise {
+ return designCnstnMngService.saveApply(buildFormPayload(data, prgrsSttsComCd));
+ }
+
+ async function onSave(data: DesignCnstnRegistFormData) {
+ if (isSubmitting) return;
+
+ try {
+ setIsSubmitting(true);
+ const designCnstnDocNo = await submitForm(data, "CV");
+ if (!designCnstnDocNo) return;
+
+ alert("저장되었습니다.");
+ router.push(`/design-cnstn/${designCnstnDocNo}/edit`);
+ if (isEditMode && id) {
+ queryClient.invalidateQueries({
+ queryKey: [...designCnstnMngBaseKey, "findById", id],
+ });
+ }
+ } catch (error) {
+ console.error("저장 실패:", error);
+ alert("저장 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ async function onApply(data: DesignCnstnRegistFormData) {
+ if (isSubmitting) return;
+
+ try {
+ setIsSubmitting(true);
+ const designCnstnDocNo = await submitForm(data, "CT");
+ if (!designCnstnDocNo) return;
+
+ alert("신청 완료되었습니다.");
+ router.push(`/design-cnstn/${designCnstnDocNo}`);
+ queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
+ if (id) {
+ queryClient.invalidateQueries({
+ queryKey: [...designCnstnMngBaseKey, "findById", id],
+ });
+ }
+ } catch (error) {
+ console.error("신청 실패:", error);
+ alert("신청 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ function handleApplyClick() {
+ form.handleSubmit(onApply)();
+ }
+
+ async function handleDelClick() {
+ if (isSubmitting) return;
+
+ const docNo = initialData?.detDto?.designCnstnDocNo;
+ if (!docNo) {
+ alert("문서번호가 없습니다.");
+ return;
+ }
+
+ if (!(await confirm("삭제하시겠습니까?"))) return;
+
+ try {
+ setIsSubmitting(true);
+ await designCnstnMngService.delete(docNo);
+ alert("삭제되었습니다.");
+ router.push("/design-cnstn");
+ } catch (error) {
+ console.error("삭제 실패:", error);
+ alert("삭제 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+ );
+}
+
+export { DesignFormPage };
diff --git a/apps/as/src/pages/design-cnstn/ui/design-list-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-list-page.tsx
new file mode 100644
index 000000000..9928a83b1
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/ui/design-list-page.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import { useState } from "react";
+
+import type { PaginationState } from "@tanstack/react-table";
+
+import {
+ type CmCdAdstDstrComboResponseDto,
+ useFindGugunCombo,
+ useFindSidoCombo,
+} from "@/entities/adstdstr";
+import { CodeSelect } from "@/entities/code";
+import type { DesignCnstnMngSearchDto } from "@/entities/design-cnstn";
+import {
+ Button,
+ Input,
+ Label,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@common/components";
+
+import { DesignList } from "./design-list";
+
+const DEFAULT_PAGE_INDEX = 0;
+const DEFAULT_PAGE_SIZE = 10;
+const DEFAULT_SORT = "designCnstnDocNo,desc";
+const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
+
+const DEFAULT_SEARCH_PARAMS: DesignCnstnMngSearchDto = {
+ bizNm: "",
+ taskSeComCd: "",
+ admdstCd: "",
+ page: DEFAULT_PAGE_INDEX,
+ size: DEFAULT_PAGE_SIZE,
+ sort: DEFAULT_SORT,
+};
+
+const ALL_SELECT_VALUE = "__ALL__";
+
+function DesignListPage() {
+ const [bizNm, setBizNm] = useState("");
+ const [taskSeComCd, setTaskSeComCd] = useState("");
+ const [sidoComCd, setSidoComCd] = useState("");
+ const [sggComCd, setSggComCd] = useState("");
+ const [pagination, setPagination] = useState({
+ pageIndex: DEFAULT_PAGE_INDEX,
+ pageSize: DEFAULT_PAGE_SIZE,
+ });
+ const [searchParams, setSearchParams] = useState(DEFAULT_SEARCH_PARAMS);
+
+ const { data: sidoData } = useFindSidoCombo();
+ const { data: gugunData } = useFindGugunCombo(sidoComCd);
+ const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
+ const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
+
+ const handleSearch = (event: React.FormEvent) => {
+ event.preventDefault();
+ setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
+ setSearchParams({
+ ...DEFAULT_SEARCH_PARAMS,
+ bizNm,
+ taskSeComCd,
+ admdstCd: sggComCd || sidoComCd || "",
+ page: DEFAULT_PAGE_INDEX,
+ size: pagination.pageSize,
+ });
+ };
+
+ return (
+
+ );
+}
+
+export { DesignListPage };
diff --git a/apps/as/src/pages/design-cnstn/ui/design-list.tsx b/apps/as/src/pages/design-cnstn/ui/design-list.tsx
new file mode 100644
index 000000000..8a0986379
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/ui/design-list.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+
+import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
+
+import { useDataTable, useUserStore } from "@/shared/lib";
+import {
+ type DesignCnstnMngResponseDto,
+ type DesignCnstnMngSearchDto,
+ useDesignCnstnMngPage,
+} from "@/entities/design-cnstn";
+import { TaskSePickerDialog } from "@/widgets/pbadms-sprt";
+import { Button } from "@common/components";
+
+import { formatTaskSeComCdForApi } from "../lib/design-cnstn-regist-schema";
+import { createDesignCnstnColumns } from "../model/columns";
+
+interface DesignListProps {
+ searchParams: DesignCnstnMngSearchDto;
+ pagination: PaginationState;
+ onPaginationChange: (pagination: PaginationState) => void;
+}
+
+function DesignList({ searchParams, pagination, onPaginationChange }: DesignListProps) {
+ const userInfo = useUserStore((state) => state.userInfo);
+ const router = useRouter();
+ const [isTaskSeDialogOpen, setIsTaskSeDialogOpen] = useState(false);
+
+ const queryParams = useMemo(
+ () => ({
+ bizNm: searchParams.bizNm,
+ taskSeComCd: searchParams.taskSeComCd,
+ admdstCd: searchParams.admdstCd,
+ page: pagination.pageIndex,
+ size: pagination.pageSize,
+ sort: searchParams.sort ?? "designCnstnDocNo,desc",
+ }),
+ [searchParams, pagination],
+ );
+
+ const pageQuery = useDesignCnstnMngPage(queryParams);
+ const columns = useMemo(() => createDesignCnstnColumns(), []);
+
+ const handleRowClick = (row: DesignCnstnMngResponseDto) => {
+ const canEdit = row.prgrsSttsComCd === "CV" && row.rgtr === userInfo?.userUnqId;
+
+ router.push(
+ canEdit
+ ? `/design-cnstn/${row.designCnstnDocNo}/edit`
+ : `/design-cnstn/${row.designCnstnDocNo}`,
+ );
+ };
+
+ const { tableComponent } = useDataTable({
+ query: pageQuery,
+ tableOptions: {
+ columns,
+ state: { pagination },
+ onPaginationChange: (updater) => {
+ const next = typeof updater === "function" ? updater(pagination) : updater;
+ onPaginationChange(next);
+ },
+ getCoreRowModel: getCoreRowModel(),
+ manualPagination: true,
+ },
+ dataTableOptions: {
+ viewPagination: true,
+ onRowClick: handleRowClick,
+ },
+ });
+
+ const handleConfirmTaskSe = (selectedCodes: string[]) => {
+ setIsTaskSeDialogOpen(false);
+ router.push(`/design-cnstn/regist?taskSeComCd=${formatTaskSeComCdForApi(selectedCodes)}`);
+ };
+
+ return (
+ <>
+
+
+
설계자문 목록
+ setIsTaskSeDialogOpen(true)}>
+ 신청
+
+
+ {tableComponent}
+
+
+ >
+ );
+}
+
+export { DesignList };
diff --git a/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx
new file mode 100644
index 000000000..fa8130898
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx
@@ -0,0 +1,11 @@
+import { DesignFormPage } from "./design-form-page";
+
+interface DesignRegistPageProps {
+ taskSeComCd?: string[];
+}
+
+function DesignRegistPage({ taskSeComCd }: DesignRegistPageProps) {
+ return ;
+}
+
+export { DesignRegistPage };
diff --git a/apps/as/src/pages/design-cnstn/ui/design-view-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-view-page.tsx
new file mode 100644
index 000000000..aefd42317
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/ui/design-view-page.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { useMemo, useRef } from "react";
+
+import { useUserStore } from "@/shared/lib";
+import { useDesignCnstnMng } from "@/entities/design-cnstn";
+import { ElaprDocPrgrsStatus } from "@/entities/elapr";
+import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Skeleton } from "@common/components";
+
+import { needsRoleUser01AtrzResolve } from "../lib/button-config";
+import { getEditableReviewStep } from "../lib/review-step-utils";
+
+import { FloatingActionButtons } from "./components/floating-action-buttons";
+import { AttachmentsSection } from "./sections/attachments-section";
+import { BasicInfoSection } from "./sections/basic-info-section";
+import { BusinessScaleSection } from "./sections/business-scale-section";
+import { ContactInfoSection } from "./sections/contact-info-section";
+import {
+ TechReviewOpinionSection,
+ type TechReviewOpinionSectionHandle,
+} from "./sections/tech-review-opinion-section";
+
+interface DesignViewPageProps {
+ id: string;
+}
+
+function DesignViewPage({ id }: DesignViewPageProps) {
+ const userInfo = useUserStore((state) => state.userInfo);
+ const techReviewOpinionRef = useRef(null);
+ const { data, isLoading, isError } = useDesignCnstnMng(id);
+
+ const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
+ const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
+ const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
+ {},
+ { enabled: needsAtrzResolve },
+ );
+ const atrzSeComCdList = useMemo(
+ () => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
+ [rvwLoginUserData],
+ );
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (isError || !data?.detDto) {
+ return 상세 정보를 찾을 수 없습니다.
;
+ }
+
+ const detDto = data.detDto;
+ const bizSclList = data.bizSclList ?? [];
+ const esntlFileList = data.esntlFileList ?? [];
+ const etcFileList = data.etcFileList ?? [];
+ const rvwList = data.rvwList ?? [];
+
+ const editableReviewStep = getEditableReviewStep(
+ userInfo?.dtlInstCd,
+ detDto.prgrsSttsComCd || "",
+ );
+
+ return (
+
+ );
+}
+
+export { DesignViewPage };
diff --git a/apps/as/src/pages/design-cnstn/ui/sections/attachments-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/attachments-section.tsx
new file mode 100644
index 000000000..082881231
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/ui/sections/attachments-section.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import { formatFileSize } from "@/shared/lib/file-utils";
+import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
+import type {
+ AtflMngClientResponseDto,
+ EsntlAtchFileMngTreeResponseDto,
+} from "@/entities/design-cnstn";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
+
+interface AttachmentsSectionProps {
+ esntlFileList: EsntlAtchFileMngTreeResponseDto[];
+ etcFileList: AtflMngClientResponseDto[];
+}
+
+function AttachmentsSection({ esntlFileList, etcFileList }: AttachmentsSectionProps) {
+ const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
+
+ return (
+ <>
+ {esntlFileList.length > 0 && (
+
+
+
+ )}
+
+
+
+
+ >
+ );
+}
+
+export { AttachmentsSection };
diff --git a/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx
new file mode 100644
index 000000000..124370ddc
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import { useState } from "react";
+import { Controller, useFormContext } from "react-hook-form";
+
+import { useUserStore } from "@/shared/lib";
+import { Field, FieldError, FieldGroup } from "@/shared/ui/field";
+import { findCodeLabel, useCodeList } from "@/entities/code";
+import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import {
+ Button,
+ Input,
+ Table,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@common/components";
+
+import type { DesignCnstnRegistFormData } from "../../lib/design-cnstn-regist-schema";
+
+function BasicInfoFormSection() {
+ const userInfo = useUserStore((state) => state.userInfo);
+ const { control, watch, setValue } = useFormContext();
+ const [isPickerOpen, setIsPickerOpen] = useState(false);
+ const taskSeComCd = watch("designCnstnMng.taskSeComCd");
+ const { data: taskOptions } = useCodeList("CNFM");
+
+ function handleBnsSelect(data: BnsApplyData) {
+ setValue("designCnstnMng.bizNm", data.bizNm);
+ setValue("designCnstnMng.bizBgngYr", data.bizBgngYr);
+ setValue("designCnstnMng.bizEndYr", data.bizEndYr);
+ setValue("designCnstnMng.bizCn", data.bizCn);
+ }
+
+ const taskSeComCdLabel =
+ taskSeComCd
+ .map((code) => findCodeLabel(taskOptions, code))
+ .filter(Boolean)
+ .join(", ") || "-";
+
+ return (
+
+
+
+ setIsPickerOpen(false)}
+ onSelect={handleBnsSelect}
+ />
+
+ );
+}
+
+export { BasicInfoFormSection };
diff --git a/apps/as/src/pages/design-cnstn/ui/sections/basic-info-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-section.tsx
new file mode 100644
index 000000000..c5db62a50
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-section.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
+
+interface BasicInfoSectionProps {
+ det: DesignCnstnMngResponseDto;
+}
+
+function BasicInfoSection({ det }: BasicInfoSectionProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ 사업명
+ {det.bizNm || "-"}
+ 사업기간
+ {(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")}
+
+
+ 사업내용
+ {det.bizCn || "-"}
+
+
+ 환경청구분
+ {det.envoSeCdNm || "-"}
+ 구분
+ {det.taskSeCdNm || "-"}
+
+
+ 신청지자체
+
+ {det.sido || "-"} {det.gugun || "-"}
+
+
+
+
+
+ );
+}
+
+export { BasicInfoSection };
diff --git a/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx
new file mode 100644
index 000000000..c18e6cf3e
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import type { BizSclTreeDto } from "@/entities/design-cnstn";
+import { fmtNumber, indentPx, SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
+
+interface BusinessScaleSectionProps {
+ bizSclList: BizSclTreeDto[];
+}
+
+function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) {
+ return (
+
+
+
+
+
+ 구분
+ 기본계획(A)
+ 예산반영(B)
+ 금회설계시(C)
+ 증감(C-B)
+
+
+
+ {bizSclList.length === 0 ? (
+
+
+ 조회된 사업규모 항목이 없습니다.
+
+
+ ) : (
+ bizSclList.map((node) => {
+ const pl = indentPx(node.indCnstpSeCdNm);
+ const bscPlanQty = Number(node.bscPlanQty) || 0;
+ const budgetReflct = Number(node.bgtRfltQty) || 0;
+ const tstmDesignQty = Number(node.tstmDesignQty) || 0;
+ const diff = tstmDesignQty - budgetReflct;
+
+ return (
+
+
+ {node.cnstpSeCdNm}
+
+
+ {fmtNumber(bscPlanQty)}
+
+
+ {fmtNumber(budgetReflct)}
+
+
+ {fmtNumber(tstmDesignQty)}
+
+
+
+ {diff > 0 ? `+${fmtNumber(diff)}` : fmtNumber(diff)}
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ );
+}
+
+export { BusinessScaleSection };
diff --git a/apps/as/src/pages/design-cnstn/ui/sections/contact-info-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/contact-info-section.tsx
new file mode 100644
index 000000000..ae495450e
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/ui/sections/contact-info-section.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
+
+interface ContactInfoSectionProps {
+ det: DesignCnstnMngResponseDto;
+}
+
+function ContactInfoSection({ det }: ContactInfoSectionProps) {
+ return (
+
+
+
+ {det.aplcntNm && (
+ <>
+
+ 신청자
+ 이름
+ {det.aplcntNm || "-"}
+ 연락처
+ {det.aplcntMblTelno || "-"}
+
+
+ 소속
+ {det.aplcntOgdpNm || "-"}
+ 이메일
+ {det.aplcntEmlAddr || "-"}
+
+ >
+ )}
+ {det.dsgnrNm && (
+ <>
+
+ 설계자
+ 이름
+ {det.dsgnrNm || "-"}
+ 연락처
+ {det.dsgnrTelno || "-"}
+
+
+ 소속
+ {det.dsgnrOgdpNm || "-"}
+ 이메일
+ {det.dsgnrEmlAddr || "-"}
+
+ >
+ )}
+
+
+
+ );
+}
+
+export { ContactInfoSection };
diff --git a/apps/as/src/pages/design-cnstn/ui/sections/tech-review-opinion-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/tech-review-opinion-section.tsx
new file mode 100644
index 000000000..9c6985ffb
--- /dev/null
+++ b/apps/as/src/pages/design-cnstn/ui/sections/tech-review-opinion-section.tsx
@@ -0,0 +1,342 @@
+"use client";
+
+import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
+
+import { PROXY_HOST } from "@/shared/config";
+import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
+import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
+import { attachService } from "@/entities/attach";
+import type { DesignCnstnRvwOpnnResponseDto } from "@/entities/design-cnstn";
+import { SectionCollapsible } from "@/widgets/pbadms-sprt";
+import {
+ Button,
+ RadioGroup,
+ RadioGroupItem,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Textarea,
+} from "@common/components";
+
+import type {
+ ReviewOpinionFormData,
+ ReviewOpinionGetFormDataResult,
+} from "../../lib/review-opinion-utils";
+import type { ReviewStepCode } from "../../model/view-types";
+import { REVIEW_STEP_LABELS } from "../../model/view-types";
+
+const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
+
+interface TechReviewOpinionSectionProps {
+ rvwList: DesignCnstnRvwOpnnResponseDto[];
+ editableReviewStep?: ReviewStepCode;
+ atrzSeComCdList?: string[];
+ isAtrzLoading?: boolean;
+}
+
+export interface TechReviewOpinionSectionHandle {
+ getFormData: () => ReviewOpinionGetFormDataResult | null;
+ resetForm: () => void;
+}
+
+interface TechReviewOpinionTableProps {
+ items: DesignCnstnRvwOpnnResponseDto[];
+}
+
+interface UploadedOpinionFile {
+ fileName: string;
+ fileKey: string;
+ atflId?: string;
+ atflCd?: string;
+}
+
+const EMPTY_FORM_DATA: ReviewOpinionFormData = {
+ rvwRsltCn: "",
+ rvwRsltAnmlyYn: "",
+ atchFileGroupId: "",
+ opnrSeComCd: "",
+};
+
+function createRvwOpnnUploadKey(file: File) {
+ return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
+}
+
+function hasFormContent(formData: ReviewOpinionFormData) {
+ return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
+}
+
+const TechReviewOpinionSection = forwardRef<
+ TechReviewOpinionSectionHandle,
+ TechReviewOpinionSectionProps
+>(function TechReviewOpinionSection({
+ rvwList,
+ editableReviewStep,
+ atrzSeComCdList = [],
+ isAtrzLoading = false,
+}, ref) {
+ const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
+ const [formData, setFormData] = useState({
+ ...EMPTY_FORM_DATA,
+ opnrSeComCd: editableReviewStep ?? "",
+ });
+ const [uploadedFile, setUploadedFile] = useState(null);
+
+ useEffect(() => {
+ setFormData((prev) => ({
+ ...prev,
+ opnrSeComCd: editableReviewStep ?? "",
+ }));
+ }, [editableReviewStep]);
+
+ const { uploadFileAsync, isUploading, uploads } = useTus({
+ onSuccess: (atflCd, atflId, _file, fileKey) => {
+ if (!atflCd || !atflId) return;
+
+ setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
+ setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
+ },
+ onError: (_error, _file, fileKey) => {
+ setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
+ },
+ });
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ getFormData: () => {
+ if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) {
+ return null;
+ }
+
+ return {
+ formData: {
+ ...formData,
+ opnrSeComCd: editableReviewStep,
+ },
+ rvwList,
+ };
+ },
+ resetForm: () => {
+ setFormData({
+ ...EMPTY_FORM_DATA,
+ opnrSeComCd: editableReviewStep ?? "",
+ });
+ setUploadedFile(null);
+ },
+ }),
+ [canWriteOpinion, editableReviewStep, formData, rvwList],
+ );
+
+ const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
+ const hasKecoSection =
+ kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion);
+
+ if (!hasKecoSection) {
+ return null;
+ }
+
+ async function handleFileUpload(files: FileList | null) {
+ if (!files || files.length === 0) return;
+
+ const file = files[0];
+ const fileKey = createRvwOpnnUploadKey(file);
+ const existingAtflCd = formData.atchFileGroupId;
+
+ setUploadedFile({ fileName: file.name, fileKey });
+
+ try {
+ await uploadFileAsync(file, {
+ fileKey,
+ metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
+ });
+ } catch {
+ // onError callback handles cleanup
+ }
+ }
+
+ async function handleFileRemove() {
+ if (!uploadedFile) return;
+
+ uploads[uploadedFile.fileKey]?.abort();
+
+ if (uploadedFile.atflId) {
+ try {
+ await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
+ } catch (error) {
+ console.error("검토의견 첨부파일 삭제 실패:", error);
+ }
+ }
+
+ setUploadedFile(null);
+ setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
+ }
+
+ function renderOpinionForm(reviewStep: ReviewStepCode) {
+ if (editableReviewStep !== reviewStep || !canWriteOpinion) return null;
+
+ const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
+
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {kecoRvwList.length > 0 ? : null}
+ {renderOpinionForm("KECO")}
+
+
+ );
+});
+
+function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ 의견자명
+ 의견내용
+ 검토결과
+ 첨부파일
+ 등록일시
+
+
+
+ {items.map((item) => (
+
+ {item.opnrNm}
+ {item.rvwRsltCn || "-"}
+
+ {item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
+
+
+ {item.atchFileInfo?.atflCd ? (
+
+ {item.atchFileInfo.orgnlFileNm || "다운로드"}
+
+ ) : (
+ "-"
+ )}
+
+
+ {item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
+
+
+ ))}
+
+
+
+ );
+}
+
+export { TechReviewOpinionSection };
diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts b/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts
index cc6d8d4b9..6fdba8073 100644
--- a/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts
+++ b/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts
@@ -2,7 +2,6 @@ export const FNRS_CNSLTN_VIEW_BUTTON_ACTION = {
strg: "strg",
aply: "aply",
del: "del",
- splmntCmptn: "splmntCmptn",
fnrsCnsltnAncmnt: "fnrsCnsltnAncmnt",
envoSbmsn: "envoSbmsn",
rjct: "rjct",
@@ -15,7 +14,6 @@ export const FNRS_CNSLTN_VIEW_BUTTON_ACTION = {
dmndSplmnt: "dmndSplmnt",
atrzAprv: "atrzAprv",
atrzRply: "atrzRply",
- grndsChck: "grndsChck",
} as const;
export type FnrsCnsltnViewButtonAction =
@@ -33,7 +31,6 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record ({
+ splmntDmndSn: item.splmntDmndSn,
+ splmntArtclComCd: item.splmntArtclComCd,
+ splmntArtclCdNm: item.splmntArtclCdNm,
+ splmntDmndCn: item.splmntDmndCn,
+ splmntDmndDt: item.splmntDmndDt,
+ splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId ?? "",
+ splmntDmndAtchFileNm: item.splmntDmndAtchFileInfo?.orgnlFileNm ?? "",
+ splmntCn: item.splmntCn ?? "",
+ splmntPicNm: item.splmntPicNm ?? "",
+ splmntCmptnDt: item.splmntCmptnDt ?? "",
+ splmntAtchFileGroupId: item.splmntAtchFileGroupId ?? "",
+ splmntAtchFileNm: item.splmntAtchFileInfo?.orgnlFileNm ?? "",
+ splmntAtchFileAtflId: item.splmntAtchFileInfo?.atflId ?? "",
+ cmptnYn: item.cmptnYn,
+ })) ?? [],
privacyConsent: "Y",
};
}
@@ -130,6 +148,7 @@ export function getCreateDefaultValues(
fnrsSharList: [],
cklstResultList: [],
esntlAtchFileList: [],
+ splmntDmndList: [],
privacyConsent: "N",
};
}
diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts
index 3c6b5e3a6..fb34d7d0e 100644
--- a/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts
+++ b/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts
@@ -1,150 +1,167 @@
-import type {
- BizCstItem,
- BizCstTreeDto,
- BizSclTreeDto,
- BzScaleItem,
- CklstResultItem,
- EsntlAtchFileItem,
- EsntlAtchFileMngTreeResponseDto,
- FnrsCnsltnApplySaveRequestDto,
- FnrsCnsltnMngDetResponseDto,
- FnrsCnsltnMngResponseDto,
- FnrsCnsltnSplmntDmndItem,
-} from "@/entities/fnrs-cnsltn";
-import { mapFnrsSharDetToFormList, toFnrsSharSaveList } from "@/widgets/pbadms-sprt";
-
-import type { ChecklistFormState, ReviewStepCode } from "../model/view-types";
-
-import { toCklstSaveDto } from "./checklist-utils";
-import { type ReviewOpinionGetFormDataResult, toRvwOpnnSaveDto } from "./review-opinion-utils";
-
-export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): BzScaleItem[] {
- return bizSclList
- .filter((node) => node.useYn === "Y")
- .map((node) => ({
- cnstpSeCd: node.cnstpSeCd,
- bscPl: node.bscPlanQty,
- budgetReflct: node.bgtRfltQty,
- thtiDsgn: node.tstmDesignQty,
- }));
-}
-
-export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): BizCstItem[] {
- return bizCstList
- .filter((node) => node.useYn === "Y")
- .map((node) => ({
- acntgSeCd: node.acntgSeCd,
- bscPlanAmt: node.bscPlanAmt,
- bgtRfltAmt: node.bgtRfltAmt,
- tstmDesignAmt: node.tstmDesignAmt,
- }));
-}
-
-export function toEsntlAtchFileSaveDto(
- esntlFileList: EsntlAtchFileMngTreeResponseDto[],
-): EsntlAtchFileItem[] {
- const esntlMap = new Map();
-
- for (const item of esntlFileList) {
- if (!item.esntlAtchFileId || !item.atflCd) continue;
- esntlMap.set(item.esntlAtchFileId, item.atflCd);
- }
-
- return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
- esntlAtchFileId,
- atchFileGroupId,
- }));
-}
-
-export function buildSavePayload(
- data: FnrsCnsltnMngDetResponseDto,
- checklistFormState: ChecklistFormState,
- reviewStep: ReviewStepCode,
- prgrsSttsComCd: string,
-): FnrsCnsltnApplySaveRequestDto {
- const detDto = data.detDto;
- if (!detDto) {
- throw new Error("상세 정보(detDto)가 없습니다.");
- }
-
- const bizSclList = data.bizSclList ?? [];
- const bizCstList = data.bizCstList ?? [];
- const cklstResultList = data.cklstResultList ?? [];
- const esntlFileList = data.esntlFileList ?? [];
-
- return {
- fnrsCnsltnMng: { ...detDto, prgrsSttsComCd },
- bzScaleList: toBzScaleSaveDto(bizSclList),
- bizCstList: toBizCstSaveDto(bizCstList),
- cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
- esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
- fnrsSharList: toFnrsSharSaveList(mapFnrsSharDetToFormList(data.fnrsSharList ?? [])),
- };
-}
-
-export function buildTechReviewReplyPayload(
- data: FnrsCnsltnMngDetResponseDto,
- checklistFormState: ChecklistFormState,
- reviewStep: ReviewStepCode,
- prgrsSttsComCd: string,
- techReViewSaveCd: string,
- reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null,
- opnrNm?: string,
-): FnrsCnsltnApplySaveRequestDto {
- return {
- ...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
- techReViewSaveCd,
- rvwStpCd: reviewStep,
- rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
- };
-}
-
-export function buildSupplementReqPayload(
- data: FnrsCnsltnMngDetResponseDto,
- splmntDmndCn: string,
-): FnrsCnsltnApplySaveRequestDto {
- const detDto = data.detDto;
- if (!detDto) {
- throw new Error("상세 정보(detDto)가 없습니다.");
- }
-
- const nextSn =
- (data.splmntDmndList?.reduce((max, item) => Math.max(max, item.splmntDmndSn ?? 0), 0) ?? 0) + 1;
-
- const splmntDmndList: FnrsCnsltnSplmntDmndItem[] = [
- ...(data.splmntDmndList ?? []),
- {
- splmntDmndSn: nextSn,
- splmntDmndCn,
- cmptnYn: "N",
- },
- ];
-
- return {
- fnrsCnsltnMng: detDto,
- splmntDmndList,
- };
-}
-
-export function buildSupplementSubmitPayload(
- data: FnrsCnsltnMngDetResponseDto,
- checklistFormState: ChecklistFormState,
-): FnrsCnsltnApplySaveRequestDto {
- return {
- ...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
- splmntDmndList: data.splmntDmndList,
- };
-}
-
-export function buildTechReviewPayload(
- det: FnrsCnsltnMngResponseDto,
- cklstResultList: CklstResultItem[],
-): FnrsCnsltnApplySaveRequestDto {
- return {
- fnrsCnsltnMng: { ...det, prgrsSttsComCd: "ER" },
- bzScaleList: [],
- bizCstList: [],
- cklstResultList,
- esntlAtchFileList: [],
- };
-}
+import type {
+ BizCstItem,
+ BizCstTreeDto,
+ BizSclTreeDto,
+ BzScaleItem,
+ CklstResultItem,
+ EsntlAtchFileItem,
+ EsntlAtchFileMngTreeResponseDto,
+ FnrsCnsltnApplySaveRequestDto,
+ FnrsCnsltnMngDetResponseDto,
+ FnrsCnsltnMngResponseDto,
+ FnrsCnsltnSplmntDmndItem,
+} from "@/entities/fnrs-cnsltn";
+import {
+ mapFnrsSharDetToFormList,
+ type SplmntDmndDialogConfirmPayload,
+ toFnrsSharSaveList,
+} from "@/widgets/pbadms-sprt";
+
+import type { ChecklistFormState, ReviewStepCode } from "../model/view-types";
+
+import { toCklstSaveDto } from "./checklist-utils";
+import { type ReviewOpinionGetFormDataResult, toRvwOpnnSaveDto } from "./review-opinion-utils";
+
+export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): BzScaleItem[] {
+ return bizSclList
+ .filter((node) => node.useYn === "Y")
+ .map((node) => ({
+ cnstpSeCd: node.cnstpSeCd,
+ bscPl: node.bscPlanQty,
+ budgetReflct: node.bgtRfltQty,
+ thtiDsgn: node.tstmDesignQty,
+ }));
+}
+
+export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): BizCstItem[] {
+ return bizCstList
+ .filter((node) => node.useYn === "Y")
+ .map((node) => ({
+ acntgSeCd: node.acntgSeCd,
+ bscPlanAmt: node.bscPlanAmt,
+ bgtRfltAmt: node.bgtRfltAmt,
+ tstmDesignAmt: node.tstmDesignAmt,
+ }));
+}
+
+export function toEsntlAtchFileSaveDto(
+ esntlFileList: EsntlAtchFileMngTreeResponseDto[],
+): EsntlAtchFileItem[] {
+ const esntlMap = new Map();
+
+ for (const item of esntlFileList) {
+ if (!item.esntlAtchFileId || !item.atflCd) continue;
+ esntlMap.set(item.esntlAtchFileId, item.atflCd);
+ }
+
+ return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
+ esntlAtchFileId,
+ atchFileGroupId,
+ }));
+}
+
+export function buildSavePayload(
+ data: FnrsCnsltnMngDetResponseDto,
+ checklistFormState: ChecklistFormState,
+ reviewStep: ReviewStepCode,
+ prgrsSttsComCd: string,
+): FnrsCnsltnApplySaveRequestDto {
+ const detDto = data.detDto;
+ if (!detDto) {
+ throw new Error("상세 정보(detDto)가 없습니다.");
+ }
+
+ const bizSclList = data.bizSclList ?? [];
+ const bizCstList = data.bizCstList ?? [];
+ const cklstResultList = data.cklstResultList ?? [];
+ const esntlFileList = data.esntlFileList ?? [];
+
+ return {
+ fnrsCnsltnMng: { ...detDto, prgrsSttsComCd },
+ bzScaleList: toBzScaleSaveDto(bizSclList),
+ bizCstList: toBizCstSaveDto(bizCstList),
+ cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
+ esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
+ fnrsSharList: toFnrsSharSaveList(mapFnrsSharDetToFormList(data.fnrsSharList ?? [])),
+ };
+}
+
+export function buildTechReviewReplyPayload(
+ data: FnrsCnsltnMngDetResponseDto,
+ checklistFormState: ChecklistFormState,
+ reviewStep: ReviewStepCode,
+ prgrsSttsComCd: string,
+ techReViewSaveCd: string,
+ reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null,
+ opnrNm?: string,
+): FnrsCnsltnApplySaveRequestDto {
+ return {
+ ...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
+ techReViewSaveCd,
+ rvwStpCd: reviewStep,
+ rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
+ };
+}
+
+export function buildSupplementReqPayload(
+ data: FnrsCnsltnMngDetResponseDto,
+ payload: SplmntDmndDialogConfirmPayload,
+ checklistFormState: ChecklistFormState,
+): FnrsCnsltnApplySaveRequestDto {
+ const existingItems: FnrsCnsltnSplmntDmndItem[] = (data.splmntDmndList ?? []).map((item) =>
+ toSplmntDmndSaveItem(item),
+ );
+
+ const nextSn = existingItems.reduce((max, item) => Math.max(max, item.splmntDmndSn ?? 0), 0) + 1;
+
+ const newItems: FnrsCnsltnSplmntDmndItem[] = payload.items.map((item, index) => ({
+ splmntDmndSn: nextSn + index,
+ splmntArtclComCd: item.splmntArtclComCd,
+ splmntDmndCn: item.splmntDmndCn,
+ splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId,
+ cmptnYn: "N",
+ }));
+
+ return {
+ ...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
+ splmntDmndList: [...existingItems, ...newItems],
+ };
+}
+
+export function toSplmntDmndSaveItem(item: FnrsCnsltnSplmntDmndItem): FnrsCnsltnSplmntDmndItem {
+ return {
+ splmntDmndSn: item.splmntDmndSn,
+ splmntArtclComCd: item.splmntArtclComCd,
+ splmntDmndCn: item.splmntDmndCn,
+ splmntDmndDt: item.splmntDmndDt,
+ splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId,
+ splmntCn: item.splmntCn,
+ splmntPicNm: item.splmntPicNm,
+ splmntCmptnDt: item.splmntCmptnDt,
+ splmntAtchFileGroupId: item.splmntAtchFileGroupId,
+ cmptnYn: item.cmptnYn,
+ };
+}
+
+export function buildSupplementSubmitPayload(
+ data: FnrsCnsltnMngDetResponseDto,
+ checklistFormState: ChecklistFormState,
+): FnrsCnsltnApplySaveRequestDto {
+ return {
+ ...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
+ splmntDmndList: data.splmntDmndList,
+ };
+}
+
+export function buildTechReviewPayload(
+ det: FnrsCnsltnMngResponseDto,
+ cklstResultList: CklstResultItem[],
+): FnrsCnsltnApplySaveRequestDto {
+ return {
+ fnrsCnsltnMng: { ...det, prgrsSttsComCd: "ER" },
+ bzScaleList: [],
+ bizCstList: [],
+ cklstResultList,
+ esntlAtchFileList: [],
+ };
+}
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-apln-picker-grid.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-apln-picker-grid.tsx
deleted file mode 100644
index a22a2e880..000000000
--- a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-apln-picker-grid.tsx
+++ /dev/null
@@ -1,206 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-
-import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
-import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
-
-import { useAlertStore } from "@/shared/lib";
-import {
- type ElaprAplnRow,
- useAplnRows,
- useDndSensors,
- useElaprAplnPage,
- validateAplnRow,
-} from "@/entities/elapr";
-import {
- Button,
- Skeleton,
- Table,
- TableBody,
- TableHead,
- TableHeader,
- TableRow,
-} from "@common/components";
-
-import type { ReviewerOption } from "../../model/reviewer-option";
-
-import { AprvrPickerDialog } from "./aprvr-picker-dialog";
-import { SortableAplnPickerRow } from "./sortable-apln-picker-row";
-
-interface ElaprAplnPickerGridProps {
- elaprTmpltId: string | null;
- onRowsChange?: (rows: ElaprAplnRow[]) => void;
- onEditingLockedChange?: (locked: boolean) => void;
-}
-
-function ElaprAplnPickerGrid({
- elaprTmpltId,
- onRowsChange,
- onEditingLockedChange,
-}: ElaprAplnPickerGridProps) {
- const [pickerOpen, setPickerOpen] = useState(false);
- const [pickingRowKey, setPickingRowKey] = useState(null);
-
- const alert = useAlertStore((state) => state.setMessage);
-
- const aplnQuery = useElaprAplnPage({
- elaprTmpltId: elaprTmpltId ?? undefined,
- page: 0,
- size: 200,
- sort: "atrzSeqNo,asc",
- });
-
- const {
- rows,
- visibleRows,
- editingRowKey,
- isEditingLocked,
- addRow,
- startEdit,
- updateRow,
- cancelEdit,
- commitEdit,
- markDeleted,
- reorderRows,
- } = useAplnRows({
- elaprTmpltId,
- serverRows: aplnQuery.data?.content,
- });
-
- useEffect(() => {
- onRowsChange?.(rows);
- }, [rows, onRowsChange]);
-
- useEffect(() => {
- onEditingLockedChange?.(isEditingLocked);
- }, [isEditingLocked, onEditingLockedChange]);
-
- const sensors = useDndSensors();
- const isDisabled = !elaprTmpltId;
- const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
-
- function handleDragEnd(event: DragEndEvent) {
- const { active, over } = event;
- if (!over || active.id === over.id) return;
-
- reorderRows(String(active.id), String(over.id));
- }
-
- function handleRowChange(row: ElaprAplnRow, patch: Partial) {
- updateRow(row.rowKey, patch);
- }
-
- function handleRowSave(row: ElaprAplnRow) {
- const fieldError = validateAplnRow(row);
- if (fieldError) {
- alert(fieldError);
- return;
- }
-
- commitEdit(row.rowKey);
- }
-
- function handleRowDelete(row: ElaprAplnRow) {
- markDeleted(row.rowKey);
- }
-
- function handleReviewerSelect(reviewer: ReviewerOption) {
- if (!pickingRowKey) return;
- updateRow(pickingRowKey, {
- aprvrUuid: reviewer.uuid,
- aprvrNm: reviewer.name,
- });
- setPickingRowKey(null);
- }
-
- return (
-
-
-
결재선 관리
-
-
- 결재선 추가
-
-
-
-
- {aplnQuery.isPending && elaprTmpltId ? (
-
-
-
-
- ) : !elaprTmpltId ? (
- 결재서식을 선택하세요.
- ) : visibleRows.length === 0 ? (
-
- 등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요.
-
- ) : (
-
-
-
-
-
-
-
-
-
-
-
-
-
- 결재선아이디
- 결재순서
- 결재구분
- 결재자
- 관리
-
-
-
- r.rowKey)}
- strategy={verticalListSortingStrategy}
- >
- {visibleRows.map((row, index) => (
- startEdit(row.rowKey)}
- onSave={() => handleRowSave(row)}
- onCancel={cancelEdit}
- onDelete={() => handleRowDelete(row)}
- onChange={(patch) => handleRowChange(row, patch)}
- onPickAprvr={() => {
- setPickingRowKey(row.rowKey);
- setPickerOpen(true);
- }}
- />
- ))}
-
-
-
-
- )}
-
-
-
- );
-}
-
-export { ElaprAplnPickerGrid };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx
deleted file mode 100644
index a6737ec3e..000000000
--- a/apps/as/src/pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx
+++ /dev/null
@@ -1,419 +0,0 @@
-"use client";
-
-import { useState } from "react";
-
-import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
-import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
-import { useQueryClient } from "@tanstack/react-query";
-import { AxiosError } from "axios";
-
-import { ENDPOINT } from "@/shared/config";
-import { useAlertStore } from "@/shared/lib";
-import {
- buildPrgrsSttsSavePayload,
- elaprPrgrsSttsService,
- findCurrentUserPendingApprovalRequest,
- hasUnsavedPrgrsSttsChanges,
- isPrgrsSttsRowEditable,
- useDndSensors,
- useElaprDocPrgrsByDocNo,
- usePrgrsSttsRows,
- useSaveElaprPrgrsStts,
- validateAllPrgrsSttsRows,
- validateAtrzOrder,
- validatePrgrsSttsRow,
-} from "@/entities/elapr";
-import {
- Button,
- Checkbox,
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- Label,
- Skeleton,
- Table,
- TableBody,
- TableHead,
- TableHeader,
- TableRow,
- Textarea,
-} from "@common/components";
-
-import type { ReviewerOption } from "../../model/reviewer-option";
-
-import { AprvrPickerDialog } from "./aprvr-picker-dialog";
-import { SortablePrgrsSttsRow } from "./sortable-prgrs-stts-row";
-
-interface ElaprApprovalDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- elaprDocNo: string;
- currentUserUuid: string;
- onSuccess: () => void;
-}
-
-const UNSAVED_PRGRS_STTS_MESSAGE = "결재선 변경 이력이 있습니다.\n결재선 저장을 해 주세요.";
-
-function ElaprApprovalDialog({
- open,
- onOpenChange,
- elaprDocNo,
- currentUserUuid,
- onSuccess,
-}: ElaprApprovalDialogProps) {
- const queryClient = useQueryClient();
- const alert = useAlertStore((state) => state.setMessage);
- const [rjctRsn, setRjctRsn] = useState("");
- const [isProcessing, setIsProcessing] = useState(false);
- const [pickerOpen, setPickerOpen] = useState(false);
- const [pickingRowKey, setPickingRowKey] = useState(null);
- const [finalApprovalYn, setFinalApprovalYn] = useState(false);
-
- const { data: elaprDocPrgrsList, isLoading } = useElaprDocPrgrsByDocNo(open ? elaprDocNo : null);
-
- const elaprDmndId = elaprDocPrgrsList?.[0]?.elaprDmndId;
-
- const {
- rows,
- visibleRows,
- editingRowKey,
- isEditingLocked,
- addRow,
- startEdit,
- updateRow,
- cancelEdit,
- commitEdit,
- markDeleted,
- reorderRows,
- } = usePrgrsSttsRows({
- serverRows: elaprDocPrgrsList,
- elaprDmndId,
- });
-
- const savePrgrsSttsMutation = useSaveElaprPrgrsStts();
- const sensors = useDndSensors();
-
- const currentUserPrgrs = findCurrentUserPendingApprovalRequest(
- elaprDocPrgrsList,
- currentUserUuid,
- );
- const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
-
- const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
- const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
- const isPrgrsSttsUnsaved = hasUnsavedPrgrsSttsChanges(rows, elaprDocPrgrsList ?? []);
-
- function handleDragEnd(event: DragEndEvent) {
- const { active, over } = event;
- if (!over || active.id === over.id) return;
-
- reorderRows(String(active.id), String(over.id));
- }
-
- function handleRowSave(rowKey: string) {
- const row = rows.find((r) => r.rowKey === rowKey);
- if (!row) return;
-
- const fieldError = validatePrgrsSttsRow(row);
- if (fieldError) {
- alert(fieldError);
- return;
- }
-
- commitEdit(rowKey);
- }
-
- function handleReviewerSelect(reviewer: ReviewerOption) {
- if (!pickingRowKey) return;
- updateRow(pickingRowKey, {
- aprvrUuid: reviewer.uuid,
- aprvrNm: reviewer.name,
- ognzNm: reviewer.deptNm,
- });
- setPickingRowKey(null);
- }
-
- function assertPrgrsSttsSaved(): boolean {
- if (isEditingLocked) {
- alert(UNSAVED_PRGRS_STTS_MESSAGE);
- return false;
- }
-
- if (isPrgrsSttsUnsaved) {
- alert(UNSAVED_PRGRS_STTS_MESSAGE);
- return false;
- }
-
- return true;
- }
-
- async function handleSavePrgrsStts() {
- if (!elaprDmndId) {
- alert("결재 요청 정보가 없습니다.");
- return;
- }
-
- const validationError = validateAllPrgrsSttsRows(rows, isEditingLocked);
- if (validationError) {
- alert(validationError);
- return;
- }
-
- const orderError = validateAtrzOrder(rows);
- if (orderError) {
- alert(orderError);
- return;
- }
-
- const payload = buildPrgrsSttsSavePayload(rows, elaprDmndId, elaprDocPrgrsList ?? []);
- if (payload.length === 0) {
- alert("변경된 결재선이 없습니다.");
- return;
- }
-
- setIsProcessing(true);
- try {
- await savePrgrsSttsMutation.mutateAsync(payload);
- alert("결재선이 저장되었습니다.");
- await queryClient.invalidateQueries({
- queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
- });
- onSuccess();
- } catch (error: unknown) {
- let errorMessage = "결재선 저장 중 오류가 발생했습니다.";
- if (error instanceof AxiosError) {
- errorMessage = error.response?.data?.message ?? errorMessage;
- }
- alert(errorMessage);
- } finally {
- setIsProcessing(false);
- }
- }
-
- async function handleApprove() {
- if (!currentUserPrgrs?.elaprPrgrsId) return;
- if (!assertPrgrsSttsSaved()) return;
-
- setIsProcessing(true);
- try {
- await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
- atrzPrgrsSttsComCd: "ATRZ_PRGRS_003",
- finalApprovalYn,
- });
- alert("결재가 완료되었습니다.");
- queryClient.invalidateQueries({
- queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
- });
- onSuccess();
- onOpenChange(false);
- } catch (error: unknown) {
- let errorMessage = "";
- if (error instanceof AxiosError) {
- errorMessage = error.response?.data?.message ?? "";
- } else {
- errorMessage = "결재 처리 중 오류가 발생했습니다.";
- }
- alert(errorMessage);
- } finally {
- setIsProcessing(false);
- }
- }
-
- async function handleReject() {
- if (!currentUserPrgrs?.elaprPrgrsId) return;
- if (!rjctRsn.trim()) {
- alert("반려 사유를 입력해주세요.");
- return;
- }
- if (!assertPrgrsSttsSaved()) return;
-
- setIsProcessing(true);
- try {
- await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
- atrzPrgrsSttsComCd: "ATRZ_PRGRS_004",
- rjctRsn: rjctRsn.trim(),
- });
- alert("반려 처리가 완료되었습니다.");
- queryClient.invalidateQueries({
- queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
- });
- onSuccess();
- onOpenChange(false);
- } catch (error: unknown) {
- let errorMessage = "";
- if (error instanceof AxiosError) {
- errorMessage = error.response?.data?.message ?? "";
- } else {
- errorMessage = "반려 처리 중 오류가 발생했습니다.";
- }
- alert(errorMessage);
- } finally {
- setIsProcessing(false);
- }
- }
-
- function handleClose() {
- setRjctRsn("");
- setPickingRowKey(null);
- onOpenChange(false);
- }
-
- return (
-
- );
-}
-
-export { ElaprApprovalDialog };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx
index bf3d02823..b4461ab1b 100644
--- a/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx
@@ -11,8 +11,11 @@ import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import {
buildElaprPrgrsSttsListFromAplnRows,
+ ElaprApprovalDialog,
type ElaprDmndSaveRequestDto,
elaprDmndService,
+ type ElaprTmpltPickerConfirmPayload,
+ ElaprTmpltPickerDialog,
hasCurrentUserPendingApprovalRequest,
useElaprDocPrgrsByDocNo,
} from "@/entities/elapr";
@@ -23,8 +26,7 @@ import {
type FnrsCnsltnMngDetResponseDto,
fnrsCnsltnMngService,
} from "@/entities/fnrs-cnsltn";
-import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
-import { TextInputDialog } from "@/widgets/pbadms-sprt";
+import { SplmntDmndDialog, type SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import {
@@ -37,24 +39,20 @@ import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
import {
buildSavePayload,
buildSupplementReqPayload,
- buildSupplementSubmitPayload,
buildTechReviewReplyPayload,
+ toSplmntDmndSaveItem,
} from "../../lib/save-payload-utils";
import { type ChecklistFormState } from "../../model/view-types";
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
-import { ElaprApprovalDialog } from "./elapr-approval-dialog";
-import {
- type ElaprTmpltPickerConfirmPayload,
- ElaprTmpltPickerDialog,
-} from "./elapr-tmplt-picker-dialog";
-
interface FloatingActionButtonsProps {
data: FnrsCnsltnMngDetResponseDto;
docSeComCd: string;
fnrsCnsltnDocNo: string;
prgrsSttsComCd: string;
dtlInstCd: string | undefined;
+ atrzSeComCdList: string[];
+ isRvwLoginUserLoading: boolean;
checklistFormState: ChecklistFormState;
techReviewOpinionRef?: React.RefObject;
opnrNm?: string;
@@ -68,6 +66,8 @@ function FloatingActionButtons({
fnrsCnsltnDocNo,
prgrsSttsComCd,
dtlInstCd,
+ atrzSeComCdList,
+ isRvwLoginUserLoading,
checklistFormState,
techReviewOpinionRef,
opnrNm,
@@ -85,18 +85,9 @@ function FloatingActionButtons({
const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
const needsTeApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "TE";
- const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
- {},
- { enabled: needsAtrzResolve },
- );
const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
useElaprDocPrgrsByDocNo(needsTeApprovalCheck ? docNo : null);
- const atrzSeComCdList = useMemo(
- () => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
- [rvwLoginUserData],
- );
-
const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
elaprDocPrgrsList,
userInfo?.userUnqId,
@@ -222,15 +213,6 @@ function FloatingActionButtons({
router.push("/fnrs-cnsltn");
}
- async function handleSplmntCmptnClick() {
- const payload = buildSupplementSubmitPayload(data, checklistFormState);
- await executeAction({
- action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn,
- request: payload,
- successMessage: "보완완료 처리되었습니다.",
- });
- }
-
async function handleFnrsCnsltnAncmntClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo,
@@ -383,15 +365,18 @@ function FloatingActionButtons({
return;
}
- const payload = buildTechReviewReplyPayload(
- data,
- checklistFormState,
- "KECO",
- "TE",
- "SV",
- getReviewOpinionFormData(),
- opnrNm,
- );
+ const payload = {
+ ...buildTechReviewReplyPayload(
+ data,
+ checklistFormState,
+ "KECO",
+ "TE",
+ "SV",
+ getReviewOpinionFormData(),
+ opnrNm,
+ ),
+ splmntDmndList: (data.splmntDmndList ?? []).map(toSplmntDmndSaveItem),
+ };
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
@@ -405,12 +390,12 @@ function FloatingActionButtons({
setActiveDialog("dmndSplmnt");
}
- async function handleDmndSplmntConfirm(splmntDmndCn: string) {
+ async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
setActiveDialog(null);
- const payload = buildSupplementReqPayload(data, splmntDmndCn);
+ const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState);
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
- request: payload,
+ request: requestPayload,
successMessage: "보완요청이 처리되었습니다.",
});
}
@@ -442,11 +427,6 @@ function FloatingActionButtons({
삭제
)}
- {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn) && (
-
- 보완완료
-
- )}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt) && (
재원협의 고시
@@ -513,14 +493,13 @@ function FloatingActionButtons({
setActiveDialog(null)}
+ docSeComCd={docSeComCd}
onConfirm={handleElaprTmpltConfirm}
/>
- setActiveDialog(null)}
- title="보완요청 내용 입력"
- placeholder="보완요청 내용을 입력하세요."
isSubmitting={isProcessing}
onConfirm={handleDmndSplmntConfirm}
/>
@@ -562,19 +541,6 @@ async function handleDel(context: ActionHandlerContext): Promise {
}
}
-async function handleSplmntCmptn(
- context: ActionHandlerContext,
-): Promise {
- try {
- const docNo = context.data.detDto?.fnrsCnsltnDocNo;
- if (!docNo) throw new Error("문서번호가 없습니다.");
- await fnrsCnsltnMngService.submitSupplement(docNo, context.request);
- context.onSuccess?.();
- } catch (error) {
- context.onError?.(error as Error);
- }
-}
-
async function handleChangePrgrsSttsComCd(
context: ActionHandlerContext,
): Promise {
@@ -634,16 +600,10 @@ async function handleAtrzRply(context: ActionHandlerContext): Promise {
context.onSuccess?.();
}
-async function handleGrndsChck(context: ActionHandlerContext): Promise {
- // TODO: 현장점검 등록 API 호출
- context.onSuccess?.();
-}
-
const buttonActionHandlers: Record = {
strg: handleSaveApply,
aply: handleSaveApply,
del: handleDel,
- splmntCmptn: handleSplmntCmptn,
fnrsCnsltnAncmnt: handleChangePrgrsSttsComCd,
envoSbmsn: handleChangePrgrsSttsComCd,
rjct: handleChangePrgrsSttsComCd,
@@ -656,7 +616,6 @@ const buttonActionHandlers: Record =
dmndSplmnt: handleDmndSplmnt,
atrzAprv: handleAtrzAprv,
atrzRply: handleAtrzRply,
- grndsChck: handleGrndsChck,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/sortable-apln-picker-row.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/sortable-apln-picker-row.tsx
deleted file mode 100644
index 46af147ca..000000000
--- a/apps/as/src/pages/fnrs-cnsltn/ui/components/sortable-apln-picker-row.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-"use client";
-
-import { useSortable } from "@dnd-kit/sortable";
-import { CSS } from "@dnd-kit/utilities";
-import { MoveVertical } from "lucide-react";
-
-import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
-import type { ElaprAplnRow } from "@/entities/elapr";
-import { Button, TableCell, TableRow } from "@common/components";
-
-interface SortableAplnPickerRowProps {
- row: ElaprAplnRow;
- displaySeqNo: number;
- isEditing: boolean;
- isLocked: boolean;
- dragDisabled: boolean;
- onStartEdit: () => void;
- onSave: () => void;
- onCancel: () => void;
- onDelete: () => void;
- onChange: (patch: Partial) => void;
- onPickAprvr: () => void;
-}
-
-function SortableAplnPickerRow({
- row,
- displaySeqNo,
- isEditing,
- isLocked,
- dragDisabled,
- onStartEdit,
- onSave,
- onCancel,
- onDelete,
- onChange,
- onPickAprvr,
-}: SortableAplnPickerRowProps) {
- const { data: atrzSeCodes } = useCodeList("ATRZSE");
-
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
- id: row.rowKey,
- disabled: dragDisabled,
- });
-
- const style = {
- transform: CSS.Transform.toString(transform),
- transition,
- opacity: isDragging ? 0.6 : 1,
- };
-
- const rowDisabled = isLocked && !isEditing;
- const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd);
-
- return (
-
-
-
-
-
-
- {row.isNew ? "-" : (row.aplnId ?? "-")}
- {displaySeqNo}
-
- {isEditing ? (
-
- onChange({
- atrzSeComCd: v,
- atrzSeComNm: findCodeLabel(atrzSeCodes, v),
- })
- }
- placeholder="결재구분"
- />
- ) : (
- atrzSeLabel || "-"
- )}
-
-
-
- {row.aprvrNm || row.aprvrUuid || "-"}
- {isEditing && (
-
- 선택
-
- )}
-
-
-
-
- {isEditing ? (
- <>
-
- 확인
-
-
- 취소
-
- >
- ) : (
- <>
-
- 수정
-
-
- 삭제
-
- >
- )}
-
-
-
- );
-}
-
-export { SortableAplnPickerRow };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx
index ddcd2074f..76c42f38f 100644
--- a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx
@@ -11,6 +11,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { useAcntgSeMngTree } from "@/entities/acntg";
import {
+ type FnrsCnsltnApplySaveRequestDto,
fnrsCnsltnMngBaseKey,
type FnrsCnsltnMngDetResponseDto,
fnrsCnsltnMngService,
@@ -28,6 +29,7 @@ import {
isFormChecklistComplete,
PrivacyConsentSection,
recalculateFnrsSharTotals,
+ SplmntDmndFormSection,
toFnrsSharSaveList,
validateBizCstSharBalance,
} from "@/widgets/pbadms-sprt";
@@ -56,6 +58,7 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
const alert = useAlertStore((state) => state.setMessage);
const [isSubmitting, setIsSubmitting] = useState(false);
const isEditMode = mode === "edit";
+ const isSplmntPrgrsStts = initialData?.detDto?.prgrsSttsComCd === "SL";
const { data: masterSharTree = [] } = useFnrsSharSeMngTree();
const { data: acntgTree = [] } = useAcntgSeMngTree();
@@ -73,10 +76,10 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
: getCreateDefaultValues(userInfo, taskSeComCd),
});
- async function submitForm(
+ function buildFormPayload(
data: FnrsCnsltnRegistFormData,
- prgrsSttsComCd: "SV" | "ST",
- ): Promise {
+ prgrsSttsComCd: "SV" | "ST" | "TE",
+ ): FnrsCnsltnApplySaveRequestDto | undefined {
const withTotals = recalculateFnrsSharTotals(data.fnrsSharList, sharItems);
const validationMessage = validateBizCstSharBalance(
data.bizCstList,
@@ -86,12 +89,12 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
);
if (validationMessage) {
alert(validationMessage);
- return;
+ return undefined;
}
const { taskSeComCd: formTaskSeComCd, ...fnrsCnsltnMngRest } = data.fnrsCnsltnMng;
- const payload = {
+ return {
fnrsCnsltnMng: {
...fnrsCnsltnMngRest,
taskSeComCd: formatTaskSeComCdForApi(formTaskSeComCd),
@@ -106,11 +109,71 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
selected: item.selected ?? undefined,
})),
esntlAtchFileList: data.esntlAtchFileList,
+ splmntDmndList: data.splmntDmndList.map(
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ ({ splmntAtchFileNm, splmntAtchFileAtflId, splmntDmndAtchFileNm, ...item }) => ({
+ ...item,
+ ...(prgrsSttsComCd === "TE" ? { cmptnYn: "Y" } : null),
+ }),
+ ),
};
+ }
+ async function submitForm(
+ data: FnrsCnsltnRegistFormData,
+ prgrsSttsComCd: "SV" | "ST",
+ ): Promise {
+ const payload = buildFormPayload(data, prgrsSttsComCd);
+ if (!payload) return undefined;
return fnrsCnsltnMngService.saveApply(payload);
}
+ async function submitSupplementForm(data: FnrsCnsltnRegistFormData): Promise {
+ const docNo = initialData?.detDto?.fnrsCnsltnDocNo;
+ if (!docNo) return undefined;
+
+ const payload = buildFormPayload(data, "TE");
+ if (!payload) return undefined;
+
+ return fnrsCnsltnMngService.submitSupplement(docNo, payload);
+ }
+
+ async function onSplmntCmptn(data: FnrsCnsltnRegistFormData) {
+ if (isSubmitting) return;
+
+ const hasEmptySplmntCn = data.splmntDmndList?.some(
+ (item) => !item.splmntCn || !item.splmntCn.trim(),
+ );
+ if (hasEmptySplmntCn) {
+ alert("보완요청내용이 있습니다. 보완완료내용을 입력해 주세요.");
+ return;
+ }
+
+ const docNo = initialData?.detDto?.fnrsCnsltnDocNo;
+ if (!docNo) {
+ alert("문서번호가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsSubmitting(true);
+ const result = await submitSupplementForm(data);
+ if (!result) return;
+
+ alert("보완완료 처리되었습니다.");
+ router.push(`/fnrs-cnsltn/${docNo}`);
+ queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
+ queryClient.invalidateQueries({
+ queryKey: [...fnrsCnsltnMngBaseKey, "findById", docNo],
+ });
+ } catch (error) {
+ console.error("보완완료 실패:", error);
+ alert("보완완료 처리 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
async function onSave(data: FnrsCnsltnRegistFormData) {
if (isSubmitting) return;
@@ -210,6 +273,7 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined}
initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined}
/>
+
@@ -221,23 +285,40 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
>
목록
-
- 저장
-
- {isEditMode && (
+ {!isSplmntPrgrsStts ? (
<>
-
- 신청완료
-
-
- 삭제
+
+ 저장
+ {isEditMode && (
+ <>
+
+ 신청완료
+
+
+ 삭제
+
+ >
+ )}
>
+ ) : (
+
+ 보완완료
+
)}
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-view-page.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-view-page.tsx
index 3e955ee0f..3ec2522d1 100644
--- a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-view-page.tsx
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-view-page.tsx
@@ -1,11 +1,15 @@
"use client";
-import { useRef } from "react";
+import { useMemo, useRef } from "react";
import { useUserStore } from "@/shared/lib";
+import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useFnrsCnsltnMng } from "@/entities/fnrs-cnsltn";
+import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
+import { SectionCollapsible, SupplementDemandSection } from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
+import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
@@ -17,7 +21,6 @@ import { BusinessScaleSection } from "./sections/business-scale-section";
import { ChecklistSection } from "./sections/checklist-section";
import { ContactInfoSection } from "./sections/contact-info-section";
import { FnrsSharSection } from "./sections/fnrs-shar-section";
-import { SupplementDemandSection } from "./sections/supplement-demand-section";
import {
TechReviewOpinionSection,
type TechReviewOpinionSectionHandle,
@@ -34,6 +37,17 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
const { formState: checklistFormState, setFormState: setChecklistFormState } =
useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST);
+ const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
+ const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
+ const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
+ {},
+ { enabled: needsAtrzResolve },
+ );
+ const atrzSeComCdList = useMemo(
+ () => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
+ [rvwLoginUserData],
+ );
+
if (isLoading) {
return (
@@ -76,6 +90,9 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
+
+
+
@@ -92,6 +109,8 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
ref={techReviewOpinionRef}
rvwList={rvwList}
editableReviewStep={editableReviewStep}
+ atrzSeComCdList={atrzSeComCdList}
+ isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading}
/>
+
+
+
+
+
+
사업명
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/supplement-demand-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/supplement-demand-section.tsx
deleted file mode 100644
index 339b803b1..000000000
--- a/apps/as/src/pages/fnrs-cnsltn/ui/sections/supplement-demand-section.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-"use client";
-
-import { dayjs } from "@/shared/lib";
-import type { FnrsCnsltnSplmntDmndResponseDto } from "@/entities/fnrs-cnsltn";
-import { SectionCollapsible } from "@/widgets/pbadms-sprt";
-import {
- Badge,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@common/components";
-
-interface SupplementDemandSectionProps {
- splmntDmndList: FnrsCnsltnSplmntDmndResponseDto[];
-}
-
-function SupplementDemandSection({ splmntDmndList }: SupplementDemandSectionProps) {
- if (splmntDmndList.length === 0) return null;
-
- return (
-
-
-
-
- 항목
- 보완요청 내용
- 보완 내용
- 완료여부
- 요청일시
-
-
-
- {splmntDmndList.map((item) => (
-
- {item.splmntArtclCdNm || "-"}
- {item.splmntDmndCn || "-"}
- {item.splmntCn || "-"}
-
-
- {item.cmptnYn === "Y" ? "완료" : "미완료"}
-
-
-
- {item.splmntDmndDt ? dayjs(item.splmntDmndDt).format("YYYY-MM-DD HH:mm") : "-"}
-
-
- ))}
-
-
-
- );
-}
-
-export { SupplementDemandSection };
diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/sections/tech-review-opinion-section.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/sections/tech-review-opinion-section.tsx
index dee5f6c8f..bc7a51af6 100644
--- a/apps/as/src/pages/fnrs-cnsltn/ui/sections/tech-review-opinion-section.tsx
+++ b/apps/as/src/pages/fnrs-cnsltn/ui/sections/tech-review-opinion-section.tsx
@@ -33,6 +33,8 @@ const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: FnrsCnsltnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
+ atrzSeComCdList?: string[];
+ isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
@@ -69,7 +71,13 @@ function hasFormContent(formData: ReviewOpinionFormData) {
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
->(function TechReviewOpinionSection({ rvwList, editableReviewStep }, ref) {
+>(function TechReviewOpinionSection({
+ rvwList,
+ editableReviewStep,
+ atrzSeComCdList = [],
+ isAtrzLoading = false,
+}, ref) {
+ const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
@@ -102,6 +110,9 @@ const TechReviewOpinionSection = forwardRef<
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
+ if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
+ return null;
+ }
return {
formData: {
@@ -119,14 +130,15 @@ const TechReviewOpinionSection = forwardRef<
setUploadedFile(null);
},
}),
- [editableReviewStep, formData, rvwList],
+ [canWriteKecoOpinion, editableReviewStep, formData, rvwList],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
- const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
+ const hasKecoSection =
+ kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
if (!hasEnvSection && !hasKecoSection) {
return null;
@@ -170,6 +182,7 @@ const TechReviewOpinionSection = forwardRef<
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return null;
+ if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
diff --git a/apps/as/src/pages/instl-sncn/lib/approval-utils.ts b/apps/as/src/pages/instl-sncn/lib/approval-utils.ts
deleted file mode 100644
index 83f22a011..000000000
--- a/apps/as/src/pages/instl-sncn/lib/approval-utils.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { ElaprDocPrgrsClientResponseDto } from "@/entities/instl-sncn";
-
-export interface ApproverStatusConfig {
- label: string;
- badgeClass: string;
-}
-
-export const APPROVER_STATUS_MAP: Record = {
- ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700" },
- ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800" },
- ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800" },
- ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800" },
-};
-
-export function sortByAtrzSeqNo(
- list: ElaprDocPrgrsClientResponseDto[],
-): ElaprDocPrgrsClientResponseDto[] {
- return list.slice().sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
-}
-
-export function getApproverStatus(status: string | undefined): ApproverStatusConfig {
- return (
- APPROVER_STATUS_MAP[status ?? ""] ?? {
- label: status || "-",
- badgeClass: "bg-gray-100 text-gray-700",
- }
- );
-}
diff --git a/apps/as/src/pages/instl-sncn/lib/button-config.ts b/apps/as/src/pages/instl-sncn/lib/button-config.ts
index 83ad6c54a..8dafefd31 100644
--- a/apps/as/src/pages/instl-sncn/lib/button-config.ts
+++ b/apps/as/src/pages/instl-sncn/lib/button-config.ts
@@ -2,7 +2,6 @@ export const INSTL_SNCN_VIEW_BUTTON_ACTION = {
strg: "strg",
aply: "aply",
del: "del",
- splmntCmptn: "splmntCmptn",
instlSncnAncmnt: "instlSncnAncmnt",
envoSbmsn: "envoSbmsn",
rjct: "rjct",
@@ -15,7 +14,6 @@ export const INSTL_SNCN_VIEW_BUTTON_ACTION = {
dmndSplmnt: "dmndSplmnt",
atrzAprv: "atrzAprv",
atrzRply: "atrzRply",
- grndsChck: "grndsChck",
} as const;
export type InstlSncnViewButtonAction =
@@ -33,7 +31,6 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record ({
+ splmntDmndSn: item.splmntDmndSn,
+ splmntArtclComCd: item.splmntArtclComCd,
+ splmntArtclCdNm: item.splmntArtclCdNm,
+ splmntDmndCn: item.splmntDmndCn,
+ splmntDmndDt: item.splmntDmndDt,
+ splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId ?? "",
+ splmntDmndAtchFileNm: item.splmntDmndAtchFileInfo?.orgnlFileNm ?? "",
+ splmntCn: item.splmntCn ?? "",
+ splmntPicNm: item.splmntPicNm ?? "",
+ splmntCmptnDt: item.splmntCmptnDt ?? "",
+ splmntAtchFileGroupId: item.splmntAtchFileGroupId ?? "",
+ splmntAtchFileNm: item.splmntAtchFileInfo?.orgnlFileNm ?? "",
+ splmntAtchFileAtflId: item.splmntAtchFileInfo?.atflId ?? "",
+ cmptnYn: item.cmptnYn,
+ })) ?? [],
privacyConsent: "Y",
};
}
@@ -116,6 +133,7 @@ export function getCreateDefaultValues(
bizCstList: [],
cklstResultList: [],
esntlAtchFileList: [],
+ splmntDmndList: [],
privacyConsent: "N",
};
}
diff --git a/apps/as/src/pages/instl-sncn/lib/instl-sncn-regist-schema.ts b/apps/as/src/pages/instl-sncn/lib/instl-sncn-regist-schema.ts
index ae55e92b9..5cc52d296 100644
--- a/apps/as/src/pages/instl-sncn/lib/instl-sncn-regist-schema.ts
+++ b/apps/as/src/pages/instl-sncn/lib/instl-sncn-regist-schema.ts
@@ -24,23 +24,23 @@ export const instlSncnRegistSchema = z.object({
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
- instlSncnDocNo: z.string().optional(),
+ instlSncnDocNo: z.string().nullish(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
- prgrsSttsComCd: z.string().optional(),
+ prgrsSttsComCd: z.string().nullish(),
atchFileGroupId: z.string().optional(),
rtlztnFcltSeComCd: z.string().optional(),
rtlztnUsgSeComCd: z.string().optional(),
- envoSeCdNm: z.string().optional(),
- sncnSeCdNm: z.string().optional(),
- taskSeCdNm: z.string().optional(),
- prgrsSttsCdNm: z.string().optional(),
- sido: z.string().optional(),
- gugun: z.string().optional(),
- regDt: z.string().optional(),
- rgtr: z.string().optional(),
- rgtrNm: z.string().optional(),
- mdfcnDt: z.string().optional(),
+ envoSeCdNm: z.string().nullish(),
+ sncnSeCdNm: z.string().nullish(),
+ taskSeCdNm: z.string().nullish(),
+ prgrsSttsCdNm: z.string().nullish(),
+ sido: z.string().nullish(),
+ gugun: z.string().nullish(),
+ regDt: z.string().nullish(),
+ rgtr: z.string().nullish(),
+ rgtrNm: z.string().nullish(),
+ mdfcnDt: z.string().nullish(),
}),
bzScaleList: z.array(
z.object({
@@ -66,7 +66,7 @@ export const instlSncnRegistSchema = z.object({
cklstRvwSn: z.number(),
rvwStpComCd: z.string(),
selected: z.enum(["Y", "N"]).nullable(),
- picRvwCn: z.string().nullable().optional(),
+ picRvwCn: z.string().nullish(),
}),
),
esntlAtchFileList: z.array(
@@ -75,6 +75,24 @@ export const instlSncnRegistSchema = z.object({
atchFileGroupId: z.string(),
}),
),
+ splmntDmndList: z.array(
+ z.object({
+ splmntDmndSn: z.number().optional(),
+ splmntArtclComCd: z.string().optional(),
+ splmntArtclCdNm: z.string().nullish(),
+ splmntDmndCn: z.string().optional(),
+ splmntDmndDt: z.string().optional(),
+ splmntDmndAtchFileGroupId: z.string().optional(),
+ splmntDmndAtchFileNm: z.string().optional(),
+ splmntCn: z.string().optional(),
+ splmntPicNm: z.string().optional(),
+ splmntCmptnDt: z.string().optional(),
+ splmntAtchFileGroupId: z.string().optional(),
+ splmntAtchFileNm: z.string().optional(),
+ splmntAtchFileAtflId: z.string().optional(),
+ cmptnYn: z.string().optional(),
+ }),
+ ),
privacyConsent: z.enum(["Y", "N"], {
required_error: "개인정보 수집/이용 동의는 필수입니다",
}),
diff --git a/apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts b/apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts
index adea42a99..bb9bc18ab 100644
--- a/apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts
+++ b/apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts
@@ -1,104 +1,131 @@
-import type {
- BizCstTreeDto,
- BizSclTreeDto,
- EsntlAtchFileMngTreeResponseDto,
- InstlSncnApplySaveRequestDto,
- InstlSncnBizCstSaveRequestDto,
- InstlSncnBzScaleSaveDto,
- InstlSncnCklstResultSaveDto,
- InstlSncnEsntlAtchFileSaveDto,
- InstlSncnMngDetResponse,
- InstlSncnMngResponseDto,
- ReviewStepCode,
-} from "@/entities/instl-sncn";
-
-import type { ChecklistFormState } from "../model/view-types";
-
-import { toCklstSaveDto } from "./checklist-utils";
-import { type ReviewOpinionFormData, toRvwOpnnSaveDto } from "./review-opinion-utils";
-
-export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): InstlSncnBzScaleSaveDto[] {
- return bizSclList
- .filter((node) => node.useYn === "Y")
- .map((node) => ({
- cnstpSeCd: node.cnstpSeCd,
- bscPl: node.bscPlanQty,
- budgetReflct: node.bgtRfltQty,
- thtiDsgn: node.tstmDesignQty,
- diff: node.tstmDesignQty - node.bgtRfltQty,
- }));
-}
-
-export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): InstlSncnBizCstSaveRequestDto[] {
- return bizCstList
- .filter((node) => node.useYn === "Y")
- .map((node) => ({
- acntgSeCd: node.acntgSeCd,
- bscPlanAmt: node.bscPlanAmt,
- bgtRfltAmt: node.bgtRfltAmt,
- tstmDesignAmt: node.tstmDesignAmt,
- }));
-}
-
-export function toEsntlAtchFileSaveDto(
- esntlFileList: EsntlAtchFileMngTreeResponseDto[],
-): InstlSncnEsntlAtchFileSaveDto[] {
- const esntlMap = new Map();
-
- for (const item of esntlFileList) {
- if (!item.esntlAtchFileId || !item.atflCd) continue;
- esntlMap.set(item.esntlAtchFileId, item.atflCd);
- }
-
- return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
- esntlAtchFileId,
- atchFileGroupId,
- }));
-}
-
-export function buildSavePayload(
- data: InstlSncnMngDetResponse,
- checklistFormState: ChecklistFormState,
- reviewStep: ReviewStepCode,
- prgrsSttsComCd: string,
-): InstlSncnApplySaveRequestDto {
- const { detDto, bizSclList, bizCstList, cklstResultList = [], esntlFileList } = data;
-
- return {
- instlSncnMng: { ...detDto, prgrsSttsComCd },
- bzScaleList: toBzScaleSaveDto(bizSclList),
- bizCstList: toBizCstSaveDto(bizCstList),
- cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
- esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
- };
-}
-
-export function buildTechReviewReplyPayload(
- data: InstlSncnMngDetResponse,
- checklistFormState: ChecklistFormState,
- reviewStep: ReviewStepCode,
- prgrsSttsComCd: string,
- techReViewSaveCd: string,
- reviewOpinionFormData?: ReviewOpinionFormData | null,
- opnrNm?: string,
-): InstlSncnApplySaveRequestDto {
- return {
- ...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
- techReViewSaveCd,
- rvwStpCd: reviewStep,
- rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
- };
-}
-
-export function buildTechReviewPayload(
- det: InstlSncnMngResponseDto,
- cklstResultList: InstlSncnCklstResultSaveDto[],
-): InstlSncnApplySaveRequestDto {
- return {
- instlSncnMng: { ...det, prgrsSttsComCd: "ER" },
- bzScaleList: [],
- bizCstList: [],
- cklstResultList,
- esntlAtchFileList: [],
- };
-}
+import type {
+ BizCstTreeDto,
+ BizSclTreeDto,
+ EsntlAtchFileMngTreeResponseDto,
+ InstlSncnApplySaveRequestDto,
+ InstlSncnBizCstSaveRequestDto,
+ InstlSncnBzScaleSaveDto,
+ InstlSncnEsntlAtchFileSaveDto,
+ InstlSncnMngDetResponse,
+ InstlSncnSplmntDmndSaveDto,
+ ReviewStepCode,
+} from "@/entities/instl-sncn";
+import type { SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
+
+import type { ChecklistFormState } from "../model/view-types";
+
+import { toCklstSaveDto } from "./checklist-utils";
+import { type ReviewOpinionFormData, toRvwOpnnSaveDto } from "./review-opinion-utils";
+
+export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): InstlSncnBzScaleSaveDto[] {
+ return bizSclList
+ .filter((node) => node.useYn === "Y")
+ .map((node) => ({
+ cnstpSeCd: node.cnstpSeCd,
+ bscPl: node.bscPlanQty,
+ budgetReflct: node.bgtRfltQty,
+ thtiDsgn: node.tstmDesignQty,
+ diff: node.tstmDesignQty - node.bgtRfltQty,
+ }));
+}
+
+export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): InstlSncnBizCstSaveRequestDto[] {
+ return bizCstList
+ .filter((node) => node.useYn === "Y")
+ .map((node) => ({
+ acntgSeCd: node.acntgSeCd,
+ bscPlanAmt: node.bscPlanAmt,
+ bgtRfltAmt: node.bgtRfltAmt,
+ tstmDesignAmt: node.tstmDesignAmt,
+ }));
+}
+
+export function toEsntlAtchFileSaveDto(
+ esntlFileList: EsntlAtchFileMngTreeResponseDto[],
+): InstlSncnEsntlAtchFileSaveDto[] {
+ const esntlMap = new Map();
+
+ for (const item of esntlFileList) {
+ if (!item.esntlAtchFileId || !item.atflCd) continue;
+ esntlMap.set(item.esntlAtchFileId, item.atflCd);
+ }
+
+ return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
+ esntlAtchFileId,
+ atchFileGroupId,
+ }));
+}
+
+export function buildSavePayload(
+ data: InstlSncnMngDetResponse,
+ checklistFormState: ChecklistFormState,
+ reviewStep: ReviewStepCode,
+ prgrsSttsComCd: string,
+): InstlSncnApplySaveRequestDto {
+ const { detDto, bizSclList, bizCstList, cklstResultList = [], esntlFileList } = data;
+
+ return {
+ instlSncnMng: { ...detDto, prgrsSttsComCd },
+ bzScaleList: toBzScaleSaveDto(bizSclList),
+ bizCstList: toBizCstSaveDto(bizCstList),
+ cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
+ esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
+ };
+}
+
+export function buildTechReviewReplyPayload(
+ data: InstlSncnMngDetResponse,
+ checklistFormState: ChecklistFormState,
+ reviewStep: ReviewStepCode,
+ prgrsSttsComCd: string,
+ techReViewSaveCd: string,
+ reviewOpinionFormData?: ReviewOpinionFormData | null,
+ opnrNm?: string,
+): InstlSncnApplySaveRequestDto {
+ return {
+ ...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
+ techReViewSaveCd,
+ rvwStpCd: reviewStep,
+ rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
+ };
+}
+
+export function buildSupplementReqPayload(
+ data: InstlSncnMngDetResponse,
+ payload: SplmntDmndDialogConfirmPayload,
+ checklistFormState: ChecklistFormState,
+): InstlSncnApplySaveRequestDto {
+ const existingItems: InstlSncnSplmntDmndSaveDto[] = (data.splmntDmndList ?? []).map((item) =>
+ toSplmntDmndSaveItem(item),
+ );
+
+ const nextSn = existingItems.reduce((max, item) => Math.max(max, item.splmntDmndSn ?? 0), 0) + 1;
+
+ const newItems: InstlSncnSplmntDmndSaveDto[] = payload.items.map((item, index) => ({
+ splmntDmndSn: nextSn + index,
+ splmntArtclComCd: item.splmntArtclComCd,
+ splmntDmndCn: item.splmntDmndCn,
+ splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId,
+ cmptnYn: "N",
+ }));
+
+ return {
+ ...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
+ splmntDmndList: [...existingItems, ...newItems],
+ };
+}
+
+export function toSplmntDmndSaveItem(item: InstlSncnSplmntDmndSaveDto): InstlSncnSplmntDmndSaveDto {
+ return {
+ splmntDmndSn: item.splmntDmndSn,
+ splmntArtclComCd: item.splmntArtclComCd,
+ splmntDmndCn: item.splmntDmndCn,
+ splmntDmndDt: item.splmntDmndDt,
+ splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId,
+ splmntCn: item.splmntCn,
+ splmntPicNm: item.splmntPicNm,
+ splmntCmptnDt: item.splmntCmptnDt,
+ splmntAtchFileGroupId: item.splmntAtchFileGroupId,
+ cmptnYn: item.cmptnYn,
+ };
+}
diff --git a/apps/as/src/pages/instl-sncn/model/reviewer-option.ts b/apps/as/src/pages/instl-sncn/model/reviewer-option.ts
deleted file mode 100644
index 2c9a72ef8..000000000
--- a/apps/as/src/pages/instl-sncn/model/reviewer-option.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export interface ReviewerOption {
- uuid: string;
- name: string;
- deptNm: string;
-}
diff --git a/apps/as/src/pages/instl-sncn/ui/components/aprvr-picker-dialog.tsx b/apps/as/src/pages/instl-sncn/ui/components/aprvr-picker-dialog.tsx
deleted file mode 100644
index dd241a514..000000000
--- a/apps/as/src/pages/instl-sncn/ui/components/aprvr-picker-dialog.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-"use client";
-
-import { RvwMnpwMngResponseDto, useRvwMnpwMngPage } from "@/entities/rvw-mnpw";
-import {
- Button,
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- Skeleton,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@common/components";
-
-import { type ReviewerOption } from "../../model/reviewer-option";
-
-const PAGE_SIZE = 500;
-
-interface AprvrPickerDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- atrzSeComCd: string;
- onSelect: (reviewer: ReviewerOption) => void;
-}
-
-function AprvrPickerDialog({ open, onOpenChange, atrzSeComCd, onSelect }: AprvrPickerDialogProps) {
- const { data, isPending, isError } = useRvwMnpwMngPage(
- {
- atrzSeComCd,
- page: 0,
- size: PAGE_SIZE,
- sort: "userUuidNm,asc",
- },
- { enabled: open },
- );
-
- const items = data?.content ?? [];
-
- function fromRvwMnpwMng(item: RvwMnpwMngResponseDto): ReviewerOption {
- return {
- uuid: item.userUuid,
- name: item.userUuidNm ?? "",
- deptNm: item.ognzNm ?? "",
- };
- }
-
- function handleSelect(item: (typeof items)[number]) {
- onSelect(fromRvwMnpwMng(item));
- onOpenChange(false);
- }
-
- return (
-
- );
-}
-
-export { AprvrPickerDialog };
diff --git a/apps/as/src/pages/instl-sncn/ui/components/elapr-tmplt-picker-dialog.tsx b/apps/as/src/pages/instl-sncn/ui/components/elapr-tmplt-picker-dialog.tsx
deleted file mode 100644
index 945a2cfc0..000000000
--- a/apps/as/src/pages/instl-sncn/ui/components/elapr-tmplt-picker-dialog.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-"use client";
-
-import { useCallback, useState } from "react";
-
-import { useAlertStore } from "@/shared/lib";
-import {
- type ElaprAplnRow,
- type ElaprTmplt,
- getVisibleRows,
- useElaprTmpltPage,
- validateAllRowsForSave,
- validateAtrzOrder,
- validateUniqueSeqNo,
-} from "@/entities/elapr";
-import {
- Button,
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@common/components";
-
-import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
-import { ElaprTmpltSelectTable } from "./elapr-tmplt-select-table";
-
-const PAGE_SIZE = 100;
-
-interface ElaprTmpltPickerConfirmPayload {
- tmplt: ElaprTmplt;
- aplnRows: ElaprAplnRow[];
-}
-
-interface ElaprTmpltPickerDialogProps {
- open: boolean;
- onClose: () => void;
- onSelect?: (tmplt: ElaprTmplt) => void;
- onConfirm?: (payload: ElaprTmpltPickerConfirmPayload) => void;
-}
-
-function ElaprTmpltPickerDialog({
- open,
- onClose,
- onSelect,
- onConfirm,
-}: ElaprTmpltPickerDialogProps) {
- const alert = useAlertStore((state) => state.setMessage);
- const [selectedTmplt, setSelectedTmplt] = useState(null);
- const [aplnRows, setAplnRows] = useState([]);
- const [isEditingLocked, setIsEditingLocked] = useState(false);
-
- const tmpltQuery = useElaprTmpltPage({
- page: 0,
- size: PAGE_SIZE,
- sort: "regDt,desc",
- docSeComCd: "INSTL",
- });
-
- const handleRowsChange = useCallback((rows: ElaprAplnRow[]) => {
- setAplnRows(rows);
- }, []);
-
- const handleEditingLockedChange = useCallback((locked: boolean) => {
- setIsEditingLocked(locked);
- }, []);
-
- function handleSelectTmplt(tmplt: ElaprTmplt | null) {
- setSelectedTmplt(tmplt);
- setAplnRows([]);
- setIsEditingLocked(false);
- }
-
- function handleOpenChange(isOpen: boolean) {
- if (!isOpen) {
- setSelectedTmplt(null);
- setAplnRows([]);
- setIsEditingLocked(false);
- onClose();
- }
- }
-
- function handleConfirm() {
- if (!selectedTmplt) {
- alert("결재서식을 선택해 주세요.");
- return;
- }
-
- const rowsError = validateAllRowsForSave(aplnRows, isEditingLocked);
- if (rowsError) {
- alert(rowsError);
- return;
- }
-
- const orderError = validateAtrzOrder(aplnRows);
- if (orderError) {
- alert(orderError);
- return;
- }
-
- const seqError = validateUniqueSeqNo(aplnRows);
- if (seqError) {
- alert(seqError);
- return;
- }
-
- const visibleRows = getVisibleRows(aplnRows);
-
- const payload: ElaprTmpltPickerConfirmPayload = {
- tmplt: selectedTmplt,
- aplnRows: visibleRows,
- };
-
- if (onConfirm) {
- onConfirm(payload);
- } else {
- onSelect?.(selectedTmplt);
- }
-
- setSelectedTmplt(null);
- setAplnRows([]);
- setIsEditingLocked(false);
- onClose();
- }
-
- return (
-
- );
-}
-
-export { ElaprTmpltPickerDialog };
-export type { ElaprTmpltPickerConfirmPayload };
diff --git a/apps/as/src/pages/instl-sncn/ui/components/elapr-tmplt-select-table.tsx b/apps/as/src/pages/instl-sncn/ui/components/elapr-tmplt-select-table.tsx
deleted file mode 100644
index 50bdb33b9..000000000
--- a/apps/as/src/pages/instl-sncn/ui/components/elapr-tmplt-select-table.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-"use client";
-
-import { useEffect, useMemo, useState } from "react";
-
-import type { UseQueryResult } from "@tanstack/react-query";
-import { createColumnHelper, getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
-
-import { useDataTable } from "@/shared/lib";
-import type { PageResponse } from "@/shared/model";
-import type { ElaprTmplt } from "@/entities/elapr";
-import { Skeleton } from "@common/components";
-
-const columnHelper = createColumnHelper();
-
-interface ElaprTmpltSelectTableProps {
- query: UseQueryResult>;
- selectedTmpltId: string | null;
- onSelectTmplt: (tmplt: ElaprTmplt | null) => void;
-}
-
-function ElaprTmpltSelectTable({
- query,
- selectedTmpltId,
- onSelectTmplt,
-}: ElaprTmpltSelectTableProps) {
- const [rowSelection, setRowSelection] = useState({});
-
- useEffect(() => {
- if (selectedTmpltId) {
- setRowSelection({ [selectedTmpltId]: true });
- } else {
- setRowSelection({});
- }
- }, [selectedTmpltId]);
-
- const columns = useMemo(
- () => [
- columnHelper.accessor("elaprTmpltId", {
- header: "전자결재서식아이디",
- meta: { align: "left" },
- }),
- columnHelper.accessor("elaprTmpltNm", {
- header: "결재서식명",
- meta: { align: "left" },
- }),
- columnHelper.accessor("docSeComNm", {
- header: "문서구분",
- cell: ({ getValue, row }) => getValue() ?? row.original.docSeComCd ?? "—",
- }),
- ],
- [],
- );
-
- const { tableComponent } = useDataTable({
- query,
- tableOptions: {
- columns,
- state: { rowSelection },
- enableRowSelection: true,
- enableMultiRowSelection: false,
- getRowId: (row) => row.elaprTmpltId,
- onRowSelectionChange: (updater) => {
- const next = typeof updater === "function" ? updater(rowSelection) : updater;
- setRowSelection(next);
- const selectedId = Object.keys(next)[0];
- if (!selectedId) {
- onSelectTmplt(null);
- return;
- }
- const selected = query.data?.content.find((t) => t.elaprTmpltId === selectedId);
- onSelectTmplt(selected ?? null);
- },
- getCoreRowModel: getCoreRowModel(),
- },
- dataTableOptions: {
- onRowClick: (row) => {
- setRowSelection({ [row.elaprTmpltId]: true });
- onSelectTmplt(row);
- },
- },
- });
-
- return (
-
-
-
전자결재서식
-
- {query.isPending && (
-
-
-
-
- )}
- {query.isError && (
-
- 결재서식 목록을 불러오지 못했습니다.
-
- )}
- {!query.isPending && !query.isError && tableComponent}
-
- );
-}
-
-export { ElaprTmpltSelectTable };
diff --git a/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx b/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx
index ba1f79441..e3c0d8119 100644
--- a/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx
+++ b/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx
@@ -11,8 +11,11 @@ import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import {
buildElaprPrgrsSttsListFromAplnRows,
+ ElaprApprovalDialog,
type ElaprDmndSaveRequestDto,
elaprDmndService,
+ type ElaprTmpltPickerConfirmPayload,
+ ElaprTmpltPickerDialog,
hasCurrentUserPendingApprovalRequest,
useElaprDocPrgrsByDocNo,
} from "@/entities/elapr";
@@ -23,8 +26,7 @@ import {
type InstlSncnMngDetResponse,
instlSncnService,
} from "@/entities/instl-sncn";
-import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
-import { TextInputDialog } from "@/widgets/pbadms-sprt";
+import { SplmntDmndDialog, type SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import {
@@ -34,22 +36,23 @@ import {
needsRoleUser01AtrzResolve,
} from "../../lib/button-config";
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
-import { buildSavePayload, buildTechReviewReplyPayload } from "../../lib/save-payload-utils";
+import {
+ buildSavePayload,
+ buildSupplementReqPayload,
+ buildTechReviewReplyPayload,
+ toSplmntDmndSaveItem,
+} from "../../lib/save-payload-utils";
import { type ChecklistFormState } from "../../model/view-types";
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
-import { ElaprApprovalDialog } from "./elapr-approval-dialog";
-import {
- type ElaprTmpltPickerConfirmPayload,
- ElaprTmpltPickerDialog,
-} from "./elapr-tmplt-picker-dialog";
-
interface FloatingActionButtonsProps {
data: InstlSncnMngDetResponse;
docSeComCd: string;
instlSncnDocNo: string;
prgrsSttsComCd: string;
dtlInstCd: string | undefined;
+ atrzSeComCdList: string[];
+ isRvwLoginUserLoading: boolean;
checklistFormState: ChecklistFormState;
techReviewOpinionRef?: React.RefObject;
opnrNm?: string;
@@ -63,6 +66,8 @@ function FloatingActionButtons({
instlSncnDocNo,
prgrsSttsComCd,
dtlInstCd,
+ atrzSeComCdList,
+ isRvwLoginUserLoading,
checklistFormState,
techReviewOpinionRef,
opnrNm,
@@ -80,18 +85,9 @@ function FloatingActionButtons({
const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
const needsTeApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "TE";
- const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
- {},
- { enabled: needsAtrzResolve },
- );
const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
useElaprDocPrgrsByDocNo(needsTeApprovalCheck ? docNo : null);
- const atrzSeComCdList = useMemo(
- () => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
- [rvwLoginUserData],
- );
-
const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
elaprDocPrgrsList,
userInfo?.userUnqId,
@@ -382,15 +378,18 @@ function FloatingActionButtons({
return;
}
- const payload = buildTechReviewReplyPayload(
- data,
- checklistFormState,
- "KECO",
- "TE",
- "SV",
- getReviewOpinionFormData(),
- opnrNm,
- );
+ const payload = {
+ ...buildTechReviewReplyPayload(
+ data,
+ checklistFormState,
+ "KECO",
+ "TE",
+ "SV",
+ getReviewOpinionFormData(),
+ opnrNm,
+ ),
+ splmntDmndList: (data.splmntDmndList ?? []).map(toSplmntDmndSaveItem),
+ };
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwCmptn,
@@ -404,11 +403,12 @@ function FloatingActionButtons({
setActiveDialog("dmndSplmnt");
}
- async function handleDmndSplmntConfirm(splmntCn: string) {
+ async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
setActiveDialog(null);
+ const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState);
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.dmndSplmnt,
- request: splmntCn,
+ request: requestPayload,
successMessage: "보완요청이 처리되었습니다.",
});
}
@@ -440,16 +440,6 @@ function FloatingActionButtons({
삭제
)}
- {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn) && (
-
- void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn })
- }
- disabled={isProcessing}
- >
- 보완완료
-
- )}
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt) && (
설치인가 고시
@@ -516,16 +506,15 @@ function FloatingActionButtons({
setActiveDialog(null)}
+ docSeComCd={docSeComCd}
onConfirm={handleElaprTmpltConfirm}
/>
- setActiveDialog(null)}
- title="보완요청 내용 입력"
- placeholder="보완요청 내용을 입력하세요."
isSubmitting={isProcessing}
- onConfirm={(value) => void handleDmndSplmntConfirm(value)}
+ onConfirm={handleDmndSplmntConfirm}
/>
{
}
}
-async function handleSplmntCmptn(context: ActionHandlerContext): Promise {
- // TODO: 보완완료 API 호출
- context.onSuccess?.();
-}
-
async function handleChangePrgrsSttsComCd(
context: ActionHandlerContext,
): Promise {
@@ -603,9 +587,17 @@ async function handleDsgntRvwr(
}
}
-async function handleDmndSplmnt(context: ActionHandlerContext): Promise {
- // TODO: 보완요청 API 호출
- context.onSuccess?.();
+async function handleDmndSplmnt(
+ context: ActionHandlerContext,
+): Promise {
+ try {
+ const docNo = context.data.detDto.instlSncnDocNo;
+ if (!docNo) throw new Error("문서번호가 없습니다.");
+ await instlSncnService.submitReqSupplement(docNo, context.request);
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
}
async function handleAtrzAprv(context: ActionHandlerContext): Promise {
@@ -618,16 +610,10 @@ async function handleAtrzRply(context: ActionHandlerContext): Promise {
context.onSuccess?.();
}
-async function handleGrndsChck(context: ActionHandlerContext): Promise {
- // TODO: 현장점검 등록 API 호출
- context.onSuccess?.();
-}
-
const buttonActionHandlers: Record = {
strg: handleSaveApply,
aply: handleSaveApply,
del: handleDel,
- splmntCmptn: handleSplmntCmptn,
instlSncnAncmnt: handleChangePrgrsSttsComCd,
envoSbmsn: handleChangePrgrsSttsComCd,
rjct: handleChangePrgrsSttsComCd,
@@ -640,7 +626,6 @@ const buttonActionHandlers: Record = {
dmndSplmnt: handleDmndSplmnt,
atrzAprv: handleAtrzAprv,
atrzRply: handleAtrzRply,
- grndsChck: handleGrndsChck,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/apps/as/src/pages/instl-sncn/ui/components/sortable-prgrs-stts-row.tsx b/apps/as/src/pages/instl-sncn/ui/components/sortable-prgrs-stts-row.tsx
deleted file mode 100644
index 795c9d08d..000000000
--- a/apps/as/src/pages/instl-sncn/ui/components/sortable-prgrs-stts-row.tsx
+++ /dev/null
@@ -1,179 +0,0 @@
-"use client";
-
-import { useSortable } from "@dnd-kit/sortable";
-import { CSS } from "@dnd-kit/utilities";
-import { MoveVertical } from "lucide-react";
-
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
-import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
-import { type ElaprPrgrsSttsRow, isPrgrsSttsRowEditable } from "@/entities/elapr";
-import { Button, cn, TableCell, TableRow } from "@common/components";
-
-import { getApproverStatus } from "../../lib/approval-utils";
-
-interface SortablePrgrsSttsRowProps {
- row: ElaprPrgrsSttsRow;
- displaySeqNo: number;
- isEditing: boolean;
- isLocked: boolean;
- dragDisabled: boolean;
- onStartEdit: () => void;
- onSave: () => void;
- onCancel: () => void;
- onDelete: () => void;
- onChange: (patch: Partial) => void;
- onPickAprvr: () => void;
-}
-
-function SortablePrgrsSttsRow({
- row,
- displaySeqNo,
- isEditing,
- isLocked,
- dragDisabled,
- onStartEdit,
- onSave,
- onCancel,
- onDelete,
- onChange,
- onPickAprvr,
-}: SortablePrgrsSttsRowProps) {
- const { data: atrzSeCodes } = useCodeList("ATRZSE");
-
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
- id: row.rowKey,
- disabled: dragDisabled,
- });
-
- const style = {
- transform: CSS.Transform.toString(transform),
- transition,
- opacity: isDragging ? 0.6 : 1,
- };
-
- const isEditable = isPrgrsSttsRowEditable(row);
- const rowDisabled = isLocked && !isEditing;
- const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd);
- const status = getApproverStatus(row.atrzPrgrsSttsComCd);
- const isRejectedRow = row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_004";
- const rjctRsn = row.rjctRsn?.trim();
-
- const statusBadge = (
-
- {status.label}
-
- );
-
- return (
-
-
- {!dragDisabled && (
-
-
-
- )}
-
- {displaySeqNo}
-
- {isEditing ? (
-
- onChange({
- atrzSeComCd: v,
- atrzSeComNm: findCodeLabel(atrzSeCodes, v),
- })
- }
- placeholder="결재구분"
- />
- ) : (
- atrzSeLabel || "-"
- )}
-
-
-
-
- {row.aprvrNm || row.aprvrUuid || "-"}
- {isEditing && (
-
- 선택
-
- )}
-
- {!isEditing && (row.ognzNm || row.jbpsSeNm || row.jbgdSeNm) && (
-
- {row.ognzNm ?? ""}
- {row.jbpsSeNm ? ` · ${row.jbpsSeNm}` : ""}
-
- )}
-
-
-
- {isRejectedRow && rjctRsn ? (
-
- {statusBadge}
-
- {rjctRsn}
-
-
- ) : (
- statusBadge
- )}
-
-
-
- {isEditing ? (
- <>
-
- 확인
-
-
- 취소
-
- >
- ) : isEditable ? (
- <>
-
- 수정
-
-
- 삭제
-
- >
- ) : (
- -
- )}
-
-
-
- );
-}
-
-export { SortablePrgrsSttsRow };
diff --git a/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx
index 7ce7142cc..6cfd0f488 100644
--- a/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx
+++ b/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx
@@ -10,6 +10,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib";
import {
+ type InstlSncnApplySaveRequestDto,
instlSncnKeys,
type InstlSncnMngDetResponse,
instlSncnService,
@@ -22,6 +23,7 @@ import {
ContactInfoFormSection,
isFormChecklistComplete,
PrivacyConsentSection,
+ SplmntDmndFormSection,
} from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
@@ -48,6 +50,7 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
const alert = useAlertStore((state) => state.setMessage);
const [isSubmitting, setIsSubmitting] = useState(false);
const isEditMode = mode === "edit";
+ const isSplmntPrgrsStts = initialData?.detDto.prgrsSttsComCd === "SL";
const form = useForm({
resolver: zodResolver(instlSncnRegistSchema),
@@ -58,13 +61,13 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
: getCreateDefaultValues(userInfo, taskSeComCd),
});
- async function submitForm(
+ function buildFormPayload(
data: InstlSncnRegistFormData,
- prgrsSttsComCd: "SV" | "ST",
- ): Promise {
+ prgrsSttsComCd: "SV" | "ST" | "TE",
+ ): InstlSncnApplySaveRequestDto {
const { taskSeComCd: formTaskSeComCd, ...instlSncnMngRest } = data.instlSncnMng;
- const payload = {
+ return {
instlSncnMng: {
...instlSncnMngRest,
instlSncnDocNo: isEditMode ? initialData?.detDto.instlSncnDocNo : undefined,
@@ -78,9 +81,61 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
selected: item.selected,
})),
esntlAtchFileList: data.esntlAtchFileList,
+ splmntDmndList: data.splmntDmndList.map(
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ ({ splmntAtchFileNm, splmntAtchFileAtflId, splmntDmndAtchFileNm, ...item }) => ({
+ ...item,
+ ...(prgrsSttsComCd === "TE" ? { cmptnYn: "Y" } : null),
+ }),
+ ),
};
+ }
- return instlSncnService.saveApply(payload);
+ async function submitForm(
+ data: InstlSncnRegistFormData,
+ prgrsSttsComCd: "SV" | "ST",
+ ): Promise {
+ return instlSncnService.saveApply(buildFormPayload(data, prgrsSttsComCd));
+ }
+
+ async function submitSupplementForm(data: InstlSncnRegistFormData): Promise {
+ const docNo = initialData?.detDto.instlSncnDocNo;
+ if (!docNo) return undefined;
+ return instlSncnService.submitSupplement(docNo, buildFormPayload(data, "TE"));
+ }
+
+ async function onSplmntCmptn(data: InstlSncnRegistFormData) {
+ if (isSubmitting) return;
+
+ const hasEmptySplmntCn = data.splmntDmndList?.some(
+ (item) => !item.splmntCn || !item.splmntCn.trim(),
+ );
+ if (hasEmptySplmntCn) {
+ alert("보완요청내용이 있습니다. 보완완료내용을 입력해 주세요.");
+ return;
+ }
+
+ const docNo = initialData?.detDto.instlSncnDocNo;
+ if (!docNo) {
+ alert("문서번호가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsSubmitting(true);
+ const result = await submitSupplementForm(data);
+ if (!result) return;
+
+ alert("보완완료 처리되었습니다.");
+ router.push(`/instl-sncn/${docNo}`);
+ queryClient.invalidateQueries({ queryKey: instlSncnKeys.list() });
+ queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(docNo) });
+ } catch (error) {
+ console.error("보완완료 실패:", error);
+ alert("보완완료 처리 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
}
async function onSave(data: InstlSncnRegistFormData) {
@@ -131,10 +186,6 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
}
}
- function handleApplyClick() {
- form.handleSubmit(onApply)();
- }
-
async function handleDelClick() {
if (isSubmitting) return;
@@ -150,6 +201,7 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
setIsSubmitting(true);
await instlSncnService.remove(docNo);
alert("삭제되었습니다.");
+ queryClient.invalidateQueries({ queryKey: instlSncnKeys.list() });
router.push("/instl-sncn");
} catch (error) {
console.error("삭제 실패:", error);
@@ -174,6 +226,7 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined}
initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined}
/>
+
@@ -185,23 +238,40 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
>
목록
-
- 저장
-
- {isEditMode && (
+ {!isSplmntPrgrsStts ? (
<>
-
- 신청완료
-
-
- 삭제
+
+ 저장
+ {isEditMode && (
+ <>
+
+ 신청완료
+
+
+ 삭제
+
+ >
+ )}
>
+ ) : (
+
+ 보완완료
+
)}
diff --git a/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx
index 12ae43453..094072f1a 100644
--- a/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx
+++ b/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx
@@ -1,11 +1,15 @@
"use client";
-import { useRef } from "react";
+import { useMemo, useRef } from "react";
import { useUserStore } from "@/shared/lib";
+import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useInstlSncnDetail } from "@/entities/instl-sncn";
+import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
+import { SectionCollapsible, SupplementDemandSection } from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
+import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
@@ -32,6 +36,17 @@ function InstlViewPage({ id }: InstlViewPageProps) {
const { formState: checklistFormState, setFormState: setChecklistFormState } =
useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST);
+ const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
+ const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
+ const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
+ {},
+ { enabled: needsAtrzResolve },
+ );
+ const atrzSeComCdList = useMemo(
+ () => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
+ [rvwLoginUserData],
+ );
+
if (isLoading) {
return (
@@ -63,6 +78,7 @@ function InstlViewPage({ id }: InstlViewPageProps) {
esntlFileList,
etcFileList,
rvwList = [],
+ splmntDmndList = [],
} = data;
const editableReviewStep = getEditableReviewStep(
@@ -74,6 +90,9 @@ function InstlViewPage({ id }: InstlViewPageProps) {
+
+
+
+
+
+
+
+
+
+
사업명
diff --git a/apps/as/src/pages/instl-sncn/ui/sections/tech-review-opinion-section.tsx b/apps/as/src/pages/instl-sncn/ui/sections/tech-review-opinion-section.tsx
index 175d31a42..2227492ed 100644
--- a/apps/as/src/pages/instl-sncn/ui/sections/tech-review-opinion-section.tsx
+++ b/apps/as/src/pages/instl-sncn/ui/sections/tech-review-opinion-section.tsx
@@ -29,6 +29,8 @@ const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: InstlSncnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
+ atrzSeComCdList?: string[];
+ isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
@@ -65,7 +67,13 @@ function hasFormContent(formData: ReviewOpinionFormData) {
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
->(function TechReviewOpinionSection({ rvwList, editableReviewStep }, ref) {
+>(function TechReviewOpinionSection({
+ rvwList,
+ editableReviewStep,
+ atrzSeComCdList = [],
+ isAtrzLoading = false,
+}, ref) {
+ const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
@@ -98,6 +106,9 @@ const TechReviewOpinionSection = forwardRef<
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
+ if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
+ return null;
+ }
return {
...formData,
@@ -112,14 +123,15 @@ const TechReviewOpinionSection = forwardRef<
setUploadedFile(null);
},
}),
- [editableReviewStep, formData],
+ [canWriteKecoOpinion, editableReviewStep, formData],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
- const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
+ const hasKecoSection =
+ kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
if (!hasEnvSection && !hasKecoSection) {
return null;
@@ -163,12 +175,13 @@ const TechReviewOpinionSection = forwardRef<
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return null;
+ if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
-
-
검토의견 작성
+
+
검토의견 작성
@@ -267,14 +280,18 @@ const TechReviewOpinionSection = forwardRef<
<>
{hasEnvSection ? (
- {envRvwList.length > 0 ? : null}
- {renderOpinionForm("ENV")}
+
+ {envRvwList.length > 0 ? : null}
+ {renderOpinionForm("ENV")}
+
) : null}
{hasKecoSection ? (
- {kecoRvwList.length > 0 ? : null}
- {renderOpinionForm("KECO")}
+
+ {kecoRvwList.length > 0 ? : null}
+ {renderOpinionForm("KECO")}
+
) : null}
>
diff --git a/apps/as/src/shared/config/api.ts b/apps/as/src/shared/config/api.ts
index 0aab98ed6..98992a063 100644
--- a/apps/as/src/shared/config/api.ts
+++ b/apps/as/src/shared/config/api.ts
@@ -17,6 +17,11 @@ export const ENDPOINT = {
ADSTDSTR: "/as-service/api/v1/adstdstr",
FNRS_CNSLTN_MNG: "/as-service/api/v1/fnrs-cnsltn-mng",
FNRS_SHAR_SE: "/as-service/api/v1/fnrs-shar-se-mng",
+ DESIGN_CNSTN: "/as-service/api/v1/design-cnstn",
+ DESIGN_CNSTN_BIZ_CST: "/as-service/api/v1/design-cnstn-biz-cst",
+ DESIGN_CNSTN_BIZ_SCL: "/as-service/api/v1/design-cnstn-biz-scl",
+ DESIGN_CNSTN_ESNTL_ATCH_FILE: "/as-service/api/v1/design-cnstn-esntl-atch-file",
+ DESIGN_CNSTN_RVW_OPNN: "/as-service/api/v1/design-cnstn-rvw-opnn",
},
USER_SERVICE: {
LOGIN: "/user-service/login",
diff --git a/apps/as/src/shared/model/pbadms-sprt.ts b/apps/as/src/shared/model/pbadms-sprt.ts
index fc0722eb2..2196ac07a 100644
--- a/apps/as/src/shared/model/pbadms-sprt.ts
+++ b/apps/as/src/shared/model/pbadms-sprt.ts
@@ -143,7 +143,8 @@ export interface ElaprDocPrgrsClientResponseDto {
atrzSeComCd?: string;
atrzSeComNm?: string;
aprvrUuid?: string;
- aprvrUuidNm?: string;
+ aprvrNm?: string;
+ aprvrUserId?: string;
atrzPrgrsSttsComCd?: string;
atrzPrgrsSttsComNm?: string;
rjctRsn?: string | null;
@@ -151,6 +152,7 @@ export interface ElaprDocPrgrsClientResponseDto {
jbpsSeNm?: string;
jbgdSeNm?: string;
ognzNm?: string;
+ dcarbYn?: string;
}
/** 사업규모 저장 공통 필드 */
diff --git a/apps/as/src/widgets/pbadms-sprt/index.ts b/apps/as/src/widgets/pbadms-sprt/index.ts
index aeee59a58..caab6fba0 100644
--- a/apps/as/src/widgets/pbadms-sprt/index.ts
+++ b/apps/as/src/widgets/pbadms-sprt/index.ts
@@ -30,5 +30,11 @@ export { ContactInfoFormSection } from "./ui/contact-info-form-section";
export { FnrsSharFormSection } from "./ui/fnrs-shar-form-section";
export { PrivacyConsentSection } from "./ui/privacy-consent-section";
export { SectionCollapsible } from "./ui/section-collapsible";
+export {
+ SplmntDmndDialog,
+ type SplmntDmndDialogConfirmItem,
+ type SplmntDmndDialogConfirmPayload,
+} from "./ui/splmnt-dmnd-dialog";
+export { type SplmntDmndFormItem, SplmntDmndFormSection } from "./ui/splmnt-dmnd-form-section";
+export { SupplementDemandSection } from "./ui/supplement-demand-section";
export { TaskSePickerDialog } from "./ui/task-se-picker-dialog";
-export { TextInputDialog } from "./ui/text-input-dialog";
diff --git a/apps/as/src/widgets/pbadms-sprt/lib/attachments-form-utils.ts b/apps/as/src/widgets/pbadms-sprt/lib/attachments-form-utils.ts
index 59981f8d1..4fdd62292 100644
--- a/apps/as/src/widgets/pbadms-sprt/lib/attachments-form-utils.ts
+++ b/apps/as/src/widgets/pbadms-sprt/lib/attachments-form-utils.ts
@@ -1,6 +1,6 @@
import { getDefaultFileKey } from "@/shared/lib";
-export type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng";
+export type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng" | "designCnstnMng";
export const ETC_FILE_KEY_PREFIX = "etc::";
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx
index a77663e58..56c01d76e 100644
--- a/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx
+++ b/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx
@@ -29,7 +29,7 @@ import { buildChecklistTabs, syncCklstResultListFromTree } from "../lib/checklis
import { SectionCollapsible } from "./section-collapsible";
-type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng";
+type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng" | "designCnstnMng";
interface CklstResultListItem {
cklstSn: number;
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx
index 1d6e31a16..0ccf5c580 100644
--- a/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx
+++ b/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx
@@ -7,7 +7,7 @@ import { Field, FieldError } from "@/shared/ui/field";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import { Input, Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
-type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng";
+type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng" | "designCnstnMng";
interface ContactInfoFormSectionProps {
fieldPrefix: FieldPrefix;
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-dialog.tsx b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-dialog.tsx
new file mode 100644
index 000000000..9373285c2
--- /dev/null
+++ b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-dialog.tsx
@@ -0,0 +1,329 @@
+"use client";
+
+import { useEffect, useId, useState } from "react";
+
+import { getDefaultFileKey, useTus } from "@/shared/lib";
+import { attachService } from "@/entities/attach";
+import { CodeSelect } from "@/entities/code";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Textarea,
+} from "@common/components";
+
+const SPLMNT_ARTCL_UP_CD = "SPLMNTARTCL";
+const SPLMNT_DMND_FILE_KEY_PREFIX = "splmnt-dmnd::";
+
+interface UploadedSplmntFile {
+ fileName: string;
+ fileKey: string;
+ atflId?: string;
+ atflCd?: string;
+}
+
+interface SplmntDmndRow {
+ rowKey: string;
+ splmntArtclComCd: string;
+ splmntDmndCn: string;
+ splmntDmndAtchFileGroupId: string;
+ uploadedFile: UploadedSplmntFile | null;
+}
+
+interface SplmntDmndDialogConfirmItem {
+ splmntArtclComCd: string;
+ splmntDmndCn: string;
+ splmntDmndAtchFileGroupId?: string;
+}
+
+interface SplmntDmndDialogConfirmPayload {
+ items: SplmntDmndDialogConfirmItem[];
+}
+
+interface SplmntDmndDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ isSubmitting?: boolean;
+ onConfirm: (payload: SplmntDmndDialogConfirmPayload) => void;
+}
+
+function createRowKey() {
+ return `splmnt-row-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
+}
+
+function createEmptyRow(): SplmntDmndRow {
+ return {
+ rowKey: createRowKey(),
+ splmntArtclComCd: "",
+ splmntDmndCn: "",
+ splmntDmndAtchFileGroupId: "",
+ uploadedFile: null,
+ };
+}
+
+function createUploadKey(rowKey: string, file: File) {
+ return `${SPLMNT_DMND_FILE_KEY_PREFIX}${rowKey}::${getDefaultFileKey(file)}`;
+}
+
+function getRowKeyFromFileKey(fileKey: string): string | null {
+ if (!fileKey.startsWith(SPLMNT_DMND_FILE_KEY_PREFIX)) return null;
+ const rest = fileKey.slice(SPLMNT_DMND_FILE_KEY_PREFIX.length);
+ const separatorIndex = rest.indexOf("::");
+ if (separatorIndex < 0) return null;
+ return rest.slice(0, separatorIndex);
+}
+
+function isRowsValid(rows: SplmntDmndRow[]) {
+ return rows.every((row) => row.splmntArtclComCd.trim() && row.splmntDmndCn.trim());
+}
+
+function SplmntDmndDialog({ open, onOpenChange, isSubmitting, onConfirm }: SplmntDmndDialogProps) {
+ const formId = useId();
+ const [rows, setRows] = useState([createEmptyRow()]);
+
+ const { uploadFileAsync, isUploading, uploads } = useTus({
+ onSuccess: (atflCd, atflId, _file, fileKey) => {
+ if (!atflCd || !atflId) return;
+ const rowKey = getRowKeyFromFileKey(fileKey);
+ if (!rowKey) return;
+
+ setRows((prev) =>
+ prev.map((row) => {
+ if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
+ return {
+ ...row,
+ splmntDmndAtchFileGroupId: atflCd,
+ uploadedFile: { ...row.uploadedFile, atflCd, atflId },
+ };
+ }),
+ );
+ },
+ onError: (_error, _file, fileKey) => {
+ const rowKey = getRowKeyFromFileKey(fileKey);
+ if (!rowKey) return;
+
+ setRows((prev) =>
+ prev.map((row) => {
+ if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
+ return { ...row, uploadedFile: null };
+ }),
+ );
+ },
+ });
+
+ useEffect(() => {
+ if (!open) return;
+ setRows([createEmptyRow()]);
+ }, [open]);
+
+ const isAnyUploading = rows.some(
+ (row) => row.uploadedFile != null && (isUploading[row.uploadedFile.fileKey] ?? false),
+ );
+ const canSubmit = isRowsValid(rows) && !isAnyUploading && !isSubmitting;
+
+ function updateRow(rowKey: string, patch: Partial) {
+ setRows((prev) => prev.map((row) => (row.rowKey === rowKey ? { ...row, ...patch } : row)));
+ }
+
+ function handleAddRow() {
+ setRows((prev) => [...prev, createEmptyRow()]);
+ }
+
+ function handleRemoveRow(rowKey: string) {
+ setRows((prev) => (prev.length <= 1 ? prev : prev.filter((row) => row.rowKey !== rowKey)));
+ }
+
+ async function handleFileUpload(rowKey: string, files: FileList | null) {
+ if (!files || files.length === 0) return;
+
+ const file = files[0];
+ const fileKey = createUploadKey(rowKey, file);
+ const targetRow = rows.find((row) => row.rowKey === rowKey);
+ const existingAtflCd = targetRow?.splmntDmndAtchFileGroupId;
+
+ updateRow(rowKey, {
+ uploadedFile: { fileName: file.name, fileKey },
+ });
+
+ try {
+ await uploadFileAsync(file, {
+ fileKey,
+ metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
+ });
+ } catch {
+ // onError callback handles cleanup
+ }
+ }
+
+ async function handleFileRemove(rowKey: string) {
+ const targetRow = rows.find((row) => row.rowKey === rowKey);
+ if (!targetRow?.uploadedFile) return;
+
+ const { fileKey, atflId } = targetRow.uploadedFile;
+ uploads[fileKey]?.abort();
+
+ if (atflId) {
+ try {
+ await attachService.deleteAttachmentByAtflId(atflId);
+ } catch (error) {
+ console.error("보완요청 첨부파일 삭제 실패:", error);
+ }
+ }
+
+ updateRow(rowKey, {
+ uploadedFile: null,
+ splmntDmndAtchFileGroupId: "",
+ });
+ }
+
+ function handleConfirm() {
+ if (!canSubmit) return;
+
+ onConfirm({
+ items: rows.map((row) => ({
+ splmntArtclComCd: row.splmntArtclComCd.trim(),
+ splmntDmndCn: row.splmntDmndCn.trim(),
+ splmntDmndAtchFileGroupId: row.splmntDmndAtchFileGroupId || undefined,
+ })),
+ });
+ }
+
+ return (
+
+ );
+}
+
+export { SplmntDmndDialog };
+export type { SplmntDmndDialogConfirmItem, SplmntDmndDialogConfirmPayload };
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-form-section.tsx
new file mode 100644
index 000000000..28cb40497
--- /dev/null
+++ b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-form-section.tsx
@@ -0,0 +1,324 @@
+"use client";
+
+import { useEffect, useId, useRef, useState } from "react";
+import { Controller, useFormContext, useWatch } from "react-hook-form";
+
+import { Paperclip } from "lucide-react";
+
+import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
+import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach";
+import {
+ Badge,
+ Button,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Textarea,
+} from "@common/components";
+
+import { SectionCollapsible } from "./section-collapsible";
+
+const SPLMNT_CMPTN_FILE_KEY_PREFIX = "splmnt-cmptn::";
+
+interface UploadedSplmntFile {
+ fileName: string;
+ fileKey: string;
+ atflId?: string;
+ atflCd?: string;
+}
+
+interface SplmntDmndFormItem {
+ splmntDmndSn?: number;
+ splmntArtclComCd?: string;
+ splmntArtclCdNm?: string | null;
+ splmntDmndCn?: string;
+ splmntDmndDt?: string;
+ splmntDmndAtchFileGroupId?: string;
+ splmntDmndAtchFileNm?: string;
+ splmntCn?: string;
+ splmntPicNm?: string;
+ splmntCmptnDt?: string;
+ splmntAtchFileGroupId?: string;
+ splmntAtchFileNm?: string;
+ splmntAtchFileAtflId?: string;
+ cmptnYn?: string;
+}
+
+interface SplmntDmndFormData {
+ splmntDmndList: SplmntDmndFormItem[];
+}
+
+interface SplmntDmndFormSectionProps {
+ readOnly?: boolean;
+}
+
+function createUploadKey(rowKey: string, file: File) {
+ return `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::${getDefaultFileKey(file)}`;
+}
+
+function getRowKeyFromFileKey(fileKey: string): string | null {
+ if (!fileKey.startsWith(SPLMNT_CMPTN_FILE_KEY_PREFIX)) return null;
+ const rest = fileKey.slice(SPLMNT_CMPTN_FILE_KEY_PREFIX.length);
+ const separatorIndex = rest.indexOf("::");
+ if (separatorIndex < 0) return null;
+ return rest.slice(0, separatorIndex);
+}
+
+function getRowKey(item: SplmntDmndFormItem, index: number) {
+ return item.splmntDmndSn != null ? String(item.splmntDmndSn) : `idx-${index}`;
+}
+
+function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps) {
+ const formId = useId();
+ const { control, setValue, getValues } = useFormContext();
+ const splmntDmndList = useWatch({ control, name: "splmntDmndList" }) ?? [];
+ const [uploadedFiles, setUploadedFiles] = useState>({});
+ const initialFilesMappedRef = useRef(false);
+ const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
+
+ const { uploadFileAsync, isUploading, uploads } = useTus({
+ onSuccess: (atflCd, atflId, _file, fileKey) => {
+ if (!atflCd || !atflId) return;
+ const rowKey = getRowKeyFromFileKey(fileKey);
+ if (!rowKey) return;
+
+ setUploadedFiles((prev) => {
+ const current = prev[rowKey];
+ if (!current || current.fileKey !== fileKey) return prev;
+ return {
+ ...prev,
+ [rowKey]: { ...current, atflCd, atflId },
+ };
+ });
+
+ const list = getValues("splmntDmndList") ?? [];
+ const index = list.findIndex((item, idx) => getRowKey(item, idx) === rowKey);
+ if (index < 0) return;
+
+ setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, atflCd, { shouldDirty: true });
+ setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, atflId, { shouldDirty: true });
+ },
+ onError: (_error, _file, fileKey) => {
+ const rowKey = getRowKeyFromFileKey(fileKey);
+ if (!rowKey) return;
+
+ setUploadedFiles((prev) => {
+ const current = prev[rowKey];
+ if (!current || current.fileKey !== fileKey) return prev;
+ return { ...prev, [rowKey]: null };
+ });
+ },
+ });
+
+ useEffect(() => {
+ if (initialFilesMappedRef.current) return;
+ if (splmntDmndList.length === 0) return;
+
+ const initial: Record = {};
+ for (let index = 0; index < splmntDmndList.length; index += 1) {
+ const item = splmntDmndList[index];
+ const rowKey = getRowKey(item, index);
+ if (item.splmntAtchFileGroupId && item.splmntAtchFileNm) {
+ initial[rowKey] = {
+ fileName: item.splmntAtchFileNm,
+ fileKey: `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::initial`,
+ atflCd: item.splmntAtchFileGroupId,
+ atflId: item.splmntAtchFileAtflId,
+ };
+ } else {
+ initial[rowKey] = null;
+ }
+ }
+
+ setUploadedFiles(initial);
+ initialFilesMappedRef.current = true;
+ }, [splmntDmndList]);
+
+ if (splmntDmndList.length === 0) return null;
+
+ async function handleFileUpload(index: number, files: FileList | null) {
+ if (!files || files.length === 0) return;
+
+ const item = splmntDmndList[index];
+ const rowKey = getRowKey(item, index);
+ const file = files[0];
+ const fileKey = createUploadKey(rowKey, file);
+ const existingAtflCd = item.splmntAtchFileGroupId;
+
+ setUploadedFiles((prev) => ({
+ ...prev,
+ [rowKey]: { fileName: file.name, fileKey },
+ }));
+ setValue(`splmntDmndList.${index}.splmntAtchFileNm`, file.name, { shouldDirty: true });
+
+ try {
+ await uploadFileAsync(file, {
+ fileKey,
+ metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
+ });
+ } catch {
+ // onError callback handles cleanup
+ }
+ }
+
+ async function handleFileRemove(index: number) {
+ const item = splmntDmndList[index];
+ const rowKey = getRowKey(item, index);
+ const uploaded = uploadedFiles[rowKey];
+ if (!uploaded) return;
+
+ const { fileKey, atflId } = uploaded;
+ uploads[fileKey]?.abort();
+
+ if (atflId) {
+ try {
+ await attachService.deleteAttachmentByAtflId(atflId);
+ } catch (error) {
+ console.error("보완완료 첨부파일 삭제 실패:", error);
+ }
+ }
+
+ setUploadedFiles((prev) => ({ ...prev, [rowKey]: null }));
+ setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, "", { shouldDirty: true });
+ setValue(`splmntDmndList.${index}.splmntAtchFileNm`, "", { shouldDirty: true });
+ setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, "", { shouldDirty: true });
+ }
+
+ return (
+
+
+
+ );
+}
+
+export { SplmntDmndFormSection };
+export type { SplmntDmndFormItem };
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/supplement-demand-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/supplement-demand-section.tsx
new file mode 100644
index 000000000..c4637fd4e
--- /dev/null
+++ b/apps/as/src/widgets/pbadms-sprt/ui/supplement-demand-section.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { Paperclip } from "lucide-react";
+
+import { dayjs } from "@/shared/lib";
+import type { SplmntDmndResponseExtension, SplmntDmndSaveBase } from "@/shared/model";
+import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
+import {
+ Badge,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@common/components";
+
+import { SectionCollapsible } from "./section-collapsible";
+
+type SplmntDmndItem = SplmntDmndSaveBase & SplmntDmndResponseExtension;
+
+interface SupplementDemandSectionProps {
+ splmntDmndList: SplmntDmndItem[];
+}
+
+function SupplementDemandSection({ splmntDmndList }: SupplementDemandSectionProps) {
+ const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 보완항목
+ 보완요청일시
+ 보완요청내용
+ 보완완료일시
+ 보완완료내용
+ 완료여부
+
+
+
+ {splmntDmndList.map((item) => {
+ const sn = item.splmntDmndSn;
+ const cmptnYn = item.cmptnYn ?? "N";
+ const dmndAtchFileNm = item.splmntDmndAtchFileInfo?.orgnlFileNm;
+ const cmptnAtchFileNm = item.splmntAtchFileInfo?.orgnlFileNm;
+
+ return (
+
+
+ {item.splmntArtclCdNm || "-"}
+
+
+ {item.splmntDmndDt ? dayjs(item.splmntDmndDt).format("YYYY-MM-DD HH:mm") : "-"}
+
+
+
+
+
+ {item.splmntCmptnDt
+ ? dayjs(item.splmntCmptnDt).format("YYYY-MM-DD HH:mm")
+ : "-"}
+
+
+
+
+
+
+ {cmptnYn === "Y" ? "완료" : "미완료"}
+
+
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+export { SupplementDemandSection };
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/text-input-dialog.tsx b/apps/as/src/widgets/pbadms-sprt/ui/text-input-dialog.tsx
deleted file mode 100644
index 168012416..000000000
--- a/apps/as/src/widgets/pbadms-sprt/ui/text-input-dialog.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-
-import {
- Button,
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- Textarea,
-} from "@common/components";
-
-interface TextInputDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- title: string;
- placeholder?: string;
- defaultValue?: string;
- isSubmitting?: boolean;
- onConfirm: (text: string) => void;
-}
-
-function TextInputDialog({
- open,
- onOpenChange,
- title,
- placeholder,
- defaultValue,
- isSubmitting,
- onConfirm,
-}: TextInputDialogProps) {
- const [value, setValue] = useState("");
-
- useEffect(() => {
- if (!open) return;
- setValue(defaultValue ?? "");
- }, [defaultValue, open]);
-
- function handleConfirm() {
- onConfirm(value.trim());
- }
-
- return (
-
- );
-}
-
-export { TextInputDialog };
diff --git a/apps/cm/app/api/proxy/[...url]/route.ts b/apps/cm/app/api/proxy/[...url]/route.ts
index 105b36fa7..57cfc86e8 100644
--- a/apps/cm/app/api/proxy/[...url]/route.ts
+++ b/apps/cm/app/api/proxy/[...url]/route.ts
@@ -2,6 +2,7 @@ import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
+ COOKIE_DOMAIN,
REFRESH_TOKEN,
SERVER_API_URL,
SITE_ID,
@@ -19,6 +20,10 @@ async function handler(
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
+ const deviceId = request.headers.get("X-Device-Id");
+ if (deviceId) {
+ headers["X-Device-Id"] = deviceId;
+ }
let body = {};
const isRefreshHandler = params.url.includes("refresh");
@@ -57,40 +62,57 @@ async function handler(
});
console.log(`[Proxy] Response status: ${result.status}`);
- console.log(`[Proxy] Response headers:`, Object.fromEntries(result.headers.entries()));
+ console.log(
+ `[Proxy] Response headers:`,
+ Object.fromEntries(result.headers.entries()),
+ );
// 2FA 관련 엔드포인트인 경우 응답 본문도 확인
- const is2FAEndpoint = params.url.includes("2fa") || params.url.includes("auth");
+ const is2FAEndpoint =
+ params.url.includes("2fa") || params.url.includes("auth");
// 배너 API는 인증 없이 호출 가능
const isBannerEndpoint = params.url.includes("bnrs");
let responseData = {};
-
+
if (is2FAEndpoint || isBannerEndpoint) {
try {
responseData = await result.json();
- console.log(`[Proxy] ${is2FAEndpoint ? '2FA' : 'Banner'} Response data:`, responseData);
+ console.log(
+ `[Proxy] ${is2FAEndpoint ? "2FA" : "Banner"} Response data:`,
+ responseData,
+ );
} catch {
// JSON 파싱 실패 시 빈 객체 사용
console.log(`[Proxy] Failed to parse JSON response`);
}
}
- const accessToken = result.headers.get(ACCESS_TOKEN) || result.headers.get('ss-token');
+ const accessToken =
+ result.headers.get(ACCESS_TOKEN) || result.headers.get("ss-token");
const refreshToken = result.headers.get(REFRESH_TOKEN);
const userId = result.headers.get(AUTH_USER_ID);
- console.log(`[Proxy] Tokens - accessToken: ${!!accessToken}, userId: ${!!userId}, refreshToken: ${!!refreshToken}`);
- console.log(`[Proxy] Available headers:`, Array.from(result.headers.keys()));
+ console.log(
+ `[Proxy] Tokens - accessToken: ${!!accessToken}, userId: ${!!userId}, refreshToken: ${!!refreshToken}`,
+ );
+ console.log(
+ `[Proxy] Available headers:`,
+ Array.from(result.headers.keys()),
+ );
// 2FA 또는 배너 엔드포인트이고 실패한 경우 실제 오류 메시지 반환
if (!result.ok && (is2FAEndpoint || isBannerEndpoint)) {
- console.log(`[Proxy] ${is2FAEndpoint ? '2FA' : 'Banner'} endpoint failed, returning actual error`);
+ console.log(
+ `[Proxy] ${is2FAEndpoint ? "2FA" : "Banner"} endpoint failed, returning actual error`,
+ );
return NextResponse.json(responseData, { status: result.status });
}
// 2FA 또는 배너 엔드포인트이고 성공한 경우 (토큰이 헤더에 없을 수 있음)
if (result.ok && (is2FAEndpoint || isBannerEndpoint)) {
- console.log(`[Proxy] ${is2FAEndpoint ? '2FA' : 'Banner'} endpoint succeeded, returning response data`);
+ console.log(
+ `[Proxy] ${is2FAEndpoint ? "2FA" : "Banner"} endpoint succeeded, returning response data`,
+ );
return NextResponse.json(responseData, { status: result.status });
}
@@ -114,6 +136,7 @@ async function handler(
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "lax",
+ domain: COOKIE_DOMAIN,
path: "/",
});
}
diff --git a/apps/cm/package.json b/apps/cm/package.json
index ba980d55c..1f9f98815 100644
--- a/apps/cm/package.json
+++ b/apps/cm/package.json
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "next dev -p 3001",
+ "dev": "next dev -p 3002",
"build": "next build",
"start": "next start",
"lint": "next lint"
@@ -12,7 +12,7 @@
"@common/assets": "workspace:*",
"@common/auth": "workspace:*",
"@common/components": "workspace:*",
- "@common/config": "workspace:*",
+ "@common/network": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -47,6 +47,7 @@
"sonner": "^2.0.1",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
+ "uuid": "^14.0.1",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
diff --git a/apps/cm/src/entities/auth/api/auth-service.ts b/apps/cm/src/entities/auth/api/auth-service.ts
index a8fa70622..073f95fe2 100644
--- a/apps/cm/src/entities/auth/api/auth-service.ts
+++ b/apps/cm/src/entities/auth/api/auth-service.ts
@@ -12,52 +12,36 @@ import {
import {
clearRefreshTimer,
- removeToken,
setToken,
setupRefreshTimer,
tokenManager,
} from "../lib/token-manager";
-
-function resolveLoginError(message: string | undefined): string {
- switch (message) {
- case "Invalid Credentials":
- return "아이디와 비밀번호가 일치하지 않습니다.";
- case "User not found":
- return "존재하지 않는 아이디입니다.";
- case "User is not active":
- return "활성화되지 않은 계정입니다.";
- case "User is not verified":
- return "이메일 인증이 필요합니다.";
- default:
- return message ?? "로그인에 실패했습니다.";
- }
-}
+import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
function completeSession(accessToken: string, tokenId: string): void {
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
- const remainingMs = getRemainingMs(accessToken);
- if (remainingMs > 0) {
- setupRefreshTimer(silentRefresh, remainingMs);
- }
+ // const remainingMs = getRemainingMs(accessToken);
+ // if (remainingMs > 0) {
+ // setupRefreshTimer(silentRefresh, remainingMs);
+ // }
}
async function login(payload: AuthPayload): Promise {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ "X-Device-Id": getOrCreateDeviceId(),
+ },
body: JSON.stringify(payload),
});
const data = await response.json();
- if (!response.ok) {
- throw new Error(resolveLoginError(data?.message));
- }
-
- const accessToken = data[ACCESS_TOKEN] as string | undefined;
- const tokenId = data[AUTH_USER_ID] as string | undefined;
+ const accessToken = data[ACCESS_TOKEN];
+ const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("인증 토큰을 받지 못했습니다.");
@@ -71,7 +55,12 @@ async function silentRefresh(): Promise {
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
- { method: "PUT" },
+ {
+ method: "PUT",
+ headers: {
+ "X-Device-Id": getOrCreateDeviceId(),
+ },
+ },
);
const data = await response.json();
@@ -80,8 +69,8 @@ async function silentRefresh(): Promise {
throw new Error(data?.message ?? "토큰 갱신에 실패했습니다.");
}
- const accessToken = data[ACCESS_TOKEN] as string | undefined;
- const tokenId = data[AUTH_USER_ID] as string | undefined;
+ const accessToken = data[ACCESS_TOKEN];
+ const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("갱신된 인증 토큰을 받지 못했습니다.");
@@ -102,8 +91,7 @@ async function silentRefresh(): Promise {
async function logout(): Promise {
tokenManager.clearAuthToken();
- removeToken("site-id-key");
- removeToken("site-theme-key");
+ clearDeviceId();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
diff --git a/apps/cm/src/entities/auth/lib/auth-wrapper.tsx b/apps/cm/src/entities/auth/lib/auth-wrapper.tsx
index d1a2a48c3..87f932817 100644
--- a/apps/cm/src/entities/auth/lib/auth-wrapper.tsx
+++ b/apps/cm/src/entities/auth/lib/auth-wrapper.tsx
@@ -18,10 +18,9 @@ import { getRemainingMs } from "@common/auth";
import { isAtLoginRedirectUrl } from "@common/network";
import { authService } from "../api/auth-service";
-
import { getToken, setupRefreshTimer } from "./token-manager";
-const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
+export function AuthWrapper({ children }: { children: React.ReactNode }) {
const router = useRouter();
const userInfo = useUserStore((state) => state.userInfo);
const setUserInfo = useUserStore((state) => state.setUserInfo);
@@ -31,9 +30,6 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
if (token) {
const remainingMs = getRemainingMs(token);
if (remainingMs > 0) {
- console.log(
- `[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`,
- );
setupRefreshTimer(authService.silentRefresh, remainingMs);
}
}
@@ -79,6 +75,4 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
}, []);
return <>{children}>;
-};
-
-export { AuthWrapper };
+}
diff --git a/apps/cm/src/entities/auth/lib/device-id.ts b/apps/cm/src/entities/auth/lib/device-id.ts
new file mode 100644
index 000000000..3683c7189
--- /dev/null
+++ b/apps/cm/src/entities/auth/lib/device-id.ts
@@ -0,0 +1,20 @@
+import { v4 as uuidv4 } from "uuid";
+
+const DEVICE_ID_KEY = "x-device-id";
+
+export function getOrCreateDeviceId(): string {
+ if (typeof window === "undefined") return "";
+
+ const existing = localStorage.getItem(DEVICE_ID_KEY);
+ if (existing) return existing;
+
+ const id = uuidv4();
+ localStorage.setItem(DEVICE_ID_KEY, id);
+ return id;
+}
+
+export function clearDeviceId(): void {
+ if (typeof window === "undefined") return;
+
+ localStorage.removeItem(DEVICE_ID_KEY);
+}
diff --git a/apps/cm/src/pages/auth/login/ui/UserSignupModal.tsx b/apps/cm/src/pages/auth/login/ui/UserSignupModal.tsx
index 8faf518f1..e4a542cfa 100644
--- a/apps/cm/src/pages/auth/login/ui/UserSignupModal.tsx
+++ b/apps/cm/src/pages/auth/login/ui/UserSignupModal.tsx
@@ -882,11 +882,6 @@ function UserSignupModal({ open, onClose, onSuccess }: UserSignupModalProps) {
type="submit"
className="user-signup-submit-button"
disabled={isLoading}
- onClick={(e) => {
- e.preventDefault();
- console.log("회원가입 버튼 클릭됨 - onClick");
- handleSubmit(e);
- }}
>
{isLoading ? "가입 중..." : "회원가입"}
diff --git a/apps/cm/src/shared/config/env.ts b/apps/cm/src/shared/config/env.ts
index 5e1392b4d..4d8874f5c 100644
--- a/apps/cm/src/shared/config/env.ts
+++ b/apps/cm/src/shared/config/env.ts
@@ -1,7 +1,7 @@
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST =
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
-export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
+export const SERVER_API_URL = process.env.SERVER_API_URL;
export const AUTH_LOGIN_REDIRECT_URL =
process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login";
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "CS";
diff --git a/apps/cm/src/shared/config/index.ts b/apps/cm/src/shared/config/index.ts
index cd83447d5..ea6a6f16a 100644
--- a/apps/cm/src/shared/config/index.ts
+++ b/apps/cm/src/shared/config/index.ts
@@ -1,2 +1,3 @@
export * from "./api";
+export * from "./auth";
export * from "./env";
diff --git a/apps/cm/tsconfig.json b/apps/cm/tsconfig.json
index 5b8c29899..05cbfce70 100644
--- a/apps/cm/tsconfig.json
+++ b/apps/cm/tsconfig.json
@@ -22,12 +22,8 @@
"name": "next"
}
],
- "baseUrl": "./src",
"paths": {
- "@/*": ["./*"],
- "@common/components": ["../../../packages/common/components/src/"],
- "@common/config": ["../../../packages/common/config/dist/"],
- "@common/network": ["../../../packages/common/network/"]
+ "@/*": ["./src/*"]
}
},
"include": [
diff --git a/apps/cs/middleware.ts b/apps/cs/middleware.ts
index 98b741325..adc6077b9 100644
--- a/apps/cs/middleware.ts
+++ b/apps/cs/middleware.ts
@@ -41,7 +41,7 @@ export function middleware(req: NextRequest) {
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
- // console.log(logMessages.join("\n"));
+ console.log(logMessages.join("\n"));
const response = NextResponse.rewrite(new URL(newUrl));
return addSecurityHeaders(response);
@@ -58,7 +58,7 @@ export function middleware(req: NextRequest) {
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
- // console.log(logMessages.join("\n"));
+ console.log(logMessages.join("\n"));
const response = NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
@@ -83,7 +83,7 @@ export function middleware(req: NextRequest) {
logMessages.push(` Link API Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
- // console.log(logMessages.join("\n"));
+ console.log(logMessages.join("\n"));
const response = NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
@@ -97,7 +97,7 @@ export function middleware(req: NextRequest) {
}
logMessages.push("\n--------------------------------\n");
- // console.log(logMessages.join("\n"));
+ console.log(logMessages.join("\n"));
const response = NextResponse.next();
return addSecurityHeaders(response);
diff --git a/apps/cs/next.config.ts b/apps/cs/next.config.ts
index 1fdc9639b..d2dcc8d47 100644
--- a/apps/cs/next.config.ts
+++ b/apps/cs/next.config.ts
@@ -19,7 +19,7 @@ const nextConfig: NextConfig = {
fetches: {
fullUrl: true,
},
- },
+ },
async headers() {
return [
{
diff --git a/apps/cs/package.json b/apps/cs/package.json
index 8d2f6be96..ea5899360 100644
--- a/apps/cs/package.json
+++ b/apps/cs/package.json
@@ -50,6 +50,7 @@
"sonner": "^2.0.1",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
+ "uuid": "^14.0.1",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
diff --git a/apps/cs/src/entities/auth/api/auth-service.ts b/apps/cs/src/entities/auth/api/auth-service.ts
index 3efb1bf85..93c0bc0bb 100644
--- a/apps/cs/src/entities/auth/api/auth-service.ts
+++ b/apps/cs/src/entities/auth/api/auth-service.ts
@@ -4,50 +4,29 @@ import {
CLAIM_NAME,
ENDPOINT,
} from "@/shared/config";
-import {
- type AuthPayload,
- type AuthService,
- getRemainingMs,
-} from "@common/auth";
+import { type AuthPayload, type AuthService } from "@common/auth";
+import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
import {
clearRefreshTimer,
- removeToken,
setToken,
- setupRefreshTimer,
tokenManager,
} from "../lib/token-manager";
-function resolveLoginError(message: string | undefined): string {
- switch (message) {
- case "Invalid Credentials":
- return "아이디와 비밀번호가 일치하지 않습니다.";
- case "User not found":
- return "존재하지 않는 아이디입니다.";
- case "User is not active":
- return "활성화되지 않은 계정입니다.";
- case "User is not verified":
- return "이메일 인증이 필요합니다.";
- default:
- return message ?? "로그인에 실패했습니다.";
- }
-}
-
async function login(payload: AuthPayload): Promise {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ "X-Device-Id": getOrCreateDeviceId(),
+ },
body: JSON.stringify(payload),
});
const data = await response.json();
- if (!response.ok) {
- throw new Error(resolveLoginError(data?.message));
- }
-
- const accessToken = data[ACCESS_TOKEN] as string | undefined;
- const tokenId = data[AUTH_USER_ID] as string | undefined;
+ const accessToken = data[ACCESS_TOKEN];
+ const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("인증 토큰을 받지 못했습니다.");
@@ -56,10 +35,10 @@ async function login(payload: AuthPayload): Promise {
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
- const remainingMs = getRemainingMs(accessToken);
- if (remainingMs > 0) {
- setupRefreshTimer(silentRefresh, remainingMs);
- }
+ // const remainingMs = getRemainingMs(accessToken);
+ // if (remainingMs > 0) {
+ // setupRefreshTimer(silentRefresh, remainingMs);
+ // }
}
async function silentRefresh(): Promise {
@@ -67,7 +46,12 @@ async function silentRefresh(): Promise {
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
- { method: "PUT" },
+ {
+ method: "PUT",
+ headers: {
+ "X-Device-Id": getOrCreateDeviceId(),
+ },
+ },
);
const data = await response.json();
@@ -76,8 +60,8 @@ async function silentRefresh(): Promise {
throw new Error(data?.message ?? "토큰 갱신에 실패했습니다.");
}
- const accessToken = data[ACCESS_TOKEN] as string | undefined;
- const tokenId = data[AUTH_USER_ID] as string | undefined;
+ const accessToken = data[ACCESS_TOKEN];
+ const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("갱신된 인증 토큰을 받지 못했습니다.");
@@ -86,10 +70,10 @@ async function silentRefresh(): Promise {
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
- const remainingMs = getRemainingMs(accessToken);
- if (remainingMs > 0) {
- setupRefreshTimer(silentRefresh, remainingMs);
- }
+ // const remainingMs = getRemainingMs(accessToken);
+ // if (remainingMs > 0) {
+ // setupRefreshTimer(silentRefresh, remainingMs);
+ // }
} catch (error) {
console.error(
`[silentRefresh] Failed at ${new Date().toISOString()}`,
@@ -105,8 +89,7 @@ async function silentRefresh(): Promise {
async function logout(): Promise {
tokenManager.clearAuthToken();
- removeToken("site-id-key");
- removeToken("site-theme-key");
+ clearDeviceId();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
diff --git a/apps/cs/src/entities/auth/lib/device-id.ts b/apps/cs/src/entities/auth/lib/device-id.ts
new file mode 100644
index 000000000..3683c7189
--- /dev/null
+++ b/apps/cs/src/entities/auth/lib/device-id.ts
@@ -0,0 +1,20 @@
+import { v4 as uuidv4 } from "uuid";
+
+const DEVICE_ID_KEY = "x-device-id";
+
+export function getOrCreateDeviceId(): string {
+ if (typeof window === "undefined") return "";
+
+ const existing = localStorage.getItem(DEVICE_ID_KEY);
+ if (existing) return existing;
+
+ const id = uuidv4();
+ localStorage.setItem(DEVICE_ID_KEY, id);
+ return id;
+}
+
+export function clearDeviceId(): void {
+ if (typeof window === "undefined") return;
+
+ localStorage.removeItem(DEVICE_ID_KEY);
+}
diff --git a/apps/dm/.env.local b/apps/dm/.env.local
index 4cef9d965..5d9ffae06 100644
--- a/apps/dm/.env.local
+++ b/apps/dm/.env.local
@@ -6,18 +6,18 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://localhost:3015
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
-NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
-NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
-NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
-# NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
-# NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
+SERVER_API_URL = http://dev.onasys.co.kr:8000
+CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
+FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service
+WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service
NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
-NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
-# NEXT_PUBLIC_GATEWAY_URL=http://localhost:8883 # local
+SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
+GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
+# GATEWAY_URL=http://localhost:8883 # local
CONTENTS_INTRO = 31
CONTENTS_EXAMPLE = 32
diff --git a/apps/dm/.env.mindone b/apps/dm/.env.mindone
index 1a11ec6a9..12ca17853 100644
--- a/apps/dm/.env.mindone
+++ b/apps/dm/.env.mindone
@@ -6,20 +6,17 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11315
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
-NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
-NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
-NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
-NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
-NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
-#NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
-#NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
+SERVER_API_URL = http://dev.onasys.co.kr:8000
+CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
+FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
+WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
+NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
+NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
-NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
-NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
-# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.128:8883/sewerage-service/api # 최용성
-#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service/api # 박이정
+SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
+GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
CONTENTS_INTRO = 31
CONTENTS_EXAMPLE = 32
diff --git a/apps/dm/.env.production b/apps/dm/.env.production
index b09c7da8a..fbe11a3a9 100644
--- a/apps/dm/.env.production
+++ b/apps/dm/.env.production
@@ -7,17 +7,17 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
#NEXT_PUBLIC_PROXY_HOST = https://devreuse.hasuon.or.kr
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = https://devqueen.hasuon.or.kr/auth/login
-NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
-NEXT_PUBLIC_CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
-NEXT_PUBLIC_FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
-NEXT_PUBLIC_WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
+SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
+CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
+FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
+WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
NEXT_PUBLIC_GEOSERVER_URL = http://172.29.143.87:8080/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://172.29.143.87:8080/geoserver_swl
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
-NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact
NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x}
-NEXT_PUBLIC_GATEWAY_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
+SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
+GATEWAY_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
CONTENTS_INTRO = 24
CONTENTS_EXAMPLE = 25
diff --git a/apps/dm/app/(with-layout)/apply/dmdco/page.tsx b/apps/dm/app/(with-layout)/apply/dmdco/page.tsx
index 86262be44..fb893c8fb 100644
--- a/apps/dm/app/(with-layout)/apply/dmdco/page.tsx
+++ b/apps/dm/app/(with-layout)/apply/dmdco/page.tsx
@@ -5,11 +5,13 @@
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { useRouter } from "next/navigation";
+import { sha512 } from "js-sha512";
import SubPageHeader from "@/components/SubPageHeader";
import PostcodeModal from "@/components/PostcodeModal";
import type { AddrItem } from "@/app/store/sdmMng/postcode-service";
import { useDmStore } from "@/app/store";
import { fetchType, getFetch, postFetch, putFetch } from "@/components/ui/api";
+import type { DemandCompType } from "@common/business_components/ui/sdmInsertForm/form";
import { getCookie } from "@common/components/lib";
type Option = { value: string; label: string };
@@ -165,7 +167,7 @@ function Hr({ className = "" }: { className?: string }) {
export default function DemandPlaceRegisterPage() {
const router = useRouter();
- const { Common, initializeCommonData } = useDmStore((state) => state);
+ const { Common, initializeCommonData, actionLogin } = useDmStore((state) => state);
const { selectSidoData, selectSigunData } = Common;
const [editMode, setEditMode] = useState(false);
@@ -424,7 +426,7 @@ export default function DemandPlaceRegisterPage() {
};
if (password) {
- payload.dmdAplyPswd = password;
+ payload.dmdAplyPswd = sha512.hex(password);
}
try {
@@ -443,21 +445,20 @@ export default function DemandPlaceRegisterPage() {
alert("수요처 수정 완료!");
} else {
- const response = await postFetch("/reuseFc/request", payload);
- const responseJson = await response.json().catch(() => null);
- const savedUser = responseJson?.data ?? payload;
+ await postFetch("/reuseFc/request", payload);
- if (typeof window !== "undefined") {
- const nextSessionUser = {
- ...(sessionUser ?? {}),
- ...savedUser,
- };
- delete nextSessionUser.dmdAplyPswd;
- sessionStorage.setItem("user", JSON.stringify(nextSessionUser));
+ const loginSuccess = await actionLogin({
+ dmdInfoId: fullDmdInfoId,
+ dmdAplyPswd: sha512.hex(password),
+ } as DemandCompType);
+
+ if (!loginSuccess) {
+ alert("수요처 등록은 완료되었지만 자동 로그인에 실패하였습니다. 다시 로그인해 주세요.");
+ return;
}
alert("수요처 등록 완료!");
- router.push("/apply/dmdinfo"); // 수요정보등록
+ router.push("/apply/dmdinfo");
}
setPw("");
diff --git a/apps/dm/app/store/index.tsx b/apps/dm/app/store/index.tsx
index 47170b3db..4291adfba 100644
--- a/apps/dm/app/store/index.tsx
+++ b/apps/dm/app/store/index.tsx
@@ -1,6 +1,6 @@
'use client'
-import { type ReactNode, createContext, useContext, useRef } from 'react'
+import { type ReactNode, createContext, useContext, useEffect, useRef } from 'react'
import { useStore } from 'zustand'
import { type DmStore, StoreZus } from './store'
@@ -23,6 +23,11 @@ export const StoreProvider = ({
// Create store instance if not initialized
storeRef.current = StoreZus() as StoreApi
}
+
+ useEffect(() => {
+ storeRef.current?.getState().hydrateLoginUser();
+ }, [])
+
return (
{children}
@@ -39,4 +44,4 @@ export const useDmStore = (
}
return useStore(storeContext, selector)
-}
\ No newline at end of file
+}
diff --git a/apps/dm/app/store/sdmMng/index.tsx b/apps/dm/app/store/sdmMng/index.tsx
index c51209515..904c0a6b0 100644
--- a/apps/dm/app/store/sdmMng/index.tsx
+++ b/apps/dm/app/store/sdmMng/index.tsx
@@ -43,6 +43,20 @@ export const sdmInfoFormSchema = z.object({
export type SdmInfoFormData = z.infer;
+export type LoginUser = {
+ dmdCoNm?: string;
+ dmdInfoId?: string;
+ dmdCoSn?: number;
+ zip?: string;
+ daddr?: string;
+ daddr2?: string;
+ telno?: string;
+ ctpvCd?: string;
+ sggCd?: string;
+ rgtr?: string;
+ [key: string]: unknown;
+};
+
const formatTelno = (telno?: string | null) => {
const digits = (telno ?? '').replace(/\D/g, '');
@@ -512,7 +526,7 @@ const fetchUpdateSdmInfoData = async (data: SdmInfoFormData): Promise =
}
}
-const postSdmLoginData = async (data: DemandCompType) => {
+const postSdmLoginData = async (data: DemandCompType): Promise => {
try {
const res = await postFetch("/reuseFc/request/login", data);
const jsonData = await res.json();
@@ -539,9 +553,23 @@ const postSdmLoginData = async (data: DemandCompType) => {
} catch (error) {
console.log(error);
alert("수요처 로그인에 실패하였습니다.");
+ return false;
}
}
+const getStoredLoginUser = (): LoginUser | null => {
+ if (typeof window === "undefined") return null;
+
+ const sessionUser = sessionStorage.getItem("user");
+ if (!sessionUser) return null;
+
+ try {
+ return JSON.parse(sessionUser) as LoginUser;
+ } catch {
+ return null;
+ }
+};
+
const getDemandCompList = async (searchText: string, pageCnt: number) => {
try {
const url = '/reuseFc/request/' + (searchText ? searchText : '') + '/' + pageCnt;
@@ -586,11 +614,12 @@ const fetchGetSimilarCaseList = async (part: string, value: number | string) =>
====================================*/
export type SdmActions = {
initializeSdmData: () => void;
+ hydrateLoginUser: () => void;
insertSdmData: (data: DemandCompType) => Promise;
insertSdmInfoData: (data: SdmInfoFormData) => Promise;
updateSdmInfoData: (data: SdmInfoFormData) => Promise;
getDemandCompList: (searchText: string, pageCnt: number) => Promise;
- actionLogin: (data: DemandCompType) => Promise;
+ actionLogin: (data: DemandCompType) => Promise;
getSdmInfoStatusList: (dmdInfoId: string) => Promise;
getSimilarCaseList: (part: string, value: number | string) => Promise;
actionLogout: () => Promise;
@@ -616,6 +645,7 @@ export type SdmType = {
// 로그인관련
successNextPage: boolean;
successLogin: boolean;
+ loginUser: LoginUser | null;
}
}
@@ -637,6 +667,7 @@ export const SdmState: SdmType = {
// 로그인관련
successNextPage: false,
successLogin: false,
+ loginUser: null,
}
}
@@ -644,6 +675,15 @@ export const SdmReducer = (set: any): SdmActions => {
return {
initializeSdmData: async () => {
},
+ hydrateLoginUser: () => {
+ const loginUser = getStoredLoginUser();
+ set((state: SdmType) => ({
+ Sdm: {
+ ...state.Sdm,
+ loginUser,
+ }
+ }))
+ },
insertSdmData: async (data: DemandCompType) => {
const preRes = await getSdmInfoStatusList(data.dmdInfoId);
@@ -743,14 +783,20 @@ export const SdmReducer = (set: any): SdmActions => {
}))
},
- actionLogin: async (data: DemandCompType) => {
+ actionLogin: async (data: DemandCompType): Promise => {
const loginSuccess = await postSdmLoginData(data);
+ const loginUser = loginSuccess ? getStoredLoginUser() : undefined;
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
successLogin: loginSuccess,
+ loginUser:
+ loginSuccess
+ ? loginUser
+ : state.Sdm.loginUser,
}
}))
+ return loginSuccess;
},
// 유사사례 조회
@@ -792,6 +838,7 @@ export const SdmReducer = (set: any): SdmActions => {
Sdm: {
...state.Sdm,
successLogin: false,
+ loginUser: null,
}
}))
},
diff --git a/apps/dm/components/TopNav.tsx b/apps/dm/components/TopNav.tsx
index 5e24ead78..741dbed2a 100644
--- a/apps/dm/components/TopNav.tsx
+++ b/apps/dm/components/TopNav.tsx
@@ -8,9 +8,6 @@ import LoginModal from "@/components/LoginModal";
import { fetchAnonymousMenu, isMenuActive, type MenuItem } from "@/app/store/common";
import { useDmStore } from "@/app/store";
-// const LS_TOKEN_KEY = "reuse_accessToken";
-const LS_USER_KEY = "user";
-
const DEFAULT_NAV_ITEMS: Pick