diff --git a/apps/as/app/(with-base-layout)/design-cnstn/[id]/edit/page.tsx b/apps/as/app/(with-base-layout)/design-cnstn/[id]/edit/page.tsx new file mode 100644 index 000000000..e5df52e22 --- /dev/null +++ b/apps/as/app/(with-base-layout)/design-cnstn/[id]/edit/page.tsx @@ -0,0 +1,10 @@ +import { DesignEditPage } from "@/pages/design-cnstn"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function Page({ params }: PageProps) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/design-cnstn/[id]/page.tsx b/apps/as/app/(with-base-layout)/design-cnstn/[id]/page.tsx new file mode 100644 index 000000000..1b0fc983c --- /dev/null +++ b/apps/as/app/(with-base-layout)/design-cnstn/[id]/page.tsx @@ -0,0 +1,10 @@ +import { DesignViewPage } from "@/pages/design-cnstn"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function Page({ params }: PageProps) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/design-cnstn/page.tsx b/apps/as/app/(with-base-layout)/design-cnstn/page.tsx new file mode 100644 index 000000000..cced4a877 --- /dev/null +++ b/apps/as/app/(with-base-layout)/design-cnstn/page.tsx @@ -0,0 +1,3 @@ +import { DesignListPage } from "@/pages/design-cnstn"; + +export default DesignListPage; diff --git a/apps/as/app/(with-base-layout)/design-cnstn/regist/page.tsx b/apps/as/app/(with-base-layout)/design-cnstn/regist/page.tsx new file mode 100644 index 000000000..567b634d5 --- /dev/null +++ b/apps/as/app/(with-base-layout)/design-cnstn/regist/page.tsx @@ -0,0 +1,12 @@ +import { DesignRegistPage } from "@/pages/design-cnstn"; +import { parseTaskSeComCdFromApi } from "@/pages/design-cnstn/lib/design-cnstn-regist-schema"; + +interface PageProps { + searchParams: Promise<{ taskSeComCd?: string }>; +} + +export default async function Page({ searchParams }: PageProps) { + const params = await searchParams; + const taskSeComCd = parseTaskSeComCdFromApi(params.taskSeComCd); + return ; +} diff --git a/apps/as/app/api/proxy/[...url]/route.ts b/apps/as/app/api/proxy/[...url]/route.ts index b32008942..d54f08656 100644 --- a/apps/as/app/api/proxy/[...url]/route.ts +++ b/apps/as/app/api/proxy/[...url]/route.ts @@ -68,7 +68,7 @@ async function handler( cookieStore.set(REFRESH_TOKEN, refreshToken, { httpOnly: true, sameSite: "strict", - domain: getCookieDomain(), + domain: COOKIE_DOMAIN, path: "/", }); } @@ -92,14 +92,3 @@ 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; -} diff --git a/apps/as/next.config.ts b/apps/as/next.config.ts index be9b410eb..b8c17efb2 100644 --- a/apps/as/next.config.ts +++ b/apps/as/next.config.ts @@ -3,6 +3,7 @@ import { withSentryConfig } from "@sentry/nextjs"; import path from "path"; const nextConfig: NextConfig = { + output: "standalone", reactStrictMode: true, transpilePackages: ["@common/assets"], outputFileTracingRoot: path.join(__dirname, "../../"), diff --git a/apps/as/src/app/providers/index.tsx b/apps/as/src/app/providers/index.tsx index 20ec0b72e..2a0ac6e69 100644 --- a/apps/as/src/app/providers/index.tsx +++ b/apps/as/src/app/providers/index.tsx @@ -27,7 +27,9 @@ export function Providers({ children }: ProvidersProps) { - +
+ +
); } diff --git a/apps/as/src/entities/auth/api/auth-service.ts b/apps/as/src/entities/auth/api/auth-service.ts index 934471c50..b7158bf80 100644 --- a/apps/as/src/entities/auth/api/auth-service.ts +++ b/apps/as/src/entities/auth/api/auth-service.ts @@ -1,14 +1,8 @@ import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config"; -import { type AuthPayload, type AuthService, getRemainingMs } from "@common/auth"; +import { type AuthPayload, type AuthService } from "@common/auth"; import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id"; -import { - clearRefreshTimer, - removeToken, - setToken, - setupRefreshTimer, - tokenManager, -} from "../lib/token-manager"; +import { clearRefreshTimer, setToken, tokenManager } from "../lib/token-manager"; async function login(payload: AuthPayload): Promise { const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { @@ -32,10 +26,10 @@ async function login(payload: AuthPayload): Promise { setToken(CLAIM_NAME, accessToken); setToken(AUTH_USER_ID, tokenId); - const remainingMs = getRemainingMs(accessToken); - if (remainingMs > 0) { - setupRefreshTimer(silentRefresh, remainingMs); - } + // const remainingMs = getRemainingMs(accessToken); + // if (remainingMs > 0) { + // setupRefreshTimer(silentRefresh, remainingMs); + // } } async function silentRefresh(): Promise { @@ -64,10 +58,10 @@ async function silentRefresh(): Promise { setToken(CLAIM_NAME, accessToken); setToken(AUTH_USER_ID, tokenId); - const remainingMs = getRemainingMs(accessToken); - if (remainingMs > 0) { - setupRefreshTimer(silentRefresh, remainingMs); - } + // const remainingMs = getRemainingMs(accessToken); + // if (remainingMs > 0) { + // setupRefreshTimer(silentRefresh, remainingMs); + // } } catch (error) { console.error(`[silentRefresh] Failed at ${new Date().toISOString()}`, error); tokenManager.clearAuthToken(); @@ -79,8 +73,6 @@ async function silentRefresh(): Promise { async function logout(): Promise { tokenManager.clearAuthToken(); - removeToken("site-id-key"); - removeToken("site-theme-key"); clearDeviceId(); if (typeof window !== "undefined") { diff --git a/apps/as/src/entities/code/ui/code-select.tsx b/apps/as/src/entities/code/ui/code-select.tsx index e803e9285..74d011bcd 100644 --- a/apps/as/src/entities/code/ui/code-select.tsx +++ b/apps/as/src/entities/code/ui/code-select.tsx @@ -18,13 +18,7 @@ interface CodeSelectProps extends React.ComponentPropsWithoutRef className?: string; } -function CodeSelect({ - upCd, - useYn, - placeholder = "선택", - className, - ...props -}: CodeSelectProps) { +function CodeSelect({ upCd, useYn, placeholder = "선택", className, ...props }: CodeSelectProps) { const listQuery = useCodeList(useYn ? undefined : upCd); const listByUseYnQuery = useCodeListByUseYn(useYn ? upCd : undefined, useYn ?? ""); @@ -34,7 +28,7 @@ function CodeSelect({ return ( ) : ( atrzSeLabel || "-" )} -
- {row.aprvrNm || row.aprvrUuid || "-"} +
+ {row.aprvrNm || row.aprvrUuid || ""} {isEditing && ( - )} diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/sortable-prgrs-stts-row.tsx b/apps/as/src/entities/elapr/ui/sortable-prgrs-stts-row.tsx similarity index 74% rename from apps/as/src/pages/fnrs-cnsltn/ui/components/sortable-prgrs-stts-row.tsx rename to apps/as/src/entities/elapr/ui/sortable-prgrs-stts-row.tsx index 795c9d08d..601d272f2 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/components/sortable-prgrs-stts-row.tsx +++ b/apps/as/src/entities/elapr/ui/sortable-prgrs-stts-row.tsx @@ -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) => 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({ {displaySeqNo} {isEditing ? ( - - onChange({ - atrzSeComCd: v, - atrzSeComNm: findCodeLabel(atrzSeCodes, v), - }) + onChange({ atrzSeComCd: v, atrzSeComNm: findCodeLabel(selectableAtrzSeCodes, v) }) } - placeholder="결재구분" - /> + > + + + + + {selectableAtrzSeCodes?.map((code) => ( + + {code.lwpoCdNm} + + ))} + + ) : ( atrzSeLabel || "-" )} @@ -123,6 +147,15 @@ function SortablePrgrsSttsRow({ )}
+ + {isDcarbable && !rowDisabled && ( + onToggleDcarb(checked === true)} + aria-label="전결자 지정" + /> + )} + {isRejectedRow && rjctRsn ? ( diff --git a/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts b/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts index 38ce8e69a..2bef72628 100644 --- a/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts +++ b/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts @@ -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 { + const response = await client.post(`${BASE}/${id}/submitsupplement`, payload); + return response.data; +} + +async function submitReqSupplement( + id: string, + payload: InstlSncnApplySaveRequestDto, +): Promise { + const response = await client.post(`${BASE}/${id}/reqsupplement`, payload); + return response.data; +} + async function remove(id: string): Promise { await client.delete(`${BASE}/${id}`); } @@ -80,6 +96,8 @@ export const instlSncnService = { saveApply, update, techReview, + submitSupplement, + submitReqSupplement, remove, changePrgrsSttsComCd, } satisfies InstlSncnMngService; diff --git a/apps/as/src/entities/instl-sncn/api/queries.ts b/apps/as/src/entities/instl-sncn/api/queries.ts index ac651be47..f9d94dbff 100644 --- a/apps/as/src/entities/instl-sncn/api/queries.ts +++ b/apps/as/src/entities/instl-sncn/api/queries.ts @@ -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(); diff --git a/apps/as/src/entities/instl-sncn/index.ts b/apps/as/src/entities/instl-sncn/index.ts index 9547dd6db..946e756f1 100644 --- a/apps/as/src/entities/instl-sncn/index.ts +++ b/apps/as/src/entities/instl-sncn/index.ts @@ -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"; diff --git a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts index 500cfb842..6ead54c13 100644 --- a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts +++ b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts @@ -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; /** 필수첨부파일 목록 */ - esntlAtchFileList: InstlSncnEsntlAtchFileSaveDto[]; + esntlAtchFileList?: InstlSncnEsntlAtchFileSaveDto[]; /** 검토의견 목록 */ rvwOpnnList?: InstlSncnRvwOpnnSaveRequestDto[]; /** 보완요구 목록 */ @@ -222,8 +229,11 @@ export type InstlSncnRvwOpnnResponseDto = InstlSncnRvwOpnnSaveRequestDto & }; /** 설치인가관리 상세 응답 DTO */ -export interface InstlSncnMngDetResponse - extends PbadmsMngDetResponseBase { +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; update: (id: string, payload: InstlSncnMngUpdateRequestDto) => Promise; techReview: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise; + submitSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise; + submitReqSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise; remove: (id: string) => Promise; changePrgrsSttsComCd: (payload: DocPrgrsUpdateRequestDto) => Promise; } diff --git a/apps/as/src/pages/approval-line-management/model/columns.tsx b/apps/as/src/pages/approval-line-management/model/columns.tsx index a7beb668b..35dcc4a74 100644 --- a/apps/as/src/pages/approval-line-management/model/columns.tsx +++ b/apps/as/src/pages/approval-line-management/model/columns.tsx @@ -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" }, diff --git a/apps/as/src/pages/approval-line-management/ui/approval-line-management-page.tsx b/apps/as/src/pages/approval-line-management/ui/approval-line-management-page.tsx index 11b9373ac..38b018809 100644 --- a/apps/as/src/pages/approval-line-management/ui/approval-line-management-page.tsx +++ b/apps/as/src/pages/approval-line-management/ui/approval-line-management-page.tsx @@ -11,7 +11,7 @@ function ApprovalLineManagementPage() { const [selectedTmplt, setSelectedTmplt] = useState(null); return ( -
+
- - + - + + - 결재선아이디 결재순서 결재구분 + 결재자 ID 결재자 등록일시 관리 diff --git a/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx b/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx index 94d28be22..b0a23ee1e 100644 --- a/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx +++ b/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx @@ -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 ( @@ -66,31 +76,36 @@ function SortableAplnRow({ - {row.isNew ? "-" : (row.aplnId ?? "-")} {displaySeqNo} {isEditing ? ( - - onChange({ - atrzSeComCd: v, - atrzSeComNm: findCodeLabel(atrzSeCodes, v), - }) + onChange({ atrzSeComCd: v, atrzSeComNm: findCodeLabel(selectableAtrzSeCodes, v) }) } - placeholder="결재구분" - /> + > + + + + + {selectableAtrzSeCodes?.map((code) => ( + + {code.lwpoCdNm} + + ))} + + ) : ( atrzSeLabel || "-" )} + {row.aprvrUserId ?? "-"} -
- {row.aprvrNm || row.aprvrUuid || "-"} +
+ {row.aprvrNm || row.aprvrUuid || ""} {isEditing && ( - )} diff --git a/apps/as/src/pages/design-cnstn/index.ts b/apps/as/src/pages/design-cnstn/index.ts new file mode 100644 index 000000000..fcaaab3ed --- /dev/null +++ b/apps/as/src/pages/design-cnstn/index.ts @@ -0,0 +1,5 @@ +export { DesignEditPage } from "./ui/design-edit-page"; +export { DesignFormPage } from "./ui/design-form-page"; +export { DesignListPage } from "./ui/design-list-page"; +export { DesignRegistPage } from "./ui/design-regist-page"; +export { DesignViewPage } from "./ui/design-view-page"; diff --git a/apps/as/src/pages/design-cnstn/lib/button-config.ts b/apps/as/src/pages/design-cnstn/lib/button-config.ts new file mode 100644 index 000000000..2727092f2 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/lib/button-config.ts @@ -0,0 +1,118 @@ +export const DESIGN_CNSTN_VIEW_BUTTON_ACTION = { + strg: "strg", + aply: "aply", + del: "del", + rqstRvw: "rqstRvw", + rvwStrg: "rvwStrg", + rvwCmptn: "rvwCmptn", + atrzAprv: "atrzAprv", + atrzRply: "atrzRply", +} as const; + +export type DesignCnstnViewButtonAction = + (typeof DESIGN_CNSTN_VIEW_BUTTON_ACTION)[keyof typeof DESIGN_CNSTN_VIEW_BUTTON_ACTION]; + +const ROLE_STATUS_BUTTON_ACTIONS: Record> = { + ROLE_USER05: { + "": [ + DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg, + DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply, + DESIGN_CNSTN_VIEW_BUTTON_ACTION.del, + ], + CV: [ + DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg, + DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply, + DESIGN_CNSTN_VIEW_BUTTON_ACTION.del, + ], + }, +}; + +const ROLE_USER01_ATRZ_STATUS_CODES = ["CT", "CS"] as const; + +const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record< + string, + Record +> = { + CT: { + ATRZ01: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.rqstRvw], + }, + CS: { + ATRZ02: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwStrg, DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwCmptn], + ATRZ03: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzAprv], + ATRZ99: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzRply], + }, +}; + +function resolveButtonConfigs( + actions: DesignCnstnViewButtonAction[], +): DesignCnstnViewButtonAction[] { + return actions.map((action) => DESIGN_CNSTN_VIEW_BUTTON_ACTION[action]); +} + +interface GetButtonConfigsOptions { + hasPendingApprovalRequest?: boolean; +} + +function resolveRoleUser01AtrzButtonActions( + prgrsSttsComCd: string, + atrzSeComCdList: string[], + options?: GetButtonConfigsOptions, +): DesignCnstnViewButtonAction[] { + if (prgrsSttsComCd === "CS" && options?.hasPendingApprovalRequest === false) { + return []; + } + + const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd]; + if (!atrzStatusMap || atrzSeComCdList.length === 0) return []; + + const seen = new Set(); + const actions: DesignCnstnViewButtonAction[] = []; + + for (const atrzSeComCd of atrzSeComCdList) { + const mappedActions = atrzStatusMap[atrzSeComCd]; + if (!mappedActions) continue; + + for (const action of mappedActions) { + if (seen.has(action)) continue; + seen.add(action); + actions.push(action); + } + } + + return actions; +} + +export function needsRoleUser01AtrzResolve( + dtlInstCd: string | undefined, + prgrsSttsComCd: string, +): boolean { + return ( + dtlInstCd === "ROLE_USER01" && + ROLE_USER01_ATRZ_STATUS_CODES.includes( + prgrsSttsComCd as (typeof ROLE_USER01_ATRZ_STATUS_CODES)[number], + ) + ); +} + +export function getButtonConfigs( + dtlInstCd: string | undefined, + prgrsSttsComCd: string, + atrzSeComCdList?: string[], + options?: GetButtonConfigsOptions, +): DesignCnstnViewButtonAction[] { + if (!dtlInstCd) return []; + + if (needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd)) { + return resolveButtonConfigs( + resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? [], options), + ); + } + + const statusMap = ROLE_STATUS_BUTTON_ACTIONS[dtlInstCd]; + if (!statusMap) return []; + + const actions = statusMap[prgrsSttsComCd]; + if (!actions) return []; + + return resolveButtonConfigs(actions); +} diff --git a/apps/as/src/pages/design-cnstn/lib/design-cnstn-regist-schema.ts b/apps/as/src/pages/design-cnstn/lib/design-cnstn-regist-schema.ts new file mode 100644 index 000000000..3eecde578 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/lib/design-cnstn-regist-schema.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +export const designCnstnRegistSchema = z.object({ + designCnstnMng: z.object({ + bizNm: z.string().min(1, "사업명은 필수입니다"), + bizBgngYr: z.string().min(1, "사업시작연도는 필수입니다"), + bizEndYr: z.string().min(1, "사업종료연도는 필수입니다"), + bizCn: z.string().min(1, "사업내용은 필수입니다"), + taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"), + dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"), + dsgnrTelno: z + .string() + .min(1, "설계자 전화번호는 필수입니다") + .regex(/^[0-9-]+$/, "숫자와 하이픈(-)만 입력 가능합니다"), + dsgnrOgdpNm: z.string().min(1, "설계자 소속은 필수입니다"), + dsgnrEmlAddr: z + .string() + .min(1, "설계자 이메일은 필수입니다") + .email("올바른 이메일 형식이 아닙니다"), + aplcntNm: z.string().optional(), + aplcntMblTelno: z.string().optional(), + aplcntOgdpNm: z.string().optional(), + aplcntEmlAddr: z.string().optional(), + designCnstnDocNo: z.string().optional(), + admdstCd: z.string().optional(), + envoSeComCd: z.string().optional(), + prgrsSttsComCd: z.string().optional(), + atchFileGroupId: z.string().optional(), + envoSeCdNm: 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(), + }), + bzScaleList: z.array( + z.object({ + cnstpSeCd: z.string(), + bscPl: z.number(), + budgetReflct: z.number(), + thtiDsgn: z.number(), + diff: z.number().optional(), + }), + ), + esntlAtchFileList: z.array( + z.object({ + esntlAtchFileId: z.string(), + atchFileGroupId: z.string(), + }), + ), + privacyConsent: z.enum(["Y", "N"], { + required_error: "개인정보 수집/이용 동의는 필수입니다", + }), +}); + +export type DesignCnstnRegistFormData = z.infer; + +export function formatTaskSeComCdForApi(taskSeComCd: string[]): string { + return taskSeComCd.join(","); +} + +export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] { + if (!taskSeComCd) return []; + return taskSeComCd.split(",").filter(Boolean); +} diff --git a/apps/as/src/pages/design-cnstn/lib/form-data-mapper.ts b/apps/as/src/pages/design-cnstn/lib/form-data-mapper.ts new file mode 100644 index 000000000..a34888317 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/lib/form-data-mapper.ts @@ -0,0 +1,97 @@ +import type { IUserInfo } from "@/shared/store"; +import type { DesignCnstnMngDetResponseDto } from "@/entities/design-cnstn"; + +import type { DesignCnstnRegistFormData } from "./design-cnstn-regist-schema"; +import { parseTaskSeComCdFromApi } from "./design-cnstn-regist-schema"; + +function mapEsntlAtchFileList(esntlFileList: DesignCnstnMngDetResponseDto["esntlFileList"]) { + const esntlMap = new Map(); + + 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 mapDetailToFormData(data: DesignCnstnMngDetResponseDto): DesignCnstnRegistFormData { + const detDto = data.detDto; + if (!detDto) { + throw new Error("상세 정보(detDto)가 없습니다."); + } + + const bizSclList = data.bizSclList ?? []; + + return { + designCnstnMng: { + designCnstnDocNo: detDto.designCnstnDocNo, + bizNm: detDto.bizNm ?? "", + bizBgngYr: detDto.bizBgngYr ?? "", + bizEndYr: detDto.bizEndYr ?? "", + bizCn: detDto.bizCn ?? "", + taskSeComCd: parseTaskSeComCdFromApi(detDto.taskSeComCd), + dsgnrNm: detDto.dsgnrNm ?? "", + dsgnrTelno: detDto.dsgnrTelno ?? "", + dsgnrOgdpNm: detDto.dsgnrOgdpNm ?? "", + dsgnrEmlAddr: detDto.dsgnrEmlAddr ?? "", + aplcntNm: detDto.aplcntNm ?? "", + aplcntMblTelno: detDto.aplcntMblTelno ?? "", + aplcntOgdpNm: detDto.aplcntOgdpNm ?? "", + aplcntEmlAddr: detDto.aplcntEmlAddr ?? "", + admdstCd: detDto.admdstCd, + envoSeComCd: detDto.envoSeComCd, + prgrsSttsComCd: detDto.prgrsSttsComCd, + atchFileGroupId: detDto.atchFileGroupId ?? "", + envoSeCdNm: detDto.envoSeCdNm, + taskSeCdNm: detDto.taskSeCdNm, + prgrsSttsCdNm: detDto.prgrsSttsCdNm, + sido: detDto.sido, + gugun: detDto.gugun, + regDt: detDto.regDt, + rgtr: detDto.rgtr, + rgtrNm: detDto.rgtrNm, + mdfcnDt: detDto.mdfcnDt, + }, + bzScaleList: bizSclList.map((item) => ({ + cnstpSeCd: item.cnstpSeCd, + bscPl: item.bscPlanQty, + budgetReflct: item.bgtRfltQty, + thtiDsgn: item.tstmDesignQty, + diff: item.tstmDesignQty - item.bgtRfltQty, + })), + esntlAtchFileList: mapEsntlAtchFileList(data.esntlFileList), + privacyConsent: "Y", + }; +} + +export function getCreateDefaultValues( + userInfo: IUserInfo | null, + taskSeComCd?: string[], +): DesignCnstnRegistFormData { + return { + designCnstnMng: { + bizNm: "", + bizBgngYr: "", + bizEndYr: "", + bizCn: "", + taskSeComCd: taskSeComCd ?? [], + dsgnrNm: "", + dsgnrTelno: "", + dsgnrOgdpNm: "", + dsgnrEmlAddr: "", + aplcntNm: userInfo?.userName ?? "", + aplcntMblTelno: userInfo?.mphn ?? "", + aplcntOgdpNm: userInfo?.ogdpSeNm ?? "", + aplcntEmlAddr: userInfo?.email ?? "", + prgrsSttsComCd: "CV", + atchFileGroupId: "", + }, + bzScaleList: [], + esntlAtchFileList: [], + privacyConsent: "N", + }; +} diff --git a/apps/as/src/pages/design-cnstn/lib/review-opinion-utils.ts b/apps/as/src/pages/design-cnstn/lib/review-opinion-utils.ts new file mode 100644 index 000000000..fd7da632b --- /dev/null +++ b/apps/as/src/pages/design-cnstn/lib/review-opinion-utils.ts @@ -0,0 +1,94 @@ +import type { + DesignCnstnMngDetResponseDto, + DesignCnstnRvwOpnnResponseDto, + DesignCnstnRvwOpnnSaveRequestDto, +} from "@/entities/design-cnstn"; + +export interface ReviewOpinionFormData { + rvwRsltCn: string; + rvwRsltAnmlyYn: "Y" | "N" | ""; + atchFileGroupId: string; + opnrSeComCd: string; + rvwOpnnSn?: number; +} + +export interface ReviewOpinionGetFormDataResult { + formData: ReviewOpinionFormData; + rvwList: DesignCnstnRvwOpnnResponseDto[]; +} + +function hasReviewOpinionContent(formData: ReviewOpinionFormData) { + return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId); +} + +function mapServerItemToSaveDto( + item: DesignCnstnRvwOpnnResponseDto, + designCnstnDocNo: string, +): DesignCnstnRvwOpnnSaveRequestDto | null { + if (item.rvwOpnnSn == null) return null; + + return { + designCnstnDocNo, + rvwOpnnSn: item.rvwOpnnSn, + opnrSeComCd: item.opnrSeComCd, + opnrNm: item.opnrNm, + rvwRsltCn: item.rvwRsltCn, + rvwRsltAnmlyYn: item.rvwRsltAnmlyYn || undefined, + atchFileGroupId: item.atchFileGroupId || undefined, + }; +} + +function mapFormDataToSaveDto( + formData: ReviewOpinionFormData, + designCnstnDocNo: string, + opnrNm?: string, +): DesignCnstnRvwOpnnSaveRequestDto { + const base = { + designCnstnDocNo, + opnrSeComCd: formData.opnrSeComCd, + opnrNm: opnrNm ?? "", + rvwRsltCn: formData.rvwRsltCn, + rvwRsltAnmlyYn: formData.rvwRsltAnmlyYn || undefined, + atchFileGroupId: formData.atchFileGroupId || undefined, + }; + + if (formData.rvwOpnnSn != null) { + return { ...base, rvwOpnnSn: formData.rvwOpnnSn }; + } + + return base as DesignCnstnRvwOpnnSaveRequestDto; +} + +export function toRvwOpnnSaveDto( + data: DesignCnstnMngDetResponseDto, + result: ReviewOpinionGetFormDataResult | null | undefined, + opnrNm?: string, +): DesignCnstnRvwOpnnSaveRequestDto[] | undefined { + if (!result || !data.detDto) return undefined; + + const { formData, rvwList } = result; + const designCnstnDocNo = data.detDto.designCnstnDocNo; + + const list: DesignCnstnRvwOpnnSaveRequestDto[] = rvwList + .map((item) => mapServerItemToSaveDto(item, designCnstnDocNo)) + .filter((item): item is DesignCnstnRvwOpnnSaveRequestDto => item != null); + + if (!hasReviewOpinionContent(formData)) { + return list.length > 0 ? list : undefined; + } + + const formDto = mapFormDataToSaveDto(formData, designCnstnDocNo, opnrNm); + + if (formData.rvwOpnnSn != null) { + const existingIndex = list.findIndex((item) => item.rvwOpnnSn === formData.rvwOpnnSn); + if (existingIndex >= 0) { + list[existingIndex] = formDto; + } else { + list.push(formDto); + } + } else { + list.push(formDto); + } + + return list.length > 0 ? list : undefined; +} diff --git a/apps/as/src/pages/design-cnstn/lib/review-step-utils.ts b/apps/as/src/pages/design-cnstn/lib/review-step-utils.ts new file mode 100644 index 000000000..f96b9b4ef --- /dev/null +++ b/apps/as/src/pages/design-cnstn/lib/review-step-utils.ts @@ -0,0 +1,12 @@ +import type { ReviewStepCode } from "../model/view-types"; + +export function getEditableReviewStep( + dtlInstCd?: string, + prgrsSttsComCd?: string, +): ReviewStepCode | undefined { + if (dtlInstCd === "ROLE_USER01" && prgrsSttsComCd === "CS") { + return "KECO"; + } + + return undefined; +} diff --git a/apps/as/src/pages/design-cnstn/lib/save-payload-utils.ts b/apps/as/src/pages/design-cnstn/lib/save-payload-utils.ts new file mode 100644 index 000000000..48fff8b26 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/lib/save-payload-utils.ts @@ -0,0 +1,71 @@ +import type { + BizSclTreeDto, + BzScaleItem, + DesignCnstnApplySaveRequestDto, + DesignCnstnMngDetResponseDto, + EsntlAtchFileItem, + EsntlAtchFileMngTreeResponseDto, +} from "@/entities/design-cnstn"; + +import type { ReviewStepCode } from "../model/view-types"; + +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 toEsntlAtchFileSaveDto( + esntlFileList: EsntlAtchFileMngTreeResponseDto[], +): EsntlAtchFileItem[] { + const esntlMap = new Map(); + + 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: DesignCnstnMngDetResponseDto, + prgrsSttsComCd: string, +): DesignCnstnApplySaveRequestDto { + const detDto = data.detDto; + if (!detDto) { + throw new Error("상세 정보(detDto)가 없습니다."); + } + + return { + designCnstnMng: { ...detDto, prgrsSttsComCd }, + bzScaleList: toBzScaleSaveDto(data.bizSclList ?? []), + esntlAtchFileList: toEsntlAtchFileSaveDto(data.esntlFileList ?? []), + }; +} + +export function buildTechReviewPayload( + data: DesignCnstnMngDetResponseDto, + prgrsSttsComCd: string, + techReViewSaveCd: string, + reviewStep: ReviewStepCode, + reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null, + opnrNm?: string, +): DesignCnstnApplySaveRequestDto { + return { + ...buildSavePayload(data, prgrsSttsComCd), + techReViewSaveCd, + rvwStpCd: reviewStep, + rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm), + }; +} diff --git a/apps/as/src/pages/design-cnstn/model/columns.tsx b/apps/as/src/pages/design-cnstn/model/columns.tsx new file mode 100644 index 000000000..6cfdb7f70 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/model/columns.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { Fragment } from "react"; + +import { createColumnHelper } from "@tanstack/react-table"; + +import { dayjs } from "@/shared/lib"; +import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn"; +import { Badge } from "@common/components"; + +const columnHelper = createColumnHelper(); + +const MAIN_STEP_LABELS = { + save: "저장", + apply: "신청완료", + review: "설계자문 검토", + reply: "설계검토 회신", +} as const; + +const REVIEW_STATUS_CODES = new Set(["CS", "CR"]); + +type BadgeState = "completed" | "current" | "pending" | "action"; + +interface ProgressBadge { + label: string; + state: BadgeState; +} + +function buildProgressBadges(prgrsSttsComCd: string, prgrsSttsCdNm: string): ProgressBadge[] { + const reviewLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.review; + const replyLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.reply; + + if (prgrsSttsComCd === "CV") { + return [ + { label: MAIN_STEP_LABELS.save, state: "current" }, + { label: MAIN_STEP_LABELS.apply, state: "pending" }, + { label: MAIN_STEP_LABELS.review, state: "pending" }, + { label: MAIN_STEP_LABELS.reply, state: "pending" }, + ]; + } + + if (prgrsSttsComCd === "CT") { + return [ + { label: MAIN_STEP_LABELS.save, state: "completed" }, + { label: MAIN_STEP_LABELS.apply, state: "current" }, + { label: MAIN_STEP_LABELS.review, state: "pending" }, + { label: MAIN_STEP_LABELS.reply, state: "pending" }, + ]; + } + + if (REVIEW_STATUS_CODES.has(prgrsSttsComCd)) { + return [ + { label: MAIN_STEP_LABELS.save, state: "completed" }, + { label: MAIN_STEP_LABELS.apply, state: "completed" }, + { label: reviewLabel, state: "action" }, + { label: MAIN_STEP_LABELS.reply, state: "pending" }, + ]; + } + + if (prgrsSttsComCd === "CP" || prgrsSttsComCd === "EP") { + return [ + { label: MAIN_STEP_LABELS.save, state: "completed" }, + { label: MAIN_STEP_LABELS.apply, state: "completed" }, + { label: MAIN_STEP_LABELS.review, state: "completed" }, + { label: replyLabel, state: "action" }, + ]; + } + + return [{ label: prgrsSttsCdNm || prgrsSttsComCd || "-", state: "current" }]; +} + +function getBadgeColor(state: BadgeState) { + switch (state) { + case "completed": + return "green"; + case "current": + return "blue"; + case "pending": + return "lightGray"; + case "action": + return "orange"; + } +} + +function formatOptional(value: string | undefined) { + return value?.trim() ? value : "-"; +} + +interface ProgressStepBadgeProps { + prgrsSttsComCd: string; + prgrsSttsCdNm: string; +} + +function ProgressStepBadge({ prgrsSttsComCd, prgrsSttsCdNm }: ProgressStepBadgeProps) { + const badges = buildProgressBadges(prgrsSttsComCd, prgrsSttsCdNm); + + return ( +
+ {badges.map((badge, index) => ( + + {index > 0 && >} + {badge.label} + + ))} +
+ ); +} + +export function createDesignCnstnColumns() { + return [ + columnHelper.accessor("sido", { + header: "시도", + meta: { align: "center" }, + cell: ({ getValue }) => formatOptional(getValue()), + }), + columnHelper.accessor("gugun", { + header: "시군구", + meta: { align: "center" }, + cell: ({ getValue }) => formatOptional(getValue()), + }), + columnHelper.accessor("bizNm", { + header: "사업명", + meta: { align: "left" }, + cell: ({ getValue }) => formatOptional(getValue()), + }), + columnHelper.accessor("taskSeCdNm", { + header: "업무구분", + meta: { align: "center" }, + cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd), + }), + columnHelper.accessor("aplcntNm", { + header: "담당자", + meta: { align: "center" }, + cell: ({ getValue }) => formatOptional(getValue()), + }), + columnHelper.accessor("envoSeCdNm", { + header: "해당기관", + meta: { align: "center" }, + cell: ({ getValue }) => formatOptional(getValue()), + }), + columnHelper.display({ + id: "progress", + header: "진행상황", + meta: { align: "center" }, + cell: ({ row }) => ( + + ), + }), + columnHelper.accessor("regDt", { + header: "신청일", + meta: { align: "center" }, + cell: ({ getValue }) => { + const value = getValue(); + return value ? dayjs(value).format("YYYY-MM-DD") : "-"; + }, + }), + ]; +} diff --git a/apps/as/src/pages/design-cnstn/model/view-types.ts b/apps/as/src/pages/design-cnstn/model/view-types.ts new file mode 100644 index 000000000..0f9b93431 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/model/view-types.ts @@ -0,0 +1,15 @@ +export type ReviewStepCode = "LOCAL" | "ENV" | "KECO"; + +export const REVIEW_STEP_LABELS: Record = { + LOCAL: "지자체", + ENV: "환경청", + KECO: "환경공단", +}; + +export const DESIGN_PROGRESS_ORDER = ["CV", "CT", "CS", "CR", "CP", "EP"] as const; + +export const ROLE_TO_REVIEW_STEP: Record = { + ROLE_USER05: "LOCAL", + ROLE_USER03: "ENV", + ROLE_USER01: "KECO", +}; diff --git a/apps/as/src/pages/design-cnstn/ui/components/floating-action-buttons.tsx b/apps/as/src/pages/design-cnstn/ui/components/floating-action-buttons.tsx new file mode 100644 index 000000000..9159fcdae --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/components/floating-action-buttons.tsx @@ -0,0 +1,391 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; + +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useUserStore } from "@/shared/lib"; +import { + type DesignCnstnApplySaveRequestDto, + designCnstnMngBaseKey, + type DesignCnstnMngDetResponseDto, + designCnstnMngService, +} from "@/entities/design-cnstn"; +import { + buildElaprPrgrsSttsListFromAplnRows, + ElaprApprovalDialog, + type ElaprDmndSaveRequestDto, + elaprDmndService, + type ElaprTmpltPickerConfirmPayload, + ElaprTmpltPickerDialog, + hasCurrentUserPendingApprovalRequest, + useElaprDocPrgrsByDocNo, +} from "@/entities/elapr"; +import { Button } from "@common/components"; + +import { + DESIGN_CNSTN_VIEW_BUTTON_ACTION, + type DesignCnstnViewButtonAction, + getButtonConfigs, + needsRoleUser01AtrzResolve, +} from "../../lib/button-config"; +import { buildSavePayload, buildTechReviewPayload } from "../../lib/save-payload-utils"; +import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section"; + +interface FloatingActionButtonsProps { + data: DesignCnstnMngDetResponseDto; + docSeComCd: string; + designCnstnDocNo: string; + prgrsSttsComCd: string; + dtlInstCd: string | undefined; + atrzSeComCdList: string[]; + isRvwLoginUserLoading: boolean; + techReviewOpinionRef?: React.RefObject; + opnrNm?: string; +} + +type ActiveDialog = "elaprTmplt" | "elaprApproval" | null; + +function FloatingActionButtons({ + data, + docSeComCd, + designCnstnDocNo, + prgrsSttsComCd, + dtlInstCd, + atrzSeComCdList, + isRvwLoginUserLoading, + techReviewOpinionRef, + opnrNm, +}: FloatingActionButtonsProps) { + const router = useRouter(); + const { confirm } = useConfirm(); + const queryClient = useQueryClient(); + const userInfo = useUserStore((state) => state.userInfo); + const alert = useAlertStore((state) => state.setMessage); + const [isProcessing, setIsProcessing] = useState(false); + const [activeDialog, setActiveDialog] = useState(null); + + const docNo = data.detDto?.designCnstnDocNo ?? designCnstnDocNo; + const isRegister = data.detDto?.rgtr === userInfo?.userUnqId; + + const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd); + const needsCsApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "CS"; + const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } = + useElaprDocPrgrsByDocNo(needsCsApprovalCheck ? docNo : null); + + const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest( + elaprDocPrgrsList, + userInfo?.userUnqId, + ); + + const actionButtons = useMemo(() => { + if (needsAtrzResolve && isRvwLoginUserLoading) return []; + if (needsCsApprovalCheck && isElaprDocPrgrsLoading) return []; + return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList, { + hasPendingApprovalRequest: needsCsApprovalCheck ? hasPendingApprovalRequest : undefined, + }); + }, [ + atrzSeComCdList, + dtlInstCd, + hasPendingApprovalRequest, + isElaprDocPrgrsLoading, + isRvwLoginUserLoading, + needsAtrzResolve, + needsCsApprovalCheck, + prgrsSttsComCd, + ]); + + const canEdit = + isRegister && actionButtons.some((button) => button === DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg); + + function handleGoToList() { + router.push("/design-cnstn"); + } + + function handleEdit() { + router.push(`/design-cnstn/${designCnstnDocNo}/edit`); + } + + function shouldShowButton(action: DesignCnstnViewButtonAction) { + return actionButtons.some((button) => button === action); + } + + async function executeAction({ + action, + request, + successMessage, + }: { + action: DesignCnstnViewButtonAction; + request?: unknown; + successMessage?: string; + }) { + const handler = buttonActionHandlers[action]; + if (!handler) return; + + setIsProcessing(true); + try { + await handler({ + request, + data, + onSuccess: () => { + if (successMessage) alert(successMessage); + queryClient.invalidateQueries({ + queryKey: [...designCnstnMngBaseKey, "findById", designCnstnDocNo], + }); + }, + onError: (error) => { + let errorMessage = ""; + if (error instanceof AxiosError) { + errorMessage = error.response?.data?.message ?? ""; + } else { + errorMessage = error.message ?? "처리 중 오류가 발생했습니다."; + } + alert(errorMessage); + }, + }); + } finally { + setIsProcessing(false); + } + } + + async function handleStrgClick() { + const payload: DesignCnstnApplySaveRequestDto = buildSavePayload(data, "CV"); + await executeAction({ + action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg, + request: payload, + successMessage: "저장되었습니다.", + }); + } + + async function handleAplyClick() { + const payload: DesignCnstnApplySaveRequestDto = buildSavePayload(data, "CT"); + await executeAction({ + action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply, + request: payload, + successMessage: "신청 완료되었습니다.", + }); + queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey }); + } + + async function handleDelClick() { + if (!(await confirm("삭제하시겠습니까?"))) return; + await executeAction({ + action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.del, + successMessage: "삭제되었습니다.", + }); + router.push("/design-cnstn"); + } + + function getReviewOpinionFormData() { + return techReviewOpinionRef?.current?.getFormData() ?? null; + } + + async function handleElaprTmpltConfirm({ aplnRows }: ElaprTmpltPickerConfirmPayload) { + const payload: ElaprDmndSaveRequestDto = { + elaprDocNo: docNo, + dmndTtl: data.detDto?.bizNm ?? "", + dmndCn: data.detDto?.bizCn ?? "", + elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows), + }; + await executeAction({ + action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.rqstRvw, + request: payload, + successMessage: "검토자지정이 완료되었습니다.", + }); + await queryClient.invalidateQueries({ + queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", docNo], + }); + } + + async function handleRvwStrgClick() { + const payload = buildTechReviewPayload( + data, + "CS", + "SV", + "KECO", + getReviewOpinionFormData(), + opnrNm, + ); + + await executeAction({ + action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwStrg, + request: payload, + successMessage: "저장되었습니다.", + }); + techReviewOpinionRef?.current?.resetForm(); + } + + async function handleRvwCmptnClick() { + const payload = buildTechReviewPayload( + data, + "CS", + "CP", + "KECO", + getReviewOpinionFormData(), + opnrNm, + ); + + await executeAction({ + action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwCmptn, + request: payload, + }); + techReviewOpinionRef?.current?.resetForm(); + setActiveDialog("elaprApproval"); + } + + return ( + <> +
+
+ + {canEdit && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg) && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply) && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.del) && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.rqstRvw) && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwStrg) && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwCmptn) && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzAprv) && ( + + )} + {shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzRply) && ( + + )} +
+
+ + setActiveDialog(null)} + docSeComCd={docSeComCd} + onConfirm={handleElaprTmpltConfirm} + /> + + setActiveDialog(open ? "elaprApproval" : null)} + elaprDocNo={data.detDto?.designCnstnDocNo ?? designCnstnDocNo} + currentUserUuid={userInfo?.userUnqId ?? ""} + onSuccess={() => { + queryClient.invalidateQueries({ + queryKey: [...designCnstnMngBaseKey, "findById", designCnstnDocNo], + }); + }} + /> + + ); +} + +export { FloatingActionButtons }; + +async function handleSaveApply( + context: ActionHandlerContext, +): Promise { + try { + await designCnstnMngService.saveApply(context.request); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } +} + +async function handleDel(context: ActionHandlerContext): Promise { + try { + await designCnstnMngService.delete(context.data.detDto?.designCnstnDocNo ?? ""); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } +} + +async function handleTechReview( + context: ActionHandlerContext, +): Promise { + try { + await designCnstnMngService.techReview( + context.data.detDto?.designCnstnDocNo ?? "", + context.request, + ); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } +} + +async function handleRqstRvw( + context: ActionHandlerContext, +): Promise { + try { + await elaprDmndService.save(context.request); + context.onSuccess?.(); + } catch (error) { + context.onError?.(error as Error); + } +} + +async function handleAtrzAprv(context: ActionHandlerContext): Promise { + context.onSuccess?.(); +} + +async function handleAtrzRply(context: ActionHandlerContext): Promise { + context.onSuccess?.(); +} + +const buttonActionHandlers: Record = { + strg: handleSaveApply, + aply: handleSaveApply, + del: handleDel, + rqstRvw: handleRqstRvw, + rvwStrg: handleTechReview, + rvwCmptn: handleTechReview, + atrzAprv: handleAtrzAprv, + atrzRply: handleAtrzRply, +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +interface ActionHandlerContext { + request: T; + data: DesignCnstnMngDetResponseDto; + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +type ActionHandler = (context: ActionHandlerContext) => Promise; diff --git a/apps/as/src/pages/design-cnstn/ui/design-edit-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-edit-page.tsx new file mode 100644 index 000000000..af2e71ace --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-edit-page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useDesignCnstnMng } from "@/entities/design-cnstn"; +import { Skeleton } from "@common/components"; + +import { DesignFormPage } from "./design-form-page"; + +interface DesignEditPageProps { + id: string; +} + +function DesignEditPage({ id }: DesignEditPageProps) { + const { data, isLoading } = useDesignCnstnMng(id); + + if (isLoading) { + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ); + } + + if (!data?.detDto) { + return
상세 정보를 찾을 수 없습니다.
; + } + + return ; +} + +export { DesignEditPage }; diff --git a/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx new file mode 100644 index 000000000..6c09a5320 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { useRouter } from "next/navigation"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; + +import { useAlertStore, useUserStore } from "@/shared/lib"; +import { + type DesignCnstnApplySaveRequestDto, + designCnstnMngBaseKey, + type DesignCnstnMngDetResponseDto, + designCnstnMngService, +} from "@/entities/design-cnstn"; +import { + AttachmentsFormSection, + BusinessScaleFormSection, + ContactInfoFormSection, + PrivacyConsentSection, +} from "@/widgets/pbadms-sprt"; +import { Button } from "@common/components"; + +import { + type DesignCnstnRegistFormData, + designCnstnRegistSchema, + formatTaskSeComCdForApi, +} from "../lib/design-cnstn-regist-schema"; +import { getCreateDefaultValues, mapDetailToFormData } from "../lib/form-data-mapper"; + +import { BasicInfoFormSection } from "./sections/basic-info-form-section"; + +interface DesignFormPageProps { + id?: string; + mode: "create" | "edit"; + initialData?: DesignCnstnMngDetResponseDto; + taskSeComCd?: string[]; +} + +function DesignFormPage({ id, mode, initialData, taskSeComCd }: DesignFormPageProps) { + const queryClient = useQueryClient(); + const router = useRouter(); + const { confirm } = useConfirm(); + const userInfo = useUserStore((state) => state.userInfo); + const alert = useAlertStore((state) => state.setMessage); + const [isSubmitting, setIsSubmitting] = useState(false); + const isEditMode = mode === "edit"; + + const form = useForm({ + resolver: zodResolver(designCnstnRegistSchema), + mode: "onChange", + defaultValues: + isEditMode && initialData + ? mapDetailToFormData(initialData) + : getCreateDefaultValues(userInfo, taskSeComCd), + }); + + function buildFormPayload( + data: DesignCnstnRegistFormData, + prgrsSttsComCd: "CV" | "CT", + ): DesignCnstnApplySaveRequestDto { + const { taskSeComCd: formTaskSeComCd, ...designCnstnMngRest } = data.designCnstnMng; + + return { + designCnstnMng: { + ...designCnstnMngRest, + taskSeComCd: formatTaskSeComCdForApi(formTaskSeComCd), + designCnstnDocNo: isEditMode ? initialData?.detDto?.designCnstnDocNo : undefined, + prgrsSttsComCd, + }, + bzScaleList: data.bzScaleList, + esntlAtchFileList: data.esntlAtchFileList, + }; + } + + async function submitForm( + data: DesignCnstnRegistFormData, + prgrsSttsComCd: "CV" | "CT", + ): Promise { + return designCnstnMngService.saveApply(buildFormPayload(data, prgrsSttsComCd)); + } + + async function onSave(data: DesignCnstnRegistFormData) { + if (isSubmitting) return; + + try { + setIsSubmitting(true); + const designCnstnDocNo = await submitForm(data, "CV"); + if (!designCnstnDocNo) return; + + alert("저장되었습니다."); + router.push(`/design-cnstn/${designCnstnDocNo}/edit`); + if (isEditMode && id) { + queryClient.invalidateQueries({ + queryKey: [...designCnstnMngBaseKey, "findById", id], + }); + } + } catch (error) { + console.error("저장 실패:", error); + alert("저장 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + } + + async function onApply(data: DesignCnstnRegistFormData) { + if (isSubmitting) return; + + try { + setIsSubmitting(true); + const designCnstnDocNo = await submitForm(data, "CT"); + if (!designCnstnDocNo) return; + + alert("신청 완료되었습니다."); + router.push(`/design-cnstn/${designCnstnDocNo}`); + queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey }); + if (id) { + queryClient.invalidateQueries({ + queryKey: [...designCnstnMngBaseKey, "findById", id], + }); + } + } catch (error) { + console.error("신청 실패:", error); + alert("신청 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + } + + function handleApplyClick() { + form.handleSubmit(onApply)(); + } + + async function handleDelClick() { + if (isSubmitting) return; + + const docNo = initialData?.detDto?.designCnstnDocNo; + if (!docNo) { + alert("문서번호가 없습니다."); + return; + } + + if (!(await confirm("삭제하시겠습니까?"))) return; + + try { + setIsSubmitting(true); + await designCnstnMngService.delete(docNo); + alert("삭제되었습니다."); + router.push("/design-cnstn"); + } catch (error) { + console.error("삭제 실패:", error); + alert("삭제 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + } + + return ( + +
+ + + + + + +
+
+ + + {isEditMode && ( + <> + + + + )} +
+
+ +
+ ); +} + +export { DesignFormPage }; diff --git a/apps/as/src/pages/design-cnstn/ui/design-list-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-list-page.tsx new file mode 100644 index 000000000..9928a83b1 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-list-page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState } from "react"; + +import type { PaginationState } from "@tanstack/react-table"; + +import { + type CmCdAdstDstrComboResponseDto, + useFindGugunCombo, + useFindSidoCombo, +} from "@/entities/adstdstr"; +import { CodeSelect } from "@/entities/code"; +import type { DesignCnstnMngSearchDto } from "@/entities/design-cnstn"; +import { + Button, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; + +import { DesignList } from "./design-list"; + +const DEFAULT_PAGE_INDEX = 0; +const DEFAULT_PAGE_SIZE = 10; +const DEFAULT_SORT = "designCnstnDocNo,desc"; +const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = []; + +const DEFAULT_SEARCH_PARAMS: DesignCnstnMngSearchDto = { + bizNm: "", + taskSeComCd: "", + admdstCd: "", + page: DEFAULT_PAGE_INDEX, + size: DEFAULT_PAGE_SIZE, + sort: DEFAULT_SORT, +}; + +const ALL_SELECT_VALUE = "__ALL__"; + +function DesignListPage() { + const [bizNm, setBizNm] = useState(""); + const [taskSeComCd, setTaskSeComCd] = useState(""); + const [sidoComCd, setSidoComCd] = useState(""); + const [sggComCd, setSggComCd] = useState(""); + const [pagination, setPagination] = useState({ + pageIndex: DEFAULT_PAGE_INDEX, + pageSize: DEFAULT_PAGE_SIZE, + }); + const [searchParams, setSearchParams] = useState(DEFAULT_SEARCH_PARAMS); + + const { data: sidoData } = useFindSidoCombo(); + const { data: gugunData } = useFindGugunCombo(sidoComCd); + const sidoOptions = sidoData ?? EMPTY_ADST_LIST; + const gugunOptions = gugunData ?? EMPTY_ADST_LIST; + + const handleSearch = (event: React.FormEvent) => { + event.preventDefault(); + setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX })); + setSearchParams({ + ...DEFAULT_SEARCH_PARAMS, + bizNm, + taskSeComCd, + admdstCd: sggComCd || sidoComCd || "", + page: DEFAULT_PAGE_INDEX, + size: pagination.pageSize, + }); + }; + + return ( +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + setBizNm(event.target.value)} + placeholder="사업명" + /> +
+ + +
+ + +
+ ); +} + +export { DesignListPage }; diff --git a/apps/as/src/pages/design-cnstn/ui/design-list.tsx b/apps/as/src/pages/design-cnstn/ui/design-list.tsx new file mode 100644 index 000000000..8a0986379 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-list.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; + +import { useDataTable, useUserStore } from "@/shared/lib"; +import { + type DesignCnstnMngResponseDto, + type DesignCnstnMngSearchDto, + useDesignCnstnMngPage, +} from "@/entities/design-cnstn"; +import { TaskSePickerDialog } from "@/widgets/pbadms-sprt"; +import { Button } from "@common/components"; + +import { formatTaskSeComCdForApi } from "../lib/design-cnstn-regist-schema"; +import { createDesignCnstnColumns } from "../model/columns"; + +interface DesignListProps { + searchParams: DesignCnstnMngSearchDto; + pagination: PaginationState; + onPaginationChange: (pagination: PaginationState) => void; +} + +function DesignList({ searchParams, pagination, onPaginationChange }: DesignListProps) { + const userInfo = useUserStore((state) => state.userInfo); + const router = useRouter(); + const [isTaskSeDialogOpen, setIsTaskSeDialogOpen] = useState(false); + + const queryParams = useMemo( + () => ({ + bizNm: searchParams.bizNm, + taskSeComCd: searchParams.taskSeComCd, + admdstCd: searchParams.admdstCd, + page: pagination.pageIndex, + size: pagination.pageSize, + sort: searchParams.sort ?? "designCnstnDocNo,desc", + }), + [searchParams, pagination], + ); + + const pageQuery = useDesignCnstnMngPage(queryParams); + const columns = useMemo(() => createDesignCnstnColumns(), []); + + const handleRowClick = (row: DesignCnstnMngResponseDto) => { + const canEdit = row.prgrsSttsComCd === "CV" && row.rgtr === userInfo?.userUnqId; + + router.push( + canEdit + ? `/design-cnstn/${row.designCnstnDocNo}/edit` + : `/design-cnstn/${row.designCnstnDocNo}`, + ); + }; + + const { tableComponent } = useDataTable({ + query: pageQuery, + tableOptions: { + columns, + state: { pagination }, + onPaginationChange: (updater) => { + const next = typeof updater === "function" ? updater(pagination) : updater; + onPaginationChange(next); + }, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + }, + dataTableOptions: { + viewPagination: true, + onRowClick: handleRowClick, + }, + }); + + const handleConfirmTaskSe = (selectedCodes: string[]) => { + setIsTaskSeDialogOpen(false); + router.push(`/design-cnstn/regist?taskSeComCd=${formatTaskSeComCdForApi(selectedCodes)}`); + }; + + return ( + <> +
+
+

설계자문 목록

+ +
+ {tableComponent} +
+ + + ); +} + +export { DesignList }; diff --git a/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx new file mode 100644 index 000000000..fa8130898 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-regist-page.tsx @@ -0,0 +1,11 @@ +import { DesignFormPage } from "./design-form-page"; + +interface DesignRegistPageProps { + taskSeComCd?: string[]; +} + +function DesignRegistPage({ taskSeComCd }: DesignRegistPageProps) { + return ; +} + +export { DesignRegistPage }; diff --git a/apps/as/src/pages/design-cnstn/ui/design-view-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-view-page.tsx new file mode 100644 index 000000000..aefd42317 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/design-view-page.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useMemo, useRef } from "react"; + +import { useUserStore } from "@/shared/lib"; +import { useDesignCnstnMng } from "@/entities/design-cnstn"; +import { ElaprDocPrgrsStatus } from "@/entities/elapr"; +import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw"; +import { SectionCollapsible } from "@/widgets/pbadms-sprt"; +import { Skeleton } from "@common/components"; + +import { needsRoleUser01AtrzResolve } from "../lib/button-config"; +import { getEditableReviewStep } from "../lib/review-step-utils"; + +import { FloatingActionButtons } from "./components/floating-action-buttons"; +import { AttachmentsSection } from "./sections/attachments-section"; +import { BasicInfoSection } from "./sections/basic-info-section"; +import { BusinessScaleSection } from "./sections/business-scale-section"; +import { ContactInfoSection } from "./sections/contact-info-section"; +import { + TechReviewOpinionSection, + type TechReviewOpinionSectionHandle, +} from "./sections/tech-review-opinion-section"; + +interface DesignViewPageProps { + id: string; +} + +function DesignViewPage({ id }: DesignViewPageProps) { + const userInfo = useUserStore((state) => state.userInfo); + const techReviewOpinionRef = useRef(null); + const { data, isLoading, isError } = useDesignCnstnMng(id); + + const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || ""; + const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd); + const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList( + {}, + { enabled: needsAtrzResolve }, + ); + const atrzSeComCdList = useMemo( + () => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [], + [rvwLoginUserData], + ); + + if (isLoading) { + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ ); + } + + if (isError || !data?.detDto) { + return
상세 정보를 찾을 수 없습니다.
; + } + + const detDto = data.detDto; + const bizSclList = data.bizSclList ?? []; + const esntlFileList = data.esntlFileList ?? []; + const etcFileList = data.etcFileList ?? []; + const rvwList = data.rvwList ?? []; + + const editableReviewStep = getEditableReviewStep( + userInfo?.dtlInstCd, + detDto.prgrsSttsComCd || "", + ); + + return ( +
+ + + + + + + + + +
+ ); +} + +export { DesignViewPage }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/attachments-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/attachments-section.tsx new file mode 100644 index 000000000..082881231 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/attachments-section.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { formatFileSize } from "@/shared/lib/file-utils"; +import { useDownloadAttachmentByAtflCd } from "@/entities/attach"; +import type { + AtflMngClientResponseDto, + EsntlAtchFileMngTreeResponseDto, +} from "@/entities/design-cnstn"; +import { SectionCollapsible } from "@/widgets/pbadms-sprt"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; + +interface AttachmentsSectionProps { + esntlFileList: EsntlAtchFileMngTreeResponseDto[]; + etcFileList: AtflMngClientResponseDto[]; +} + +function AttachmentsSection({ esntlFileList, etcFileList }: AttachmentsSectionProps) { + const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd(); + + return ( + <> + {esntlFileList.length > 0 && ( + +
+
+ + + 필수 자료명 + 파일명 + 크기 + + + + {esntlFileList.map((file) => ( + + + {file.esntlAtchFileNm} + {file.esntlAtchYn === "Y" ? ( + * + ) : null} + + + {file.atflCd ? ( + downloadAttachment(file.atflCd ?? "")} + target="_blank" + rel="noreferrer" + > + {file.orgnlFileNm || "다운로드"} + + ) : ( + 첨부없음 + )} + + {formatFileSize(file.atflFlieSz)} + + ))} + +
+
+ + )} + + +
+ + + + 파일명 + 크기 + + + + {etcFileList.length === 0 ? ( + + + 등록된 기타첨부파일이 없습니다. + + + ) : ( + etcFileList.map((file) => ( + + + {file.atflCd ? ( + downloadAttachment(file.atflCd)} + target="_blank" + rel="noreferrer" + > + {file.orgnlFileNm || file.physFileNm || file.atflId} + + ) : ( + {file.orgnlFileNm || file.physFileNm || file.atflId} + )} + + + {formatFileSize(file.atflFlieSz)} + + + )) + )} + +
+
+
+ + ); +} + +export { AttachmentsSection }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx new file mode 100644 index 000000000..124370ddc --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-form-section.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; + +import { useUserStore } from "@/shared/lib"; +import { Field, FieldError, FieldGroup } from "@/shared/ui/field"; +import { findCodeLabel, useCodeList } from "@/entities/code"; +import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker"; +import { SectionCollapsible } from "@/widgets/pbadms-sprt"; +import { + Button, + Input, + Table, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import type { DesignCnstnRegistFormData } from "../../lib/design-cnstn-regist-schema"; + +function BasicInfoFormSection() { + const userInfo = useUserStore((state) => state.userInfo); + const { control, watch, setValue } = useFormContext(); + const [isPickerOpen, setIsPickerOpen] = useState(false); + const taskSeComCd = watch("designCnstnMng.taskSeComCd"); + const { data: taskOptions } = useCodeList("CNFM"); + + function handleBnsSelect(data: BnsApplyData) { + setValue("designCnstnMng.bizNm", data.bizNm); + setValue("designCnstnMng.bizBgngYr", data.bizBgngYr); + setValue("designCnstnMng.bizEndYr", data.bizEndYr); + setValue("designCnstnMng.bizCn", data.bizCn); + } + + const taskSeComCdLabel = + taskSeComCd + .map((code) => findCodeLabel(taskOptions, code)) + .filter(Boolean) + .join(", ") || "-"; + + return ( + + + + + + + + + + + + 사업명 + + + ( + +
+ + +
+ +
+ )} + /> +
+
+ + 사업기간 + + + ( + + + + + )} + /> + ~ + ( + + + + + )} + /> + + + + + 사업내용 + + ( + + + + + )} + /> + + + + 구분 + {taskSeComCdLabel} + 신청 지자체 + {userInfo?.instSeNm} + +
+
+ + setIsPickerOpen(false)} + onSelect={handleBnsSelect} + /> +
+ ); +} + +export { BasicInfoFormSection }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/basic-info-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-section.tsx new file mode 100644 index 000000000..c5db62a50 --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/basic-info-section.tsx @@ -0,0 +1,50 @@ +"use client"; + +import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn"; +import { SectionCollapsible } from "@/widgets/pbadms-sprt"; +import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; + +interface BasicInfoSectionProps { + det: DesignCnstnMngResponseDto; +} + +function BasicInfoSection({ det }: BasicInfoSectionProps) { + return ( + + + + + + + + + + + 사업명 + {det.bizNm || "-"} + 사업기간 + {(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")} + + + 사업내용 + {det.bizCn || "-"} + + + 환경청구분 + {det.envoSeCdNm || "-"} + 구분 + {det.taskSeCdNm || "-"} + + + 신청지자체 + + {det.sido || "-"} {det.gugun || "-"} + + + +
+
+ ); +} + +export { BasicInfoSection }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx new file mode 100644 index 000000000..c18e6cf3e --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/business-scale-section.tsx @@ -0,0 +1,70 @@ +"use client"; + +import type { BizSclTreeDto } from "@/entities/design-cnstn"; +import { fmtNumber, indentPx, SectionCollapsible } from "@/widgets/pbadms-sprt"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; + +interface BusinessScaleSectionProps { + bizSclList: BizSclTreeDto[]; +} + +function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) { + return ( + +
+ + + + 구분 + 기본계획(A) + 예산반영(B) + 금회설계시(C) + 증감(C-B) + + + + {bizSclList.length === 0 ? ( + + + 조회된 사업규모 항목이 없습니다. + + + ) : ( + bizSclList.map((node) => { + const pl = indentPx(node.indCnstpSeCdNm); + const bscPlanQty = Number(node.bscPlanQty) || 0; + const budgetReflct = Number(node.bgtRfltQty) || 0; + const tstmDesignQty = Number(node.tstmDesignQty) || 0; + const diff = tstmDesignQty - budgetReflct; + + return ( + + + {node.cnstpSeCdNm} + + +
{fmtNumber(bscPlanQty)}
+
+ +
{fmtNumber(budgetReflct)}
+
+ +
{fmtNumber(tstmDesignQty)}
+
+ +
+ {diff > 0 ? `+${fmtNumber(diff)}` : fmtNumber(diff)} +
+
+
+ ); + }) + )} +
+
+
+
+ ); +} + +export { BusinessScaleSection }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/contact-info-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/contact-info-section.tsx new file mode 100644 index 000000000..ae495450e --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/contact-info-section.tsx @@ -0,0 +1,56 @@ +"use client"; + +import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn"; +import { SectionCollapsible } from "@/widgets/pbadms-sprt"; +import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; + +interface ContactInfoSectionProps { + det: DesignCnstnMngResponseDto; +} + +function ContactInfoSection({ det }: ContactInfoSectionProps) { + return ( + + + + {det.aplcntNm && ( + <> + + 신청자 + 이름 + {det.aplcntNm || "-"} + 연락처 + {det.aplcntMblTelno || "-"} + + + 소속 + {det.aplcntOgdpNm || "-"} + 이메일 + {det.aplcntEmlAddr || "-"} + + + )} + {det.dsgnrNm && ( + <> + + 설계자 + 이름 + {det.dsgnrNm || "-"} + 연락처 + {det.dsgnrTelno || "-"} + + + 소속 + {det.dsgnrOgdpNm || "-"} + 이메일 + {det.dsgnrEmlAddr || "-"} + + + )} + +
+
+ ); +} + +export { ContactInfoSection }; diff --git a/apps/as/src/pages/design-cnstn/ui/sections/tech-review-opinion-section.tsx b/apps/as/src/pages/design-cnstn/ui/sections/tech-review-opinion-section.tsx new file mode 100644 index 000000000..9c6985ffb --- /dev/null +++ b/apps/as/src/pages/design-cnstn/ui/sections/tech-review-opinion-section.tsx @@ -0,0 +1,342 @@ +"use client"; + +import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; + +import { PROXY_HOST } from "@/shared/config"; +import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib"; +import { Field, FieldLabel, FieldSet } from "@/shared/ui/field"; +import { attachService } from "@/entities/attach"; +import type { DesignCnstnRvwOpnnResponseDto } from "@/entities/design-cnstn"; +import { SectionCollapsible } from "@/widgets/pbadms-sprt"; +import { + Button, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Textarea, +} from "@common/components"; + +import type { + ReviewOpinionFormData, + ReviewOpinionGetFormDataResult, +} from "../../lib/review-opinion-utils"; +import type { ReviewStepCode } from "../../model/view-types"; +import { REVIEW_STEP_LABELS } from "../../model/view-types"; + +const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::"; + +interface TechReviewOpinionSectionProps { + rvwList: DesignCnstnRvwOpnnResponseDto[]; + editableReviewStep?: ReviewStepCode; + atrzSeComCdList?: string[]; + isAtrzLoading?: boolean; +} + +export interface TechReviewOpinionSectionHandle { + getFormData: () => ReviewOpinionGetFormDataResult | null; + resetForm: () => void; +} + +interface TechReviewOpinionTableProps { + items: DesignCnstnRvwOpnnResponseDto[]; +} + +interface UploadedOpinionFile { + fileName: string; + fileKey: string; + atflId?: string; + atflCd?: string; +} + +const EMPTY_FORM_DATA: ReviewOpinionFormData = { + rvwRsltCn: "", + rvwRsltAnmlyYn: "", + atchFileGroupId: "", + opnrSeComCd: "", +}; + +function createRvwOpnnUploadKey(file: File) { + return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`; +} + +function hasFormContent(formData: ReviewOpinionFormData) { + return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId); +} + +const TechReviewOpinionSection = forwardRef< + TechReviewOpinionSectionHandle, + TechReviewOpinionSectionProps +>(function TechReviewOpinionSection({ + rvwList, + editableReviewStep, + atrzSeComCdList = [], + isAtrzLoading = false, +}, ref) { + const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02"); + const [formData, setFormData] = useState({ + ...EMPTY_FORM_DATA, + opnrSeComCd: editableReviewStep ?? "", + }); + const [uploadedFile, setUploadedFile] = useState(null); + + useEffect(() => { + setFormData((prev) => ({ + ...prev, + opnrSeComCd: editableReviewStep ?? "", + })); + }, [editableReviewStep]); + + const { uploadFileAsync, isUploading, uploads } = useTus({ + onSuccess: (atflCd, atflId, _file, fileKey) => { + if (!atflCd || !atflId) return; + + setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd })); + setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev)); + }, + onError: (_error, _file, fileKey) => { + setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev)); + }, + }); + + useImperativeHandle( + ref, + () => ({ + getFormData: () => { + if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) { + return null; + } + + return { + formData: { + ...formData, + opnrSeComCd: editableReviewStep, + }, + rvwList, + }; + }, + resetForm: () => { + setFormData({ + ...EMPTY_FORM_DATA, + opnrSeComCd: editableReviewStep ?? "", + }); + setUploadedFile(null); + }, + }), + [canWriteOpinion, editableReviewStep, formData, rvwList], + ); + + const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO"); + const hasKecoSection = + kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion); + + if (!hasKecoSection) { + return null; + } + + async function handleFileUpload(files: FileList | null) { + if (!files || files.length === 0) return; + + const file = files[0]; + const fileKey = createRvwOpnnUploadKey(file); + const existingAtflCd = formData.atchFileGroupId; + + setUploadedFile({ fileName: file.name, fileKey }); + + try { + await uploadFileAsync(file, { + fileKey, + metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined, + }); + } catch { + // onError callback handles cleanup + } + } + + async function handleFileRemove() { + if (!uploadedFile) return; + + uploads[uploadedFile.fileKey]?.abort(); + + if (uploadedFile.atflId) { + try { + await attachService.deleteAttachmentByAtflId(uploadedFile.atflId); + } catch (error) { + console.error("검토의견 첨부파일 삭제 실패:", error); + } + } + + setUploadedFile(null); + setFormData((prev) => ({ ...prev, atchFileGroupId: "" })); + } + + function renderOpinionForm(reviewStep: ReviewStepCode) { + if (editableReviewStep !== reviewStep || !canWriteOpinion) return null; + + const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false; + + return ( +
+

검토의견 작성

+ + + + 의견내용 + +