Merge branch 'iipbs/as/dev'
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
COOKIE_DOMAIN,
|
||||
REFRESH_TOKEN,
|
||||
SERVER_API_URL,
|
||||
SITE_ID,
|
||||
@@ -20,6 +21,10 @@ async function handler(
|
||||
"Content-Type": "application/json",
|
||||
"X-Site-Id": SITE_ID,
|
||||
};
|
||||
const deviceId = request.headers.get("X-Device-Id");
|
||||
if (deviceId) {
|
||||
headers["X-Device-Id"] = deviceId;
|
||||
}
|
||||
let body = {};
|
||||
const isRefreshHandler = params.url.includes("refresh");
|
||||
|
||||
@@ -39,27 +44,13 @@ async function handler(
|
||||
}
|
||||
|
||||
const targetUrl = `${SERVER_API_URL}/${params.url.join("/")}`;
|
||||
console.log(`[Proxy API] ${method} ${params.url.join("/")} -> ${targetUrl}`);
|
||||
console.log(`[Proxy API] Headers:`, headers);
|
||||
if (!isRefreshHandler) {
|
||||
console.log(`[Proxy API] Body:`, body);
|
||||
}
|
||||
|
||||
const result = await fetch(targetUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
console.log(`[Proxy API] Response Status: ${result.status}`);
|
||||
console.log(`[Proxy API] Response Headers:`, {
|
||||
[ACCESS_TOKEN]: result.headers.get(ACCESS_TOKEN),
|
||||
[REFRESH_TOKEN]: result.headers.get(REFRESH_TOKEN) ? '***hidden***' : undefined,
|
||||
[AUTH_USER_ID]: result.headers.get(AUTH_USER_ID),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(`[Proxy API] Request failed with status ${result.status}`);
|
||||
return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -68,24 +59,16 @@ async function handler(
|
||||
const userId = result.headers.get(AUTH_USER_ID);
|
||||
|
||||
if (accessToken && userId) {
|
||||
console.log(`[Proxy API] Login successful for user: ${userId}`);
|
||||
const response = NextResponse.json({
|
||||
[ACCESS_TOKEN]: accessToken,
|
||||
[AUTH_USER_ID]: userId,
|
||||
});
|
||||
|
||||
const forwardedHost = request.headers.get("x-forwarded-host");
|
||||
const host = request.headers.get("host");
|
||||
const actualHost = forwardedHost || host || new URL(request.url).hostname.toString();
|
||||
|
||||
// 운영 환경에서 domain 설정
|
||||
const domain = actualHost.includes("hasuon.or.kr") ? ".hasuon.or.kr" : actualHost;
|
||||
|
||||
if (refreshToken) {
|
||||
cookieStore.set(REFRESH_TOKEN, refreshToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
domain: domain,
|
||||
domain: getCookieDomain(),
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
@@ -109,3 +92,14 @@ export async function PUT(request: Request, props: { params: Promise<{ url: stri
|
||||
const params = await props.params;
|
||||
return handler(request, params, "PUT");
|
||||
}
|
||||
|
||||
function getCookieDomain(): string | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
if (COOKIE_DOMAIN) return COOKIE_DOMAIN;
|
||||
|
||||
const { hostname } = window.location;
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1") return undefined;
|
||||
|
||||
return hostname;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"next": "15.5.18",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"tus-js-client": "^4.3.1",
|
||||
"uuid": "^14.0.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -27,7 +27,9 @@ export function Providers({ children }: ProvidersProps) {
|
||||
</AuthWrapper>
|
||||
</ConfirmProvider>
|
||||
</StoreProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
<div style={{ fontSize: "1.4rem" }}>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
|
||||
import { type AuthPayload, type AuthService, getRemainingMs } from "@common/auth";
|
||||
|
||||
import {
|
||||
clearRefreshTimer,
|
||||
removeToken,
|
||||
setToken,
|
||||
setupRefreshTimer,
|
||||
tokenManager,
|
||||
} from "../lib/token-manager";
|
||||
import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
|
||||
import { clearRefreshTimer, setToken, setupRefreshTimer, tokenManager } from "../lib/token-manager";
|
||||
|
||||
async function login(payload: AuthPayload): Promise<void> {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Device-Id": getOrCreateDeviceId(),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
@@ -39,6 +37,9 @@ async function silentRefresh(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"X-Device-Id": getOrCreateDeviceId(),
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -72,8 +73,7 @@ async function silentRefresh(): Promise<void> {
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
tokenManager.clearAuthToken();
|
||||
removeToken("site-id-key");
|
||||
removeToken("site-theme-key");
|
||||
clearDeviceId();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const DEVICE_ID_KEY = "x-device-id";
|
||||
|
||||
export function getOrCreateDeviceId(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
const existing = localStorage.getItem(DEVICE_ID_KEY);
|
||||
if (existing) return existing;
|
||||
const id = uuidv4();
|
||||
localStorage.setItem(DEVICE_ID_KEY, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function clearDeviceId(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(DEVICE_ID_KEY);
|
||||
}
|
||||
@@ -47,6 +47,12 @@ export {
|
||||
findCurrentUserPendingApprovalRequest,
|
||||
hasCurrentUserPendingApprovalRequest,
|
||||
} from "./lib/approval-request-utils";
|
||||
export {
|
||||
APPROVER_STATUS_MAP,
|
||||
type ApproverStatusConfig,
|
||||
getApproverStatus,
|
||||
sortByAtrzSeqNo,
|
||||
} from "./lib/approval-utils";
|
||||
export {
|
||||
buildElaprPrgrsSttsListFromApln,
|
||||
buildElaprPrgrsSttsListFromAplnRows,
|
||||
@@ -102,3 +108,15 @@ export type {
|
||||
ElaprTmpltService,
|
||||
ElaprTmpltUpdateRequest,
|
||||
} from "./model/elapr-tmplt-interface";
|
||||
export type { ReviewerOption } from "./model/reviewer-option";
|
||||
export { AprvrPickerDialog } from "./ui/aprvr-picker-dialog";
|
||||
export { ElaprAplnPickerGrid } from "./ui/elapr-apln-picker-grid";
|
||||
export { ElaprApprovalDialog } from "./ui/elapr-approval-dialog";
|
||||
export { ElaprDocPrgrsStatus } from "./ui/elapr-doc-prgrs-status";
|
||||
export {
|
||||
type ElaprTmpltPickerConfirmPayload,
|
||||
ElaprTmpltPickerDialog,
|
||||
} from "./ui/elapr-tmplt-picker-dialog";
|
||||
export { ElaprTmpltSelectTable } from "./ui/elapr-tmplt-select-table";
|
||||
export { SortableAplnPickerRow } from "./ui/sortable-apln-picker-row";
|
||||
export { SortablePrgrsSttsRow } from "./ui/sortable-prgrs-stts-row";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
|
||||
|
||||
const ATRZ_RECEIVER_CD = "ATRZ01";
|
||||
const ATRZ_FINAL_CD = "ATRZ99";
|
||||
|
||||
interface AtrzOrderValidateRow {
|
||||
@@ -16,14 +15,6 @@ export function getVisibleRows(rows: ElaprAplnRow[]): ElaprAplnRow[] {
|
||||
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) {
|
||||
return "접수자는 반드시 등록해야 합니다.";
|
||||
}
|
||||
if (receiverCount > 1) {
|
||||
return "접수자는 한 명만 등록할 수 있습니다.";
|
||||
}
|
||||
|
||||
const finalCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_FINAL_CD).length;
|
||||
if (finalCount === 0) {
|
||||
return "최종승인자는 반드시 등록해야 합니다.";
|
||||
@@ -32,10 +23,6 @@ export function validateAtrzOrder(rows: AtrzOrderValidateRow[]): string | null {
|
||||
return "최종승인자는 한 명만 등록할 수 있습니다.";
|
||||
}
|
||||
|
||||
if (receiverCount === 1 && visibleRows[0]?.atrzSeComCd !== ATRZ_RECEIVER_CD) {
|
||||
return "접수자는 결재선 첫 번째에 배치해야 합니다.";
|
||||
}
|
||||
|
||||
if (finalCount === 1) {
|
||||
const lastRow = visibleRows[visibleRows.length - 1];
|
||||
if (lastRow?.atrzSeComCd !== ATRZ_FINAL_CD) {
|
||||
|
||||
+5
-4
@@ -1,4 +1,4 @@
|
||||
import type { ElaprDocPrgrsClientResponseDto } from "@/entities/fnrs-cnsltn";
|
||||
import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model";
|
||||
|
||||
export interface ApproverStatusConfig {
|
||||
label: string;
|
||||
@@ -7,9 +7,10 @@ export interface ApproverStatusConfig {
|
||||
|
||||
export const APPROVER_STATUS_MAP: Record<string, ApproverStatusConfig> = {
|
||||
ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700" },
|
||||
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800" },
|
||||
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800" },
|
||||
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800" },
|
||||
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-700" },
|
||||
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-700" },
|
||||
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-700" },
|
||||
ATRZ_PRGRS_999: { label: "결재안함", badgeClass: "bg-blue-100 text-blue-700" },
|
||||
};
|
||||
|
||||
export function sortByAtrzSeqNo(
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from "../model/elapr-prgrs-stts-interface";
|
||||
|
||||
export const PRGRS_STTS_WAITING_CD = "ATRZ_PRGRS_001";
|
||||
export const PRGRS_STTS_APPROVED_CD = "ATRZ_PRGRS_003";
|
||||
|
||||
/** 서버 행 비교용 최소 필드 */
|
||||
export interface PrgrsSttsServerRow {
|
||||
@@ -11,6 +12,7 @@ export interface PrgrsSttsServerRow {
|
||||
aprvrUuid?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeqNo?: number;
|
||||
dcarbYn?: string;
|
||||
}
|
||||
|
||||
/** 결재선 그리드 로컬 행 — 진행상태 API 응답 + UI 보조 필드 */
|
||||
@@ -29,6 +31,7 @@ export interface ElaprPrgrsSttsRow {
|
||||
ognzNm?: string;
|
||||
jbpsSeNm?: string;
|
||||
jbgdSeNm?: string;
|
||||
dcarbYn?: string;
|
||||
isNew?: boolean;
|
||||
isDirty?: boolean;
|
||||
/** 소프트 삭제 — 최종 저장 시 mode:D */
|
||||
@@ -41,6 +44,10 @@ export function isPrgrsSttsRowEditable(row: ElaprPrgrsSttsRow): boolean {
|
||||
return row.isNew === true || row.atrzPrgrsSttsComCd === PRGRS_STTS_WAITING_CD;
|
||||
}
|
||||
|
||||
export function isPrgrsSttsRowDcarbable(row: ElaprPrgrsSttsRow): boolean {
|
||||
return row.atrzPrgrsSttsComCd !== PRGRS_STTS_APPROVED_CD;
|
||||
}
|
||||
|
||||
function getVisibleRows(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] {
|
||||
return rows.filter((row) => !row.isDeleted);
|
||||
}
|
||||
@@ -85,7 +92,8 @@ function rowDiffersFromServer(row: ElaprPrgrsSttsRow, serverRow: PrgrsSttsServer
|
||||
return (
|
||||
row.aprvrUuid !== serverRow.aprvrUuid ||
|
||||
row.atrzSeComCd !== serverRow.atrzSeComCd ||
|
||||
row.atrzSeqNo !== serverRow.atrzSeqNo
|
||||
row.atrzSeqNo !== serverRow.atrzSeqNo ||
|
||||
(row.dcarbYn ?? "N") !== (serverRow.dcarbYn ?? "N")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,6 +157,7 @@ export function buildPrgrsSttsSavePayload(
|
||||
atrzSeComCd: row.atrzSeComCd,
|
||||
atrzSeqNo: row.atrzSeqNo,
|
||||
atrzPrgrsSttsComCd: row.atrzPrgrsSttsComCd,
|
||||
dcarbYn: row.dcarbYn ?? "N",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -164,6 +173,7 @@ export function buildPrgrsSttsSavePayload(
|
||||
atrzSeComCd: row.atrzSeComCd,
|
||||
atrzSeqNo: row.atrzSeqNo,
|
||||
atrzPrgrsSttsComCd: row.atrzPrgrsSttsComCd,
|
||||
dcarbYn: row.dcarbYn ?? "N",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ function toRow(item: ElaprDocPrgrsClientResponseDto): ElaprPrgrsSttsRow {
|
||||
elaprPrgrsId: item.elaprPrgrsId,
|
||||
elaprDmndId: item.elaprDmndId,
|
||||
aprvrUuid: item.aprvrUuid,
|
||||
aprvrNm: item.aprvrUuidNm,
|
||||
aprvrNm: item.aprvrNm,
|
||||
atrzSeComCd: item.atrzSeComCd,
|
||||
atrzSeComNm: item.atrzSeComNm,
|
||||
atrzSeqNo: item.atrzSeqNo,
|
||||
@@ -34,6 +34,7 @@ function toRow(item: ElaprDocPrgrsClientResponseDto): ElaprPrgrsSttsRow {
|
||||
ognzNm: item.ognzNm,
|
||||
jbpsSeNm: item.jbpsSeNm,
|
||||
jbgdSeNm: item.jbgdSeNm,
|
||||
dcarbYn: item.dcarbYn ?? "N",
|
||||
isNew: false,
|
||||
isDirty: false,
|
||||
isDeleted: false,
|
||||
@@ -71,6 +72,7 @@ export function usePrgrsSttsRows({ serverRows, elaprDmndId }: UsePrgrsSttsRowsOp
|
||||
aprvrUuid: "",
|
||||
aprvrNm: "",
|
||||
atrzPrgrsSttsComCd: PRGRS_STTS_WAITING_CD,
|
||||
dcarbYn: "N",
|
||||
isNew: true,
|
||||
isDirty: true,
|
||||
isDeleted: false,
|
||||
@@ -104,6 +106,33 @@ export function usePrgrsSttsRows({ serverRows, elaprDmndId }: UsePrgrsSttsRowsOp
|
||||
[editingRowKey],
|
||||
);
|
||||
|
||||
const setDcarbYn = useCallback(
|
||||
(rowKey: string, checked: boolean) => {
|
||||
if (editingRowKey != null && editingRowKey !== rowKey) return;
|
||||
|
||||
setRows((prev) => {
|
||||
const target = prev.find((r) => r.rowKey === rowKey);
|
||||
if (!target || target.isDeleted) return prev;
|
||||
|
||||
const nextYn = checked ? "Y" : "N";
|
||||
|
||||
return prev.map((row) => {
|
||||
if (row.isDeleted) return row;
|
||||
|
||||
if (row.rowKey === rowKey) {
|
||||
if ((row.dcarbYn ?? "N") === nextYn) return row;
|
||||
return { ...row, dcarbYn: nextYn, isDirty: true };
|
||||
}
|
||||
|
||||
if (!checked) return row;
|
||||
if ((row.dcarbYn ?? "N") === "N") return row;
|
||||
return { ...row, dcarbYn: "N", isDirty: true };
|
||||
});
|
||||
});
|
||||
},
|
||||
[editingRowKey],
|
||||
);
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
if (!editingRowKey) return;
|
||||
|
||||
@@ -202,6 +231,7 @@ export function usePrgrsSttsRows({ serverRows, elaprDmndId }: UsePrgrsSttsRowsOp
|
||||
addRow,
|
||||
startEdit,
|
||||
updateRow,
|
||||
setDcarbYn,
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
markDeleted,
|
||||
|
||||
@@ -6,14 +6,14 @@ export const ELAPR_APLN_SAVE_MODE = {
|
||||
DELETE: "D",
|
||||
} as const;
|
||||
|
||||
export type ElaprAplnSaveMode =
|
||||
(typeof ELAPR_APLN_SAVE_MODE)[keyof typeof ELAPR_APLN_SAVE_MODE];
|
||||
export type ElaprAplnSaveMode = (typeof ELAPR_APLN_SAVE_MODE)[keyof typeof ELAPR_APLN_SAVE_MODE];
|
||||
|
||||
export interface ElaprApln {
|
||||
aplnId: string;
|
||||
elaprTmpltId: string;
|
||||
aprvrUuid?: string;
|
||||
aprvrNm?: string;
|
||||
aprvrUserId?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeComNm?: string;
|
||||
atrzSeqNo?: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
import type { ElaprDocPrgrsClientResponseDto, PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
import type { ElaprPrgrsSttsSaveRequestDto } from "./elapr-prgrs-stts-interface";
|
||||
|
||||
@@ -33,23 +33,7 @@ export interface ElaprDmndSearchDto extends PageableParams {
|
||||
}
|
||||
|
||||
/** 백엔드 ElaprDocPrgrsResponseDto */
|
||||
export interface ElaprDocPrgrsResponseDto {
|
||||
elaprDocNo: string;
|
||||
elaprDmndId: string;
|
||||
elaprPrgrsId: string;
|
||||
atrzSeqNo?: number;
|
||||
atrzSeComCd: string;
|
||||
atrzSeComNm?: string;
|
||||
aprvrUuid?: string;
|
||||
aprvrUuidNm?: string;
|
||||
atrzPrgrsSttsComCd: string;
|
||||
atrzPrgrsSttsComNm?: string;
|
||||
rjctRsn?: string;
|
||||
atrzDt?: string;
|
||||
jbpsSeNm?: string;
|
||||
jbgdSeNm?: string;
|
||||
ognzNm?: string;
|
||||
}
|
||||
export type ElaprDocPrgrsResponseDto = ElaprDocPrgrsClientResponseDto;
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface ElaprDmndService {
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ElaprPrgrsSttsResponseDto {
|
||||
atrzPrgrsSttsComNm?: string;
|
||||
rjctRsn?: string;
|
||||
atrzDt?: string;
|
||||
dcarbYn?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 ElaprPrgrsSttsSaveRequestDto */
|
||||
@@ -33,6 +34,7 @@ export interface ElaprPrgrsSttsSaveRequestDto {
|
||||
atrzSeComCd?: string;
|
||||
atrzSeqNo?: number;
|
||||
atrzPrgrsSttsComCd?: string;
|
||||
dcarbYn?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 ElaprPrgrsSttsSearchDto */
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import {
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { type ReviewerOption } from "../../model/reviewer-option";
|
||||
import { type ReviewerOption } from "../model/reviewer-option";
|
||||
|
||||
const PAGE_SIZE = 500;
|
||||
|
||||
+8
-12
@@ -6,13 +6,6 @@ import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
type ElaprAplnRow,
|
||||
useAplnRows,
|
||||
useDndSensors,
|
||||
useElaprAplnPage,
|
||||
validateAplnRow,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
Skeleton,
|
||||
@@ -23,7 +16,12 @@ import {
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import type { ReviewerOption } from "../../model/reviewer-option";
|
||||
import { useElaprAplnPage } from "../api/queries";
|
||||
import { validateAplnRow } from "../lib/apln-validators";
|
||||
import { useAplnRows } from "../lib/use-apln-rows";
|
||||
import { useDndSensors } from "../lib/use-dnd-sensors";
|
||||
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
|
||||
import type { ReviewerOption } from "../model/reviewer-option";
|
||||
|
||||
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
|
||||
import { SortableAplnPickerRow } from "./sortable-apln-picker-row";
|
||||
@@ -147,16 +145,14 @@ function ElaprAplnPickerGrid({
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="10%" />
|
||||
<col width="30%" />
|
||||
<col width="10%" />
|
||||
<col width="10%" />
|
||||
<col width="30%" />
|
||||
<col width="10%" />
|
||||
<col width="50%" />
|
||||
<col width="20%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>결재선아이디</TableHead>
|
||||
<TableHead>결재순서</TableHead>
|
||||
<TableHead>결재구분</TableHead>
|
||||
<TableHead>결재자</TableHead>
|
||||
+22
-33
@@ -9,29 +9,13 @@ import { AxiosError } from "axios";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
buildPrgrsSttsSavePayload,
|
||||
elaprPrgrsSttsService,
|
||||
findCurrentUserPendingApprovalRequest,
|
||||
hasUnsavedPrgrsSttsChanges,
|
||||
isPrgrsSttsRowEditable,
|
||||
useDndSensors,
|
||||
useElaprDocPrgrsByDocNo,
|
||||
usePrgrsSttsRows,
|
||||
useSaveElaprPrgrsStts,
|
||||
validateAllPrgrsSttsRows,
|
||||
validateAtrzOrder,
|
||||
validatePrgrsSttsRow,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Label,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -41,7 +25,20 @@ import {
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import type { ReviewerOption } from "../../model/reviewer-option";
|
||||
import { elaprPrgrsSttsService } from "../api/elapr-prgrs-stts-service";
|
||||
import { useElaprDocPrgrsByDocNo, useSaveElaprPrgrsStts } from "../api/queries";
|
||||
import { validateAtrzOrder } from "../lib/apln-validators";
|
||||
import { findCurrentUserPendingApprovalRequest } from "../lib/approval-request-utils";
|
||||
import {
|
||||
buildPrgrsSttsSavePayload,
|
||||
hasUnsavedPrgrsSttsChanges,
|
||||
isPrgrsSttsRowEditable,
|
||||
validateAllPrgrsSttsRows,
|
||||
validatePrgrsSttsRow,
|
||||
} from "../lib/prgrs-stts-save-utils";
|
||||
import { useDndSensors } from "../lib/use-dnd-sensors";
|
||||
import { usePrgrsSttsRows } from "../lib/use-prgrs-stts-rows";
|
||||
import type { ReviewerOption } from "../model/reviewer-option";
|
||||
|
||||
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
|
||||
import { SortablePrgrsSttsRow } from "./sortable-prgrs-stts-row";
|
||||
@@ -69,7 +66,6 @@ function ElaprApprovalDialog({
|
||||
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);
|
||||
|
||||
@@ -83,6 +79,7 @@ function ElaprApprovalDialog({
|
||||
addRow,
|
||||
startEdit,
|
||||
updateRow,
|
||||
setDcarbYn,
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
markDeleted,
|
||||
@@ -200,7 +197,6 @@ function ElaprApprovalDialog({
|
||||
try {
|
||||
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
|
||||
atrzPrgrsSttsComCd: "ATRZ_PRGRS_003",
|
||||
finalApprovalYn,
|
||||
});
|
||||
alert("결재가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
@@ -313,12 +309,13 @@ function ElaprApprovalDialog({
|
||||
>
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="8%" />
|
||||
<col width="8%" />
|
||||
<col width="10%" />
|
||||
<col width="15%" />
|
||||
<col width="35%" />
|
||||
<col width="12%" />
|
||||
<col width="20%" />
|
||||
<col width="30%" />
|
||||
<col width="10%" />
|
||||
<col width="10%" />
|
||||
<col width="24%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -326,6 +323,7 @@ function ElaprApprovalDialog({
|
||||
<TableHead>결재순서</TableHead>
|
||||
<TableHead>결재구분</TableHead>
|
||||
<TableHead>결재자</TableHead>
|
||||
<TableHead>전결자 지정</TableHead>
|
||||
<TableHead>결재상태</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
@@ -354,6 +352,7 @@ function ElaprApprovalDialog({
|
||||
setPickingRowKey(row.rowKey);
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
onToggleDcarb={(checked) => setDcarbYn(row.rowKey, checked)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
@@ -384,16 +383,6 @@ function ElaprApprovalDialog({
|
||||
</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>
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { cn, Skeleton } from "@common/components";
|
||||
|
||||
import { useElaprDocPrgrsByDocNo } from "../api/queries";
|
||||
import { getApproverStatus, sortByAtrzSeqNo } from "../lib/approval-utils";
|
||||
|
||||
const REJECT_STATUS_CD = "ATRZ_PRGRS_004";
|
||||
|
||||
interface ElaprDocPrgrsStatusProps {
|
||||
elaprDocNo: string | null | undefined;
|
||||
}
|
||||
|
||||
function ElaprDocPrgrsStep({ item }: { item: ElaprDocPrgrsClientResponseDto }) {
|
||||
const status = getApproverStatus(item.atrzPrgrsSttsComCd);
|
||||
const isRejected = item.atrzPrgrsSttsComCd === REJECT_STATUS_CD;
|
||||
const rjctRsn = item.rjctRsn?.trim();
|
||||
const metaParts = [item.ognzNm, item.jbpsSeNm].filter(Boolean);
|
||||
|
||||
const statusBadge = (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-sm px-2 py-1 text-lg font-medium",
|
||||
status.badgeClass,
|
||||
isRejected && rjctRsn && "cursor-help underline decoration-dotted underline-offset-4",
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[12rem] flex-1 flex-col items-center gap-3 p-4 text-center">
|
||||
{isRejected && rjctRsn ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{statusBadge}</TooltipTrigger>
|
||||
<TooltipContent sideOffset={4} className="max-w-md whitespace-pre-wrap">
|
||||
{rjctRsn}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
statusBadge
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl text-muted-foreground">[{item.atrzSeComNm || "-"}]</span>
|
||||
<span className="text-xl font-semibold">{item.aprvrNm || item.aprvrUuid || "-"}</span>
|
||||
</div>
|
||||
{metaParts.length > 0 && (
|
||||
<span className="text-base text-gray-500">{metaParts.join(" · ")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ElaprDocPrgrsStatus({ elaprDocNo }: ElaprDocPrgrsStatusProps) {
|
||||
const { data, isLoading } = useElaprDocPrgrsByDocNo(elaprDocNo);
|
||||
const steps = sortByAtrzSeqNo(data ?? []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-6">
|
||||
<Skeleton className="h-24 flex-1 rounded-sm" />
|
||||
<Skeleton className="h-24 flex-1 rounded-sm" />
|
||||
<Skeleton className="h-24 flex-1 rounded-sm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (steps.length === 0) {
|
||||
return <div className="py-8 text-center text-xl text-gray-500">결재 정보가 없습니다.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-stretch justify-start gap-4 overflow-x-auto">
|
||||
{steps.map((item, index) => (
|
||||
<Fragment key={item.elaprPrgrsId ?? `${item.atrzSeqNo}-${item.aprvrUuid ?? index}`}>
|
||||
<div className="flex items-center">
|
||||
{index > 0 && <ChevronRight className="size-8 text-muted-foreground" />}
|
||||
</div>
|
||||
<ElaprDocPrgrsStep item={item} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprDocPrgrsStatus };
|
||||
+166
-163
@@ -1,163 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
type ElaprAplnRow,
|
||||
type ElaprTmplt,
|
||||
getVisibleRows,
|
||||
useElaprTmpltPage,
|
||||
validateAllRowsForSave,
|
||||
validateAtrzOrder,
|
||||
validateUniqueSeqNo,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@common/components";
|
||||
|
||||
import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
|
||||
import { ElaprTmpltSelectTable } from "./elapr-tmplt-select-table";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface ElaprTmpltPickerConfirmPayload {
|
||||
tmplt: ElaprTmplt;
|
||||
aplnRows: ElaprAplnRow[];
|
||||
}
|
||||
|
||||
interface ElaprTmpltPickerDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect?: (tmplt: ElaprTmplt) => void;
|
||||
onConfirm?: (payload: ElaprTmpltPickerConfirmPayload) => void;
|
||||
}
|
||||
|
||||
function ElaprTmpltPickerDialog({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
onConfirm,
|
||||
}: ElaprTmpltPickerDialogProps) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [selectedTmplt, setSelectedTmplt] = useState<ElaprTmplt | null>(null);
|
||||
const [aplnRows, setAplnRows] = useState<ElaprAplnRow[]>([]);
|
||||
const [isEditingLocked, setIsEditingLocked] = useState(false);
|
||||
|
||||
const tmpltQuery = useElaprTmpltPage({
|
||||
page: 0,
|
||||
size: PAGE_SIZE,
|
||||
sort: "regDt,desc",
|
||||
docSeComCd: "FNRS",
|
||||
});
|
||||
|
||||
const handleRowsChange = useCallback((rows: ElaprAplnRow[]) => {
|
||||
setAplnRows(rows);
|
||||
}, []);
|
||||
|
||||
const handleEditingLockedChange = useCallback((locked: boolean) => {
|
||||
setIsEditingLocked(locked);
|
||||
}, []);
|
||||
|
||||
function handleSelectTmplt(tmplt: ElaprTmplt | null) {
|
||||
setSelectedTmplt(tmplt);
|
||||
setAplnRows([]);
|
||||
setIsEditingLocked(false);
|
||||
}
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
setSelectedTmplt(null);
|
||||
setAplnRows([]);
|
||||
setIsEditingLocked(false);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!selectedTmplt) {
|
||||
alert("결재서식을 선택해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rowsError = validateAllRowsForSave(aplnRows, isEditingLocked);
|
||||
if (rowsError) {
|
||||
alert(rowsError);
|
||||
return;
|
||||
}
|
||||
|
||||
const orderError = validateAtrzOrder(aplnRows);
|
||||
if (orderError) {
|
||||
alert(orderError);
|
||||
return;
|
||||
}
|
||||
|
||||
const seqError = validateUniqueSeqNo(aplnRows);
|
||||
if (seqError) {
|
||||
alert(seqError);
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleRows = getVisibleRows(aplnRows);
|
||||
|
||||
const payload: ElaprTmpltPickerConfirmPayload = {
|
||||
tmplt: selectedTmplt,
|
||||
aplnRows: visibleRows,
|
||||
};
|
||||
|
||||
if (onConfirm) {
|
||||
onConfirm(payload);
|
||||
} else {
|
||||
onSelect?.(selectedTmplt);
|
||||
}
|
||||
|
||||
setSelectedTmplt(null);
|
||||
setAplnRows([]);
|
||||
setIsEditingLocked(false);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-[120rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>결재선 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<ElaprTmpltSelectTable
|
||||
query={tmpltQuery}
|
||||
selectedTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
||||
onSelectTmplt={handleSelectTmplt}
|
||||
/>
|
||||
<ElaprAplnPickerGrid
|
||||
elaprTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
||||
onRowsChange={handleRowsChange}
|
||||
onEditingLockedChange={handleEditingLockedChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => handleOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!selectedTmplt || isEditingLocked}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprTmpltPickerDialog };
|
||||
export type { ElaprTmpltPickerConfirmPayload };
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@common/components";
|
||||
|
||||
import { useElaprTmpltPage } from "../api/queries";
|
||||
import {
|
||||
getVisibleRows,
|
||||
validateAllRowsForSave,
|
||||
validateAtrzOrder,
|
||||
validateUniqueSeqNo,
|
||||
} from "../lib/apln-validators";
|
||||
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
|
||||
import type { ElaprTmplt } from "../model/elapr-tmplt-interface";
|
||||
|
||||
import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
|
||||
import { ElaprTmpltSelectTable } from "./elapr-tmplt-select-table";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface ElaprTmpltPickerConfirmPayload {
|
||||
tmplt: ElaprTmplt;
|
||||
aplnRows: ElaprAplnRow[];
|
||||
}
|
||||
|
||||
interface ElaprTmpltPickerDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
docSeComCd: string;
|
||||
onSelect?: (tmplt: ElaprTmplt) => void;
|
||||
onConfirm?: (payload: ElaprTmpltPickerConfirmPayload) => void;
|
||||
}
|
||||
|
||||
function ElaprTmpltPickerDialog({
|
||||
open,
|
||||
onClose,
|
||||
docSeComCd,
|
||||
onSelect,
|
||||
onConfirm,
|
||||
}: ElaprTmpltPickerDialogProps) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [selectedTmplt, setSelectedTmplt] = useState<ElaprTmplt | null>(null);
|
||||
const [aplnRows, setAplnRows] = useState<ElaprAplnRow[]>([]);
|
||||
const [isEditingLocked, setIsEditingLocked] = useState(false);
|
||||
|
||||
const tmpltQuery = useElaprTmpltPage({
|
||||
page: 0,
|
||||
size: PAGE_SIZE,
|
||||
sort: "regDt,desc",
|
||||
docSeComCd,
|
||||
});
|
||||
|
||||
const handleRowsChange = useCallback((rows: ElaprAplnRow[]) => {
|
||||
setAplnRows(rows);
|
||||
}, []);
|
||||
|
||||
const handleEditingLockedChange = useCallback((locked: boolean) => {
|
||||
setIsEditingLocked(locked);
|
||||
}, []);
|
||||
|
||||
function handleSelectTmplt(tmplt: ElaprTmplt | null) {
|
||||
setSelectedTmplt(tmplt);
|
||||
setAplnRows([]);
|
||||
setIsEditingLocked(false);
|
||||
}
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
setSelectedTmplt(null);
|
||||
setAplnRows([]);
|
||||
setIsEditingLocked(false);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!selectedTmplt) {
|
||||
alert("결재서식을 선택해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rowsError = validateAllRowsForSave(aplnRows, isEditingLocked);
|
||||
if (rowsError) {
|
||||
alert(rowsError);
|
||||
return;
|
||||
}
|
||||
|
||||
const orderError = validateAtrzOrder(aplnRows);
|
||||
if (orderError) {
|
||||
alert(orderError);
|
||||
return;
|
||||
}
|
||||
|
||||
const seqError = validateUniqueSeqNo(aplnRows);
|
||||
if (seqError) {
|
||||
alert(seqError);
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleRows = getVisibleRows(aplnRows);
|
||||
|
||||
const payload: ElaprTmpltPickerConfirmPayload = {
|
||||
tmplt: selectedTmplt,
|
||||
aplnRows: visibleRows,
|
||||
};
|
||||
|
||||
if (onConfirm) {
|
||||
onConfirm(payload);
|
||||
} else {
|
||||
onSelect?.(selectedTmplt);
|
||||
}
|
||||
|
||||
setSelectedTmplt(null);
|
||||
setAplnRows([]);
|
||||
setIsEditingLocked(false);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-[120rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>결재선 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<ElaprTmpltSelectTable
|
||||
query={tmpltQuery}
|
||||
selectedTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
||||
onSelectTmplt={handleSelectTmplt}
|
||||
/>
|
||||
<ElaprAplnPickerGrid
|
||||
elaprTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
||||
onRowsChange={handleRowsChange}
|
||||
onEditingLockedChange={handleEditingLockedChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => handleOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!selectedTmplt || isEditingLocked}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprTmpltPickerDialog };
|
||||
export type { ElaprTmpltPickerConfirmPayload };
|
||||
+2
-9
@@ -7,9 +7,10 @@ import { createColumnHelper, getCoreRowModel, type RowSelectionState } from "@ta
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
import type { ElaprTmplt } from "@/entities/elapr";
|
||||
import { Skeleton } from "@common/components";
|
||||
|
||||
import type { ElaprTmplt } from "../model/elapr-tmplt-interface";
|
||||
|
||||
const columnHelper = createColumnHelper<ElaprTmplt>();
|
||||
|
||||
interface ElaprTmpltSelectTableProps {
|
||||
@@ -35,18 +36,10 @@ function ElaprTmpltSelectTable({
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("elaprTmpltId", {
|
||||
header: "전자결재서식아이디",
|
||||
meta: { align: "left" },
|
||||
}),
|
||||
columnHelper.accessor("elaprTmpltNm", {
|
||||
header: "결재서식명",
|
||||
meta: { align: "left" },
|
||||
}),
|
||||
columnHelper.accessor("docSeComNm", {
|
||||
header: "문서구분",
|
||||
cell: ({ getValue, row }) => getValue() ?? row.original.docSeComCd ?? "—",
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
+31
-16
@@ -4,9 +4,19 @@ import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { MoveVertical } from "lucide-react";
|
||||
|
||||
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import type { ElaprAplnRow } from "@/entities/elapr";
|
||||
import { Button, TableCell, TableRow } from "@common/components";
|
||||
import { findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
|
||||
|
||||
interface SortableAplnPickerRowProps {
|
||||
row: ElaprAplnRow;
|
||||
@@ -36,6 +46,7 @@ function SortableAplnPickerRow({
|
||||
onPickAprvr,
|
||||
}: SortableAplnPickerRowProps) {
|
||||
const { data: atrzSeCodes } = useCodeList("ATRZSE");
|
||||
const selectableAtrzSeCodes = atrzSeCodes?.filter((code) => code.lwpoCd !== "ATRZ01");
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: row.rowKey,
|
||||
@@ -66,31 +77,35 @@ function SortableAplnPickerRow({
|
||||
<MoveVertical className="!size-6" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell align="center">{row.isNew ? "-" : (row.aplnId ?? "-")}</TableCell>
|
||||
<TableCell align="center">{displaySeqNo}</TableCell>
|
||||
<TableCell align="center">
|
||||
{isEditing ? (
|
||||
<CodeSelect
|
||||
upCd="ATRZSE"
|
||||
className="min-w-[120px]"
|
||||
<Select
|
||||
value={row.atrzSeComCd ?? ""}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
atrzSeComCd: v,
|
||||
atrzSeComNm: findCodeLabel(atrzSeCodes, v),
|
||||
})
|
||||
onChange({ atrzSeComCd: v, atrzSeComNm: findCodeLabel(selectableAtrzSeCodes, v) })
|
||||
}
|
||||
placeholder="결재구분"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="min-w-[120px]">
|
||||
<SelectValue placeholder="결재구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectableAtrzSeCodes?.map((code) => (
|
||||
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
|
||||
{code.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
atrzSeLabel || "-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<span>{row.aprvrNm || row.aprvrUuid || "-"}</span>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span>{row.aprvrNm || row.aprvrUuid || ""}</span>
|
||||
{isEditing && (
|
||||
<Button type="button" size="sm" onClick={onPickAprvr}>
|
||||
<Button type="button" size="sm" onClick={onPickAprvr} disabled={!row.atrzSeComCd}>
|
||||
선택
|
||||
</Button>
|
||||
)}
|
||||
+46
-13
@@ -5,11 +5,26 @@ 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 { findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
cn,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { getApproverStatus } from "../../lib/approval-utils";
|
||||
import { getApproverStatus } from "../lib/approval-utils";
|
||||
import {
|
||||
type ElaprPrgrsSttsRow,
|
||||
isPrgrsSttsRowDcarbable,
|
||||
isPrgrsSttsRowEditable,
|
||||
} from "../lib/prgrs-stts-save-utils";
|
||||
|
||||
interface SortablePrgrsSttsRowProps {
|
||||
row: ElaprPrgrsSttsRow;
|
||||
@@ -23,6 +38,7 @@ interface SortablePrgrsSttsRowProps {
|
||||
onDelete: () => void;
|
||||
onChange: (patch: Partial<ElaprPrgrsSttsRow>) => void;
|
||||
onPickAprvr: () => void;
|
||||
onToggleDcarb: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
function SortablePrgrsSttsRow({
|
||||
@@ -37,8 +53,10 @@ function SortablePrgrsSttsRow({
|
||||
onDelete,
|
||||
onChange,
|
||||
onPickAprvr,
|
||||
onToggleDcarb,
|
||||
}: SortablePrgrsSttsRowProps) {
|
||||
const { data: atrzSeCodes } = useCodeList("ATRZSE");
|
||||
const selectableAtrzSeCodes = atrzSeCodes?.filter((code) => code.lwpoCd !== "ATRZ01");
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: row.rowKey,
|
||||
@@ -52,6 +70,7 @@ function SortablePrgrsSttsRow({
|
||||
};
|
||||
|
||||
const isEditable = isPrgrsSttsRowEditable(row);
|
||||
const isDcarbable = isPrgrsSttsRowDcarbable(row);
|
||||
const rowDisabled = isLocked && !isEditing;
|
||||
const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd);
|
||||
const status = getApproverStatus(row.atrzPrgrsSttsComCd);
|
||||
@@ -89,18 +108,23 @@ function SortablePrgrsSttsRow({
|
||||
<TableCell align="center">{displaySeqNo}</TableCell>
|
||||
<TableCell align="center">
|
||||
{isEditing ? (
|
||||
<CodeSelect
|
||||
upCd="ATRZSE"
|
||||
className="min-w-[120px]"
|
||||
<Select
|
||||
value={row.atrzSeComCd ?? ""}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
atrzSeComCd: v,
|
||||
atrzSeComNm: findCodeLabel(atrzSeCodes, v),
|
||||
})
|
||||
onChange({ atrzSeComCd: v, atrzSeComNm: findCodeLabel(selectableAtrzSeCodes, v) })
|
||||
}
|
||||
placeholder="결재구분"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="min-w-[120px]">
|
||||
<SelectValue placeholder="결재구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectableAtrzSeCodes?.map((code) => (
|
||||
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
|
||||
{code.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
atrzSeLabel || "-"
|
||||
)}
|
||||
@@ -123,6 +147,15 @@ function SortablePrgrsSttsRow({
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{isDcarbable && !rowDisabled && (
|
||||
<Checkbox
|
||||
checked={row.dcarbYn === "Y"}
|
||||
onCheckedChange={(checked) => onToggleDcarb(checked === true)}
|
||||
aria-label="전결자 지정"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{isRejectedRow && rjctRsn ? (
|
||||
<Tooltip>
|
||||
@@ -65,6 +65,22 @@ async function techReview(id: string, payload: InstlSncnApplySaveRequestDto): Pr
|
||||
await client.post(`${BASE}/${id}/tech-review`, payload);
|
||||
}
|
||||
|
||||
async function submitSupplement(
|
||||
id: string,
|
||||
payload: InstlSncnApplySaveRequestDto,
|
||||
): Promise<string> {
|
||||
const response = await client.post<string>(`${BASE}/${id}/submitsupplement`, payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function submitReqSupplement(
|
||||
id: string,
|
||||
payload: InstlSncnApplySaveRequestDto,
|
||||
): Promise<string> {
|
||||
const response = await client.post<string>(`${BASE}/${id}/reqsupplement`, payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(id: string): Promise<void> {
|
||||
await client.delete(`${BASE}/${id}`);
|
||||
}
|
||||
@@ -80,6 +96,8 @@ export const instlSncnService = {
|
||||
saveApply,
|
||||
update,
|
||||
techReview,
|
||||
submitSupplement,
|
||||
submitReqSupplement,
|
||||
remove,
|
||||
changePrgrsSttsComCd,
|
||||
} satisfies InstlSncnMngService;
|
||||
|
||||
@@ -99,6 +99,32 @@ export function useInstlSncnTechReview() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstlSncnSubmitSupplement() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: InstlSncnApplySaveRequestDto }) =>
|
||||
instlSncnService.submitSupplement(id, payload),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstlSncnSubmitReqSupplement() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: InstlSncnApplySaveRequestDto }) =>
|
||||
instlSncnService.submitReqSupplement(id, payload),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstlSncnRemove() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ export {
|
||||
useInstlSncnList,
|
||||
useInstlSncnSidoCombo,
|
||||
useInstlSncnSigunguCombo,
|
||||
useInstlSncnSubmitReqSupplement,
|
||||
useInstlSncnSubmitSupplement,
|
||||
useInstlSncnTechReview,
|
||||
} from "./api/queries";
|
||||
export {
|
||||
@@ -28,6 +30,7 @@ export {
|
||||
type InstlSncnMngSearchParams,
|
||||
type InstlSncnRvwOpnnResponseDto,
|
||||
type InstlSncnRvwOpnnSaveRequestDto,
|
||||
type InstlSncnSplmntDmndResponseDto,
|
||||
type InstlSncnSplmntDmndSaveDto,
|
||||
type ReviewStepCode,
|
||||
} from "./model/instl-sncn-interface";
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
PbadmsMngDetResponseBase,
|
||||
RvwOpnnResponseExtension,
|
||||
RvwOpnnSaveBase,
|
||||
SplmntDmndResponseExtension,
|
||||
SplmntDmndSaveBase,
|
||||
} from "@/shared/model";
|
||||
|
||||
@@ -185,20 +186,26 @@ export interface InstlSncnRvwOpnnSaveRequestDto extends RvwOpnnSaveBase {
|
||||
/** 설치인가 보완요구 저장 DTO(배열 원소) */
|
||||
export type InstlSncnSplmntDmndSaveDto = SplmntDmndSaveBase;
|
||||
|
||||
/** 설치인가 보완요구 응답 DTO */
|
||||
export type InstlSncnSplmntDmndResponseDto = InstlSncnSplmntDmndSaveDto &
|
||||
SplmntDmndResponseExtension & {
|
||||
instlSncnDocNo?: string;
|
||||
};
|
||||
|
||||
/** 설치인가 신청 - 통합 저장 요청 DTO */
|
||||
export interface InstlSncnApplySaveRequestDto extends PbadmsApplySaveHeader {
|
||||
/** 설치인가관리 정보 */
|
||||
instlSncnMng: InstlSncnMngSaveRequestDto;
|
||||
/** 사업규모 목록 */
|
||||
bzScaleList: InstlSncnBzScaleSaveDto[];
|
||||
bzScaleList?: InstlSncnBzScaleSaveDto[];
|
||||
/** 총사업비 목록 */
|
||||
bizCstList: InstlSncnBizCstSaveRequestDto[];
|
||||
bizCstList?: InstlSncnBizCstSaveRequestDto[];
|
||||
/** 체크리스트 검토결과 목록 */
|
||||
cklstResultList: InstlSncnCklstResultSaveDto[];
|
||||
cklstResultList?: InstlSncnCklstResultSaveDto[];
|
||||
/** 체크리스트 검토결과 맵 (rvwStpComCd -> 목록) */
|
||||
cklstResultMap?: Record<string, InstlSncnCklstResultSaveDto[]>;
|
||||
/** 필수첨부파일 목록 */
|
||||
esntlAtchFileList: InstlSncnEsntlAtchFileSaveDto[];
|
||||
esntlAtchFileList?: InstlSncnEsntlAtchFileSaveDto[];
|
||||
/** 검토의견 목록 */
|
||||
rvwOpnnList?: InstlSncnRvwOpnnSaveRequestDto[];
|
||||
/** 보완요구 목록 */
|
||||
@@ -222,8 +229,11 @@ export type InstlSncnRvwOpnnResponseDto = InstlSncnRvwOpnnSaveRequestDto &
|
||||
};
|
||||
|
||||
/** 설치인가관리 상세 응답 DTO */
|
||||
export interface InstlSncnMngDetResponse
|
||||
extends PbadmsMngDetResponseBase<InstlSncnMngResponseDto, InstlSncnRvwOpnnResponseDto> {
|
||||
export interface InstlSncnMngDetResponse extends PbadmsMngDetResponseBase<
|
||||
InstlSncnMngResponseDto,
|
||||
InstlSncnRvwOpnnResponseDto,
|
||||
InstlSncnSplmntDmndResponseDto
|
||||
> {
|
||||
detDto: InstlSncnMngResponseDto;
|
||||
bizSclList: BizSclTreeDto[];
|
||||
bizCstList: BizCstTreeDto[];
|
||||
@@ -239,6 +249,8 @@ export interface InstlSncnMngService {
|
||||
saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise<string>;
|
||||
update: (id: string, payload: InstlSncnMngUpdateRequestDto) => Promise<void>;
|
||||
techReview: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<void>;
|
||||
submitSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<string>;
|
||||
submitReqSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<string>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
changePrgrsSttsComCd: (payload: DocPrgrsUpdateRequestDto) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,6 @@ interface CreateTmpltColumnsOptions {
|
||||
|
||||
export function createTmpltColumns({ onEdit, onDelete }: CreateTmpltColumnsOptions) {
|
||||
return [
|
||||
columnHelper.accessor("elaprTmpltId", {
|
||||
header: "전자결재서식아이디",
|
||||
meta: { align: "left" },
|
||||
}),
|
||||
columnHelper.accessor("elaprTmpltNm", {
|
||||
header: "결재서식명",
|
||||
meta: { align: "left" },
|
||||
|
||||
@@ -11,7 +11,7 @@ function ApprovalLineManagementPage() {
|
||||
const [selectedTmplt, setSelectedTmplt] = useState<ElaprTmplt | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
<div className="grid grid-cols-1 gap-6 px-[1.8rem] xl:grid-cols-2">
|
||||
<ElaprTmpltList
|
||||
selectedTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
||||
onSelectTmplt={setSelectedTmplt}
|
||||
|
||||
@@ -186,20 +186,20 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="10%" />
|
||||
<col width="30%" />
|
||||
<col width="5%" />
|
||||
<col width="10%" />
|
||||
<col width="10%" />
|
||||
<col width="15%" />
|
||||
<col width="15%" />
|
||||
<col width="25%" />
|
||||
<col width="20%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>결재선아이디</TableHead>
|
||||
<TableHead>결재순서</TableHead>
|
||||
<TableHead>결재구분</TableHead>
|
||||
<TableHead>결재자 ID</TableHead>
|
||||
<TableHead>결재자</TableHead>
|
||||
<TableHead>등록일시</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
|
||||
@@ -4,9 +4,18 @@ import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Equal } from "lucide-react";
|
||||
|
||||
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import { findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import type { ElaprAplnRow } from "@/entities/elapr";
|
||||
import { Button, TableCell, TableRow } from "@common/components";
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
interface SortableAplnRowProps {
|
||||
row: ElaprAplnRow;
|
||||
@@ -36,6 +45,7 @@ function SortableAplnRow({
|
||||
onPickAprvr,
|
||||
}: SortableAplnRowProps) {
|
||||
const { data: atrzSeCodes } = useCodeList("ATRZSE");
|
||||
const selectableAtrzSeCodes = atrzSeCodes?.filter((code) => code.lwpoCd !== "ATRZ01");
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: row.rowKey,
|
||||
@@ -49,7 +59,7 @@ function SortableAplnRow({
|
||||
};
|
||||
|
||||
const rowDisabled = isLocked && !isEditing;
|
||||
const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd);
|
||||
const atrzSeLabel = row.atrzSeComNm || findCodeLabel(selectableAtrzSeCodes, row.atrzSeComCd);
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style} className={isEditing ? "bg-accent/30" : undefined}>
|
||||
@@ -66,31 +76,36 @@ function SortableAplnRow({
|
||||
<Equal className="!size-6" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell align="center">{row.isNew ? "-" : (row.aplnId ?? "-")}</TableCell>
|
||||
<TableCell align="center">{displaySeqNo}</TableCell>
|
||||
<TableCell align="center">
|
||||
{isEditing ? (
|
||||
<CodeSelect
|
||||
upCd="ATRZSE"
|
||||
className="min-w-[120px]"
|
||||
<Select
|
||||
value={row.atrzSeComCd ?? ""}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
atrzSeComCd: v,
|
||||
atrzSeComNm: findCodeLabel(atrzSeCodes, v),
|
||||
})
|
||||
onChange({ atrzSeComCd: v, atrzSeComNm: findCodeLabel(selectableAtrzSeCodes, v) })
|
||||
}
|
||||
placeholder="결재구분"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="min-w-[120px]">
|
||||
<SelectValue placeholder="결재구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectableAtrzSeCodes?.map((code) => (
|
||||
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
|
||||
{code.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
atrzSeLabel || "-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">{row.aprvrUserId ?? "-"}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<span>{row.aprvrNm || row.aprvrUuid || "-"}</span>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span>{row.aprvrNm || row.aprvrUuid || ""}</span>
|
||||
{isEditing && (
|
||||
<Button type="button" size="sm" onClick={onPickAprvr}>
|
||||
<Button type="button" size="sm" onClick={onPickAprvr} disabled={!row.atrzSeComCd}>
|
||||
선택
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -49,21 +49,15 @@ export function formatTreeBranchPrefix(depth: number): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function compareSortSeq(a: CklstRvwRow, b: CklstRvwRow): number {
|
||||
return (a.sortSeq ?? 0) - (b.sortSeq ?? 0);
|
||||
}
|
||||
|
||||
export function buildRvwDisplayRows(rows: CklstRvwRow[]): CklstRvwDisplayRow[] {
|
||||
const result: CklstRvwDisplayRow[] = [];
|
||||
|
||||
const roots = rows
|
||||
.filter((row) => (row.rvwCnLvNo ?? 1) === 1 && row.upCklstRvwSn == null)
|
||||
.sort(compareSortSeq);
|
||||
const roots = rows.filter((row) => (row.rvwCnLvNo ?? 1) === 1 && row.upCklstRvwSn == null);
|
||||
|
||||
function appendSubtree(parent: CklstRvwRow, depth: number) {
|
||||
const children = rows
|
||||
.filter((row) => row.upCklstRvwSn != null && row.upCklstRvwSn === parent.cklstRvwSn)
|
||||
.sort(compareSortSeq);
|
||||
const children = rows.filter(
|
||||
(row) => row.upCklstRvwSn != null && row.upCklstRvwSn === parent.cklstRvwSn,
|
||||
);
|
||||
|
||||
for (const child of children) {
|
||||
result.push({ row: child, depth });
|
||||
@@ -82,7 +76,7 @@ export function buildRvwDisplayRows(rows: CklstRvwRow[]): CklstRvwDisplayRow[] {
|
||||
(row) => !result.some((display) => display.row.rowKey === row.rowKey),
|
||||
);
|
||||
|
||||
for (const orphan of orphans.sort(compareSortSeq)) {
|
||||
for (const orphan of orphans) {
|
||||
result.push({ row: orphan, depth: Math.max(0, (orphan.rvwCnLvNo ?? 1) - 1) });
|
||||
}
|
||||
|
||||
|
||||
@@ -279,12 +279,13 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
|
||||
) : (
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="18%" />
|
||||
<col width="42%" />
|
||||
<col width="12%" />
|
||||
<col width="12%" />
|
||||
<col width="16%" />
|
||||
<col width="36%" />
|
||||
<col width="10%" />
|
||||
<col width="10%" />
|
||||
<col width="8%" />
|
||||
<col width="5%" />
|
||||
<col width="11%" />
|
||||
<col width="15%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -292,6 +293,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
|
||||
<TableHead>주요 검토내용</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>체크 사용</TableHead>
|
||||
<TableHead>정렬 순서</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
|
||||
@@ -107,6 +107,20 @@ function CklstRvwRowComponent({
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={row.sortSeq ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
sortSeq: e.target.value ? Number(e.target.value) : undefined,
|
||||
isDirty: true,
|
||||
})
|
||||
}
|
||||
className="w-20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">{levelLabel}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
|
||||
@@ -2,7 +2,6 @@ export const FNRS_CNSLTN_VIEW_BUTTON_ACTION = {
|
||||
strg: "strg",
|
||||
aply: "aply",
|
||||
del: "del",
|
||||
splmntCmptn: "splmntCmptn",
|
||||
fnrsCnsltnAncmnt: "fnrsCnsltnAncmnt",
|
||||
envoSbmsn: "envoSbmsn",
|
||||
rjct: "rjct",
|
||||
@@ -15,7 +14,6 @@ export const FNRS_CNSLTN_VIEW_BUTTON_ACTION = {
|
||||
dmndSplmnt: "dmndSplmnt",
|
||||
atrzAprv: "atrzAprv",
|
||||
atrzRply: "atrzRply",
|
||||
grndsChck: "grndsChck",
|
||||
} as const;
|
||||
|
||||
export type FnrsCnsltnViewButtonAction =
|
||||
@@ -33,7 +31,6 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, FnrsCnsltnViewBu
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply,
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION.del,
|
||||
],
|
||||
SL: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn],
|
||||
TA: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt],
|
||||
},
|
||||
ROLE_USER04: {
|
||||
@@ -44,10 +41,6 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, FnrsCnsltnViewBu
|
||||
LE: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw],
|
||||
TP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply],
|
||||
},
|
||||
ROLE_USER01: {
|
||||
TE: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.grndsChck],
|
||||
SL: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn],
|
||||
},
|
||||
};
|
||||
|
||||
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
|
||||
|
||||
@@ -10,7 +10,10 @@ export const fnrsCnsltnRegistSchema = z.object({
|
||||
cfmtnBgtBizNo: z.string().optional(),
|
||||
bgtAplyYr: z.string().optional(),
|
||||
dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
|
||||
dsgnrTelno: z.string().min(1, "설계자 전화번호는 필수입니다"),
|
||||
dsgnrTelno: z
|
||||
.string()
|
||||
.min(1, "설계자 전화번호는 필수입니다")
|
||||
.regex(/^[0-9-]+$/, "숫자와 하이픈(-)만 입력 가능합니다"),
|
||||
dsgnrOgdpNm: z.string().min(1, "설계자 소속은 필수입니다"),
|
||||
dsgnrEmlAddr: z
|
||||
.string()
|
||||
@@ -80,6 +83,24 @@ export const fnrsCnsltnRegistSchema = z.object({
|
||||
atchFileGroupId: z.string(),
|
||||
}),
|
||||
),
|
||||
splmntDmndList: z.array(
|
||||
z.object({
|
||||
splmntDmndSn: z.number().optional(),
|
||||
splmntArtclComCd: z.string().optional(),
|
||||
splmntArtclCdNm: z.string().nullish(),
|
||||
splmntDmndCn: z.string().optional(),
|
||||
splmntDmndDt: z.string().optional(),
|
||||
splmntDmndAtchFileGroupId: z.string().optional(),
|
||||
splmntDmndAtchFileNm: z.string().optional(),
|
||||
splmntCn: z.string().optional(),
|
||||
splmntPicNm: z.string().optional(),
|
||||
splmntCmptnDt: z.string().optional(),
|
||||
splmntAtchFileGroupId: z.string().optional(),
|
||||
splmntAtchFileNm: z.string().optional(),
|
||||
splmntAtchFileAtflId: z.string().optional(),
|
||||
cmptnYn: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
privacyConsent: z.enum(["Y", "N"], {
|
||||
required_error: "개인정보 수집/이용 동의는 필수입니다",
|
||||
}),
|
||||
|
||||
@@ -34,6 +34,7 @@ export function mapDetailToFormData(data: FnrsCnsltnMngDetResponseDto): FnrsCnsl
|
||||
const bizCstList = data.bizCstList ?? [];
|
||||
const cklstResultList = data.cklstResultList;
|
||||
const esntlFileList = data.esntlFileList;
|
||||
const splmntDmndList = data.splmntDmndList;
|
||||
|
||||
return {
|
||||
fnrsCnsltnMng: {
|
||||
@@ -94,6 +95,23 @@ export function mapDetailToFormData(data: FnrsCnsltnMngDetResponseDto): FnrsCnsl
|
||||
picRvwCn: item.picRvwCn,
|
||||
})) ?? [],
|
||||
esntlAtchFileList: mapEsntlAtchFileList(esntlFileList),
|
||||
splmntDmndList:
|
||||
splmntDmndList?.map((item) => ({
|
||||
splmntDmndSn: item.splmntDmndSn,
|
||||
splmntArtclComCd: item.splmntArtclComCd,
|
||||
splmntArtclCdNm: item.splmntArtclCdNm,
|
||||
splmntDmndCn: item.splmntDmndCn,
|
||||
splmntDmndDt: item.splmntDmndDt,
|
||||
splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId ?? "",
|
||||
splmntDmndAtchFileNm: item.splmntDmndAtchFileInfo?.orgnlFileNm ?? "",
|
||||
splmntCn: item.splmntCn ?? "",
|
||||
splmntPicNm: item.splmntPicNm ?? "",
|
||||
splmntCmptnDt: item.splmntCmptnDt ?? "",
|
||||
splmntAtchFileGroupId: item.splmntAtchFileGroupId ?? "",
|
||||
splmntAtchFileNm: item.splmntAtchFileInfo?.orgnlFileNm ?? "",
|
||||
splmntAtchFileAtflId: item.splmntAtchFileInfo?.atflId ?? "",
|
||||
cmptnYn: item.cmptnYn,
|
||||
})) ?? [],
|
||||
privacyConsent: "Y",
|
||||
};
|
||||
}
|
||||
@@ -130,6 +148,7 @@ export function getCreateDefaultValues(
|
||||
fnrsSharList: [],
|
||||
cklstResultList: [],
|
||||
esntlAtchFileList: [],
|
||||
splmntDmndList: [],
|
||||
privacyConsent: "N",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,150 +1,167 @@
|
||||
import type {
|
||||
BizCstItem,
|
||||
BizCstTreeDto,
|
||||
BizSclTreeDto,
|
||||
BzScaleItem,
|
||||
CklstResultItem,
|
||||
EsntlAtchFileItem,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
FnrsCnsltnApplySaveRequestDto,
|
||||
FnrsCnsltnMngDetResponseDto,
|
||||
FnrsCnsltnMngResponseDto,
|
||||
FnrsCnsltnSplmntDmndItem,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
import { mapFnrsSharDetToFormList, toFnrsSharSaveList } from "@/widgets/pbadms-sprt";
|
||||
|
||||
import type { ChecklistFormState, ReviewStepCode } from "../model/view-types";
|
||||
|
||||
import { toCklstSaveDto } from "./checklist-utils";
|
||||
import { type ReviewOpinionGetFormDataResult, toRvwOpnnSaveDto } from "./review-opinion-utils";
|
||||
|
||||
export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): BzScaleItem[] {
|
||||
return bizSclList
|
||||
.filter((node) => node.useYn === "Y")
|
||||
.map((node) => ({
|
||||
cnstpSeCd: node.cnstpSeCd,
|
||||
bscPl: node.bscPlanQty,
|
||||
budgetReflct: node.bgtRfltQty,
|
||||
thtiDsgn: node.tstmDesignQty,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): BizCstItem[] {
|
||||
return bizCstList
|
||||
.filter((node) => node.useYn === "Y")
|
||||
.map((node) => ({
|
||||
acntgSeCd: node.acntgSeCd,
|
||||
bscPlanAmt: node.bscPlanAmt,
|
||||
bgtRfltAmt: node.bgtRfltAmt,
|
||||
tstmDesignAmt: node.tstmDesignAmt,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toEsntlAtchFileSaveDto(
|
||||
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
|
||||
): EsntlAtchFileItem[] {
|
||||
const esntlMap = new Map<string, string>();
|
||||
|
||||
for (const item of esntlFileList) {
|
||||
if (!item.esntlAtchFileId || !item.atflCd) continue;
|
||||
esntlMap.set(item.esntlAtchFileId, item.atflCd);
|
||||
}
|
||||
|
||||
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
|
||||
esntlAtchFileId,
|
||||
atchFileGroupId,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSavePayload(
|
||||
data: FnrsCnsltnMngDetResponseDto,
|
||||
checklistFormState: ChecklistFormState,
|
||||
reviewStep: ReviewStepCode,
|
||||
prgrsSttsComCd: string,
|
||||
): FnrsCnsltnApplySaveRequestDto {
|
||||
const detDto = data.detDto;
|
||||
if (!detDto) {
|
||||
throw new Error("상세 정보(detDto)가 없습니다.");
|
||||
}
|
||||
|
||||
const bizSclList = data.bizSclList ?? [];
|
||||
const bizCstList = data.bizCstList ?? [];
|
||||
const cklstResultList = data.cklstResultList ?? [];
|
||||
const esntlFileList = data.esntlFileList ?? [];
|
||||
|
||||
return {
|
||||
fnrsCnsltnMng: { ...detDto, prgrsSttsComCd },
|
||||
bzScaleList: toBzScaleSaveDto(bizSclList),
|
||||
bizCstList: toBizCstSaveDto(bizCstList),
|
||||
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
|
||||
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
|
||||
fnrsSharList: toFnrsSharSaveList(mapFnrsSharDetToFormList(data.fnrsSharList ?? [])),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTechReviewReplyPayload(
|
||||
data: FnrsCnsltnMngDetResponseDto,
|
||||
checklistFormState: ChecklistFormState,
|
||||
reviewStep: ReviewStepCode,
|
||||
prgrsSttsComCd: string,
|
||||
techReViewSaveCd: string,
|
||||
reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null,
|
||||
opnrNm?: string,
|
||||
): FnrsCnsltnApplySaveRequestDto {
|
||||
return {
|
||||
...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
|
||||
techReViewSaveCd,
|
||||
rvwStpCd: reviewStep,
|
||||
rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSupplementReqPayload(
|
||||
data: FnrsCnsltnMngDetResponseDto,
|
||||
splmntDmndCn: string,
|
||||
): FnrsCnsltnApplySaveRequestDto {
|
||||
const detDto = data.detDto;
|
||||
if (!detDto) {
|
||||
throw new Error("상세 정보(detDto)가 없습니다.");
|
||||
}
|
||||
|
||||
const nextSn =
|
||||
(data.splmntDmndList?.reduce((max, item) => Math.max(max, item.splmntDmndSn ?? 0), 0) ?? 0) + 1;
|
||||
|
||||
const splmntDmndList: FnrsCnsltnSplmntDmndItem[] = [
|
||||
...(data.splmntDmndList ?? []),
|
||||
{
|
||||
splmntDmndSn: nextSn,
|
||||
splmntDmndCn,
|
||||
cmptnYn: "N",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
fnrsCnsltnMng: detDto,
|
||||
splmntDmndList,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSupplementSubmitPayload(
|
||||
data: FnrsCnsltnMngDetResponseDto,
|
||||
checklistFormState: ChecklistFormState,
|
||||
): FnrsCnsltnApplySaveRequestDto {
|
||||
return {
|
||||
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
|
||||
splmntDmndList: data.splmntDmndList,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTechReviewPayload(
|
||||
det: FnrsCnsltnMngResponseDto,
|
||||
cklstResultList: CklstResultItem[],
|
||||
): FnrsCnsltnApplySaveRequestDto {
|
||||
return {
|
||||
fnrsCnsltnMng: { ...det, prgrsSttsComCd: "ER" },
|
||||
bzScaleList: [],
|
||||
bizCstList: [],
|
||||
cklstResultList,
|
||||
esntlAtchFileList: [],
|
||||
};
|
||||
}
|
||||
import type {
|
||||
BizCstItem,
|
||||
BizCstTreeDto,
|
||||
BizSclTreeDto,
|
||||
BzScaleItem,
|
||||
CklstResultItem,
|
||||
EsntlAtchFileItem,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
FnrsCnsltnApplySaveRequestDto,
|
||||
FnrsCnsltnMngDetResponseDto,
|
||||
FnrsCnsltnMngResponseDto,
|
||||
FnrsCnsltnSplmntDmndItem,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
import {
|
||||
mapFnrsSharDetToFormList,
|
||||
type SplmntDmndDialogConfirmPayload,
|
||||
toFnrsSharSaveList,
|
||||
} from "@/widgets/pbadms-sprt";
|
||||
|
||||
import type { ChecklistFormState, ReviewStepCode } from "../model/view-types";
|
||||
|
||||
import { toCklstSaveDto } from "./checklist-utils";
|
||||
import { type ReviewOpinionGetFormDataResult, toRvwOpnnSaveDto } from "./review-opinion-utils";
|
||||
|
||||
export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): BzScaleItem[] {
|
||||
return bizSclList
|
||||
.filter((node) => node.useYn === "Y")
|
||||
.map((node) => ({
|
||||
cnstpSeCd: node.cnstpSeCd,
|
||||
bscPl: node.bscPlanQty,
|
||||
budgetReflct: node.bgtRfltQty,
|
||||
thtiDsgn: node.tstmDesignQty,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): BizCstItem[] {
|
||||
return bizCstList
|
||||
.filter((node) => node.useYn === "Y")
|
||||
.map((node) => ({
|
||||
acntgSeCd: node.acntgSeCd,
|
||||
bscPlanAmt: node.bscPlanAmt,
|
||||
bgtRfltAmt: node.bgtRfltAmt,
|
||||
tstmDesignAmt: node.tstmDesignAmt,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toEsntlAtchFileSaveDto(
|
||||
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
|
||||
): EsntlAtchFileItem[] {
|
||||
const esntlMap = new Map<string, string>();
|
||||
|
||||
for (const item of esntlFileList) {
|
||||
if (!item.esntlAtchFileId || !item.atflCd) continue;
|
||||
esntlMap.set(item.esntlAtchFileId, item.atflCd);
|
||||
}
|
||||
|
||||
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
|
||||
esntlAtchFileId,
|
||||
atchFileGroupId,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSavePayload(
|
||||
data: FnrsCnsltnMngDetResponseDto,
|
||||
checklistFormState: ChecklistFormState,
|
||||
reviewStep: ReviewStepCode,
|
||||
prgrsSttsComCd: string,
|
||||
): FnrsCnsltnApplySaveRequestDto {
|
||||
const detDto = data.detDto;
|
||||
if (!detDto) {
|
||||
throw new Error("상세 정보(detDto)가 없습니다.");
|
||||
}
|
||||
|
||||
const bizSclList = data.bizSclList ?? [];
|
||||
const bizCstList = data.bizCstList ?? [];
|
||||
const cklstResultList = data.cklstResultList ?? [];
|
||||
const esntlFileList = data.esntlFileList ?? [];
|
||||
|
||||
return {
|
||||
fnrsCnsltnMng: { ...detDto, prgrsSttsComCd },
|
||||
bzScaleList: toBzScaleSaveDto(bizSclList),
|
||||
bizCstList: toBizCstSaveDto(bizCstList),
|
||||
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
|
||||
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
|
||||
fnrsSharList: toFnrsSharSaveList(mapFnrsSharDetToFormList(data.fnrsSharList ?? [])),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTechReviewReplyPayload(
|
||||
data: FnrsCnsltnMngDetResponseDto,
|
||||
checklistFormState: ChecklistFormState,
|
||||
reviewStep: ReviewStepCode,
|
||||
prgrsSttsComCd: string,
|
||||
techReViewSaveCd: string,
|
||||
reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null,
|
||||
opnrNm?: string,
|
||||
): FnrsCnsltnApplySaveRequestDto {
|
||||
return {
|
||||
...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
|
||||
techReViewSaveCd,
|
||||
rvwStpCd: reviewStep,
|
||||
rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSupplementReqPayload(
|
||||
data: FnrsCnsltnMngDetResponseDto,
|
||||
payload: SplmntDmndDialogConfirmPayload,
|
||||
checklistFormState: ChecklistFormState,
|
||||
): FnrsCnsltnApplySaveRequestDto {
|
||||
const existingItems: FnrsCnsltnSplmntDmndItem[] = (data.splmntDmndList ?? []).map((item) =>
|
||||
toSplmntDmndSaveItem(item),
|
||||
);
|
||||
|
||||
const nextSn = existingItems.reduce((max, item) => Math.max(max, item.splmntDmndSn ?? 0), 0) + 1;
|
||||
|
||||
const newItems: FnrsCnsltnSplmntDmndItem[] = payload.items.map((item, index) => ({
|
||||
splmntDmndSn: nextSn + index,
|
||||
splmntArtclComCd: item.splmntArtclComCd,
|
||||
splmntDmndCn: item.splmntDmndCn,
|
||||
splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId,
|
||||
cmptnYn: "N",
|
||||
}));
|
||||
|
||||
return {
|
||||
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
|
||||
splmntDmndList: [...existingItems, ...newItems],
|
||||
};
|
||||
}
|
||||
|
||||
export function toSplmntDmndSaveItem(item: FnrsCnsltnSplmntDmndItem): FnrsCnsltnSplmntDmndItem {
|
||||
return {
|
||||
splmntDmndSn: item.splmntDmndSn,
|
||||
splmntArtclComCd: item.splmntArtclComCd,
|
||||
splmntDmndCn: item.splmntDmndCn,
|
||||
splmntDmndDt: item.splmntDmndDt,
|
||||
splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId,
|
||||
splmntCn: item.splmntCn,
|
||||
splmntPicNm: item.splmntPicNm,
|
||||
splmntCmptnDt: item.splmntCmptnDt,
|
||||
splmntAtchFileGroupId: item.splmntAtchFileGroupId,
|
||||
cmptnYn: item.cmptnYn,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSupplementSubmitPayload(
|
||||
data: FnrsCnsltnMngDetResponseDto,
|
||||
checklistFormState: ChecklistFormState,
|
||||
): FnrsCnsltnApplySaveRequestDto {
|
||||
return {
|
||||
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
|
||||
splmntDmndList: data.splmntDmndList,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTechReviewPayload(
|
||||
det: FnrsCnsltnMngResponseDto,
|
||||
cklstResultList: CklstResultItem[],
|
||||
): FnrsCnsltnApplySaveRequestDto {
|
||||
return {
|
||||
fnrsCnsltnMng: { ...det, prgrsSttsComCd: "ER" },
|
||||
bzScaleList: [],
|
||||
bizCstList: [],
|
||||
cklstResultList,
|
||||
esntlAtchFileList: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import type { CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn";
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -36,6 +37,24 @@ function ChecklistTaskPanel({
|
||||
const currentUserReviewStep = userRoleCode ? ROLE_TO_REVIEW_STEP[userRoleCode] : undefined;
|
||||
const isStepEditable = currentUserReviewStep === selectedReviewStep;
|
||||
|
||||
function handleSelectAll(value: "Y" | "N") {
|
||||
if (!isStepEditable) return;
|
||||
setFormState((prev) => {
|
||||
const nextResults = { ...prev.results };
|
||||
selectedRows
|
||||
.filter((row) => row.bchckUseYn === "Y")
|
||||
.forEach((row) => {
|
||||
nextResults[buildChecklistRowKey(row, selectedReviewStep)] = value;
|
||||
});
|
||||
return patchChecklistFormState(
|
||||
prev,
|
||||
{ results: nextResults },
|
||||
allRows,
|
||||
selectedReviewStep,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
@@ -54,8 +73,27 @@ function ChecklistTaskPanel({
|
||||
) : null}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="w-[9rem]">예</TableHead>
|
||||
<TableHead className="w-[9rem]">아니오</TableHead>
|
||||
<TableHead className="w-[12rem]">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!isStepEditable}
|
||||
onClick={() => handleSelectAll("Y")}
|
||||
>
|
||||
전체 예
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[12rem]">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!isStepEditable}
|
||||
onClick={() => handleSelectAll("N")}
|
||||
className="!bg-destructive"
|
||||
>
|
||||
전체 아니오
|
||||
</Button>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -131,7 +169,7 @@ function ChecklistTaskPanel({
|
||||
<TableCell className="text-left">
|
||||
<Textarea
|
||||
className={cn(
|
||||
"min-h-[6rem] !text-xl",
|
||||
"min-h-[6rem] w-[32rem] !text-xl",
|
||||
isStepEditable ? "cursor-pointer" : "cursor-not-allowed",
|
||||
)}
|
||||
value={formState.comments[key] ?? ""}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
type ElaprAplnRow,
|
||||
useAplnRows,
|
||||
useDndSensors,
|
||||
useElaprAplnPage,
|
||||
validateAplnRow,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import type { ReviewerOption } from "../../model/reviewer-option";
|
||||
|
||||
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
|
||||
import { SortableAplnPickerRow } from "./sortable-apln-picker-row";
|
||||
|
||||
interface ElaprAplnPickerGridProps {
|
||||
elaprTmpltId: string | null;
|
||||
onRowsChange?: (rows: ElaprAplnRow[]) => void;
|
||||
onEditingLockedChange?: (locked: boolean) => void;
|
||||
}
|
||||
|
||||
function ElaprAplnPickerGrid({
|
||||
elaprTmpltId,
|
||||
onRowsChange,
|
||||
onEditingLockedChange,
|
||||
}: ElaprAplnPickerGridProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickingRowKey, setPickingRowKey] = useState<string | null>(null);
|
||||
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const aplnQuery = useElaprAplnPage({
|
||||
elaprTmpltId: elaprTmpltId ?? undefined,
|
||||
page: 0,
|
||||
size: 200,
|
||||
sort: "atrzSeqNo,asc",
|
||||
});
|
||||
|
||||
const {
|
||||
rows,
|
||||
visibleRows,
|
||||
editingRowKey,
|
||||
isEditingLocked,
|
||||
addRow,
|
||||
startEdit,
|
||||
updateRow,
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
markDeleted,
|
||||
reorderRows,
|
||||
} = useAplnRows({
|
||||
elaprTmpltId,
|
||||
serverRows: aplnQuery.data?.content,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onRowsChange?.(rows);
|
||||
}, [rows, onRowsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onEditingLockedChange?.(isEditingLocked);
|
||||
}, [isEditingLocked, onEditingLockedChange]);
|
||||
|
||||
const sensors = useDndSensors();
|
||||
const isDisabled = !elaprTmpltId;
|
||||
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
reorderRows(String(active.id), String(over.id));
|
||||
}
|
||||
|
||||
function handleRowChange(row: ElaprAplnRow, patch: Partial<ElaprAplnRow>) {
|
||||
updateRow(row.rowKey, patch);
|
||||
}
|
||||
|
||||
function handleRowSave(row: ElaprAplnRow) {
|
||||
const fieldError = validateAplnRow(row);
|
||||
if (fieldError) {
|
||||
alert(fieldError);
|
||||
return;
|
||||
}
|
||||
|
||||
commitEdit(row.rowKey);
|
||||
}
|
||||
|
||||
function handleRowDelete(row: ElaprAplnRow) {
|
||||
markDeleted(row.rowKey);
|
||||
}
|
||||
|
||||
function handleReviewerSelect(reviewer: ReviewerOption) {
|
||||
if (!pickingRowKey) return;
|
||||
updateRow(pickingRowKey, {
|
||||
aprvrUuid: reviewer.uuid,
|
||||
aprvrNm: reviewer.name,
|
||||
});
|
||||
setPickingRowKey(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card card-border mx-0 min-h-[32rem] flex-1 pt-0">
|
||||
<div className="card-header flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-2xl font-semibold">결재선 관리</h3>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
color="green"
|
||||
disabled={isDisabled || isEditingLocked}
|
||||
onClick={addRow}
|
||||
>
|
||||
결재선 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aplnQuery.isPending && elaprTmpltId ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : !elaprTmpltId ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">결재서식을 선택하세요.</p>
|
||||
) : visibleRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="10%" />
|
||||
<col width="30%" />
|
||||
<col width="10%" />
|
||||
<col width="10%" />
|
||||
<col width="30%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>결재선아이디</TableHead>
|
||||
<TableHead>결재순서</TableHead>
|
||||
<TableHead>결재구분</TableHead>
|
||||
<TableHead>결재자</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<SortableContext
|
||||
items={visibleRows.map((r) => r.rowKey)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{visibleRows.map((row, index) => (
|
||||
<SortableAplnPickerRow
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
displaySeqNo={index + 1}
|
||||
isEditing={editingRowKey === row.rowKey}
|
||||
isLocked={isEditingLocked}
|
||||
dragDisabled={isEditingLocked || !row.atrzSeComCd}
|
||||
onStartEdit={() => startEdit(row.rowKey)}
|
||||
onSave={() => handleRowSave(row)}
|
||||
onCancel={cancelEdit}
|
||||
onDelete={() => handleRowDelete(row)}
|
||||
onChange={(patch) => handleRowChange(row, patch)}
|
||||
onPickAprvr={() => {
|
||||
setPickingRowKey(row.rowKey);
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
<AprvrPickerDialog
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
atrzSeComCd={pickingRow?.atrzSeComCd ?? ""}
|
||||
onSelect={handleReviewerSelect}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprAplnPickerGrid };
|
||||
@@ -1,419 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
buildPrgrsSttsSavePayload,
|
||||
elaprPrgrsSttsService,
|
||||
findCurrentUserPendingApprovalRequest,
|
||||
hasUnsavedPrgrsSttsChanges,
|
||||
isPrgrsSttsRowEditable,
|
||||
useDndSensors,
|
||||
useElaprDocPrgrsByDocNo,
|
||||
usePrgrsSttsRows,
|
||||
useSaveElaprPrgrsStts,
|
||||
validateAllPrgrsSttsRows,
|
||||
validateAtrzOrder,
|
||||
validatePrgrsSttsRow,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Label,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import type { ReviewerOption } from "../../model/reviewer-option";
|
||||
|
||||
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
|
||||
import { SortablePrgrsSttsRow } from "./sortable-prgrs-stts-row";
|
||||
|
||||
interface ElaprApprovalDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
elaprDocNo: string;
|
||||
currentUserUuid: string;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const UNSAVED_PRGRS_STTS_MESSAGE = "결재선 변경 이력이 있습니다.\n결재선 저장을 해 주세요.";
|
||||
|
||||
function ElaprApprovalDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
elaprDocNo,
|
||||
currentUserUuid,
|
||||
onSuccess,
|
||||
}: ElaprApprovalDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [rjctRsn, setRjctRsn] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickingRowKey, setPickingRowKey] = useState<string | null>(null);
|
||||
const [finalApprovalYn, setFinalApprovalYn] = useState(false);
|
||||
|
||||
const { data: elaprDocPrgrsList, isLoading } = useElaprDocPrgrsByDocNo(open ? elaprDocNo : null);
|
||||
|
||||
const elaprDmndId = elaprDocPrgrsList?.[0]?.elaprDmndId;
|
||||
|
||||
const {
|
||||
rows,
|
||||
visibleRows,
|
||||
editingRowKey,
|
||||
isEditingLocked,
|
||||
addRow,
|
||||
startEdit,
|
||||
updateRow,
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
markDeleted,
|
||||
reorderRows,
|
||||
} = usePrgrsSttsRows({
|
||||
serverRows: elaprDocPrgrsList,
|
||||
elaprDmndId,
|
||||
});
|
||||
|
||||
const savePrgrsSttsMutation = useSaveElaprPrgrsStts();
|
||||
const sensors = useDndSensors();
|
||||
|
||||
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(
|
||||
elaprDocPrgrsList,
|
||||
currentUserUuid,
|
||||
);
|
||||
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
|
||||
|
||||
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
|
||||
const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
|
||||
const isPrgrsSttsUnsaved = hasUnsavedPrgrsSttsChanges(rows, elaprDocPrgrsList ?? []);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
reorderRows(String(active.id), String(over.id));
|
||||
}
|
||||
|
||||
function handleRowSave(rowKey: string) {
|
||||
const row = rows.find((r) => r.rowKey === rowKey);
|
||||
if (!row) return;
|
||||
|
||||
const fieldError = validatePrgrsSttsRow(row);
|
||||
if (fieldError) {
|
||||
alert(fieldError);
|
||||
return;
|
||||
}
|
||||
|
||||
commitEdit(rowKey);
|
||||
}
|
||||
|
||||
function handleReviewerSelect(reviewer: ReviewerOption) {
|
||||
if (!pickingRowKey) return;
|
||||
updateRow(pickingRowKey, {
|
||||
aprvrUuid: reviewer.uuid,
|
||||
aprvrNm: reviewer.name,
|
||||
ognzNm: reviewer.deptNm,
|
||||
});
|
||||
setPickingRowKey(null);
|
||||
}
|
||||
|
||||
function assertPrgrsSttsSaved(): boolean {
|
||||
if (isEditingLocked) {
|
||||
alert(UNSAVED_PRGRS_STTS_MESSAGE);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPrgrsSttsUnsaved) {
|
||||
alert(UNSAVED_PRGRS_STTS_MESSAGE);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleSavePrgrsStts() {
|
||||
if (!elaprDmndId) {
|
||||
alert("결재 요청 정보가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateAllPrgrsSttsRows(rows, isEditingLocked);
|
||||
if (validationError) {
|
||||
alert(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const orderError = validateAtrzOrder(rows);
|
||||
if (orderError) {
|
||||
alert(orderError);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildPrgrsSttsSavePayload(rows, elaprDmndId, elaprDocPrgrsList ?? []);
|
||||
if (payload.length === 0) {
|
||||
alert("변경된 결재선이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await savePrgrsSttsMutation.mutateAsync(payload);
|
||||
alert("결재선이 저장되었습니다.");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
|
||||
});
|
||||
onSuccess();
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "결재선 저장 중 오류가 발생했습니다.";
|
||||
if (error instanceof AxiosError) {
|
||||
errorMessage = error.response?.data?.message ?? errorMessage;
|
||||
}
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!currentUserPrgrs?.elaprPrgrsId) return;
|
||||
if (!assertPrgrsSttsSaved()) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
|
||||
atrzPrgrsSttsComCd: "ATRZ_PRGRS_003",
|
||||
finalApprovalYn,
|
||||
});
|
||||
alert("결재가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
|
||||
});
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "";
|
||||
if (error instanceof AxiosError) {
|
||||
errorMessage = error.response?.data?.message ?? "";
|
||||
} else {
|
||||
errorMessage = "결재 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!currentUserPrgrs?.elaprPrgrsId) return;
|
||||
if (!rjctRsn.trim()) {
|
||||
alert("반려 사유를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!assertPrgrsSttsSaved()) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
|
||||
atrzPrgrsSttsComCd: "ATRZ_PRGRS_004",
|
||||
rjctRsn: rjctRsn.trim(),
|
||||
});
|
||||
alert("반려 처리가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
|
||||
});
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "";
|
||||
if (error instanceof AxiosError) {
|
||||
errorMessage = error.response?.data?.message ?? "";
|
||||
} else {
|
||||
errorMessage = "반려 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setRjctRsn("");
|
||||
setPickingRowKey(null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[72rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>전자결재</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<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>
|
||||
) : visibleRows.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
{elaprDmndId
|
||||
? "등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요."
|
||||
: "결재 정보가 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>결재순서</TableHead>
|
||||
<TableHead>결재구분</TableHead>
|
||||
<TableHead>결재자</TableHead>
|
||||
<TableHead>결재상태</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<SortableContext
|
||||
items={visibleRows.map((r) => r.rowKey)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{visibleRows.map((row, index) => (
|
||||
<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>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canApprove && (
|
||||
<div>
|
||||
<h4 className="mb-3 text-xl font-semibold">반려 의견</h4>
|
||||
<Textarea
|
||||
value={rjctRsn}
|
||||
onChange={(event) => setRjctRsn(event.target.value)}
|
||||
placeholder="반려 시 사유를 입력하세요"
|
||||
rows={4}
|
||||
className="!text-xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprApprovalDialog };
|
||||
@@ -11,8 +11,11 @@ import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import {
|
||||
buildElaprPrgrsSttsListFromAplnRows,
|
||||
ElaprApprovalDialog,
|
||||
type ElaprDmndSaveRequestDto,
|
||||
elaprDmndService,
|
||||
type ElaprTmpltPickerConfirmPayload,
|
||||
ElaprTmpltPickerDialog,
|
||||
hasCurrentUserPendingApprovalRequest,
|
||||
useElaprDocPrgrsByDocNo,
|
||||
} from "@/entities/elapr";
|
||||
@@ -24,7 +27,7 @@ import {
|
||||
fnrsCnsltnMngService,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
|
||||
import { TextInputDialog } from "@/widgets/pbadms-sprt";
|
||||
import { SplmntDmndDialog, type SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import {
|
||||
@@ -37,18 +40,12 @@ import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
|
||||
import {
|
||||
buildSavePayload,
|
||||
buildSupplementReqPayload,
|
||||
buildSupplementSubmitPayload,
|
||||
buildTechReviewReplyPayload,
|
||||
toSplmntDmndSaveItem,
|
||||
} from "../../lib/save-payload-utils";
|
||||
import { type ChecklistFormState } from "../../model/view-types";
|
||||
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
|
||||
|
||||
import { ElaprApprovalDialog } from "./elapr-approval-dialog";
|
||||
import {
|
||||
type ElaprTmpltPickerConfirmPayload,
|
||||
ElaprTmpltPickerDialog,
|
||||
} from "./elapr-tmplt-picker-dialog";
|
||||
|
||||
interface FloatingActionButtonsProps {
|
||||
data: FnrsCnsltnMngDetResponseDto;
|
||||
docSeComCd: string;
|
||||
@@ -222,15 +219,6 @@ function FloatingActionButtons({
|
||||
router.push("/fnrs-cnsltn");
|
||||
}
|
||||
|
||||
async function handleSplmntCmptnClick() {
|
||||
const payload = buildSupplementSubmitPayload(data, checklistFormState);
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn,
|
||||
request: payload,
|
||||
successMessage: "보완완료 처리되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFnrsCnsltnAncmntClick() {
|
||||
const payload: DocPrgrsUpdateRequestDto = {
|
||||
docNo,
|
||||
@@ -383,15 +371,18 @@ function FloatingActionButtons({
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildTechReviewReplyPayload(
|
||||
data,
|
||||
checklistFormState,
|
||||
"KECO",
|
||||
"TE",
|
||||
"SV",
|
||||
getReviewOpinionFormData(),
|
||||
opnrNm,
|
||||
);
|
||||
const payload = {
|
||||
...buildTechReviewReplyPayload(
|
||||
data,
|
||||
checklistFormState,
|
||||
"KECO",
|
||||
"TE",
|
||||
"SV",
|
||||
getReviewOpinionFormData(),
|
||||
opnrNm,
|
||||
),
|
||||
splmntDmndList: (data.splmntDmndList ?? []).map(toSplmntDmndSaveItem),
|
||||
};
|
||||
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
|
||||
@@ -405,12 +396,12 @@ function FloatingActionButtons({
|
||||
setActiveDialog("dmndSplmnt");
|
||||
}
|
||||
|
||||
async function handleDmndSplmntConfirm(splmntDmndCn: string) {
|
||||
async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
|
||||
setActiveDialog(null);
|
||||
const payload = buildSupplementReqPayload(data, splmntDmndCn);
|
||||
const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState);
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
|
||||
request: payload,
|
||||
request: requestPayload,
|
||||
successMessage: "보완요청이 처리되었습니다.",
|
||||
});
|
||||
}
|
||||
@@ -442,11 +433,6 @@ function FloatingActionButtons({
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn) && (
|
||||
<Button onClick={handleSplmntCmptnClick} disabled={isProcessing}>
|
||||
보완완료
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt) && (
|
||||
<Button onClick={handleFnrsCnsltnAncmntClick} disabled={isProcessing}>
|
||||
재원협의 고시
|
||||
@@ -513,14 +499,13 @@ function FloatingActionButtons({
|
||||
<ElaprTmpltPickerDialog
|
||||
open={activeDialog === "elaprTmplt"}
|
||||
onClose={() => setActiveDialog(null)}
|
||||
docSeComCd={docSeComCd}
|
||||
onConfirm={handleElaprTmpltConfirm}
|
||||
/>
|
||||
|
||||
<TextInputDialog
|
||||
<SplmntDmndDialog
|
||||
open={activeDialog === "dmndSplmnt"}
|
||||
onOpenChange={() => setActiveDialog(null)}
|
||||
title="보완요청 내용 입력"
|
||||
placeholder="보완요청 내용을 입력하세요."
|
||||
isSubmitting={isProcessing}
|
||||
onConfirm={handleDmndSplmntConfirm}
|
||||
/>
|
||||
@@ -562,19 +547,6 @@ async function handleDel(context: ActionHandlerContext): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSplmntCmptn(
|
||||
context: ActionHandlerContext<FnrsCnsltnApplySaveRequestDto>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const docNo = context.data.detDto?.fnrsCnsltnDocNo;
|
||||
if (!docNo) throw new Error("문서번호가 없습니다.");
|
||||
await fnrsCnsltnMngService.submitSupplement(docNo, context.request);
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
context.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePrgrsSttsComCd(
|
||||
context: ActionHandlerContext<DocPrgrsUpdateRequestDto>,
|
||||
): Promise<void> {
|
||||
@@ -634,16 +606,10 @@ async function handleAtrzRply(context: ActionHandlerContext): Promise<void> {
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleGrndsChck(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 현장점검 등록 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
const buttonActionHandlers: Record<FnrsCnsltnViewButtonAction, ActionHandler> = {
|
||||
strg: handleSaveApply,
|
||||
aply: handleSaveApply,
|
||||
del: handleDel,
|
||||
splmntCmptn: handleSplmntCmptn,
|
||||
fnrsCnsltnAncmnt: handleChangePrgrsSttsComCd,
|
||||
envoSbmsn: handleChangePrgrsSttsComCd,
|
||||
rjct: handleChangePrgrsSttsComCd,
|
||||
@@ -656,7 +622,6 @@ const buttonActionHandlers: Record<FnrsCnsltnViewButtonAction, ActionHandler> =
|
||||
dmndSplmnt: handleDmndSplmnt,
|
||||
atrzAprv: handleAtrzAprv,
|
||||
atrzRply: handleAtrzRply,
|
||||
grndsChck: handleGrndsChck,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { MoveVertical } from "lucide-react";
|
||||
|
||||
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import type { ElaprAplnRow } from "@/entities/elapr";
|
||||
import { Button, TableCell, TableRow } from "@common/components";
|
||||
|
||||
interface SortableAplnPickerRowProps {
|
||||
row: ElaprAplnRow;
|
||||
displaySeqNo: number;
|
||||
isEditing: boolean;
|
||||
isLocked: boolean;
|
||||
dragDisabled: boolean;
|
||||
onStartEdit: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
onChange: (patch: Partial<ElaprAplnRow>) => void;
|
||||
onPickAprvr: () => void;
|
||||
}
|
||||
|
||||
function SortableAplnPickerRow({
|
||||
row,
|
||||
displaySeqNo,
|
||||
isEditing,
|
||||
isLocked,
|
||||
dragDisabled,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onChange,
|
||||
onPickAprvr,
|
||||
}: SortableAplnPickerRowProps) {
|
||||
const { data: atrzSeCodes } = useCodeList("ATRZSE");
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: row.rowKey,
|
||||
disabled: dragDisabled,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
|
||||
const rowDisabled = isLocked && !isEditing;
|
||||
const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd);
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style} className={isEditing ? "bg-accent/30" : undefined}>
|
||||
<TableCell align="center" className="w-10">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="cursor-grab !bg-transparent text-muted-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={dragDisabled}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="순서 변경"
|
||||
>
|
||||
<MoveVertical className="!size-6" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell align="center">{row.isNew ? "-" : (row.aplnId ?? "-")}</TableCell>
|
||||
<TableCell align="center">{displaySeqNo}</TableCell>
|
||||
<TableCell align="center">
|
||||
{isEditing ? (
|
||||
<CodeSelect
|
||||
upCd="ATRZSE"
|
||||
className="min-w-[120px]"
|
||||
value={row.atrzSeComCd ?? ""}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
atrzSeComCd: v,
|
||||
atrzSeComNm: findCodeLabel(atrzSeCodes, v),
|
||||
})
|
||||
}
|
||||
placeholder="결재구분"
|
||||
/>
|
||||
) : (
|
||||
atrzSeLabel || "-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<span>{row.aprvrNm || row.aprvrUuid || "-"}</span>
|
||||
{isEditing && (
|
||||
<Button type="button" size="sm" onClick={onPickAprvr}>
|
||||
선택
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button type="button" size="sm" onClick={onSave}>
|
||||
확인
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="white" onClick={onCancel}>
|
||||
취소
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
disabled={rowDisabled}
|
||||
onClick={onStartEdit}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="gray"
|
||||
disabled={rowDisabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export { SortableAplnPickerRow };
|
||||
@@ -11,6 +11,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import { useAcntgSeMngTree } from "@/entities/acntg";
|
||||
import {
|
||||
type FnrsCnsltnApplySaveRequestDto,
|
||||
fnrsCnsltnMngBaseKey,
|
||||
type FnrsCnsltnMngDetResponseDto,
|
||||
fnrsCnsltnMngService,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
isFormChecklistComplete,
|
||||
PrivacyConsentSection,
|
||||
recalculateFnrsSharTotals,
|
||||
SplmntDmndFormSection,
|
||||
toFnrsSharSaveList,
|
||||
validateBizCstSharBalance,
|
||||
} from "@/widgets/pbadms-sprt";
|
||||
@@ -56,6 +58,7 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const isEditMode = mode === "edit";
|
||||
const isSplmntPrgrsStts = initialData?.detDto?.prgrsSttsComCd === "SL";
|
||||
|
||||
const { data: masterSharTree = [] } = useFnrsSharSeMngTree();
|
||||
const { data: acntgTree = [] } = useAcntgSeMngTree();
|
||||
@@ -73,10 +76,10 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
: getCreateDefaultValues(userInfo, taskSeComCd),
|
||||
});
|
||||
|
||||
async function submitForm(
|
||||
function buildFormPayload(
|
||||
data: FnrsCnsltnRegistFormData,
|
||||
prgrsSttsComCd: "SV" | "ST",
|
||||
): Promise<string | undefined> {
|
||||
prgrsSttsComCd: "SV" | "ST" | "TE",
|
||||
): FnrsCnsltnApplySaveRequestDto | undefined {
|
||||
const withTotals = recalculateFnrsSharTotals(data.fnrsSharList, sharItems);
|
||||
const validationMessage = validateBizCstSharBalance(
|
||||
data.bizCstList,
|
||||
@@ -86,12 +89,12 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
);
|
||||
if (validationMessage) {
|
||||
alert(validationMessage);
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { taskSeComCd: formTaskSeComCd, ...fnrsCnsltnMngRest } = data.fnrsCnsltnMng;
|
||||
|
||||
const payload = {
|
||||
return {
|
||||
fnrsCnsltnMng: {
|
||||
...fnrsCnsltnMngRest,
|
||||
taskSeComCd: formatTaskSeComCdForApi(formTaskSeComCd),
|
||||
@@ -106,11 +109,71 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
selected: item.selected ?? undefined,
|
||||
})),
|
||||
esntlAtchFileList: data.esntlAtchFileList,
|
||||
splmntDmndList: data.splmntDmndList.map(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ splmntAtchFileNm, splmntAtchFileAtflId, splmntDmndAtchFileNm, ...item }) => ({
|
||||
...item,
|
||||
...(prgrsSttsComCd === "TE" ? { cmptnYn: "Y" } : null),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function submitForm(
|
||||
data: FnrsCnsltnRegistFormData,
|
||||
prgrsSttsComCd: "SV" | "ST",
|
||||
): Promise<string | undefined> {
|
||||
const payload = buildFormPayload(data, prgrsSttsComCd);
|
||||
if (!payload) return undefined;
|
||||
return fnrsCnsltnMngService.saveApply(payload);
|
||||
}
|
||||
|
||||
async function submitSupplementForm(data: FnrsCnsltnRegistFormData): Promise<string | undefined> {
|
||||
const docNo = initialData?.detDto?.fnrsCnsltnDocNo;
|
||||
if (!docNo) return undefined;
|
||||
|
||||
const payload = buildFormPayload(data, "TE");
|
||||
if (!payload) return undefined;
|
||||
|
||||
return fnrsCnsltnMngService.submitSupplement(docNo, payload);
|
||||
}
|
||||
|
||||
async function onSplmntCmptn(data: FnrsCnsltnRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
const hasEmptySplmntCn = data.splmntDmndList?.some(
|
||||
(item) => !item.splmntCn || !item.splmntCn.trim(),
|
||||
);
|
||||
if (hasEmptySplmntCn) {
|
||||
alert("보완요청내용이 있습니다. 보완완료내용을 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const docNo = initialData?.detDto?.fnrsCnsltnDocNo;
|
||||
if (!docNo) {
|
||||
alert("문서번호가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const result = await submitSupplementForm(data);
|
||||
if (!result) return;
|
||||
|
||||
alert("보완완료 처리되었습니다.");
|
||||
router.push(`/fnrs-cnsltn/${docNo}`);
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", docNo],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("보완완료 실패:", error);
|
||||
alert("보완완료 처리 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave(data: FnrsCnsltnRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
@@ -210,6 +273,7 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined}
|
||||
initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined}
|
||||
/>
|
||||
<SplmntDmndFormSection readOnly={!isSplmntPrgrsStts} />
|
||||
|
||||
<div className="sticky bottom-0 z-40 !-mb-2 border-t bg-white pb-8 pt-6 shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
|
||||
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
|
||||
@@ -221,23 +285,40 @@ function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps)
|
||||
>
|
||||
목록
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
저장
|
||||
</Button>
|
||||
{isEditMode && (
|
||||
{!isSplmntPrgrsStts ? (
|
||||
<>
|
||||
<Button
|
||||
color="green"
|
||||
type="button"
|
||||
onClick={handleApplyClick}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
신청완료
|
||||
</Button>
|
||||
<Button color="red" type="button" onClick={handleDelClick} disabled={isSubmitting}>
|
||||
삭제
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(onSplmntCmptn)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
보완완료
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
|
||||
import { useFnrsCnsltnMng } from "@/entities/fnrs-cnsltn";
|
||||
import { SectionCollapsible, SupplementDemandSection } from "@/widgets/pbadms-sprt";
|
||||
import { Skeleton } from "@common/components";
|
||||
|
||||
import { getEditableReviewStep } from "../lib/review-step-utils";
|
||||
@@ -17,7 +19,6 @@ import { BusinessScaleSection } from "./sections/business-scale-section";
|
||||
import { ChecklistSection } from "./sections/checklist-section";
|
||||
import { ContactInfoSection } from "./sections/contact-info-section";
|
||||
import { FnrsSharSection } from "./sections/fnrs-shar-section";
|
||||
import { SupplementDemandSection } from "./sections/supplement-demand-section";
|
||||
import {
|
||||
TechReviewOpinionSection,
|
||||
type TechReviewOpinionSectionHandle,
|
||||
@@ -76,6 +77,9 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
|
||||
<div className="mt-6 space-y-4">
|
||||
<BasicInfoSection det={detDto} />
|
||||
<ContactInfoSection det={detDto} />
|
||||
<SectionCollapsible title="결재현황">
|
||||
<ElaprDocPrgrsStatus elaprDocNo={detDto.fnrsCnsltnDocNo} />
|
||||
</SectionCollapsible>
|
||||
<BusinessScaleSection bizSclList={bizSclList} />
|
||||
<BusinessCostSection bizCstList={bizCstList} />
|
||||
<FnrsSharSection fnrsSharList={fnrsSharList} />
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import type { FnrsCnsltnSplmntDmndResponseDto } from "@/entities/fnrs-cnsltn";
|
||||
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
|
||||
import {
|
||||
Badge,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
interface SupplementDemandSectionProps {
|
||||
splmntDmndList: FnrsCnsltnSplmntDmndResponseDto[];
|
||||
}
|
||||
|
||||
function SupplementDemandSection({ splmntDmndList }: SupplementDemandSectionProps) {
|
||||
if (splmntDmndList.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="보완요청">
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[8rem]">항목</TableHead>
|
||||
<TableHead>보완요청 내용</TableHead>
|
||||
<TableHead>보완 내용</TableHead>
|
||||
<TableHead className="w-[8rem]">완료여부</TableHead>
|
||||
<TableHead className="w-[12rem]">요청일시</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{splmntDmndList.map((item) => (
|
||||
<TableRow key={item.splmntDmndSn}>
|
||||
<TableCell>{item.splmntArtclCdNm || "-"}</TableCell>
|
||||
<TableCell className="whitespace-pre-wrap">{item.splmntDmndCn || "-"}</TableCell>
|
||||
<TableCell className="whitespace-pre-wrap">{item.splmntCn || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge color={item.cmptnYn === "Y" ? "green" : "gray"}>
|
||||
{item.cmptnYn === "Y" ? "완료" : "미완료"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.splmntDmndDt ? dayjs(item.splmntDmndDt).format("YYYY-MM-DD HH:mm") : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { SupplementDemandSection };
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { ElaprDocPrgrsClientResponseDto } from "@/entities/instl-sncn";
|
||||
|
||||
export interface ApproverStatusConfig {
|
||||
label: string;
|
||||
badgeClass: string;
|
||||
}
|
||||
|
||||
export const APPROVER_STATUS_MAP: Record<string, ApproverStatusConfig> = {
|
||||
ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700" },
|
||||
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800" },
|
||||
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800" },
|
||||
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800" },
|
||||
};
|
||||
|
||||
export function sortByAtrzSeqNo(
|
||||
list: ElaprDocPrgrsClientResponseDto[],
|
||||
): ElaprDocPrgrsClientResponseDto[] {
|
||||
return list.slice().sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
|
||||
}
|
||||
|
||||
export function getApproverStatus(status: string | undefined): ApproverStatusConfig {
|
||||
return (
|
||||
APPROVER_STATUS_MAP[status ?? ""] ?? {
|
||||
label: status || "-",
|
||||
badgeClass: "bg-gray-100 text-gray-700",
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ export const INSTL_SNCN_VIEW_BUTTON_ACTION = {
|
||||
strg: "strg",
|
||||
aply: "aply",
|
||||
del: "del",
|
||||
splmntCmptn: "splmntCmptn",
|
||||
instlSncnAncmnt: "instlSncnAncmnt",
|
||||
envoSbmsn: "envoSbmsn",
|
||||
rjct: "rjct",
|
||||
@@ -15,7 +14,6 @@ export const INSTL_SNCN_VIEW_BUTTON_ACTION = {
|
||||
dmndSplmnt: "dmndSplmnt",
|
||||
atrzAprv: "atrzAprv",
|
||||
atrzRply: "atrzRply",
|
||||
grndsChck: "grndsChck",
|
||||
} as const;
|
||||
|
||||
export type InstlSncnViewButtonAction =
|
||||
@@ -33,7 +31,6 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, InstlSncnViewBut
|
||||
INSTL_SNCN_VIEW_BUTTON_ACTION.aply,
|
||||
INSTL_SNCN_VIEW_BUTTON_ACTION.del,
|
||||
],
|
||||
SL: [INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn],
|
||||
TA: [INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt],
|
||||
},
|
||||
ROLE_USER04: {
|
||||
@@ -44,10 +41,6 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, InstlSncnViewBut
|
||||
LE: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply, INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw],
|
||||
TP: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply],
|
||||
},
|
||||
ROLE_USER01: {
|
||||
TE: [INSTL_SNCN_VIEW_BUTTON_ACTION.grndsChck],
|
||||
SL: [INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn],
|
||||
},
|
||||
};
|
||||
|
||||
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
|
||||
|
||||
@@ -24,7 +24,7 @@ function mapEsntlAtchFileList(esntlFileList: InstlSncnMngDetResponse["esntlFileL
|
||||
}
|
||||
|
||||
export function mapDetailToFormData(data: InstlSncnMngDetResponse): InstlSncnRegistFormData {
|
||||
const { detDto, bizSclList, bizCstList, cklstResultList, esntlFileList } = data;
|
||||
const { detDto, bizSclList, bizCstList, cklstResultList, esntlFileList, splmntDmndList } = data;
|
||||
|
||||
return {
|
||||
instlSncnMng: {
|
||||
@@ -85,6 +85,23 @@ export function mapDetailToFormData(data: InstlSncnMngDetResponse): InstlSncnReg
|
||||
picRvwCn: item.picRvwCn,
|
||||
})) ?? [],
|
||||
esntlAtchFileList: mapEsntlAtchFileList(esntlFileList),
|
||||
splmntDmndList:
|
||||
splmntDmndList?.map((item) => ({
|
||||
splmntDmndSn: item.splmntDmndSn,
|
||||
splmntArtclComCd: item.splmntArtclComCd,
|
||||
splmntArtclCdNm: item.splmntArtclCdNm,
|
||||
splmntDmndCn: item.splmntDmndCn,
|
||||
splmntDmndDt: item.splmntDmndDt,
|
||||
splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId ?? "",
|
||||
splmntDmndAtchFileNm: item.splmntDmndAtchFileInfo?.orgnlFileNm ?? "",
|
||||
splmntCn: item.splmntCn ?? "",
|
||||
splmntPicNm: item.splmntPicNm ?? "",
|
||||
splmntCmptnDt: item.splmntCmptnDt ?? "",
|
||||
splmntAtchFileGroupId: item.splmntAtchFileGroupId ?? "",
|
||||
splmntAtchFileNm: item.splmntAtchFileInfo?.orgnlFileNm ?? "",
|
||||
splmntAtchFileAtflId: item.splmntAtchFileInfo?.atflId ?? "",
|
||||
cmptnYn: item.cmptnYn,
|
||||
})) ?? [],
|
||||
privacyConsent: "Y",
|
||||
};
|
||||
}
|
||||
@@ -116,6 +133,7 @@ export function getCreateDefaultValues(
|
||||
bizCstList: [],
|
||||
cklstResultList: [],
|
||||
esntlAtchFileList: [],
|
||||
splmntDmndList: [],
|
||||
privacyConsent: "N",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ export const instlSncnRegistSchema = z.object({
|
||||
taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"),
|
||||
sncnSeComCd: z.string().min(1, "사업구분은 필수입니다"),
|
||||
dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
|
||||
dsgnrTelno: z.string().min(1, "설계자 전화번호는 필수입니다"),
|
||||
dsgnrTelno: z
|
||||
.string()
|
||||
.min(1, "설계자 전화번호는 필수입니다")
|
||||
.regex(/^[0-9-]+$/, "숫자와 하이픈(-)만 입력 가능합니다"),
|
||||
dsgnrOgdpNm: z.string().min(1, "설계자 소속은 필수입니다"),
|
||||
dsgnrEmlAddr: z
|
||||
.string()
|
||||
@@ -21,23 +24,23 @@ export const instlSncnRegistSchema = z.object({
|
||||
aplcntMblTelno: z.string().optional(),
|
||||
aplcntOgdpNm: z.string().optional(),
|
||||
aplcntEmlAddr: z.string().optional(),
|
||||
instlSncnDocNo: z.string().optional(),
|
||||
instlSncnDocNo: z.string().nullish(),
|
||||
admdstCd: z.string().optional(),
|
||||
envoSeComCd: z.string().optional(),
|
||||
prgrsSttsComCd: z.string().optional(),
|
||||
prgrsSttsComCd: z.string().nullish(),
|
||||
atchFileGroupId: z.string().optional(),
|
||||
rtlztnFcltSeComCd: z.string().optional(),
|
||||
rtlztnUsgSeComCd: z.string().optional(),
|
||||
envoSeCdNm: z.string().optional(),
|
||||
sncnSeCdNm: z.string().optional(),
|
||||
taskSeCdNm: z.string().optional(),
|
||||
prgrsSttsCdNm: z.string().optional(),
|
||||
sido: z.string().optional(),
|
||||
gugun: z.string().optional(),
|
||||
regDt: z.string().optional(),
|
||||
rgtr: z.string().optional(),
|
||||
rgtrNm: z.string().optional(),
|
||||
mdfcnDt: z.string().optional(),
|
||||
envoSeCdNm: z.string().nullish(),
|
||||
sncnSeCdNm: z.string().nullish(),
|
||||
taskSeCdNm: z.string().nullish(),
|
||||
prgrsSttsCdNm: z.string().nullish(),
|
||||
sido: z.string().nullish(),
|
||||
gugun: z.string().nullish(),
|
||||
regDt: z.string().nullish(),
|
||||
rgtr: z.string().nullish(),
|
||||
rgtrNm: z.string().nullish(),
|
||||
mdfcnDt: z.string().nullish(),
|
||||
}),
|
||||
bzScaleList: z.array(
|
||||
z.object({
|
||||
@@ -63,7 +66,7 @@ export const instlSncnRegistSchema = z.object({
|
||||
cklstRvwSn: z.number(),
|
||||
rvwStpComCd: z.string(),
|
||||
selected: z.enum(["Y", "N"]).nullable(),
|
||||
picRvwCn: z.string().nullable().optional(),
|
||||
picRvwCn: z.string().nullish(),
|
||||
}),
|
||||
),
|
||||
esntlAtchFileList: z.array(
|
||||
@@ -72,6 +75,24 @@ export const instlSncnRegistSchema = z.object({
|
||||
atchFileGroupId: z.string(),
|
||||
}),
|
||||
),
|
||||
splmntDmndList: z.array(
|
||||
z.object({
|
||||
splmntDmndSn: z.number().optional(),
|
||||
splmntArtclComCd: z.string().optional(),
|
||||
splmntArtclCdNm: z.string().nullish(),
|
||||
splmntDmndCn: z.string().optional(),
|
||||
splmntDmndDt: z.string().optional(),
|
||||
splmntDmndAtchFileGroupId: z.string().optional(),
|
||||
splmntDmndAtchFileNm: z.string().optional(),
|
||||
splmntCn: z.string().optional(),
|
||||
splmntPicNm: z.string().optional(),
|
||||
splmntCmptnDt: z.string().optional(),
|
||||
splmntAtchFileGroupId: z.string().optional(),
|
||||
splmntAtchFileNm: z.string().optional(),
|
||||
splmntAtchFileAtflId: z.string().optional(),
|
||||
cmptnYn: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
privacyConsent: z.enum(["Y", "N"], {
|
||||
required_error: "개인정보 수집/이용 동의는 필수입니다",
|
||||
}),
|
||||
|
||||
@@ -1,104 +1,131 @@
|
||||
import type {
|
||||
BizCstTreeDto,
|
||||
BizSclTreeDto,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
InstlSncnApplySaveRequestDto,
|
||||
InstlSncnBizCstSaveRequestDto,
|
||||
InstlSncnBzScaleSaveDto,
|
||||
InstlSncnCklstResultSaveDto,
|
||||
InstlSncnEsntlAtchFileSaveDto,
|
||||
InstlSncnMngDetResponse,
|
||||
InstlSncnMngResponseDto,
|
||||
ReviewStepCode,
|
||||
} from "@/entities/instl-sncn";
|
||||
|
||||
import type { ChecklistFormState } from "../model/view-types";
|
||||
|
||||
import { toCklstSaveDto } from "./checklist-utils";
|
||||
import { type ReviewOpinionFormData, toRvwOpnnSaveDto } from "./review-opinion-utils";
|
||||
|
||||
export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): InstlSncnBzScaleSaveDto[] {
|
||||
return bizSclList
|
||||
.filter((node) => node.useYn === "Y")
|
||||
.map((node) => ({
|
||||
cnstpSeCd: node.cnstpSeCd,
|
||||
bscPl: node.bscPlanQty,
|
||||
budgetReflct: node.bgtRfltQty,
|
||||
thtiDsgn: node.tstmDesignQty,
|
||||
diff: node.tstmDesignQty - node.bgtRfltQty,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): InstlSncnBizCstSaveRequestDto[] {
|
||||
return bizCstList
|
||||
.filter((node) => node.useYn === "Y")
|
||||
.map((node) => ({
|
||||
acntgSeCd: node.acntgSeCd,
|
||||
bscPlanAmt: node.bscPlanAmt,
|
||||
bgtRfltAmt: node.bgtRfltAmt,
|
||||
tstmDesignAmt: node.tstmDesignAmt,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toEsntlAtchFileSaveDto(
|
||||
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
|
||||
): InstlSncnEsntlAtchFileSaveDto[] {
|
||||
const esntlMap = new Map<string, string>();
|
||||
|
||||
for (const item of esntlFileList) {
|
||||
if (!item.esntlAtchFileId || !item.atflCd) continue;
|
||||
esntlMap.set(item.esntlAtchFileId, item.atflCd);
|
||||
}
|
||||
|
||||
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
|
||||
esntlAtchFileId,
|
||||
atchFileGroupId,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSavePayload(
|
||||
data: InstlSncnMngDetResponse,
|
||||
checklistFormState: ChecklistFormState,
|
||||
reviewStep: ReviewStepCode,
|
||||
prgrsSttsComCd: string,
|
||||
): InstlSncnApplySaveRequestDto {
|
||||
const { detDto, bizSclList, bizCstList, cklstResultList = [], esntlFileList } = data;
|
||||
|
||||
return {
|
||||
instlSncnMng: { ...detDto, prgrsSttsComCd },
|
||||
bzScaleList: toBzScaleSaveDto(bizSclList),
|
||||
bizCstList: toBizCstSaveDto(bizCstList),
|
||||
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
|
||||
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTechReviewReplyPayload(
|
||||
data: InstlSncnMngDetResponse,
|
||||
checklistFormState: ChecklistFormState,
|
||||
reviewStep: ReviewStepCode,
|
||||
prgrsSttsComCd: string,
|
||||
techReViewSaveCd: string,
|
||||
reviewOpinionFormData?: ReviewOpinionFormData | null,
|
||||
opnrNm?: string,
|
||||
): InstlSncnApplySaveRequestDto {
|
||||
return {
|
||||
...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
|
||||
techReViewSaveCd,
|
||||
rvwStpCd: reviewStep,
|
||||
rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTechReviewPayload(
|
||||
det: InstlSncnMngResponseDto,
|
||||
cklstResultList: InstlSncnCklstResultSaveDto[],
|
||||
): InstlSncnApplySaveRequestDto {
|
||||
return {
|
||||
instlSncnMng: { ...det, prgrsSttsComCd: "ER" },
|
||||
bzScaleList: [],
|
||||
bizCstList: [],
|
||||
cklstResultList,
|
||||
esntlAtchFileList: [],
|
||||
};
|
||||
}
|
||||
import type {
|
||||
BizCstTreeDto,
|
||||
BizSclTreeDto,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
InstlSncnApplySaveRequestDto,
|
||||
InstlSncnBizCstSaveRequestDto,
|
||||
InstlSncnBzScaleSaveDto,
|
||||
InstlSncnEsntlAtchFileSaveDto,
|
||||
InstlSncnMngDetResponse,
|
||||
InstlSncnSplmntDmndSaveDto,
|
||||
ReviewStepCode,
|
||||
} from "@/entities/instl-sncn";
|
||||
import type { SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
|
||||
|
||||
import type { ChecklistFormState } from "../model/view-types";
|
||||
|
||||
import { toCklstSaveDto } from "./checklist-utils";
|
||||
import { type ReviewOpinionFormData, toRvwOpnnSaveDto } from "./review-opinion-utils";
|
||||
|
||||
export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): InstlSncnBzScaleSaveDto[] {
|
||||
return bizSclList
|
||||
.filter((node) => node.useYn === "Y")
|
||||
.map((node) => ({
|
||||
cnstpSeCd: node.cnstpSeCd,
|
||||
bscPl: node.bscPlanQty,
|
||||
budgetReflct: node.bgtRfltQty,
|
||||
thtiDsgn: node.tstmDesignQty,
|
||||
diff: node.tstmDesignQty - node.bgtRfltQty,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): InstlSncnBizCstSaveRequestDto[] {
|
||||
return bizCstList
|
||||
.filter((node) => node.useYn === "Y")
|
||||
.map((node) => ({
|
||||
acntgSeCd: node.acntgSeCd,
|
||||
bscPlanAmt: node.bscPlanAmt,
|
||||
bgtRfltAmt: node.bgtRfltAmt,
|
||||
tstmDesignAmt: node.tstmDesignAmt,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toEsntlAtchFileSaveDto(
|
||||
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
|
||||
): InstlSncnEsntlAtchFileSaveDto[] {
|
||||
const esntlMap = new Map<string, string>();
|
||||
|
||||
for (const item of esntlFileList) {
|
||||
if (!item.esntlAtchFileId || !item.atflCd) continue;
|
||||
esntlMap.set(item.esntlAtchFileId, item.atflCd);
|
||||
}
|
||||
|
||||
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
|
||||
esntlAtchFileId,
|
||||
atchFileGroupId,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSavePayload(
|
||||
data: InstlSncnMngDetResponse,
|
||||
checklistFormState: ChecklistFormState,
|
||||
reviewStep: ReviewStepCode,
|
||||
prgrsSttsComCd: string,
|
||||
): InstlSncnApplySaveRequestDto {
|
||||
const { detDto, bizSclList, bizCstList, cklstResultList = [], esntlFileList } = data;
|
||||
|
||||
return {
|
||||
instlSncnMng: { ...detDto, prgrsSttsComCd },
|
||||
bzScaleList: toBzScaleSaveDto(bizSclList),
|
||||
bizCstList: toBizCstSaveDto(bizCstList),
|
||||
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
|
||||
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTechReviewReplyPayload(
|
||||
data: InstlSncnMngDetResponse,
|
||||
checklistFormState: ChecklistFormState,
|
||||
reviewStep: ReviewStepCode,
|
||||
prgrsSttsComCd: string,
|
||||
techReViewSaveCd: string,
|
||||
reviewOpinionFormData?: ReviewOpinionFormData | null,
|
||||
opnrNm?: string,
|
||||
): InstlSncnApplySaveRequestDto {
|
||||
return {
|
||||
...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
|
||||
techReViewSaveCd,
|
||||
rvwStpCd: reviewStep,
|
||||
rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSupplementReqPayload(
|
||||
data: InstlSncnMngDetResponse,
|
||||
payload: SplmntDmndDialogConfirmPayload,
|
||||
checklistFormState: ChecklistFormState,
|
||||
): InstlSncnApplySaveRequestDto {
|
||||
const existingItems: InstlSncnSplmntDmndSaveDto[] = (data.splmntDmndList ?? []).map((item) =>
|
||||
toSplmntDmndSaveItem(item),
|
||||
);
|
||||
|
||||
const nextSn = existingItems.reduce((max, item) => Math.max(max, item.splmntDmndSn ?? 0), 0) + 1;
|
||||
|
||||
const newItems: InstlSncnSplmntDmndSaveDto[] = payload.items.map((item, index) => ({
|
||||
splmntDmndSn: nextSn + index,
|
||||
splmntArtclComCd: item.splmntArtclComCd,
|
||||
splmntDmndCn: item.splmntDmndCn,
|
||||
splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId,
|
||||
cmptnYn: "N",
|
||||
}));
|
||||
|
||||
return {
|
||||
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
|
||||
splmntDmndList: [...existingItems, ...newItems],
|
||||
};
|
||||
}
|
||||
|
||||
export function toSplmntDmndSaveItem(item: InstlSncnSplmntDmndSaveDto): InstlSncnSplmntDmndSaveDto {
|
||||
return {
|
||||
splmntDmndSn: item.splmntDmndSn,
|
||||
splmntArtclComCd: item.splmntArtclComCd,
|
||||
splmntDmndCn: item.splmntDmndCn,
|
||||
splmntDmndDt: item.splmntDmndDt,
|
||||
splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId,
|
||||
splmntCn: item.splmntCn,
|
||||
splmntPicNm: item.splmntPicNm,
|
||||
splmntCmptnDt: item.splmntCmptnDt,
|
||||
splmntAtchFileGroupId: item.splmntAtchFileGroupId,
|
||||
cmptnYn: item.cmptnYn,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface ReviewerOption {
|
||||
uuid: string;
|
||||
name: string;
|
||||
deptNm: string;
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { RvwMnpwMngResponseDto, useRvwMnpwMngPage } from "@/entities/rvw-mnpw";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { type ReviewerOption } from "../../model/reviewer-option";
|
||||
|
||||
const PAGE_SIZE = 500;
|
||||
|
||||
interface AprvrPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
atrzSeComCd: string;
|
||||
onSelect: (reviewer: ReviewerOption) => void;
|
||||
}
|
||||
|
||||
function AprvrPickerDialog({ open, onOpenChange, atrzSeComCd, onSelect }: AprvrPickerDialogProps) {
|
||||
const { data, isPending, isError } = useRvwMnpwMngPage(
|
||||
{
|
||||
atrzSeComCd,
|
||||
page: 0,
|
||||
size: PAGE_SIZE,
|
||||
sort: "userUuidNm,asc",
|
||||
},
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
const items = data?.content ?? [];
|
||||
|
||||
function fromRvwMnpwMng(item: RvwMnpwMngResponseDto): ReviewerOption {
|
||||
return {
|
||||
uuid: item.userUuid,
|
||||
name: item.userUuidNm ?? "",
|
||||
deptNm: item.ognzNm ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function handleSelect(item: (typeof items)[number]) {
|
||||
onSelect(fromRvwMnpwMng(item));
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[56rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>검토인력 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="h-[36rem] overflow-auto">
|
||||
{isPending ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
검토인력 목록을 불러오지 못했습니다.
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
배치된 검토인력이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="25%" />
|
||||
<col width="25%" />
|
||||
<col width="15%" />
|
||||
<col width="15%" />
|
||||
<col width="20%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>성명</TableHead>
|
||||
<TableHead>부서</TableHead>
|
||||
<TableHead>직위</TableHead>
|
||||
<TableHead>직급</TableHead>
|
||||
<TableHead>선택</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={`${item.atrzSeComCd}-${item.ognzSn}-${item.userUuid}`}>
|
||||
<TableCell align="center">{item.userUuidNm || item.userUuid}</TableCell>
|
||||
<TableCell align="center">{item.ognzNm || "-"}</TableCell>
|
||||
<TableCell align="center">{item.jbpsSeNm || "-"}</TableCell>
|
||||
<TableCell align="center">{item.jbgdSeNm || "-"}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Button type="button" size="sm" onClick={() => handleSelect(item)}>
|
||||
선택
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { AprvrPickerDialog };
|
||||
@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import type { CklstRvwRsltTreeDto, ReviewStepCode } from "@/entities/instl-sncn";
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -35,6 +36,24 @@ function ChecklistTaskPanel({
|
||||
const currentUserReviewStep = userRoleCode ? ROLE_TO_REVIEW_STEP[userRoleCode] : undefined;
|
||||
const isStepEditable = currentUserReviewStep === selectedReviewStep;
|
||||
|
||||
function handleSelectAll(value: "Y" | "N") {
|
||||
if (!isStepEditable) return;
|
||||
setFormState((prev) => {
|
||||
const nextResults = { ...prev.results };
|
||||
selectedRows
|
||||
.filter((row) => row.bchckUseYn === "Y")
|
||||
.forEach((row) => {
|
||||
nextResults[buildChecklistRowKey(row, selectedReviewStep)] = value;
|
||||
});
|
||||
return patchChecklistFormState(
|
||||
prev,
|
||||
{ results: nextResults },
|
||||
allRows,
|
||||
selectedReviewStep,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
@@ -53,8 +72,27 @@ function ChecklistTaskPanel({
|
||||
) : null}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="w-[9rem]">예</TableHead>
|
||||
<TableHead className="w-[9rem]">아니오</TableHead>
|
||||
<TableHead className="w-[12rem]">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!isStepEditable}
|
||||
onClick={() => handleSelectAll("Y")}
|
||||
>
|
||||
전체 예
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[12rem]">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!isStepEditable}
|
||||
onClick={() => handleSelectAll("N")}
|
||||
className="!bg-destructive"
|
||||
>
|
||||
전체 아니오
|
||||
</Button>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -130,7 +168,7 @@ function ChecklistTaskPanel({
|
||||
<TableCell className="text-left">
|
||||
<Textarea
|
||||
className={cn(
|
||||
"min-h-[6rem] !text-xl",
|
||||
"min-h-[6rem] w-[32rem] !text-xl",
|
||||
isStepEditable ? "cursor-pointer" : "cursor-not-allowed",
|
||||
)}
|
||||
value={formState.comments[key] ?? ""}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
type ElaprAplnRow,
|
||||
type ElaprTmplt,
|
||||
getVisibleRows,
|
||||
useElaprTmpltPage,
|
||||
validateAllRowsForSave,
|
||||
validateAtrzOrder,
|
||||
validateUniqueSeqNo,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@common/components";
|
||||
|
||||
import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
|
||||
import { ElaprTmpltSelectTable } from "./elapr-tmplt-select-table";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface ElaprTmpltPickerConfirmPayload {
|
||||
tmplt: ElaprTmplt;
|
||||
aplnRows: ElaprAplnRow[];
|
||||
}
|
||||
|
||||
interface ElaprTmpltPickerDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect?: (tmplt: ElaprTmplt) => void;
|
||||
onConfirm?: (payload: ElaprTmpltPickerConfirmPayload) => void;
|
||||
}
|
||||
|
||||
function ElaprTmpltPickerDialog({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
onConfirm,
|
||||
}: ElaprTmpltPickerDialogProps) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [selectedTmplt, setSelectedTmplt] = useState<ElaprTmplt | null>(null);
|
||||
const [aplnRows, setAplnRows] = useState<ElaprAplnRow[]>([]);
|
||||
const [isEditingLocked, setIsEditingLocked] = useState(false);
|
||||
|
||||
const tmpltQuery = useElaprTmpltPage({
|
||||
page: 0,
|
||||
size: PAGE_SIZE,
|
||||
sort: "regDt,desc",
|
||||
docSeComCd: "INSTL",
|
||||
});
|
||||
|
||||
const handleRowsChange = useCallback((rows: ElaprAplnRow[]) => {
|
||||
setAplnRows(rows);
|
||||
}, []);
|
||||
|
||||
const handleEditingLockedChange = useCallback((locked: boolean) => {
|
||||
setIsEditingLocked(locked);
|
||||
}, []);
|
||||
|
||||
function handleSelectTmplt(tmplt: ElaprTmplt | null) {
|
||||
setSelectedTmplt(tmplt);
|
||||
setAplnRows([]);
|
||||
setIsEditingLocked(false);
|
||||
}
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
setSelectedTmplt(null);
|
||||
setAplnRows([]);
|
||||
setIsEditingLocked(false);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!selectedTmplt) {
|
||||
alert("결재서식을 선택해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rowsError = validateAllRowsForSave(aplnRows, isEditingLocked);
|
||||
if (rowsError) {
|
||||
alert(rowsError);
|
||||
return;
|
||||
}
|
||||
|
||||
const orderError = validateAtrzOrder(aplnRows);
|
||||
if (orderError) {
|
||||
alert(orderError);
|
||||
return;
|
||||
}
|
||||
|
||||
const seqError = validateUniqueSeqNo(aplnRows);
|
||||
if (seqError) {
|
||||
alert(seqError);
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleRows = getVisibleRows(aplnRows);
|
||||
|
||||
const payload: ElaprTmpltPickerConfirmPayload = {
|
||||
tmplt: selectedTmplt,
|
||||
aplnRows: visibleRows,
|
||||
};
|
||||
|
||||
if (onConfirm) {
|
||||
onConfirm(payload);
|
||||
} else {
|
||||
onSelect?.(selectedTmplt);
|
||||
}
|
||||
|
||||
setSelectedTmplt(null);
|
||||
setAplnRows([]);
|
||||
setIsEditingLocked(false);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-[120rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>결재선 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<ElaprTmpltSelectTable
|
||||
query={tmpltQuery}
|
||||
selectedTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
||||
onSelectTmplt={handleSelectTmplt}
|
||||
/>
|
||||
<ElaprAplnPickerGrid
|
||||
elaprTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
||||
onRowsChange={handleRowsChange}
|
||||
onEditingLockedChange={handleEditingLockedChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => handleOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!selectedTmplt || isEditingLocked}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprTmpltPickerDialog };
|
||||
export type { ElaprTmpltPickerConfirmPayload };
|
||||
@@ -1,104 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import { createColumnHelper, getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
import type { ElaprTmplt } from "@/entities/elapr";
|
||||
import { Skeleton } from "@common/components";
|
||||
|
||||
const columnHelper = createColumnHelper<ElaprTmplt>();
|
||||
|
||||
interface ElaprTmpltSelectTableProps {
|
||||
query: UseQueryResult<PageResponse<ElaprTmplt>>;
|
||||
selectedTmpltId: string | null;
|
||||
onSelectTmplt: (tmplt: ElaprTmplt | null) => void;
|
||||
}
|
||||
|
||||
function ElaprTmpltSelectTable({
|
||||
query,
|
||||
selectedTmpltId,
|
||||
onSelectTmplt,
|
||||
}: ElaprTmpltSelectTableProps) {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTmpltId) {
|
||||
setRowSelection({ [selectedTmpltId]: true });
|
||||
} else {
|
||||
setRowSelection({});
|
||||
}
|
||||
}, [selectedTmpltId]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("elaprTmpltId", {
|
||||
header: "전자결재서식아이디",
|
||||
meta: { align: "left" },
|
||||
}),
|
||||
columnHelper.accessor("elaprTmpltNm", {
|
||||
header: "결재서식명",
|
||||
meta: { align: "left" },
|
||||
}),
|
||||
columnHelper.accessor("docSeComNm", {
|
||||
header: "문서구분",
|
||||
cell: ({ getValue, row }) => getValue() ?? row.original.docSeComCd ?? "—",
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query,
|
||||
tableOptions: {
|
||||
columns,
|
||||
state: { rowSelection },
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: false,
|
||||
getRowId: (row) => row.elaprTmpltId,
|
||||
onRowSelectionChange: (updater) => {
|
||||
const next = typeof updater === "function" ? updater(rowSelection) : updater;
|
||||
setRowSelection(next);
|
||||
const selectedId = Object.keys(next)[0];
|
||||
if (!selectedId) {
|
||||
onSelectTmplt(null);
|
||||
return;
|
||||
}
|
||||
const selected = query.data?.content.find((t) => t.elaprTmpltId === selectedId);
|
||||
onSelectTmplt(selected ?? null);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
},
|
||||
dataTableOptions: {
|
||||
onRowClick: (row) => {
|
||||
setRowSelection({ [row.elaprTmpltId]: true });
|
||||
onSelectTmplt(row);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="card card-border mx-0 min-h-[32rem] flex-[0.5] bg-background pt-0">
|
||||
<div className="card-header h-[4.6rem]">
|
||||
<h3 className="text-2xl font-semibold">전자결재서식</h3>
|
||||
</div>
|
||||
{query.isPending && (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
)}
|
||||
{query.isError && (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
결재서식 목록을 불러오지 못했습니다.
|
||||
</p>
|
||||
)}
|
||||
{!query.isPending && !query.isError && tableComponent}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprTmpltSelectTable };
|
||||
@@ -11,8 +11,11 @@ import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import {
|
||||
buildElaprPrgrsSttsListFromAplnRows,
|
||||
ElaprApprovalDialog,
|
||||
type ElaprDmndSaveRequestDto,
|
||||
elaprDmndService,
|
||||
type ElaprTmpltPickerConfirmPayload,
|
||||
ElaprTmpltPickerDialog,
|
||||
hasCurrentUserPendingApprovalRequest,
|
||||
useElaprDocPrgrsByDocNo,
|
||||
} from "@/entities/elapr";
|
||||
@@ -24,7 +27,7 @@ import {
|
||||
instlSncnService,
|
||||
} from "@/entities/instl-sncn";
|
||||
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
|
||||
import { TextInputDialog } from "@/widgets/pbadms-sprt";
|
||||
import { SplmntDmndDialog, type SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import {
|
||||
@@ -34,16 +37,15 @@ import {
|
||||
needsRoleUser01AtrzResolve,
|
||||
} from "../../lib/button-config";
|
||||
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
|
||||
import { buildSavePayload, buildTechReviewReplyPayload } from "../../lib/save-payload-utils";
|
||||
import {
|
||||
buildSavePayload,
|
||||
buildSupplementReqPayload,
|
||||
buildTechReviewReplyPayload,
|
||||
toSplmntDmndSaveItem,
|
||||
} from "../../lib/save-payload-utils";
|
||||
import { type ChecklistFormState } from "../../model/view-types";
|
||||
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
|
||||
|
||||
import { ElaprApprovalDialog } from "./elapr-approval-dialog";
|
||||
import {
|
||||
type ElaprTmpltPickerConfirmPayload,
|
||||
ElaprTmpltPickerDialog,
|
||||
} from "./elapr-tmplt-picker-dialog";
|
||||
|
||||
interface FloatingActionButtonsProps {
|
||||
data: InstlSncnMngDetResponse;
|
||||
docSeComCd: string;
|
||||
@@ -382,15 +384,18 @@ function FloatingActionButtons({
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildTechReviewReplyPayload(
|
||||
data,
|
||||
checklistFormState,
|
||||
"KECO",
|
||||
"TE",
|
||||
"SV",
|
||||
getReviewOpinionFormData(),
|
||||
opnrNm,
|
||||
);
|
||||
const payload = {
|
||||
...buildTechReviewReplyPayload(
|
||||
data,
|
||||
checklistFormState,
|
||||
"KECO",
|
||||
"TE",
|
||||
"SV",
|
||||
getReviewOpinionFormData(),
|
||||
opnrNm,
|
||||
),
|
||||
splmntDmndList: (data.splmntDmndList ?? []).map(toSplmntDmndSaveItem),
|
||||
};
|
||||
|
||||
await executeAction({
|
||||
action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwCmptn,
|
||||
@@ -404,11 +409,12 @@ function FloatingActionButtons({
|
||||
setActiveDialog("dmndSplmnt");
|
||||
}
|
||||
|
||||
async function handleDmndSplmntConfirm(splmntCn: string) {
|
||||
async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
|
||||
setActiveDialog(null);
|
||||
const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState);
|
||||
await executeAction({
|
||||
action: INSTL_SNCN_VIEW_BUTTON_ACTION.dmndSplmnt,
|
||||
request: splmntCn,
|
||||
request: requestPayload,
|
||||
successMessage: "보완요청이 처리되었습니다.",
|
||||
});
|
||||
}
|
||||
@@ -440,16 +446,6 @@ function FloatingActionButtons({
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn) && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn })
|
||||
}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
보완완료
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt) && (
|
||||
<Button onClick={handleInstlSncnAncmntClick} disabled={isProcessing}>
|
||||
설치인가 고시
|
||||
@@ -516,16 +512,15 @@ function FloatingActionButtons({
|
||||
<ElaprTmpltPickerDialog
|
||||
open={activeDialog === "elaprTmplt"}
|
||||
onClose={() => setActiveDialog(null)}
|
||||
docSeComCd={docSeComCd}
|
||||
onConfirm={handleElaprTmpltConfirm}
|
||||
/>
|
||||
|
||||
<TextInputDialog
|
||||
<SplmntDmndDialog
|
||||
open={activeDialog === "dmndSplmnt"}
|
||||
onOpenChange={() => setActiveDialog(null)}
|
||||
title="보완요청 내용 입력"
|
||||
placeholder="보완요청 내용을 입력하세요."
|
||||
isSubmitting={isProcessing}
|
||||
onConfirm={(value) => void handleDmndSplmntConfirm(value)}
|
||||
onConfirm={handleDmndSplmntConfirm}
|
||||
/>
|
||||
|
||||
<ElaprApprovalDialog
|
||||
@@ -565,11 +560,6 @@ async function handleDel(context: ActionHandlerContext): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSplmntCmptn(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 보완완료 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleChangePrgrsSttsComCd(
|
||||
context: ActionHandlerContext<DocPrgrsUpdateRequestDto>,
|
||||
): Promise<void> {
|
||||
@@ -603,9 +593,17 @@ async function handleDsgntRvwr(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDmndSplmnt(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 보완요청 API 호출
|
||||
context.onSuccess?.();
|
||||
async function handleDmndSplmnt(
|
||||
context: ActionHandlerContext<InstlSncnApplySaveRequestDto>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const docNo = context.data.detDto.instlSncnDocNo;
|
||||
if (!docNo) throw new Error("문서번호가 없습니다.");
|
||||
await instlSncnService.submitReqSupplement(docNo, context.request);
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
context.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAtrzAprv(context: ActionHandlerContext): Promise<void> {
|
||||
@@ -618,16 +616,10 @@ async function handleAtrzRply(context: ActionHandlerContext): Promise<void> {
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleGrndsChck(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 현장점검 등록 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
const buttonActionHandlers: Record<InstlSncnViewButtonAction, ActionHandler> = {
|
||||
strg: handleSaveApply,
|
||||
aply: handleSaveApply,
|
||||
del: handleDel,
|
||||
splmntCmptn: handleSplmntCmptn,
|
||||
instlSncnAncmnt: handleChangePrgrsSttsComCd,
|
||||
envoSbmsn: handleChangePrgrsSttsComCd,
|
||||
rjct: handleChangePrgrsSttsComCd,
|
||||
@@ -640,7 +632,6 @@ const buttonActionHandlers: Record<InstlSncnViewButtonAction, ActionHandler> = {
|
||||
dmndSplmnt: handleDmndSplmnt,
|
||||
atrzAprv: handleAtrzAprv,
|
||||
atrzRply: handleAtrzRply,
|
||||
grndsChck: handleGrndsChck,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { MoveVertical } from "lucide-react";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import { type ElaprPrgrsSttsRow, isPrgrsSttsRowEditable } from "@/entities/elapr";
|
||||
import { Button, cn, TableCell, TableRow } from "@common/components";
|
||||
|
||||
import { getApproverStatus } from "../../lib/approval-utils";
|
||||
|
||||
interface SortablePrgrsSttsRowProps {
|
||||
row: ElaprPrgrsSttsRow;
|
||||
displaySeqNo: number;
|
||||
isEditing: boolean;
|
||||
isLocked: boolean;
|
||||
dragDisabled: boolean;
|
||||
onStartEdit: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
onChange: (patch: Partial<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 };
|
||||
@@ -10,6 +10,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import {
|
||||
type InstlSncnApplySaveRequestDto,
|
||||
instlSncnKeys,
|
||||
type InstlSncnMngDetResponse,
|
||||
instlSncnService,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
ContactInfoFormSection,
|
||||
isFormChecklistComplete,
|
||||
PrivacyConsentSection,
|
||||
SplmntDmndFormSection,
|
||||
} from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
@@ -48,6 +50,7 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const isEditMode = mode === "edit";
|
||||
const isSplmntPrgrsStts = initialData?.detDto.prgrsSttsComCd === "SL";
|
||||
|
||||
const form = useForm<InstlSncnRegistFormData>({
|
||||
resolver: zodResolver(instlSncnRegistSchema),
|
||||
@@ -58,13 +61,13 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
: getCreateDefaultValues(userInfo, taskSeComCd),
|
||||
});
|
||||
|
||||
async function submitForm(
|
||||
function buildFormPayload(
|
||||
data: InstlSncnRegistFormData,
|
||||
prgrsSttsComCd: "SV" | "ST",
|
||||
): Promise<string | undefined> {
|
||||
prgrsSttsComCd: "SV" | "ST" | "TE",
|
||||
): InstlSncnApplySaveRequestDto {
|
||||
const { taskSeComCd: formTaskSeComCd, ...instlSncnMngRest } = data.instlSncnMng;
|
||||
|
||||
const payload = {
|
||||
return {
|
||||
instlSncnMng: {
|
||||
...instlSncnMngRest,
|
||||
instlSncnDocNo: isEditMode ? initialData?.detDto.instlSncnDocNo : undefined,
|
||||
@@ -78,9 +81,61 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
selected: item.selected,
|
||||
})),
|
||||
esntlAtchFileList: data.esntlAtchFileList,
|
||||
splmntDmndList: data.splmntDmndList.map(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ splmntAtchFileNm, splmntAtchFileAtflId, splmntDmndAtchFileNm, ...item }) => ({
|
||||
...item,
|
||||
...(prgrsSttsComCd === "TE" ? { cmptnYn: "Y" } : null),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return instlSncnService.saveApply(payload);
|
||||
async function submitForm(
|
||||
data: InstlSncnRegistFormData,
|
||||
prgrsSttsComCd: "SV" | "ST",
|
||||
): Promise<string | undefined> {
|
||||
return instlSncnService.saveApply(buildFormPayload(data, prgrsSttsComCd));
|
||||
}
|
||||
|
||||
async function submitSupplementForm(data: InstlSncnRegistFormData): Promise<string | undefined> {
|
||||
const docNo = initialData?.detDto.instlSncnDocNo;
|
||||
if (!docNo) return undefined;
|
||||
return instlSncnService.submitSupplement(docNo, buildFormPayload(data, "TE"));
|
||||
}
|
||||
|
||||
async function onSplmntCmptn(data: InstlSncnRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
const hasEmptySplmntCn = data.splmntDmndList?.some(
|
||||
(item) => !item.splmntCn || !item.splmntCn.trim(),
|
||||
);
|
||||
if (hasEmptySplmntCn) {
|
||||
alert("보완요청내용이 있습니다. 보완완료내용을 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const docNo = initialData?.detDto.instlSncnDocNo;
|
||||
if (!docNo) {
|
||||
alert("문서번호가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const result = await submitSupplementForm(data);
|
||||
if (!result) return;
|
||||
|
||||
alert("보완완료 처리되었습니다.");
|
||||
router.push(`/instl-sncn/${docNo}`);
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.list() });
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(docNo) });
|
||||
} catch (error) {
|
||||
console.error("보완완료 실패:", error);
|
||||
alert("보완완료 처리 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave(data: InstlSncnRegistFormData) {
|
||||
@@ -131,10 +186,6 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
}
|
||||
}
|
||||
|
||||
function handleApplyClick() {
|
||||
form.handleSubmit(onApply)();
|
||||
}
|
||||
|
||||
async function handleDelClick() {
|
||||
if (isSubmitting) return;
|
||||
|
||||
@@ -150,6 +201,7 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
setIsSubmitting(true);
|
||||
await instlSncnService.remove(docNo);
|
||||
alert("삭제되었습니다.");
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.list() });
|
||||
router.push("/instl-sncn");
|
||||
} catch (error) {
|
||||
console.error("삭제 실패:", error);
|
||||
@@ -174,6 +226,7 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined}
|
||||
initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined}
|
||||
/>
|
||||
<SplmntDmndFormSection readOnly={!isSplmntPrgrsStts} />
|
||||
|
||||
<div className="sticky bottom-0 z-40 !-mb-2 border-t bg-white pb-8 pt-6 shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
|
||||
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
|
||||
@@ -185,23 +238,40 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
>
|
||||
목록
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
저장
|
||||
</Button>
|
||||
{isEditMode && (
|
||||
{!isSplmntPrgrsStts ? (
|
||||
<>
|
||||
<Button
|
||||
color="green"
|
||||
type="button"
|
||||
onClick={handleApplyClick}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
신청완료
|
||||
</Button>
|
||||
<Button color="red" type="button" onClick={handleDelClick} disabled={isSubmitting}>
|
||||
삭제
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
저장
|
||||
</Button>
|
||||
{isEditMode && (
|
||||
<>
|
||||
<Button
|
||||
color="green"
|
||||
type="button"
|
||||
onClick={form.handleSubmit(onApply)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
신청완료
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
type="button"
|
||||
onClick={handleDelClick}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(onSplmntCmptn)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
보완완료
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
|
||||
import { useInstlSncnDetail } from "@/entities/instl-sncn";
|
||||
import { SectionCollapsible, SupplementDemandSection } from "@/widgets/pbadms-sprt";
|
||||
import { Skeleton } from "@common/components";
|
||||
|
||||
import { getEditableReviewStep } from "../lib/review-step-utils";
|
||||
@@ -63,6 +65,7 @@ function InstlViewPage({ id }: InstlViewPageProps) {
|
||||
esntlFileList,
|
||||
etcFileList,
|
||||
rvwList = [],
|
||||
splmntDmndList = [],
|
||||
} = data;
|
||||
|
||||
const editableReviewStep = getEditableReviewStep(
|
||||
@@ -74,6 +77,9 @@ function InstlViewPage({ id }: InstlViewPageProps) {
|
||||
<div className="mt-6 space-y-4">
|
||||
<BasicInfoSection det={detDto} />
|
||||
<ContactInfoSection det={detDto} />
|
||||
<SectionCollapsible title="결재현황">
|
||||
<ElaprDocPrgrsStatus elaprDocNo={detDto.instlSncnDocNo} />
|
||||
</SectionCollapsible>
|
||||
<BusinessScaleSection bizSclList={bizSclList} />
|
||||
<BusinessCostSection bizCstList={bizCstList} />
|
||||
<ChecklistSection
|
||||
@@ -89,6 +95,7 @@ function InstlViewPage({ id }: InstlViewPageProps) {
|
||||
rvwList={rvwList}
|
||||
editableReviewStep={editableReviewStep}
|
||||
/>
|
||||
<SupplementDemandSection splmntDmndList={splmntDmndList} />
|
||||
<FloatingActionButtons
|
||||
data={data}
|
||||
docSeComCd="INSTL"
|
||||
|
||||
@@ -167,8 +167,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>
|
||||
@@ -267,14 +267,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}
|
||||
</>
|
||||
|
||||
@@ -143,7 +143,8 @@ export interface ElaprDocPrgrsClientResponseDto {
|
||||
atrzSeComCd?: string;
|
||||
atrzSeComNm?: string;
|
||||
aprvrUuid?: string;
|
||||
aprvrUuidNm?: string;
|
||||
aprvrNm?: string;
|
||||
aprvrUserId?: string;
|
||||
atrzPrgrsSttsComCd?: string;
|
||||
atrzPrgrsSttsComNm?: string;
|
||||
rjctRsn?: string | null;
|
||||
@@ -151,6 +152,7 @@ export interface ElaprDocPrgrsClientResponseDto {
|
||||
jbpsSeNm?: string;
|
||||
jbgdSeNm?: string;
|
||||
ognzNm?: string;
|
||||
dcarbYn?: string;
|
||||
}
|
||||
|
||||
/** 사업규모 저장 공통 필드 */
|
||||
|
||||
@@ -9,7 +9,6 @@ export { buildCnstpScaleItems, recalculateBzScaleTotals } from "./lib/business-s
|
||||
export {
|
||||
buildChecklistTabs,
|
||||
isFormChecklistComplete,
|
||||
orderCklstTreeNodes,
|
||||
syncCklstResultListFromTree,
|
||||
} from "./lib/checklist-form-utils";
|
||||
export type { FnrsSharItemDef, FnrsSharListItem } from "./lib/fnrs-shar-form-utils";
|
||||
@@ -31,5 +30,11 @@ export { ContactInfoFormSection } from "./ui/contact-info-form-section";
|
||||
export { FnrsSharFormSection } from "./ui/fnrs-shar-form-section";
|
||||
export { PrivacyConsentSection } from "./ui/privacy-consent-section";
|
||||
export { SectionCollapsible } from "./ui/section-collapsible";
|
||||
export {
|
||||
SplmntDmndDialog,
|
||||
type SplmntDmndDialogConfirmItem,
|
||||
type SplmntDmndDialogConfirmPayload,
|
||||
} from "./ui/splmnt-dmnd-dialog";
|
||||
export { type SplmntDmndFormItem, SplmntDmndFormSection } from "./ui/splmnt-dmnd-form-section";
|
||||
export { SupplementDemandSection } from "./ui/supplement-demand-section";
|
||||
export { TaskSePickerDialog } from "./ui/task-se-picker-dialog";
|
||||
export { TextInputDialog } from "./ui/text-input-dialog";
|
||||
|
||||
@@ -19,3 +19,14 @@ export function createEtcUploadKey(file: File): string {
|
||||
export function isEtcFileKey(fileKey: string): boolean {
|
||||
return fileKey.startsWith(ETC_FILE_KEY_PREFIX);
|
||||
}
|
||||
|
||||
export function toFileAccept(rmrk: string | null | undefined): string {
|
||||
if (!rmrk?.trim()) return "";
|
||||
|
||||
return rmrk
|
||||
.split(",")
|
||||
.map((ext) => ext.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
|
||||
.join(",");
|
||||
}
|
||||
|
||||
@@ -26,41 +26,6 @@ interface CklstResultListItem {
|
||||
picRvwCn?: string | null;
|
||||
}
|
||||
|
||||
function compareCklstSiblings(a: CklstTreeNode, b: CklstTreeNode): number {
|
||||
if (a.cklstArtclSn !== b.cklstArtclSn) {
|
||||
return a.cklstArtclSn - b.cklstArtclSn;
|
||||
}
|
||||
if (a.sortSeq !== b.sortSeq) {
|
||||
return b.sortSeq - a.sortSeq;
|
||||
}
|
||||
return a.cklstRvwSn - b.cklstRvwSn;
|
||||
}
|
||||
|
||||
export function orderCklstTreeNodes(rows: CklstTreeNode[]): CklstTreeNode[] {
|
||||
const childMap = new Map<number | null, CklstTreeNode[]>();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const parent = row.upCklstRvwSn ?? null;
|
||||
if (!childMap.has(parent)) childMap.set(parent, []);
|
||||
childMap.get(parent)!.push(row);
|
||||
});
|
||||
|
||||
childMap.forEach((siblings) => siblings.sort(compareCklstSiblings));
|
||||
|
||||
const ordered: CklstTreeNode[] = [];
|
||||
|
||||
function walk(parent: number | null) {
|
||||
(childMap.get(parent) ?? []).forEach((child) => {
|
||||
ordered.push(child);
|
||||
walk(child.cklstRvwSn);
|
||||
});
|
||||
}
|
||||
|
||||
walk(null);
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function groupCklstTreeByTaskSeComCd(cklstTree: CklstTreeNode[]): Map<string, CklstTreeNode[]> {
|
||||
const grouped = new Map<string, CklstTreeNode[]>();
|
||||
|
||||
@@ -81,7 +46,7 @@ function getOrderedSelectableNodes(
|
||||
const grouped = groupCklstTreeByTaskSeComCd(cklstTree);
|
||||
|
||||
return taskSeComCdArray.flatMap((code) =>
|
||||
orderCklstTreeNodes(grouped.get(code) ?? []).filter((node) => node.bchckUseYn === "Y"),
|
||||
(grouped.get(code) ?? []).filter((node) => node.bchckUseYn === "Y"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,7 +63,7 @@ export function buildChecklistTabs(
|
||||
return orderedCodes.map((code) => ({
|
||||
code,
|
||||
label: taskCodeLabels[code] || code,
|
||||
items: orderCklstTreeNodes(grouped.get(code) ?? []).map((node) => ({
|
||||
items: (grouped.get(code) ?? []).map((node) => ({
|
||||
cklstSn: node.cklstSn,
|
||||
cklstArtclSn: node.cklstArtclSn,
|
||||
cklstRvwSn: node.cklstRvwSn,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
createUploadKey,
|
||||
type FieldPrefix,
|
||||
isEtcFileKey,
|
||||
toFileAccept,
|
||||
} from "../lib/attachments-form-utils";
|
||||
|
||||
import { SectionCollapsible } from "./section-collapsible";
|
||||
@@ -373,7 +374,8 @@ function AttachmentsFormSection({
|
||||
<h3 className="mb-4 text-xl font-semibold">필수 첨부파일</h3>
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="60%" />
|
||||
<col width="50%" />
|
||||
<col width="10%" />
|
||||
<col width="40%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
@@ -381,6 +383,7 @@ function AttachmentsFormSection({
|
||||
<TableHead className="w-[30rem]" align="left">
|
||||
파일명
|
||||
</TableHead>
|
||||
<TableHead>파일 확장자</TableHead>
|
||||
<TableHead>업로드된 파일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -401,6 +404,7 @@ function AttachmentsFormSection({
|
||||
>
|
||||
{file.esntlAtchFileNm}
|
||||
</TableHead>
|
||||
<TableCell align="center">{file.esntlAtchFileRmrk}</TableCell>
|
||||
<TableCell className="space-y-2">
|
||||
{uploadedFiles[file.esntlAtchFileId]?.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
@@ -432,6 +436,7 @@ function AttachmentsFormSection({
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept={toFileAccept(file.esntlAtchFileRmrk) || undefined}
|
||||
onChange={(e) => {
|
||||
void handleEssentialFileUpload(
|
||||
file.esntlAtchFileId,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { Field, FieldLabel } from "@/shared/ui/field";
|
||||
import { cklstMngService, type CklstTreeNode } from "@/entities/cklst";
|
||||
import { useCodeList } from "@/entities/code";
|
||||
import {
|
||||
@@ -196,31 +196,33 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
|
||||
검토항목
|
||||
</TableHead>
|
||||
<TableHead rowSpan={2}>주요 검토내용</TableHead>
|
||||
<TableHead className="w-[16rem]">검토</TableHead>
|
||||
<TableHead colSpan={2}>검토 결과</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelectAll(tab.code, "Y");
|
||||
}}
|
||||
>
|
||||
전체 예
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelectAll(tab.code, "N");
|
||||
}}
|
||||
className="!bg-destructive"
|
||||
>
|
||||
전체 아니오
|
||||
</Button>
|
||||
</div>
|
||||
<TableHead className="w-[12rem]">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelectAll(tab.code, "Y");
|
||||
}}
|
||||
>
|
||||
전체 예
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[12rem]">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelectAll(tab.code, "N");
|
||||
}}
|
||||
className="!bg-destructive"
|
||||
>
|
||||
전체 아니오
|
||||
</Button>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -236,64 +238,72 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
|
||||
style={{
|
||||
paddingLeft: `${Math.max(0, item.treeLevel - 1) * 16 + 8}px`,
|
||||
}}
|
||||
colSpan={showRadios ? 0 : 2}
|
||||
colSpan={showRadios ? 0 : 3}
|
||||
>
|
||||
{item.treeLevel > 1 ? "└ " : ""}
|
||||
{item.content}
|
||||
</TableCell>
|
||||
{showRadios && resultIdx >= 0 ? (
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`cklstResultList.${resultIdx}.selected`}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FieldSet>
|
||||
<Controller
|
||||
name={`cklstResultList.${resultIdx}.selected`}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<TableCell className="text-center">
|
||||
<RadioGroup
|
||||
name={field.name}
|
||||
value={field.value || undefined}
|
||||
value={field.value === "Y" ? "Y" : undefined}
|
||||
onValueChange={field.onChange}
|
||||
className="flex justify-center"
|
||||
>
|
||||
<div className="flex w-full justify-center gap-6">
|
||||
<FieldLabel
|
||||
htmlFor={`check-${item.cklstRvwSn}-Y`}
|
||||
className="!w-auto border-none"
|
||||
<FieldLabel
|
||||
htmlFor={`check-${item.cklstRvwSn}-Y`}
|
||||
className="!w-auto justify-center border-none"
|
||||
>
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-auto"
|
||||
>
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-auto"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="Y"
|
||||
id={`check-${item.cklstRvwSn}-Y`}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<span className="font-normal">예</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel
|
||||
htmlFor={`check-${item.cklstRvwSn}-N`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-auto"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="N"
|
||||
id={`check-${item.cklstRvwSn}-N`}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<span className="font-normal">아니오</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</div>
|
||||
<RadioGroupItem
|
||||
value="Y"
|
||||
id={`check-${item.cklstRvwSn}-Y`}
|
||||
aria-invalid={fieldState.invalid}
|
||||
aria-label="예"
|
||||
/>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<RadioGroup
|
||||
name={field.name}
|
||||
value={field.value === "N" ? "N" : undefined}
|
||||
onValueChange={field.onChange}
|
||||
className="flex justify-center"
|
||||
>
|
||||
<FieldLabel
|
||||
htmlFor={`check-${item.cklstRvwSn}-N`}
|
||||
className="!w-auto justify-center border-none"
|
||||
>
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-auto"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="N"
|
||||
id={`check-${item.cklstRvwSn}-N`}
|
||||
aria-invalid={fieldState.invalid}
|
||||
aria-label="아니오"
|
||||
/>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -70,6 +70,8 @@ function ContactInfoFormSection({ fieldPrefix }: ContactInfoFormSectionProps) {
|
||||
id={field.name}
|
||||
placeholder="설계자 전화번호"
|
||||
aria-invalid={fieldState.invalid}
|
||||
maxLength={13}
|
||||
onChange={(e) => field.onChange(e.target.value.replace(/[^0-9-]/g, ""))}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
|
||||
import { getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import { attachService } from "@/entities/attach";
|
||||
import { CodeSelect } from "@/entities/code";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
const SPLMNT_ARTCL_UP_CD = "SPLMNTARTCL";
|
||||
const SPLMNT_DMND_FILE_KEY_PREFIX = "splmnt-dmnd::";
|
||||
|
||||
interface UploadedSplmntFile {
|
||||
fileName: string;
|
||||
fileKey: string;
|
||||
atflId?: string;
|
||||
atflCd?: string;
|
||||
}
|
||||
|
||||
interface SplmntDmndRow {
|
||||
rowKey: string;
|
||||
splmntArtclComCd: string;
|
||||
splmntDmndCn: string;
|
||||
splmntDmndAtchFileGroupId: string;
|
||||
uploadedFile: UploadedSplmntFile | null;
|
||||
}
|
||||
|
||||
interface SplmntDmndDialogConfirmItem {
|
||||
splmntArtclComCd: string;
|
||||
splmntDmndCn: string;
|
||||
splmntDmndAtchFileGroupId?: string;
|
||||
}
|
||||
|
||||
interface SplmntDmndDialogConfirmPayload {
|
||||
items: SplmntDmndDialogConfirmItem[];
|
||||
}
|
||||
|
||||
interface SplmntDmndDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isSubmitting?: boolean;
|
||||
onConfirm: (payload: SplmntDmndDialogConfirmPayload) => void;
|
||||
}
|
||||
|
||||
function createRowKey() {
|
||||
return `splmnt-row-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
function createEmptyRow(): SplmntDmndRow {
|
||||
return {
|
||||
rowKey: createRowKey(),
|
||||
splmntArtclComCd: "",
|
||||
splmntDmndCn: "",
|
||||
splmntDmndAtchFileGroupId: "",
|
||||
uploadedFile: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createUploadKey(rowKey: string, file: File) {
|
||||
return `${SPLMNT_DMND_FILE_KEY_PREFIX}${rowKey}::${getDefaultFileKey(file)}`;
|
||||
}
|
||||
|
||||
function getRowKeyFromFileKey(fileKey: string): string | null {
|
||||
if (!fileKey.startsWith(SPLMNT_DMND_FILE_KEY_PREFIX)) return null;
|
||||
const rest = fileKey.slice(SPLMNT_DMND_FILE_KEY_PREFIX.length);
|
||||
const separatorIndex = rest.indexOf("::");
|
||||
if (separatorIndex < 0) return null;
|
||||
return rest.slice(0, separatorIndex);
|
||||
}
|
||||
|
||||
function isRowsValid(rows: SplmntDmndRow[]) {
|
||||
return rows.every((row) => row.splmntArtclComCd.trim() && row.splmntDmndCn.trim());
|
||||
}
|
||||
|
||||
function SplmntDmndDialog({ open, onOpenChange, isSubmitting, onConfirm }: SplmntDmndDialogProps) {
|
||||
const formId = useId();
|
||||
const [rows, setRows] = useState<SplmntDmndRow[]>([createEmptyRow()]);
|
||||
|
||||
const { uploadFileAsync, isUploading, uploads } = useTus({
|
||||
onSuccess: (atflCd, atflId, _file, fileKey) => {
|
||||
if (!atflCd || !atflId) return;
|
||||
const rowKey = getRowKeyFromFileKey(fileKey);
|
||||
if (!rowKey) return;
|
||||
|
||||
setRows((prev) =>
|
||||
prev.map((row) => {
|
||||
if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
|
||||
return {
|
||||
...row,
|
||||
splmntDmndAtchFileGroupId: atflCd,
|
||||
uploadedFile: { ...row.uploadedFile, atflCd, atflId },
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
onError: (_error, _file, fileKey) => {
|
||||
const rowKey = getRowKeyFromFileKey(fileKey);
|
||||
if (!rowKey) return;
|
||||
|
||||
setRows((prev) =>
|
||||
prev.map((row) => {
|
||||
if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
|
||||
return { ...row, uploadedFile: null };
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setRows([createEmptyRow()]);
|
||||
}, [open]);
|
||||
|
||||
const isAnyUploading = rows.some(
|
||||
(row) => row.uploadedFile != null && (isUploading[row.uploadedFile.fileKey] ?? false),
|
||||
);
|
||||
const canSubmit = isRowsValid(rows) && !isAnyUploading && !isSubmitting;
|
||||
|
||||
function updateRow(rowKey: string, patch: Partial<SplmntDmndRow>) {
|
||||
setRows((prev) => prev.map((row) => (row.rowKey === rowKey ? { ...row, ...patch } : row)));
|
||||
}
|
||||
|
||||
function handleAddRow() {
|
||||
setRows((prev) => [...prev, createEmptyRow()]);
|
||||
}
|
||||
|
||||
function handleRemoveRow(rowKey: string) {
|
||||
setRows((prev) => (prev.length <= 1 ? prev : prev.filter((row) => row.rowKey !== rowKey)));
|
||||
}
|
||||
|
||||
async function handleFileUpload(rowKey: string, files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
const fileKey = createUploadKey(rowKey, file);
|
||||
const targetRow = rows.find((row) => row.rowKey === rowKey);
|
||||
const existingAtflCd = targetRow?.splmntDmndAtchFileGroupId;
|
||||
|
||||
updateRow(rowKey, {
|
||||
uploadedFile: { fileName: file.name, fileKey },
|
||||
});
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileRemove(rowKey: string) {
|
||||
const targetRow = rows.find((row) => row.rowKey === rowKey);
|
||||
if (!targetRow?.uploadedFile) return;
|
||||
|
||||
const { fileKey, atflId } = targetRow.uploadedFile;
|
||||
uploads[fileKey]?.abort();
|
||||
|
||||
if (atflId) {
|
||||
try {
|
||||
await attachService.deleteAttachmentByAtflId(atflId);
|
||||
} catch (error) {
|
||||
console.error("보완요청 첨부파일 삭제 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
updateRow(rowKey, {
|
||||
uploadedFile: null,
|
||||
splmntDmndAtchFileGroupId: "",
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!canSubmit) return;
|
||||
|
||||
onConfirm({
|
||||
items: rows.map((row) => ({
|
||||
splmntArtclComCd: row.splmntArtclComCd.trim(),
|
||||
splmntDmndCn: row.splmntDmndCn.trim(),
|
||||
splmntDmndAtchFileGroupId: row.splmntDmndAtchFileGroupId || undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[100rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>보완요청</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h4 className="text-xl font-semibold">보완 내용</h4>
|
||||
<Button type="button" size="sm" color="green" onClick={handleAddRow}>
|
||||
항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="20%" />
|
||||
<col width="50%" />
|
||||
<col width="20%" />
|
||||
{rows.length > 1 ? <col width="10%" /> : null}
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>보완 항목</TableHead>
|
||||
<TableHead>보완 의견</TableHead>
|
||||
<TableHead>첨부파일</TableHead>
|
||||
{rows.length > 1 ? <TableHead>관리</TableHead> : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const uploading = row.uploadedFile
|
||||
? (isUploading[row.uploadedFile.fileKey] ?? false)
|
||||
: false;
|
||||
const fileInputId = `${formId}-${row.rowKey}-file`;
|
||||
|
||||
return (
|
||||
<TableRow key={row.rowKey}>
|
||||
<TableCell align="center" className="align-top">
|
||||
<CodeSelect
|
||||
upCd={SPLMNT_ARTCL_UP_CD}
|
||||
value={row.splmntArtclComCd || undefined}
|
||||
onValueChange={(value) =>
|
||||
updateRow(row.rowKey, { splmntArtclComCd: value })
|
||||
}
|
||||
placeholder="보완항목 선택"
|
||||
className="w-full min-w-[12rem]"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Textarea
|
||||
value={row.splmntDmndCn}
|
||||
onChange={(event) =>
|
||||
updateRow(row.rowKey, { splmntDmndCn: event.target.value })
|
||||
}
|
||||
placeholder="보완 의견을 입력하세요."
|
||||
rows={4}
|
||||
className="min-h-[8rem] !text-xl"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex items-center gap-2">
|
||||
{row.uploadedFile ? (
|
||||
<div className="flex items-stretch gap-2">
|
||||
<span className="text-xl">{row.uploadedFile.fileName}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 !bg-destructive"
|
||||
disabled={uploading}
|
||||
onClick={() => void handleFileRemove(row.rowKey)}
|
||||
>
|
||||
<i className="diveicon di-x text-xs" aria-label="삭제" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button type="button" size="sm" disabled={uploading} asChild>
|
||||
<label htmlFor={fileInputId} className="cursor-pointer">
|
||||
파일추가
|
||||
<input
|
||||
id={fileInputId}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(row.rowKey, event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
{rows.length > 1 ? (
|
||||
<TableCell align="center" className="align-top">
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveRow(row.rowKey)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="button" onClick={handleConfirm} disabled={!canSubmit}>
|
||||
보완요청
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { SplmntDmndDialog };
|
||||
export type { SplmntDmndDialogConfirmItem, SplmntDmndDialogConfirmPayload };
|
||||
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { Paperclip } from "lucide-react";
|
||||
|
||||
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import { SectionCollapsible } from "./section-collapsible";
|
||||
|
||||
const SPLMNT_CMPTN_FILE_KEY_PREFIX = "splmnt-cmptn::";
|
||||
|
||||
interface UploadedSplmntFile {
|
||||
fileName: string;
|
||||
fileKey: string;
|
||||
atflId?: string;
|
||||
atflCd?: string;
|
||||
}
|
||||
|
||||
interface SplmntDmndFormItem {
|
||||
splmntDmndSn?: number;
|
||||
splmntArtclComCd?: string;
|
||||
splmntArtclCdNm?: string | null;
|
||||
splmntDmndCn?: string;
|
||||
splmntDmndDt?: string;
|
||||
splmntDmndAtchFileGroupId?: string;
|
||||
splmntDmndAtchFileNm?: string;
|
||||
splmntCn?: string;
|
||||
splmntPicNm?: string;
|
||||
splmntCmptnDt?: string;
|
||||
splmntAtchFileGroupId?: string;
|
||||
splmntAtchFileNm?: string;
|
||||
splmntAtchFileAtflId?: string;
|
||||
cmptnYn?: string;
|
||||
}
|
||||
|
||||
interface SplmntDmndFormData {
|
||||
splmntDmndList: SplmntDmndFormItem[];
|
||||
}
|
||||
|
||||
interface SplmntDmndFormSectionProps {
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function createUploadKey(rowKey: string, file: File) {
|
||||
return `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::${getDefaultFileKey(file)}`;
|
||||
}
|
||||
|
||||
function getRowKeyFromFileKey(fileKey: string): string | null {
|
||||
if (!fileKey.startsWith(SPLMNT_CMPTN_FILE_KEY_PREFIX)) return null;
|
||||
const rest = fileKey.slice(SPLMNT_CMPTN_FILE_KEY_PREFIX.length);
|
||||
const separatorIndex = rest.indexOf("::");
|
||||
if (separatorIndex < 0) return null;
|
||||
return rest.slice(0, separatorIndex);
|
||||
}
|
||||
|
||||
function getRowKey(item: SplmntDmndFormItem, index: number) {
|
||||
return item.splmntDmndSn != null ? String(item.splmntDmndSn) : `idx-${index}`;
|
||||
}
|
||||
|
||||
function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps) {
|
||||
const formId = useId();
|
||||
const { control, setValue, getValues } = useFormContext<SplmntDmndFormData>();
|
||||
const splmntDmndList = useWatch({ control, name: "splmntDmndList" }) ?? [];
|
||||
const [uploadedFiles, setUploadedFiles] = useState<Record<string, UploadedSplmntFile | null>>({});
|
||||
const initialFilesMappedRef = useRef(false);
|
||||
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
|
||||
|
||||
const { uploadFileAsync, isUploading, uploads } = useTus({
|
||||
onSuccess: (atflCd, atflId, _file, fileKey) => {
|
||||
if (!atflCd || !atflId) return;
|
||||
const rowKey = getRowKeyFromFileKey(fileKey);
|
||||
if (!rowKey) return;
|
||||
|
||||
setUploadedFiles((prev) => {
|
||||
const current = prev[rowKey];
|
||||
if (!current || current.fileKey !== fileKey) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[rowKey]: { ...current, atflCd, atflId },
|
||||
};
|
||||
});
|
||||
|
||||
const list = getValues("splmntDmndList") ?? [];
|
||||
const index = list.findIndex((item, idx) => getRowKey(item, idx) === rowKey);
|
||||
if (index < 0) return;
|
||||
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, atflCd, { shouldDirty: true });
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, atflId, { shouldDirty: true });
|
||||
},
|
||||
onError: (_error, _file, fileKey) => {
|
||||
const rowKey = getRowKeyFromFileKey(fileKey);
|
||||
if (!rowKey) return;
|
||||
|
||||
setUploadedFiles((prev) => {
|
||||
const current = prev[rowKey];
|
||||
if (!current || current.fileKey !== fileKey) return prev;
|
||||
return { ...prev, [rowKey]: null };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFilesMappedRef.current) return;
|
||||
if (splmntDmndList.length === 0) return;
|
||||
|
||||
const initial: Record<string, UploadedSplmntFile | null> = {};
|
||||
for (let index = 0; index < splmntDmndList.length; index += 1) {
|
||||
const item = splmntDmndList[index];
|
||||
const rowKey = getRowKey(item, index);
|
||||
if (item.splmntAtchFileGroupId && item.splmntAtchFileNm) {
|
||||
initial[rowKey] = {
|
||||
fileName: item.splmntAtchFileNm,
|
||||
fileKey: `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::initial`,
|
||||
atflCd: item.splmntAtchFileGroupId,
|
||||
atflId: item.splmntAtchFileAtflId,
|
||||
};
|
||||
} else {
|
||||
initial[rowKey] = null;
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFiles(initial);
|
||||
initialFilesMappedRef.current = true;
|
||||
}, [splmntDmndList]);
|
||||
|
||||
if (splmntDmndList.length === 0) return null;
|
||||
|
||||
async function handleFileUpload(index: number, files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const item = splmntDmndList[index];
|
||||
const rowKey = getRowKey(item, index);
|
||||
const file = files[0];
|
||||
const fileKey = createUploadKey(rowKey, file);
|
||||
const existingAtflCd = item.splmntAtchFileGroupId;
|
||||
|
||||
setUploadedFiles((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: { fileName: file.name, fileKey },
|
||||
}));
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileNm`, file.name, { shouldDirty: true });
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileRemove(index: number) {
|
||||
const item = splmntDmndList[index];
|
||||
const rowKey = getRowKey(item, index);
|
||||
const uploaded = uploadedFiles[rowKey];
|
||||
if (!uploaded) return;
|
||||
|
||||
const { fileKey, atflId } = uploaded;
|
||||
uploads[fileKey]?.abort();
|
||||
|
||||
if (atflId) {
|
||||
try {
|
||||
await attachService.deleteAttachmentByAtflId(atflId);
|
||||
} catch (error) {
|
||||
console.error("보완완료 첨부파일 삭제 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFiles((prev) => ({ ...prev, [rowKey]: null }));
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, "", { shouldDirty: true });
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileNm`, "", { shouldDirty: true });
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, "", { shouldDirty: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="보완요청">
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="12%" />
|
||||
<col width="14%" />
|
||||
<col width="22%" />
|
||||
<col width="22%" />
|
||||
<col width="18%" />
|
||||
<col width="12%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>보완항목</TableHead>
|
||||
<TableHead>보완요청일시</TableHead>
|
||||
<TableHead>보완요청내용</TableHead>
|
||||
<TableHead>보완완료내용</TableHead>
|
||||
<TableHead>파일첨부</TableHead>
|
||||
<TableHead>완료여부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{splmntDmndList.map((item, index) => {
|
||||
const rowKey = getRowKey(item, index);
|
||||
const uploaded = uploadedFiles[rowKey] ?? null;
|
||||
const uploading = uploaded ? (isUploading[uploaded.fileKey] ?? false) : false;
|
||||
const fileInputId = `${formId}-${rowKey}-file`;
|
||||
const isRowReadOnly = readOnly || item.cmptnYn === "Y";
|
||||
|
||||
return (
|
||||
<TableRow key={rowKey}>
|
||||
<TableCell align="center" className="align-top">
|
||||
{item.splmntArtclCdNm || "-"}
|
||||
</TableCell>
|
||||
<TableCell align="center" className="whitespace-nowrap align-top">
|
||||
{item.splmntDmndDt ? dayjs(item.splmntDmndDt).format("YYYY-MM-DD HH:mm") : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="space-y-2">
|
||||
<div className="whitespace-pre-wrap">{item.splmntDmndCn || "-"}</div>
|
||||
{item.splmntDmndAtchFileGroupId ? (
|
||||
<a
|
||||
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
|
||||
onClick={() => downloadAttachment(item.splmntDmndAtchFileGroupId!)}
|
||||
>
|
||||
<Paperclip className="my-1 size-5 shrink-0" />
|
||||
{item.splmntDmndAtchFileNm || "다운로드"}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
{isRowReadOnly ? (
|
||||
<span className="whitespace-pre-wrap">{item.splmntCn || "-"}</span>
|
||||
) : (
|
||||
<Controller
|
||||
control={control}
|
||||
name={`splmntDmndList.${index}.splmntCn`}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="보완완료 내용을 입력하세요."
|
||||
className="min-h-[6rem] !text-xl"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
{uploaded ? (
|
||||
<div className="flex items-stretch gap-2">
|
||||
{uploaded.atflCd || item.splmntAtchFileGroupId ? (
|
||||
<a
|
||||
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
|
||||
onClick={() =>
|
||||
downloadAttachment((uploaded.atflCd || item.splmntAtchFileGroupId)!)
|
||||
}
|
||||
>
|
||||
<Paperclip className="my-1 size-5 shrink-0" />
|
||||
{uploaded.fileName}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xl">{uploaded.fileName}</span>
|
||||
)}
|
||||
{!isRowReadOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 !bg-destructive"
|
||||
disabled={uploading}
|
||||
onClick={() => void handleFileRemove(index)}
|
||||
>
|
||||
<i className="diveicon di-x text-xs" aria-label="삭제" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : isRowReadOnly ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
<Button type="button" size="sm" disabled={uploading} asChild>
|
||||
<label htmlFor={fileInputId} className="cursor-pointer">
|
||||
파일추가
|
||||
<input
|
||||
id={fileInputId}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(index, event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center" className="align-top">
|
||||
<Badge size="lg" color={item.cmptnYn === "Y" ? "green" : "gray"}>
|
||||
{item.cmptnYn === "Y" ? "완료" : "미완료"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { SplmntDmndFormSection };
|
||||
export type { SplmntDmndFormItem };
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { Paperclip } from "lucide-react";
|
||||
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import type { SplmntDmndResponseExtension, SplmntDmndSaveBase } from "@/shared/model";
|
||||
import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
|
||||
import {
|
||||
Badge,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { SectionCollapsible } from "./section-collapsible";
|
||||
|
||||
type SplmntDmndItem = SplmntDmndSaveBase & SplmntDmndResponseExtension;
|
||||
|
||||
interface SupplementDemandSectionProps {
|
||||
splmntDmndList: SplmntDmndItem[];
|
||||
}
|
||||
|
||||
function SupplementDemandSection({ splmntDmndList }: SupplementDemandSectionProps) {
|
||||
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="보완요청 및 완료 현황">
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="14%" />
|
||||
<col width="8%" />
|
||||
<col width="30%" />
|
||||
<col width="8%" />
|
||||
<col width="30%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>보완항목</TableHead>
|
||||
<TableHead>보완요청일시</TableHead>
|
||||
<TableHead>보완요청내용</TableHead>
|
||||
<TableHead>보완완료일시</TableHead>
|
||||
<TableHead>보완완료내용</TableHead>
|
||||
<TableHead>완료여부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{splmntDmndList.map((item) => {
|
||||
const sn = item.splmntDmndSn;
|
||||
const cmptnYn = item.cmptnYn ?? "N";
|
||||
const dmndAtchFileNm = item.splmntDmndAtchFileInfo?.orgnlFileNm;
|
||||
const cmptnAtchFileNm = item.splmntAtchFileInfo?.orgnlFileNm;
|
||||
|
||||
return (
|
||||
<TableRow key={sn ?? `${item.splmntArtclComCd}-${item.splmntDmndDt}`}>
|
||||
<TableCell align="center" className="align-top">
|
||||
{item.splmntArtclCdNm || "-"}
|
||||
</TableCell>
|
||||
<TableCell align="center" className="whitespace-nowrap align-top">
|
||||
{item.splmntDmndDt ? dayjs(item.splmntDmndDt).format("YYYY-MM-DD HH:mm") : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="space-y-2">
|
||||
<div className="whitespace-pre-wrap">{item.splmntDmndCn || "-"}</div>
|
||||
{item.splmntDmndAtchFileGroupId ? (
|
||||
<a
|
||||
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
|
||||
onClick={() => downloadAttachment(item.splmntDmndAtchFileGroupId!)}
|
||||
>
|
||||
<Paperclip className="my-1 size-5 shrink-0" />
|
||||
{dmndAtchFileNm || "다운로드"}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center" className="whitespace-nowrap align-top">
|
||||
{item.splmntCmptnDt
|
||||
? dayjs(item.splmntCmptnDt).format("YYYY-MM-DD HH:mm")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="space-y-2">
|
||||
<div className="whitespace-pre-wrap">{item.splmntCn || "-"}</div>
|
||||
{item.splmntAtchFileGroupId ? (
|
||||
<a
|
||||
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
|
||||
onClick={() => downloadAttachment(item.splmntAtchFileGroupId!)}
|
||||
>
|
||||
<Paperclip className="my-1 size-5 shrink-0" />
|
||||
{cmptnAtchFileNm || "다운로드"}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center" className="align-top">
|
||||
<Badge size="lg" color={cmptnYn === "Y" ? "green" : "gray"}>
|
||||
{cmptnYn === "Y" ? "완료" : "미완료"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { SupplementDemandSection };
|
||||
@@ -1,77 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
interface TextInputDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
isSubmitting?: boolean;
|
||||
onConfirm: (text: string) => void;
|
||||
}
|
||||
|
||||
function TextInputDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
placeholder,
|
||||
defaultValue,
|
||||
isSubmitting,
|
||||
onConfirm,
|
||||
}: TextInputDialogProps) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setValue(defaultValue ?? "");
|
||||
}, [defaultValue, open]);
|
||||
|
||||
function handleConfirm() {
|
||||
onConfirm(value.trim());
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[44rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={6}
|
||||
className="!text-xl"
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={isSubmitting || value.trim().length === 0}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { TextInputDialog };
|
||||
Generated
+9
@@ -195,6 +195,9 @@ importers:
|
||||
tus-js-client:
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.1
|
||||
uuid:
|
||||
specifier: ^14.0.1
|
||||
version: 14.0.1
|
||||
zod:
|
||||
specifier: ^3.24.2
|
||||
version: 3.25.76
|
||||
@@ -7417,6 +7420,10 @@ packages:
|
||||
utrie@1.0.2:
|
||||
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||
|
||||
uuid@14.0.1:
|
||||
resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==}
|
||||
hasBin: true
|
||||
|
||||
uuid@9.0.1:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
@@ -13550,6 +13557,8 @@ snapshots:
|
||||
dependencies:
|
||||
base64-arraybuffer: 1.0.2
|
||||
|
||||
uuid@14.0.1: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
||||
victory-vendor@36.9.2:
|
||||
|
||||
Reference in New Issue
Block a user