WIP: 전자결재 로직 수정

This commit is contained in:
2026-07-07 09:18:50 +09:00
parent 2f11b1185b
commit 3167a9a6e3
38 changed files with 2669 additions and 5605 deletions
@@ -1,7 +0,0 @@
import { client } from "@/shared/api";
export async function processElaprPrgrs(elaprPrgrsId: string, atrzPrgrsSttsComCd = "ATRZ_STTS_003"): Promise<void> {
await client.put(`/as-elapr-service/api/v1/elapr-prgrs-stts/${elaprPrgrsId}/process`, {
atrzPrgrsSttsComCd,
});
}
@@ -0,0 +1,102 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
ElaprApproverChangeRequestDto,
ElaprApproverInsertRequestDto,
ElaprPrgrsSttsMyDocSearchParams,
ElaprPrgrsSttsResponseDto,
ElaprPrgrsSttsSaveRequestDto,
ElaprPrgrsSttsService,
ElaprProcessRequestDto,
} from "../model/elapr-prgrs-stts-interface";
const BASE = ENDPOINT.AS_ELAPR_SERVICE.ELAPR_PRGRS_STTS;
async function findAllByDmndId(elaprDmndId?: string): Promise<ElaprPrgrsSttsResponseDto[]> {
const response = await client.get<ElaprPrgrsSttsResponseDto[]>(BASE, {
params: elaprDmndId ? { elaprDmndId } : undefined,
});
return response.data;
}
async function findById(elaprPrgrsId: string): Promise<ElaprPrgrsSttsResponseDto> {
const response = await client.get<ElaprPrgrsSttsResponseDto>(`${BASE}/${elaprPrgrsId}`);
return response.data;
}
async function save(body: ElaprPrgrsSttsSaveRequestDto): Promise<ElaprPrgrsSttsResponseDto> {
const response = await client.post<ElaprPrgrsSttsResponseDto>(BASE, body);
return response.data;
}
async function process(
elaprPrgrsId: string,
body: ElaprProcessRequestDto,
): Promise<ElaprPrgrsSttsResponseDto> {
const response = await client.put<ElaprPrgrsSttsResponseDto>(
`${BASE}/${elaprPrgrsId}/process`,
body,
);
return response.data;
}
async function processByDocNoAndAprvrUuid(
elaprDocNo: string,
aprvrUuid: string,
body: ElaprProcessRequestDto,
): Promise<ElaprPrgrsSttsResponseDto> {
const response = await client.put<ElaprPrgrsSttsResponseDto>(
`${BASE}/doc/${elaprDocNo}/aprvr/${aprvrUuid}/process`,
body,
);
return response.data;
}
async function deletePrgrsStts(elaprPrgrsId: string): Promise<void> {
await client.delete(`${BASE}/${elaprPrgrsId}`);
}
async function updateApprover(
elaprPrgrsId: string,
body: ElaprApproverChangeRequestDto,
): Promise<ElaprPrgrsSttsResponseDto> {
const response = await client.put<ElaprPrgrsSttsResponseDto>(
`${BASE}/${elaprPrgrsId}/approver`,
body,
);
return response.data;
}
async function insertApprover(
elaprDmndId: string,
body: ElaprApproverInsertRequestDto,
): Promise<ElaprPrgrsSttsResponseDto> {
const response = await client.post<ElaprPrgrsSttsResponseDto>(
`${BASE}/${elaprDmndId}/approver/insert`,
body,
);
return response.data;
}
async function findMyElaperDocList(
params: ElaprPrgrsSttsMyDocSearchParams,
): Promise<PageResponse<ElaprPrgrsSttsResponseDto>> {
const response = await client.get<PageResponse<ElaprPrgrsSttsResponseDto>>(`${BASE}/my/doc`, {
params,
});
return response.data;
}
export const elaprPrgrsSttsService = {
findAllByDmndId,
findById,
save,
process,
processByDocNoAndAprvrUuid,
delete: deletePrgrsStts,
updateApprover,
insertApprover,
findMyElaperDocList,
} satisfies ElaprPrgrsSttsService;
+129
View File
@@ -9,6 +9,13 @@ import type {
ElaprAplnUpdateRequest,
} from "../model/elapr-apln-interface";
import type { ElaprDmndSaveRequestDto, ElaprDmndSearchDto } from "../model/elapr-dmnd-interface";
import type {
ElaprApproverChangeRequestDto,
ElaprApproverInsertRequestDto,
ElaprPrgrsSttsMyDocSearchParams,
ElaprPrgrsSttsSaveRequestDto,
ElaprProcessRequestDto,
} from "../model/elapr-prgrs-stts-interface";
import type {
ElaprTmpltSaveRequest,
ElaprTmpltSearchParams,
@@ -17,11 +24,18 @@ import type {
import { elaprAplnService } from "./elapr-apln-service";
import { elaprDmndService } from "./elapr-dmnd-service";
import { elaprPrgrsSttsService } from "./elapr-prgrs-stts-service";
import { elaprTmpltService } from "./elapr-tmplt-service";
const elaprTmpltBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT] as const;
const elaprAplnBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN] as const;
const elaprDmndBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND] as const;
const elaprPrgrsSttsBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_PRGRS_STTS] as const;
function invalidateElaprPrgrsSttsQueries(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: elaprPrgrsSttsBaseKey });
queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey });
}
export function useElaprTmpltPage(params: ElaprTmpltSearchParams) {
return useQuery({
@@ -230,3 +244,118 @@ export function useRemoveElaprDmnd() {
},
});
}
export function useElaprPrgrsSttsByDmndId(elaprDmndId: string | null | undefined) {
return useQuery({
queryKey: [...elaprPrgrsSttsBaseKey, "findAllByDmndId", elaprDmndId],
queryFn: () => elaprPrgrsSttsService.findAllByDmndId(elaprDmndId!),
enabled: Boolean(elaprDmndId),
});
}
export function useElaprPrgrsStts(elaprPrgrsId: string | null | undefined) {
return useQuery({
queryKey: [...elaprPrgrsSttsBaseKey, elaprPrgrsId],
queryFn: () => elaprPrgrsSttsService.findById(elaprPrgrsId!),
enabled: Boolean(elaprPrgrsId),
});
}
export function useElaprPrgrsSttsMyDocPage(params: ElaprPrgrsSttsMyDocSearchParams) {
return useQuery({
queryKey: [...elaprPrgrsSttsBaseKey, "findMyElaperDocList", params],
queryFn: () => elaprPrgrsSttsService.findMyElaperDocList(params),
});
}
export function useSaveElaprPrgrsStts() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: ElaprPrgrsSttsSaveRequestDto) => elaprPrgrsSttsService.save(body),
onSuccess: () => {
invalidateElaprPrgrsSttsQueries(queryClient);
},
});
}
export function useProcessElaprPrgrsStts() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
elaprPrgrsId,
body,
}: {
elaprPrgrsId: string;
body: ElaprProcessRequestDto;
}) => elaprPrgrsSttsService.process(elaprPrgrsId, body),
onSuccess: () => {
invalidateElaprPrgrsSttsQueries(queryClient);
},
});
}
export function useProcessElaprPrgrsSttsByDoc() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
elaprDocNo,
aprvrUuid,
body,
}: {
elaprDocNo: string;
aprvrUuid: string;
body: ElaprProcessRequestDto;
}) => elaprPrgrsSttsService.processByDocNoAndAprvrUuid(elaprDocNo, aprvrUuid, body),
onSuccess: () => {
invalidateElaprPrgrsSttsQueries(queryClient);
},
});
}
export function useDeleteElaprPrgrsStts() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (elaprPrgrsId: string) => elaprPrgrsSttsService.delete(elaprPrgrsId),
onSuccess: () => {
invalidateElaprPrgrsSttsQueries(queryClient);
},
});
}
export function useUpdateElaprPrgrsSttsApprover() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
elaprPrgrsId,
body,
}: {
elaprPrgrsId: string;
body: ElaprApproverChangeRequestDto;
}) => elaprPrgrsSttsService.updateApprover(elaprPrgrsId, body),
onSuccess: () => {
invalidateElaprPrgrsSttsQueries(queryClient);
},
});
}
export function useInsertElaprPrgrsSttsApprover() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
elaprDmndId,
body,
}: {
elaprDmndId: string;
body: ElaprApproverInsertRequestDto;
}) => elaprPrgrsSttsService.insertApprover(elaprDmndId, body),
onSuccess: () => {
invalidateElaprPrgrsSttsQueries(queryClient);
},
});
}
+91 -48
View File
@@ -1,48 +1,91 @@
export { elaprAplnService } from "./api/elapr-apln-service";
export { elaprDmndService } from "./api/elapr-dmnd-service";
export { elaprTmpltService } from "./api/elapr-tmplt-service";
export {
useCreateElaprTmplt,
useDeleteElaprApln,
useDeleteElaprTmplt,
useElaprApln,
useElaprAplnPage,
useElaprDmnd,
useElaprDmndPage,
useElaprDocPrgrsByDocNo,
useElaprTmplt,
useElaprTmpltPage,
useRemoveElaprDmnd,
useReorderElaprApln,
useSaveElaprDmnd,
useSaveMultiElaprApln,
useUpdateElaprApln,
useUpdateElaprDmnd,
useUpdateElaprDmndStatus,
useUpdateElaprTmplt,
} from "./api/queries";
export {
ELAPR_APLN_SAVE_MODE,
type ElaprApln,
type ElaprAplnListParams,
type ElaprAplnReorderItem,
type ElaprAplnReorderRequest,
type ElaprAplnSaveMode,
type ElaprAplnSaveRequest,
type ElaprAplnService,
type ElaprAplnUpdateRequest,
} from "./model/elapr-apln-interface";
export type {
ElaprDmndResponseDto,
ElaprDmndSaveRequestDto,
ElaprDmndSearchDto,
ElaprDmndService,
ElaprDocPrgrsResponseDto,
} from "./model/elapr-dmnd-interface";
export type {
ElaprTmplt,
ElaprTmpltSaveRequest,
ElaprTmpltSearchParams,
ElaprTmpltService,
ElaprTmpltUpdateRequest,
} from "./model/elapr-tmplt-interface";
export { elaprAplnService } from "./api/elapr-apln-service";
export { elaprDmndService } from "./api/elapr-dmnd-service";
export { elaprPrgrsSttsService } from "./api/elapr-prgrs-stts-service";
export { elaprTmpltService } from "./api/elapr-tmplt-service";
export {
useCreateElaprTmplt,
useDeleteElaprApln,
useDeleteElaprPrgrsStts,
useDeleteElaprTmplt,
useElaprApln,
useElaprAplnPage,
useElaprDmnd,
useElaprDmndPage,
useElaprDocPrgrsByDocNo,
useElaprPrgrsStts,
useElaprPrgrsSttsByDmndId,
useElaprPrgrsSttsMyDocPage,
useElaprTmplt,
useElaprTmpltPage,
useInsertElaprPrgrsSttsApprover,
useProcessElaprPrgrsStts,
useProcessElaprPrgrsSttsByDoc,
useRemoveElaprDmnd,
useReorderElaprApln,
useSaveElaprDmnd,
useSaveElaprPrgrsStts,
useSaveMultiElaprApln,
useUpdateElaprApln,
useUpdateElaprDmnd,
useUpdateElaprDmndStatus,
useUpdateElaprPrgrsSttsApprover,
useUpdateElaprTmplt,
} from "./api/queries";
export {
assignAtrzSeqNumbers,
buildSaveMultiPayload,
hasUnsavedChanges,
} from "./lib/apln-save-utils";
export {
getVisibleRows,
validateAllRowsForSave,
validateAplnRow,
validateAtrzOrder,
validateUniqueSeqNo,
} from "./lib/apln-validators";
export {
buildElaprPrgrsSttsListFromApln,
buildElaprPrgrsSttsListFromAplnRows,
} from "./lib/dmnd-save-utils";
export {
findCurrentUserPendingApprovalRequest,
hasCurrentUserPendingApprovalRequest,
} from "./lib/approval-request-utils";
export { createDraftRowKey, useAplnRows } from "./lib/use-apln-rows";
export { useDndSensors } from "./lib/use-dnd-sensors";
export {
ELAPR_APLN_SAVE_MODE,
type ElaprApln,
type ElaprAplnListParams,
type ElaprAplnReorderItem,
type ElaprAplnReorderRequest,
type ElaprAplnSaveMode,
type ElaprAplnSaveRequest,
type ElaprAplnService,
type ElaprAplnUpdateRequest,
} from "./model/elapr-apln-interface";
export type { ElaprAplnRow } from "./model/elapr-apln-row-types";
export type {
ElaprDmndResponseDto,
ElaprDmndSaveRequestDto,
ElaprDmndSearchDto,
ElaprDmndService,
ElaprDocPrgrsResponseDto,
} from "./model/elapr-dmnd-interface";
export type {
ElaprApproverChangeRequestDto,
ElaprApproverInsertRequestDto,
ElaprPrgrsSttsMyDocSearchParams,
ElaprPrgrsSttsResponseDto,
ElaprPrgrsSttsSaveRequestDto,
ElaprPrgrsSttsSearchDto,
ElaprPrgrsSttsService,
ElaprProcessRequestDto,
} from "./model/elapr-prgrs-stts-interface";
export type {
ElaprTmplt,
ElaprTmpltSaveRequest,
ElaprTmpltSearchParams,
ElaprTmpltService,
ElaprTmpltUpdateRequest,
} from "./model/elapr-tmplt-interface";
@@ -1,8 +1,16 @@
import { ELAPR_APLN_SAVE_MODE, type ElaprApln, type ElaprAplnSaveRequest } from "@/entities/elapr";
import {
ELAPR_APLN_SAVE_MODE,
type ElaprApln,
type ElaprAplnSaveRequest,
} from "../model/elapr-apln-interface";
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
import { assignAtrzSeqNumbers } from "./assign-atrz-seq";
import { getVisibleRows } from "./validators";
import type { ElaprAplnRow } from "../model/apln-row-types";
import { getVisibleRows } from "./apln-validators";
/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */
export function assignAtrzSeqNumbers(rows: ElaprAplnRow[]): ElaprAplnRow[] {
return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 }));
}
function rowDiffersFromServer(row: ElaprAplnRow, serverRow: ElaprApln): boolean {
return (
@@ -22,6 +30,25 @@ function shouldIncludeUpdate(row: ElaprAplnRow, serverRows: ElaprApln[]): boolea
return rowDiffersFromServer(row, serverRow);
}
export function hasUnsavedChanges(rows: ElaprAplnRow[], serverRows: ElaprApln[]): boolean {
if (rows.some((row) => row.isDeleted && row.aplnId)) return true;
if (rows.some((row) => row.isNew && !row.isDeleted)) return true;
const visibleWithSeq = assignAtrzSeqNumbers(getVisibleRows(rows));
for (const row of visibleWithSeq) {
if (row.isNew) continue;
if (!row.aplnId) continue;
if (row.isDirty) return true;
const serverRow = serverRows.find((s) => s.aplnId === row.aplnId);
if (!serverRow) continue;
if (rowDiffersFromServer(row, serverRow)) return true;
}
return false;
}
export function buildSaveMultiPayload(
rows: ElaprAplnRow[],
elaprTmpltId: string,
@@ -1,4 +1,4 @@
import type { ElaprAplnRow } from "../model/apln-row-types";
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
const ATRZ_RECEIVER_CD = "ATRZ01";
const ATRZ_FINAL_CD = "ATRZ99";
@@ -0,0 +1,25 @@
import type { ElaprDocPrgrsResponseDto } from "../model/elapr-dmnd-interface";
export function hasCurrentUserPendingApprovalRequest(
list: ElaprDocPrgrsResponseDto[],
currentUserUuid: string | undefined,
): boolean {
if (!currentUserUuid) return false;
return list.some(
(row) =>
row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
);
}
export function findCurrentUserPendingApprovalRequest(
list: ElaprDocPrgrsResponseDto[],
currentUserUuid: string | undefined,
): ElaprDocPrgrsResponseDto | undefined {
if (!currentUserUuid) return undefined;
return list.find(
(row) =>
row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
);
}
@@ -0,0 +1,43 @@
import type { ElaprApln } from "../model/elapr-apln-interface";
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
import type { ElaprPrgrsSttsSaveRequestDto } from "../model/elapr-prgrs-stts-interface";
import { getVisibleRows } from "./apln-validators";
function toPrgrsSttsItem(
aprvrUuid: string | undefined,
atrzSeComCd: string | undefined,
atrzSeqNo: number | undefined,
): ElaprPrgrsSttsSaveRequestDto {
if (!aprvrUuid?.trim()) {
throw new Error("결재자가 지정되지 않은 행이 있습니다.");
}
if (!atrzSeComCd?.trim()) {
throw new Error("결재구분이 지정되지 않은 행이 있습니다.");
}
if (atrzSeqNo == null) {
throw new Error("결재순서번호가 지정되지 않은 행이 있습니다.");
}
return {
aprvrUuid,
atrzSeComCd,
atrzSeqNo,
};
}
/** 서식 결재선 API 응답 → 기안 요청용 진행상태 목록 */
export function buildElaprPrgrsSttsListFromApln(
items: ElaprApln[],
): ElaprPrgrsSttsSaveRequestDto[] {
return items.map((item) => toPrgrsSttsItem(item.aprvrUuid, item.atrzSeComCd, item.atrzSeqNo));
}
/** 결재선 그리드 행 → 기안 요청용 진행상태 목록 (서버가 seq 기준 상태 자동 결정) */
export function buildElaprPrgrsSttsListFromAplnRows(
rows: ElaprAplnRow[],
): ElaprPrgrsSttsSaveRequestDto[] {
return getVisibleRows(rows).map((row) =>
toPrgrsSttsItem(row.aprvrUuid, row.atrzSeComCd, row.atrzSeqNo),
);
}
@@ -4,12 +4,11 @@ import { useCallback, useEffect, useState } from "react";
import { arrayMove } from "@dnd-kit/sortable";
import type { ElaprApln } from "@/entities/elapr";
import type { ElaprApln } from "../model/elapr-apln-interface";
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
import type { ElaprAplnRow } from "../model/apln-row-types";
import { assignAtrzSeqNumbers } from "./assign-atrz-seq";
import { getVisibleRows } from "./validators";
import { assignAtrzSeqNumbers } from "./apln-save-utils";
import { getVisibleRows } from "./apln-validators";
function toRow(item: ElaprApln): ElaprAplnRow {
return {
@@ -101,7 +100,13 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
return prev.map((row) => {
if (row.rowKey !== editingRowKey) return row;
if (row.snapshot) {
const { snapshot: _s, isNew: _n, isDirty: _d, isDeleted: _del, ...restored } = row.snapshot;
const {
snapshot: _s,
isNew: _n,
isDirty: _d,
isDeleted: _del,
...restored
} = row.snapshot;
return { ...restored, isNew: false, isDirty: false, isDeleted: false };
}
return { ...row, isDirty: false };
@@ -110,19 +115,16 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
setEditingRowKey(null);
}, [editingRowKey]);
const commitEdit = useCallback(
(rowKey: string) => {
setRows((prev) =>
prev.map((row) => {
if (row.rowKey !== rowKey) return row;
const { snapshot: _s, ...committed } = row;
return { ...committed, isDirty: true };
}),
);
setEditingRowKey(null);
},
[],
);
const commitEdit = useCallback((rowKey: string) => {
setRows((prev) =>
prev.map((row) => {
if (row.rowKey !== rowKey) return row;
const { snapshot: _s, ...committed } = row;
return { ...committed, isDirty: true };
}),
);
setEditingRowKey(null);
}, []);
const markDeleted = useCallback((rowKey: string) => {
setRows((prev) => {
@@ -158,7 +160,10 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
const visibleKeys = new Set(reorderedVisible.map((r) => r.rowKey));
const deletedRows = prev.filter((r) => r.isDeleted);
const merged = [...reorderedVisible, ...deletedRows.filter((r) => !visibleKeys.has(r.rowKey))];
const merged = [
...reorderedVisible,
...deletedRows.filter((r) => !visibleKeys.has(r.rowKey)),
];
payloadRows = reorderedVisible;
return merged;
@@ -1,3 +1,5 @@
"use client";
import { KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
@@ -1,4 +1,4 @@
import type { ElaprApln } from "@/entities/elapr";
import type { ElaprApln } from "./elapr-apln-interface";
/** 그리드 로컬 행 — API 응답 + UI 보조 필드 */
export interface ElaprAplnRow extends Omit<ElaprApln, "aplnId"> {
@@ -1,11 +1,13 @@
import type { PageableParams, PageResponse } from "@/shared/model";
import type { ElaprPrgrsSttsSaveRequestDto } from "./elapr-prgrs-stts-interface";
/** 백엔드 ElaprDmndSaveRequestDto */
export interface ElaprDmndSaveRequestDto {
elaprDocNo: string;
elaprTmpltId: string;
dmndTtl: string;
dmndCn: string;
elaprPrgrsSttsList?: ElaprPrgrsSttsSaveRequestDto[];
}
/** 백엔드 ElaprDmndResponseDto */
@@ -13,7 +15,6 @@ export interface ElaprDmndResponseDto {
elaprDmndId: string;
rqstrUuid?: string;
elaprDocNo: string;
elaprTmpltId: string;
dmndTtl: string;
dmndCn: string;
atrzSttsComCd: string;
@@ -0,0 +1,78 @@
import type { PageableParams, PageResponse } from "@/shared/model";
/** 백엔드 ElaprPrgrsSttsResponseDto */
export interface ElaprPrgrsSttsResponseDto {
elaprPrgrsId: string;
elaprDmndId?: string;
aprvrUuid?: string;
atrzSeComCd?: string;
atrzSeComNm?: string;
atrzSeqNo?: number;
atrzPrgrsSttsComCd?: string;
atrzPrgrsSttsComNm?: string;
rjctRsn?: string;
atrzDt?: string;
}
/** 백엔드 ElaprPrgrsSttsSaveRequestDto */
export interface ElaprPrgrsSttsSaveRequestDto {
elaprDmndId?: string;
aprvrUuid: string;
atrzSeComCd: string;
atrzSeqNo: number;
atrzPrgrsSttsComCd?: string;
}
/** 백엔드 ElaprPrgrsSttsSearchDto */
export interface ElaprPrgrsSttsSearchDto {
atrzSeComCd: string;
atrzSeqNo: number;
atrzPrgrsSttsComCd?: string;
}
/** 백엔드 ElaprPrgrsSttsSearchDto + Pageable */
export interface ElaprPrgrsSttsMyDocSearchParams extends ElaprPrgrsSttsSearchDto, PageableParams {}
/** 백엔드 ElaprProcessRequestDto */
export interface ElaprProcessRequestDto {
atrzPrgrsSttsComCd: string;
rjctRsn?: string;
finalApprovalYn?: boolean;
}
/** 백엔드 ElaprApproverChangeRequestDto */
export interface ElaprApproverChangeRequestDto {
aprvrUuid: string;
}
/** 백엔드 ElaprApproverInsertRequestDto */
export interface ElaprApproverInsertRequestDto {
aprvrUuid: string;
atrzSeComCd: string;
atrzSeqNo: number;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface ElaprPrgrsSttsService {
findAllByDmndId(elaprDmndId?: string): Promise<ElaprPrgrsSttsResponseDto[]>;
findById(elaprPrgrsId: string): Promise<ElaprPrgrsSttsResponseDto>;
save(body: ElaprPrgrsSttsSaveRequestDto): Promise<ElaprPrgrsSttsResponseDto>;
process(elaprPrgrsId: string, body: ElaprProcessRequestDto): Promise<ElaprPrgrsSttsResponseDto>;
processByDocNoAndAprvrUuid(
elaprDocNo: string,
aprvrUuid: string,
body: ElaprProcessRequestDto,
): Promise<ElaprPrgrsSttsResponseDto>;
delete(elaprPrgrsId: string): Promise<void>;
updateApprover(
elaprPrgrsId: string,
body: ElaprApproverChangeRequestDto,
): Promise<ElaprPrgrsSttsResponseDto>;
insertApprover(
elaprDmndId: string,
body: ElaprApproverInsertRequestDto,
): Promise<ElaprPrgrsSttsResponseDto>;
findMyElaperDocList(
params: ElaprPrgrsSttsMyDocSearchParams,
): Promise<PageResponse<ElaprPrgrsSttsResponseDto>>;
}
@@ -1,6 +0,0 @@
import type { ElaprAplnRow } from "../model/apln-row-types";
/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */
export function assignAtrzSeqNumbers(rows: ElaprAplnRow[]): ElaprAplnRow[] {
return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 }));
}
@@ -1,32 +0,0 @@
import type { ElaprApln } from "@/entities/elapr";
import { assignAtrzSeqNumbers } from "./assign-atrz-seq";
import { getVisibleRows } from "./validators";
import type { ElaprAplnRow } from "../model/apln-row-types";
function rowDiffersFromServer(row: ElaprAplnRow, serverRow: ElaprApln): boolean {
return (
row.aprvrUuid !== serverRow.aprvrUuid ||
row.atrzSeComCd !== serverRow.atrzSeComCd ||
row.atrzSeqNo !== serverRow.atrzSeqNo
);
}
export function hasUnsavedChanges(rows: ElaprAplnRow[], serverRows: ElaprApln[]): boolean {
if (rows.some((row) => row.isDeleted && row.aplnId)) return true;
if (rows.some((row) => row.isNew && !row.isDeleted)) return true;
const visibleWithSeq = assignAtrzSeqNumbers(getVisibleRows(rows));
for (const row of visibleWithSeq) {
if (row.isNew) continue;
if (!row.aplnId) continue;
if (row.isDirty) return true;
const serverRow = serverRows.find((s) => s.aplnId === row.aplnId);
if (!serverRow) continue;
if (rowDiffersFromServer(row, serverRow)) return true;
}
return false;
}
@@ -7,7 +7,19 @@ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import { useElaprAplnPage, useSaveMultiElaprApln } from "@/entities/elapr";
import {
buildSaveMultiPayload,
type ElaprAplnRow,
hasUnsavedChanges,
useAplnRows,
useDndSensors,
useElaprAplnPage,
useSaveMultiElaprApln,
validateAllRowsForSave,
validateAplnRow,
validateAtrzOrder,
validateUniqueSeqNo,
} from "@/entities/elapr";
import {
Button,
Skeleton,
@@ -18,17 +30,6 @@ import {
TableRow,
} from "@common/components";
import { buildSaveMultiPayload } from "../lib/build-save-multi-payload";
import { hasUnsavedChanges } from "../lib/has-unsaved-changes";
import { useAplnRows } from "../lib/use-apln-rows";
import { useDndSensors } from "../lib/use-dnd-sensors";
import {
validateAllRowsForSave,
validateAplnRow,
validateAtrzOrder,
validateUniqueSeqNo,
} from "../lib/validators";
import type { ElaprAplnRow } from "../model/apln-row-types";
import type { ReviewerOption } from "../model/reviewer-option";
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
@@ -5,10 +5,9 @@ import { CSS } from "@dnd-kit/utilities";
import { Equal } from "lucide-react";
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
import type { ElaprAplnRow } from "@/entities/elapr";
import { Button, TableCell, TableRow } from "@common/components";
import type { ElaprAplnRow } from "../model/apln-row-types";
interface SortableAplnRowProps {
row: ElaprAplnRow;
displaySeqNo: number;
@@ -74,10 +74,19 @@ function resolveButtonConfigs(actions: FnrsCnsltnViewButtonAction[]): FnrsCnsltn
return actions.map((action) => FNRS_CNSLTN_VIEW_BUTTON_ACTION[action]);
}
interface GetButtonConfigsOptions {
hasPendingApprovalRequest?: boolean;
}
function resolveRoleUser01AtrzButtonActions(
prgrsSttsComCd: string,
atrzSeComCdList: string[],
options?: GetButtonConfigsOptions,
): FnrsCnsltnViewButtonAction[] {
if (prgrsSttsComCd === "TE" && options?.hasPendingApprovalRequest === false) {
return [];
}
const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd];
if (!atrzStatusMap || atrzSeComCdList.length === 0) return [];
@@ -114,12 +123,13 @@ export function getButtonConfigs(
dtlInstCd: string | undefined,
prgrsSttsComCd: string,
atrzSeComCdList?: string[],
options?: GetButtonConfigsOptions,
): FnrsCnsltnViewButtonAction[] {
if (!dtlInstCd) return [];
if (needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd)) {
return resolveButtonConfigs(
resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? []),
resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? [], options),
);
}
@@ -1,4 +1,4 @@
import type { IUserInfo } from "@/shared/store/user-store";
import type { IUserInfo } from "@/shared/store";
import type { FnrsCnsltnMngDetResponseDto } from "@/entities/fnrs-cnsltn";
import { mapFnrsSharDetToFormList } from "@/widgets/pbadms-sprt";
@@ -83,9 +83,7 @@ export function mapDetailToFormData(data: FnrsCnsltnMngDetResponseDto): FnrsCnsl
bgtRfltAmt: item.bgtRfltAmt,
tstmDesignAmt: item.tstmDesignAmt,
})),
fnrsSharList: data.fnrsSharList?.length
? mapFnrsSharDetToFormList(data.fnrsSharList)
: [],
fnrsSharList: data.fnrsSharList?.length ? mapFnrsSharDetToFormList(data.fnrsSharList) : [],
cklstResultList:
cklstResultList?.map((item) => ({
cklstSn: item.cklstSn,
@@ -0,0 +1,5 @@
export interface ReviewerOption {
uuid: string;
name: string;
deptNm: string;
}
@@ -0,0 +1,125 @@
"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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[56rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>검토인력 선택</DialogTitle>
</DialogHeader>
<div className="h-[36rem] overflow-auto">
{isPending ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem]" />
<Skeleton className="h-[16rem]" />
</div>
) : isError ? (
<p className="py-8 text-center text-lg text-muted-foreground">
검토인력 목록을 불러오지 못했습니다.
</p>
) : items.length === 0 ? (
<p className="py-8 text-center text-lg text-muted-foreground">
배치된 검토인력이 없습니다.
</p>
) : (
<Table className="w-full">
<colgroup>
<col width="25%" />
<col width="25%" />
<col width="15%" />
<col width="15%" />
<col width="20%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>성명</TableHead>
<TableHead>부서</TableHead>
<TableHead>직위</TableHead>
<TableHead>직급</TableHead>
<TableHead>선택</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={`${item.atrzSeComCd}-${item.ognzSn}-${item.userUuid}`}>
<TableCell align="center">{item.userUuidNm || item.userUuid}</TableCell>
<TableCell align="center">{item.ognzNm || "-"}</TableCell>
<TableCell align="center">{item.jbpsSeNm || "-"}</TableCell>
<TableCell align="center">{item.jbgdSeNm || "-"}</TableCell>
<TableCell align="center">
<Button type="button" size="sm" onClick={() => handleSelect(item)}>
선택
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<DialogFooter>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { AprvrPickerDialog };
@@ -0,0 +1,206 @@
"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<string | null>(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<ElaprAplnRow>) {
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 (
<section className="card card-border mx-0 min-h-[32rem] flex-1 pt-0">
<div className="card-header flex flex-wrap items-center justify-between gap-3">
<h3 className="text-2xl font-semibold">결재선 관리</h3>
<div className="flex flex-wrap items-center gap-3">
<Button
size="sm"
type="button"
color="green"
disabled={isDisabled || isEditingLocked}
onClick={addRow}
>
결재선 추가
</Button>
</div>
</div>
{aplnQuery.isPending && elaprTmpltId ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem]" />
<Skeleton className="h-[16rem]" />
</div>
) : !elaprTmpltId ? (
<p className="py-8 text-center text-lg text-muted-foreground">결재서식을 선택하세요.</p>
) : visibleRows.length === 0 ? (
<p className="py-8 text-center text-lg text-muted-foreground">
등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요.
</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table className="w-full">
<colgroup>
<col width="10%" />
<col width="30%" />
<col width="10%" />
<col width="10%" />
<col width="30%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-10" />
<TableHead>결재선아이디</TableHead>
<TableHead>결재순서</TableHead>
<TableHead>결재구분</TableHead>
<TableHead>결재자</TableHead>
<TableHead>관리</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<SortableContext
items={visibleRows.map((r) => r.rowKey)}
strategy={verticalListSortingStrategy}
>
{visibleRows.map((row, index) => (
<SortableAplnPickerRow
key={row.rowKey}
row={row}
displaySeqNo={index + 1}
isEditing={editingRowKey === row.rowKey}
isLocked={isEditingLocked}
dragDisabled={isEditingLocked || !row.atrzSeComCd}
onStartEdit={() => startEdit(row.rowKey)}
onSave={() => handleRowSave(row)}
onCancel={cancelEdit}
onDelete={() => handleRowDelete(row)}
onChange={(patch) => handleRowChange(row, patch)}
onPickAprvr={() => {
setPickingRowKey(row.rowKey);
setPickerOpen(true);
}}
/>
))}
</SortableContext>
</TableBody>
</Table>
</DndContext>
)}
<AprvrPickerDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
atrzSeComCd={pickingRow?.atrzSeComCd ?? ""}
onSelect={handleReviewerSelect}
/>
</section>
);
}
export { ElaprAplnPickerGrid };
@@ -7,8 +7,7 @@ import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib";
import { useElaprDocPrgrsByDocNo } from "@/entities/elapr";
import { processElaprPrgrs } from "@/entities/elapr/api/elapr-prgrs-service";
import { elaprPrgrsSttsService, findCurrentUserPendingApprovalRequest, useElaprDocPrgrsByDocNo } from "@/entities/elapr";
import {
Button,
cn,
@@ -55,8 +54,9 @@ function ElaprApprovalDialog({
const orderedApprovers = sortByAtrzSeqNo(elaprDocPrgrsList);
const currentUserPrgrs = elaprDocPrgrsList.find(
(row) => row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(
elaprDocPrgrsList,
currentUserUuid,
);
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
@@ -65,7 +65,9 @@ function ElaprApprovalDialog({
setIsProcessing(true);
try {
await processElaprPrgrs(currentUserPrgrs.elaprPrgrsId, "ATRZ_PRGRS_003");
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
atrzPrgrsSttsComCd: "ATRZ_PRGRS_003",
});
alert("결재가 완료되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
@@ -94,7 +96,9 @@ function ElaprApprovalDialog({
setIsProcessing(true);
try {
await processElaprPrgrs(currentUserPrgrs.elaprPrgrsId, "ATRZ_PRGRS_004");
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
atrzPrgrsSttsComCd: "ATRZ_PRGRS_004",
});
alert("반려 처리가 완료되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
@@ -1,137 +1,163 @@
"use client";
import { Fragment } from "react";
import { type ElaprTmplt, useElaprAplnPage, useElaprTmpltPage } from "@/entities/elapr";
import {
Badge,
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
const PAGE_SIZE = 100;
interface ElaprTmpltPickerDialogProps {
open: boolean;
onClose: () => void;
onSelect: (tmplt: ElaprTmplt) => void;
}
function ElaprTmpltPickerDialog({ open, onClose, onSelect }: ElaprTmpltPickerDialogProps) {
const { data, isPending, isError } = useElaprTmpltPage({
page: 0,
size: PAGE_SIZE,
sort: "regDt,desc",
docSeComCd: "FNRS",
});
const items = data?.content ?? [];
function handleSelect(tmplt: ElaprTmplt) {
onSelect(tmplt);
onClose();
}
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-[80rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>결재선 선택</DialogTitle>
</DialogHeader>
<div className="max-h-[36rem] overflow-auto">
{isPending ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem]" />
<Skeleton className="h-[16rem]" />
</div>
) : isError ? (
<p className="py-8 text-center text-lg text-muted-foreground">
결재서식 목록을 불러오지 못했습니다.
</p>
) : items.length === 0 ? (
<p className="py-8 text-center text-lg text-muted-foreground">
선택 가능한 결재서식이 없습니다.
</p>
) : (
<Table className="w-full">
<colgroup>
<col width="25%" />
<col width="25%" />
<col width="40%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>결재서식명</TableHead>
<TableHead>서식 설명</TableHead>
<TableHead>검토자</TableHead>
<TableHead>선택</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.elaprTmpltId}>
<TableCell align="center">{item.elaprTmpltNm}</TableCell>
<TableCell align="center">{item.elaprTmpltExpln || "-"}</TableCell>
<TableCell align="center">
<ElaprAplnCell elaprTmpltId={item.elaprTmpltId} />
</TableCell>
<TableCell align="center">
<Button type="button" size="sm" onClick={() => handleSelect(item)}>
선택
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<DialogFooter>
<Button type="button" color="white" onClick={onClose}>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ElaprAplnCell({ elaprTmpltId }: { elaprTmpltId: string }) {
const { data } = useElaprAplnPage({
elaprTmpltId,
page: 0,
size: PAGE_SIZE,
sort: "atrzSeqNo,asc",
});
const items = data?.content ?? [];
return (
<div className="flex flex-col gap-2">
{items.map((item) => (
<Fragment key={item.aplnId}>
<div className="flex items-center gap-1">
<Badge color="gray">{item.atrzSeComNm}</Badge>
{item.aprvrNm}
</div>
</Fragment>
))}
</div>
);
}
export { ElaprTmpltPickerDialog };
"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<ElaprTmplt | null>(null);
const [aplnRows, setAplnRows] = useState<ElaprAplnRow[]>([]);
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[120rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>결재선 선택</DialogTitle>
</DialogHeader>
<div className="flex gap-6">
<ElaprTmpltSelectTable
query={tmpltQuery}
selectedTmpltId={selectedTmplt?.elaprTmpltId ?? null}
onSelectTmplt={handleSelectTmplt}
/>
<ElaprAplnPickerGrid
elaprTmpltId={selectedTmplt?.elaprTmpltId ?? null}
onRowsChange={handleRowsChange}
onEditingLockedChange={handleEditingLockedChange}
/>
</div>
<DialogFooter>
<Button type="button" color="white" onClick={() => handleOpenChange(false)}>
닫기
</Button>
<Button
type="button"
disabled={!selectedTmplt || isEditingLocked}
onClick={handleConfirm}
>
확인
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { ElaprTmpltPickerDialog };
export type { ElaprTmpltPickerConfirmPayload };
@@ -0,0 +1,104 @@
"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<ElaprTmplt>();
interface ElaprTmpltSelectTableProps {
query: UseQueryResult<PageResponse<ElaprTmplt>>;
selectedTmpltId: string | null;
onSelectTmplt: (tmplt: ElaprTmplt | null) => void;
}
function ElaprTmpltSelectTable({
query,
selectedTmpltId,
onSelectTmplt,
}: ElaprTmpltSelectTableProps) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
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 (
<section className="card card-border mx-0 min-h-[32rem] flex-[0.5] bg-background pt-0">
<div className="card-header h-[4.6rem]">
<h3 className="text-2xl font-semibold">전자결재서식</h3>
</div>
{query.isPending && (
<div className="flex flex-col gap-2 p-4">
<Skeleton className="h-[4rem]" />
<Skeleton className="h-[16rem]" />
</div>
)}
{query.isError && (
<p className="py-8 text-center text-lg text-muted-foreground">
결재서식 목록을 불러오지 못했습니다.
</p>
)}
{!query.isPending && !query.isError && tableComponent}
</section>
);
}
export { ElaprTmpltSelectTable };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,138 @@
"use client";
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 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<ElaprAplnRow>) => 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 (
<TableRow ref={setNodeRef} style={style} className={isEditing ? "bg-accent/30" : undefined}>
<TableCell align="center" className="w-10">
<Button
type="button"
size="icon"
className="cursor-grab !bg-transparent text-muted-foreground disabled:cursor-not-allowed disabled:opacity-40"
disabled={dragDisabled}
{...attributes}
{...listeners}
aria-label="순서 변경"
>
<Equal className="!size-6" />
</Button>
</TableCell>
<TableCell align="center">{row.isNew ? "-" : (row.aplnId ?? "-")}</TableCell>
<TableCell align="center">{displaySeqNo}</TableCell>
<TableCell align="center">
{isEditing ? (
<CodeSelect
upCd="ATRZSE"
className="min-w-[120px]"
value={row.atrzSeComCd ?? ""}
onValueChange={(v) =>
onChange({
atrzSeComCd: v,
atrzSeComNm: findCodeLabel(atrzSeCodes, v),
})
}
placeholder="결재구분"
/>
) : (
atrzSeLabel || "-"
)}
</TableCell>
<TableCell align="center">
<div className="flex items-center justify-center gap-4">
<span>{row.aprvrNm || row.aprvrUuid || "-"}</span>
{isEditing && (
<Button type="button" size="sm" onClick={onPickAprvr}>
선택
</Button>
)}
</div>
</TableCell>
<TableCell align="center">
<div className="flex justify-center gap-2">
{isEditing ? (
<>
<Button type="button" size="sm" onClick={onSave}>
확인
</Button>
<Button type="button" size="sm" color="white" onClick={onCancel}>
취소
</Button>
</>
) : (
<>
<Button
type="button"
size="sm"
color="white"
disabled={rowDisabled}
onClick={onStartEdit}
>
수정
</Button>
<Button
type="button"
size="sm"
color="gray"
disabled={rowDisabled}
onClick={onDelete}
>
삭제
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
);
}
export { SortableAplnPickerRow };
@@ -1,4 +0,0 @@
export { CnfmListPage } from "./ui/cnfm-list-page";
export { CnfmViewPage } from "./ui/cnfm-view-page";
export { CnfmRegistPage } from "./ui/cnfm-regist-page";
export { CnfmModifyPage } from "./ui/cnfm-modify-page";
@@ -1,441 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { dayjs } from "@/shared/lib";
import { useCodeList } from "@/entities/code";
import {
INSTL_SNCN_REGISTER_SELECTION_KEY,
type InstlSncnMngSearchParams,
instlSncnService,
} from "@/entities/instl-sncn";
import "./cnfm-page.css";
const TASK_OPTIONS = [
{ value: "", label: "전체" },
{ value: "SF", label: "하수처리시설" },
{ value: "SP", label: "하수관로" },
{ value: "RE", label: "재이용수" },
];
const SIDO_OPTIONS = [
{ value: "", label: "전체" },
{ value: "11", label: "서울특별시" },
{ value: "26", label: "부산광역시" },
{ value: "27", label: "대구광역시" },
{ value: "28", label: "인천광역시" },
{ value: "29", label: "광주광역시" },
{ value: "30", label: "대전광역시" },
{ value: "31", label: "울산광역시" },
{ value: "36", label: "세종특별자치시" },
{ value: "41", label: "경기도" },
{ value: "43", label: "충청북도" },
{ value: "44", label: "충청남도" },
{ value: "45", label: "전라북도" },
{ value: "46", label: "전라남도" },
{ value: "47", label: "경상북도" },
{ value: "48", label: "경상남도" },
{ value: "50", label: "제주특별자치도" },
];
type StepCode = "SV" | "ST" | "ER" | "TE" | "TR" | "TP" | "TA";
const STEPS: { code: StepCode; label: string }[] = [
{ code: "SV", label: "저장" },
{ code: "ST", label: "신청완료" },
{ code: "ER", label: "환경청 검토" },
{ code: "TE", label: "기술검토" },
{ code: "TR", label: "검토결과 회신" },
{ code: "TP", label: "기술평가" },
{ code: "TA", label: "최종승인" },
];
const PAGE_GROUP_SIZE = 5;
function CnfmListPage() {
const router = useRouter();
const [bizNm, setBizNm] = useState("");
const [taskSeComCd, setTaskSeComCd] = useState("");
const [sidoComCd, setSidoComCd] = useState("");
const [sggComCd, setSggComCd] = useState("");
const [search, setSearch] = useState<InstlSncnMngSearchParams>({
bizNm: "",
taskSeComCd: "",
sidoComCd: "",
sggComCd: "",
});
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [taskDialogOpen, setTaskDialogOpen] = useState(false);
const [selectedTaskSeComCd, setSelectedTaskSeComCd] = useState<string[]>([]);
const { data: fnrsCodeList = [] } = useCodeList("INSTL");
useEffect(() => {
if (!taskDialogOpen) return;
if (selectedTaskSeComCd.length) return;
if (fnrsCodeList.length === 0) return;
// setSelectedTaskSeComCd([fnrsCodeList[0].lwpoCd]);
}, [taskDialogOpen, selectedTaskSeComCd, fnrsCodeList]);
const query = useQuery({
queryKey: ["instl-sncn-list", search, pageIndex, pageSize],
queryFn: () =>
instlSncnService.getPage({
...search,
page: pageIndex,
size: pageSize,
sort: "regDt,desc",
}),
});
const totalPages = query.data?.totalPages ?? 0;
const pageGroupStart = Math.floor(pageIndex / PAGE_GROUP_SIZE) * PAGE_GROUP_SIZE;
const pageNumbers = useMemo(() => {
const end = Math.min(pageGroupStart + PAGE_GROUP_SIZE, totalPages);
return Array.from({ length: end - pageGroupStart }, (_, i) => pageGroupStart + i);
}, [pageGroupStart, totalPages]);
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setPageIndex(0);
setSearch({ bizNm, taskSeComCd, sidoComCd, sggComCd });
};
const handleOpenTaskDialog = () => {
//setSelectedTaskSeComCd((prev) => (prev && prev.length ? prev : [fnrsCodeList[0]?.lwpoCd || ""]));
setTaskDialogOpen(true);
};
const handleConfirmTaskDialog = () => {
if (!selectedTaskSeComCd || selectedTaskSeComCd.length === 0) {
alert("하나 이상의 업무구분을 선택하세요.");
return;
}
// prepare arrays of selected codes and names
const selectedCodes = selectedTaskSeComCd;
const selectedNames = selectedTaskSeComCd.map(
(code) => fnrsCodeList.find((item) => item.lwpoCd === code)?.lwpoCdNm || "",
);
sessionStorage.setItem(
INSTL_SNCN_REGISTER_SELECTION_KEY,
JSON.stringify({
taskSeComCdList: selectedCodes,
taskSeComCdNmList: selectedNames,
}),
);
setTaskDialogOpen(false);
router.push("/instl-sncn/register");
};
return (
<div className="cnfm-page-wrap">
{/* 검색 영역 */}
<div className="cnfm-card">
<form onSubmit={handleSearch} className="cnfm-search-bar">
<div className="cnfm-search-field">
<label className="cnfm-search-label" htmlFor="sidoComCd">
시도
</label>
<select
id="sidoComCd"
className="cnfm-select"
value={sidoComCd}
onChange={(e) => setSidoComCd(e.target.value)}
>
{SIDO_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="cnfm-search-field">
<label className="cnfm-search-label" htmlFor="sggComCd">
시군구
</label>
<select
id="sggComCd"
className="cnfm-select"
value={sggComCd}
onChange={(e) => setSggComCd(e.target.value)}
>
<option value="">전체</option>
</select>
</div>
<div className="cnfm-search-field cnfm-search-field--grow">
<label className="cnfm-search-label" htmlFor="bizNm">
사업명
</label>
<input
id="bizNm"
type="text"
className="cnfm-input"
value={bizNm}
onChange={(e) => setBizNm(e.target.value)}
placeholder="사업명을 입력하세요"
/>
</div>
<div className="cnfm-search-field">
<label className="cnfm-search-label" htmlFor="taskSeComCd">
구분
</label>
<select
id="taskSeComCd"
className="cnfm-select cnfm-select--wide"
value={taskSeComCd}
onChange={(e) => setTaskSeComCd(e.target.value)}
>
{TASK_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<button type="submit" className="cnfm-btn-search">
검색
</button>
</form>
</div>
{/* 목록 영역 */}
<div className="cnfm-card mt-4">
<div className="cnfm-list-header">
<h3 className="cnfm-list-title">설치인가 목록</h3>
<div className="cnfm-list-actions">
<button type="button" className="cnfm-btn-register" onClick={handleOpenTaskDialog}>
✎ 신청
</button>
<select
className="cnfm-select"
value={pageSize}
onChange={(e) => {
setPageIndex(0);
setPageSize(Number(e.target.value));
}}
>
{[10, 20, 30, 50].map((size) => (
<option key={size} value={size}>
{size}개
</option>
))}
</select>
</div>
</div>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th>시도</th>
<th>시군구</th>
<th>사업명</th>
<th>구분</th>
<th>담당자</th>
<th>해당기관</th>
<th>진행상황</th>
<th>신청일</th>
</tr>
</thead>
<tbody>
{query.isLoading && (
<tr>
<td colSpan={8} className="cnfm-td-empty">
조회 중입니다...
</td>
</tr>
)}
{!query.isLoading && (query.data?.content?.length ?? 0) === 0 && (
<tr>
<td colSpan={8} className="cnfm-td-empty">
조회 결과가 없습니다.
</td>
</tr>
)}
{(query.data?.content ?? []).map((row) => {
const currentStepIndex = STEPS.findIndex((s) => s.code === row.prgrsSttsComCd);
return (
<tr
key={row.instlSncnDocNo}
className="cnfm-row-clickable"
onClick={() => router.push(`/instl-sncn/${row.instlSncnDocNo}`)}
>
<td>{row.sido || "-"}</td>
<td>{row.gugun || "-"}</td>
<td className="text-left">{row.bizNm || "-"}</td>
<td>{row.taskSeCdNm || row.taskSeComCd || "-"}</td>
<td>{row.aplcntNm || "-"}</td>
<td>{row.envoSeCdNm || "-"}</td>
<td>
<div className="cnfm-step-wrap">
<div className="cnfm-step-row">
{STEPS.slice(0, 4).map((step, index) => {
const state =
index < currentStepIndex
? "completed"
: index === currentStepIndex
? "current"
: "pending";
return (
<span
key={step.code}
className={`cnfm-step-badge cnfm-step-badge--${state}`}
>
<span className={`cnfm-step-dot cnfm-step-dot--${state}`} />
{step.label}
</span>
);
})}
</div>
<div className="cnfm-step-row">
{STEPS.slice(4).map((step, i) => {
const index = i + 4;
const state =
index < currentStepIndex
? "completed"
: index === currentStepIndex
? "current"
: "pending";
return (
<span
key={step.code}
className={`cnfm-step-badge cnfm-step-badge--${state}`}
>
<span className={`cnfm-step-dot cnfm-step-dot--${state}`} />
{step.label}
</span>
);
})}
</div>
</div>
</td>
<td>{row.regDt ? dayjs(row.regDt).format("YYYY-MM-DD") : "-"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
<div className="cnfm-pagination">
<button
type="button"
className="cnfm-page-btn"
onClick={() => setPageIndex(0)}
disabled={pageIndex === 0}
>
«
</button>
<button
type="button"
className="cnfm-page-btn"
onClick={() => setPageIndex((prev) => Math.max(0, prev - 1))}
disabled={pageIndex === 0}
>
‹
</button>
{pageNumbers.map((pageNo) => (
<button
type="button"
key={pageNo}
className={`cnfm-page-btn ${pageNo === pageIndex ? "active" : ""}`}
onClick={() => setPageIndex(pageNo)}
>
{pageNo + 1}
</button>
))}
<button
type="button"
className="cnfm-page-btn"
onClick={() => setPageIndex((prev) => Math.min(Math.max(0, totalPages - 1), prev + 1))}
disabled={pageIndex >= totalPages - 1}
>
›
</button>
<button
type="button"
className="cnfm-page-btn"
onClick={() => setPageIndex(Math.max(0, totalPages - 1))}
disabled={pageIndex >= totalPages - 1}
>
»
</button>
</div>
</div>
{taskDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-[72rem] max-w-[95vw] overflow-hidden rounded-md border bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<h4 className="text-[2rem] font-bold text-gray-700">업무구분 선택</h4>
<button
type="button"
className="rounded border border-gray-300 px-4 py-1 text-[1.2rem] text-gray-600 hover:bg-gray-50"
onClick={() => setTaskDialogOpen(false)}
>
닫기
</button>
</div>
<div className="px-6 py-5">
<div className="flex flex-wrap items-center gap-8 text-[1.5rem]">
{fnrsCodeList.map((item) => (
<label key={item.lwpoCd} className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
name="taskSeComCdSelect"
value={item.lwpoCd}
checked={selectedTaskSeComCd.includes(item.lwpoCd)}
onChange={() => {
setSelectedTaskSeComCd((prev) =>
prev.includes(item.lwpoCd)
? prev.filter((v) => v !== item.lwpoCd)
: [...prev, item.lwpoCd],
);
}}
/>
{item.lwpoCdNm}
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 border-t px-6 py-4">
<button
type="button"
className="rounded border border-gray-300 px-5 py-2 text-[1.3rem] text-gray-700 hover:bg-gray-50"
onClick={() => setTaskDialogOpen(false)}
>
취소
</button>
<button
type="button"
className="rounded bg-blue-500 px-5 py-2 text-[1.3rem] font-semibold text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-300"
onClick={handleConfirmTaskDialog}
disabled={!selectedTaskSeComCd || selectedTaskSeComCd.length === 0}
>
확인
</button>
</div>
</div>
</div>
)}
</div>
);
}
export { CnfmListPage };
@@ -1,37 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { instlSncnService } from "@/entities/instl-sncn";
import { CnfmRegistPage } from "./cnfm-regist-page";
import "./cnfm-page.css";
function CnfmModifyPage({ id }: { id: string }) {
const query = useQuery({
// share cache with view page
queryKey: ["instl-sncn-detail", id],
queryFn: () => instlSncnService.getById(id),
});
if (query.isLoading) {
return <div className="mt-6 text-[1.4rem]">수정 정보를 불러오는 중입니다...</div>;
}
if (!query.data) {
return <div className="mt-6 text-[1.4rem]">수정 대상을 찾을 수 없습니다.</div>;
}
const data = query.data;
if (!data) return <div className="mt-6 text-[1.4rem]">수정 대상을 찾을 수 없습니다.</div>;
return (
<div className="mt-6">
<CnfmRegistPage initialData={data} />
</div>
);
}
export { CnfmModifyPage };
@@ -1,490 +0,0 @@
/* ============================
페이지 레이아웃
============================= */
.cnfm-page-wrap {
padding: 2rem 2.4rem;
}
/* ============================
카드
============================= */
.cnfm-card {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #ffffff;
padding: 1.6rem 2rem;
}
.cnfm-card.mt-4 {
margin-top: 1.6rem;
}
/* ============================
검색 바
============================= */
.cnfm-search-bar {
display: flex;
align-items: center;
gap: 1.2rem;
flex-wrap: wrap;
}
.cnfm-search-field {
display: flex;
align-items: center;
gap: 0.6rem;
}
.cnfm-search-field--grow {
flex: 1;
min-width: 18rem;
}
.cnfm-search-label {
font-size: 1.3rem;
font-weight: 600;
color: #374151;
white-space: nowrap;
}
.cnfm-input {
height: 3.6rem;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 0 1rem;
font-size: 1.3rem;
color: #111827;
width: 100%;
outline: none;
background: #fff;
}
.cnfm-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.cnfm-select {
height: 3.6rem;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 0 0.8rem;
font-size: 1.3rem;
color: #111827;
background: #fff;
min-width: 11rem;
cursor: pointer;
outline: none;
}
.cnfm-select--wide {
min-width: 16rem;
}
.cnfm-select:focus {
border-color: #3b82f6;
}
.cnfm-btn-search {
height: 3.6rem;
padding: 0 2rem;
background: #3b82f6;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
}
.cnfm-btn-search:hover {
background: #2563eb;
}
/* ============================
목록 헤더
============================= */
.cnfm-list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.2rem;
}
.cnfm-list-title {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.cnfm-list-actions {
display: flex;
align-items: center;
gap: 0.8rem;
}
.cnfm-btn-register {
height: 3.4rem;
padding: 0 1.6rem;
background: #22c55e;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
}
.cnfm-btn-register:hover {
background: #16a34a;
}
/* ============================
테이블
============================= */
.cnfm-table-wrap {
overflow-x: auto;
}
.cnfm-table {
width: 100%;
border-collapse: collapse;
font-size: 1.3rem;
}
.cnfm-table th,
.cnfm-table td {
border-bottom: 1px solid #e5e7eb;
padding: 1.2rem 0.8rem;
text-align: center;
vertical-align: middle;
}
.cnfm-table thead th {
background: #f8fafc;
color: #374151;
font-weight: 600;
font-size: 1.25rem;
border-bottom: 2px solid #e2e8f0;
}
.cnfm-table tbody td {
color: #111827;
}
.cnfm-td-empty {
padding: 3rem 0;
color: #9ca3af;
font-size: 1.3rem;
}
.cnfm-row-clickable {
cursor: pointer;
}
.cnfm-row-clickable:hover {
background: #f8fafc;
}
/* ============================
진행상황 스텝 배지
============================= */
.cnfm-step-wrap {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.cnfm-step-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: nowrap;
}
.cnfm-step-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border-radius: 9999px;
padding: 0.3rem 0.9rem;
font-size: 1.15rem;
font-weight: 500;
white-space: nowrap;
border: 1px solid transparent;
}
.cnfm-step-badge--completed {
background: #dbeafe;
border-color: #93c5fd;
color: #1d4ed8;
}
.cnfm-step-badge--current {
background: #fed7aa;
border-color: #fb923c;
color: #c2410c;
font-weight: 700;
}
.cnfm-step-badge--pending {
background: #f3f4f6;
border-color: #d1d5db;
color: #9ca3af;
}
.cnfm-step-dot {
display: inline-block;
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
flex-shrink: 0;
}
.cnfm-step-dot--completed {
background: #3b82f6;
}
.cnfm-step-dot--current {
background: #f97316;
}
.cnfm-step-dot--pending {
background: #d1d5db;
}
/* ============================
페이지네이션
============================= */
.cnfm-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.4rem;
margin-top: 1.6rem;
padding-bottom: 0.4rem;
}
.cnfm-page-btn {
min-width: 3.2rem;
height: 3.2rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
font-size: 1.3rem;
color: #374151;
cursor: pointer;
transition: all 0.15s;
padding: 0 0.6rem;
}
.cnfm-page-btn:hover:not(:disabled) {
border-color: #3b82f6;
color: #3b82f6;
}
.cnfm-page-btn.active {
border-color: #3b82f6;
background: #3b82f6;
color: #fff;
font-weight: 700;
}
.cnfm-page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ============================
폼 테이블 (상세/등록 페이지용)
============================= */
.cnfm-form-table {
width: 100%;
border-collapse: collapse;
font-size: 1.3rem;
}
.cnfm-form-table th,
.cnfm-form-table td {
border: 1px solid #e5e7eb;
padding: 0.9rem 1rem;
}
.cnfm-form-table th {
width: 16rem;
background: #f9fafb;
text-align: left;
font-weight: 700;
}
.cnfm-form-table td {
background: #fff;
}
.cnfm-action-row {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.8rem;
}
/* ============================
기존 호환 클래스 유지
============================= */
.cnfm-section-title {
font-size: 1.8rem;
font-weight: 700;
color: #1f2937;
}
@media (max-width: 1100px) {
.cnfm-search-bar {
flex-direction: column;
align-items: stretch;
}
.cnfm-search-field {
flex-direction: column;
align-items: flex-start;
}
.cnfm-search-field--grow {
width: 100%;
}
}
.cnfm-section-title {
font-size: 1.8rem;
font-weight: 700;
color: #1f2937;
}
.cnfm-search-grid {
display: grid;
grid-template-columns: 8rem 1fr 6rem 16rem auto auto;
gap: 1.2rem;
align-items: center;
}
.cnfm-table-wrap {
overflow-x: auto;
}
.cnfm-table {
width: 100%;
border-collapse: collapse;
font-size: 1.3rem;
}
.cnfm-table th,
.cnfm-table td {
border: 1px solid #e5e7eb;
padding: 1rem 0.8rem;
text-align: center;
vertical-align: middle;
}
.cnfm-table thead th {
background: #f3f4f6;
color: #111827;
font-weight: 700;
}
.cnfm-row-clickable {
cursor: pointer;
}
.cnfm-row-clickable:hover {
background: #f9fafb;
}
.cnfm-step {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: center;
}
.cnfm-step-badge {
border-radius: 9999px;
border: 1px solid #d1d5db;
background: #f9fafb;
color: #6b7280;
font-size: 1.1rem;
padding: 0.25rem 0.65rem;
}
.cnfm-step-badge.completed {
border-color: #93c5fd;
background: #dbeafe;
color: #1d4ed8;
}
.cnfm-step-badge.current {
border-color: #16a34a;
background: #dcfce7;
color: #166534;
font-weight: 700;
}
.cnfm-pagination {
display: flex;
justify-content: center;
gap: 0.6rem;
margin-top: 1.2rem;
}
.cnfm-pagination button {
min-width: 3.2rem;
height: 3.2rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
font-size: 1.3rem;
}
.cnfm-pagination button.active {
border-color: #2563eb;
background: #2563eb;
color: #fff;
font-weight: 700;
}
.cnfm-form-table {
width: 100%;
border-collapse: collapse;
font-size: 1.3rem;
}
.cnfm-form-table th,
.cnfm-form-table td {
border: 1px solid #e5e7eb;
padding: 0.9rem 1rem;
}
.cnfm-form-table th {
width: 16rem;
background: #f9fafb;
text-align: left;
font-weight: 700;
}
.cnfm-form-table td {
background: #fff;
}
.cnfm-action-row {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.8rem;
}
@media (max-width: 1100px) {
.cnfm-search-grid {
grid-template-columns: 1fr;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,985 +0,0 @@
"use client";
/* eslint-disable @typescript-eslint/no-explicit-any, simple-import-sort/imports */
import { useEffect, useMemo, useState } from "react";
import { Button } from "@common/components";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
// InstlSncnApi imported below with types
import { PROXY_HOST } from "@/shared/config";
import { formatFileSize } from "@/shared/lib/file-utils";
import axios from "axios";
import { processElaprPrgrs } from "@/entities/elapr/api/elapr-prgrs-service";
import { elaprDmndService } from "@/entities/elapr";
import { dayjs, useUserStore, useAlertStore } from "@/shared/lib";
import { instlSncnService, type InstlSncnApplySaveRequestDto } from "@/entities/instl-sncn";
import { client } from "@/shared/api";
import "./cnfm-page.css";
type ReviewStepCode = "LOCAL" | "ENV" | "KECO";
const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
const ROLE_TO_REVIEW_STEP: Record<string, ReviewStepCode> = {
ROLE_USER05: "LOCAL",
ROLE_USER04: "LOCAL",
ROLE_USER03: "ENV",
ROLE_USER02: "KECO",
ROLE_USER01: "KECO",
};
const REVIEW_STEP_ROLE_RANK: Record<ReviewStepCode, number> = {
LOCAL: 1,
ENV: 2,
KECO: 3,
};
const INSTL_PROGRESS_ORDER = [
"SV",
"ST",
"LE",
"ER",
"EE",
"TE",
"TS",
"TR",
"TP",
"EP",
"TA",
"TB",
];
const getProgressStepCount = (prgrsSttsComCd?: string): number => {
const idx = INSTL_PROGRESS_ORDER.indexOf(prgrsSttsComCd ?? "ST");
if (idx <= INSTL_PROGRESS_ORDER.indexOf("ST")) return 1;
if (idx <= INSTL_PROGRESS_ORDER.indexOf("TP")) return 2;
return 3;
};
const getRoleStepCount = (dtlInstCd?: string): number => {
const mapped = dtlInstCd ? ROLE_TO_REVIEW_STEP[dtlInstCd] : undefined;
return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0;
};
// 업무구분 코드 -> 표시명 매핑 (체크리스트 그룹 표시용)
const TASK_CODE_LABELS: Record<string, string> = {
RE: "재이용수",
SP: "하수관로",
SF: "하수처리시설",
};
const normalizeChecklistResultMap = (raw: unknown): Record<ReviewStepCode, any[]> => {
const base: Record<ReviewStepCode, any[]> = { LOCAL: [], ENV: [], KECO: [] };
if (!raw) return base;
if (Array.isArray(raw)) {
raw.forEach((row: any) => {
const step = (row?.rvwStpComCd ?? "LOCAL") as ReviewStepCode;
if (REVIEW_STEP_CODES.includes(step)) {
base[step].push(row);
}
});
return base;
}
if (typeof raw === "object") {
REVIEW_STEP_CODES.forEach((step) => {
const rows = (raw as Record<string, unknown>)[step];
base[step] = Array.isArray(rows) ? (rows as any[]) : [];
});
}
return base;
};
function CnfmViewPage({ id }: { id: string }) {
const router = useRouter();
const userInfo = useUserStore((state) => state.userInfo);
const alert = useAlertStore((state) => state.setMessage);
const query = useQuery({
queryKey: ["instl-sncn-detail", id],
queryFn: () => instlSncnService.getById(id),
});
const data = (query.data ?? {}) as any;
// support API shape where payload includes detDto + lists
const det = data.detDto ?? data;
const bizSclList = data.bizSclList ?? [];
const bizCstList = data.bizCstList ?? [];
// API now provides a flat checklist result list: `cklstResultList`
const rawCklst = useMemo(() => data.cklstResultList ?? [], [data.cklstResultList]);
// ensure we have a flat array
const cklstAll = useMemo(() => (Array.isArray(rawCklst) ? (rawCklst as any[]) : []), [rawCklst]);
// build a step-keyed map from the flat list so the rest of the view can use same access pattern
const cklstResultMap = useMemo(() => {
const base: Record<ReviewStepCode, any[]> = { LOCAL: [], ENV: [], KECO: [] };
(cklstAll ?? []).forEach((row: any) => {
const step = (row?.rvwStpComCd ?? "LOCAL") as ReviewStepCode;
if (REVIEW_STEP_CODES.includes(step)) base[step].push(row);
});
return base;
}, [cklstAll]);
const esntlFileList = data.esntlFileList ?? [];
const etcFileList = data.etcFileList ?? [];
const elaprDocPrgrsList = (data.elaprDocPrgrsList ?? []) as any[];
const renderApproverCell = (code: string) => {
const row = elaprDocPrgrsList.find((r) => r.atrzSeComCd === code);
if (!row) return <div className="text-center">-</div>;
const status = row.atrzPrgrsSttsComCd ?? "";
const statusMap: Record<string, { label: string; badgeClass: string; icon: string }> = {
ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700", icon: "⏳" },
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800", icon: "📨" },
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800", icon: "✔️" },
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800", icon: "✖️" },
};
const st = statusMap[status] ?? {
label: status || "",
badgeClass: "bg-gray-100 text-gray-700",
icon: "",
};
return (
<div className="text-center">
<div className="font-semibold">{row.aprvrUuidNm ?? "-"}</div>
<div className="text-[0.95rem] text-gray-500">
{row.ognzNm ? `${row.ognzNm}` : ""}
{row.jbpsSeNm || row.jbgdSeNm
? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}`
: ""}
</div>
<div className="mt-1 flex items-center justify-center gap-2 text-[0.9rem]">
{row.atrzDt ? (
<span className="text-gray-400">{dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}</span>
) : null}
{st.label ? (
<span
title={`${row.atrzPrgrsSttsComNm ?? st.label}${row.rjctRsn ? ` — 사유: ${row.rjctRsn}` : ""}${row.atrzDt ? ` · ${dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}` : ""}`}
className={`ml-1 inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${st.badgeClass}`}
>
<span className="mr-1">{st.icon}</span>
<span>{st.label}</span>
</span>
) : null}
</div>
</div>
);
};
const orderedApprovers = useMemo(() => {
return (elaprDocPrgrsList ?? []).slice().sort((a: any, b: any) => {
const A = Number(a?.atrzSeqNo ?? 0);
const B = Number(b?.atrzSeqNo ?? 0);
return A - B;
});
}, [elaprDocPrgrsList]);
const handleApproveClick = async (elaprPrgrsId: any, atrzSeComCd: any) => {
if (!elaprPrgrsId) {
alert("결재 정보가 올바르지 않습니다.");
return;
}
try {
await processElaprPrgrs(String(elaprPrgrsId), "ATRZ_PRGRS_003"); //진행상태를 승인으로 업데이트
alert("결재가 완료되었습니다.");
try {
await query.refetch();
} catch (e) {
/* ignore refetch errors */
}
} catch (err: any) {
console.error("결재 처리 실패", err);
const msg =
axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
: "결재 처리 중 오류가 발생했습니다.";
alert(msg);
}
};
const handleAssignApproverClick = async (elaprTmpltId: string) => {
try {
const payload = {
elaprDocNo: det.instlSncnDocNo,
elaprTmpltId: elaprTmpltId /* 팝업에서 선택된 전자결제 템플릿 */,
dmndTtl: det.bizNm /* "구례 하수도 처리시설 2026", */,
dmndCn: det.bizCn /*"구례 발주 하수도 처리시설 사업 2026년도 진행분 결제 요청 입니다."*/,
};
await elaprDmndService.save(payload);
alert("결재자 지정(결재요청 등록)이 완료되었습니다.");
try {
await query.refetch();
} catch (e) {
/* ignore refetch errors */
}
} catch (err: any) {
console.error("결재자 지정 실패", err);
const msg =
axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
: "결재자 지정 중 오류가 발생했습니다.";
alert(msg);
}
};
const renderApproverRow = (row: any) => {
if (!row) return <div className="text-center">-</div>;
const status = row.atrzPrgrsSttsComCd ?? "";
const statusMap: Record<string, { label: string; badgeClass: string; icon: string }> = {
ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700", icon: "⏳" },
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800", icon: "📨" },
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800", icon: "✔️" },
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800", icon: "✖️" },
};
const st = statusMap[status] ?? {
label: status || "",
badgeClass: "bg-gray-100 text-gray-700",
icon: "",
};
return (
<div className="text-center">
<div className="font-semibold">{row.aprvrUuidNm ?? "-"}</div>
<div className="text-[0.95rem] text-gray-500">
{row.ognzNm ? `${row.ognzNm}` : ""}
{row.jbpsSeNm || row.jbgdSeNm
? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}`
: ""}
</div>
<div className="mt-1 flex items-center justify-center gap-2 text-[0.9rem]">
{row.atrzDt ? (
<span className="text-gray-400">{dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}</span>
) : null}
{st.label ? (
<>
<span
title={`${row.atrzPrgrsSttsComNm ?? st.label}${row.rjctRsn ? ` — 사유: ${row.rjctRsn}` : ""}${row.atrzDt ? ` · ${dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}` : ""}`}
className={`ml-1 inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${st.badgeClass}`}
>
<span className="mr-1">{st.icon}</span>
<span>{st.label}</span>
</span>
{status === "ATRZ_PRGRS_002" && userInfo?.userUnqId === row.aprvrUuid ? (
<button
type="button"
onClick={() => handleApproveClick(row.elaprPrgrsId, row.atrzSeComCd)}
className="ml-2 rounded bg-blue-500 px-2 py-1 text-xs text-white"
>
결제
</button>
) : null}
</>
) : null}
</div>
</div>
);
};
const currentPrgrsSttsComCd = (det.prgrsSttsComCd as string | undefined) ?? "ST";
const roleBasedMaxStepCount = getRoleStepCount(userInfo?.dtlInstCd);
const progressBasedMaxStepCount = getProgressStepCount(currentPrgrsSttsComCd);
const visibleStepCount = Math.min(progressBasedMaxStepCount, roleBasedMaxStepCount);
const visibleReviewStepCodes = useMemo(
() => REVIEW_STEP_CODES.slice(0, visibleStepCount),
[visibleStepCount],
);
const [selectedReviewStep, setSelectedReviewStep] = useState<ReviewStepCode>("LOCAL");
// selected task code for header filtering (null = none selected)
const [selectedTaskCode, setSelectedTaskCode] = useState<string | null>(null);
// derive available task codes from checklist results
const taskCodes = useMemo(() => {
return Array.from(
new Set((cklstAll ?? []).map((r: any) => (r as any).taskSeComCd).filter(Boolean)),
);
}, [cklstAll]);
useEffect(() => {
if (visibleReviewStepCodes.length === 0) return;
if (!visibleReviewStepCodes.includes(selectedReviewStep)) {
setSelectedReviewStep(visibleReviewStepCodes[visibleReviewStepCodes.length - 1]);
}
}, [selectedReviewStep, visibleReviewStepCodes]);
// when checklist result task codes change, default selectedTaskCode to first available code
useEffect(() => {
if (!taskCodes || taskCodes.length === 0) {
if (selectedTaskCode !== null) setSelectedTaskCode(null);
return;
}
if (!selectedTaskCode || !taskCodes.includes(selectedTaskCode)) {
setSelectedTaskCode(taskCodes[0]);
}
}, [taskCodes, selectedTaskCode]);
const selectedChecklistRows = useMemo(() => {
if (visibleReviewStepCodes.length === 0) return [];
const rows = (cklstResultMap[selectedReviewStep] ?? []).filter((r: any) =>
selectedTaskCode ? (r.taskSeComCd ?? "") === selectedTaskCode : true,
);
return rows
.slice()
.sort(
(a: any, b: any) =>
(a.cklstArtclSn ?? 0) - (b.cklstArtclSn ?? 0) ||
(a.sortSeq ?? 0) - (b.sortSeq ?? 0) ||
(a.cklstRvwSn ?? 0) - (b.cklstRvwSn ?? 0),
);
}, [cklstResultMap, selectedReviewStep, visibleReviewStepCodes.length, selectedTaskCode]);
const [viewCheckResults, setViewCheckResults] = useState<Record<string, string>>({});
const [viewCheckComments, setViewCheckComments] = useState<Record<string, string>>({});
const cklstStats = useMemo(() => {
if (!selectedReviewStep) return { total: 0, completed: 0, percent: 0 };
const rows = (cklstResultMap[selectedReviewStep] ?? []).filter(
(r: any) =>
r.bchckUseYn === "Y" &&
(selectedTaskCode ? (r.taskSeComCd ?? "") === selectedTaskCode : true),
);
const total = rows.length;
const completed = rows.reduce((acc: number, row: any) => {
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${selectedReviewStep}`;
const val = viewCheckResults[key] ?? row.rvwRsltAnmlyYn ?? "";
return acc + (val ? 1 : 0);
}, 0);
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
return { total, completed, percent };
}, [cklstResultMap, viewCheckResults, selectedReviewStep, selectedTaskCode]);
const fnValidation = (mode: "SAVE" | "SUBMIT") => {
// 기본 검증: 필요하면 확장
return true;
};
const hadleTechReviewSubmit = async () => {
if (!fnValidation("SAVE")) return;
const cklstResultList = (cklstAll ?? [])
.filter((r: any) => r.bchckUseYn === "Y")
.map((row: any) => {
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${selectedReviewStep}`;
const rvwRsltAnmlyYn = viewCheckResults[key] ?? row.rvwRsltAnmlyYn ?? null;
const picRvwCn = viewCheckComments[key] ?? row.picRvwCn ?? null;
return {
taskSeComCd: row.taskSeComCd ?? "",
cklstSn: row.cklstSn,
cklstArtclSn: row.cklstArtclSn,
cklstRvwSn: row.cklstRvwSn,
rvwStpComCd: selectedReviewStep,
rvwRsltAnmlyYn,
picRvwCn,
};
});
const payload: InstlSncnApplySaveRequestDto = {
// only map core management DTO and checklist results; other lists empty per request
instlSncnMng: { ...(det ?? {}), prgrsSttsComCd: "ER" } as any,
bzScaleList: [],
bizCstList: [],
cklstResultList,
esntlAtchFileList: [],
} as unknown as InstlSncnApplySaveRequestDto;
try {
const issuedDocNo = await instlSncnService.techReview(det.instlSncnDocNo, payload);
alert(`기술검토 등록이 완료되었습니다. 문서번호: ${issuedDocNo}`);
} catch (error) {
console.error("기술검토 등록 API 호출 실패", error);
const serverMessage =
axios.isAxiosError(error) && error.response?.data?.message
? (error.response.data as { message: string }).message
: "저장 중 오류가 발생했습니다.";
alert(serverMessage);
}
};
useEffect(() => {
const results: Record<string, string> = {};
const comments: Record<string, string> = {};
(selectedChecklistRows ?? []).forEach((row: any) => {
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N")
results[key] = row.rvwRsltAnmlyYn;
if (row.picRvwCn) comments[key] = String(row.picRvwCn);
});
setViewCheckResults(results);
setViewCheckComments(comments);
}, [selectedChecklistRows]);
if (query.isLoading) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 불러오는 중입니다...</div>;
}
if (!query.data) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
}
const fmt = (v: number | null | undefined) =>
typeof v === "number" ? v.toLocaleString() : v === null || v === undefined ? "" : String(v);
const indentPx = (indNm: string | undefined) => {
if (!indNm) return 0;
const spaces = indNm.length - indNm.trimStart().length;
return (spaces / 4) * 16;
};
/* formatFileSize moved to shared lib: @/shared/lib/file-utils */
return (
<div className="mt-6 space-y-4">
{/* orderedApprovers.length > 0 && ( */}
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-semibold">결재 정보</h3>
<div className="cnfm-table-wrap mt-3">
<table className="cnfm-table w-full">
<thead>
<tr>
{orderedApprovers.length === 0 ? (
<>
<th className="text-center">접수자</th>
<th className="text-center">검토자</th>
<th className="text-center">승인자</th>
<th className="text-center">최종승인자</th>
</>
) : (
orderedApprovers.map((r: any) => (
<th key={String(r.elaprPrgrsId ?? r.atrzSeComCd)} className="text-center">
{r.atrzSeComNm ?? r.atrzSeComCd}
</th>
))
)}
</tr>
</thead>
<tbody>
<tr>
{orderedApprovers.length === 0 ? (
<>
<td>{renderApproverCell("ATRZ01")}</td>
<td>{renderApproverCell("ATRZ02")}</td>
<td>{renderApproverCell("ATRZ03")}</td>
<td>{renderApproverCell("ATRZ99")}</td>
</>
) : (
orderedApprovers.map((r: any) => (
<td key={String(r.elaprPrgrsId ?? r.atrzSeComCd)}>{renderApproverRow(r)}</td>
))
)}
</tr>
</tbody>
</table>
{/** 결제자 지정 버튼 */}
<div className="mt-4 text-right">
<Button type="button" onClick={() => handleAssignApproverClick("TMPLT20260611000001")}>
결재자 지정
</Button>
</div>
</div>
</div>
{/* )} */}
<div className="cnfm-card p-4">
<h2 className="cnfm-section-title">설치인가 상세보기</h2>
<p className="mt-2 text-[1.3rem] text-gray-600">
문서번호: <strong>{det.instlSncnDocNo}</strong>
</p>
</div>
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-semibold">기본 정보</h3>
<table className="cnfm-form-table mt-3">
<tbody>
<tr>
<th>사업명</th>
<td>{det.bizNm || "-"}</td>
<th>사업기간</th>
<td>{(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")}</td>
</tr>
<tr>
<th>사업내용</th>
<td colSpan={3}>{det.bizCn || "-"}</td>
</tr>
<tr>
<th>구분</th>
<td>{det.taskSeCdNm || det.taskSeComCd || "-"}</td>
<th>신청지자체</th>
<td>
{det.sido || "-"} {det.gugun || "-"}
</td>
</tr>
<tr>
<th>사업구분</th>
<td colSpan={3}>{det.sncnSeCdNm || "-"}</td>
</tr>
<tr>
<th>진행상태</th>
<td>{det.prgrsSttsCdNm || det.prgrsSttsComCd || "-"}</td>
<th>예산사업코드</th>
<td>{det.bgtBizCd || "-"}</td>
</tr>
</tbody>
</table>
</div>
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-semibold">담당자 정보</h3>
<table className="cnfm-form-table mt-3">
<tbody>
<tr>
<th>신청자명</th>
<td>{det.aplcntNm || "-"}</td>
<th>신청자 연락처</th>
<td>{det.aplcntMblTelno || "-"}</td>
</tr>
<tr>
<th>신청자 소속</th>
<td>{det.aplcntOgdpNm || "-"}</td>
<th>신청자 이메일</th>
<td>{det.aplcntEmlAddr || "-"}</td>
</tr>
<tr>
<th>설계자명</th>
<td>{det.dsgnrNm || "-"}</td>
<th>설계자 연락처</th>
<td>{det.dsgnrTelno || "-"}</td>
</tr>
<tr>
<th>설계자 소속</th>
<td>{det.dsgnrOgdpNm || "-"}</td>
<th>설계자 이메일</th>
<td>{det.dsgnrEmlAddr || "-"}</td>
</tr>
</tbody>
</table>
</div>
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-semibold">이력 정보</h3>
<table className="cnfm-form-table mt-3">
<tbody>
<tr>
<th>등록일시</th>
<td>{det.regDt ? dayjs(det.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}</td>
<th>수정일시</th>
<td>{det.mdfcnDt ? dayjs(det.mdfcnDt).format("YYYY-MM-DD HH:mm:ss") : "-"}</td>
</tr>
<tr>
<th>등록자명</th>
<td>{det.rgtrNm || "-"}</td>
<th>첨부파일그룹ID</th>
<td>{det.atchFileGroupId || "-"}</td>
</tr>
</tbody>
</table>
</div>
{/* 사업규모 */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">사업규모</h3>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th className="w-[30rem]">구분</th>
<th>기본계획(A)</th>
<th>예산반영(B)</th>
<th>금회설계시(C)</th>
<th>증감(C-B)</th>
</tr>
</thead>
<tbody>
{bizSclList.length === 0 ? (
<tr>
<td colSpan={5} className="cnfm-td-empty">
조회된 사업규모 항목이 없습니다.
</td>
</tr>
) : (
bizSclList.map((node: any) => {
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 (
<tr
key={node.cnstpSeCd}
className={node.treeLevel === 1 ? "bg-gray-50 font-semibold" : ""}
>
<td
className="bg-gray-50 pl-2 text-left"
style={{ paddingLeft: `${pl + 8}px` }}
>
{node.cnstpSeCdNm}
</td>
<td>
<div className="text-right">{fmt(bscPlanQty)}</div>
</td>
<td>
<div className="text-right">{fmt(budgetReflct)}</div>
</td>
<td>
<div className="text-right">{fmt(tstmDesignQty)}</div>
</td>
<td>
<div className="text-right">{diff > 0 ? `+${fmt(diff)}` : fmt(diff)}</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* 총사업비 */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">
총사업비 <span className="text-[1.2rem] font-normal text-gray-400">(단위: 백만원)</span>
</h3>
<div className="cnfm-table-wrap">
<table className="cnfm-table w-full table-fixed">
<thead>
<tr>
<th colSpan={2} className="w-[30rem]" rowSpan={2}>
구분
</th>
<th rowSpan={2}>예산반영(A)</th>
<th rowSpan={2}>표준사업비(B)</th>
<th rowSpan={2}>금회설계시(C)</th>
<th colSpan={2}>증감</th>
</tr>
<tr>
<th className="w-[12rem]">(C-B)</th>
<th className="w-[6rem]">%</th>
</tr>
</thead>
<tbody>
{bizCstList.map((node: any) => {
const pl = indentPx(node.indAcntgSeCdNm);
const bscPlanAmt = Number(node.bscPlanAmt) || 0;
const budgetReflct = Number(node.bgtRfltAmt) || 0;
const tstmDesignAmt = Number(node.tstmDesignAmt) || 0;
const diff = tstmDesignAmt - budgetReflct;
const showSubIcon = node.treeLevel >= 4;
const isLeaf = !bizCstList.some((n: any) => n.upAcntgSeCd === node.acntgSeCd);
return (
<tr
key={node.acntgSeCd}
className={
node.treeLevel === 1
? "bg-blue-50 font-bold"
: node.treeLevel === 2
? "bg-gray-100 font-semibold"
: ""
}
>
{node.treeLevel <= 2 ? (
<td colSpan={2} className="pl-2 text-left">
{node.acntgSeCdNm}
</td>
) : (
<>
<td className="w-[4rem]"></td>
<td
className={`bg-gray-50 text-left ${isLeaf ? "text-gray-700" : ""}`}
style={{ paddingLeft: `${pl + 8}px` }}
>
{showSubIcon && <span className="mr-1 text-gray-400">└</span>}
{node.acntgSeCdNm}
</td>
</>
)}
<td>
<div className="text-right">{fmt(bscPlanAmt)}</div>
</td>
<td>
<div className="text-right">{fmt(budgetReflct)}</div>
</td>
<td>
<div className="text-right">{fmt(tstmDesignAmt)}</div>
</td>
<td>
<div className="text-right">{diff > 0 ? `+${fmt(diff)}` : fmt(diff)}</div>
</td>
<td>
<div className="text-right">
{budgetReflct !== 0 ? `${Math.round((diff / budgetReflct) * 100)}%` : "-"}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* 체크리스트 */}
<div className="cnfm-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{Array.from(new Set(taskCodes)).map((code) => {
const name = TASK_CODE_LABELS[code] ?? code;
const active = selectedTaskCode === code;
return (
<button
key={code}
type="button"
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${
active
? "border-blue-500 bg-blue-50 text-blue-700"
: "border-gray-300 bg-white text-gray-600"
}`}
onClick={() => setSelectedTaskCode(code)}
>
{name}
</button>
);
})}
</div>
<div className="text-[1.1rem] text-gray-500">{`(${cklstStats.completed}/${cklstStats.total})`}</div>
</div>
</div>
<div className="mb-3 flex flex-wrap gap-2">
{visibleReviewStepCodes.length === 0 ? (
<span className="text-[1.2rem] text-gray-500">
현재 사용자 권한/진행상태 조합에서 표시 가능한 체크리스트 단계가 없습니다.
</span>
) : (
visibleReviewStepCodes.map((stepCode) => (
<button
key={stepCode}
type="button"
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${
selectedReviewStep === stepCode
? "border-blue-500 bg-blue-50 text-blue-700"
: "border-gray-300 bg-white text-gray-600"
}`}
onClick={() => setSelectedReviewStep(stepCode)}
>
{stepCode}
</button>
))
)}
</div>
<div className="mb-2 text-[1.25rem] text-gray-500">
체크리스트 진행상황 [{selectedReviewStep}] — 총합: <strong>{cklstStats.total}</strong> ·
완료: <strong>{cklstStats.completed}</strong> · 진행률:{" "}
<strong>{cklstStats.percent}%</strong>
</div>
<div className="cnfm-table-wrap mt-3">
<table className="cnfm-table">
<thead>
<tr>
<th className="w-[20rem]" rowSpan={2}>
검토항목
</th>
<th rowSpan={2}>주요 검토내용</th>
<th colSpan={2}>검토 결과(✓)</th>
{selectedReviewStep !== "LOCAL" ? (
<th rowSpan={2} className="w-[20rem]">
기술검토 의견
</th>
) : null}
</tr>
<tr>
<th className="w-[9rem]">
<button type="button">예</button>
</th>
<th className="w-[9rem]">
<button type="button">아니오</button>
</th>
</tr>
</thead>
<tbody>
{selectedChecklistRows.length === 0 ? (
<tr>
<td colSpan={5} className="cnfm-td-empty">
조회된 체크리스트 항목이 없습니다.
</td>
</tr>
) : (
selectedChecklistRows.map((row: any) => {
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
const hasResult = row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N";
const showRadios = row.bchckUseYn === "Y";
return (
<tr key={key}>
<td className="text-center text-[1.2rem]">{row.cklstArtclNm}</td>
<td
className="pl-2 !text-left text-[1.2rem] text-gray-600"
style={{ paddingLeft: `${Math.max(0, row.treeLevel - 1) * 16 + 8}px` }}
>
{row.treeLevel > 1 ? "└ " : ""}
{row.rvwCn}
</td>
{!showRadios ? (
<>
<td className="text-center">-</td>
<td className="text-center">-</td>
</>
) : (
<>
<td className="text-center">
<input
type="radio"
name={`chk_${key}`}
value="Y"
checked={(viewCheckResults[key] ?? "") === "Y"}
disabled={selectedReviewStep === "LOCAL"}
onChange={() =>
setViewCheckResults((prev) => ({ ...prev, [key]: "Y" }))
}
/>
</td>
<td className="text-center">
<input
type="radio"
name={`chk_${key}`}
value="N"
checked={(viewCheckResults[key] ?? "") === "N"}
disabled={selectedReviewStep === "LOCAL"}
onChange={() =>
setViewCheckResults((prev) => ({ ...prev, [key]: "N" }))
}
/>
</td>
</>
)}
{showRadios && selectedReviewStep !== "LOCAL" ? (
<td className="text-left">
<textarea
className="cnfm-input w-full"
value={viewCheckComments[key] ?? ""}
onChange={(e) =>
setViewCheckComments((prev) => ({ ...prev, [key]: e.target.value }))
}
/>
</td>
) : null}
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* 필수 첨부파일 */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">필수 첨부파일</h3>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th>필수 자료명</th>
<th>파일명</th>
<th className="w-[8rem] text-right">크기</th>
</tr>
</thead>
<tbody>
{esntlFileList.length === 0 ? (
<tr>
<td colSpan={3} className="cnfm-td-empty">
조회된 필수 첨부파일 항목이 없습니다.
</td>
</tr>
) : (
esntlFileList.map((f: any) => (
<tr key={f.esntlAtchFileId}>
<td className="pl-2">
<div className="w-full text-left">
{f.esntlAtchFileNm}
{f.esntlAtchYn === "Y" ? (
<span className="ml-1 text-red-500">*</span>
) : null}
</div>
</td>
<td>
{f.atflCd ? (
<a
className="text-blue-600"
href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`}
target="_blank"
rel="noreferrer"
>
{f.orgnlFileNm || "다운로드"}
</a>
) : (
<span className="text-gray-500">첨부없음</span>
)}
</td>
<td className="w-[8rem] text-right">
<div className="text-right">{formatFileSize(f.atflFlieSz)}</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* 기타 첨부파일 */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">기타 첨부파일</h3>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th>파일명</th>
<th className="w-[8rem] text-right">크기</th>
</tr>
</thead>
<tbody>
{etcFileList.length === 0 ? (
<tr>
<td colSpan={2} className="cnfm-td-empty">
등록된 기타첨부파일이 없습니다.
</td>
</tr>
) : (
etcFileList.map((f: any) => (
<tr key={f.atflId}>
<td>
{f.orgnlFileNm ? (
<a
className="text-blue-600"
href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`}
target="_blank"
rel="noreferrer"
>
{f.orgnlFileNm}
</a>
) : (
<span className="text-gray-700">{f.physFileNm || f.atflId}</span>
)}
</td>
<td className="w-[8rem] text-right text-gray-500">
{formatFileSize(f.atflFlieSz)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<div className="cnfm-action-row">
<Button color="white" onClick={() => router.push("/instl-sncn")}>
목록
</Button>
<Button onClick={() => router.push(`/instl-sncn/${id}/modify`)}>수정</Button>
<Button onClick={() => void hadleTechReviewSubmit()}>기술검토등록</Button>
</div>
</div>
);
}
export { CnfmViewPage };
@@ -74,10 +74,19 @@ function resolveButtonConfigs(actions: InstlSncnViewButtonAction[]): InstlSncnVi
return actions.map((action) => INSTL_SNCN_VIEW_BUTTON_ACTION[action]);
}
interface GetButtonConfigsOptions {
hasPendingApprovalRequest?: boolean;
}
function resolveRoleUser01AtrzButtonActions(
prgrsSttsComCd: string,
atrzSeComCdList: string[],
options?: GetButtonConfigsOptions,
): InstlSncnViewButtonAction[] {
if (prgrsSttsComCd === "TE" && options?.hasPendingApprovalRequest === false) {
return [];
}
const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd];
if (!atrzStatusMap || atrzSeComCdList.length === 0) return [];
@@ -114,12 +123,13 @@ export function getButtonConfigs(
dtlInstCd: string | undefined,
prgrsSttsComCd: string,
atrzSeComCdList?: string[],
options?: GetButtonConfigsOptions,
): InstlSncnViewButtonAction[] {
if (!dtlInstCd) return [];
if (needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd)) {
return resolveButtonConfigs(
resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? []),
resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? [], options),
);
}
@@ -7,8 +7,11 @@ import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib";
import { useElaprDocPrgrsByDocNo } from "@/entities/elapr";
import { processElaprPrgrs } from "@/entities/elapr/api/elapr-prgrs-service";
import {
elaprPrgrsSttsService,
findCurrentUserPendingApprovalRequest,
useElaprDocPrgrsByDocNo,
} from "@/entities/elapr";
import {
Button,
cn,
@@ -55,8 +58,9 @@ function ElaprApprovalDialog({
const orderedApprovers = sortByAtrzSeqNo(elaprDocPrgrsList);
const currentUserPrgrs = elaprDocPrgrsList.find(
(row) => row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(
elaprDocPrgrsList,
currentUserUuid,
);
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
@@ -65,7 +69,9 @@ function ElaprApprovalDialog({
setIsProcessing(true);
try {
await processElaprPrgrs(currentUserPrgrs.elaprPrgrsId, "ATRZ_PRGRS_003");
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
atrzPrgrsSttsComCd: "ATRZ_PRGRS_003",
});
alert("결재가 완료되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
@@ -94,7 +100,9 @@ function ElaprApprovalDialog({
setIsProcessing(true);
try {
await processElaprPrgrs(currentUserPrgrs.elaprPrgrsId, "ATRZ_PRGRS_004");
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
atrzPrgrsSttsComCd: "ATRZ_PRGRS_004",
});
alert("반려 처리가 완료되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
File diff suppressed because it is too large Load Diff
@@ -1,151 +0,0 @@
"use client";
import { dayjs, useAlertStore, useUserStore } from "@/shared/lib";
import { elaprDmndService } from "@/entities/elapr";
import { processElaprPrgrs } from "@/entities/elapr/api/elapr-prgrs-service";
import type { ElaprDocPrgrsClientResponseDto } from "@/entities/instl-sncn";
import { Button } from "@common/components";
import { getApproverStatus, sortByAtrzSeqNo } from "../../lib/approval-utils";
interface ApprovalInfoSectionProps {
docNo: string;
docTitle: string;
docContent: string;
elaprDocPrgrsList: ElaprDocPrgrsClientResponseDto[];
onRefresh: () => Promise<void>;
}
const DEFAULT_APPROVER_COLUMNS = [
{ code: "ATRZ01", label: "접수자" },
{ code: "ATRZ02", label: "검토자" },
{ code: "ATRZ03", label: "승인자" },
{ code: "ATRZ99", label: "최종승인자" },
] as const;
function ApprovalInfoSection({
docNo,
docTitle,
docContent,
elaprDocPrgrsList,
onRefresh,
}: ApprovalInfoSectionProps) {
const alert = useAlertStore((state) => state.setMessage);
const userInfo = useUserStore((state) => state.userInfo);
const orderedApprovers = sortByAtrzSeqNo(elaprDocPrgrsList);
const renderApproverCell = (row: ElaprDocPrgrsClientResponseDto | undefined) => {
if (!row) return <div className="text-center">-</div>;
const status = row.atrzPrgrsSttsComCd ?? "";
const st = getApproverStatus(status);
const canApprove =
status === "ATRZ_PRGRS_002" && row.elaprPrgrsId && userInfo?.userUnqId === row.aprvrUuid;
return (
<div className="text-center">
<div className="font-semibold">{row.aprvrUuidNm ?? "-"}</div>
<div className="text-[0.95rem] text-gray-500">
{row.ognzNm ?? ""}
{row.jbpsSeNm || row.jbgdSeNm
? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}`
: ""}
</div>
<div className="mt-1 flex flex-wrap items-center justify-center gap-2 text-[0.9rem]">
{row.atrzDt ? (
<span className="text-gray-400">{dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}</span>
) : null}
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${st.badgeClass}`}
>
{st.label}
</span>
{canApprove ? (
<Button
type="button"
size="sm"
onClick={() => void handleApprove(String(row.elaprPrgrsId))}
>
결재
</Button>
) : null}
</div>
</div>
);
};
const handleApprove = async (elaprPrgrsId: string) => {
try {
await processElaprPrgrs(elaprPrgrsId, "ATRZ_PRGRS_003");
alert("결재가 완료되었습니다.");
await onRefresh();
} catch {
alert("결재 처리 중 오류가 발생했습니다.");
}
};
const handleAssignApprover = async () => {
try {
await elaprDmndService.save({
elaprDocNo: docNo,
elaprTmpltId: "TMPLT20260611000001",
dmndTtl: docTitle,
dmndCn: docContent,
});
alert("결재자 지정(결재요청 등록)이 완료되었습니다.");
await onRefresh();
} catch {
alert("결재자 지정 중 오류가 발생했습니다.");
}
};
return (
<div className="cnfm-card">
<h3 className="text-[1.5rem] font-semibold">결재 정보</h3>
<div className="cnfm-table-wrap mt-3">
<table className="cnfm-table w-full">
<thead>
<tr>
{orderedApprovers.length === 0
? DEFAULT_APPROVER_COLUMNS.map((col) => (
<th key={col.code} className="text-center">
{col.label}
</th>
))
: orderedApprovers.map((row) => (
<th key={String(row.elaprPrgrsId ?? row.atrzSeComCd)} className="text-center">
{row.atrzSeComNm ?? row.atrzSeComCd}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
{orderedApprovers.length === 0
? DEFAULT_APPROVER_COLUMNS.map((col) => (
<td key={col.code}>
{renderApproverCell(
elaprDocPrgrsList.find((row) => row.atrzSeComCd === col.code),
)}
</td>
))
: orderedApprovers.map((row) => (
<td key={String(row.elaprPrgrsId ?? row.atrzSeComCd)}>
{renderApproverCell(row)}
</td>
))}
</tr>
</tbody>
</table>
</div>
<div className="mt-4 text-right">
<Button type="button" onClick={() => void handleAssignApprover()}>
결재자 지정
</Button>
</div>
</div>
);
}
export { ApprovalInfoSection };
+1
View File
@@ -27,6 +27,7 @@ export const ENDPOINT = {
ELAPR_TMPLT: "/as-elapr-service/api/v1/elapr-tmplt",
ELAPR_APLN: "/as-elapr-service/api/v1/elapr-apln",
ELAPR_DMND: "/as-elapr-service/api/v1/elapr-dmnd",
ELAPR_PRGRS_STTS: "/as-elapr-service/api/v1/elapr-prgrs-stts",
},
CMS_SERVICE: {
CODE: "/cms-service/api/v1/code/cache",