Merge branch 'iipbs/as/dev'

This commit is contained in:
2026-07-16 18:25:49 +09:00
72 changed files with 2256 additions and 2424 deletions
+17 -23
View File
@@ -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;
}
+1
View File
@@ -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": {
+3 -1
View File
@@ -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>
);
}
+10 -10
View File
@@ -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);
}
+18
View File
@@ -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) {
@@ -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 */
@@ -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;
@@ -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>
@@ -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 };
@@ -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 };
@@ -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 ?? "—",
}),
],
[],
);
@@ -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>
)}
@@ -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();
+3
View File
@@ -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}
</>
+3 -1
View File
@@ -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;
}
/** 사업규모 저장 공통 필드 */
+7 -2
View File
@@ -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 };
+9
View File
@@ -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: