[as] feat: 재원협의 전자결재 로직 수정
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"@faker-js/faker": "^9.9.0",
|
||||
"@frontend-opensource/use-react-hooks": "^1.1.4",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.75.5",
|
||||
"@tanstack/react-query-devtools": "^5.75.5",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
|
||||
@@ -56,9 +56,14 @@ export {
|
||||
assignPrgrsSttsAtrzSeqNumbers,
|
||||
buildPrgrsSttsSavePayload,
|
||||
hasUnsavedPrgrsSttsChanges,
|
||||
isPrgrsSttsRowEditable,
|
||||
PRGRS_STTS_WAITING_CD,
|
||||
validateAllPrgrsSttsRows,
|
||||
validatePrgrsSttsRow,
|
||||
} from "./lib/prgrs-stts-save-utils";
|
||||
export { createDraftRowKey, useAplnRows } from "./lib/use-apln-rows";
|
||||
export { useDndSensors } from "./lib/use-dnd-sensors";
|
||||
export { usePrgrsSttsRows } from "./lib/use-prgrs-stts-rows";
|
||||
export {
|
||||
ELAPR_APLN_SAVE_MODE,
|
||||
type ElaprApln,
|
||||
|
||||
@@ -3,13 +3,18 @@ import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
|
||||
const ATRZ_RECEIVER_CD = "ATRZ01";
|
||||
const ATRZ_FINAL_CD = "ATRZ99";
|
||||
|
||||
interface AtrzOrderValidateRow {
|
||||
atrzSeComCd?: string;
|
||||
isDeleted?: boolean;
|
||||
}
|
||||
|
||||
export function getVisibleRows(rows: ElaprAplnRow[]): ElaprAplnRow[] {
|
||||
return rows.filter((row) => !row.isDeleted);
|
||||
}
|
||||
|
||||
/** 접수자·최종승인자 위치·중복 검증 (최종 저장 시점) */
|
||||
export function validateAtrzOrder(rows: ElaprAplnRow[]): string | null {
|
||||
const visibleRows = getVisibleRows(rows);
|
||||
export function validateAtrzOrder(rows: AtrzOrderValidateRow[]): string | null {
|
||||
const visibleRows = rows.filter((row) => !row.isDeleted);
|
||||
|
||||
const receiverCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_RECEIVER_CD).length;
|
||||
if (receiverCount === 0) {
|
||||
|
||||
@@ -7,19 +7,17 @@ export function hasCurrentUserPendingApprovalRequest(
|
||||
if (!currentUserUuid) return false;
|
||||
|
||||
return list.some(
|
||||
(row) =>
|
||||
row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
|
||||
(row) => row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
|
||||
);
|
||||
}
|
||||
|
||||
export function findCurrentUserPendingApprovalRequest(
|
||||
list: ElaprDocPrgrsResponseDto[],
|
||||
currentUserUuid: string | undefined,
|
||||
list?: ElaprDocPrgrsResponseDto[],
|
||||
currentUserUuid?: string | undefined,
|
||||
): ElaprDocPrgrsResponseDto | undefined {
|
||||
if (!currentUserUuid) return undefined;
|
||||
|
||||
return list.find(
|
||||
(row) =>
|
||||
row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
|
||||
return list?.find(
|
||||
(row) => row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import {
|
||||
ELAPR_PRGRS_STTS_SAVE_MODE,
|
||||
type ElaprPrgrsSttsResponseDto,
|
||||
type ElaprPrgrsSttsSaveRequestDto,
|
||||
} from "../model/elapr-prgrs-stts-interface";
|
||||
|
||||
export const PRGRS_STTS_WAITING_CD = "ATRZ_PRGRS_001";
|
||||
|
||||
/** 서버 행 비교용 최소 필드 */
|
||||
export interface PrgrsSttsServerRow {
|
||||
elaprPrgrsId?: string;
|
||||
aprvrUuid?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeqNo?: number;
|
||||
}
|
||||
|
||||
/** 결재선 그리드 로컬 행 — 진행상태 API 응답 + UI 보조 필드 */
|
||||
export interface ElaprPrgrsSttsRow {
|
||||
rowKey: string;
|
||||
elaprPrgrsId?: string;
|
||||
elaprDmndId?: string;
|
||||
aprvrUuid?: string;
|
||||
aprvrNm?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeComNm?: string;
|
||||
atrzSeqNo?: number;
|
||||
atrzPrgrsSttsComCd?: string;
|
||||
atrzPrgrsSttsComNm?: string;
|
||||
rjctRsn?: string | null;
|
||||
ognzNm?: string;
|
||||
jbpsSeNm?: string;
|
||||
jbgdSeNm?: string;
|
||||
isNew?: boolean;
|
||||
isDirty?: boolean;
|
||||
/** 소프트 삭제 — 최종 저장 시 mode:D */
|
||||
@@ -21,6 +37,10 @@ export interface ElaprPrgrsSttsRow {
|
||||
snapshot?: ElaprPrgrsSttsRow;
|
||||
}
|
||||
|
||||
export function isPrgrsSttsRowEditable(row: ElaprPrgrsSttsRow): boolean {
|
||||
return row.isNew === true || row.atrzPrgrsSttsComCd === PRGRS_STTS_WAITING_CD;
|
||||
}
|
||||
|
||||
function getVisibleRows(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] {
|
||||
return rows.filter((row) => !row.isDeleted);
|
||||
}
|
||||
@@ -30,10 +50,38 @@ export function assignPrgrsSttsAtrzSeqNumbers(rows: ElaprPrgrsSttsRow[]): ElaprP
|
||||
return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 }));
|
||||
}
|
||||
|
||||
function rowDiffersFromServer(
|
||||
row: ElaprPrgrsSttsRow,
|
||||
serverRow: ElaprPrgrsSttsResponseDto,
|
||||
): boolean {
|
||||
export function validatePrgrsSttsRow(row: ElaprPrgrsSttsRow): string | null {
|
||||
if (!row.atrzSeComCd?.trim()) {
|
||||
return "결재구분을 선택해 주세요.";
|
||||
}
|
||||
if (!row.aprvrUuid?.trim()) {
|
||||
return "결재자를 선택해 주세요.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateAllPrgrsSttsRows(
|
||||
rows: ElaprPrgrsSttsRow[],
|
||||
isEditingLocked: boolean,
|
||||
): string | null {
|
||||
if (isEditingLocked) {
|
||||
return "편집 중인 행을 먼저 저장하거나 취소해 주세요.";
|
||||
}
|
||||
|
||||
const visibleRows = getVisibleRows(rows);
|
||||
if (visibleRows.length === 0) {
|
||||
return "저장할 결재선이 없습니다.";
|
||||
}
|
||||
|
||||
for (const row of visibleRows) {
|
||||
const fieldError = validatePrgrsSttsRow(row);
|
||||
if (fieldError) return fieldError;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function rowDiffersFromServer(row: ElaprPrgrsSttsRow, serverRow: PrgrsSttsServerRow): boolean {
|
||||
return (
|
||||
row.aprvrUuid !== serverRow.aprvrUuid ||
|
||||
row.atrzSeComCd !== serverRow.atrzSeComCd ||
|
||||
@@ -41,10 +89,7 @@ function rowDiffersFromServer(
|
||||
);
|
||||
}
|
||||
|
||||
function shouldIncludeUpdate(
|
||||
row: ElaprPrgrsSttsRow,
|
||||
serverRows: ElaprPrgrsSttsResponseDto[],
|
||||
): boolean {
|
||||
function shouldIncludeUpdate(row: ElaprPrgrsSttsRow, serverRows: PrgrsSttsServerRow[]): boolean {
|
||||
if (row.isDirty) return true;
|
||||
if (!row.elaprPrgrsId) return false;
|
||||
|
||||
@@ -56,7 +101,7 @@ function shouldIncludeUpdate(
|
||||
|
||||
export function hasUnsavedPrgrsSttsChanges(
|
||||
rows: ElaprPrgrsSttsRow[],
|
||||
serverRows: ElaprPrgrsSttsResponseDto[],
|
||||
serverRows: PrgrsSttsServerRow[],
|
||||
): boolean {
|
||||
if (rows.some((row) => row.isDeleted && row.elaprPrgrsId)) return true;
|
||||
if (rows.some((row) => row.isNew && !row.isDeleted)) return true;
|
||||
@@ -79,7 +124,7 @@ export function hasUnsavedPrgrsSttsChanges(
|
||||
export function buildPrgrsSttsSavePayload(
|
||||
rows: ElaprPrgrsSttsRow[],
|
||||
elaprDmndId: string,
|
||||
serverRows: ElaprPrgrsSttsResponseDto[],
|
||||
serverRows: PrgrsSttsServerRow[],
|
||||
): ElaprPrgrsSttsSaveRequestDto[] {
|
||||
const payload: ElaprPrgrsSttsSaveRequestDto[] = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model";
|
||||
|
||||
import {
|
||||
assignPrgrsSttsAtrzSeqNumbers,
|
||||
type ElaprPrgrsSttsRow,
|
||||
isPrgrsSttsRowEditable,
|
||||
PRGRS_STTS_WAITING_CD,
|
||||
} from "./prgrs-stts-save-utils";
|
||||
import { createDraftRowKey } from "./use-apln-rows";
|
||||
|
||||
function getVisibleRows(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] {
|
||||
return rows.filter((row) => !row.isDeleted);
|
||||
}
|
||||
|
||||
function toRow(item: ElaprDocPrgrsClientResponseDto): ElaprPrgrsSttsRow {
|
||||
return {
|
||||
rowKey: item.elaprPrgrsId ?? createDraftRowKey(),
|
||||
elaprPrgrsId: item.elaprPrgrsId,
|
||||
elaprDmndId: item.elaprDmndId,
|
||||
aprvrUuid: item.aprvrUuid,
|
||||
aprvrNm: item.aprvrUuidNm,
|
||||
atrzSeComCd: item.atrzSeComCd,
|
||||
atrzSeComNm: item.atrzSeComNm,
|
||||
atrzSeqNo: item.atrzSeqNo,
|
||||
atrzPrgrsSttsComCd: item.atrzPrgrsSttsComCd,
|
||||
atrzPrgrsSttsComNm: item.atrzPrgrsSttsComNm,
|
||||
rjctRsn: item.rjctRsn,
|
||||
ognzNm: item.ognzNm,
|
||||
jbpsSeNm: item.jbpsSeNm,
|
||||
jbgdSeNm: item.jbgdSeNm,
|
||||
isNew: false,
|
||||
isDirty: false,
|
||||
isDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
interface UsePrgrsSttsRowsOptions {
|
||||
serverRows?: ElaprDocPrgrsClientResponseDto[];
|
||||
elaprDmndId?: string | null;
|
||||
}
|
||||
|
||||
export function usePrgrsSttsRows({ serverRows, elaprDmndId }: UsePrgrsSttsRowsOptions) {
|
||||
const [rows, setRows] = useState<ElaprPrgrsSttsRow[]>([]);
|
||||
const [editingRowKey, setEditingRowKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRows((serverRows ?? []).map(toRow));
|
||||
setEditingRowKey(null);
|
||||
}, [serverRows]);
|
||||
|
||||
const isEditingLocked = editingRowKey != null;
|
||||
const visibleRows = getVisibleRows(rows);
|
||||
|
||||
const addRow = useCallback(() => {
|
||||
if (!elaprDmndId || isEditingLocked) return;
|
||||
|
||||
const rowKey = createDraftRowKey();
|
||||
|
||||
setRows((prev) => [
|
||||
...prev,
|
||||
{
|
||||
rowKey,
|
||||
elaprDmndId,
|
||||
atrzSeComCd: "",
|
||||
aprvrUuid: "",
|
||||
aprvrNm: "",
|
||||
atrzPrgrsSttsComCd: PRGRS_STTS_WAITING_CD,
|
||||
isNew: true,
|
||||
isDirty: true,
|
||||
isDeleted: false,
|
||||
},
|
||||
]);
|
||||
setEditingRowKey(rowKey);
|
||||
}, [elaprDmndId, isEditingLocked]);
|
||||
|
||||
const startEdit = useCallback(
|
||||
(rowKey: string) => {
|
||||
if (isEditingLocked) return;
|
||||
|
||||
const target = rows.find((r) => r.rowKey === rowKey);
|
||||
if (!target || !isPrgrsSttsRowEditable(target)) return;
|
||||
|
||||
setEditingRowKey(rowKey);
|
||||
setRows((prev) =>
|
||||
prev.map((row) => (row.rowKey === rowKey ? { ...row, snapshot: { ...row } } : row)),
|
||||
);
|
||||
},
|
||||
[isEditingLocked, rows],
|
||||
);
|
||||
|
||||
const updateRow = useCallback(
|
||||
(rowKey: string, patch: Partial<ElaprPrgrsSttsRow>) => {
|
||||
if (editingRowKey !== rowKey) return;
|
||||
setRows((prev) =>
|
||||
prev.map((row) => (row.rowKey === rowKey ? { ...row, ...patch, isDirty: true } : row)),
|
||||
);
|
||||
},
|
||||
[editingRowKey],
|
||||
);
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
if (!editingRowKey) return;
|
||||
|
||||
setRows((prev) => {
|
||||
const target = prev.find((r) => r.rowKey === editingRowKey);
|
||||
if (target?.isNew) {
|
||||
return prev.filter((r) => r.rowKey !== editingRowKey);
|
||||
}
|
||||
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;
|
||||
return { ...restored, isNew: false, isDirty: false, isDeleted: false };
|
||||
}
|
||||
return { ...row, isDirty: false };
|
||||
});
|
||||
});
|
||||
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 markDeleted = useCallback((rowKey: string) => {
|
||||
setRows((prev) => {
|
||||
const target = prev.find((r) => r.rowKey === rowKey);
|
||||
if (!target || !isPrgrsSttsRowEditable(target)) return prev;
|
||||
|
||||
if (target.isNew) {
|
||||
return prev.filter((r) => r.rowKey !== rowKey);
|
||||
}
|
||||
|
||||
return prev.map((row) =>
|
||||
row.rowKey === rowKey ? { ...row, isDeleted: true, isDirty: true } : row,
|
||||
);
|
||||
});
|
||||
setEditingRowKey((key) => (key === rowKey ? null : key));
|
||||
}, []);
|
||||
|
||||
const reorderRows = useCallback(
|
||||
(activeKey: string, overKey: string): ElaprPrgrsSttsRow[] => {
|
||||
if (isEditingLocked) return [];
|
||||
|
||||
let payloadRows: ElaprPrgrsSttsRow[] = [];
|
||||
|
||||
setRows((prev) => {
|
||||
const visible = getVisibleRows(prev);
|
||||
const activeRow = visible.find((r) => r.rowKey === activeKey);
|
||||
const overRow = visible.find((r) => r.rowKey === overKey);
|
||||
if (!activeRow || !overRow) return prev;
|
||||
if (!isPrgrsSttsRowEditable(activeRow) || !isPrgrsSttsRowEditable(overRow)) return prev;
|
||||
|
||||
const oldIndex = visible.findIndex((r) => r.rowKey === activeKey);
|
||||
const newIndex = visible.findIndex((r) => r.rowKey === overKey);
|
||||
if (oldIndex < 0 || newIndex < 0) return prev;
|
||||
|
||||
const reorderedVisible = assignPrgrsSttsAtrzSeqNumbers(
|
||||
arrayMove(visible, oldIndex, newIndex),
|
||||
).map((row) => (isPrgrsSttsRowEditable(row) ? { ...row, isDirty: true } : row));
|
||||
|
||||
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)),
|
||||
];
|
||||
|
||||
payloadRows = reorderedVisible.filter(isPrgrsSttsRowEditable);
|
||||
return merged;
|
||||
});
|
||||
|
||||
return payloadRows;
|
||||
},
|
||||
[isEditingLocked],
|
||||
);
|
||||
|
||||
return {
|
||||
rows,
|
||||
visibleRows,
|
||||
editingRowKey,
|
||||
isEditingLocked,
|
||||
addRow,
|
||||
startEdit,
|
||||
updateRow,
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
markDeleted,
|
||||
reorderRows,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { CklstRvwRsltTreeDto, CklstResultItem } from "@/entities/fnrs-cnsltn";
|
||||
import type { CklstResultItem, CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn";
|
||||
|
||||
import {
|
||||
type ChecklistFormState,
|
||||
type ChecklistProgress,
|
||||
type ChecklistRowKey,
|
||||
type ChecklistStepMap,
|
||||
type ReviewStepCode,
|
||||
REVIEW_STEP_CODES,
|
||||
type ReviewStepCode,
|
||||
} from "../model/view-types";
|
||||
|
||||
export function buildChecklistRowKey(
|
||||
@@ -89,7 +89,7 @@ export function computeReviewStepChecklistProgress(
|
||||
const val = results[key];
|
||||
return acc + (val === "Y" || val === "N" ? 1 : 0);
|
||||
}, 0);
|
||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 100;
|
||||
return { total, completed, percent };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
FnrsCnsltnMngDetResponseDto,
|
||||
FnrsCnsltnRvwOpnnResponseDto,
|
||||
FnrsCnsltnRvwOpnnSaveRequestDto,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
|
||||
@@ -11,35 +12,83 @@ export interface ReviewOpinionFormData {
|
||||
rvwOpnnSn?: number;
|
||||
}
|
||||
|
||||
export interface ReviewOpinionGetFormDataResult {
|
||||
formData: ReviewOpinionFormData;
|
||||
rvwList: FnrsCnsltnRvwOpnnResponseDto[];
|
||||
}
|
||||
|
||||
function hasReviewOpinionContent(formData: ReviewOpinionFormData) {
|
||||
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
|
||||
}
|
||||
|
||||
function mapServerItemToSaveDto(
|
||||
item: FnrsCnsltnRvwOpnnResponseDto,
|
||||
fnrsCnsltnDocNo: string,
|
||||
): FnrsCnsltnRvwOpnnSaveRequestDto | null {
|
||||
if (item.rvwOpnnSn == null) return null;
|
||||
|
||||
return {
|
||||
fnrsCnsltnDocNo,
|
||||
rvwOpnnSn: item.rvwOpnnSn,
|
||||
opnrSeComCd: item.opnrSeComCd,
|
||||
opnrNm: item.opnrNm,
|
||||
rvwRsltCn: item.rvwRsltCn,
|
||||
rvwRsltAnmlyYn: item.rvwRsltAnmlyYn || undefined,
|
||||
atchFileGroupId: item.atchFileGroupId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mapFormDataToSaveDto(
|
||||
formData: ReviewOpinionFormData,
|
||||
fnrsCnsltnDocNo: string,
|
||||
opnrNm?: string,
|
||||
): FnrsCnsltnRvwOpnnSaveRequestDto {
|
||||
const base = {
|
||||
fnrsCnsltnDocNo,
|
||||
opnrSeComCd: formData.opnrSeComCd,
|
||||
opnrNm: opnrNm ?? "",
|
||||
rvwRsltCn: formData.rvwRsltCn,
|
||||
rvwRsltAnmlyYn: formData.rvwRsltAnmlyYn || undefined,
|
||||
atchFileGroupId: formData.atchFileGroupId || undefined,
|
||||
};
|
||||
|
||||
if (formData.rvwOpnnSn != null) {
|
||||
return { ...base, rvwOpnnSn: formData.rvwOpnnSn };
|
||||
}
|
||||
|
||||
return base as FnrsCnsltnRvwOpnnSaveRequestDto;
|
||||
}
|
||||
|
||||
export function toRvwOpnnSaveDto(
|
||||
data: FnrsCnsltnMngDetResponseDto,
|
||||
reviewOpinionFormData: ReviewOpinionFormData | null | undefined,
|
||||
result: ReviewOpinionGetFormDataResult | null | undefined,
|
||||
opnrNm?: string,
|
||||
): FnrsCnsltnRvwOpnnSaveRequestDto[] | undefined {
|
||||
if (!reviewOpinionFormData || !data.detDto) return undefined;
|
||||
if (!result || !data.detDto) return undefined;
|
||||
|
||||
const hasContent =
|
||||
reviewOpinionFormData.rvwRsltCn.trim() ||
|
||||
reviewOpinionFormData.rvwRsltAnmlyYn ||
|
||||
reviewOpinionFormData.atchFileGroupId;
|
||||
const { formData, rvwList } = result;
|
||||
const fnrsCnsltnDocNo = data.detDto.fnrsCnsltnDocNo;
|
||||
|
||||
if (!hasContent) return undefined;
|
||||
const list: FnrsCnsltnRvwOpnnSaveRequestDto[] = rvwList
|
||||
.map((item) => mapServerItemToSaveDto(item, fnrsCnsltnDocNo))
|
||||
.filter((item): item is FnrsCnsltnRvwOpnnSaveRequestDto => item != null);
|
||||
|
||||
const existingSn =
|
||||
reviewOpinionFormData.rvwOpnnSn ??
|
||||
data.rvwList?.find((item) => item.opnrSeComCd === reviewOpinionFormData.opnrSeComCd)
|
||||
?.rvwOpnnSn ??
|
||||
1;
|
||||
if (!hasReviewOpinionContent(formData)) {
|
||||
return list.length > 0 ? list : undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
fnrsCnsltnDocNo: data.detDto.fnrsCnsltnDocNo,
|
||||
rvwOpnnSn: existingSn,
|
||||
opnrSeComCd: reviewOpinionFormData.opnrSeComCd,
|
||||
opnrNm: opnrNm ?? "",
|
||||
rvwRsltCn: reviewOpinionFormData.rvwRsltCn,
|
||||
rvwRsltAnmlyYn: reviewOpinionFormData.rvwRsltAnmlyYn || undefined,
|
||||
atchFileGroupId: reviewOpinionFormData.atchFileGroupId || undefined,
|
||||
},
|
||||
];
|
||||
const formDto = mapFormDataToSaveDto(formData, fnrsCnsltnDocNo, opnrNm);
|
||||
|
||||
if (formData.rvwOpnnSn != null) {
|
||||
const existingIndex = list.findIndex((item) => item.rvwOpnnSn === formData.rvwOpnnSn);
|
||||
if (existingIndex >= 0) {
|
||||
list[existingIndex] = formDto;
|
||||
} else {
|
||||
list.push(formDto);
|
||||
}
|
||||
} else {
|
||||
list.push(formDto);
|
||||
}
|
||||
|
||||
return list.length > 0 ? list : undefined;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { mapFnrsSharDetToFormList, toFnrsSharSaveList } from "@/widgets/pbadms-s
|
||||
import type { ChecklistFormState, ReviewStepCode } from "../model/view-types";
|
||||
|
||||
import { toCklstSaveDto } from "./checklist-utils";
|
||||
import { type ReviewOpinionFormData, toRvwOpnnSaveDto } from "./review-opinion-utils";
|
||||
import { type ReviewOpinionGetFormDataResult, toRvwOpnnSaveDto } from "./review-opinion-utils";
|
||||
|
||||
export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): BzScaleItem[] {
|
||||
return bizSclList
|
||||
@@ -88,7 +88,7 @@ export function buildTechReviewReplyPayload(
|
||||
reviewStep: ReviewStepCode,
|
||||
prgrsSttsComCd: string,
|
||||
techReViewSaveCd: string,
|
||||
reviewOpinionFormData?: ReviewOpinionFormData | null,
|
||||
reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null,
|
||||
opnrNm?: string,
|
||||
): FnrsCnsltnApplySaveRequestDto {
|
||||
return {
|
||||
|
||||
@@ -2,31 +2,49 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { elaprPrgrsSttsService, findCurrentUserPendingApprovalRequest, useElaprDocPrgrsByDocNo } from "@/entities/elapr";
|
||||
import {
|
||||
buildPrgrsSttsSavePayload,
|
||||
elaprPrgrsSttsService,
|
||||
findCurrentUserPendingApprovalRequest,
|
||||
hasUnsavedPrgrsSttsChanges,
|
||||
isPrgrsSttsRowEditable,
|
||||
useDndSensors,
|
||||
useElaprDocPrgrsByDocNo,
|
||||
usePrgrsSttsRows,
|
||||
useSaveElaprPrgrsStts,
|
||||
validateAllPrgrsSttsRows,
|
||||
validateAtrzOrder,
|
||||
validatePrgrsSttsRow,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Label,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import { getApproverStatus, sortByAtrzSeqNo } from "../../lib/approval-utils";
|
||||
import type { ReviewerOption } from "../../model/reviewer-option";
|
||||
|
||||
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
|
||||
import { SortablePrgrsSttsRow } from "./sortable-prgrs-stts-row";
|
||||
|
||||
interface ElaprApprovalDialogProps {
|
||||
open: boolean;
|
||||
@@ -36,6 +54,8 @@ interface ElaprApprovalDialogProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const UNSAVED_PRGRS_STTS_MESSAGE = "결재선 변경 이력이 있습니다.\n결재선 저장을 해 주세요.";
|
||||
|
||||
function ElaprApprovalDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -47,12 +67,33 @@ function ElaprApprovalDialog({
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [rjctRsn, setRjctRsn] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickingRowKey, setPickingRowKey] = useState<string | null>(null);
|
||||
const [finalApprovalYn, setFinalApprovalYn] = useState(false);
|
||||
|
||||
const { data: elaprDocPrgrsList = [], isLoading } = useElaprDocPrgrsByDocNo(
|
||||
open ? elaprDocNo : null,
|
||||
);
|
||||
const { data: elaprDocPrgrsList, isLoading } = useElaprDocPrgrsByDocNo(open ? elaprDocNo : null);
|
||||
|
||||
const orderedApprovers = sortByAtrzSeqNo(elaprDocPrgrsList);
|
||||
const elaprDmndId = elaprDocPrgrsList?.[0]?.elaprDmndId;
|
||||
|
||||
const {
|
||||
rows,
|
||||
visibleRows,
|
||||
editingRowKey,
|
||||
isEditingLocked,
|
||||
addRow,
|
||||
startEdit,
|
||||
updateRow,
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
markDeleted,
|
||||
reorderRows,
|
||||
} = usePrgrsSttsRows({
|
||||
serverRows: elaprDocPrgrsList,
|
||||
elaprDmndId,
|
||||
});
|
||||
|
||||
const savePrgrsSttsMutation = useSaveElaprPrgrsStts();
|
||||
const sensors = useDndSensors();
|
||||
|
||||
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(
|
||||
elaprDocPrgrsList,
|
||||
@@ -60,13 +101,106 @@ function ElaprApprovalDialog({
|
||||
);
|
||||
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
|
||||
|
||||
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
|
||||
const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
|
||||
const isPrgrsSttsUnsaved = hasUnsavedPrgrsSttsChanges(rows, elaprDocPrgrsList ?? []);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
reorderRows(String(active.id), String(over.id));
|
||||
}
|
||||
|
||||
function handleRowSave(rowKey: string) {
|
||||
const row = rows.find((r) => r.rowKey === rowKey);
|
||||
if (!row) return;
|
||||
|
||||
const fieldError = validatePrgrsSttsRow(row);
|
||||
if (fieldError) {
|
||||
alert(fieldError);
|
||||
return;
|
||||
}
|
||||
|
||||
commitEdit(rowKey);
|
||||
}
|
||||
|
||||
function handleReviewerSelect(reviewer: ReviewerOption) {
|
||||
if (!pickingRowKey) return;
|
||||
updateRow(pickingRowKey, {
|
||||
aprvrUuid: reviewer.uuid,
|
||||
aprvrNm: reviewer.name,
|
||||
ognzNm: reviewer.deptNm,
|
||||
});
|
||||
setPickingRowKey(null);
|
||||
}
|
||||
|
||||
function assertPrgrsSttsSaved(): boolean {
|
||||
if (isEditingLocked) {
|
||||
alert(UNSAVED_PRGRS_STTS_MESSAGE);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPrgrsSttsUnsaved) {
|
||||
alert(UNSAVED_PRGRS_STTS_MESSAGE);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleSavePrgrsStts() {
|
||||
if (!elaprDmndId) {
|
||||
alert("결재 요청 정보가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateAllPrgrsSttsRows(rows, isEditingLocked);
|
||||
if (validationError) {
|
||||
alert(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const orderError = validateAtrzOrder(rows);
|
||||
if (orderError) {
|
||||
alert(orderError);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildPrgrsSttsSavePayload(rows, elaprDmndId, elaprDocPrgrsList ?? []);
|
||||
if (payload.length === 0) {
|
||||
alert("변경된 결재선이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await savePrgrsSttsMutation.mutateAsync(payload);
|
||||
alert("결재선이 저장되었습니다.");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
|
||||
});
|
||||
onSuccess();
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "결재선 저장 중 오류가 발생했습니다.";
|
||||
if (error instanceof AxiosError) {
|
||||
errorMessage = error.response?.data?.message ?? errorMessage;
|
||||
}
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!currentUserPrgrs?.elaprPrgrsId) return;
|
||||
if (!assertPrgrsSttsSaved()) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
|
||||
atrzPrgrsSttsComCd: "ATRZ_PRGRS_003",
|
||||
finalApprovalYn,
|
||||
});
|
||||
alert("결재가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
@@ -93,11 +227,13 @@ function ElaprApprovalDialog({
|
||||
alert("반려 사유를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!assertPrgrsSttsSaved()) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
|
||||
atrzPrgrsSttsComCd: "ATRZ_PRGRS_004",
|
||||
rjctRsn: rjctRsn.trim(),
|
||||
});
|
||||
alert("반려 처리가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
@@ -120,6 +256,7 @@ function ElaprApprovalDialog({
|
||||
|
||||
function handleClose() {
|
||||
setRjctRsn("");
|
||||
setPickingRowKey(null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
@@ -132,61 +269,97 @@ function ElaprApprovalDialog({
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-3 text-xl font-semibold">결재 진행 단계</h4>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<h4 className="text-xl font-semibold">결재 진행 단계</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
color="white"
|
||||
disabled={!elaprDmndId || isEditingLocked || isBusy}
|
||||
onClick={addRow}
|
||||
>
|
||||
결재선 추가
|
||||
</Button>
|
||||
{elaprDmndId && (
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={handleSavePrgrsStts}
|
||||
disabled={!isPrgrsSttsUnsaved || isEditingLocked || isBusy}
|
||||
>
|
||||
결재선 저장
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
) : orderedApprovers.length === 0 ? (
|
||||
<div className="text-center text-gray-500">결재 정보가 없습니다.</div>
|
||||
) : visibleRows.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
{elaprDmndId
|
||||
? "등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요."
|
||||
: "결재 정보가 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="line">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="8%" />
|
||||
<col width="10%" />
|
||||
<col width="15%" />
|
||||
<col width="35%" />
|
||||
<col width="12%" />
|
||||
<col width="20%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{orderedApprovers.map((row) => (
|
||||
<TableHead key={String(row.elaprPrgrsId ?? row.atrzSeComCd)}>
|
||||
{row.atrzSeComNm ?? row.atrzSeComCd}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>결재순서</TableHead>
|
||||
<TableHead>결재구분</TableHead>
|
||||
<TableHead>결재자</TableHead>
|
||||
<TableHead>결재상태</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
{orderedApprovers.map((row) => {
|
||||
const status = row.atrzPrgrsSttsComCd ?? "";
|
||||
const st = getApproverStatus(status);
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={String(row.elaprPrgrsId ?? row.atrzSeComCd)}
|
||||
align="center"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-md px-2 py-1 text-base font-semibold",
|
||||
st.badgeClass,
|
||||
)}
|
||||
>
|
||||
{st.label}
|
||||
</span>
|
||||
<span className="font-semibold">{row.aprvrUuidNm ?? "-"}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-base text-gray-500">
|
||||
{row.ognzNm ?? ""}
|
||||
{row.jbpsSeNm || row.jbgdSeNm
|
||||
? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}`
|
||||
: ""}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
<SortableContext
|
||||
items={visibleRows.map((r) => r.rowKey)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{visibleRows.map((row, index) => (
|
||||
<SortablePrgrsSttsRow
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
displaySeqNo={index + 1}
|
||||
isEditing={editingRowKey === row.rowKey}
|
||||
isLocked={isEditingLocked}
|
||||
dragDisabled={
|
||||
isEditingLocked || !isPrgrsSttsRowEditable(row) || !row.atrzSeComCd
|
||||
}
|
||||
onStartEdit={() => startEdit(row.rowKey)}
|
||||
onSave={() => handleRowSave(row.rowKey)}
|
||||
onCancel={cancelEdit}
|
||||
onDelete={() => markDeleted(row.rowKey)}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onPickAprvr={() => {
|
||||
setPickingRowKey(row.rowKey);
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -205,21 +378,40 @@ function ElaprApprovalDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={handleClose} disabled={isProcessing}>
|
||||
취소
|
||||
</Button>
|
||||
{canApprove && (
|
||||
<>
|
||||
<Button color="red" onClick={handleReject} disabled={isProcessing}>
|
||||
반려
|
||||
</Button>
|
||||
<Button onClick={handleApprove} disabled={isProcessing}>
|
||||
결재
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button type="button" color="white" onClick={handleClose} disabled={isBusy}>
|
||||
취소
|
||||
</Button>
|
||||
{canApprove && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mr-2 flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="all-approve"
|
||||
checked={finalApprovalYn}
|
||||
onCheckedChange={(checked) => setFinalApprovalYn(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="all-approve" className="text-xl">
|
||||
전결
|
||||
</Label>
|
||||
</div>
|
||||
<Button onClick={handleApprove} disabled={isBusy}>
|
||||
결재
|
||||
</Button>
|
||||
<Button color="red" onClick={handleReject} disabled={isBusy}>
|
||||
반려
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
<AprvrPickerDialog
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
atrzSeComCd={pickingRow?.atrzSeComCd ?? ""}
|
||||
onSelect={handleReviewerSelect}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ function FloatingActionButtons({
|
||||
request,
|
||||
data,
|
||||
onSuccess: () => {
|
||||
alert(successMessage ?? "처리가 완료되었습니다.");
|
||||
if (successMessage) alert(successMessage);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo],
|
||||
});
|
||||
@@ -388,7 +388,7 @@ function FloatingActionButtons({
|
||||
checklistFormState,
|
||||
"KECO",
|
||||
"TE",
|
||||
"CP",
|
||||
"SV",
|
||||
getReviewOpinionFormData(),
|
||||
opnrNm,
|
||||
);
|
||||
@@ -396,9 +396,9 @@ function FloatingActionButtons({
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
|
||||
request: payload,
|
||||
successMessage: "검토완료 처리되었습니다.",
|
||||
});
|
||||
techReviewOpinionRef?.current?.resetForm();
|
||||
setActiveDialog("elaprApproval");
|
||||
}
|
||||
|
||||
function handleDmndSplmntClick() {
|
||||
@@ -531,8 +531,6 @@ function FloatingActionButtons({
|
||||
elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo}
|
||||
currentUserUuid={userInfo?.userUnqId ?? ""}
|
||||
onSuccess={() => {
|
||||
setActiveDialog(null);
|
||||
alert("결재가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo],
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Equal } from "lucide-react";
|
||||
import { MoveVertical } from "lucide-react";
|
||||
|
||||
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import type { ElaprAplnRow } from "@/entities/elapr";
|
||||
@@ -63,7 +63,7 @@ function SortableAplnPickerRow({
|
||||
{...listeners}
|
||||
aria-label="순서 변경"
|
||||
>
|
||||
<Equal className="!size-6" />
|
||||
<MoveVertical className="!size-6" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell align="center">{row.isNew ? "-" : (row.aplnId ?? "-")}</TableCell>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { MoveVertical } from "lucide-react";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import { type ElaprPrgrsSttsRow, isPrgrsSttsRowEditable } from "@/entities/elapr";
|
||||
import { Button, cn, TableCell, TableRow } from "@common/components";
|
||||
|
||||
import { getApproverStatus } from "../../lib/approval-utils";
|
||||
|
||||
interface SortablePrgrsSttsRowProps {
|
||||
row: ElaprPrgrsSttsRow;
|
||||
displaySeqNo: number;
|
||||
isEditing: boolean;
|
||||
isLocked: boolean;
|
||||
dragDisabled: boolean;
|
||||
onStartEdit: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
onChange: (patch: Partial<ElaprPrgrsSttsRow>) => void;
|
||||
onPickAprvr: () => void;
|
||||
}
|
||||
|
||||
function SortablePrgrsSttsRow({
|
||||
row,
|
||||
displaySeqNo,
|
||||
isEditing,
|
||||
isLocked,
|
||||
dragDisabled,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onChange,
|
||||
onPickAprvr,
|
||||
}: SortablePrgrsSttsRowProps) {
|
||||
const { data: atrzSeCodes } = useCodeList("ATRZSE");
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: row.rowKey,
|
||||
disabled: dragDisabled,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
|
||||
const isEditable = isPrgrsSttsRowEditable(row);
|
||||
const rowDisabled = isLocked && !isEditing;
|
||||
const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd);
|
||||
const status = getApproverStatus(row.atrzPrgrsSttsComCd);
|
||||
const isRejectedRow = row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_004";
|
||||
const rjctRsn = row.rjctRsn?.trim();
|
||||
|
||||
const statusBadge = (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-sm px-3 py-1.5 text-lg font-semibold",
|
||||
status.badgeClass,
|
||||
isRejectedRow && rjctRsn && "cursor-help underline decoration-dotted underline-offset-4",
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style} className={isEditing ? "bg-accent/30" : undefined}>
|
||||
<TableCell align="center" className="w-10">
|
||||
{!dragDisabled && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="cursor-grab !bg-transparent text-muted-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="순서 변경"
|
||||
>
|
||||
<MoveVertical className="!size-6" />
|
||||
</Button>
|
||||
)}
|
||||
</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 flex-col items-center gap-1">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span>{row.aprvrNm || row.aprvrUuid || "-"}</span>
|
||||
{isEditing && (
|
||||
<Button type="button" size="sm" onClick={onPickAprvr}>
|
||||
선택
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!isEditing && (row.ognzNm || row.jbpsSeNm || row.jbgdSeNm) && (
|
||||
<div className="text-base text-gray-500">
|
||||
{row.ognzNm ?? ""}
|
||||
{row.jbpsSeNm ? ` · ${row.jbpsSeNm}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{isRejectedRow && rjctRsn ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{statusBadge}</TooltipTrigger>
|
||||
<TooltipContent sideOffset={4} className="max-w-md whitespace-pre-wrap">
|
||||
{rjctRsn}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
statusBadge
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
) : isEditable ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
disabled={rowDisabled}
|
||||
onClick={onStartEdit}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="gray"
|
||||
disabled={rowDisabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export { SortablePrgrsSttsRow };
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useFnrsCnsltnMng } from "@/entities/fnrs-cnsltn";
|
||||
import { Skeleton } from "@common/components";
|
||||
|
||||
import { FnrsFormPage } from "./fnrs-form-page";
|
||||
|
||||
@@ -12,7 +13,22 @@ function FnrsEditPage({ id }: FnrsEditPageProps) {
|
||||
const { data, isLoading } = useFnrsCnsltnMng(id);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="mt-6 text-[1.4rem]">불러오는 중...</div>;
|
||||
return (
|
||||
<div className="mt-6 space-y-6 px-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-[4rem] w-full" />
|
||||
<Skeleton className="h-[20rem] w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-[4rem] w-full" />
|
||||
<Skeleton className="h-[20rem] w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-[4rem] w-full" />
|
||||
<Skeleton className="h-[20rem] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.detDto) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
ChecklistFormSection,
|
||||
ContactInfoFormSection,
|
||||
FnrsSharFormSection,
|
||||
isFormChecklistComplete,
|
||||
PrivacyConsentSection,
|
||||
recalculateFnrsSharTotals,
|
||||
toFnrsSharSaveList,
|
||||
@@ -49,6 +51,7 @@ interface FnrsFormPageProps {
|
||||
function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const { confirm } = useConfirm();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -57,9 +60,7 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
const { data: masterSharTree = [] } = useFnrsSharSeMngTree();
|
||||
const { data: acntgTree = [] } = useAcntgSeMngTree();
|
||||
const sharTree =
|
||||
isEditMode && initialData?.fnrsSharList?.length
|
||||
? initialData.fnrsSharList
|
||||
: masterSharTree;
|
||||
isEditMode && initialData?.fnrsSharList?.length ? initialData.fnrsSharList : masterSharTree;
|
||||
const sharItems = useMemo(() => buildFnrsSharItems(sharTree), [sharTree]);
|
||||
const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]);
|
||||
|
||||
@@ -72,47 +73,54 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
: getCreateDefaultValues(userInfo, taskSeComCd),
|
||||
});
|
||||
|
||||
async function submitForm(
|
||||
data: FnrsCnsltnRegistFormData,
|
||||
prgrsSttsComCd: "SV" | "ST",
|
||||
): Promise<string | undefined> {
|
||||
const withTotals = recalculateFnrsSharTotals(data.fnrsSharList, sharItems);
|
||||
const validationMessage = validateBizCstSharBalance(
|
||||
data.bizCstList,
|
||||
withTotals,
|
||||
sharItems,
|
||||
costItems,
|
||||
);
|
||||
if (validationMessage) {
|
||||
alert(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const { taskSeComCd: formTaskSeComCd, ...fnrsCnsltnMngRest } = data.fnrsCnsltnMng;
|
||||
|
||||
const payload = {
|
||||
fnrsCnsltnMng: {
|
||||
...fnrsCnsltnMngRest,
|
||||
taskSeComCd: formatTaskSeComCdForApi(formTaskSeComCd),
|
||||
fnrsCnsltnDocNo: isEditMode ? initialData?.detDto?.fnrsCnsltnDocNo : undefined,
|
||||
prgrsSttsComCd,
|
||||
},
|
||||
bzScaleList: data.bzScaleList,
|
||||
bizCstList: data.bizCstList,
|
||||
fnrsSharList: toFnrsSharSaveList(withTotals),
|
||||
cklstResultList: data.cklstResultList.map((item) => ({
|
||||
...item,
|
||||
selected: item.selected ?? undefined,
|
||||
})),
|
||||
esntlAtchFileList: data.esntlAtchFileList,
|
||||
};
|
||||
|
||||
return fnrsCnsltnMngService.saveApply(payload);
|
||||
}
|
||||
|
||||
async function onSave(data: FnrsCnsltnRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const withTotals = recalculateFnrsSharTotals(data.fnrsSharList, sharItems);
|
||||
const validationMessage = validateBizCstSharBalance(
|
||||
data.bizCstList,
|
||||
withTotals,
|
||||
sharItems,
|
||||
costItems,
|
||||
);
|
||||
if (validationMessage) {
|
||||
alert(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const { taskSeComCd, ...fnrsCnsltnMngRest } = data.fnrsCnsltnMng;
|
||||
|
||||
const payload = {
|
||||
fnrsCnsltnMng: {
|
||||
...fnrsCnsltnMngRest,
|
||||
taskSeComCd: formatTaskSeComCdForApi(taskSeComCd),
|
||||
fnrsCnsltnDocNo: isEditMode ? initialData?.detDto?.fnrsCnsltnDocNo : undefined,
|
||||
prgrsSttsComCd: "SV",
|
||||
},
|
||||
bzScaleList: data.bzScaleList,
|
||||
bizCstList: data.bizCstList,
|
||||
fnrsSharList: toFnrsSharSaveList(withTotals),
|
||||
cklstResultList: data.cklstResultList.map((item) => ({
|
||||
...item,
|
||||
selected: item.selected ?? undefined,
|
||||
})),
|
||||
esntlAtchFileList: data.esntlAtchFileList,
|
||||
};
|
||||
|
||||
const fnrsCnsltnDocNo = await fnrsCnsltnMngService.saveApply(payload);
|
||||
const fnrsCnsltnDocNo = await submitForm(data, "SV");
|
||||
if (!fnrsCnsltnDocNo) return;
|
||||
|
||||
alert("저장되었습니다.");
|
||||
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`);
|
||||
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`);
|
||||
if (isEditMode && id) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", id],
|
||||
@@ -126,6 +134,63 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
}
|
||||
}
|
||||
|
||||
async function onApply(data: FnrsCnsltnRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (!isFormChecklistComplete(data.cklstResultList)) {
|
||||
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const fnrsCnsltnDocNo = await submitForm(data, "ST");
|
||||
if (!fnrsCnsltnDocNo) return;
|
||||
|
||||
alert("신청 완료되었습니다.");
|
||||
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`);
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
if (id) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", id],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("신청 실패:", error);
|
||||
alert("신청 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleApplyClick() {
|
||||
form.handleSubmit(onApply)();
|
||||
}
|
||||
|
||||
async function handleDelClick() {
|
||||
if (isSubmitting) return;
|
||||
|
||||
const docNo = initialData?.detDto?.fnrsCnsltnDocNo;
|
||||
if (!docNo) {
|
||||
alert("문서번호가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await fnrsCnsltnMngService.remove(docNo);
|
||||
alert("삭제되었습니다.");
|
||||
router.push("/fnrs-cnsltn");
|
||||
} catch (error) {
|
||||
console.error("삭제 실패:", error);
|
||||
alert("삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-4">
|
||||
@@ -150,10 +215,8 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
|
||||
<Button
|
||||
color="white"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.back();
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => router.push("/fnrs-cnsltn")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
목록
|
||||
@@ -161,6 +224,21 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
저장
|
||||
</Button>
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Button
|
||||
color="green"
|
||||
type="button"
|
||||
onClick={handleApplyClick}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
신청완료
|
||||
</Button>
|
||||
<Button color="red" type="button" onClick={handleDelClick} disabled={isSubmitting}>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -5,8 +5,12 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { type FnrsCnsltnMngSearchDto, useFnrsCnsltnMngPage } from "@/entities/fnrs-cnsltn";
|
||||
import { useDataTable, useUserStore } from "@/shared/lib";
|
||||
import {
|
||||
FnrsCnsltnMngResponseDto,
|
||||
type FnrsCnsltnMngSearchDto,
|
||||
useFnrsCnsltnMngPage,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
import { TaskSePickerDialog } from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
@@ -20,6 +24,7 @@ interface FnrsListProps {
|
||||
}
|
||||
|
||||
function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProps) {
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const router = useRouter();
|
||||
const [isTaskSeDialogOpen, setIsTaskSeDialogOpen] = useState(false);
|
||||
|
||||
@@ -38,6 +43,16 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp
|
||||
const pageQuery = useFnrsCnsltnMngPage(queryParams);
|
||||
const columns = useMemo(() => createFnrsCnsltnColumns(), []);
|
||||
|
||||
const handleRowClick = (row: FnrsCnsltnMngResponseDto) => {
|
||||
const canEdit =
|
||||
(row.prgrsSttsComCd === "SV" || row.prgrsSttsComCd === "SL") &&
|
||||
row.rgtr === userInfo?.userUnqId;
|
||||
|
||||
router.push(
|
||||
canEdit ? `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}/edit` : `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`,
|
||||
);
|
||||
};
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: pageQuery,
|
||||
tableOptions: {
|
||||
@@ -52,7 +67,7 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp
|
||||
},
|
||||
dataTableOptions: {
|
||||
viewPagination: true,
|
||||
onRowClick: (row) => router.push(`/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`),
|
||||
onRowClick: handleRowClick,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils";
|
||||
import type {
|
||||
ReviewOpinionFormData,
|
||||
ReviewOpinionGetFormDataResult,
|
||||
} from "../../lib/review-opinion-utils";
|
||||
import type { ReviewStepCode } from "../../model/view-types";
|
||||
import { REVIEW_STEP_LABELS } from "../../model/view-types";
|
||||
|
||||
@@ -33,7 +36,7 @@ interface TechReviewOpinionSectionProps {
|
||||
}
|
||||
|
||||
export interface TechReviewOpinionSectionHandle {
|
||||
getFormData: () => ReviewOpinionFormData | null;
|
||||
getFormData: () => ReviewOpinionGetFormDataResult | null;
|
||||
resetForm: () => void;
|
||||
}
|
||||
|
||||
@@ -101,8 +104,11 @@ const TechReviewOpinionSection = forwardRef<
|
||||
}
|
||||
|
||||
return {
|
||||
...formData,
|
||||
opnrSeComCd: editableReviewStep,
|
||||
formData: {
|
||||
...formData,
|
||||
opnrSeComCd: editableReviewStep,
|
||||
},
|
||||
rvwList,
|
||||
};
|
||||
},
|
||||
resetForm: () => {
|
||||
@@ -113,7 +119,7 @@ const TechReviewOpinionSection = forwardRef<
|
||||
setUploadedFile(null);
|
||||
},
|
||||
}),
|
||||
[editableReviewStep, formData],
|
||||
[editableReviewStep, formData, rvwList],
|
||||
);
|
||||
|
||||
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
|
||||
@@ -168,8 +174,8 @@ const TechReviewOpinionSection = forwardRef<
|
||||
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<h4 className="mb-4 text-xl font-semibold">검토의견 작성</h4>
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xl font-semibold">검토의견 작성</h4>
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -268,14 +274,18 @@ const TechReviewOpinionSection = forwardRef<
|
||||
<>
|
||||
{hasEnvSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
||||
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
|
||||
{renderOpinionForm("ENV")}
|
||||
<div className="space-y-6">
|
||||
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
|
||||
{renderOpinionForm("ENV")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
{hasKecoSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
|
||||
{renderOpinionForm("KECO")}
|
||||
<div className="space-y-6">
|
||||
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
|
||||
{renderOpinionForm("KECO")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@common/components";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"origin-(--radix-tooltip-content-transform-origin) animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit text-balance rounded-md border border-border bg-background px-3 py-1.5 text-lg text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[-50%] rotate-45 rounded-[2px] border-b border-r border-border bg-background fill-background" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
@@ -8,6 +8,7 @@ export { buildAcntgCostItems, recalculateBizCstTotals } from "./lib/business-cos
|
||||
export { buildCnstpScaleItems, recalculateBzScaleTotals } from "./lib/business-scale-form-utils";
|
||||
export {
|
||||
buildChecklistTabs,
|
||||
isFormChecklistComplete,
|
||||
orderCklstTreeNodes,
|
||||
syncCklstResultListFromTree,
|
||||
} from "./lib/checklist-form-utils";
|
||||
|
||||
@@ -159,3 +159,8 @@ export function syncCklstResultListFromTree(
|
||||
if (isSameCklstResultList(prev, next)) return prev;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isFormChecklistComplete(cklstResultList: CklstResultListItem[]): boolean {
|
||||
if (cklstResultList.length === 0) return true;
|
||||
return cklstResultList.every((item) => item.selected === "Y" || item.selected === "N");
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export function validateBizCstSharBalance(
|
||||
);
|
||||
|
||||
if (exstnRemaining !== 0 || chgRqtRemaining !== 0) {
|
||||
return "총사업비를 확인해주세요.";
|
||||
return "재원분담 총사업비를 확인해주세요.";
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user