WIP: 전자결재 로직 수정
This commit is contained in:
@@ -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;
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
+31
-4
@@ -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
-1
@@ -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),
|
||||
);
|
||||
}
|
||||
+25
-20
@@ -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;
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
|
||||
+1
-1
@@ -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 };
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user