[as] feat: 재원협의 전자결재 로직 수정

This commit is contained in:
2026-07-09 16:17:51 +09:00
parent 35b6f4ae54
commit 0141ed56c2
22 changed files with 1048 additions and 183 deletions
+1
View File
@@ -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",
+5
View File
@@ -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>
+18 -3
View File
@@ -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}
</>
+58
View File
@@ -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 };
+1
View File
@@ -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;