diff --git a/.gitignore b/.gitignore index ee2cfd052..871b209f0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ settings.json .agent apps/**/tsconfig.tsbuildinfo +packages/**/tsconfig.tsbuildinfo logs/ diff --git a/apps/as/app/(with-base-layout)/acntg-code-management/page.tsx b/apps/as/app/(with-base-layout)/acntg-code-management/page.tsx deleted file mode 100644 index c22d0f0b4..000000000 --- a/apps/as/app/(with-base-layout)/acntg-code-management/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { AcntgCodeManagementPage as default } from "@/pages/acntg-code-management"; diff --git a/apps/as/app/(with-base-layout)/cnstp-code-management/page.tsx b/apps/as/app/(with-base-layout)/cnstp-code-management/page.tsx deleted file mode 100644 index d20a49e4f..000000000 --- a/apps/as/app/(with-base-layout)/cnstp-code-management/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { CnstpCodeManagementPage as default } from "@/pages/cnstp-code-management"; diff --git a/apps/as/app/(with-base-layout)/code-management/page.tsx b/apps/as/app/(with-base-layout)/code-management/page.tsx new file mode 100644 index 000000000..7a4daf43f --- /dev/null +++ b/apps/as/app/(with-base-layout)/code-management/page.tsx @@ -0,0 +1 @@ +export { CodeManagementPage as default } from "@/pages/code-management"; diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx b/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx new file mode 100644 index 000000000..f1371598e --- /dev/null +++ b/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx @@ -0,0 +1,10 @@ +import { FnrsEditPage } from "@/pages/fnrs-cnsltn"; + +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)/fnrs-cnsltn/[id]/page.tsx b/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx new file mode 100644 index 000000000..ec9a3911e --- /dev/null +++ b/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx @@ -0,0 +1,10 @@ +import { FnrsViewPage } from "@/pages/fnrs-cnsltn"; + +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)/fnrs-cnsltn/page.tsx b/apps/as/app/(with-base-layout)/fnrs-cnsltn/page.tsx new file mode 100644 index 000000000..734a0afd9 --- /dev/null +++ b/apps/as/app/(with-base-layout)/fnrs-cnsltn/page.tsx @@ -0,0 +1,3 @@ +import { FnrsListPage } from "@/pages/fnrs-cnsltn"; + +export default FnrsListPage; diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/regist/page.tsx b/apps/as/app/(with-base-layout)/fnrs-cnsltn/regist/page.tsx new file mode 100644 index 000000000..4360a6979 --- /dev/null +++ b/apps/as/app/(with-base-layout)/fnrs-cnsltn/regist/page.tsx @@ -0,0 +1,12 @@ +import { FnrsRegistPage } from "@/pages/fnrs-cnsltn"; +import { parseTaskSeComCdFromApi } from "@/pages/fnrs-cnsltn/lib/fnrs-cnsltn-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/(with-base-layout)/instl-sncn/[id]/edit/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/[id]/edit/page.tsx new file mode 100644 index 000000000..35d336bd3 --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn/[id]/edit/page.tsx @@ -0,0 +1,10 @@ +import { InstlEditPage } from "@/pages/instl-sncn"; + +interface InstlSncnEditRouteProps { + params: Promise<{ id: string }>; +} + +export default async function Page({ params }: InstlSncnEditRouteProps) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/[id]/modify/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/[id]/modify/page.tsx deleted file mode 100644 index e4718eceb..000000000 --- a/apps/as/app/(with-base-layout)/instl-sncn/[id]/modify/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { CnfmModifyPage } from "@/pages/instl-sncn"; - -export default async function InstlSncnModifyPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - return ; -} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx index 3f25c4a3e..89cd7a329 100644 --- a/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx +++ b/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx @@ -1,10 +1,6 @@ -import { CnfmViewPage } from "@/pages/instl-sncn"; - -export default async function InstlSncnViewPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - return ; -} +import { InstlViewPage } from "@/pages/instl-sncn"; + +export default async function InstlSncnDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/page.tsx index 7cd7bf82e..a3d6e05ac 100644 --- a/apps/as/app/(with-base-layout)/instl-sncn/page.tsx +++ b/apps/as/app/(with-base-layout)/instl-sncn/page.tsx @@ -1,3 +1,3 @@ -import { CnfmListPage } from "@/pages/instl-sncn"; - -export default CnfmListPage; +import { InstlListPage } from "@/pages/instl-sncn"; + +export default InstlListPage; diff --git a/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx new file mode 100644 index 000000000..476d0b7c2 --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx @@ -0,0 +1,14 @@ +import { InstlRegistPage } from "@/pages/instl-sncn"; + +interface InstlSncnRegistRouteProps { + searchParams: Promise<{ taskSeComCd?: string }>; +} + +export default async function Page({ searchParams }: InstlSncnRegistRouteProps) { + const params = await searchParams; + const taskSeComCd = params.taskSeComCd + ? params.taskSeComCd.split(",").filter(Boolean) + : undefined; + + return ; +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx deleted file mode 100644 index ffe6a71a4..000000000 --- a/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { CnfmRegistPage } from "@/pages/instl-sncn"; - -export default function Page(props: any) { - return ; -} diff --git a/apps/as/app/api/auth/logout/route.ts b/apps/as/app/api/auth/logout/route.ts new file mode 100644 index 000000000..d040a8f49 --- /dev/null +++ b/apps/as/app/api/auth/logout/route.ts @@ -0,0 +1,18 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +import { + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, + REFRESH_TOKEN, +} from "@/shared/config"; + +export async function POST() { + const cookieStore = await cookies(); + cookieStore.delete(ACCESS_TOKEN); + cookieStore.delete(REFRESH_TOKEN); + cookieStore.delete(AUTH_USER_ID); + cookieStore.delete(CLAIM_NAME); + return NextResponse.json({ message: "Logged out" }, { status: 200 }); +} diff --git a/apps/as/package.json b/apps/as/package.json index 86cfd8071..e7fa14d85 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -31,6 +31,7 @@ "@tanstack/react-query-devtools": "^5.75.5", "@tanstack/react-table": "^8.21.2", "axios": "^1.8.1", + "class-variance-authority": "^0.7.1", "dayjs": "^1.11.13", "js-cookie": "^3.0.5", "lucide-react": "^0.474.0", diff --git a/apps/as/src/app/layouts/base-layout.tsx b/apps/as/src/app/layouts/base-layout.tsx index fc84a5e3e..ebe24b41c 100644 --- a/apps/as/src/app/layouts/base-layout.tsx +++ b/apps/as/src/app/layouts/base-layout.tsx @@ -7,6 +7,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useMenuStore } from "@/shared/lib"; import { PageLayout } from "@/shared/ui/page-layout"; +import { authService } from "@/entities/auth"; import type { MenuItem } from "@/entities/menu"; import { BaseLayout } from "@common/components"; @@ -32,6 +33,7 @@ export function BaseLayoutWrapper({ children }: BaseLayoutWrapperProps) { return ( diff --git a/apps/as/src/entities/acntg/api/acntg-service.ts b/apps/as/src/entities/acntg/api/acntg-service.ts index 957113dec..fd620009e 100644 --- a/apps/as/src/entities/acntg/api/acntg-service.ts +++ b/apps/as/src/entities/acntg/api/acntg-service.ts @@ -12,7 +12,7 @@ import { type AcntgSeMngTreeDto, } from "../model/acntg-interface"; -const BASE = ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE; +const BASE = ENDPOINT.AS_SERVICE.ACNTG_SE; async function findAll(params: AcntgSeMngSearchDto): Promise> { const response = await client.get>(BASE, { params }); diff --git a/apps/as/src/entities/acntg/api/queries.ts b/apps/as/src/entities/acntg/api/queries.ts index c2bcc7f9f..db00d2317 100644 --- a/apps/as/src/entities/acntg/api/queries.ts +++ b/apps/as/src/entities/acntg/api/queries.ts @@ -10,7 +10,7 @@ import type { import { acntgSeMngService } from "./acntg-service"; -const acntgSeMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE] as const; +const acntgSeMngBaseKey = [ENDPOINT.AS_SERVICE.ACNTG_SE] as const; export function useAcntgSeMngPage(params: AcntgSeMngSearchDto) { return useQuery({ diff --git a/apps/as/src/entities/acntg/model/acntg-interface.ts b/apps/as/src/entities/acntg/model/acntg-interface.ts index c1457208d..ea64fc218 100644 --- a/apps/as/src/entities/acntg/model/acntg-interface.ts +++ b/apps/as/src/entities/acntg/model/acntg-interface.ts @@ -16,6 +16,7 @@ export interface AcntgSeMngResponseDto { cdLvNo: number; useYn: string; expln?: string; + sortSeq?: number; rgtr?: string; rgtrNm?: string; mdfr?: string; @@ -39,6 +40,7 @@ export interface AcntgSeMngSaveRequestDto { cdLvNo: number; useYn?: string; expln?: string; + sortSeq?: number; } /** PUT /{acntgSeCd} 요청 (mode는 서비스에서 U로 설정) */ diff --git a/apps/as/src/entities/adstdstr/api/adstdstr-service.ts b/apps/as/src/entities/adstdstr/api/adstdstr-service.ts new file mode 100644 index 000000000..b4bb11fae --- /dev/null +++ b/apps/as/src/entities/adstdstr/api/adstdstr-service.ts @@ -0,0 +1,26 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + CmCdAdstDstrComboResponseDto, + CmCdAdstDstrService, +} from "../model/adstdstr-interface"; + +const BASE = ENDPOINT.AS_SERVICE.ADSTDSTR; + +async function findSidoCombo(): Promise { + const response = await client.get(`${BASE}/sido`); + return response.data; +} + +async function findGugunCombo(sido: string): Promise { + const response = await client.get(`${BASE}/gugun`, { + params: { sido }, + }); + return response.data; +} + +export const cmCdAdstDstrService = { + findSidoCombo, + findGugunCombo, +} satisfies CmCdAdstDstrService; diff --git a/apps/as/src/entities/adstdstr/api/queries.ts b/apps/as/src/entities/adstdstr/api/queries.ts new file mode 100644 index 000000000..353e25cfe --- /dev/null +++ b/apps/as/src/entities/adstdstr/api/queries.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import { cmCdAdstDstrService } from "./adstdstr-service"; + +const cmCdAdstDstrBaseKey = [ENDPOINT.AS_SERVICE.ADSTDSTR] as const; + +export function useFindSidoCombo() { + return useQuery({ + queryKey: [...cmCdAdstDstrBaseKey, "findSidoCombo"], + queryFn: () => cmCdAdstDstrService.findSidoCombo(), + }); +} + +export function useFindGugunCombo(sido: string) { + return useQuery({ + queryKey: [...cmCdAdstDstrBaseKey, "findGugunCombo", sido], + queryFn: () => cmCdAdstDstrService.findGugunCombo(sido), + enabled: Boolean(sido), + }); +} diff --git a/apps/as/src/entities/adstdstr/index.ts b/apps/as/src/entities/adstdstr/index.ts new file mode 100644 index 000000000..da1dbd132 --- /dev/null +++ b/apps/as/src/entities/adstdstr/index.ts @@ -0,0 +1,6 @@ +export { cmCdAdstDstrService } from "./api/adstdstr-service"; +export { useFindGugunCombo, useFindSidoCombo } from "./api/queries"; +export type { + CmCdAdstDstrComboResponseDto, + CmCdAdstDstrService, +} from "./model/adstdstr-interface"; diff --git a/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts b/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts new file mode 100644 index 000000000..72cb673dc --- /dev/null +++ b/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts @@ -0,0 +1,11 @@ +/** 백엔드 CmCdAdstDstrComboResponseDto */ +export interface CmCdAdstDstrComboResponseDto { + adstDstrCd: string; + adstDstrNm: string; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface CmCdAdstDstrService { + findSidoCombo(): Promise; + findGugunCombo(sido: string): Promise; +} diff --git a/apps/as/src/entities/attach/api/attach-service.ts b/apps/as/src/entities/attach/api/attach-service.ts new file mode 100644 index 000000000..0af0a916d --- /dev/null +++ b/apps/as/src/entities/attach/api/attach-service.ts @@ -0,0 +1,22 @@ +import { AxiosResponse } from "axios"; + +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { AttachService } from "../model/attach-interface"; + +async function deleteAttachmentByAtflId(atflId: string): Promise { + if (!atflId) return; + await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`); +} + +async function downloadAttachmentByAtflCd(atflCd: string): Promise { + return await client.get(`${ENDPOINT.ATTACH_SERVICE.DOWNLOAD}/${atflCd}`, { + responseType: "blob", + }); +} + +export const attachService = { + deleteAttachmentByAtflId, + downloadAttachmentByAtflCd, +} satisfies AttachService; diff --git a/apps/as/src/entities/attach/api/queries.ts b/apps/as/src/entities/attach/api/queries.ts new file mode 100644 index 000000000..ed9efefbf --- /dev/null +++ b/apps/as/src/entities/attach/api/queries.ts @@ -0,0 +1,39 @@ +import { useMutation } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import { parseFilenameFromResponse } from "../lib/parse-filename-from-response"; + +import { attachService } from "./attach-service"; + +const attachBaseKey = [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS] as const; +const attachDownloadBaseKey = [ENDPOINT.ATTACH_SERVICE.DOWNLOAD] as const; + +export function useDeleteAttachment() { + return useMutation({ + mutationKey: [...attachBaseKey, "delete"], + mutationFn: (atflId: string) => attachService.deleteAttachmentByAtflId(atflId), + }); +} + +export function useDownloadAttachmentByAtflCd() { + return useMutation({ + mutationKey: [...attachDownloadBaseKey, "download"], + mutationFn: async (atflCd: string) => { + const response = await attachService.downloadAttachmentByAtflCd(atflCd); + const filename = parseFilenameFromResponse(response); + + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + }, + }); +} diff --git a/apps/as/src/entities/attach/index.ts b/apps/as/src/entities/attach/index.ts new file mode 100644 index 000000000..37258e6e5 --- /dev/null +++ b/apps/as/src/entities/attach/index.ts @@ -0,0 +1,3 @@ +export { attachService } from "./api/attach-service"; +export { useDeleteAttachment, useDownloadAttachmentByAtflCd } from "./api/queries"; +export type { Attach, AttachService } from "./model/attach-interface"; diff --git a/apps/as/src/entities/attach/lib/parse-filename-from-response.ts b/apps/as/src/entities/attach/lib/parse-filename-from-response.ts new file mode 100644 index 000000000..cd8a5aa3d --- /dev/null +++ b/apps/as/src/entities/attach/lib/parse-filename-from-response.ts @@ -0,0 +1,11 @@ +import type { AxiosResponse } from "axios"; + +export function parseFilenameFromResponse(response: AxiosResponse): string { + const contentDisposition = response.headers["content-disposition"]; + if (!contentDisposition) return "download"; + + const filenameMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/); + if (!filenameMatch?.[1]) return "download"; + + return decodeURIComponent(filenameMatch[1]); +} diff --git a/apps/as/src/entities/attach/model/attach-interface.ts b/apps/as/src/entities/attach/model/attach-interface.ts new file mode 100644 index 000000000..d6b0a08d2 --- /dev/null +++ b/apps/as/src/entities/attach/model/attach-interface.ts @@ -0,0 +1,22 @@ +import { AxiosResponse } from "axios"; + +/** 백엔드 AtflMngResponseDto */ +export interface Attach { + atflCd: string; + atflSn: number; + atflId: string; + physFileNm: string; + orgnlFileNm: string; + atflFlieSz: number; + atflType: string; + dwnldNocs: number; + delYn: string; + lnkgDmnId: string; + lnkgDmnNm: string; + regDt: string; +} + +export interface AttachService { + deleteAttachmentByAtflId: (atflId: string) => Promise; + downloadAttachmentByAtflCd: (atflCd: string) => Promise; +} diff --git a/apps/as/src/entities/auth/api/auth-service.ts b/apps/as/src/entities/auth/api/auth-service.ts index 0c1730054..80090f82f 100644 --- a/apps/as/src/entities/auth/api/auth-service.ts +++ b/apps/as/src/entities/auth/api/auth-service.ts @@ -1,8 +1,19 @@ -import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config"; -import { getRemainingMs } from "@common/auth"; +import { + ACCESS_TOKEN, + AUTH_LOGIN_REDIRECT_URL, + AUTH_USER_ID, + CLAIM_NAME, + ENDPOINT, +} from "@/shared/config"; +import { type AuthPayload, type AuthService, getRemainingMs } from "@common/auth"; -import { clearAuthToken, setToken, setupRefreshTimer } from "../lib/token-manager"; -import type { AuthPayload, AuthService } from "../model/auth-interface"; +import { + clearAuthToken, + clearRefreshTimer, + removeToken, + setToken, + setupRefreshTimer, +} from "../lib/token-manager"; async function login(payload: AuthPayload): Promise { const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { @@ -66,14 +77,28 @@ async function silentRefresh(): Promise { } async function logout(): Promise { + try { + await fetch("/api/auth/logout", { + method: "POST", + credentials: "include", + }); + } catch (error) { + console.error("Logout API failed:", error); + } + clearAuthToken(); + removeToken("site-id-key"); + removeToken("site-theme-key"); + if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("auth:logout")); + window.location.href = AUTH_LOGIN_REDIRECT_URL; } } export const authService: AuthService = { login, silentRefresh, + declineSessionExtend: clearRefreshTimer, logout, }; diff --git a/apps/as/src/entities/auth/lib/token-manager.ts b/apps/as/src/entities/auth/lib/token-manager.ts index d449e711b..cd468f1aa 100644 --- a/apps/as/src/entities/auth/lib/token-manager.ts +++ b/apps/as/src/entities/auth/lib/token-manager.ts @@ -8,6 +8,7 @@ export const { setToken, removeToken, setupRefreshTimer, + clearRefreshTimer, clearAuthToken, getAccessToken, getRefreshToken, diff --git a/apps/as/src/entities/auth/model/auth-interface.ts b/apps/as/src/entities/auth/model/auth-interface.ts deleted file mode 100644 index fb6e10630..000000000 --- a/apps/as/src/entities/auth/model/auth-interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface AuthPayload { - userId: string; - password: string; -} - -export interface AuthService { - login(payload: AuthPayload): Promise; - silentRefresh(): Promise; - logout(): Promise; -} diff --git a/apps/as/src/entities/bns-if/api/bns-if-service.ts b/apps/as/src/entities/bns-if/api/bns-if-service.ts new file mode 100644 index 000000000..320a01efc --- /dev/null +++ b/apps/as/src/entities/bns-if/api/bns-if-service.ts @@ -0,0 +1,47 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import { buildBnsSearchDto } from "../lib/build-bns-search-dto"; +import type { + BnsDetailResponseWrapperDto, + BnsIfService, + BnsListResponseWrapperDto, + BnsSearchDto, + EmCodeListResponseDto, +} from "../model/bns-if-interface"; + +const BASE = ENDPOINT.AS_SERVICE.BNS_IF; + +async function getBnsList(params: BnsSearchDto): Promise { + const response = await client.get(`${BASE}/bns`, { + params: buildBnsSearchDto(params), + }); + return response.data; +} + +async function getBnsDetail( + gbn: string, + yr: string, + seqNo: string, +): Promise { + const response = await client.get(`${BASE}/bns/detail`, { + params: { gbn, yr, seqNo }, + }); + return response.data; +} + +async function getEmCodeList( + dpmCd?: string, + lclgvrnmntCd?: string, +): Promise { + const response = await client.get(`${BASE}/emCodeList`, { + params: { dpmCd, lclgvrnmntCd }, + }); + return response.data; +} + +export const bnsIfService = { + getBnsList, + getBnsDetail, + getEmCodeList, +} satisfies BnsIfService; diff --git a/apps/as/src/entities/bns-if/api/queries.ts b/apps/as/src/entities/bns-if/api/queries.ts new file mode 100644 index 000000000..7ab0b46a3 --- /dev/null +++ b/apps/as/src/entities/bns-if/api/queries.ts @@ -0,0 +1,33 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { BnsSearchDto } from "../model/bns-if-interface"; + +import { bnsIfService } from "./bns-if-service"; + +const bnsIfBaseKey = [ENDPOINT.AS_SERVICE.BNS_IF] as const; + +export function useBnsList(params: BnsSearchDto, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: [...bnsIfBaseKey, "getBnsList", params], + queryFn: () => bnsIfService.getBnsList(params), + enabled: options?.enabled, + placeholderData: keepPreviousData, + }); +} + +export function useBnsDetail(gbn: string, yr: string, seqNo: string) { + return useQuery({ + queryKey: [...bnsIfBaseKey, "getBnsDetail", gbn, yr, seqNo], + queryFn: () => bnsIfService.getBnsDetail(gbn, yr, seqNo), + enabled: Boolean(gbn && yr && seqNo), + }); +} + +export function useEmCodeList(dpmCd?: string, lclgvrnmntCd?: string) { + return useQuery({ + queryKey: [...bnsIfBaseKey, "getEmCodeList", dpmCd, lclgvrnmntCd], + queryFn: () => bnsIfService.getEmCodeList(dpmCd, lclgvrnmntCd), + }); +} diff --git a/apps/as/src/entities/bns-if/index.ts b/apps/as/src/entities/bns-if/index.ts new file mode 100644 index 000000000..430f4a611 --- /dev/null +++ b/apps/as/src/entities/bns-if/index.ts @@ -0,0 +1,13 @@ +export { bnsIfService } from "./api/bns-if-service"; +export { useBnsDetail, useBnsList, useEmCodeList } from "./api/queries"; +export { buildBnsSearchDto } from "./lib/build-bns-search-dto"; +export type { + BnsDetailResponseDto, + BnsDetailResponseWrapperDto, + BnsIfService, + BnsListResponseDto, + BnsListResponseWrapperDto, + BnsSearchDto, + EmCodeItemDto, + EmCodeListResponseDto, +} from "./model/bns-if-interface"; diff --git a/apps/as/src/entities/bns-if/lib/build-bns-search-dto.ts b/apps/as/src/entities/bns-if/lib/build-bns-search-dto.ts new file mode 100644 index 000000000..8e95b27dd --- /dev/null +++ b/apps/as/src/entities/bns-if/lib/build-bns-search-dto.ts @@ -0,0 +1,20 @@ +import type { BnsSearchDto } from "../model/bns-if-interface"; + +export function buildBnsSearchDto(input: { + cPage: number; + searchStartYear?: string; + searchSido?: string; + searchGugun?: string; +}): BnsSearchDto { + const dto: BnsSearchDto = { cPage: input.cPage }; + + const year = input.searchStartYear?.trim(); + const sido = input.searchSido?.trim(); + const gugun = input.searchGugun?.trim(); + + if (year) dto.searchStartYear = year; + if (sido) dto.searchSido = sido; + if (gugun) dto.searchGugun = gugun; + + return dto; +} diff --git a/apps/as/src/entities/bns-if/model/bns-if-interface.ts b/apps/as/src/entities/bns-if/model/bns-if-interface.ts new file mode 100644 index 000000000..e13ab8500 --- /dev/null +++ b/apps/as/src/entities/bns-if/model/bns-if-interface.ts @@ -0,0 +1,125 @@ +/** 백엔드 BnsSearchDto */ +export interface BnsSearchDto { + searchStartYear?: string; + cPage: number; + searchSido?: string; + searchGugun?: string; +} + +/** 백엔드 BnsListResponseWrapperDto */ +export interface BnsListResponseWrapperDto { + list: BnsListResponseDto[]; + cnt: number; +} + +/** 백엔드 BnsListResponseDto */ +export interface BnsListResponseDto { + cPage: number; + gbn: number; + chk: number; + seqNo: string; + rgnNm: string; + sido: string; + gugun: string; + bzCd: string; + bzNm: string; + yr: string; + strtYr: string; + edYr: string; + totBzamt: string; +} + +/** 백엔드 BnsDetailResponseWrapperDto */ +export interface BnsDetailResponseWrapperDto { + saup: unknown; + detail: BnsDetailResponseDto; +} + +/** 백엔드 BnsDetailResponseDto */ +export interface BnsDetailResponseDto { + cPage: number; + seqNo: string; + confBzCd: string; + chngDgr: string; + yr: string; + sido: string; + gugun: string; + rvbsnNm: string; + lgbsnNm: string; + mdmbsnNm: string; + lclgvrnmntNm: string; + accKdNm: string; + bzKdNm: string; + untBzNm: string; + dtlBzNm: string; + mevBzNm: string; + ddtlBzNm: string; + strtYr: string; + edYr: string; + aidRt: string; + bzStatus: string; + bzCd: string; + bzNm: string; + bzLoc: string; + efflWqltStndrdNm: string; + goalYear: string; + bssProm: string; + uppPlan: string; + techInsp: string; + bssCont: string; + rmk: string; + inflBscFctNm: string; + inflBscFctCd: string; + trtMtd: string; + swrExtns: string; + swrChng: string; + swrRnvt: string; + totFamNum: string; + pumpCnt: string; + rvWbsn: string; + rvText: string; + imprvExpctFlw: string; + imprvExpctWqlt: string; + trtplntPlEfflWqlt: string; + wqltAvg: string; + wqltHgh: string; + wqltLow: string; + stdCost: string; + bzEtc: string; + bscFctCd: string; + bscFctNm: string; + fctCpc: string; + fctCpcAfr: string; + trtDstrArea: string; + trtDstrAreaAfr: string; + swrTrtPopl: string; + swrTrtPoplAfr: string; + adstPopl: string; +} + +/** 백엔드 EmCodeItemDto */ +export interface EmCodeItemDto { + code: string; + data: string; + upCode: string; +} + +/** 백엔드 EmCodeListResponseDto */ +export interface EmCodeListResponseDto { + rvbsnList: EmCodeItemDto[]; + lgbsnList: EmCodeItemDto[]; + mdmbsnList: EmCodeItemDto[]; + accList: EmCodeItemDto[]; + bzList: EmCodeItemDto[]; + untList: EmCodeItemDto[]; + dtlList: EmCodeItemDto[]; + mevList: EmCodeItemDto[]; + ddtlList: EmCodeItemDto[]; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface BnsIfService { + getBnsList(params: BnsSearchDto): Promise; + getBnsDetail(gbn: string, yr: string, seqNo: string): Promise; + getEmCodeList(dpmCd?: string, lclgvrnmntCd?: string): Promise; +} diff --git a/apps/as/src/entities/cklst/api/cklst-artcl-service.ts b/apps/as/src/entities/cklst/api/cklst-artcl-service.ts index dda8defd5..9561a659c 100644 --- a/apps/as/src/entities/cklst/api/cklst-artcl-service.ts +++ b/apps/as/src/entities/cklst/api/cklst-artcl-service.ts @@ -11,33 +11,25 @@ import type { CklstArtclUpdateRequest, } from "../model/cklst-artcl-interface"; +const BASE = ENDPOINT.AS_SERVICE.CKLST_ARTCL; + async function findPage(params: CklstArtclSearchParams): Promise> { - const response = await client.get>( - ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL, - { params }, - ); + const response = await client.get>(BASE, { params }); return response.data; } async function findByCklstSn(cklstSn: number): Promise { - const response = await client.get( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/by-cklst/${cklstSn}`, - ); + const response = await client.get(`${BASE}/by-cklst/${cklstSn}`); return response.data; } async function findById(cklstArtclSn: number, cklstSn: number): Promise { - const response = await client.get( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`, - ); + const response = await client.get(`${BASE}/${cklstArtclSn}/${cklstSn}`); return response.data; } async function create(body: CklstArtclSaveRequest): Promise { - const response = await client.post( - ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL, - body, - ); + const response = await client.post(BASE, body); return response.data; } @@ -46,17 +38,12 @@ async function update( cklstSn: number, body: CklstArtclUpdateRequest, ): Promise { - const response = await client.put( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`, - body, - ); + const response = await client.put(`${BASE}/${cklstArtclSn}/${cklstSn}`, body); return response.data; } async function remove(cklstArtclSn: number, cklstSn: number): Promise { - const response = await client.delete( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`, - ); + const response = await client.delete(`${BASE}/${cklstArtclSn}/${cklstSn}`); return response.data; } diff --git a/apps/as/src/entities/cklst/api/cklst-mng-service.ts b/apps/as/src/entities/cklst/api/cklst-mng-service.ts index fdae4e8ff..8ad2e0556 100644 --- a/apps/as/src/entities/cklst/api/cklst-mng-service.ts +++ b/apps/as/src/entities/cklst/api/cklst-mng-service.ts @@ -11,40 +11,39 @@ import type { CklstTreeSearchParams, } from "../model/cklst-mng-interface"; +const BASE = ENDPOINT.AS_SERVICE.CKLST; + async function findPage(params: CklstMngSearchParams): Promise> { - const response = await client.get>(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, { + const response = await client.get>(BASE, { params, }); return response.data; } async function findById(cklstSn: number): Promise { - const response = await client.get(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`); + const response = await client.get(`${BASE}/${cklstSn}`); return response.data; } async function findTree(params: CklstTreeSearchParams): Promise { - const response = await client.get(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/list/tree`, { + const response = await client.get(`${BASE}/list/tree`, { params, }); return response.data; } async function create(body: CklstMngSaveRequest): Promise { - const response = await client.post(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, body); + const response = await client.post(BASE, body); return response.data; } async function update(cklstSn: number, body: CklstMngSaveRequest): Promise { - const response = await client.put( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`, - body, - ); + const response = await client.put(`${BASE}/${cklstSn}`, body); return response.data; } async function remove(cklstSn: number): Promise { - const response = await client.delete(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`); + const response = await client.delete(`${BASE}/${cklstSn}`); return response.data; } diff --git a/apps/as/src/entities/cklst/api/cklst-rvw-service.ts b/apps/as/src/entities/cklst/api/cklst-rvw-service.ts index 0039bfbc1..e184d45aa 100644 --- a/apps/as/src/entities/cklst/api/cklst-rvw-service.ts +++ b/apps/as/src/entities/cklst/api/cklst-rvw-service.ts @@ -11,18 +11,15 @@ import type { CklstRvwUpdateRequest, } from "../model/cklst-rvw-interface"; +const BASE = ENDPOINT.AS_SERVICE.CKLST_RVW; + async function findPage(params: CklstRvwSearchParams): Promise> { - const response = await client.get>( - ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW, - { params }, - ); + const response = await client.get>(BASE, { params }); return response.data; } async function findByArtcl(cklstSn: number, cklstArtclSn: number): Promise { - const response = await client.get( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/by-artcl/${cklstSn}/${cklstArtclSn}`, - ); + const response = await client.get(`${BASE}/by-artcl/${cklstSn}/${cklstArtclSn}`); return response.data; } @@ -31,17 +28,12 @@ async function findById( cklstArtclSn: number, cklstSn: number, ): Promise { - const response = await client.get( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`, - ); + const response = await client.get(`${BASE}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`); return response.data; } async function create(body: CklstRvwSaveRequest): Promise { - const response = await client.post( - ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW, - body, - ); + const response = await client.post(BASE, body); return response.data; } @@ -52,7 +44,7 @@ async function update( body: CklstRvwUpdateRequest, ): Promise { const response = await client.put( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`, + `${BASE}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`, body, ); return response.data; @@ -64,7 +56,7 @@ async function remove( cklstSn: number, ): Promise { const response = await client.delete( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`, + `${BASE}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`, ); return response.data; } diff --git a/apps/as/src/entities/cklst/api/queries.ts b/apps/as/src/entities/cklst/api/queries.ts index 33280c41a..afa3e02e3 100644 --- a/apps/as/src/entities/cklst/api/queries.ts +++ b/apps/as/src/entities/cklst/api/queries.ts @@ -18,9 +18,9 @@ import { cklstArtclService } from "./cklst-artcl-service"; import { cklstMngService } from "./cklst-mng-service"; import { cklstRvwService } from "./cklst-rvw-service"; -const cklstMngBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST] as const; -const cklstArtclBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL] as const; -const cklstRvwBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW] as const; +const cklstMngBaseKey = [ENDPOINT.AS_SERVICE.CKLST] as const; +const cklstArtclBaseKey = [ENDPOINT.AS_SERVICE.CKLST_ARTCL] as const; +const cklstRvwBaseKey = [ENDPOINT.AS_SERVICE.CKLST_RVW] as const; export function useCklstMngPage(params: CklstMngSearchParams) { return useQuery({ diff --git a/apps/as/src/entities/cnstp/api/cnstp-service.ts b/apps/as/src/entities/cnstp/api/cnstp-service.ts index f39be006a..292ac7069 100644 --- a/apps/as/src/entities/cnstp/api/cnstp-service.ts +++ b/apps/as/src/entities/cnstp/api/cnstp-service.ts @@ -12,7 +12,7 @@ import { type CnstpSeMngTreeDto, } from "../model/cnstp-interface"; -const BASE = ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE; +const BASE = ENDPOINT.AS_SERVICE.CNSTP_SE; async function findAll(params: CnstpSeMngSearchDto): Promise> { const response = await client.get>(BASE, { params }); diff --git a/apps/as/src/entities/cnstp/api/queries.ts b/apps/as/src/entities/cnstp/api/queries.ts index 2ebb44dea..a419af10c 100644 --- a/apps/as/src/entities/cnstp/api/queries.ts +++ b/apps/as/src/entities/cnstp/api/queries.ts @@ -10,7 +10,7 @@ import type { import { cnstpSeMngService } from "./cnstp-service"; -const cnstpSeMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE] as const; +const cnstpSeMngBaseKey = [ENDPOINT.AS_SERVICE.CNSTP_SE] as const; export function useCnstpSeMngPage(params: CnstpSeMngSearchDto) { return useQuery({ diff --git a/apps/as/src/entities/cnstp/model/cnstp-interface.ts b/apps/as/src/entities/cnstp/model/cnstp-interface.ts index def16901a..eabd12f46 100644 --- a/apps/as/src/entities/cnstp/model/cnstp-interface.ts +++ b/apps/as/src/entities/cnstp/model/cnstp-interface.ts @@ -16,6 +16,7 @@ export interface CnstpSeMngResponseDto { cdLvNo: number; useYn: string; expln?: string; + sortSeq?: number; rgtr?: string; rgtrNm?: string; mdfr?: string; @@ -39,6 +40,7 @@ export interface CnstpSeMngSaveRequestDto { cdLvNo?: number; useYn?: string; expln?: string; + sortSeq?: number; } /** PUT /{cnstpSeCd} 요청 (mode는 서비스에서 U로 설정) */ diff --git a/apps/as/src/entities/elapr/api/elapr-dmnd-service.ts b/apps/as/src/entities/elapr/api/elapr-dmnd-service.ts index 8b2e67fa2..368600110 100644 --- a/apps/as/src/entities/elapr/api/elapr-dmnd-service.ts +++ b/apps/as/src/entities/elapr/api/elapr-dmnd-service.ts @@ -1,17 +1,67 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; import type { - ElaprDmndResponse, - ElaprDmndSaveRequest, + ElaprDmndResponseDto, + ElaprDmndSaveRequestDto, + ElaprDmndSearchDto, ElaprDmndService, + ElaprDocPrgrsResponseDto, } from "../model/elapr-dmnd-interface"; -async function create(body: ElaprDmndSaveRequest): Promise { - const response = await client.post(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, body); +const BASE = ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND; + +async function findPage(params: ElaprDmndSearchDto): Promise> { + const response = await client.get>(BASE, { params }); return response.data; } -export const ElaprDmndApi: ElaprDmndService = { - create, -}; +async function findById(elaprDmndId: string): Promise { + const response = await client.get(`${BASE}/${elaprDmndId}`); + return response.data; +} + +async function save(body: ElaprDmndSaveRequestDto): Promise { + const response = await client.post(BASE, body); + return response.data; +} + +async function update( + elaprDmndId: string, + body: ElaprDmndSaveRequestDto, +): Promise { + const response = await client.put(`${BASE}/${elaprDmndId}`, body); + return response.data; +} + +async function updateStatus( + elaprDmndId: string, + atrzSttsComCd: string, +): Promise { + const response = await client.put(`${BASE}/${elaprDmndId}/status`, null, { + params: { atrzSttsComCd }, + }); + return response.data; +} + +async function remove(elaprDmndId: string): Promise { + await client.delete(`${BASE}/${elaprDmndId}`); +} + +async function findDocPrgrsByDocNo(elaprDocNo: string): Promise { + const response = await client.get( + `${BASE}/sanc/docprgrs/${elaprDocNo}`, + ); + return response.data; +} + +export const elaprDmndService = { + findPage, + findById, + save, + update, + updateStatus, + remove, + findDocPrgrsByDocNo, +} satisfies ElaprDmndService; diff --git a/apps/as/src/entities/elapr/api/elapr-prgrs-service.ts b/apps/as/src/entities/elapr/api/elapr-prgrs-service.ts deleted file mode 100644 index 97f925113..000000000 --- a/apps/as/src/entities/elapr/api/elapr-prgrs-service.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { client } from "@/shared/api"; - -export async function processElaprPrgrs(elaprPrgrsId: string, atrzPrgrsSttsComCd = "ATRZ_STTS_003"): Promise { - await client.put(`/as-elapr-service/api/v1/elapr-prgrs-stts/${elaprPrgrsId}/process`, { - atrzPrgrsSttsComCd, - }); -} diff --git a/apps/as/src/entities/elapr/api/elapr-prgrs-stts-service.ts b/apps/as/src/entities/elapr/api/elapr-prgrs-stts-service.ts new file mode 100644 index 000000000..25991ab97 --- /dev/null +++ b/apps/as/src/entities/elapr/api/elapr-prgrs-stts-service.ts @@ -0,0 +1,102 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import type { + ElaprApproverChangeRequestDto, + ElaprApproverInsertRequestDto, + ElaprPrgrsSttsMyDocSearchParams, + ElaprPrgrsSttsResponseDto, + ElaprPrgrsSttsSaveRequestDto, + ElaprPrgrsSttsService, + ElaprProcessRequestDto, +} from "../model/elapr-prgrs-stts-interface"; + +const BASE = ENDPOINT.AS_ELAPR_SERVICE.ELAPR_PRGRS_STTS; + +async function findAllByDmndId(elaprDmndId?: string): Promise { + const response = await client.get(BASE, { + params: elaprDmndId ? { elaprDmndId } : undefined, + }); + return response.data; +} + +async function findById(elaprPrgrsId: string): Promise { + const response = await client.get(`${BASE}/${elaprPrgrsId}`); + return response.data; +} + +async function save(body: ElaprPrgrsSttsSaveRequestDto): Promise { + const response = await client.post(BASE, body); + return response.data; +} + +async function process( + elaprPrgrsId: string, + body: ElaprProcessRequestDto, +): Promise { + const response = await client.put( + `${BASE}/${elaprPrgrsId}/process`, + body, + ); + return response.data; +} + +async function processByDocNoAndAprvrUuid( + elaprDocNo: string, + aprvrUuid: string, + body: ElaprProcessRequestDto, +): Promise { + const response = await client.put( + `${BASE}/doc/${elaprDocNo}/aprvr/${aprvrUuid}/process`, + body, + ); + return response.data; +} + +async function deletePrgrsStts(elaprPrgrsId: string): Promise { + await client.delete(`${BASE}/${elaprPrgrsId}`); +} + +async function updateApprover( + elaprPrgrsId: string, + body: ElaprApproverChangeRequestDto, +): Promise { + const response = await client.put( + `${BASE}/${elaprPrgrsId}/approver`, + body, + ); + return response.data; +} + +async function insertApprover( + elaprDmndId: string, + body: ElaprApproverInsertRequestDto, +): Promise { + const response = await client.post( + `${BASE}/${elaprDmndId}/approver/insert`, + body, + ); + return response.data; +} + +async function findMyElaperDocList( + params: ElaprPrgrsSttsMyDocSearchParams, +): Promise> { + const response = await client.get>(`${BASE}/my/doc`, { + params, + }); + return response.data; +} + +export const elaprPrgrsSttsService = { + findAllByDmndId, + findById, + save, + process, + processByDocNoAndAprvrUuid, + delete: deletePrgrsStts, + updateApprover, + insertApprover, + findMyElaperDocList, +} satisfies ElaprPrgrsSttsService; diff --git a/apps/as/src/entities/elapr/api/queries.ts b/apps/as/src/entities/elapr/api/queries.ts index 59a6bb91b..8100a4d3f 100644 --- a/apps/as/src/entities/elapr/api/queries.ts +++ b/apps/as/src/entities/elapr/api/queries.ts @@ -8,6 +8,14 @@ import type { ElaprAplnSaveRequest, ElaprAplnUpdateRequest, } from "../model/elapr-apln-interface"; +import type { ElaprDmndSaveRequestDto, ElaprDmndSearchDto } from "../model/elapr-dmnd-interface"; +import type { + ElaprApproverChangeRequestDto, + ElaprApproverInsertRequestDto, + ElaprPrgrsSttsMyDocSearchParams, + ElaprPrgrsSttsSaveRequestDto, + ElaprProcessRequestDto, +} from "../model/elapr-prgrs-stts-interface"; import type { ElaprTmpltSaveRequest, ElaprTmpltSearchParams, @@ -15,10 +23,19 @@ import type { } from "../model/elapr-tmplt-interface"; import { elaprAplnService } from "./elapr-apln-service"; +import { elaprDmndService } from "./elapr-dmnd-service"; +import { elaprPrgrsSttsService } from "./elapr-prgrs-stts-service"; import { elaprTmpltService } from "./elapr-tmplt-service"; const elaprTmpltBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT] as const; const elaprAplnBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN] as const; +const elaprDmndBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND] as const; +const elaprPrgrsSttsBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_PRGRS_STTS] as const; + +function invalidateElaprPrgrsSttsQueries(queryClient: ReturnType) { + queryClient.invalidateQueries({ queryKey: elaprPrgrsSttsBaseKey }); + queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey }); +} export function useElaprTmpltPage(params: ElaprTmpltSearchParams) { return useQuery({ @@ -50,13 +67,8 @@ export function useUpdateElaprTmplt() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ - elaprTmpltId, - body, - }: { - elaprTmpltId: string; - body: ElaprTmpltUpdateRequest; - }) => elaprTmpltService.update(elaprTmpltId, body), + mutationFn: ({ elaprTmpltId, body }: { elaprTmpltId: string; body: ElaprTmpltUpdateRequest }) => + elaprTmpltService.update(elaprTmpltId, body), onSuccess: (_data, { elaprTmpltId }) => { queryClient.invalidateQueries({ queryKey: elaprTmpltBaseKey }); queryClient.invalidateQueries({ @@ -99,12 +111,8 @@ export function useSaveMultiElaprApln() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ - body, - }: { - body: ElaprAplnSaveRequest[]; - elaprTmpltId: string; - }) => elaprAplnService.saveMulti(body), + mutationFn: ({ body }: { body: ElaprAplnSaveRequest[]; elaprTmpltId: string }) => + elaprAplnService.saveMulti(body), onSuccess: (_data, { elaprTmpltId }) => { queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey }); queryClient.invalidateQueries({ @@ -118,13 +126,8 @@ export function useUpdateElaprApln() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ - aplnId, - body, - }: { - aplnId: string; - body: ElaprAplnUpdateRequest; - }) => elaprAplnService.update(aplnId, body), + mutationFn: ({ aplnId, body }: { aplnId: string; body: ElaprAplnUpdateRequest }) => + elaprAplnService.update(aplnId, body), onSuccess: (_data, { body, aplnId }) => { queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey }); queryClient.invalidateQueries({ @@ -141,12 +144,8 @@ export function useDeleteElaprApln() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ - aplnId, - }: { - aplnId: string; - elaprTmpltId: string; - }) => elaprAplnService.remove(aplnId), + mutationFn: ({ aplnId }: { aplnId: string; elaprTmpltId: string }) => + elaprAplnService.remove(aplnId), onSuccess: (_data, { elaprTmpltId }) => { queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey }); queryClient.invalidateQueries({ @@ -160,12 +159,8 @@ export function useReorderElaprApln() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ - body, - }: { - body: ElaprAplnReorderRequest; - elaprTmpltId: string; - }) => elaprAplnService.reorder(body), + mutationFn: ({ body }: { body: ElaprAplnReorderRequest; elaprTmpltId: string }) => + elaprAplnService.reorder(body), onSuccess: (_data, { elaprTmpltId }) => { queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey }); queryClient.invalidateQueries({ @@ -174,3 +169,193 @@ export function useReorderElaprApln() { }, }); } + +export function useElaprDmndPage(params: ElaprDmndSearchDto) { + return useQuery({ + queryKey: [...elaprDmndBaseKey, "findPage", params], + queryFn: () => elaprDmndService.findPage(params), + }); +} + +export function useElaprDmnd(elaprDmndId: string | null | undefined) { + return useQuery({ + queryKey: [...elaprDmndBaseKey, "findById", elaprDmndId], + queryFn: () => elaprDmndService.findById(elaprDmndId!), + enabled: Boolean(elaprDmndId), + }); +} + +export function useElaprDocPrgrsByDocNo(elaprDocNo: string | null | undefined) { + return useQuery({ + queryKey: [...elaprDmndBaseKey, "findDocPrgrsByDocNo", elaprDocNo], + queryFn: () => elaprDmndService.findDocPrgrsByDocNo(elaprDocNo!), + enabled: Boolean(elaprDocNo), + }); +} + +export function useSaveElaprDmnd() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: ElaprDmndSaveRequestDto) => elaprDmndService.save(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey }); + }, + }); +} + +export function useUpdateElaprDmnd() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ elaprDmndId, body }: { elaprDmndId: string; body: ElaprDmndSaveRequestDto }) => + elaprDmndService.update(elaprDmndId, body), + onSuccess: (_data, { elaprDmndId }) => { + queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...elaprDmndBaseKey, "findById", elaprDmndId], + }); + }, + }); +} + +export function useUpdateElaprDmndStatus() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ elaprDmndId, atrzSttsComCd }: { elaprDmndId: string; atrzSttsComCd: string }) => + elaprDmndService.updateStatus(elaprDmndId, atrzSttsComCd), + onSuccess: (_data, { elaprDmndId }) => { + queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...elaprDmndBaseKey, "findById", elaprDmndId], + }); + }, + }); +} + +export function useRemoveElaprDmnd() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (elaprDmndId: string) => elaprDmndService.remove(elaprDmndId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey }); + }, + }); +} + +export function useElaprPrgrsSttsByDmndId(elaprDmndId: string | null | undefined) { + return useQuery({ + queryKey: [...elaprPrgrsSttsBaseKey, "findAllByDmndId", elaprDmndId], + queryFn: () => elaprPrgrsSttsService.findAllByDmndId(elaprDmndId!), + enabled: Boolean(elaprDmndId), + }); +} + +export function useElaprPrgrsStts(elaprPrgrsId: string | null | undefined) { + return useQuery({ + queryKey: [...elaprPrgrsSttsBaseKey, elaprPrgrsId], + queryFn: () => elaprPrgrsSttsService.findById(elaprPrgrsId!), + enabled: Boolean(elaprPrgrsId), + }); +} + +export function useElaprPrgrsSttsMyDocPage(params: ElaprPrgrsSttsMyDocSearchParams) { + return useQuery({ + queryKey: [...elaprPrgrsSttsBaseKey, "findMyElaperDocList", params], + queryFn: () => elaprPrgrsSttsService.findMyElaperDocList(params), + }); +} + +export function useSaveElaprPrgrsStts() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: ElaprPrgrsSttsSaveRequestDto) => elaprPrgrsSttsService.save(body), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} + +export function useProcessElaprPrgrsStts() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + elaprPrgrsId, + body, + }: { + elaprPrgrsId: string; + body: ElaprProcessRequestDto; + }) => elaprPrgrsSttsService.process(elaprPrgrsId, body), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} + +export function useProcessElaprPrgrsSttsByDoc() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + elaprDocNo, + aprvrUuid, + body, + }: { + elaprDocNo: string; + aprvrUuid: string; + body: ElaprProcessRequestDto; + }) => elaprPrgrsSttsService.processByDocNoAndAprvrUuid(elaprDocNo, aprvrUuid, body), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} + +export function useDeleteElaprPrgrsStts() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (elaprPrgrsId: string) => elaprPrgrsSttsService.delete(elaprPrgrsId), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} + +export function useUpdateElaprPrgrsSttsApprover() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + elaprPrgrsId, + body, + }: { + elaprPrgrsId: string; + body: ElaprApproverChangeRequestDto; + }) => elaprPrgrsSttsService.updateApprover(elaprPrgrsId, body), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} + +export function useInsertElaprPrgrsSttsApprover() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + elaprDmndId, + body, + }: { + elaprDmndId: string; + body: ElaprApproverInsertRequestDto; + }) => elaprPrgrsSttsService.insertApprover(elaprDmndId, body), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} diff --git a/apps/as/src/entities/elapr/index.ts b/apps/as/src/entities/elapr/index.ts index caa639e51..42673c996 100644 --- a/apps/as/src/entities/elapr/index.ts +++ b/apps/as/src/entities/elapr/index.ts @@ -1,33 +1,91 @@ -export { elaprAplnService } from "./api/elapr-apln-service"; -export { elaprTmpltService } from "./api/elapr-tmplt-service"; -export { - useCreateElaprTmplt, - useDeleteElaprApln, - useDeleteElaprTmplt, - useElaprApln, - useElaprAplnPage, - useElaprTmplt, - useElaprTmpltPage, - useReorderElaprApln, - useSaveMultiElaprApln, - useUpdateElaprApln, - useUpdateElaprTmplt, -} from "./api/queries"; -export { - ELAPR_APLN_SAVE_MODE, - type ElaprApln, - type ElaprAplnListParams, - type ElaprAplnReorderItem, - type ElaprAplnReorderRequest, - type ElaprAplnSaveMode, - type ElaprAplnSaveRequest, - type ElaprAplnService, - type ElaprAplnUpdateRequest, -} from "./model/elapr-apln-interface"; -export type { - ElaprTmplt, - ElaprTmpltSaveRequest, - ElaprTmpltSearchParams, - ElaprTmpltService, - ElaprTmpltUpdateRequest, -} from "./model/elapr-tmplt-interface"; +export { elaprAplnService } from "./api/elapr-apln-service"; +export { elaprDmndService } from "./api/elapr-dmnd-service"; +export { elaprPrgrsSttsService } from "./api/elapr-prgrs-stts-service"; +export { elaprTmpltService } from "./api/elapr-tmplt-service"; +export { + useCreateElaprTmplt, + useDeleteElaprApln, + useDeleteElaprPrgrsStts, + useDeleteElaprTmplt, + useElaprApln, + useElaprAplnPage, + useElaprDmnd, + useElaprDmndPage, + useElaprDocPrgrsByDocNo, + useElaprPrgrsStts, + useElaprPrgrsSttsByDmndId, + useElaprPrgrsSttsMyDocPage, + useElaprTmplt, + useElaprTmpltPage, + useInsertElaprPrgrsSttsApprover, + useProcessElaprPrgrsStts, + useProcessElaprPrgrsSttsByDoc, + useRemoveElaprDmnd, + useReorderElaprApln, + useSaveElaprDmnd, + useSaveElaprPrgrsStts, + useSaveMultiElaprApln, + useUpdateElaprApln, + useUpdateElaprDmnd, + useUpdateElaprDmndStatus, + useUpdateElaprPrgrsSttsApprover, + useUpdateElaprTmplt, +} from "./api/queries"; +export { + assignAtrzSeqNumbers, + buildSaveMultiPayload, + hasUnsavedChanges, +} from "./lib/apln-save-utils"; +export { + getVisibleRows, + validateAllRowsForSave, + validateAplnRow, + validateAtrzOrder, + validateUniqueSeqNo, +} from "./lib/apln-validators"; +export { + buildElaprPrgrsSttsListFromApln, + buildElaprPrgrsSttsListFromAplnRows, +} from "./lib/dmnd-save-utils"; +export { + findCurrentUserPendingApprovalRequest, + hasCurrentUserPendingApprovalRequest, +} from "./lib/approval-request-utils"; +export { createDraftRowKey, useAplnRows } from "./lib/use-apln-rows"; +export { useDndSensors } from "./lib/use-dnd-sensors"; +export { + ELAPR_APLN_SAVE_MODE, + type ElaprApln, + type ElaprAplnListParams, + type ElaprAplnReorderItem, + type ElaprAplnReorderRequest, + type ElaprAplnSaveMode, + type ElaprAplnSaveRequest, + type ElaprAplnService, + type ElaprAplnUpdateRequest, +} from "./model/elapr-apln-interface"; +export type { ElaprAplnRow } from "./model/elapr-apln-row-types"; +export type { + ElaprDmndResponseDto, + ElaprDmndSaveRequestDto, + ElaprDmndSearchDto, + ElaprDmndService, + ElaprDocPrgrsResponseDto, +} from "./model/elapr-dmnd-interface"; +export type { + ElaprApproverChangeRequestDto, + ElaprApproverInsertRequestDto, + ElaprPrgrsSttsMyDocSearchParams, + ElaprPrgrsSttsResponseDto, + ElaprPrgrsSttsSaveRequestDto, + ElaprPrgrsSttsSearchDto, + ElaprPrgrsSttsService, + ElaprProcessRequestDto, +} from "./model/elapr-prgrs-stts-interface"; +export type { + ElaprTmplt, + ElaprTmpltSaveRequest, + ElaprTmpltSearchParams, + ElaprTmpltService, + ElaprTmpltUpdateRequest, +} from "./model/elapr-tmplt-interface"; diff --git a/apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts b/apps/as/src/entities/elapr/lib/apln-save-utils.ts similarity index 57% rename from apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts rename to apps/as/src/entities/elapr/lib/apln-save-utils.ts index 0c66b7623..17974a166 100644 --- a/apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts +++ b/apps/as/src/entities/elapr/lib/apln-save-utils.ts @@ -1,8 +1,16 @@ -import { ELAPR_APLN_SAVE_MODE, type ElaprApln, type ElaprAplnSaveRequest } from "@/entities/elapr"; +import { + ELAPR_APLN_SAVE_MODE, + type ElaprApln, + type ElaprAplnSaveRequest, +} from "../model/elapr-apln-interface"; +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; -import { assignAtrzSeqNumbers } from "./assign-atrz-seq"; -import { getVisibleRows } from "./validators"; -import type { ElaprAplnRow } from "../model/apln-row-types"; +import { getVisibleRows } from "./apln-validators"; + +/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */ +export function assignAtrzSeqNumbers(rows: ElaprAplnRow[]): ElaprAplnRow[] { + return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 })); +} function rowDiffersFromServer(row: ElaprAplnRow, serverRow: ElaprApln): boolean { return ( @@ -22,6 +30,25 @@ function shouldIncludeUpdate(row: ElaprAplnRow, serverRows: ElaprApln[]): boolea return rowDiffersFromServer(row, serverRow); } +export function hasUnsavedChanges(rows: ElaprAplnRow[], serverRows: ElaprApln[]): boolean { + if (rows.some((row) => row.isDeleted && row.aplnId)) return true; + if (rows.some((row) => row.isNew && !row.isDeleted)) return true; + + const visibleWithSeq = assignAtrzSeqNumbers(getVisibleRows(rows)); + + for (const row of visibleWithSeq) { + if (row.isNew) continue; + if (!row.aplnId) continue; + if (row.isDirty) return true; + + const serverRow = serverRows.find((s) => s.aplnId === row.aplnId); + if (!serverRow) continue; + if (rowDiffersFromServer(row, serverRow)) return true; + } + + return false; +} + export function buildSaveMultiPayload( rows: ElaprAplnRow[], elaprTmpltId: string, diff --git a/apps/as/src/pages/approval-line-management/lib/validators.ts b/apps/as/src/entities/elapr/lib/apln-validators.ts similarity index 94% rename from apps/as/src/pages/approval-line-management/lib/validators.ts rename to apps/as/src/entities/elapr/lib/apln-validators.ts index 4e301afa9..adfe167db 100644 --- a/apps/as/src/pages/approval-line-management/lib/validators.ts +++ b/apps/as/src/entities/elapr/lib/apln-validators.ts @@ -1,4 +1,4 @@ -import type { ElaprAplnRow } from "../model/apln-row-types"; +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; const ATRZ_RECEIVER_CD = "ATRZ01"; const ATRZ_FINAL_CD = "ATRZ99"; diff --git a/apps/as/src/entities/elapr/lib/approval-request-utils.ts b/apps/as/src/entities/elapr/lib/approval-request-utils.ts new file mode 100644 index 000000000..db9cae9dc --- /dev/null +++ b/apps/as/src/entities/elapr/lib/approval-request-utils.ts @@ -0,0 +1,25 @@ +import type { ElaprDocPrgrsResponseDto } from "../model/elapr-dmnd-interface"; + +export function hasCurrentUserPendingApprovalRequest( + list: ElaprDocPrgrsResponseDto[], + currentUserUuid: string | undefined, +): boolean { + if (!currentUserUuid) return false; + + return list.some( + (row) => + row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002", + ); +} + +export function findCurrentUserPendingApprovalRequest( + list: ElaprDocPrgrsResponseDto[], + currentUserUuid: string | undefined, +): ElaprDocPrgrsResponseDto | undefined { + if (!currentUserUuid) return undefined; + + return list.find( + (row) => + row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002", + ); +} diff --git a/apps/as/src/entities/elapr/lib/dmnd-save-utils.ts b/apps/as/src/entities/elapr/lib/dmnd-save-utils.ts new file mode 100644 index 000000000..7eee2894d --- /dev/null +++ b/apps/as/src/entities/elapr/lib/dmnd-save-utils.ts @@ -0,0 +1,43 @@ +import type { ElaprApln } from "../model/elapr-apln-interface"; +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; +import type { ElaprPrgrsSttsSaveRequestDto } from "../model/elapr-prgrs-stts-interface"; + +import { getVisibleRows } from "./apln-validators"; + +function toPrgrsSttsItem( + aprvrUuid: string | undefined, + atrzSeComCd: string | undefined, + atrzSeqNo: number | undefined, +): ElaprPrgrsSttsSaveRequestDto { + if (!aprvrUuid?.trim()) { + throw new Error("결재자가 지정되지 않은 행이 있습니다."); + } + if (!atrzSeComCd?.trim()) { + throw new Error("결재구분이 지정되지 않은 행이 있습니다."); + } + if (atrzSeqNo == null) { + throw new Error("결재순서번호가 지정되지 않은 행이 있습니다."); + } + + return { + aprvrUuid, + atrzSeComCd, + atrzSeqNo, + }; +} + +/** 서식 결재선 API 응답 → 기안 요청용 진행상태 목록 */ +export function buildElaprPrgrsSttsListFromApln( + items: ElaprApln[], +): ElaprPrgrsSttsSaveRequestDto[] { + return items.map((item) => toPrgrsSttsItem(item.aprvrUuid, item.atrzSeComCd, item.atrzSeqNo)); +} + +/** 결재선 그리드 행 → 기안 요청용 진행상태 목록 (서버가 seq 기준 상태 자동 결정) */ +export function buildElaprPrgrsSttsListFromAplnRows( + rows: ElaprAplnRow[], +): ElaprPrgrsSttsSaveRequestDto[] { + return getVisibleRows(rows).map((row) => + toPrgrsSttsItem(row.aprvrUuid, row.atrzSeComCd, row.atrzSeqNo), + ); +} diff --git a/apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts b/apps/as/src/entities/elapr/lib/use-apln-rows.ts similarity index 80% rename from apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts rename to apps/as/src/entities/elapr/lib/use-apln-rows.ts index 01c71ce3b..384c8ffc1 100644 --- a/apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts +++ b/apps/as/src/entities/elapr/lib/use-apln-rows.ts @@ -4,12 +4,11 @@ import { useCallback, useEffect, useState } from "react"; import { arrayMove } from "@dnd-kit/sortable"; -import type { ElaprApln } from "@/entities/elapr"; +import type { ElaprApln } from "../model/elapr-apln-interface"; +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; -import type { ElaprAplnRow } from "../model/apln-row-types"; - -import { assignAtrzSeqNumbers } from "./assign-atrz-seq"; -import { getVisibleRows } from "./validators"; +import { assignAtrzSeqNumbers } from "./apln-save-utils"; +import { getVisibleRows } from "./apln-validators"; function toRow(item: ElaprApln): ElaprAplnRow { return { @@ -101,7 +100,13 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { return prev.map((row) => { if (row.rowKey !== editingRowKey) return row; if (row.snapshot) { - const { snapshot: _s, isNew: _n, isDirty: _d, isDeleted: _del, ...restored } = row.snapshot; + const { + snapshot: _s, + isNew: _n, + isDirty: _d, + isDeleted: _del, + ...restored + } = row.snapshot; return { ...restored, isNew: false, isDirty: false, isDeleted: false }; } return { ...row, isDirty: false }; @@ -110,19 +115,16 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { setEditingRowKey(null); }, [editingRowKey]); - const commitEdit = useCallback( - (rowKey: string) => { - setRows((prev) => - prev.map((row) => { - if (row.rowKey !== rowKey) return row; - const { snapshot: _s, ...committed } = row; - return { ...committed, isDirty: true }; - }), - ); - setEditingRowKey(null); - }, - [], - ); + const commitEdit = useCallback((rowKey: string) => { + setRows((prev) => + prev.map((row) => { + if (row.rowKey !== rowKey) return row; + const { snapshot: _s, ...committed } = row; + return { ...committed, isDirty: true }; + }), + ); + setEditingRowKey(null); + }, []); const markDeleted = useCallback((rowKey: string) => { setRows((prev) => { @@ -158,7 +160,10 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { const visibleKeys = new Set(reorderedVisible.map((r) => r.rowKey)); const deletedRows = prev.filter((r) => r.isDeleted); - const merged = [...reorderedVisible, ...deletedRows.filter((r) => !visibleKeys.has(r.rowKey))]; + const merged = [ + ...reorderedVisible, + ...deletedRows.filter((r) => !visibleKeys.has(r.rowKey)), + ]; payloadRows = reorderedVisible; return merged; diff --git a/apps/as/src/pages/approval-line-management/lib/use-dnd-sensors.ts b/apps/as/src/entities/elapr/lib/use-dnd-sensors.ts similarity index 93% rename from apps/as/src/pages/approval-line-management/lib/use-dnd-sensors.ts rename to apps/as/src/entities/elapr/lib/use-dnd-sensors.ts index 7c4e82ba9..039414303 100644 --- a/apps/as/src/pages/approval-line-management/lib/use-dnd-sensors.ts +++ b/apps/as/src/entities/elapr/lib/use-dnd-sensors.ts @@ -1,3 +1,5 @@ +"use client"; + import { KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; diff --git a/apps/as/src/pages/approval-line-management/model/apln-row-types.ts b/apps/as/src/entities/elapr/model/elapr-apln-row-types.ts similarity index 87% rename from apps/as/src/pages/approval-line-management/model/apln-row-types.ts rename to apps/as/src/entities/elapr/model/elapr-apln-row-types.ts index cabc7bb08..b3532df8a 100644 --- a/apps/as/src/pages/approval-line-management/model/apln-row-types.ts +++ b/apps/as/src/entities/elapr/model/elapr-apln-row-types.ts @@ -1,4 +1,4 @@ -import type { ElaprApln } from "@/entities/elapr"; +import type { ElaprApln } from "./elapr-apln-interface"; /** 그리드 로컬 행 — API 응답 + UI 보조 필드 */ export interface ElaprAplnRow extends Omit { diff --git a/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts b/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts index 249d98bf9..5a3fbc689 100644 --- a/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts +++ b/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts @@ -1,22 +1,63 @@ -export interface ElaprDmndSaveRequest { +import type { PageableParams, PageResponse } from "@/shared/model"; + +import type { ElaprPrgrsSttsSaveRequestDto } from "./elapr-prgrs-stts-interface"; + +/** 백엔드 ElaprDmndSaveRequestDto */ +export interface ElaprDmndSaveRequestDto { elaprDocNo: string; - elaprTmpltId: string; dmndTtl: string; dmndCn: string; + elaprPrgrsSttsList?: ElaprPrgrsSttsSaveRequestDto[]; } -export interface ElaprDmndResponse { +/** 백엔드 ElaprDmndResponseDto */ +export interface ElaprDmndResponseDto { elaprDmndId: string; + rqstrUuid?: string; elaprDocNo: string; - elaprTmpltId: string; dmndTtl: string; dmndCn: string; atrzSttsComCd: string; - rqstrUuid: string; - regDt: string; - updtDt: string; + atrzSttsComNm?: string; + dmndDt?: string; + updtDt?: string; } -export interface ElaprDmndService { - create(body: ElaprDmndSaveRequest): Promise; +/** 백엔드 ElaprDmndSearchDto + Pageable */ +export interface ElaprDmndSearchDto extends PageableParams { + rqstrUuid?: string; + atrzSttsComCd?: string; + dmndTtl?: string; + dmndDtStart?: string; + dmndDtEnd?: string; +} + +/** 백엔드 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; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface ElaprDmndService { + findPage(params: ElaprDmndSearchDto): Promise>; + findById(elaprDmndId: string): Promise; + save(body: ElaprDmndSaveRequestDto): Promise; + update(elaprDmndId: string, body: ElaprDmndSaveRequestDto): Promise; + updateStatus(elaprDmndId: string, atrzSttsComCd: string): Promise; + remove(elaprDmndId: string): Promise; + findDocPrgrsByDocNo(elaprDocNo: string): Promise; } diff --git a/apps/as/src/entities/elapr/model/elapr-prgrs-stts-interface.ts b/apps/as/src/entities/elapr/model/elapr-prgrs-stts-interface.ts new file mode 100644 index 000000000..2ab1c5e14 --- /dev/null +++ b/apps/as/src/entities/elapr/model/elapr-prgrs-stts-interface.ts @@ -0,0 +1,78 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +/** 백엔드 ElaprPrgrsSttsResponseDto */ +export interface ElaprPrgrsSttsResponseDto { + elaprPrgrsId: string; + elaprDmndId?: string; + aprvrUuid?: string; + atrzSeComCd?: string; + atrzSeComNm?: string; + atrzSeqNo?: number; + atrzPrgrsSttsComCd?: string; + atrzPrgrsSttsComNm?: string; + rjctRsn?: string; + atrzDt?: string; +} + +/** 백엔드 ElaprPrgrsSttsSaveRequestDto */ +export interface ElaprPrgrsSttsSaveRequestDto { + elaprDmndId?: string; + aprvrUuid: string; + atrzSeComCd: string; + atrzSeqNo: number; + atrzPrgrsSttsComCd?: string; +} + +/** 백엔드 ElaprPrgrsSttsSearchDto */ +export interface ElaprPrgrsSttsSearchDto { + atrzSeComCd: string; + atrzSeqNo: number; + atrzPrgrsSttsComCd?: string; +} + +/** 백엔드 ElaprPrgrsSttsSearchDto + Pageable */ +export interface ElaprPrgrsSttsMyDocSearchParams extends ElaprPrgrsSttsSearchDto, PageableParams {} + +/** 백엔드 ElaprProcessRequestDto */ +export interface ElaprProcessRequestDto { + atrzPrgrsSttsComCd: string; + rjctRsn?: string; + finalApprovalYn?: boolean; +} + +/** 백엔드 ElaprApproverChangeRequestDto */ +export interface ElaprApproverChangeRequestDto { + aprvrUuid: string; +} + +/** 백엔드 ElaprApproverInsertRequestDto */ +export interface ElaprApproverInsertRequestDto { + aprvrUuid: string; + atrzSeComCd: string; + atrzSeqNo: number; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface ElaprPrgrsSttsService { + findAllByDmndId(elaprDmndId?: string): Promise; + findById(elaprPrgrsId: string): Promise; + save(body: ElaprPrgrsSttsSaveRequestDto): Promise; + process(elaprPrgrsId: string, body: ElaprProcessRequestDto): Promise; + processByDocNoAndAprvrUuid( + elaprDocNo: string, + aprvrUuid: string, + body: ElaprProcessRequestDto, + ): Promise; + delete(elaprPrgrsId: string): Promise; + updateApprover( + elaprPrgrsId: string, + body: ElaprApproverChangeRequestDto, + ): Promise; + insertApprover( + elaprDmndId: string, + body: ElaprApproverInsertRequestDto, + ): Promise; + findMyElaperDocList( + params: ElaprPrgrsSttsMyDocSearchParams, + ): Promise>; +} diff --git a/apps/as/src/entities/esntl/api/esntl-service.ts b/apps/as/src/entities/esntl/api/esntl-service.ts index 458940399..e8de77618 100644 --- a/apps/as/src/entities/esntl/api/esntl-service.ts +++ b/apps/as/src/entities/esntl/api/esntl-service.ts @@ -11,50 +11,41 @@ import type { EsntlAtchFileMngTreeSearchParams, } from "../model/esntl-interface"; +const BASE = ENDPOINT.AS_SERVICE.ESNTL_ATCH_FILE; + async function findTree( params: EsntlAtchFileMngTreeSearchParams, ): Promise { - const response = await client.get( - `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/list/tree`, - { params }, - ); + const response = await client.get(`${BASE}/list/tree`, { + params, + }); return response.data; } async function findPage( params: EsntlAtchFileMngSearchParams, ): Promise> { - const response = await client.get>( - ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE, - { params }, - ); + const response = await client.get>(BASE, { params }); return response.data; } async function findById(esntlAtchFileId: string): Promise { - const response = await client.get( - `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`, - ); + const response = await client.get(`${BASE}/${esntlAtchFileId}`); return response.data; } async function create(body: EsntlAtchFileMngSaveRequest): Promise { - const response = await client.post(ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE, body); + const response = await client.post(BASE, body); return response.data; } async function update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise { - const response = await client.put( - `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`, - body, - ); + const response = await client.put(`${BASE}/${esntlAtchFileId}`, body); return response.data; } async function remove(esntlAtchFileId: string): Promise { - const response = await client.delete( - `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`, - ); + const response = await client.delete(`${BASE}/${esntlAtchFileId}`); return response.data; } diff --git a/apps/as/src/entities/esntl/api/queries.ts b/apps/as/src/entities/esntl/api/queries.ts index 48e30fc68..dc7afb789 100644 --- a/apps/as/src/entities/esntl/api/queries.ts +++ b/apps/as/src/entities/esntl/api/queries.ts @@ -10,7 +10,7 @@ import type { import { esntlAtchFileMngService } from "./esntl-service"; -const esntlAtchFileMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE] as const; +const esntlAtchFileMngBaseKey = [ENDPOINT.AS_SERVICE.ESNTL_ATCH_FILE] as const; export function useEsntlAtchFileMngTree(params: EsntlAtchFileMngTreeSearchParams) { return useQuery({ diff --git a/apps/as/src/entities/fnrs-cnsltn/api/fnrs-cnsltn-service.ts b/apps/as/src/entities/fnrs-cnsltn/api/fnrs-cnsltn-service.ts new file mode 100644 index 000000000..e67a9a6c7 --- /dev/null +++ b/apps/as/src/entities/fnrs-cnsltn/api/fnrs-cnsltn-service.ts @@ -0,0 +1,86 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import type { + DocPrgrsUpdateRequestDto, + FnrsCnsltnApplySaveRequestDto, + FnrsCnsltnMngDetResponseDto, + FnrsCnsltnMngResponseDto, + FnrsCnsltnMngSaveRequestDto, + FnrsCnsltnMngSearchDto, + FnrsCnsltnMngService, + FnrsCnsltnMngUpdateRequestDto, +} from "../model/fnrs-cnsltn-interface"; + +const BASE = ENDPOINT.AS_SERVICE.FNRS_CNSLTN_MNG; + +async function findPage( + searchDto: FnrsCnsltnMngSearchDto, +): Promise> { + const response = await client.get>(BASE, { + params: searchDto, + }); + return response.data; +} + +async function findById(fnrsCnsltnDocNo: string): Promise { + const response = await client.get(`${BASE}/${fnrsCnsltnDocNo}`); + return response.data; +} + +async function save(body: FnrsCnsltnMngSaveRequestDto): Promise { + const response = await client.post(BASE, body); + return response.data; +} + +async function saveApply(body: FnrsCnsltnApplySaveRequestDto): Promise { + const response = await client.post(`${BASE}/save-apply`, body); + return response.data; +} + +async function update( + fnrsCnsltnDocNo: string, + body: FnrsCnsltnMngUpdateRequestDto, +): Promise { + const response = await client.put(`${BASE}/${fnrsCnsltnDocNo}`, body); + return response.data; +} + +async function techReview(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise { + await client.post(`${BASE}/${id}/tech-review`, body); +} + +async function submitSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise { + const response = await client.post(`${BASE}/${id}/submitsupplement`, body); + return response.data; +} + +async function submitReqSupplement( + id: string, + body: FnrsCnsltnApplySaveRequestDto, +): Promise { + const response = await client.post(`${BASE}/${id}/reqsupplement`, body); + return response.data; +} + +async function remove(fnrsCnsltnDocNo: string): Promise { + await client.delete(`${BASE}/${fnrsCnsltnDocNo}`); +} + +async function changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise { + await client.post(`${BASE}/doc/updatePrgrsStts`, body); +} + +export const fnrsCnsltnMngService = { + findPage, + findById, + save, + saveApply, + update, + techReview, + submitSupplement, + submitReqSupplement, + remove, + changePrgrsSttsComCd, +} satisfies FnrsCnsltnMngService; diff --git a/apps/as/src/entities/fnrs-cnsltn/api/queries.ts b/apps/as/src/entities/fnrs-cnsltn/api/queries.ts new file mode 100644 index 000000000..127e87e08 --- /dev/null +++ b/apps/as/src/entities/fnrs-cnsltn/api/queries.ts @@ -0,0 +1,131 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { + DocPrgrsUpdateRequestDto, + FnrsCnsltnApplySaveRequestDto, + FnrsCnsltnMngSaveRequestDto, + FnrsCnsltnMngSearchDto, + FnrsCnsltnMngUpdateRequestDto, +} from "../model/fnrs-cnsltn-interface"; + +import { fnrsCnsltnMngService } from "./fnrs-cnsltn-service"; + +export const fnrsCnsltnMngBaseKey = [ENDPOINT.AS_SERVICE.FNRS_CNSLTN_MNG] as const; + +export function useFnrsCnsltnMngPage(params: FnrsCnsltnMngSearchDto) { + return useQuery({ + queryKey: [...fnrsCnsltnMngBaseKey, "findPage", params], + queryFn: () => fnrsCnsltnMngService.findPage(params), + }); +} + +export function useFnrsCnsltnMng(fnrsCnsltnDocNo: string | null | undefined) { + return useQuery({ + queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo], + queryFn: () => fnrsCnsltnMngService.findById(fnrsCnsltnDocNo!), + enabled: Boolean(fnrsCnsltnDocNo), + }); +} + +export function useSaveFnrsCnsltnMng() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: FnrsCnsltnMngSaveRequestDto) => fnrsCnsltnMngService.save(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); + }, + }); +} + +export function useSaveApplyFnrsCnsltnMng() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: FnrsCnsltnApplySaveRequestDto) => fnrsCnsltnMngService.saveApply(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); + }, + }); +} + +export function useUpdateFnrsCnsltnMng() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + fnrsCnsltnDocNo, + body, + }: { + fnrsCnsltnDocNo: string; + body: FnrsCnsltnMngUpdateRequestDto; + }) => fnrsCnsltnMngService.update(fnrsCnsltnDocNo, body), + onSuccess: (_data, { fnrsCnsltnDocNo }) => { + queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo], + }); + }, + }); +} + +export function useTechReviewFnrsCnsltnMng() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: FnrsCnsltnApplySaveRequestDto }) => + fnrsCnsltnMngService.techReview(id, body), + onSuccess: (_data, { id }) => { + queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...fnrsCnsltnMngBaseKey, "findById", id], + }); + }, + }); +} + +export function useSubmitSupplementFnrsCnsltnMng() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: FnrsCnsltnApplySaveRequestDto }) => + fnrsCnsltnMngService.submitSupplement(id, body), + onSuccess: (_data, { id }) => { + queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...fnrsCnsltnMngBaseKey, "findById", id], + }); + }, + }); +} + +export function useSubmitReqSupplementFnrsCnsltnMng() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: FnrsCnsltnApplySaveRequestDto }) => + fnrsCnsltnMngService.submitReqSupplement(id, body), + onSuccess: (_data, { id }) => { + queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...fnrsCnsltnMngBaseKey, "findById", id], + }); + }, + }); +} + +export function useDeleteFnrsCnsltnMng() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (fnrsCnsltnDocNo: string) => fnrsCnsltnMngService.remove(fnrsCnsltnDocNo), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); + }, + }); +} + +export function useChangePrgrsSttsComCd() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: DocPrgrsUpdateRequestDto) => fnrsCnsltnMngService.changePrgrsSttsComCd(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey }); + }, + }); +} diff --git a/apps/as/src/entities/fnrs-cnsltn/index.ts b/apps/as/src/entities/fnrs-cnsltn/index.ts new file mode 100644 index 000000000..645065ffe --- /dev/null +++ b/apps/as/src/entities/fnrs-cnsltn/index.ts @@ -0,0 +1,39 @@ +export { fnrsCnsltnMngService } from "./api/fnrs-cnsltn-service"; +export { fnrsCnsltnMngBaseKey } from "./api/queries"; +export { + useChangePrgrsSttsComCd, + useDeleteFnrsCnsltnMng, + useFnrsCnsltnMng, + useFnrsCnsltnMngPage, + useSaveApplyFnrsCnsltnMng, + useSaveFnrsCnsltnMng, + useSubmitReqSupplementFnrsCnsltnMng, + useSubmitSupplementFnrsCnsltnMng, + useTechReviewFnrsCnsltnMng, + useUpdateFnrsCnsltnMng, +} from "./api/queries"; +export type { + AtflMngClientResponseDto, + BizCstItem, + BizCstTreeDto, + BizSclTreeDto, + BzScaleItem, + CklstResultItem, + CklstRvwRsltTreeDto, + DocPrgrsUpdateRequestDto, + ElaprDocPrgrsClientResponseDto, + EsntlAtchFileItem, + EsntlAtchFileMngTreeResponseDto, + FnrsCnsltnApplySaveRequestDto, + FnrsCnsltnMngDetResponseDto, + FnrsCnsltnMngResponseDto, + FnrsCnsltnMngSaveRequestDto, + FnrsCnsltnMngSearchDto, + FnrsCnsltnMngService, + FnrsCnsltnMngUpdateRequestDto, + FnrsCnsltnRvwOpnnResponseDto, + FnrsCnsltnRvwOpnnSaveRequestDto, + FnrsCnsltnSplmntDmndItem, + FnrsCnsltnSplmntDmndResponseDto, + FnrsSharItem, +} from "./model/fnrs-cnsltn-interface"; diff --git a/apps/as/src/entities/fnrs-cnsltn/model/fnrs-cnsltn-interface.ts b/apps/as/src/entities/fnrs-cnsltn/model/fnrs-cnsltn-interface.ts new file mode 100644 index 000000000..d0a43a71b --- /dev/null +++ b/apps/as/src/entities/fnrs-cnsltn/model/fnrs-cnsltn-interface.ts @@ -0,0 +1,177 @@ +import type { + AtflMngClientResponseDto, + BizCstSaveBase, + BzScaleSaveBase, + CklstResultSaveBase, + DocPrgrsUpdateRequestDto, + EsntlAtchFileSaveBase, + PageableParams, + PageResponse, + PbadmsApplySaveHeader, + PbadmsMngDetResponseBase, + RvwOpnnResponseExtension, + RvwOpnnSaveBase, + SplmntDmndResponseExtension, + SplmntDmndSaveBase, +} from "@/shared/model"; +import type { FnrsSharBizCstTreeDto } from "@/entities/fnrs-shar-se"; + +export type { + AtflMngClientResponseDto, + BizCstTreeDto, + BizSclTreeDto, + CklstRvwRsltTreeDto, + DocPrgrsUpdateRequestDto, + ElaprDocPrgrsClientResponseDto, + EsntlAtchFileMngTreeResponseDto, +} from "@/shared/model"; + +/** 백엔드 FnrsCnsltnMngSaveRequestDto */ +export interface FnrsCnsltnMngSaveRequestDto { + fnrsCnsltnDocNo?: string; + admdstCd?: string; + envoSeComCd?: string; + taskSeComCd?: string; + cfmtnBgtBizNo?: string; + bgtAplyYr?: string; + bizNm?: string; + bizBgngYr?: string; + bizEndYr?: string; + bizCn?: string; + aplcntNm?: string; + aplcntMblTelno?: string; + aplcntOgdpNm?: string; + aplcntEmlAddr?: string; + dsgnrNm?: string; + dsgnrTelno?: string; + dsgnrOgdpNm?: string; + dsgnrEmlAddr?: string; + atchFileGroupId?: string; + sharAtchFileGroupId?: string; + prgrsSttsComCd?: string; + rtlztnFcltSeComCd?: string; + rtlztnUsgSeComCd?: string; +} + +/** 백엔드 FnrsCnsltnMngUpdateRequestDto */ +export type FnrsCnsltnMngUpdateRequestDto = Omit; + +/** 백엔드 FnrsCnsltnMngSearchDto + Pageable */ +export interface FnrsCnsltnMngSearchDto extends PageableParams { + fnrsCnsltnDocNo?: string; + admdstCd?: string; + envoSeComCd?: string; + taskSeComCd?: string; + cfmtnBgtBizNo?: string; + bgtAplyYr?: string; + bizNm?: string; + bizBgngYr?: string; + bizEndYr?: string; + bizCn?: string; + aplcntNm?: string; + aplcntMblTelno?: string; + aplcntOgdpNm?: string; + aplcntEmlAddr?: string; + dsgnrNm?: string; + dsgnrTelno?: string; + dsgnrOgdpNm?: string; + dsgnrEmlAddr?: string; + atchFileGroupId?: string; + sharAtchFileGroupId?: string; + prgrsSttsComCd?: string; +} + +interface FnrsCnsltnMngResponseExtension { + fnrsCnsltnDocNo: string; + envoSeCdNm?: string; + taskSeCdNm?: string; + prgrsSttsCdNm?: string; + regDt?: string; + rgtr?: string; + rgtrNm?: string; + mdfcnDt?: string; + sido?: string; + gugun?: string; +} + +/** 백엔드 FnrsCnsltnMngResponseDto */ +export type FnrsCnsltnMngResponseDto = Omit & + FnrsCnsltnMngResponseExtension; + +export type BzScaleItem = BzScaleSaveBase; + +export type BizCstItem = BizCstSaveBase; + +export interface CklstResultItem extends CklstResultSaveBase { + selected?: string; +} + +export type EsntlAtchFileItem = EsntlAtchFileSaveBase; + +export type FnrsCnsltnSplmntDmndItem = SplmntDmndSaveBase; + +/** 백엔드 FnrsCnsltnApplySaveRequestDto.FnrsSharItem */ +export interface FnrsSharItem { + fnrsSharSeCd?: string; + exstnAmt?: number; + chgRqtAmt?: number; + ajmtAmt?: number; + rmrkCn?: string; + envoRmrkCn?: string; +} + +/** 백엔드 FnrsCnsltnRvwOpnnSaveRequestDto */ +export interface FnrsCnsltnRvwOpnnSaveRequestDto extends RvwOpnnSaveBase { + fnrsCnsltnDocNo: string; + rvwOpnnSn: number; +} + +/** 백엔드 FnrsCnsltnRvwOpnnResponseDto */ +export type FnrsCnsltnRvwOpnnResponseDto = Partial< + Pick +> & + Omit & + RvwOpnnResponseExtension; + +/** 백엔드 FnrsCnsltnSplmntDmndResponseDto */ +export type FnrsCnsltnSplmntDmndResponseDto = FnrsCnsltnSplmntDmndItem & + SplmntDmndResponseExtension & { + fnrsCnsltnDocNo?: string; + }; + +/** 백엔드 FnrsCnsltnMngDetResponseDto */ +export type FnrsCnsltnMngDetResponseDto = PbadmsMngDetResponseBase< + FnrsCnsltnMngResponseDto, + FnrsCnsltnRvwOpnnResponseDto, + FnrsCnsltnSplmntDmndResponseDto +> & { + fnrsSharList?: FnrsSharBizCstTreeDto[]; + sharAtchFileList?: AtflMngClientResponseDto[]; +}; + +/** 백엔드 FnrsCnsltnApplySaveRequestDto */ +export interface FnrsCnsltnApplySaveRequestDto extends PbadmsApplySaveHeader { + fnrsCnsltnMng: FnrsCnsltnMngSaveRequestDto; + bzScaleList?: BzScaleItem[]; + bizCstList?: BizCstItem[]; + cklstResultList?: CklstResultItem[]; + cklstResultMap?: Record; + esntlAtchFileList?: EsntlAtchFileItem[]; + rvwOpnnList?: FnrsCnsltnRvwOpnnSaveRequestDto[]; + splmntDmndList?: FnrsCnsltnSplmntDmndItem[]; + fnrsSharList?: FnrsSharItem[]; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface FnrsCnsltnMngService { + findPage(searchDto: FnrsCnsltnMngSearchDto): Promise>; + findById(fnrsCnsltnDocNo: string): Promise; + save(body: FnrsCnsltnMngSaveRequestDto): Promise; + saveApply(body: FnrsCnsltnApplySaveRequestDto): Promise; + update(fnrsCnsltnDocNo: string, body: FnrsCnsltnMngUpdateRequestDto): Promise; + techReview(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise; + submitSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise; + submitReqSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise; + remove(fnrsCnsltnDocNo: string): Promise; + changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise; +} diff --git a/apps/as/src/entities/fnrs-shar-se/api/fnrs-shar-se-service.ts b/apps/as/src/entities/fnrs-shar-se/api/fnrs-shar-se-service.ts new file mode 100644 index 000000000..57bfa67ec --- /dev/null +++ b/apps/as/src/entities/fnrs-shar-se/api/fnrs-shar-se-service.ts @@ -0,0 +1,70 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import { + FNRS_SHAR_SE_SAVE_MODE, + type FnrsSharBizCstTreeDto, + type FnrsSharSeMngResponseDto, + type FnrsSharSeMngSaveRequestDto, + type FnrsSharSeMngSearchDto, + type FnrsSharSeMngService, + type FnrsSharSeMngTreeDto, +} from "../model/fnrs-shar-se-interface"; + +const BASE = ENDPOINT.AS_SERVICE.FNRS_SHAR_SE; + +async function findAll( + params: FnrsSharSeMngSearchDto, +): Promise> { + const response = await client.get>(BASE, { params }); + return response.data; +} + +async function findTree(): Promise { + const response = await client.get(`${BASE}/list/tree`); + return response.data; +} + +async function findById(fnrsSharSeCd: string): Promise { + const response = await client.get(`${BASE}/${fnrsSharSeCd}`); + return response.data; +} + +async function saveMulti( + body: FnrsSharSeMngSaveRequestDto[], +): Promise { + const response = await client.post(BASE, body); + return response.data; +} + +async function update(fnrsSharSeCd: string, body: FnrsSharSeMngSaveRequestDto): Promise { + const response = await client.put(`${BASE}/${fnrsSharSeCd}`, { + ...body, + fnrsSharSeCd, + mode: FNRS_SHAR_SE_SAVE_MODE.UPDATE, + }); + return response.data; +} + +async function deleteFnrsSharSeCd(fnrsSharSeCd: string): Promise { + const response = await client.delete(`${BASE}/${fnrsSharSeCd}`); + return response.data; +} + +async function findTreeFnrsShar(fnrsCnsltnDocNo: string): Promise { + const response = await client.get( + `${BASE}/list/tree/fnrs-shar/${fnrsCnsltnDocNo}`, + ); + return response.data; +} + +export const fnrsSharSeMngService = { + findAll, + findTree, + findById, + saveMulti, + update, + delete: deleteFnrsSharSeCd, + findTreeFnrsShar, +} satisfies FnrsSharSeMngService; diff --git a/apps/as/src/entities/fnrs-shar-se/api/queries.ts b/apps/as/src/entities/fnrs-shar-se/api/queries.ts new file mode 100644 index 000000000..0dd73596e --- /dev/null +++ b/apps/as/src/entities/fnrs-shar-se/api/queries.ts @@ -0,0 +1,85 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { + FnrsSharSeMngSaveRequestDto, + FnrsSharSeMngSearchDto, +} from "../model/fnrs-shar-se-interface"; + +import { fnrsSharSeMngService } from "./fnrs-shar-se-service"; + +const fnrsSharSeMngBaseKey = [ENDPOINT.AS_SERVICE.FNRS_SHAR_SE] as const; + +export function useFnrsSharSeMngPage(params: FnrsSharSeMngSearchDto) { + return useQuery({ + queryKey: [...fnrsSharSeMngBaseKey, "findAll", params], + queryFn: () => fnrsSharSeMngService.findAll(params), + }); +} + +export function useFnrsSharSeMngTree() { + return useQuery({ + queryKey: [...fnrsSharSeMngBaseKey, "findTree"], + queryFn: () => fnrsSharSeMngService.findTree(), + staleTime: Infinity, + }); +} + +export function useFnrsSharSeMng(fnrsSharSeCd: string | null | undefined) { + return useQuery({ + queryKey: [...fnrsSharSeMngBaseKey, "findById", fnrsSharSeCd], + queryFn: () => fnrsSharSeMngService.findById(fnrsSharSeCd!), + enabled: Boolean(fnrsSharSeCd), + }); +} + +export function useSaveMultiFnrsSharSeMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: FnrsSharSeMngSaveRequestDto[]) => fnrsSharSeMngService.saveMulti(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: fnrsSharSeMngBaseKey }); + }, + }); +} + +export function useUpdateFnrsSharSeMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + fnrsSharSeCd, + body, + }: { + fnrsSharSeCd: string; + body: FnrsSharSeMngSaveRequestDto; + }) => fnrsSharSeMngService.update(fnrsSharSeCd, body), + onSuccess: (_data, { fnrsSharSeCd }) => { + queryClient.invalidateQueries({ queryKey: fnrsSharSeMngBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...fnrsSharSeMngBaseKey, "findById", fnrsSharSeCd], + }); + }, + }); +} + +export function useDeleteFnrsSharSeMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (fnrsSharSeCd: string) => fnrsSharSeMngService.delete(fnrsSharSeCd), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: fnrsSharSeMngBaseKey }); + }, + }); +} + +export function useFnrsSharBizCstTree(fnrsCnsltnDocNo: string | null | undefined) { + return useQuery({ + queryKey: [...fnrsSharSeMngBaseKey, "findTreeFnrsShar", fnrsCnsltnDocNo], + queryFn: () => fnrsSharSeMngService.findTreeFnrsShar(fnrsCnsltnDocNo!), + enabled: Boolean(fnrsCnsltnDocNo), + }); +} diff --git a/apps/as/src/entities/fnrs-shar-se/index.ts b/apps/as/src/entities/fnrs-shar-se/index.ts new file mode 100644 index 000000000..2527aead1 --- /dev/null +++ b/apps/as/src/entities/fnrs-shar-se/index.ts @@ -0,0 +1,20 @@ +export { fnrsSharSeMngService } from "./api/fnrs-shar-se-service"; +export { + useDeleteFnrsSharSeMng, + useFnrsSharBizCstTree, + useFnrsSharSeMng, + useFnrsSharSeMngPage, + useFnrsSharSeMngTree, + useSaveMultiFnrsSharSeMng, + useUpdateFnrsSharSeMng, +} from "./api/queries"; +export type { + FnrsSharBizCstTreeDto, + FnrsSharSeMngResponseDto, + FnrsSharSeMngSaveRequestDto, + FnrsSharSeMngSearchDto, + FnrsSharSeMngService, + FnrsSharSeMngTreeDto, + FnrsSharSeSaveMode, +} from "./model/fnrs-shar-se-interface"; +export { FNRS_SHAR_SE_SAVE_MODE } from "./model/fnrs-shar-se-interface"; diff --git a/apps/as/src/entities/fnrs-shar-se/model/fnrs-shar-se-interface.ts b/apps/as/src/entities/fnrs-shar-se/model/fnrs-shar-se-interface.ts new file mode 100644 index 000000000..ab60a868f --- /dev/null +++ b/apps/as/src/entities/fnrs-shar-se/model/fnrs-shar-se-interface.ts @@ -0,0 +1,83 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +export const FNRS_SHAR_SE_SAVE_MODE = { + CREATE: "C", + UPDATE: "U", + DELETE: "D", +} as const; + +export type FnrsSharSeSaveMode = (typeof FNRS_SHAR_SE_SAVE_MODE)[keyof typeof FNRS_SHAR_SE_SAVE_MODE]; + +/** 백엔드 FnrsSharSeMngResponseDto */ +export interface FnrsSharSeMngResponseDto { + fnrsSharSeCd: string; + fnrsSharSeCdNm: string; + upFnrsSharSeCd?: string; + cdLvNo: number; + useYn: string; + expln?: string; + sortSeq?: number; + rgtr?: string; + rgtrNm?: string; + mdfr?: string; + regDt?: string; + mdfcnDt?: string; +} + +/** 백엔드 FnrsSharSeMngSearchDto + Pageable */ +export interface FnrsSharSeMngSearchDto extends PageableParams { + fnrsSharSeCd?: string; + fnrsSharSeCdNm?: string; + useYn?: string; +} + +/** 백엔드 FnrsSharSeMngSaveRequestDto */ +export interface FnrsSharSeMngSaveRequestDto { + fnrsSharSeCd: string; + mode: FnrsSharSeSaveMode; + fnrsSharSeCdNm: string; + upFnrsSharSeCd?: string; + cdLvNo: number; + useYn?: string; + expln?: string; + sortSeq?: number; +} + +/** 백엔드 FnrsSharSeMngTreeDto */ +export interface FnrsSharSeMngTreeDto { + indFnrsSharSeCdNm: string; + fnrsSharSeCd: string; + fnrsSharSeCdNm: string; + upFnrsSharSeCd?: string; + useYn: string; + treeLevel: number; + treePath: string; + sortPath: string; +} + +/** 백엔드 FnrsSharBizCstTreeDto */ +export interface FnrsSharBizCstTreeDto { + indFnrsSharSeCdNm: string; + fnrsSharSeCd: string; + fnrsSharSeCdNm: string; + useYn: string; + treeLevel: number; + treePath: string; + sortPath: string; + exstnAmt?: number; + chgRqtAmt?: number; + ajmtAmt?: number; + rmrkCn?: string; + envoRmrkCn?: string; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface FnrsSharSeMngService { + findAll(params: FnrsSharSeMngSearchDto): Promise>; + findTree(): Promise; + findById(fnrsSharSeCd: string): Promise; + saveMulti(body: FnrsSharSeMngSaveRequestDto[]): Promise; + update(fnrsSharSeCd: string, body: FnrsSharSeMngSaveRequestDto): Promise; + delete(fnrsSharSeCd: string): Promise; + findTreeFnrsShar(fnrsCnsltnDocNo: string): Promise; +} 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 208e0172d..38ce8e69a 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 @@ -1,41 +1,91 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + import type { + AdstZoneResponseDto, + DocPrgrsUpdateRequestDto, InstlSncnApplySaveRequestDto, - InstlSncnMng, + InstlSncnMngDetResponse, + InstlSncnMngResponseDto, + InstlSncnMngSaveRequestDto, InstlSncnMngSearchParams, InstlSncnMngService, - SpringPage, - InstlSncnMngDetResponse, + InstlSncnMngUpdateRequestDto, } from "../model/instl-sncn-interface"; -export class InstlSncnApiService implements InstlSncnMngService { - public async getPage(params: InstlSncnMngSearchParams): Promise> { - const response = await client.get>(ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN, { - params, - }); - return response.data; - } +async function getSidoCombo(): Promise { + const response = await client.get( + `${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}/sido`, + ); - public async getById(id: string): Promise { - const response = await client.get(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`); - return response.data; - } - - public async saveApply(payload: InstlSncnApplySaveRequestDto): Promise { - const response = await client.post( - `${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/save-apply`, - payload, - ); - return response.data; - } - public async techReview(payload: InstlSncnApplySaveRequestDto): Promise { - const response = await client.post( - `${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/tech-review`, - payload, - ); - return response.data; - } + return response.data; } -export const InstlSncnApi = new InstlSncnApiService(); +async function getSigunguCombo(sidoCd: string): Promise { + const response = await client.get( + `${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}/sigungu`, + { params: { sidoCd } }, + ); + + return response.data; +} + +const BASE = ENDPOINT.AS_SERVICE.INSTL_SNCN; + +async function getPage( + params: InstlSncnMngSearchParams, +): Promise> { + const response = await client.get>(BASE, { + params, + }); + return response.data; +} + +async function getById(id: string): Promise { + const response = await client.get(`${BASE}/${id}`); + return response.data; +} + +async function save(payload: InstlSncnMngSaveRequestDto): Promise { + const response = await client.post(BASE, payload); + return response.data; +} + +async function saveApply(payload: InstlSncnApplySaveRequestDto): Promise { + const response = await client.post(`${BASE}/save-apply`, payload); + return response.data; +} + +async function update(id: string, payload: InstlSncnMngUpdateRequestDto): Promise { + await client.put(`${BASE}/${id}`, payload); +} + +async function techReview(id: string, payload: InstlSncnApplySaveRequestDto): Promise { + await client.post(`${BASE}/${id}/tech-review`, payload); +} + +async function remove(id: string): Promise { + await client.delete(`${BASE}/${id}`); +} + +async function changePrgrsSttsComCd(payload: DocPrgrsUpdateRequestDto): Promise { + await client.post(`${BASE}/doc/updatePrgrsStts`, payload); +} + +export const instlSncnService = { + getPage, + getById, + save, + saveApply, + update, + techReview, + remove, + changePrgrsSttsComCd, +} satisfies InstlSncnMngService; + +// TODO: st-anls-mst service 로 분리 +export const instlSncnRegionService = { + getSidoCombo, + getSigunguCombo, +}; diff --git a/apps/as/src/entities/instl-sncn/api/queries.ts b/apps/as/src/entities/instl-sncn/api/queries.ts new file mode 100644 index 000000000..ac651be47 --- /dev/null +++ b/apps/as/src/entities/instl-sncn/api/queries.ts @@ -0,0 +1,126 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { + DocPrgrsUpdateRequestDto, + InstlSncnApplySaveRequestDto, + InstlSncnMngSaveRequestDto, + InstlSncnMngSearchParams, + InstlSncnMngUpdateRequestDto, +} from "../model/instl-sncn-interface"; + +import { instlSncnRegionService, instlSncnService } from "./instl-sncn-service"; + +export const instlSncnKeys = { + all: ["instl-sncn"] as const, + list: () => [...instlSncnKeys.all, "list"] as const, + detail: (id: string) => [...instlSncnKeys.all, "detail", id] as const, + sidoCombo: () => [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST, "sido"] as const, + sigunguCombo: (sidoCd: string) => + [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST, "sigungu", sidoCd] as const, +}; + +export function useInstlSncnList(params: InstlSncnMngSearchParams) { + return useQuery({ + queryKey: [...instlSncnKeys.list(), params], + queryFn: () => instlSncnService.getPage(params), + }); +} + +export function useInstlSncnDetail(id: string) { + return useQuery({ + queryKey: instlSncnKeys.detail(id), + queryFn: () => instlSncnService.getById(id), + enabled: Boolean(id), + }); +} + +export function useInstlSncnSidoCombo() { + return useQuery({ + queryKey: instlSncnKeys.sidoCombo(), + queryFn: () => instlSncnRegionService.getSidoCombo(), + }); +} + +export function useInstlSncnSigunguCombo(sidoCd: string) { + return useQuery({ + queryKey: instlSncnKeys.sigunguCombo(sidoCd), + queryFn: () => instlSncnRegionService.getSigunguCombo(sidoCd), + enabled: Boolean(sidoCd), + }); +} + +export function useInstlSncnSave() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: InstlSncnMngSaveRequestDto) => instlSncnService.save(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: instlSncnKeys.all }); + }, + }); +} + +export function useInstlSncnSaveApply() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: InstlSncnApplySaveRequestDto) => instlSncnService.saveApply(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: instlSncnKeys.all }); + }, + }); +} + +export function useInstlSncnUpdate() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: InstlSncnMngUpdateRequestDto }) => + instlSncnService.update(id, payload), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: instlSncnKeys.all }); + queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) }); + }, + }); +} + +export function useInstlSncnTechReview() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: InstlSncnApplySaveRequestDto }) => + instlSncnService.techReview(id, payload), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: instlSncnKeys.all }); + queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) }); + }, + }); +} + +export function useInstlSncnRemove() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => instlSncnService.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: instlSncnKeys.all }); + }, + }); +} + +export function useInstlSncnChangePrgrsStts() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: DocPrgrsUpdateRequestDto) => + instlSncnService.changePrgrsSttsComCd(payload), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: instlSncnKeys.all }); + queryClient.invalidateQueries({ + queryKey: instlSncnKeys.detail(variables.docNo), + }); + }, + }); +} diff --git a/apps/as/src/entities/instl-sncn/index.ts b/apps/as/src/entities/instl-sncn/index.ts index cd340d42d..9547dd6db 100644 --- a/apps/as/src/entities/instl-sncn/index.ts +++ b/apps/as/src/entities/instl-sncn/index.ts @@ -1,12 +1,33 @@ -export { InstlSncnApi } from "./api/instl-sncn-service"; +export { instlSncnRegionService, instlSncnService } from "./api/instl-sncn-service"; export { + instlSncnKeys, + useInstlSncnDetail, + useInstlSncnList, + useInstlSncnSidoCombo, + useInstlSncnSigunguCombo, + useInstlSncnTechReview, +} from "./api/queries"; +export { + type AdstZoneResponseDto, + type AtflMngClientResponseDto, + type BizCstTreeDto, + type BizSclTreeDto, + type CklstRvwRsltTreeDto, + type DocPrgrsUpdateRequestDto, + type ElaprDocPrgrsClientResponseDto, + type EsntlAtchFileMngTreeResponseDto, INSTL_SNCN_REGISTER_SELECTION_KEY, type InstlSncnApplySaveRequestDto, type InstlSncnBizCstSaveRequestDto, type InstlSncnBzScaleSaveDto, type InstlSncnCklstResultSaveDto, - type InstlSncnMng, + type InstlSncnEsntlAtchFileSaveDto, + type InstlSncnMngDetResponse, + type InstlSncnMngResponseDto, + type InstlSncnMngSaveRequestDto, type InstlSncnMngSearchParams, - type SpringPage, - type ElaprDocPrgrsClientResponseDto, + type InstlSncnRvwOpnnResponseDto, + type InstlSncnRvwOpnnSaveRequestDto, + 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 81c769f98..500cfb842 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 @@ -1,78 +1,149 @@ -export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection"; +import type { + AtflMngClientResponseDto, + BizCstSaveBase, + BizCstTreeDto, + BizSclTreeDto, + BzScaleSaveBase, + CklstResultSaveBase, + DocPrgrsUpdateRequestDto, + ElaprDocPrgrsClientResponseDto, + EsntlAtchFileMngTreeResponseDto, + EsntlAtchFileSaveBase, + PageableParams, + PageResponse, + PbadmsApplySaveHeader, + PbadmsMngDetResponseBase, + RvwOpnnResponseExtension, + RvwOpnnSaveBase, + SplmntDmndSaveBase, +} from "@/shared/model"; -export interface SpringPage { - content: T[]; - totalElements: number; - totalPages: number; - number: number; - size: number; - first: boolean; - last: boolean; - numberOfElements: number; +export type { + AtflMngClientResponseDto, + BizCstTreeDto, + BizSclTreeDto, + CklstRvwRsltTreeDto, + DocPrgrsUpdateRequestDto, + ElaprDocPrgrsClientResponseDto, + EsntlAtchFileMngTreeResponseDto, +} from "@/shared/model"; + +/** 행정구역(시도·시군구) 콤보 응답 DTO */ +export interface AdstZoneResponseDto { + /** 행정구역 코드 */ + adstCd: string; + /** 행정구역 명 */ + adstNm: string; } -export interface InstlSncnMngSearchParams { +export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection"; + +export interface InstlSncnMngSearchParams extends PageableParams { bizNm?: string; taskSeComCd?: string; sidoComCd?: string; sggComCd?: string; - page?: number; - size?: number; - sort?: string; } -export interface InstlSncnMng { - gugun: string; - sido: string; - instlSncnDocNo: string; - admdstCd: string; - sidoNm?: string; - sggNm?: string; - chrgrNm?: string; - instNm?: string; - envoSeComCd: string; +/** 설치인가관리 저장 요청 DTO */ +export interface InstlSncnMngSaveRequestDto { + /** 설치인가문서번호 */ + instlSncnDocNo?: string; + /** 행정구역코드 */ + admdstCd?: string; + /** 환경청구분공통코드 */ + envoSeComCd?: string; + /** 인가구분공통코드 */ sncnSeComCd: string; - envoSeCdNm: string; + /** 업무구분공통코드 */ taskSeComCd: string; - taskSeCdNm: string; - bgtBizCd: string; - bgtAplyYr: string; + /** 예산사업코드 */ + bgtBizCd?: string; + /** 예산신청연도 */ + bgtAplyYr?: string; + /** 사업명 */ bizNm: string; + /** 사업시작연도 */ bizBgngYr: string; + /** 사업종료연도 */ bizEndYr: string; + /** 사업내용 */ bizCn: string; - aplcntNm: string; - aplcntMblTelno: string; - aplcntOgdpNm: string; - aplcntEmlAddr: string; + /** 신청자명 */ + aplcntNm?: string; + /** 신청자휴대전화번호 */ + aplcntMblTelno?: string; + /** 신청자소속명 */ + aplcntOgdpNm?: string; + /** 신청자이메일주소 */ + aplcntEmlAddr?: string; + /** 설계자명 */ dsgnrNm: string; + /** 설계자전화번호 */ dsgnrTelno: string; + /** 설계자소속명 */ dsgnrOgdpNm: string; + /** 설계자이메일주소 */ dsgnrEmlAddr: string; - atchFileGroupId: string; - prgrsSttsComCd: string; + /** 첨부파일그룹아이디 */ + atchFileGroupId?: string; + /** 진행상태공통코드 */ + prgrsSttsComCd?: string; + /** 재이용시설구분공통코드 */ + rtlztnFcltSeComCd?: string; + /** 재이용용도구분공통코드 */ + rtlztnUsgSeComCd?: string; +} + +/** 설치인가관리 수정 요청 DTO */ +export type InstlSncnMngUpdateRequestDto = InstlSncnMngSaveRequestDto; + +/** 설치인가관리 응답 DTO */ +export interface InstlSncnMngResponseDto extends InstlSncnMngSaveRequestDto { + /** 설치인가문서번호 (필수로 재정의) */ + instlSncnDocNo: string; + /** 환경청구분공통코드명 */ + envoSeCdNm: string; + /** 인가구분공통코드명 */ + sncnSeCdNm: string; + /** 업무구분공통코드명 */ + taskSeCdNm: string; + /** 진행상태공통코드명 */ prgrsSttsCdNm: string; + /** 재이용시설구분공통코드명 */ + rtlztnFcltSeComNm: string; + /** 재이용용도구분공통코드명 */ + rtlztnUsgSeComNm: string; + /** 등록일시 */ regDt: string; - mdfcnDt: string; + /** 등록자 UUID */ + rgtr: string; + /** 등록자명 */ rgtrNm: string; + /** 수정 일시 */ + mdfcnDt: string; + /** 시도 */ + sido: string; + /** 구군 */ + gugun: string; } /** 설치인가 신청 - 사업규모 저장 DTO(배열 원소) */ -export interface InstlSncnBzScaleSaveDto { +export interface InstlSncnBzScaleSaveDto extends BzScaleSaveBase { /** 공종구분코드 */ cnstpSeCd: string; - /** 기본계획(A) */ + /** 기본계획수량 */ bscPl: number; - /** 예산반영(B) */ + /** 예산반영수량 */ budgetReflct: number; - /** 금회설계시(C) */ + /** 금회설계수량 */ thtiDsgn: number; - /** 증감(C-B) */ - diff: number; + /** 증감(C-B) — 클라이언트 계산용, API 미전송 */ + diff?: number; } /** 설치인가 신청 - 총사업비 저장 DTO(배열 원소) */ -export interface InstlSncnBizCstSaveRequestDto { +export interface InstlSncnBizCstSaveRequestDto extends BizCstSaveBase { /** 회계구분코드 */ acntgSeCd: string; /** 기본계획금액 */ @@ -84,66 +155,90 @@ export interface InstlSncnBizCstSaveRequestDto { } /** 설치인가 신청 - 체크리스트 결과 저장 DTO(배열 원소) */ -export interface InstlSncnCklstResultSaveDto { +export interface InstlSncnCklstResultSaveDto extends CklstResultSaveBase { + /** 점검표일련번호 */ cklstSn: number; + /** 점검표항목일련번호 */ cklstArtclSn: number; + /** 점검표검토일련번호 */ cklstRvwSn: number; - rvwStpComCd: string; + /** 선택값(Y/N/null) */ selected: "Y" | "N" | null; + /** 담당자검토내용(기술검토의견) */ picRvwCn?: string | null; } /** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */ -export interface InstlSncnEsntlAtchFileSaveDto { +export interface InstlSncnEsntlAtchFileSaveDto extends EsntlAtchFileSaveBase { + /** 필수첨부파일아이디 */ esntlAtchFileId: string; + /** 첨부파일그룹아이디 */ atchFileGroupId: string; } +/** 설치인가검토의견 저장 요청 DTO */ +export interface InstlSncnRvwOpnnSaveRequestDto extends RvwOpnnSaveBase { + /** 설치인가문서번호 */ + instlSncnDocNo: string; +} + +/** 설치인가 보완요구 저장 DTO(배열 원소) */ +export type InstlSncnSplmntDmndSaveDto = SplmntDmndSaveBase; + /** 설치인가 신청 - 통합 저장 요청 DTO */ -export interface InstlSncnApplySaveRequestDto { - instlSncnMng: InstlSncnMng; +export interface InstlSncnApplySaveRequestDto extends PbadmsApplySaveHeader { + /** 설치인가관리 정보 */ + instlSncnMng: InstlSncnMngSaveRequestDto; + /** 사업규모 목록 */ bzScaleList: InstlSncnBzScaleSaveDto[]; + /** 총사업비 목록 */ bizCstList: InstlSncnBizCstSaveRequestDto[]; + /** 체크리스트 검토결과 목록 */ cklstResultList: InstlSncnCklstResultSaveDto[]; + /** 체크리스트 검토결과 맵 (rvwStpComCd -> 목록) */ + cklstResultMap?: Record; + /** 필수첨부파일 목록 */ esntlAtchFileList: InstlSncnEsntlAtchFileSaveDto[]; + /** 검토의견 목록 */ + rvwOpnnList?: InstlSncnRvwOpnnSaveRequestDto[]; + /** 보완요구 목록 */ + splmntDmndList?: InstlSncnSplmntDmndSaveDto[]; } export type ReviewStepCode = "LOCAL" | "ENV" | "KECO"; +/** 설치인가검토의견 응답 DTO */ +export type InstlSncnRvwOpnnResponseDto = InstlSncnRvwOpnnSaveRequestDto & + RvwOpnnResponseExtension & { + instlSncnDocNo: string; + rvwOpnnSn: number; + opnrSeComCd: string; + opnrNm: string; + rvwRsltCn: string; + rvwRsltAnmlyYn: string; + atchFileGroupId: string | null; + opnrSeCdNm: string | null; + regDt: string; + }; - -/** 전자결재 진행상태 (클라이언트 응답용) */ -export interface ElaprDocPrgrsClientResponseDto { - elaprDocNo?: string; - elaprDmndId?: string; - elaprPrgrsId?: string; - atrzSeqNo?: number; - atrzSeComCd?: string; - atrzSeComNm?: string; - aprvrUuid?: string; - aprvrUuidNm?: string; - atrzPrgrsSttsComCd?: string; - atrzPrgrsSttsComNm?: string; - rjctRsn?: string | null; - atrzDt?: string | null; - jbpsSeNm?: string; - jbgdSeNm?: string; - ognzNm?: string; -} - -// det 응답에 전자결재 진행상태 목록을 포함 -export interface InstlSncnMngDetResponse { - detDto: InstlSncnMng; - bizSclList: any[]; - bizCstList: any[]; - cklstResultList?: any[]; - esntlFileList: any[]; - etcFileList: any[]; +/** 설치인가관리 상세 응답 DTO */ +export interface InstlSncnMngDetResponse + extends PbadmsMngDetResponseBase { + detDto: InstlSncnMngResponseDto; + bizSclList: BizSclTreeDto[]; + bizCstList: BizCstTreeDto[]; + esntlFileList: EsntlAtchFileMngTreeResponseDto[]; + etcFileList: AtflMngClientResponseDto[]; elaprDocPrgrsList?: ElaprDocPrgrsClientResponseDto[] | null; } export interface InstlSncnMngService { - getPage: (params: InstlSncnMngSearchParams) => Promise>; + getPage: (params: InstlSncnMngSearchParams) => Promise>; getById: (id: string) => Promise; + save: (payload: InstlSncnMngSaveRequestDto) => Promise; saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise; + update: (id: string, payload: InstlSncnMngUpdateRequestDto) => Promise; + techReview: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise; + remove: (id: string) => Promise; + changePrgrsSttsComCd: (payload: DocPrgrsUpdateRequestDto) => Promise; } diff --git a/apps/as/src/entities/rvw-mnpw/api/queries.ts b/apps/as/src/entities/rvw-mnpw/api/queries.ts index 6e075e4d5..7b38c6acf 100644 --- a/apps/as/src/entities/rvw-mnpw/api/queries.ts +++ b/apps/as/src/entities/rvw-mnpw/api/queries.ts @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ENDPOINT } from "@/shared/config"; import type { + RvwMnpwMngPageSearchDto, RvwMnpwMngSaveRequestDto, RvwMnpwMngSearchDto, RvwMnpwMngUpdateRequestDto, @@ -10,9 +11,12 @@ import type { import { rvwMnpwMngService } from "./rvw-mnpw-service"; -const rvwMnpwMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW] as const; +const rvwMnpwMngBaseKey = [ENDPOINT.AS_SERVICE.RVW_MNPW] as const; -export function useRvwMnpwMngPage(params: RvwMnpwMngSearchDto, options?: { enabled?: boolean }) { +export function useRvwMnpwMngPage( + params: RvwMnpwMngPageSearchDto, + options?: { enabled?: boolean }, +) { return useQuery({ queryKey: [...rvwMnpwMngBaseKey, "findPage", params], queryFn: () => rvwMnpwMngService.findPage(params), @@ -20,6 +24,17 @@ export function useRvwMnpwMngPage(params: RvwMnpwMngSearchDto, options?: { enabl }); } +export function useRvwMnpwMngLoginUserList( + searchDto: RvwMnpwMngSearchDto, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: [...rvwMnpwMngBaseKey, "findRvwLoginUserList", searchDto], + queryFn: () => rvwMnpwMngService.findRvwLoginUserList(searchDto), + enabled: options?.enabled, + }); +} + export function useRvwMnpwMng( atrzSeComCd: string | null | undefined, ognzSn: number | null | undefined, diff --git a/apps/as/src/entities/rvw-mnpw/api/rvw-mnpw-service.ts b/apps/as/src/entities/rvw-mnpw/api/rvw-mnpw-service.ts index 4f756787f..2e99f3fba 100644 --- a/apps/as/src/entities/rvw-mnpw/api/rvw-mnpw-service.ts +++ b/apps/as/src/entities/rvw-mnpw/api/rvw-mnpw-service.ts @@ -3,6 +3,7 @@ import { ENDPOINT } from "@/shared/config"; import type { PageResponse } from "@/shared/model"; import type { + RvwMnpwMngPageSearchDto, RvwMnpwMngResponseDto, RvwMnpwMngSaveRequestDto, RvwMnpwMngSearchDto, @@ -10,14 +11,21 @@ import type { RvwMnpwMngUpdateRequestDto, } from "../model/rvw-mnpw-interface"; -const BASE = ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW; +const BASE = ENDPOINT.AS_SERVICE.RVW_MNPW; -function pkPath(atrzSeComCd: string, ognzSn: number, userUuid: string): string { - return `${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`; +async function findPage( + params: RvwMnpwMngPageSearchDto, +): Promise> { + const response = await client.get>(BASE, { params }); + return response.data; } -async function findPage(params: RvwMnpwMngSearchDto): Promise> { - const response = await client.get>(BASE, { params }); +async function findRvwLoginUserList( + searchDto: RvwMnpwMngSearchDto, +): Promise> { + const response = await client.get>(`${BASE}/loginuser/me`, { + params: searchDto, + }); return response.data; } @@ -26,41 +34,48 @@ async function findById( ognzSn: number, userUuid: string, ): Promise { - const response = await client.get(pkPath(atrzSeComCd, ognzSn, userUuid)); + const response = await client.get( + `${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`, + ); return response.data; } -async function save(body: RvwMnpwMngSaveRequestDto): Promise { - const response = await client.post(BASE, body); +async function saveRvwMnpwMng(body: RvwMnpwMngSaveRequestDto): Promise { + const { atrzSeComCd, ognzSn, userUuid } = body; + const response = await client.post( + `${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`, + body, + ); return response.data; } -async function update( +async function updateRvwMnpwMng( atrzSeComCd: string, ognzSn: number, userUuid: string, body: RvwMnpwMngUpdateRequestDto, ): Promise { const response = await client.put( - pkPath(atrzSeComCd, ognzSn, userUuid), + `${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`, body, ); return response.data; } -async function deleteRvwMnpw( +async function deleteRvwMnpwMng( atrzSeComCd: string, ognzSn: number, userUuid: string, ): Promise { - const response = await client.delete(pkPath(atrzSeComCd, ognzSn, userUuid)); + const response = await client.delete(`${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`); return response.data; } export const rvwMnpwMngService = { findPage, + findRvwLoginUserList, findById, - save, - update, - delete: deleteRvwMnpw, + save: saveRvwMnpwMng, + update: updateRvwMnpwMng, + delete: deleteRvwMnpwMng, } satisfies RvwMnpwMngService; diff --git a/apps/as/src/entities/rvw-mnpw/index.ts b/apps/as/src/entities/rvw-mnpw/index.ts index 14dd27dfc..380c50eb7 100644 --- a/apps/as/src/entities/rvw-mnpw/index.ts +++ b/apps/as/src/entities/rvw-mnpw/index.ts @@ -1,12 +1,14 @@ export { useDeleteRvwMnpwMng, useRvwMnpwMng, + useRvwMnpwMngLoginUserList, useRvwMnpwMngPage, useSaveRvwMnpwMng, useUpdateRvwMnpwMng, } from "./api/queries"; export { rvwMnpwMngService } from "./api/rvw-mnpw-service"; export type { + RvwMnpwMngPageSearchDto, RvwMnpwMngResponseDto, RvwMnpwMngSaveRequestDto, RvwMnpwMngSearchDto, diff --git a/apps/as/src/entities/rvw-mnpw/model/rvw-mnpw-interface.ts b/apps/as/src/entities/rvw-mnpw/model/rvw-mnpw-interface.ts index 92cbff071..4fee8df2e 100644 --- a/apps/as/src/entities/rvw-mnpw/model/rvw-mnpw-interface.ts +++ b/apps/as/src/entities/rvw-mnpw/model/rvw-mnpw-interface.ts @@ -14,13 +14,16 @@ export interface RvwMnpwMngResponseDto { regDt?: string; } -/** 백엔드 RvwMnpwMngSearchDto + Pageable */ -export interface RvwMnpwMngSearchDto extends PageableParams { +/** 백엔드 RvwMnpwMngSearchDto */ +export interface RvwMnpwMngSearchDto { atrzSeComCd?: string; ognzSn?: number; userUuid?: string; } +/** findPage — RvwMnpwMngSearchDto + Pageable */ +export type RvwMnpwMngPageSearchDto = RvwMnpwMngSearchDto & PageableParams; + /** 백엔드 RvwMnpwMngSaveRequestDto */ export interface RvwMnpwMngSaveRequestDto { atrzSeComCd: string; @@ -35,7 +38,10 @@ export interface RvwMnpwMngUpdateRequestDto { /** Controller public 메서드명·시그니처와 동일 */ export interface RvwMnpwMngService { - findPage(params: RvwMnpwMngSearchDto): Promise>; + findPage(params: RvwMnpwMngPageSearchDto): Promise>; + findRvwLoginUserList( + searchDto: RvwMnpwMngSearchDto, + ): Promise>; findById(atrzSeComCd: string, ognzSn: number, userUuid: string): Promise; save(body: RvwMnpwMngSaveRequestDto): Promise; update( diff --git a/apps/as/src/entities/tus/index.ts b/apps/as/src/entities/tus/index.ts new file mode 100644 index 000000000..5cf2e3fbf --- /dev/null +++ b/apps/as/src/entities/tus/index.ts @@ -0,0 +1 @@ +export { handleTusUpload, type TusUploadOptions } from "./lib/utils"; diff --git a/apps/as/src/entities/tus/lib/utils.ts b/apps/as/src/entities/tus/lib/utils.ts new file mode 100644 index 000000000..6bb40bfd7 --- /dev/null +++ b/apps/as/src/entities/tus/lib/utils.ts @@ -0,0 +1,61 @@ +import { Upload } from "tus-js-client"; + +import { ENDPOINT, PROXY_HOST } from "@/shared/config"; +import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config/env"; +import { getCookie } from "@/shared/lib"; + +interface TusUploadOptions { + metadata?: Record; + handleUploadStart?: (upload: Upload) => void; + handleProgress?: (percentage: number) => void; + handleSuccess?: (upload: Upload, atflCd: string, atflId: string) => void; + handleError?: (error: Error) => void; +} + +function handleTusUpload(file: File, options?: TusUploadOptions) { + let atflCd = ""; + let atflId = ""; + + const metadata = { + filename: file.name, + filetype: file.type, + ...options?.metadata, + }; + + const upload = new Upload(file, { + endpoint: `${PROXY_HOST}${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`, + chunkSize: 10 * 1024 * 1024, + headers: { + [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "", + [AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "", + }, + metadata, + onError(error) { + console.error("TUS upload failed:", error); + options?.handleError?.(error); + }, + onProgress(bytesUploaded, bytesTotal) { + const percentage = (bytesUploaded / bytesTotal) * 100; + options?.handleProgress?.(percentage); + }, + onAfterResponse(_req, res) { + const fileInfo = res.getHeader("X-File-Info"); + if (!fileInfo) return; + + const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string }; + atflCd = parsed.atflCd ?? ""; + atflId = parsed.atflId ?? ""; + }, + onSuccess() { + options?.handleSuccess?.(upload, atflCd, atflId); + }, + }); + + upload.start(); + options?.handleUploadStart?.(upload); + + return upload; +} + +export { handleTusUpload }; +export type { TusUploadOptions }; diff --git a/apps/as/src/pages/acntg-code-management/index.ts b/apps/as/src/pages/acntg-code-management/index.ts deleted file mode 100644 index ee4bfa445..000000000 --- a/apps/as/src/pages/acntg-code-management/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AcntgCodeManagementPage } from "./ui/acntg-code-management-page"; diff --git a/apps/as/src/pages/acntg-code-management/lib/acntg-se-grid.ts b/apps/as/src/pages/acntg-code-management/lib/acntg-se-grid.ts deleted file mode 100644 index c0ca7abc7..000000000 --- a/apps/as/src/pages/acntg-code-management/lib/acntg-se-grid.ts +++ /dev/null @@ -1,245 +0,0 @@ -import type { AcntgSeMngResponseDto } from "@/entities/acntg"; - -import type { - AcntgDisplaySlot, - AcntgSeBaseEntry, - AcntgSeGridState, -} from "../model/acntg-se-grid-types"; -import type { AcntgSeRow } from "../model/acntg-se-row-types"; -import { buildAcntgDisplayRows } from "../model/build-acntg-display-rows"; - -export function createLoadedRowKey(index: number, acntgSeCd: string): string { - return `loaded-${index}-${acntgSeCd}`; -} - -export function createDraftRowKey(): string { - return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; -} - -export function createEmptyGridState(): AcntgSeGridState { - return { - baseByKey: {}, - structure: [], - drafts: {}, - patches: {}, - deletedKeys: new Set(), - }; -} - -function toInitialRow(entry: AcntgSeBaseEntry): AcntgSeRow { - return { - ...entry, - isNew: false, - isDirty: false, - isDeleted: false, - }; -} - -export function buildStructureFromBaseRows( - baseByKey: Record, -): AcntgDisplaySlot[] { - const rows = Object.values(baseByKey).map(toInitialRow); - const display = buildAcntgDisplayRows(rows); - const cdToRowKey = new Map(rows.map((row) => [row.acntgSeCd, row.rowKey])); - - return display.map(({ row, depth }) => ({ - rowKey: row.rowKey, - depth, - parentRowKey: row.upAcntgSeCd?.trim() ? (cdToRowKey.get(row.upAcntgSeCd.trim()) ?? null) : null, - })); -} - -export function hydrateGridFromServer(content: AcntgSeMngResponseDto[]): AcntgSeGridState { - const baseByKey: Record = {}; - - content.forEach((item, index) => { - const rowKey = createLoadedRowKey(index, item.acntgSeCd); - baseByKey[rowKey] = { ...item, rowKey }; - }); - - return { - baseByKey, - structure: buildStructureFromBaseRows(baseByKey), - drafts: {}, - patches: {}, - deletedKeys: new Set(), - }; -} - -export function resolveParentAcntgSeCd( - state: AcntgSeGridState, - parentRowKey: string | null, -): string | undefined { - if (!parentRowKey) return undefined; - const parent = mergeAcntgRow(state, parentRowKey); - const cd = parent?.acntgSeCd?.trim(); - return cd || undefined; -} - -export function mergeAcntgRow(state: AcntgSeGridState, rowKey: string): AcntgSeRow | null { - const draft = state.drafts[rowKey]; - if (draft) { - return { - rowKey, - acntgSeCd: draft.acntgSeCd, - acntgSeCdNm: draft.acntgSeCdNm, - expln: draft.expln, - useYn: draft.useYn, - cdLvNo: draft.cdLvNo, - upAcntgSeCd: resolveParentAcntgSeCd(state, draft.parentRowKey), - isNew: true, - isDirty: true, - isDeleted: false, - }; - } - - const base = state.baseByKey[rowKey]; - if (!base) return null; - - const patch = state.patches[rowKey] ?? {}; - const hasPatch = Object.keys(patch).length > 0; - - return { - ...base, - ...patch, - rowKey, - isNew: false, - isDirty: hasPatch, - isDeleted: state.deletedKeys.has(rowKey), - }; -} - -export function mergeAllAcntgRows(state: AcntgSeGridState): AcntgSeRow[] { - const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]); - - return [...keys] - .map((rowKey) => mergeAcntgRow(state, rowKey)) - .filter((row): row is AcntgSeRow => row != null); -} - -export function hasAcntgGridPendingChanges(state: AcntgSeGridState): boolean { - return ( - state.deletedKeys.size > 0 || - Object.keys(state.drafts).length > 0 || - Object.keys(state.patches).length > 0 - ); -} - -export function collectDescendantRowKeys( - structure: AcntgDisplaySlot[], - rootRowKey: string, -): Set { - const result = new Set([rootRowKey]); - const queue = [rootRowKey]; - - while (queue.length > 0) { - const parentKey = queue.shift()!; - for (const slot of structure) { - if (slot.parentRowKey === parentKey && !result.has(slot.rowKey)) { - result.add(slot.rowKey); - queue.push(slot.rowKey); - } - } - } - - return result; -} - -export function insertRootSlot( - structure: AcntgDisplaySlot[], - slot: AcntgDisplaySlot, -): AcntgDisplaySlot[] { - const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null); - if (firstRootIndex === -1) return [slot, ...structure]; - return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)]; -} - -export function insertChildSlot( - structure: AcntgDisplaySlot[], - parentRowKey: string, - slot: AcntgDisplaySlot, -): AcntgDisplaySlot[] { - const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey); - if (parentIndex === -1) return [...structure, slot]; - - const insertAt = parentIndex + 1; - return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)]; -} - -export function removeSlotsFromStructure( - structure: AcntgDisplaySlot[], - rowKeysToRemove: Set, -): AcntgDisplaySlot[] { - return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey)); -} - -export function patchAcntgGridRow( - state: AcntgSeGridState, - rowKey: string, - patch: Partial, -): AcntgSeGridState { - const draft = state.drafts[rowKey]; - if (draft) { - return { - ...state, - drafts: { - ...state.drafts, - [rowKey]: { - ...draft, - ...(patch.acntgSeCd !== undefined ? { acntgSeCd: patch.acntgSeCd } : {}), - ...(patch.acntgSeCdNm !== undefined ? { acntgSeCdNm: patch.acntgSeCdNm ?? "" } : {}), - ...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}), - ...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}), - }, - }, - }; - } - - const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch; - - return { - ...state, - patches: { - ...state.patches, - [rowKey]: { ...state.patches[rowKey], ...dtoPatch }, - }, - }; -} - -export function markAcntgRowsDeleted( - state: AcntgSeGridState, - rootRowKey: string, -): AcntgSeGridState { - const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey); - const deletedKeys = new Set(state.deletedKeys); - for (const key of keysToDelete) { - deletedKeys.add(key); - } - return { ...state, deletedKeys }; -} - -export function validateDraftParentAcntgSeCd( - state: AcntgSeGridState, - rowKey: string, -): string | null { - const draft = state.drafts[rowKey]; - if (!draft?.parentRowKey) return null; - - const parent = mergeAcntgRow(state, draft.parentRowKey); - if (!parent?.acntgSeCd?.trim()) { - return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요."; - } - - return null; -} - -export function removeDraftFromGrid(state: AcntgSeGridState, rowKey: string): AcntgSeGridState { - const { [rowKey]: _removed, ...restDrafts } = state.drafts; - const keysToRemove = collectDescendantRowKeys(state.structure, rowKey); - - return { - ...state, - drafts: restDrafts, - structure: removeSlotsFromStructure(state.structure, keysToRemove), - }; -} diff --git a/apps/as/src/pages/acntg-code-management/lib/build-save-multi-payload.ts b/apps/as/src/pages/acntg-code-management/lib/build-save-multi-payload.ts deleted file mode 100644 index f18634b51..000000000 --- a/apps/as/src/pages/acntg-code-management/lib/build-save-multi-payload.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { ACNTG_SE_SAVE_MODE, type AcntgSeMngSaveRequestDto } from "@/entities/acntg"; - -import type { AcntgSeRow } from "../model/acntg-se-row-types"; - -import { getVisibleRows } from "./row-state"; - -function compareCdLvNoDesc(a: AcntgSeRow, b: AcntgSeRow): number { - return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1); -} - -function compareCdLvNoAsc(a: AcntgSeRow, b: AcntgSeRow): number { - return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1); -} - -export function buildSaveMultiPayload(rows: AcntgSeRow[]): AcntgSeMngSaveRequestDto[] { - const payload: AcntgSeMngSaveRequestDto[] = []; - - const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc); - - for (const row of deletedRows) { - payload.push({ - mode: ACNTG_SE_SAVE_MODE.DELETE, - acntgSeCd: row.acntgSeCd, - acntgSeCdNm: row.acntgSeCdNm?.trim() || "", - upAcntgSeCd: row.upAcntgSeCd, - cdLvNo: row.cdLvNo, - useYn: row.useYn, - expln: row.expln?.trim() || undefined, - }); - } - - const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc); - - for (const row of visibleRows) { - if (row.isNew) { - payload.push({ - mode: ACNTG_SE_SAVE_MODE.CREATE, - acntgSeCd: row.acntgSeCd.trim(), - acntgSeCdNm: row.acntgSeCdNm.trim(), - upAcntgSeCd: row.upAcntgSeCd, - cdLvNo: row.cdLvNo, - useYn: row.useYn, - expln: row.expln?.trim() || undefined, - }); - continue; - } - - if (row.isDirty) { - payload.push({ - mode: ACNTG_SE_SAVE_MODE.UPDATE, - acntgSeCd: row.acntgSeCd.trim(), - acntgSeCdNm: row.acntgSeCdNm.trim(), - upAcntgSeCd: row.upAcntgSeCd, - cdLvNo: row.cdLvNo, - useYn: row.useYn, - expln: row.expln?.trim() || undefined, - }); - } - } - - return payload; -} diff --git a/apps/as/src/pages/acntg-code-management/lib/row-state.ts b/apps/as/src/pages/acntg-code-management/lib/row-state.ts deleted file mode 100644 index 908df1f70..000000000 --- a/apps/as/src/pages/acntg-code-management/lib/row-state.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { AcntgSeRow } from "../model/acntg-se-row-types"; - -export function getVisibleRows(rows: AcntgSeRow[]): AcntgSeRow[] { - return rows.filter((row) => !row.isDeleted); -} diff --git a/apps/as/src/pages/acntg-code-management/lib/use-acntg-se-rows.ts b/apps/as/src/pages/acntg-code-management/lib/use-acntg-se-rows.ts deleted file mode 100644 index 5fd9f85c5..000000000 --- a/apps/as/src/pages/acntg-code-management/lib/use-acntg-se-rows.ts +++ /dev/null @@ -1,172 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import type { AcntgSeMngResponseDto } from "@/entities/acntg"; - -import type { AcntgSeGridState } from "../model/acntg-se-grid-types"; -import type { AcntgSeRow } from "../model/acntg-se-row-types"; - -import { - createDraftRowKey, - createEmptyGridState, - hasAcntgGridPendingChanges, - hydrateGridFromServer, - insertChildSlot, - insertRootSlot, - markAcntgRowsDeleted, - mergeAcntgRow, - mergeAllAcntgRows, - patchAcntgGridRow, - removeDraftFromGrid, - validateDraftParentAcntgSeCd, -} from "./acntg-se-grid"; -import { buildSaveMultiPayload } from "./build-save-multi-payload"; - -function validateAcntgSeRow(row: AcntgSeRow): string | null { - if (!row.acntgSeCd.trim()) return "코드를 입력하세요."; - if (!row.acntgSeCdNm?.trim()) return "코드명을 입력하세요."; - const parentCd = row.upAcntgSeCd?.trim(); - if (parentCd && row.acntgSeCd.trim() === parentCd) { - return "상위 코드와 동일한 코드는 사용할 수 없습니다."; - } - return null; -} - -interface UseAcntgSeRowsOptions { - serverContent: AcntgSeMngResponseDto[] | undefined; - onAlert: (message: string) => void; - onSave: (payload: ReturnType) => Promise; -} - -export function useAcntgSeRows({ serverContent, onAlert, onSave }: UseAcntgSeRowsOptions) { - const [grid, setGrid] = useState(createEmptyGridState); - const hasPendingRef = useRef(false); - - hasPendingRef.current = hasAcntgGridPendingChanges(grid); - - useEffect(() => { - if (serverContent == null) return; - if (hasPendingRef.current) return; - setGrid(hydrateGridFromServer(serverContent)); - }, [serverContent]); - - const displayRows = useMemo(() => { - return grid.structure - .filter((slot) => !grid.deletedKeys.has(slot.rowKey)) - .map((slot) => { - const row = mergeAcntgRow(grid, slot.rowKey); - return row ? { row, depth: slot.depth } : null; - }) - .filter((item): item is { row: AcntgSeRow; depth: number } => item != null); - }, [grid]); - - const canSave = hasAcntgGridPendingChanges(grid); - - const updateRow = useCallback((rowKey: string, patch: Partial) => { - setGrid((prev) => patchAcntgGridRow(prev, rowKey, patch)); - }, []); - - const addRootRow = useCallback(() => { - const rowKey = createDraftRowKey(); - setGrid((prev) => ({ - ...prev, - drafts: { - ...prev.drafts, - [rowKey]: { - rowKey, - parentRowKey: null, - cdLvNo: 1, - acntgSeCd: "", - acntgSeCdNm: "", - expln: "", - useYn: "Y", - }, - }, - structure: insertRootSlot(prev.structure, { rowKey, depth: 0, parentRowKey: null }), - })); - }, []); - - const addChildRow = useCallback((parent: AcntgSeRow) => { - const rowKey = createDraftRowKey(); - const slot = { - rowKey, - depth: 0, - parentRowKey: parent.rowKey, - }; - - setGrid((prev) => { - const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey); - const depth = (parentSlot?.depth ?? 0) + 1; - - return { - ...prev, - drafts: { - ...prev.drafts, - [rowKey]: { - rowKey, - parentRowKey: parent.rowKey, - cdLvNo: (parent.cdLvNo ?? 1) + 1, - acntgSeCd: "", - acntgSeCdNm: "", - expln: "", - useYn: "Y", - }, - }, - structure: insertChildSlot(prev.structure, parent.rowKey, { - ...slot, - depth, - }), - }; - }); - }, []); - - const removeDraft = useCallback((rowKey: string) => { - setGrid((prev) => removeDraftFromGrid(prev, rowKey)); - }, []); - - const markDeleted = useCallback((rowKey: string) => { - setGrid((prev) => markAcntgRowsDeleted(prev, rowKey)); - }, []); - - const resetFromServer = useCallback((content: AcntgSeMngResponseDto[]) => { - setGrid(hydrateGridFromServer(content)); - }, []); - - const save = useCallback(async () => { - const mergedRows = mergeAllAcntgRows(grid); - const payload = buildSaveMultiPayload(mergedRows); - if (payload.length === 0) return false; - - for (const row of mergedRows) { - if (!row.isDeleted && (row.isNew || row.isDirty)) { - const parentCodeError = validateDraftParentAcntgSeCd(grid, row.rowKey); - if (parentCodeError) { - onAlert(parentCodeError); - return false; - } - - const fieldError = validateAcntgSeRow(row); - if (fieldError) { - onAlert(fieldError); - return false; - } - } - } - - await onSave(payload); - return true; - }, [grid, onAlert, onSave]); - - return { - displayRows, - canSave, - updateRow, - addRootRow, - addChildRow, - removeDraft, - markDeleted, - resetFromServer, - save, - }; -} diff --git a/apps/as/src/pages/acntg-code-management/ui/acntg-code-management-page.tsx b/apps/as/src/pages/acntg-code-management/ui/acntg-code-management-page.tsx deleted file mode 100644 index d480ced59..000000000 --- a/apps/as/src/pages/acntg-code-management/ui/acntg-code-management-page.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import { useCallback } from "react"; - -import { useConfirm } from "@frontend-opensource/use-react-hooks"; - -import { useAlertStore } from "@/shared/lib"; -import { useAcntgSeMngPage, useSaveMultiAcntgSeMng } from "@/entities/acntg"; -import { - Button, - Skeleton, - Table, - TableBody, - TableHead, - TableHeader, - TableRow, -} from "@common/components"; - -import { useAcntgSeRows } from "../lib/use-acntg-se-rows"; -import type { AcntgSeRow } from "../model/acntg-se-row-types"; - -import { AcntgSeRowComponent } from "./acntg-se-row"; - -function AcntgCodeManagementPage() { - const { confirm } = useConfirm(); - const alert = useAlertStore((state) => state.setMessage); - - const pageQuery = useAcntgSeMngPage({ - page: 0, - size: 500, - sort: "cdLvNo,asc", - }); - - const saveMultiMutation = useSaveMultiAcntgSeMng(); - - const handlePersist = useCallback( - async (payload: Parameters[0]) => { - await saveMultiMutation.mutateAsync(payload); - }, - [saveMultiMutation], - ); - - const { - displayRows, - canSave, - updateRow, - addRootRow, - addChildRow, - removeDraft, - markDeleted, - resetFromServer, - save, - } = useAcntgSeRows({ - serverContent: pageQuery.data?.content, - onAlert: alert, - onSave: handlePersist, - }); - - const handleDelete = async (row: AcntgSeRow) => { - if (!(await confirm("삭제하시겠습니까?"))) return; - - if (row.isNew) { - removeDraft(row.rowKey); - return; - } - - markDeleted(row.rowKey); - }; - - const handleSave = async () => { - if (!(await confirm("저장하시겠습니까?"))) return; - - const saved = await save(); - if (!saved) return; - - alert("저장되었습니다."); - const result = await pageQuery.refetch(); - resetFromServer(result.data?.content ?? []); - }; - - const isSaving = saveMultiMutation.isPending; - - return ( -
-
-
-

회계코드 관리

-
- - -
-
- - {pageQuery.isPending ? ( -
- - -
- ) : displayRows.length === 0 ? ( -

- 등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요. -

- ) : ( -
- - - - - - - - - - - - 코드 - 코드명 - 설명 - 사용 여부 - 구분 - 관리 - - - - {displayRows.map(({ row, depth }) => ( - updateRow(row.rowKey, patch)} - onAddChild={() => addChildRow(row)} - onDelete={() => handleDelete(row)} - /> - ))} - -
-
- )} -
-
- ); -} - -export { AcntgCodeManagementPage }; diff --git a/apps/as/src/pages/approval-line-management/lib/assign-atrz-seq.ts b/apps/as/src/pages/approval-line-management/lib/assign-atrz-seq.ts deleted file mode 100644 index 6b5b63bd4..000000000 --- a/apps/as/src/pages/approval-line-management/lib/assign-atrz-seq.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { ElaprAplnRow } from "../model/apln-row-types"; - -/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */ -export function assignAtrzSeqNumbers(rows: ElaprAplnRow[]): ElaprAplnRow[] { - return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 })); -} diff --git a/apps/as/src/pages/approval-line-management/lib/has-unsaved-changes.ts b/apps/as/src/pages/approval-line-management/lib/has-unsaved-changes.ts deleted file mode 100644 index 46efa59f3..000000000 --- a/apps/as/src/pages/approval-line-management/lib/has-unsaved-changes.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { ElaprApln } from "@/entities/elapr"; - -import { assignAtrzSeqNumbers } from "./assign-atrz-seq"; -import { getVisibleRows } from "./validators"; -import type { ElaprAplnRow } from "../model/apln-row-types"; - -function rowDiffersFromServer(row: ElaprAplnRow, serverRow: ElaprApln): boolean { - return ( - row.aprvrUuid !== serverRow.aprvrUuid || - row.atrzSeComCd !== serverRow.atrzSeComCd || - row.atrzSeqNo !== serverRow.atrzSeqNo - ); -} - -export function hasUnsavedChanges(rows: ElaprAplnRow[], serverRows: ElaprApln[]): boolean { - if (rows.some((row) => row.isDeleted && row.aplnId)) return true; - if (rows.some((row) => row.isNew && !row.isDeleted)) return true; - - const visibleWithSeq = assignAtrzSeqNumbers(getVisibleRows(rows)); - - for (const row of visibleWithSeq) { - if (row.isNew) continue; - if (!row.aplnId) continue; - if (row.isDirty) return true; - - const serverRow = serverRows.find((s) => s.aplnId === row.aplnId); - if (!serverRow) continue; - if (rowDiffersFromServer(row, serverRow)) return true; - } - - return false; -} diff --git a/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx b/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx index c2393715b..83c01c7ab 100644 --- a/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx +++ b/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx @@ -7,7 +7,19 @@ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable" import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useAlertStore } from "@/shared/lib"; -import { useElaprAplnPage, useSaveMultiElaprApln } from "@/entities/elapr"; +import { + buildSaveMultiPayload, + type ElaprAplnRow, + hasUnsavedChanges, + useAplnRows, + useDndSensors, + useElaprAplnPage, + useSaveMultiElaprApln, + validateAllRowsForSave, + validateAplnRow, + validateAtrzOrder, + validateUniqueSeqNo, +} from "@/entities/elapr"; import { Button, Skeleton, @@ -18,17 +30,6 @@ import { TableRow, } from "@common/components"; -import { buildSaveMultiPayload } from "../lib/build-save-multi-payload"; -import { hasUnsavedChanges } from "../lib/has-unsaved-changes"; -import { useAplnRows } from "../lib/use-apln-rows"; -import { useDndSensors } from "../lib/use-dnd-sensors"; -import { - validateAllRowsForSave, - validateAplnRow, - validateAtrzOrder, - validateUniqueSeqNo, -} from "../lib/validators"; -import type { ElaprAplnRow } from "../model/apln-row-types"; import type { ReviewerOption } from "../model/reviewer-option"; import { AprvrPickerDialog } from "./aprvr-picker-dialog"; diff --git a/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-form-dialog.tsx b/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-form-dialog.tsx index 3e7f36de1..8aea03c7e 100644 --- a/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-form-dialog.tsx +++ b/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-form-dialog.tsx @@ -92,7 +92,7 @@ function ElaprTmpltFormDialog({
setValue("docSeComCd", v)} placeholder="문서구분 선택" 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 fcb315bdb..94d28be22 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 @@ -5,10 +5,9 @@ import { CSS } from "@dnd-kit/utilities"; import { Equal } from "lucide-react"; import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code"; +import type { ElaprAplnRow } from "@/entities/elapr"; import { Button, TableCell, TableRow } from "@common/components"; -import type { ElaprAplnRow } from "../model/apln-row-types"; - interface SortableAplnRowProps { row: ElaprAplnRow; displaySeqNo: number; diff --git a/apps/as/src/pages/checklist-management/ui/cklst-mng-form-dialog.tsx b/apps/as/src/pages/checklist-management/ui/cklst-mng-form-dialog.tsx index 8ff919256..3efa22ba9 100644 --- a/apps/as/src/pages/checklist-management/ui/cklst-mng-form-dialog.tsx +++ b/apps/as/src/pages/checklist-management/ui/cklst-mng-form-dialog.tsx @@ -102,7 +102,7 @@ function CklstMngFormDialog({
setValue("docSeComCd", v)} placeholder="문서구분 선택" @@ -112,7 +112,7 @@ function CklstMngFormDialog({
setValue("taskSeComCd", v)} placeholder="업무구분 선택" diff --git a/apps/as/src/pages/checklist-management/ui/cklst-preview-dialog.tsx b/apps/as/src/pages/checklist-management/ui/cklst-preview-dialog.tsx index 42f2edcf8..c6c1f3746 100644 --- a/apps/as/src/pages/checklist-management/ui/cklst-preview-dialog.tsx +++ b/apps/as/src/pages/checklist-management/ui/cklst-preview-dialog.tsx @@ -67,6 +67,7 @@ function CklstPreviewDialog({ {displayRows.map(({ row, depth }) => { const branchPrefix = formatTreeBranchPrefix(depth); + const showRadios = row.bchckUseYn !== "N"; if (row.useYn === "N") return null; @@ -82,16 +83,20 @@ function CklstPreviewDialog({ {row.rvwCn ?? "—"} - -
- - -
-
- - -
-
+ {showRadios ? ( + +
+ + +
+
+ + +
+
+ ) : ( + "—" + )}
); diff --git a/apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx b/apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx index 8eca3ce21..7b44ba2b5 100644 --- a/apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx +++ b/apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx @@ -113,6 +113,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) { rvwCnLvNo: 1, rvwCn: "", useYn: "Y", + bchckUseYn: "Y", sortSeq: maxSort + 1, isNew: true, isDirty: true, @@ -145,6 +146,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) { rvwCnLvNo: parentLv + 1, rvwCn: "", useYn: "Y", + bchckUseYn: "Y", sortSeq: maxSort + 1, isNew: true, isDirty: true, @@ -189,6 +191,8 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) { const handleSave = async () => { if (!cklstSn) return; + if (!(await confirm("체크리스트 항목을 저장하시겠습니까?"))) return; + const dirty = getDirtyRows().sort((a, b) => (a.rvwCnLvNo ?? 1) - (b.rvwCnLvNo ?? 1)); for (const row of dirty) { @@ -198,8 +202,6 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) { return; } - if (!(await confirm("체크리스트 항목을 저장하시겠습니까?"))) return; - if (row.isNew) { await createMutation.mutateAsync({ cklstSn: row.cklstSn, @@ -209,7 +211,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) { rvwCn: row.rvwCn, useYn: row.useYn, sortSeq: row.sortSeq, - bchckUseYn: "Y", + bchckUseYn: row.bchckUseYn ?? "Y", }); } else if (row.cklstRvwSn != null) { await updateMutation.mutateAsync({ @@ -277,17 +279,19 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) { ) : ( - - - + + + + - + 점검항목 주요 검토내용 사용 여부 + 체크 사용 구분 관리 diff --git a/apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx b/apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx index 9ea9094ce..c8bc7cb76 100644 --- a/apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx +++ b/apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx @@ -91,6 +91,22 @@ function CklstRvwRowComponent({ + + onChange({ bchckUseYn: v as "Y" | "N", isDirty: true })} + className="flex justify-center gap-4" + > +
+ + +
+
+ + +
+
+
{levelLabel}
diff --git a/apps/as/src/pages/cnstp-code-management/index.ts b/apps/as/src/pages/cnstp-code-management/index.ts deleted file mode 100644 index 2ed1cb4ba..000000000 --- a/apps/as/src/pages/cnstp-code-management/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CnstpCodeManagementPage } from "./ui/cnstp-code-management-page"; diff --git a/apps/as/src/pages/cnstp-code-management/lib/build-save-multi-payload.ts b/apps/as/src/pages/cnstp-code-management/lib/build-save-multi-payload.ts deleted file mode 100644 index 20ec66853..000000000 --- a/apps/as/src/pages/cnstp-code-management/lib/build-save-multi-payload.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { CNSTP_SE_SAVE_MODE, type CnstpSeMngSaveRequestDto } from "@/entities/cnstp"; - -import type { CnstpSeRow } from "../model/cnstp-se-row-types"; - -import { getVisibleRows } from "./row-state"; - -function compareCdLvNoDesc(a: CnstpSeRow, b: CnstpSeRow): number { - return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1); -} - -function compareCdLvNoAsc(a: CnstpSeRow, b: CnstpSeRow): number { - return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1); -} - -export function buildSaveMultiPayload(rows: CnstpSeRow[]): CnstpSeMngSaveRequestDto[] { - const payload: CnstpSeMngSaveRequestDto[] = []; - - const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc); - - for (const row of deletedRows) { - payload.push({ - mode: CNSTP_SE_SAVE_MODE.DELETE, - cnstpSeCd: row.cnstpSeCd, - cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined, - upCnstpCd: row.upCnstpCd, - cdLvNo: row.cdLvNo, - useYn: row.useYn, - expln: row.expln?.trim() || undefined, - }); - } - - const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc); - - for (const row of visibleRows) { - if (row.isNew) { - payload.push({ - mode: CNSTP_SE_SAVE_MODE.CREATE, - cnstpSeCd: row.cnstpSeCd.trim(), - cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined, - upCnstpCd: row.upCnstpCd, - cdLvNo: row.cdLvNo, - useYn: row.useYn, - expln: row.expln?.trim() || undefined, - }); - continue; - } - - if (row.isDirty) { - payload.push({ - mode: CNSTP_SE_SAVE_MODE.UPDATE, - cnstpSeCd: row.cnstpSeCd.trim(), - cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined, - upCnstpCd: row.upCnstpCd, - cdLvNo: row.cdLvNo, - useYn: row.useYn, - expln: row.expln?.trim() || undefined, - }); - } - } - - return payload; -} diff --git a/apps/as/src/pages/cnstp-code-management/lib/cnstp-se-grid.ts b/apps/as/src/pages/cnstp-code-management/lib/cnstp-se-grid.ts deleted file mode 100644 index 6b1bd99f9..000000000 --- a/apps/as/src/pages/cnstp-code-management/lib/cnstp-se-grid.ts +++ /dev/null @@ -1,245 +0,0 @@ -import type { CnstpSeMngResponseDto } from "@/entities/cnstp"; - -import { buildCnstpDisplayRows } from "../model/build-cnstp-display-rows"; -import type { - CnstpDisplaySlot, - CnstpSeBaseEntry, - CnstpSeGridState, -} from "../model/cnstp-se-grid-types"; -import type { CnstpSeRow } from "../model/cnstp-se-row-types"; - -export function createLoadedRowKey(index: number, cnstpSeCd: string): string { - return `loaded-${index}-${cnstpSeCd}`; -} - -export function createDraftRowKey(): string { - return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; -} - -export function createEmptyGridState(): CnstpSeGridState { - return { - baseByKey: {}, - structure: [], - drafts: {}, - patches: {}, - deletedKeys: new Set(), - }; -} - -function toInitialRow(entry: CnstpSeBaseEntry): CnstpSeRow { - return { - ...entry, - isNew: false, - isDirty: false, - isDeleted: false, - }; -} - -export function buildStructureFromBaseRows( - baseByKey: Record, -): CnstpDisplaySlot[] { - const rows = Object.values(baseByKey).map(toInitialRow); - const display = buildCnstpDisplayRows(rows); - const cdToRowKey = new Map(rows.map((row) => [row.cnstpSeCd, row.rowKey])); - - return display.map(({ row, depth }) => ({ - rowKey: row.rowKey, - depth, - parentRowKey: row.upCnstpCd?.trim() ? (cdToRowKey.get(row.upCnstpCd.trim()) ?? null) : null, - })); -} - -export function hydrateGridFromServer(content: CnstpSeMngResponseDto[]): CnstpSeGridState { - const baseByKey: Record = {}; - - content.forEach((item, index) => { - const rowKey = createLoadedRowKey(index, item.cnstpSeCd); - baseByKey[rowKey] = { ...item, rowKey }; - }); - - return { - baseByKey, - structure: buildStructureFromBaseRows(baseByKey), - drafts: {}, - patches: {}, - deletedKeys: new Set(), - }; -} - -export function resolveParentCnstpSeCd( - state: CnstpSeGridState, - parentRowKey: string | null, -): string | undefined { - if (!parentRowKey) return undefined; - const parent = mergeCnstpRow(state, parentRowKey); - const cd = parent?.cnstpSeCd?.trim(); - return cd || undefined; -} - -export function mergeCnstpRow(state: CnstpSeGridState, rowKey: string): CnstpSeRow | null { - const draft = state.drafts[rowKey]; - if (draft) { - return { - rowKey, - cnstpSeCd: draft.cnstpSeCd, - cnstpSeCdNm: draft.cnstpSeCdNm, - expln: draft.expln, - useYn: draft.useYn, - cdLvNo: draft.cdLvNo, - upCnstpCd: resolveParentCnstpSeCd(state, draft.parentRowKey), - isNew: true, - isDirty: true, - isDeleted: false, - }; - } - - const base = state.baseByKey[rowKey]; - if (!base) return null; - - const patch = state.patches[rowKey] ?? {}; - const hasPatch = Object.keys(patch).length > 0; - - return { - ...base, - ...patch, - rowKey, - isNew: false, - isDirty: hasPatch, - isDeleted: state.deletedKeys.has(rowKey), - }; -} - -export function mergeAllCnstpRows(state: CnstpSeGridState): CnstpSeRow[] { - const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]); - - return [...keys] - .map((rowKey) => mergeCnstpRow(state, rowKey)) - .filter((row): row is CnstpSeRow => row != null); -} - -export function hasCnstpGridPendingChanges(state: CnstpSeGridState): boolean { - return ( - state.deletedKeys.size > 0 || - Object.keys(state.drafts).length > 0 || - Object.keys(state.patches).length > 0 - ); -} - -export function collectDescendantRowKeys( - structure: CnstpDisplaySlot[], - rootRowKey: string, -): Set { - const result = new Set([rootRowKey]); - const queue = [rootRowKey]; - - while (queue.length > 0) { - const parentKey = queue.shift()!; - for (const slot of structure) { - if (slot.parentRowKey === parentKey && !result.has(slot.rowKey)) { - result.add(slot.rowKey); - queue.push(slot.rowKey); - } - } - } - - return result; -} - -export function insertRootSlot( - structure: CnstpDisplaySlot[], - slot: CnstpDisplaySlot, -): CnstpDisplaySlot[] { - const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null); - if (firstRootIndex === -1) return [slot, ...structure]; - return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)]; -} - -export function insertChildSlot( - structure: CnstpDisplaySlot[], - parentRowKey: string, - slot: CnstpDisplaySlot, -): CnstpDisplaySlot[] { - const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey); - if (parentIndex === -1) return [...structure, slot]; - - const insertAt = parentIndex + 1; - return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)]; -} - -export function removeSlotsFromStructure( - structure: CnstpDisplaySlot[], - rowKeysToRemove: Set, -): CnstpDisplaySlot[] { - return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey)); -} - -export function patchCnstpGridRow( - state: CnstpSeGridState, - rowKey: string, - patch: Partial, -): CnstpSeGridState { - const draft = state.drafts[rowKey]; - if (draft) { - return { - ...state, - drafts: { - ...state.drafts, - [rowKey]: { - ...draft, - ...(patch.cnstpSeCd !== undefined ? { cnstpSeCd: patch.cnstpSeCd } : {}), - ...(patch.cnstpSeCdNm !== undefined ? { cnstpSeCdNm: patch.cnstpSeCdNm ?? "" } : {}), - ...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}), - ...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}), - }, - }, - }; - } - - const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch; - - return { - ...state, - patches: { - ...state.patches, - [rowKey]: { ...state.patches[rowKey], ...dtoPatch }, - }, - }; -} - -export function markCnstpRowsDeleted( - state: CnstpSeGridState, - rootRowKey: string, -): CnstpSeGridState { - const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey); - const deletedKeys = new Set(state.deletedKeys); - for (const key of keysToDelete) { - deletedKeys.add(key); - } - return { ...state, deletedKeys }; -} - -export function validateDraftParentCnstpSeCd( - state: CnstpSeGridState, - rowKey: string, -): string | null { - const draft = state.drafts[rowKey]; - if (!draft?.parentRowKey) return null; - - const parent = mergeCnstpRow(state, draft.parentRowKey); - if (!parent?.cnstpSeCd?.trim()) { - return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요."; - } - - return null; -} - -export function removeDraftFromGrid(state: CnstpSeGridState, rowKey: string): CnstpSeGridState { - const { [rowKey]: _removed, ...restDrafts } = state.drafts; - const keysToRemove = collectDescendantRowKeys(state.structure, rowKey); - - return { - ...state, - drafts: restDrafts, - structure: removeSlotsFromStructure(state.structure, keysToRemove), - }; -} diff --git a/apps/as/src/pages/cnstp-code-management/lib/row-state.ts b/apps/as/src/pages/cnstp-code-management/lib/row-state.ts deleted file mode 100644 index b7a98aca7..000000000 --- a/apps/as/src/pages/cnstp-code-management/lib/row-state.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { CnstpSeRow } from "../model/cnstp-se-row-types"; - -export function getVisibleRows(rows: CnstpSeRow[]): CnstpSeRow[] { - return rows.filter((row) => !row.isDeleted); -} diff --git a/apps/as/src/pages/cnstp-code-management/lib/use-cnstp-se-rows.ts b/apps/as/src/pages/cnstp-code-management/lib/use-cnstp-se-rows.ts deleted file mode 100644 index 7e4320c8f..000000000 --- a/apps/as/src/pages/cnstp-code-management/lib/use-cnstp-se-rows.ts +++ /dev/null @@ -1,167 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import type { CnstpSeMngResponseDto } from "@/entities/cnstp"; - -import type { CnstpSeGridState } from "../model/cnstp-se-grid-types"; -import type { CnstpSeRow } from "../model/cnstp-se-row-types"; - -import { buildSaveMultiPayload } from "./build-save-multi-payload"; -import { - createDraftRowKey, - createEmptyGridState, - hasCnstpGridPendingChanges, - hydrateGridFromServer, - insertChildSlot, - insertRootSlot, - markCnstpRowsDeleted, - mergeAllCnstpRows, - mergeCnstpRow, - patchCnstpGridRow, - removeDraftFromGrid, - validateDraftParentCnstpSeCd, -} from "./cnstp-se-grid"; - -function validateCnstpSeRow(row: CnstpSeRow): string | null { - if (!row.cnstpSeCd.trim()) return "코드를 입력하세요."; - const parentCd = row.upCnstpCd?.trim(); - if (parentCd && row.cnstpSeCd.trim() === parentCd) { - return "상위 코드와 동일한 코드는 사용할 수 없습니다."; - } - return null; -} - -interface UseCnstpSeRowsOptions { - serverContent: CnstpSeMngResponseDto[] | undefined; - onAlert: (message: string) => void; - onSave: (payload: ReturnType) => Promise; -} - -export function useCnstpSeRows({ serverContent, onAlert, onSave }: UseCnstpSeRowsOptions) { - const [grid, setGrid] = useState(createEmptyGridState); - const hasPendingRef = useRef(false); - - hasPendingRef.current = hasCnstpGridPendingChanges(grid); - - useEffect(() => { - if (serverContent == null) return; - if (hasPendingRef.current) return; - setGrid(hydrateGridFromServer(serverContent)); - }, [serverContent]); - - const displayRows = useMemo(() => { - return grid.structure - .filter((slot) => !grid.deletedKeys.has(slot.rowKey)) - .map((slot) => { - const row = mergeCnstpRow(grid, slot.rowKey); - return row ? { row, depth: slot.depth } : null; - }) - .filter((item): item is { row: CnstpSeRow; depth: number } => item != null); - }, [grid]); - - const canSave = hasCnstpGridPendingChanges(grid); - - const updateRow = useCallback((rowKey: string, patch: Partial) => { - setGrid((prev) => patchCnstpGridRow(prev, rowKey, patch)); - }, []); - - const addRootRow = useCallback(() => { - const rowKey = createDraftRowKey(); - setGrid((prev) => ({ - ...prev, - drafts: { - ...prev.drafts, - [rowKey]: { - rowKey, - parentRowKey: null, - cdLvNo: 1, - cnstpSeCd: "", - cnstpSeCdNm: "", - expln: "", - useYn: "Y", - }, - }, - structure: insertRootSlot(prev.structure, { rowKey, depth: 0, parentRowKey: null }), - })); - }, []); - - const addChildRow = useCallback((parent: CnstpSeRow) => { - const rowKey = createDraftRowKey(); - - setGrid((prev) => { - const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey); - const depth = (parentSlot?.depth ?? 0) + 1; - - return { - ...prev, - drafts: { - ...prev.drafts, - [rowKey]: { - rowKey, - parentRowKey: parent.rowKey, - cdLvNo: (parent.cdLvNo ?? 1) + 1, - cnstpSeCd: "", - cnstpSeCdNm: "", - expln: "", - useYn: "Y", - }, - }, - structure: insertChildSlot(prev.structure, parent.rowKey, { - rowKey, - depth, - parentRowKey: parent.rowKey, - }), - }; - }); - }, []); - - const removeDraft = useCallback((rowKey: string) => { - setGrid((prev) => removeDraftFromGrid(prev, rowKey)); - }, []); - - const markDeleted = useCallback((rowKey: string) => { - setGrid((prev) => markCnstpRowsDeleted(prev, rowKey)); - }, []); - - const resetFromServer = useCallback((content: CnstpSeMngResponseDto[]) => { - setGrid(hydrateGridFromServer(content)); - }, []); - - const save = useCallback(async () => { - const mergedRows = mergeAllCnstpRows(grid); - const payload = buildSaveMultiPayload(mergedRows); - if (payload.length === 0) return false; - - for (const row of mergedRows) { - if (!row.isDeleted && (row.isNew || row.isDirty)) { - const parentCodeError = validateDraftParentCnstpSeCd(grid, row.rowKey); - if (parentCodeError) { - onAlert(parentCodeError); - return false; - } - - const fieldError = validateCnstpSeRow(row); - if (fieldError) { - onAlert(fieldError); - return false; - } - } - } - - await onSave(payload); - return true; - }, [grid, onAlert, onSave]); - - return { - displayRows, - canSave, - updateRow, - addRootRow, - addChildRow, - removeDraft, - markDeleted, - resetFromServer, - save, - }; -} diff --git a/apps/as/src/pages/cnstp-code-management/ui/cnstp-code-management-page.tsx b/apps/as/src/pages/cnstp-code-management/ui/cnstp-code-management-page.tsx deleted file mode 100644 index 25ad711cb..000000000 --- a/apps/as/src/pages/cnstp-code-management/ui/cnstp-code-management-page.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import { useCallback } from "react"; - -import { useConfirm } from "@frontend-opensource/use-react-hooks"; - -import { useAlertStore } from "@/shared/lib"; -import { useCnstpSeMngPage, useSaveMultiCnstpSeMng } from "@/entities/cnstp"; -import { - Button, - Skeleton, - Table, - TableBody, - TableHead, - TableHeader, - TableRow, -} from "@common/components"; - -import { useCnstpSeRows } from "../lib/use-cnstp-se-rows"; -import type { CnstpSeRow } from "../model/cnstp-se-row-types"; - -import { CnstpSeRowComponent } from "./cnstp-se-row"; - -function CnstpCodeManagementPage() { - const { confirm } = useConfirm(); - const alert = useAlertStore((state) => state.setMessage); - - const pageQuery = useCnstpSeMngPage({ - page: 0, - size: 500, - sort: "cdLvNo,asc", - }); - - const saveMultiMutation = useSaveMultiCnstpSeMng(); - - const handlePersist = useCallback( - async (payload: Parameters[0]) => { - await saveMultiMutation.mutateAsync(payload); - }, - [saveMultiMutation], - ); - - const { - displayRows, - canSave, - updateRow, - addRootRow, - addChildRow, - removeDraft, - markDeleted, - resetFromServer, - save, - } = useCnstpSeRows({ - serverContent: pageQuery.data?.content, - onAlert: alert, - onSave: handlePersist, - }); - - const handleDelete = async (row: CnstpSeRow) => { - if (!(await confirm("삭제하시겠습니까?"))) return; - - if (row.isNew) { - removeDraft(row.rowKey); - return; - } - - markDeleted(row.rowKey); - }; - - const handleSave = async () => { - if (!(await confirm("저장하시겠습니까?"))) return; - - const saved = await save(); - if (!saved) return; - - alert("저장되었습니다."); - const result = await pageQuery.refetch(); - resetFromServer(result.data?.content ?? []); - }; - - const isSaving = saveMultiMutation.isPending; - - return ( -
-
-
-

공종코드 관리

-
- - -
-
- - {pageQuery.isPending ? ( -
- - -
- ) : displayRows.length === 0 ? ( -

- 등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요. -

- ) : ( -
-
- - - - - - - - - - - 코드 - 코드명 - 설명 - 사용 여부 - 구분 - 관리 - - - - {displayRows.map(({ row, depth }) => ( - updateRow(row.rowKey, patch)} - onAddChild={() => addChildRow(row)} - onDelete={() => handleDelete(row)} - /> - ))} - -
-
- )} - -
- ); -} - -export { CnstpCodeManagementPage }; diff --git a/apps/as/src/pages/code-management/index.ts b/apps/as/src/pages/code-management/index.ts new file mode 100644 index 000000000..3b00522e0 --- /dev/null +++ b/apps/as/src/pages/code-management/index.ts @@ -0,0 +1 @@ +export { CodeManagementPage } from "./ui/code-management-page"; diff --git a/apps/as/src/pages/code-management/lib/build-save-payload.ts b/apps/as/src/pages/code-management/lib/build-save-payload.ts new file mode 100644 index 000000000..80eac476c --- /dev/null +++ b/apps/as/src/pages/code-management/lib/build-save-payload.ts @@ -0,0 +1,60 @@ +export interface SaveModeMap { + CREATE: string; + UPDATE: string; + DELETE: string; +} + +export interface SavePayloadBuilderConfig { + saveModes: SaveModeMap; + toDeleteDto: (row: TRow) => TSaveDto; + toCreateDto: (row: TRow) => TSaveDto; + toUpdateDto: (row: TRow) => TSaveDto; +} + +interface RowWithState { + cdLvNo?: number; + isNew: boolean; + isDirty: boolean; + isDeleted: boolean; +} + +function getVisibleRows(rows: T[]): T[] { + return rows.filter((row) => !row.isDeleted); +} + +function compareCdLvNoDesc(a: T, b: T): number { + return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1); +} + +function compareCdLvNoAsc(a: T, b: T): number { + return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1); +} + +export function createSavePayloadBuilder( + config: SavePayloadBuilderConfig, +) { + return function buildSavePayload(rows: TRow[]): TSaveDto[] { + const payload: TSaveDto[] = []; + + const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc); + + for (const row of deletedRows) { + payload.push(config.toDeleteDto(row)); + } + + const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc); + + for (const row of visibleRows) { + if (row.isNew) { + payload.push(config.toCreateDto(row)); + continue; + } + + if (row.isDirty) { + payload.push(config.toUpdateDto(row)); + } + } + + return payload; + }; +} diff --git a/apps/as/src/pages/code-management/lib/configs/acntg-config.ts b/apps/as/src/pages/code-management/lib/configs/acntg-config.ts new file mode 100644 index 000000000..718919d8b --- /dev/null +++ b/apps/as/src/pages/code-management/lib/configs/acntg-config.ts @@ -0,0 +1,99 @@ +import { + ACNTG_SE_SAVE_MODE, + type AcntgSeMngResponseDto, + type AcntgSeMngSaveRequestDto, +} from "@/entities/acntg"; + +import type { AcntgSeDraft, AcntgSeGridState } from "../../model/acntg/acntg-se-grid-types"; +import type { AcntgSeRow } from "../../model/acntg/acntg-se-row-types"; +import { buildAcntgDisplayRows } from "../../model/acntg/build-acntg-display-rows"; +import { createSavePayloadBuilder } from "../build-save-payload"; +import type { SeGridConfig } from "../grid-types"; + +const buildSavePayload = createSavePayloadBuilder({ + saveModes: ACNTG_SE_SAVE_MODE, + toDeleteDto: (row) => ({ + mode: ACNTG_SE_SAVE_MODE.DELETE, + acntgSeCd: row.acntgSeCd, + acntgSeCdNm: row.acntgSeCdNm?.trim() || "", + upAcntgSeCd: row.upAcntgSeCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + sortSeq: row.sortSeq, + }), + toCreateDto: (row) => ({ + mode: ACNTG_SE_SAVE_MODE.CREATE, + acntgSeCd: row.acntgSeCd.trim(), + acntgSeCdNm: row.acntgSeCdNm.trim(), + upAcntgSeCd: row.upAcntgSeCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + sortSeq: row.sortSeq, + }), + toUpdateDto: (row) => ({ + mode: ACNTG_SE_SAVE_MODE.UPDATE, + acntgSeCd: row.acntgSeCd.trim(), + acntgSeCdNm: row.acntgSeCdNm.trim(), + upAcntgSeCd: row.upAcntgSeCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + sortSeq: row.sortSeq, + }), +}); + +export const acntgGridConfig: SeGridConfig< + AcntgSeMngResponseDto, + AcntgSeGridState["baseByKey"][string], + AcntgSeRow, + AcntgSeDraft, + Partial, + AcntgSeMngSaveRequestDto +> = { + getCode: (item) => item.acntgSeCd, + getName: (item) => item.acntgSeCdNm, + getParentCode: (item) => item.upAcntgSeCd, + createEmptyDraft: ({ rowKey, parentRowKey, cdLvNo }) => ({ + rowKey, + parentRowKey, + cdLvNo, + acntgSeCd: "", + acntgSeCdNm: "", + expln: "", + useYn: "Y", + }), + applyDraftPatch: (draft, patch) => ({ + ...draft, + ...(patch.acntgSeCd !== undefined ? { acntgSeCd: patch.acntgSeCd } : {}), + ...(patch.acntgSeCdNm !== undefined ? { acntgSeCdNm: patch.acntgSeCdNm ?? "" } : {}), + ...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}), + ...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}), + ...(patch.sortSeq !== undefined ? { sortSeq: patch.sortSeq } : {}), + }), + assembleDraftRow: (draft, parentCode) => ({ + rowKey: draft.rowKey, + acntgSeCd: draft.acntgSeCd, + acntgSeCdNm: draft.acntgSeCdNm, + expln: draft.expln, + useYn: draft.useYn, + cdLvNo: draft.cdLvNo, + upAcntgSeCd: parentCode, + sortSeq: draft.sortSeq, + isNew: true, + isDirty: true, + isDeleted: false, + }), + buildDisplayRows: buildAcntgDisplayRows, + validateRow: (row) => { + if (!row.acntgSeCd.trim()) return "코드를 입력하세요."; + if (!row.acntgSeCdNm?.trim()) return "코드명을 입력하세요."; + const parentCd = row.upAcntgSeCd?.trim(); + if (parentCd && row.acntgSeCd.trim() === parentCd) { + return "상위 코드와 동일한 코드는 사용할 수 없습니다."; + } + return null; + }, + buildSavePayload, +}; diff --git a/apps/as/src/pages/code-management/lib/configs/cnstp-config.ts b/apps/as/src/pages/code-management/lib/configs/cnstp-config.ts new file mode 100644 index 000000000..26bcc8000 --- /dev/null +++ b/apps/as/src/pages/code-management/lib/configs/cnstp-config.ts @@ -0,0 +1,98 @@ +import { + CNSTP_SE_SAVE_MODE, + type CnstpSeMngResponseDto, + type CnstpSeMngSaveRequestDto, +} from "@/entities/cnstp"; + +import { buildCnstpDisplayRows } from "../../model/cnstp/build-cnstp-display-rows"; +import type { CnstpSeDraft, CnstpSeGridState } from "../../model/cnstp/cnstp-se-grid-types"; +import type { CnstpSeRow } from "../../model/cnstp/cnstp-se-row-types"; +import { createSavePayloadBuilder } from "../build-save-payload"; +import type { SeGridConfig } from "../grid-types"; + +const buildSavePayload = createSavePayloadBuilder({ + saveModes: CNSTP_SE_SAVE_MODE, + toDeleteDto: (row) => ({ + mode: CNSTP_SE_SAVE_MODE.DELETE, + cnstpSeCd: row.cnstpSeCd, + cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined, + upCnstpCd: row.upCnstpCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + sortSeq: row.sortSeq, + }), + toCreateDto: (row) => ({ + mode: CNSTP_SE_SAVE_MODE.CREATE, + cnstpSeCd: row.cnstpSeCd.trim(), + cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined, + upCnstpCd: row.upCnstpCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + sortSeq: row.sortSeq, + }), + toUpdateDto: (row) => ({ + mode: CNSTP_SE_SAVE_MODE.UPDATE, + cnstpSeCd: row.cnstpSeCd.trim(), + cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined, + upCnstpCd: row.upCnstpCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + sortSeq: row.sortSeq, + }), +}); + +export const cnstpGridConfig: SeGridConfig< + CnstpSeMngResponseDto, + CnstpSeGridState["baseByKey"][string], + CnstpSeRow, + CnstpSeDraft, + Partial, + CnstpSeMngSaveRequestDto +> = { + getCode: (item) => item.cnstpSeCd, + getName: (item) => item.cnstpSeCdNm, + getParentCode: (item) => item.upCnstpCd, + createEmptyDraft: ({ rowKey, parentRowKey, cdLvNo }) => ({ + rowKey, + parentRowKey, + cdLvNo, + cnstpSeCd: "", + cnstpSeCdNm: "", + expln: "", + useYn: "Y", + }), + applyDraftPatch: (draft, patch) => ({ + ...draft, + ...(patch.cnstpSeCd !== undefined ? { cnstpSeCd: patch.cnstpSeCd } : {}), + ...(patch.cnstpSeCdNm !== undefined ? { cnstpSeCdNm: patch.cnstpSeCdNm ?? "" } : {}), + ...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}), + ...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}), + ...(patch.sortSeq !== undefined ? { sortSeq: patch.sortSeq } : {}), + }), + assembleDraftRow: (draft, parentCode) => ({ + rowKey: draft.rowKey, + cnstpSeCd: draft.cnstpSeCd, + cnstpSeCdNm: draft.cnstpSeCdNm, + expln: draft.expln, + useYn: draft.useYn, + cdLvNo: draft.cdLvNo, + upCnstpCd: parentCode, + sortSeq: draft.sortSeq, + isNew: true, + isDirty: true, + isDeleted: false, + }), + buildDisplayRows: buildCnstpDisplayRows, + validateRow: (row) => { + if (!row.cnstpSeCd.trim()) return "코드를 입력하세요."; + const parentCd = row.upCnstpCd?.trim(); + if (parentCd && row.cnstpSeCd.trim() === parentCd) { + return "상위 코드와 동일한 코드는 사용할 수 없습니다."; + } + return null; + }, + buildSavePayload, +}; diff --git a/apps/as/src/pages/code-management/lib/configs/fnrs-shar-config.ts b/apps/as/src/pages/code-management/lib/configs/fnrs-shar-config.ts new file mode 100644 index 000000000..9039693d8 --- /dev/null +++ b/apps/as/src/pages/code-management/lib/configs/fnrs-shar-config.ts @@ -0,0 +1,102 @@ +import { + FNRS_SHAR_SE_SAVE_MODE, + type FnrsSharSeMngResponseDto, + type FnrsSharSeMngSaveRequestDto, +} from "@/entities/fnrs-shar-se"; + +import { buildFnrsSharDisplayRows } from "../../model/fnrs-shar/build-fnrs-shar-display-rows"; +import type { + FnrsSharSeDraft, + FnrsSharSeGridState, +} from "../../model/fnrs-shar/fnrs-shar-se-grid-types"; +import type { FnrsSharSeRow } from "../../model/fnrs-shar/fnrs-shar-se-row-types"; +import { createSavePayloadBuilder } from "../build-save-payload"; +import type { SeGridConfig } from "../grid-types"; + +const buildSavePayload = createSavePayloadBuilder({ + saveModes: FNRS_SHAR_SE_SAVE_MODE, + toDeleteDto: (row) => ({ + mode: FNRS_SHAR_SE_SAVE_MODE.DELETE, + fnrsSharSeCd: row.fnrsSharSeCd, + fnrsSharSeCdNm: row.fnrsSharSeCdNm?.trim() || "", + upFnrsSharSeCd: row.upFnrsSharSeCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + sortSeq: row.sortSeq, + }), + toCreateDto: (row) => ({ + mode: FNRS_SHAR_SE_SAVE_MODE.CREATE, + fnrsSharSeCd: row.fnrsSharSeCd.trim(), + fnrsSharSeCdNm: row.fnrsSharSeCdNm.trim(), + upFnrsSharSeCd: row.upFnrsSharSeCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + sortSeq: row.sortSeq, + }), + toUpdateDto: (row) => ({ + mode: FNRS_SHAR_SE_SAVE_MODE.UPDATE, + fnrsSharSeCd: row.fnrsSharSeCd.trim(), + fnrsSharSeCdNm: row.fnrsSharSeCdNm.trim(), + upFnrsSharSeCd: row.upFnrsSharSeCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + sortSeq: row.sortSeq, + }), +}); + +export const fnrsSharGridConfig: SeGridConfig< + FnrsSharSeMngResponseDto, + FnrsSharSeGridState["baseByKey"][string], + FnrsSharSeRow, + FnrsSharSeDraft, + Partial, + FnrsSharSeMngSaveRequestDto +> = { + getCode: (item) => item.fnrsSharSeCd, + getName: (item) => item.fnrsSharSeCdNm, + getParentCode: (item) => item.upFnrsSharSeCd, + createEmptyDraft: ({ rowKey, parentRowKey, cdLvNo }) => ({ + rowKey, + parentRowKey, + cdLvNo, + fnrsSharSeCd: "", + fnrsSharSeCdNm: "", + expln: "", + useYn: "Y", + }), + applyDraftPatch: (draft, patch) => ({ + ...draft, + ...(patch.fnrsSharSeCd !== undefined ? { fnrsSharSeCd: patch.fnrsSharSeCd } : {}), + ...(patch.fnrsSharSeCdNm !== undefined ? { fnrsSharSeCdNm: patch.fnrsSharSeCdNm ?? "" } : {}), + ...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}), + ...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}), + ...(patch.sortSeq !== undefined ? { sortSeq: patch.sortSeq } : {}), + }), + assembleDraftRow: (draft, parentCode) => ({ + rowKey: draft.rowKey, + fnrsSharSeCd: draft.fnrsSharSeCd, + fnrsSharSeCdNm: draft.fnrsSharSeCdNm, + expln: draft.expln, + useYn: draft.useYn, + cdLvNo: draft.cdLvNo, + upFnrsSharSeCd: parentCode, + sortSeq: draft.sortSeq, + isNew: true, + isDirty: true, + isDeleted: false, + }), + buildDisplayRows: buildFnrsSharDisplayRows, + validateRow: (row) => { + if (!row.fnrsSharSeCd.trim()) return "코드를 입력하세요."; + if (!row.fnrsSharSeCdNm?.trim()) return "코드명을 입력하세요."; + const parentCd = row.upFnrsSharSeCd?.trim(); + if (parentCd && row.fnrsSharSeCd.trim() === parentCd) { + return "상위 코드와 동일한 코드는 사용할 수 없습니다."; + } + return null; + }, + buildSavePayload, +}; diff --git a/apps/as/src/pages/code-management/lib/grid-types.ts b/apps/as/src/pages/code-management/lib/grid-types.ts new file mode 100644 index 000000000..9acaaad4d --- /dev/null +++ b/apps/as/src/pages/code-management/lib/grid-types.ts @@ -0,0 +1,69 @@ +export interface BaseSeDto { + cdLvNo: number; + useYn: string; + expln?: string; + sortSeq?: number; +} + +export interface SeRowState { + rowKey: string; + isNew: boolean; + isDirty: boolean; + isDeleted: boolean; +} + +export interface DisplaySlot { + rowKey: string; + depth: number; + parentRowKey: string | null; +} + +export interface SeDraftBase { + rowKey: string; + parentRowKey: string | null; + cdLvNo: number; + expln: string; + useYn: string; + sortSeq?: number; +} + +export interface SeGridState< + TDto extends BaseSeDto, + TBaseEntry extends TDto & { rowKey: string }, + TDraft extends SeDraftBase, + TPatch extends Partial, +> { + baseByKey: Record; + structure: DisplaySlot[]; + drafts: Record; + patches: Record; + deletedKeys: Set; +} + +export interface SeDisplayRow { + row: TRow; + depth: number; +} + +export interface SeGridConfig< + TDto extends BaseSeDto, + TBaseEntry extends TDto & { rowKey: string }, + TRow extends TBaseEntry & SeRowState, + TDraft extends SeDraftBase, + _TPatch extends Partial, + TSaveDto, +> { + getCode: (item: TDto | TRow | TDraft) => string; + getName: (item: TDto | TRow | TDraft) => string | undefined; + getParentCode: (item: TDto | TRow) => string | undefined; + createEmptyDraft: (params: { + rowKey: string; + parentRowKey: string | null; + cdLvNo: number; + }) => TDraft; + applyDraftPatch: (draft: TDraft, patch: Partial) => TDraft; + assembleDraftRow: (draft: TDraft, parentCode: string | undefined) => TRow; + buildDisplayRows: (rows: TRow[]) => SeDisplayRow[]; + validateRow: (row: TRow) => string | null; + buildSavePayload: (rows: TRow[]) => TSaveDto[]; +} diff --git a/apps/as/src/pages/code-management/lib/grid-utils.ts b/apps/as/src/pages/code-management/lib/grid-utils.ts new file mode 100644 index 000000000..29b812017 --- /dev/null +++ b/apps/as/src/pages/code-management/lib/grid-utils.ts @@ -0,0 +1,254 @@ +import type { + BaseSeDto, + DisplaySlot, + SeDraftBase, + SeGridConfig, + SeGridState, + SeRowState, +} from "./grid-types"; + +export function createLoadedRowKey(index: number, code: string): string { + return `loaded-${index}-${code}`; +} + +export function createDraftRowKey(): string { + return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +export function createSeGridUtils< + TDto extends BaseSeDto, + TBaseEntry extends TDto & { rowKey: string }, + TRow extends TBaseEntry & SeRowState, + TDraft extends SeDraftBase, + TPatch extends Partial, + TSaveDto, +>(config: SeGridConfig) { + function createEmptyGridState(): SeGridState { + return { + baseByKey: {}, + structure: [], + drafts: {}, + patches: {}, + deletedKeys: new Set(), + }; + } + + function toInitialRow(entry: TBaseEntry): TRow { + return { + ...entry, + isNew: false, + isDirty: false, + isDeleted: false, + } as unknown as TRow; + } + + function buildStructureFromBaseRows(baseByKey: Record): DisplaySlot[] { + const rows = Object.values(baseByKey).map(toInitialRow); + const display = config.buildDisplayRows(rows); + const cdToRowKey = new Map(rows.map((row) => [config.getCode(row), row.rowKey])); + + return display.map(({ row, depth }) => ({ + rowKey: row.rowKey, + depth, + parentRowKey: config.getParentCode(row)?.trim() + ? (cdToRowKey.get(config.getParentCode(row)!.trim()) ?? null) + : null, + })); + } + + function hydrateGridFromServer(content: TDto[]): SeGridState { + const baseByKey: Record = {}; + + content.forEach((item, index) => { + const rowKey = createLoadedRowKey(index, config.getCode(item)); + baseByKey[rowKey] = { ...item, rowKey } as TBaseEntry; + }); + + return { + baseByKey, + structure: buildStructureFromBaseRows(baseByKey), + drafts: {}, + patches: {}, + deletedKeys: new Set(), + }; + } + + function resolveParentCode( + state: SeGridState, + parentRowKey: string | null, + ): string | undefined { + if (!parentRowKey) return undefined; + const parent = mergeRow(state, parentRowKey); + const code = parent ? config.getCode(parent).trim() : ""; + return code || undefined; + } + + function mergeRow( + state: SeGridState, + rowKey: string, + ): TRow | null { + const draft = state.drafts[rowKey]; + if (draft) { + const parentCode = resolveParentCode(state, draft.parentRowKey); + return config.assembleDraftRow(draft, parentCode); + } + + const base = state.baseByKey[rowKey]; + if (!base) return null; + + const patch = state.patches[rowKey] ?? ({} as TPatch); + const hasPatch = Object.keys(patch).length > 0; + + return { + ...base, + ...patch, + rowKey, + isNew: false, + isDirty: hasPatch, + isDeleted: state.deletedKeys.has(rowKey), + } as unknown as TRow; + } + + function mergeAllRows(state: SeGridState): TRow[] { + const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]); + + return [...keys] + .map((rowKey) => mergeRow(state, rowKey)) + .filter((row): row is TRow => row != null); + } + + function hasGridPendingChanges(state: SeGridState): boolean { + return ( + state.deletedKeys.size > 0 || + Object.keys(state.drafts).length > 0 || + Object.keys(state.patches).length > 0 + ); + } + + function collectDescendantRowKeys(structure: DisplaySlot[], rootRowKey: string): Set { + const result = new Set([rootRowKey]); + const queue = [rootRowKey]; + + while (queue.length > 0) { + const parentKey = queue.shift()!; + for (const slot of structure) { + if (slot.parentRowKey === parentKey && !result.has(slot.rowKey)) { + result.add(slot.rowKey); + queue.push(slot.rowKey); + } + } + } + + return result; + } + + function insertRootSlot(structure: DisplaySlot[], slot: DisplaySlot): DisplaySlot[] { + const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null); + if (firstRootIndex === -1) return [slot, ...structure]; + return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)]; + } + + function insertChildSlot( + structure: DisplaySlot[], + parentRowKey: string, + slot: DisplaySlot, + ): DisplaySlot[] { + const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey); + if (parentIndex === -1) return [...structure, slot]; + + const insertAt = parentIndex + 1; + return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)]; + } + + function removeSlotsFromStructure( + structure: DisplaySlot[], + rowKeysToRemove: Set, + ): DisplaySlot[] { + return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey)); + } + + function patchGridRow( + state: SeGridState, + rowKey: string, + patch: Partial, + ): SeGridState { + const draft = state.drafts[rowKey]; + if (draft) { + return { + ...state, + drafts: { + ...state.drafts, + [rowKey]: config.applyDraftPatch(draft, patch), + }, + }; + } + + const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch; + + return { + ...state, + patches: { + ...state.patches, + [rowKey]: { ...state.patches[rowKey], ...dtoPatch } as TPatch, + }, + }; + } + + function markRowsDeleted( + state: SeGridState, + rootRowKey: string, + ): SeGridState { + const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey); + const deletedKeys = new Set(state.deletedKeys); + for (const key of keysToDelete) { + deletedKeys.add(key); + } + return { ...state, deletedKeys }; + } + + function validateDraftParentCode( + state: SeGridState, + rowKey: string, + ): string | null { + const draft = state.drafts[rowKey]; + if (!draft?.parentRowKey) return null; + + const parent = mergeRow(state, draft.parentRowKey); + if (!parent || !config.getCode(parent).trim()) { + return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요."; + } + + return null; + } + + function removeDraftFromGrid( + state: SeGridState, + rowKey: string, + ): SeGridState { + const { [rowKey]: _removed, ...restDrafts } = state.drafts; + const keysToRemove = collectDescendantRowKeys(state.structure, rowKey); + + return { + ...state, + drafts: restDrafts, + structure: removeSlotsFromStructure(state.structure, keysToRemove), + }; + } + + return { + createEmptyGridState, + hydrateGridFromServer, + mergeRow, + mergeAllRows, + hasGridPendingChanges, + collectDescendantRowKeys, + insertRootSlot, + insertChildSlot, + removeSlotsFromStructure, + patchGridRow, + markRowsDeleted, + validateDraftParentCode, + removeDraftFromGrid, + resolveParentCode, + }; +} diff --git a/apps/as/src/pages/code-management/lib/use-se-grid-rows.ts b/apps/as/src/pages/code-management/lib/use-se-grid-rows.ts new file mode 100644 index 000000000..623baf310 --- /dev/null +++ b/apps/as/src/pages/code-management/lib/use-se-grid-rows.ts @@ -0,0 +1,176 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { BaseSeDto, SeDraftBase, SeGridConfig, SeGridState, SeRowState } from "./grid-types"; +import { createDraftRowKey, createSeGridUtils } from "./grid-utils"; + +interface UseSeGridRowsOptions< + TDto extends BaseSeDto, + TBaseEntry extends TDto & { rowKey: string }, + _TRow extends TBaseEntry & SeRowState, + _TDraft extends SeDraftBase, + _TPatch extends Partial, + TSaveDto, +> { + serverContent: TDto[] | undefined; + onAlert: (message: string) => void; + onSave: (payload: TSaveDto[]) => Promise; +} + +export function useSeGridRows< + TDto extends BaseSeDto, + TBaseEntry extends TDto & { rowKey: string }, + TRow extends TBaseEntry & SeRowState, + TDraft extends SeDraftBase, + TPatch extends Partial, + TSaveDto, +>( + config: SeGridConfig, + { + serverContent, + onAlert, + onSave, + }: UseSeGridRowsOptions, +) { + const utils = useMemo(() => createSeGridUtils(config), [config]); + + const [grid, setGrid] = useState>( + utils.createEmptyGridState, + ); + const hasPendingRef = useRef(false); + + hasPendingRef.current = utils.hasGridPendingChanges(grid); + + useEffect(() => { + if (serverContent == null) return; + if (hasPendingRef.current) return; + setGrid(utils.hydrateGridFromServer(serverContent)); + }, [serverContent, utils]); + + const displayRows = useMemo(() => { + return grid.structure + .filter((slot) => !grid.deletedKeys.has(slot.rowKey)) + .map((slot) => { + const row = utils.mergeRow(grid, slot.rowKey); + return row ? { row, depth: slot.depth } : null; + }) + .filter((item): item is { row: TRow; depth: number } => item != null); + }, [grid, utils]); + + const canSave = utils.hasGridPendingChanges(grid); + + const updateRow = useCallback( + (rowKey: string, patch: Partial) => { + setGrid((prev) => utils.patchGridRow(prev, rowKey, patch)); + }, + [utils], + ); + + const addRootRow = useCallback(() => { + const rowKey = createDraftRowKey(); + setGrid((prev) => ({ + ...prev, + drafts: { + ...prev.drafts, + [rowKey]: config.createEmptyDraft({ + rowKey, + parentRowKey: null, + cdLvNo: 1, + }), + }, + structure: utils.insertRootSlot(prev.structure, { + rowKey, + depth: 0, + parentRowKey: null, + }), + })); + }, [config, utils]); + + const addChildRow = useCallback( + (parent: TRow) => { + const rowKey = createDraftRowKey(); + + setGrid((prev) => { + const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey); + const depth = (parentSlot?.depth ?? 0) + 1; + + return { + ...prev, + drafts: { + ...prev.drafts, + [rowKey]: config.createEmptyDraft({ + rowKey, + parentRowKey: parent.rowKey, + cdLvNo: (parent.cdLvNo ?? 1) + 1, + }), + }, + structure: utils.insertChildSlot(prev.structure, parent.rowKey, { + rowKey, + depth, + parentRowKey: parent.rowKey, + }), + }; + }); + }, + [config, utils], + ); + + const removeDraft = useCallback( + (rowKey: string) => { + setGrid((prev) => utils.removeDraftFromGrid(prev, rowKey)); + }, + [utils], + ); + + const markDeleted = useCallback( + (rowKey: string) => { + setGrid((prev) => utils.markRowsDeleted(prev, rowKey)); + }, + [utils], + ); + + const resetFromServer = useCallback( + (content: TDto[]) => { + setGrid(utils.hydrateGridFromServer(content)); + }, + [utils], + ); + + const save = useCallback(async () => { + const mergedRows = utils.mergeAllRows(grid); + const payload = config.buildSavePayload(mergedRows); + if (payload.length === 0) return false; + + for (const row of mergedRows) { + if (!row.isDeleted && (row.isNew || row.isDirty)) { + const parentCodeError = utils.validateDraftParentCode(grid, row.rowKey); + if (parentCodeError) { + onAlert(parentCodeError); + return false; + } + + const fieldError = config.validateRow(row); + if (fieldError) { + onAlert(fieldError); + return false; + } + } + } + + await onSave(payload); + return true; + }, [config, grid, onAlert, onSave, utils]); + + return { + displayRows, + canSave, + updateRow, + addRootRow, + addChildRow, + removeDraft, + markDeleted, + resetFromServer, + save, + }; +} diff --git a/apps/as/src/pages/acntg-code-management/model/acntg-se-grid-types.ts b/apps/as/src/pages/code-management/model/acntg/acntg-se-grid-types.ts similarity index 95% rename from apps/as/src/pages/acntg-code-management/model/acntg-se-grid-types.ts rename to apps/as/src/pages/code-management/model/acntg/acntg-se-grid-types.ts index a115c122d..5bac897a0 100644 --- a/apps/as/src/pages/acntg-code-management/model/acntg-se-grid-types.ts +++ b/apps/as/src/pages/code-management/model/acntg/acntg-se-grid-types.ts @@ -1,34 +1,35 @@ -import type { AcntgSeMngResponseDto } from "@/entities/acntg"; - -export interface AcntgDisplaySlot { - rowKey: string; - depth: number; - parentRowKey: string | null; -} - -export interface AcntgSeDraft { - rowKey: string; - parentRowKey: string | null; - cdLvNo: number; - acntgSeCd: string; - acntgSeCdNm: string; - expln: string; - useYn: string; -} - -export interface AcntgSeBaseEntry extends AcntgSeMngResponseDto { - rowKey: string; -} - -export interface AcntgSeGridState { - baseByKey: Record; - structure: AcntgDisplaySlot[]; - drafts: Record; - patches: Record>; - deletedKeys: Set; -} - -export interface AcntgSeDisplayItem { - rowKey: string; - depth: number; -} +import type { AcntgSeMngResponseDto } from "@/entities/acntg"; + +export interface AcntgDisplaySlot { + rowKey: string; + depth: number; + parentRowKey: string | null; +} + +export interface AcntgSeDraft { + rowKey: string; + parentRowKey: string | null; + cdLvNo: number; + acntgSeCd: string; + acntgSeCdNm: string; + expln: string; + useYn: string; + sortSeq?: number; +} + +export interface AcntgSeBaseEntry extends AcntgSeMngResponseDto { + rowKey: string; +} + +export interface AcntgSeGridState { + baseByKey: Record; + structure: AcntgDisplaySlot[]; + drafts: Record; + patches: Record>; + deletedKeys: Set; +} + +export interface AcntgSeDisplayItem { + rowKey: string; + depth: number; +} diff --git a/apps/as/src/pages/acntg-code-management/model/acntg-se-row-types.ts b/apps/as/src/pages/code-management/model/acntg/acntg-se-row-types.ts similarity index 100% rename from apps/as/src/pages/acntg-code-management/model/acntg-se-row-types.ts rename to apps/as/src/pages/code-management/model/acntg/acntg-se-row-types.ts diff --git a/apps/as/src/pages/acntg-code-management/model/build-acntg-display-rows.ts b/apps/as/src/pages/code-management/model/acntg/build-acntg-display-rows.ts similarity index 100% rename from apps/as/src/pages/acntg-code-management/model/build-acntg-display-rows.ts rename to apps/as/src/pages/code-management/model/acntg/build-acntg-display-rows.ts diff --git a/apps/as/src/pages/cnstp-code-management/model/build-cnstp-display-rows.ts b/apps/as/src/pages/code-management/model/cnstp/build-cnstp-display-rows.ts similarity index 100% rename from apps/as/src/pages/cnstp-code-management/model/build-cnstp-display-rows.ts rename to apps/as/src/pages/code-management/model/cnstp/build-cnstp-display-rows.ts diff --git a/apps/as/src/pages/cnstp-code-management/model/cnstp-se-grid-types.ts b/apps/as/src/pages/code-management/model/cnstp/cnstp-se-grid-types.ts similarity index 95% rename from apps/as/src/pages/cnstp-code-management/model/cnstp-se-grid-types.ts rename to apps/as/src/pages/code-management/model/cnstp/cnstp-se-grid-types.ts index 7cff8922a..9c2d37c5d 100644 --- a/apps/as/src/pages/cnstp-code-management/model/cnstp-se-grid-types.ts +++ b/apps/as/src/pages/code-management/model/cnstp/cnstp-se-grid-types.ts @@ -1,29 +1,30 @@ -import type { CnstpSeMngResponseDto } from "@/entities/cnstp"; - -export interface CnstpDisplaySlot { - rowKey: string; - depth: number; - parentRowKey: string | null; -} - -export interface CnstpSeDraft { - rowKey: string; - parentRowKey: string | null; - cdLvNo: number; - cnstpSeCd: string; - cnstpSeCdNm: string; - expln: string; - useYn: string; -} - -export interface CnstpSeBaseEntry extends CnstpSeMngResponseDto { - rowKey: string; -} - -export interface CnstpSeGridState { - baseByKey: Record; - structure: CnstpDisplaySlot[]; - drafts: Record; - patches: Record>; - deletedKeys: Set; -} +import type { CnstpSeMngResponseDto } from "@/entities/cnstp"; + +export interface CnstpDisplaySlot { + rowKey: string; + depth: number; + parentRowKey: string | null; +} + +export interface CnstpSeDraft { + rowKey: string; + parentRowKey: string | null; + cdLvNo: number; + cnstpSeCd: string; + cnstpSeCdNm: string; + expln: string; + useYn: string; + sortSeq?: number; +} + +export interface CnstpSeBaseEntry extends CnstpSeMngResponseDto { + rowKey: string; +} + +export interface CnstpSeGridState { + baseByKey: Record; + structure: CnstpDisplaySlot[]; + drafts: Record; + patches: Record>; + deletedKeys: Set; +} diff --git a/apps/as/src/pages/cnstp-code-management/model/cnstp-se-row-types.ts b/apps/as/src/pages/code-management/model/cnstp/cnstp-se-row-types.ts similarity index 100% rename from apps/as/src/pages/cnstp-code-management/model/cnstp-se-row-types.ts rename to apps/as/src/pages/code-management/model/cnstp/cnstp-se-row-types.ts diff --git a/apps/as/src/pages/code-management/model/fnrs-shar/build-fnrs-shar-display-rows.ts b/apps/as/src/pages/code-management/model/fnrs-shar/build-fnrs-shar-display-rows.ts new file mode 100644 index 000000000..9ab931371 --- /dev/null +++ b/apps/as/src/pages/code-management/model/fnrs-shar/build-fnrs-shar-display-rows.ts @@ -0,0 +1,77 @@ +import type { FnrsSharSeRow } from "./fnrs-shar-se-row-types"; + +export interface FnrsSharSeDisplayRow { + row: FnrsSharSeRow; + depth: number; +} + +export function formatCdLvNo(lvNo?: number): string { + return `${lvNo ?? 1}단계`; +} + +export function canAddChildRow(): boolean { + return true; +} + +export function formatTreeBranchPrefix(depth: number): string { + if (depth <= 0) return ""; + const vertical = "\u2502 "; + const branch = "\u251C\u2500"; + return vertical.repeat(depth - 1) + branch; +} + +function isRootRow(row: FnrsSharSeRow): boolean { + return (row.cdLvNo ?? 1) === 1 && !row.upFnrsSharSeCd?.trim(); +} + +function rowOrderIndex(rows: FnrsSharSeRow[], row: FnrsSharSeRow): number { + return rows.findIndex((candidate) => candidate.rowKey === row.rowKey); +} + +function pushDisplayRow( + result: FnrsSharSeDisplayRow[], + displayedRowKeys: Set, + row: FnrsSharSeRow, + depth: number, +) { + if (displayedRowKeys.has(row.rowKey)) return; + displayedRowKeys.add(row.rowKey); + result.push({ row, depth }); +} + +export function buildFnrsSharDisplayRows(rows: FnrsSharSeRow[]): FnrsSharSeDisplayRow[] { + const result: FnrsSharSeDisplayRow[] = []; + const displayedRowKeys = new Set(); + + const roots = rows.filter(isRootRow); + + function appendSubtree(parent: FnrsSharSeRow, depth: number) { + const children = rows + .filter( + (row) => + row.rowKey !== parent.rowKey && + row.upFnrsSharSeCd != null && + row.upFnrsSharSeCd === parent.fnrsSharSeCd && + row.fnrsSharSeCd !== parent.fnrsSharSeCd, + ) + .sort((a, b) => rowOrderIndex(rows, a) - rowOrderIndex(rows, b)); + + for (const child of children) { + pushDisplayRow(result, displayedRowKeys, child, depth); + appendSubtree(child, depth + 1); + } + } + + for (const root of roots) { + pushDisplayRow(result, displayedRowKeys, root, 0); + appendSubtree(root, 1); + } + + const orphans = rows.filter((row) => !displayedRowKeys.has(row.rowKey)); + + for (const orphan of orphans) { + pushDisplayRow(result, displayedRowKeys, orphan, Math.max(0, (orphan.cdLvNo ?? 1) - 1)); + } + + return result; +} diff --git a/apps/as/src/pages/code-management/model/fnrs-shar/fnrs-shar-se-grid-types.ts b/apps/as/src/pages/code-management/model/fnrs-shar/fnrs-shar-se-grid-types.ts new file mode 100644 index 000000000..e0aadf7ae --- /dev/null +++ b/apps/as/src/pages/code-management/model/fnrs-shar/fnrs-shar-se-grid-types.ts @@ -0,0 +1,35 @@ +import type { FnrsSharSeMngResponseDto } from "@/entities/fnrs-shar-se"; + +export interface FnrsSharDisplaySlot { + rowKey: string; + depth: number; + parentRowKey: string | null; +} + +export interface FnrsSharSeDraft { + rowKey: string; + parentRowKey: string | null; + cdLvNo: number; + fnrsSharSeCd: string; + fnrsSharSeCdNm: string; + expln: string; + useYn: string; + sortSeq?: number; +} + +export interface FnrsSharSeBaseEntry extends FnrsSharSeMngResponseDto { + rowKey: string; +} + +export interface FnrsSharSeGridState { + baseByKey: Record; + structure: FnrsSharDisplaySlot[]; + drafts: Record; + patches: Record>; + deletedKeys: Set; +} + +export interface FnrsSharSeDisplayItem { + rowKey: string; + depth: number; +} diff --git a/apps/as/src/pages/code-management/model/fnrs-shar/fnrs-shar-se-row-types.ts b/apps/as/src/pages/code-management/model/fnrs-shar/fnrs-shar-se-row-types.ts new file mode 100644 index 000000000..14425c9cc --- /dev/null +++ b/apps/as/src/pages/code-management/model/fnrs-shar/fnrs-shar-se-row-types.ts @@ -0,0 +1,8 @@ +import type { FnrsSharSeMngResponseDto } from "@/entities/fnrs-shar-se"; + +export interface FnrsSharSeRow extends FnrsSharSeMngResponseDto { + rowKey: string; + isNew: boolean; + isDirty: boolean; + isDeleted: boolean; +} diff --git a/apps/as/src/pages/code-management/ui/acntg-code-tab-panel.tsx b/apps/as/src/pages/code-management/ui/acntg-code-tab-panel.tsx new file mode 100644 index 000000000..0baaa2813 --- /dev/null +++ b/apps/as/src/pages/code-management/ui/acntg-code-tab-panel.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useCallback } from "react"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; + +import { useAlertStore } from "@/shared/lib"; +import { useAcntgSeMngPage, useSaveMultiAcntgSeMng } from "@/entities/acntg"; +import { + Button, + Skeleton, + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import { acntgGridConfig } from "../lib/configs/acntg-config"; +import { useSeGridRows } from "../lib/use-se-grid-rows"; +import type { AcntgSeRow } from "../model/acntg/acntg-se-row-types"; + +import { AcntgSeRowComponent } from "./acntg-se-row"; + +function AcntgCodeTabPanel() { + const { confirm } = useConfirm(); + const alert = useAlertStore((state) => state.setMessage); + + const pageQuery = useAcntgSeMngPage({ + page: 0, + size: 500, + sort: "sortSeq,asc", + }); + + const saveMultiMutation = useSaveMultiAcntgSeMng(); + + const handlePersist = useCallback( + async (payload: Parameters[0]) => { + await saveMultiMutation.mutateAsync(payload); + }, + [saveMultiMutation], + ); + + const { + displayRows, + canSave, + updateRow, + addRootRow, + addChildRow, + removeDraft, + markDeleted, + resetFromServer, + save, + } = useSeGridRows(acntgGridConfig, { + serverContent: pageQuery.data?.content, + onAlert: alert, + onSave: handlePersist, + }); + + const handleDelete = async (row: AcntgSeRow) => { + if (!(await confirm("삭제하시겠습니까?"))) return; + + if (row.isNew) { + removeDraft(row.rowKey); + return; + } + + markDeleted(row.rowKey); + }; + + const handleSave = async () => { + if (!(await confirm("저장하시겠습니까?"))) return; + + const saved = await save(); + if (!saved) return; + + alert("저장되었습니다."); + const result = await pageQuery.refetch(); + resetFromServer(result.data?.content ?? []); + }; + + const isSaving = saveMultiMutation.isPending; + + return ( +
+
+ + +
+ + {pageQuery.isPending ? ( +
+ + +
+ ) : displayRows.length === 0 ? ( +

+ 등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요. +

+ ) : ( +
+ + + + + + + + + + + + + 코드 + 코드명 + 설명 + 사용 여부 + 정렬순서 + 구분 + 관리 + + + + {displayRows.map(({ row, depth }) => ( + updateRow(row.rowKey, patch)} + onAddChild={() => addChildRow(row)} + onDelete={() => handleDelete(row)} + /> + ))} + +
+
+ )} +
+ ); +} + +export { AcntgCodeTabPanel }; diff --git a/apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx b/apps/as/src/pages/code-management/ui/acntg-se-row.tsx similarity index 83% rename from apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx rename to apps/as/src/pages/code-management/ui/acntg-se-row.tsx index 69b53ec60..2ec5a7b16 100644 --- a/apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx +++ b/apps/as/src/pages/code-management/ui/acntg-se-row.tsx @@ -10,12 +10,12 @@ import { TableRow, } from "@common/components"; -import type { AcntgSeRow } from "../model/acntg-se-row-types"; +import type { AcntgSeRow } from "../model/acntg/acntg-se-row-types"; import { canAddChildRow, formatCdLvNo, formatTreeBranchPrefix, -} from "../model/build-acntg-display-rows"; +} from "../model/acntg/build-acntg-display-rows"; interface AcntgSeRowProps { row: AcntgSeRow; @@ -78,6 +78,17 @@ function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Acn
+ + + onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined }) + } + className="w-20" + /> + {levelLabel}
diff --git a/apps/as/src/pages/code-management/ui/cnstp-code-tab-panel.tsx b/apps/as/src/pages/code-management/ui/cnstp-code-tab-panel.tsx new file mode 100644 index 000000000..2db182456 --- /dev/null +++ b/apps/as/src/pages/code-management/ui/cnstp-code-tab-panel.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useCallback } from "react"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; + +import { useAlertStore } from "@/shared/lib"; +import { useCnstpSeMngPage, useSaveMultiCnstpSeMng } from "@/entities/cnstp"; +import { + Button, + Skeleton, + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import { cnstpGridConfig } from "../lib/configs/cnstp-config"; +import { useSeGridRows } from "../lib/use-se-grid-rows"; +import type { CnstpSeRow } from "../model/cnstp/cnstp-se-row-types"; + +import { CnstpSeRowComponent } from "./cnstp-se-row"; + +function CnstpCodeTabPanel() { + const { confirm } = useConfirm(); + const alert = useAlertStore((state) => state.setMessage); + + const pageQuery = useCnstpSeMngPage({ + page: 0, + size: 500, + sort: "sortSeq,asc", + }); + + const saveMultiMutation = useSaveMultiCnstpSeMng(); + + const handlePersist = useCallback( + async (payload: Parameters[0]) => { + await saveMultiMutation.mutateAsync(payload); + }, + [saveMultiMutation], + ); + + const { + displayRows, + canSave, + updateRow, + addRootRow, + addChildRow, + removeDraft, + markDeleted, + resetFromServer, + save, + } = useSeGridRows(cnstpGridConfig, { + serverContent: pageQuery.data?.content, + onAlert: alert, + onSave: handlePersist, + }); + + const handleDelete = async (row: CnstpSeRow) => { + if (!(await confirm("삭제하시겠습니까?"))) return; + + if (row.isNew) { + removeDraft(row.rowKey); + return; + } + + markDeleted(row.rowKey); + }; + + const handleSave = async () => { + if (!(await confirm("저장하시겠습니까?"))) return; + + const saved = await save(); + if (!saved) return; + + alert("저장되었습니다."); + const result = await pageQuery.refetch(); + resetFromServer(result.data?.content ?? []); + }; + + const isSaving = saveMultiMutation.isPending; + + return ( +
+
+ + +
+ + {pageQuery.isPending ? ( +
+ + +
+ ) : displayRows.length === 0 ? ( +

+ 등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요. +

+ ) : ( +
+ + + + + + + + + + + + + 코드 + 코드명 + 설명 + 사용 여부 + 정렬순서 + 구분 + 관리 + + + + {displayRows.map(({ row, depth }) => ( + updateRow(row.rowKey, patch)} + onAddChild={() => addChildRow(row)} + onDelete={() => handleDelete(row)} + /> + ))} + +
+
+ )} +
+ ); +} + +export { CnstpCodeTabPanel }; diff --git a/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx b/apps/as/src/pages/code-management/ui/cnstp-se-row.tsx similarity index 83% rename from apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx rename to apps/as/src/pages/code-management/ui/cnstp-se-row.tsx index 090e7d2ba..299894821 100644 --- a/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx +++ b/apps/as/src/pages/code-management/ui/cnstp-se-row.tsx @@ -14,8 +14,8 @@ import { canAddChildRow, formatCdLvNo, formatTreeBranchPrefix, -} from "../model/build-cnstp-display-rows"; -import type { CnstpSeRow } from "../model/cnstp-se-row-types"; +} from "../model/cnstp/build-cnstp-display-rows"; +import type { CnstpSeRow } from "../model/cnstp/cnstp-se-row-types"; interface CnstpSeRowProps { row: CnstpSeRow; @@ -78,6 +78,17 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
+ + + onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined }) + } + className="w-20" + /> + {levelLabel}
diff --git a/apps/as/src/pages/code-management/ui/code-management-page.tsx b/apps/as/src/pages/code-management/ui/code-management-page.tsx new file mode 100644 index 000000000..6f647eaff --- /dev/null +++ b/apps/as/src/pages/code-management/ui/code-management-page.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from "react"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components"; + +import { AcntgCodeTabPanel } from "./acntg-code-tab-panel"; +import { CnstpCodeTabPanel } from "./cnstp-code-tab-panel"; +import { FnrsSharCodeTabPanel } from "./fnrs-shar-code-tab-panel"; + +const TAB = { + ACNTG: "acntg", + CNSTP: "cnstp", + FNRS_SHAR: "fnrs-shar", +} as const; + +function CodeManagementPage() { + const [activeTab, setActiveTab] = useState(TAB.ACNTG); + + return ( +
+
+
+

코드 관리

+
+ + + + + 회계코드 + + + 공사코드 + + + 재원분담구분코드 + + + + + + + + + + + + + +
+
+ ); +} + +export { CodeManagementPage }; diff --git a/apps/as/src/pages/code-management/ui/fnrs-shar-code-tab-panel.tsx b/apps/as/src/pages/code-management/ui/fnrs-shar-code-tab-panel.tsx new file mode 100644 index 000000000..3f7092c59 --- /dev/null +++ b/apps/as/src/pages/code-management/ui/fnrs-shar-code-tab-panel.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useCallback } from "react"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; + +import { useAlertStore } from "@/shared/lib"; +import { useFnrsSharSeMngPage, useSaveMultiFnrsSharSeMng } from "@/entities/fnrs-shar-se"; +import { + Button, + Skeleton, + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import { fnrsSharGridConfig } from "../lib/configs/fnrs-shar-config"; +import { useSeGridRows } from "../lib/use-se-grid-rows"; +import type { FnrsSharSeRow } from "../model/fnrs-shar/fnrs-shar-se-row-types"; + +import { FnrsSharSeRowComponent } from "./fnrs-shar-se-row"; + +function FnrsSharCodeTabPanel() { + const { confirm } = useConfirm(); + const alert = useAlertStore((state) => state.setMessage); + + const pageQuery = useFnrsSharSeMngPage({ + page: 0, + size: 500, + sort: "sortSeq,asc", + }); + + const saveMultiMutation = useSaveMultiFnrsSharSeMng(); + + const handlePersist = useCallback( + async (payload: Parameters[0]) => { + await saveMultiMutation.mutateAsync(payload); + }, + [saveMultiMutation], + ); + + const { + displayRows, + canSave, + updateRow, + addRootRow, + addChildRow, + removeDraft, + markDeleted, + resetFromServer, + save, + } = useSeGridRows(fnrsSharGridConfig, { + serverContent: pageQuery.data?.content, + onAlert: alert, + onSave: handlePersist, + }); + + const handleDelete = async (row: FnrsSharSeRow) => { + if (!(await confirm("삭제하시겠습니까?"))) return; + + if (row.isNew) { + removeDraft(row.rowKey); + return; + } + + markDeleted(row.rowKey); + }; + + const handleSave = async () => { + if (!(await confirm("저장하시겠습니까?"))) return; + + const saved = await save(); + if (!saved) return; + + alert("저장되었습니다."); + const result = await pageQuery.refetch(); + resetFromServer(result.data?.content ?? []); + }; + + const isSaving = saveMultiMutation.isPending; + + return ( +
+
+ + +
+ + {pageQuery.isPending ? ( +
+ + +
+ ) : displayRows.length === 0 ? ( +

+ 등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요. +

+ ) : ( +
+ + + + + + + + + + + + + 코드 + 코드명 + 설명 + 사용 여부 + 정렬순서 + 구분 + 관리 + + + + {displayRows.map(({ row, depth }) => ( + updateRow(row.rowKey, patch)} + onAddChild={() => addChildRow(row)} + onDelete={() => handleDelete(row)} + /> + ))} + +
+
+ )} +
+ ); +} + +export { FnrsSharCodeTabPanel }; diff --git a/apps/as/src/pages/code-management/ui/fnrs-shar-se-row.tsx b/apps/as/src/pages/code-management/ui/fnrs-shar-se-row.tsx new file mode 100644 index 000000000..071ac16e2 --- /dev/null +++ b/apps/as/src/pages/code-management/ui/fnrs-shar-se-row.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { + Button, + Input, + Label, + RadioGroup, + RadioGroupItem, + TableCell, + TableRow, +} from "@common/components"; + +import { + canAddChildRow, + formatCdLvNo, + formatTreeBranchPrefix, +} from "../model/fnrs-shar/build-fnrs-shar-display-rows"; +import type { FnrsSharSeRow } from "../model/fnrs-shar/fnrs-shar-se-row-types"; + +interface FnrsSharSeRowProps { + row: FnrsSharSeRow; + depth: number; + onChange: (patch: Partial) => void; + onAddChild: () => void; + onDelete: () => void; +} + +function FnrsSharSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: FnrsSharSeRowProps) { + const levelLabel = formatCdLvNo(row.cdLvNo); + const branchPrefix = formatTreeBranchPrefix(depth); + + return ( + + +
+ {branchPrefix ? ( + {branchPrefix} + ) : null} + onChange({ fnrsSharSeCd: e.target.value })} + placeholder="코드" + maxLength={10} + disabled={!row.isNew} + className="flex-1" + /> +
+
+ + onChange({ fnrsSharSeCdNm: e.target.value })} + placeholder="코드명" + maxLength={100} + /> + + + onChange({ expln: e.target.value })} + placeholder="설명" + maxLength={1000} + /> + + + onChange({ useYn: v })} + className="flex justify-center gap-4" + > +
+ + +
+
+ + +
+
+
+ + + onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined }) + } + className="w-20" + /> + + {levelLabel} + +
+ + +
+
+
+ ); +} + +export { FnrsSharSeRowComponent }; diff --git a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx index 0f1faf8a1..16d6fed1c 100644 --- a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx +++ b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx @@ -94,7 +94,7 @@ function EsntlAtchFileFormDialog({
setValue("docSeComCd", v, { shouldValidate: true })} placeholder="문서구분 선택" diff --git a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-list.tsx b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-list.tsx index 661b3a822..62a728073 100644 --- a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-list.tsx +++ b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-list.tsx @@ -36,7 +36,7 @@ function EsntlAtchFileList({ onPaginationChange, }: EsntlAtchFileListProps) { const { confirm } = useConfirm(); - const { data: docSeCodes } = useCodeList("ASDOCSECD"); + const { data: docSeCodes } = useCodeList("ASDOCSE"); const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null); const [editingRow, setEditingRow] = useState(null); diff --git a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-mng-page.tsx b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-mng-page.tsx index 425169983..3e5dbe9bb 100644 --- a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-mng-page.tsx +++ b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-mng-page.tsx @@ -32,7 +32,7 @@ const DEFAULT_SEARCH_PARAMS = { }; function EsntlAtchFileMngPage() { - const { data: docSeCodes, isPending: isDocSeLoading } = useCodeList("ASDOCSECD"); + const { data: docSeCodes, isPending: isDocSeLoading } = useCodeList("ASDOCSE"); const [docSeComCd, setDocSeComCd] = useState(ESNTL_ATCH_FILE_ALL_DOC_SE); const [pagination, setPagination] = useState({ diff --git a/apps/as/src/pages/fnrs-cnsltn/index.ts b/apps/as/src/pages/fnrs-cnsltn/index.ts new file mode 100644 index 000000000..0193bd7c8 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/index.ts @@ -0,0 +1,5 @@ +export { FnrsEditPage } from "./ui/fnrs-edit-page"; +export { FnrsFormPage } from "./ui/fnrs-form-page"; +export { FnrsListPage } from "./ui/fnrs-list-page"; +export { FnrsRegistPage } from "./ui/fnrs-regist-page"; +export { FnrsViewPage } from "./ui/fnrs-view-page"; diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/approval-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/approval-utils.ts new file mode 100644 index 000000000..d60d20b1c --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/approval-utils.ts @@ -0,0 +1,28 @@ +import type { ElaprDocPrgrsClientResponseDto } from "@/entities/fnrs-cnsltn"; + +export interface ApproverStatusConfig { + label: string; + badgeClass: string; +} + +export const APPROVER_STATUS_MAP: Record = { + 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", + } + ); +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/business-cost-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/business-cost-utils.ts new file mode 100644 index 000000000..6f3c14851 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/business-cost-utils.ts @@ -0,0 +1,35 @@ +import type { BizCstTreeDto } from "@/entities/fnrs-cnsltn"; + +const ACNTG_TREE_PATH_SEPARATOR = "|"; + +/** treePath를 회계구분 코드 배열로 파싱 */ +export function parseAcntgTreePath(treePath: string): string[] { + return treePath.split(ACNTG_TREE_PATH_SEPARATOR).filter(Boolean); +} + +/** treePath에서 직계 부모 acntgSeCd 반환 (루트면 null) */ +export function getParentAcntgSeCd(node: Pick): string | null { + const segments = parseAcntgTreePath(node.treePath); + if (segments.length <= 1) return null; + return segments[segments.length - 2]; +} + +/** treePath 기준으로 bizCstList 정렬 (원본 불변) */ +export function sortBizCstListByTreePath(list: BizCstTreeDto[]): BizCstTreeDto[] { + return [...list].sort((a, b) => { + const byPath = a.treePath.localeCompare(b.treePath); + if (byPath !== 0) return byPath; + return a.acntgSeCd.localeCompare(b.acntgSeCd); + }); +} + +/** 자식 노드가 없는 leaf 여부 (treePath | 구분자 기준) */ +export function isBizCstLeafNode( + node: Pick, + allNodes: Pick[], +): boolean { + const childPrefix = `${node.treePath}${ACNTG_TREE_PATH_SEPARATOR}`; + return !allNodes.some( + (other) => other.acntgSeCd !== node.acntgSeCd && other.treePath.startsWith(childPrefix), + ); +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts b/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts new file mode 100644 index 000000000..cc6d8d4b9 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts @@ -0,0 +1,143 @@ +export const FNRS_CNSLTN_VIEW_BUTTON_ACTION = { + strg: "strg", + aply: "aply", + del: "del", + splmntCmptn: "splmntCmptn", + fnrsCnsltnAncmnt: "fnrsCnsltnAncmnt", + envoSbmsn: "envoSbmsn", + rjct: "rjct", + sncnAprv: "sncnAprv", + rvwRply: "rvwRply", + rqstTechRvw: "rqstTechRvw", + dsgntRvwr: "dsgntRvwr", + rvwStrg: "rvwStrg", + rvwCmptn: "rvwCmptn", + dmndSplmnt: "dmndSplmnt", + atrzAprv: "atrzAprv", + atrzRply: "atrzRply", + grndsChck: "grndsChck", +} as const; + +export type FnrsCnsltnViewButtonAction = + (typeof FNRS_CNSLTN_VIEW_BUTTON_ACTION)[keyof typeof FNRS_CNSLTN_VIEW_BUTTON_ACTION]; + +const ROLE_STATUS_BUTTON_ACTIONS: Record> = { + ROLE_USER05: { + "": [ + FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg, + FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply, + FNRS_CNSLTN_VIEW_BUTTON_ACTION.del, + ], + SV: [ + FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg, + 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: { + ST: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct], + EP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv], + }, + ROLE_USER03: { + 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; + +const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record< + string, + Record +> = { + ER: { + ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr], + }, + TE: { + ATRZ02: [ + FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg, + FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn, + FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt, + ], + ATRZ03: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv], + ATRZ99: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply], + }, +}; + +function resolveButtonConfigs(actions: FnrsCnsltnViewButtonAction[]): FnrsCnsltnViewButtonAction[] { + return actions.map((action) => FNRS_CNSLTN_VIEW_BUTTON_ACTION[action]); +} + +interface GetButtonConfigsOptions { + hasPendingApprovalRequest?: boolean; +} + +function resolveRoleUser01AtrzButtonActions( + prgrsSttsComCd: string, + atrzSeComCdList: string[], + options?: GetButtonConfigsOptions, +): FnrsCnsltnViewButtonAction[] { + if (prgrsSttsComCd === "TE" && options?.hasPendingApprovalRequest === false) { + return []; + } + + const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd]; + if (!atrzStatusMap || atrzSeComCdList.length === 0) return []; + + const seen = new Set(); + const actions: FnrsCnsltnViewButtonAction[] = []; + + 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, +): FnrsCnsltnViewButtonAction[] { + 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/fnrs-cnsltn/lib/checklist-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/checklist-utils.ts new file mode 100644 index 000000000..c7ef294b9 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/checklist-utils.ts @@ -0,0 +1,178 @@ +import type { CklstRvwRsltTreeDto, CklstResultItem } from "@/entities/fnrs-cnsltn"; + +import { + type ChecklistFormState, + type ChecklistProgress, + type ChecklistRowKey, + type ChecklistStepMap, + type ReviewStepCode, + REVIEW_STEP_CODES, +} from "../model/view-types"; + +export function buildChecklistRowKey( + row: CklstRvwRsltTreeDto, + reviewStep?: ReviewStepCode, +): ChecklistRowKey { + const step = reviewStep ?? (row.rvwStpComCd as ReviewStepCode) ?? "LOCAL"; + return `${row.taskSeComCd}_${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}_${step}`; +} + +export function groupByReviewStep(rows: CklstRvwRsltTreeDto[]): ChecklistStepMap { + const base: ChecklistStepMap = { LOCAL: [], ENV: [], KECO: [] }; + rows.forEach((row) => { + const step = (row.rvwStpComCd ?? "LOCAL") as ReviewStepCode; + if (REVIEW_STEP_CODES.includes(step)) base[step].push(row); + }); + return base; +} + +export function extractTaskCodes(rows: CklstRvwRsltTreeDto[]): string[] { + return Array.from(new Set(rows.map((row) => row.taskSeComCd).filter(Boolean))); +} + +export function filterChecklistRows( + rows: CklstRvwRsltTreeDto[], + taskCode: string | null, +): CklstRvwRsltTreeDto[] { + return rows.filter((row) => (taskCode ? row.taskSeComCd === taskCode : true)).slice(); +} + +export function getChecklistDisplayRows( + rows: CklstRvwRsltTreeDto[], + taskCode?: string | null, +): CklstRvwRsltTreeDto[] { + const filtered = filterChecklistRows(rows, taskCode ?? null); + const seen = new Set(); + const deduped: CklstRvwRsltTreeDto[] = []; + + for (const row of filtered) { + const dedupeKey = `${row.taskSeComCd}_${row.cklstRvwSn}`; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + deduped.push(row); + } + + return deduped; +} + +export function computeChecklistProgress( + rows: CklstRvwRsltTreeDto[], + results: ChecklistFormState["results"], +): ChecklistProgress { + const activeRows = rows.filter((row) => row.bchckUseYn === "Y"); + const total = activeRows.length; + const completed = activeRows.reduce((acc, row) => { + const key = buildChecklistRowKey(row); + const val = results[key] ?? row.rvwRsltAnmlyYn ?? ""; + return acc + (val ? 1 : 0); + }, 0); + const percent = total > 0 ? Math.round((completed / total) * 100) : 0; + return { total, completed, percent }; +} + +export function filterChecklistRowsByReviewStep( + rows: CklstRvwRsltTreeDto[], + reviewStep: ReviewStepCode, +): CklstRvwRsltTreeDto[] { + return rows.filter((row) => row.rvwStpComCd === reviewStep); +} + +export function computeReviewStepChecklistProgress( + rows: CklstRvwRsltTreeDto[], + results: ChecklistFormState["results"], + reviewStep: ReviewStepCode, +): ChecklistProgress { + const activeRows = getChecklistDisplayRows(rows).filter((row) => row.bchckUseYn === "Y"); + const total = activeRows.length; + const completed = activeRows.reduce((acc, row) => { + const key = buildChecklistRowKey(row, reviewStep); + const val = results[key]; + return acc + (val === "Y" || val === "N" ? 1 : 0); + }, 0); + const percent = total > 0 ? Math.round((completed / total) * 100) : 0; + return { total, completed, percent }; +} + +export function buildChecklistFormState(rows: CklstRvwRsltTreeDto[]): ChecklistFormState { + const results: ChecklistFormState["results"] = {}; + const comments: ChecklistFormState["comments"] = {}; + rows.forEach((row) => { + const key = buildChecklistRowKey(row); + if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") { + results[key] = row.rvwRsltAnmlyYn; + } + if (row.picRvwCn) comments[key] = row.picRvwCn; + }); + return { + results, + comments, + progress: computeChecklistProgress(rows, results), + }; +} + +export function mergeChecklistFormStateFromServer( + prev: ChecklistFormState, + allRows: CklstRvwRsltTreeDto[], +): ChecklistFormState { + const results = { ...prev.results }; + const comments = { ...prev.comments }; + + for (const row of allRows) { + const step = row.rvwStpComCd as ReviewStepCode | undefined; + if (!step || !REVIEW_STEP_CODES.includes(step)) continue; + + const key = buildChecklistRowKey(row); + if ((row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") && results[key] === undefined) { + results[key] = row.rvwRsltAnmlyYn; + } + if (row.picRvwCn && !comments[key]) { + comments[key] = row.picRvwCn; + } + } + + return { + results, + comments, + progress: computeChecklistProgress(getChecklistDisplayRows(allRows), results), + }; +} + +export function patchChecklistFormState( + prev: ChecklistFormState, + patch: Partial>, + allRows: CklstRvwRsltTreeDto[], + reviewStep?: ReviewStepCode, +): ChecklistFormState { + const results = patch.results ?? prev.results; + const comments = patch.comments ?? prev.comments; + const progress = reviewStep + ? computeReviewStepChecklistProgress(allRows, results, reviewStep) + : computeChecklistProgress(getChecklistDisplayRows(allRows), results); + + return { + results, + comments, + progress, + }; +} + +export function toCklstSaveDto( + rows: CklstRvwRsltTreeDto[], + formState: ChecklistFormState, + reviewStep: ReviewStepCode, +): CklstResultItem[] { + return getChecklistDisplayRows(rows) + .filter((row) => row.bchckUseYn === "Y") + .map((row) => { + const key = buildChecklistRowKey(row, reviewStep); + const selected = formState.results[key] ?? null; + return { + cklstSn: row.cklstSn, + cklstArtclSn: row.cklstArtclSn, + cklstRvwSn: row.cklstRvwSn, + rvwStpComCd: reviewStep, + selected, + picRvwCn: formState.comments[key] ?? null, + }; + }); +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema.ts b/apps/as/src/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema.ts new file mode 100644 index 000000000..332ef9e14 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +export const fnrsCnsltnRegistSchema = z.object({ + fnrsCnsltnMng: 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, "구분은 필수입니다"), + cfmtnBgtBizNo: z.string().optional(), + bgtAplyYr: z.string().optional(), + dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"), + dsgnrTelno: z.string().min(1, "설계자 전화번호는 필수입니다"), + 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(), + fnrsCnsltnDocNo: z.string().optional(), + admdstCd: z.string().optional(), + envoSeComCd: z.string().optional(), + rtlztnFcltSeComCd: z.string().optional(), + rtlztnUsgSeComCd: z.string().optional(), + prgrsSttsComCd: z.string().optional(), + atchFileGroupId: z.string().optional(), + sharAtchFileGroupId: 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(), + }), + ), + bizCstList: z.array( + z.object({ + acntgSeCd: z.string(), + bscPlanAmt: z.number(), + bgtRfltAmt: z.number(), + tstmDesignAmt: z.number(), + }), + ), + fnrsSharList: z.array( + z.object({ + fnrsSharSeCd: z.string(), + exstnAmt: z.number(), + chgRqtAmt: z.number(), + ajmtAmt: z.number().optional(), + rmrkCn: z.string().optional(), + }), + ), + cklstResultList: z.array( + z.object({ + cklstSn: z.number(), + cklstArtclSn: z.number(), + cklstRvwSn: z.number(), + rvwStpComCd: z.string(), + selected: z.enum(["Y", "N"]).nullable(), + picRvwCn: z.string().nullable().optional(), + }), + ), + esntlAtchFileList: z.array( + z.object({ + esntlAtchFileId: z.string(), + atchFileGroupId: z.string(), + }), + ), + privacyConsent: z.enum(["Y", "N"], { + required_error: "개인정보 수집/이용 동의는 필수입니다", + }), +}); + +export type FnrsCnsltnRegistFormData = 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/fnrs-cnsltn/lib/form-data-mapper.ts b/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts new file mode 100644 index 000000000..d76887394 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts @@ -0,0 +1,135 @@ +import type { IUserInfo } from "@/shared/store"; +import type { FnrsCnsltnMngDetResponseDto } from "@/entities/fnrs-cnsltn"; +import { mapFnrsSharDetToFormList } from "@/widgets/pbadms-sprt"; + +import type { FnrsCnsltnRegistFormData } from "./fnrs-cnsltn-regist-schema"; +import { parseTaskSeComCdFromApi } from "./fnrs-cnsltn-regist-schema"; + +function mapSelectedValue(value?: string | null): "Y" | "N" | null { + if (value === "Y" || value === "N") return value; + return null; +} + +function mapEsntlAtchFileList(esntlFileList: FnrsCnsltnMngDetResponseDto["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: FnrsCnsltnMngDetResponseDto): FnrsCnsltnRegistFormData { + 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: { + fnrsCnsltnDocNo: detDto.fnrsCnsltnDocNo, + bizNm: detDto.bizNm ?? "", + bizBgngYr: detDto.bizBgngYr ?? "", + bizEndYr: detDto.bizEndYr ?? "", + bizCn: detDto.bizCn ?? "", + taskSeComCd: parseTaskSeComCdFromApi(detDto.taskSeComCd), + cfmtnBgtBizNo: detDto.cfmtnBgtBizNo, + bgtAplyYr: detDto.bgtAplyYr, + 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, + rtlztnFcltSeComCd: detDto.rtlztnFcltSeComCd ?? "", + rtlztnUsgSeComCd: detDto.rtlztnUsgSeComCd ?? "", + prgrsSttsComCd: detDto.prgrsSttsComCd, + atchFileGroupId: detDto.atchFileGroupId ?? "", + sharAtchFileGroupId: detDto.sharAtchFileGroupId ?? "", + 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, + })), + bizCstList: bizCstList.map((item) => ({ + acntgSeCd: item.acntgSeCd, + bscPlanAmt: item.bscPlanAmt, + bgtRfltAmt: item.bgtRfltAmt, + tstmDesignAmt: item.tstmDesignAmt, + })), + fnrsSharList: data.fnrsSharList?.length ? mapFnrsSharDetToFormList(data.fnrsSharList) : [], + cklstResultList: + cklstResultList?.map((item) => ({ + cklstSn: item.cklstSn, + cklstArtclSn: item.cklstArtclSn, + cklstRvwSn: item.cklstRvwSn, + rvwStpComCd: item.rvwStpComCd, + selected: mapSelectedValue(item.rvwRsltAnmlyYn), + picRvwCn: item.picRvwCn, + })) ?? [], + esntlAtchFileList: mapEsntlAtchFileList(esntlFileList), + privacyConsent: "Y", + }; +} + +export function getCreateDefaultValues( + userInfo: IUserInfo | null, + taskSeComCd?: string[], +): FnrsCnsltnRegistFormData { + return { + fnrsCnsltnMng: { + bizNm: "", + bizBgngYr: "", + bizEndYr: "", + bizCn: "", + taskSeComCd: taskSeComCd ?? [], + cfmtnBgtBizNo: "", + bgtAplyYr: "", + dsgnrNm: "", + dsgnrTelno: "", + dsgnrOgdpNm: "", + dsgnrEmlAddr: "", + aplcntNm: userInfo?.userName ?? "", + aplcntMblTelno: userInfo?.mphn ?? "", + aplcntOgdpNm: userInfo?.ogdpSeNm ?? "", + aplcntEmlAddr: userInfo?.email ?? "", + prgrsSttsComCd: "SV", + atchFileGroupId: "", + sharAtchFileGroupId: "", + rtlztnFcltSeComCd: "", + rtlztnUsgSeComCd: "", + }, + bzScaleList: [], + bizCstList: [], + fnrsSharList: [], + cklstResultList: [], + esntlAtchFileList: [], + privacyConsent: "N", + }; +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/review-opinion-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/review-opinion-utils.ts new file mode 100644 index 000000000..d1d2e2352 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/review-opinion-utils.ts @@ -0,0 +1,45 @@ +import type { + FnrsCnsltnMngDetResponseDto, + FnrsCnsltnRvwOpnnSaveRequestDto, +} from "@/entities/fnrs-cnsltn"; + +export interface ReviewOpinionFormData { + rvwRsltCn: string; + rvwRsltAnmlyYn: "Y" | "N" | ""; + atchFileGroupId: string; + opnrSeComCd: string; + rvwOpnnSn?: number; +} + +export function toRvwOpnnSaveDto( + data: FnrsCnsltnMngDetResponseDto, + reviewOpinionFormData: ReviewOpinionFormData | null | undefined, + opnrNm?: string, +): FnrsCnsltnRvwOpnnSaveRequestDto[] | undefined { + if (!reviewOpinionFormData || !data.detDto) return undefined; + + const hasContent = + reviewOpinionFormData.rvwRsltCn.trim() || + reviewOpinionFormData.rvwRsltAnmlyYn || + reviewOpinionFormData.atchFileGroupId; + + if (!hasContent) return undefined; + + const existingSn = + reviewOpinionFormData.rvwOpnnSn ?? + data.rvwList?.find((item) => item.opnrSeComCd === reviewOpinionFormData.opnrSeComCd) + ?.rvwOpnnSn ?? + 1; + + return [ + { + fnrsCnsltnDocNo: data.detDto.fnrsCnsltnDocNo, + rvwOpnnSn: existingSn, + opnrSeComCd: reviewOpinionFormData.opnrSeComCd, + opnrNm: opnrNm ?? "", + rvwRsltCn: reviewOpinionFormData.rvwRsltCn, + rvwRsltAnmlyYn: reviewOpinionFormData.rvwRsltAnmlyYn || undefined, + atchFileGroupId: reviewOpinionFormData.atchFileGroupId || undefined, + }, + ]; +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/review-step-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/review-step-utils.ts new file mode 100644 index 000000000..bce365857 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/review-step-utils.ts @@ -0,0 +1,44 @@ +import { + PBADMS_PROGRESS_ORDER, + REVIEW_STEP_CODES, + REVIEW_STEP_ROLE_RANK, + type ReviewStepCode, + ROLE_TO_REVIEW_STEP, +} from "../model/view-types"; + +export function getProgressStepCount(prgrsSttsComCd?: string): number { + const idx = PBADMS_PROGRESS_ORDER.indexOf( + prgrsSttsComCd as (typeof PBADMS_PROGRESS_ORDER)[number], + ); + if (idx <= PBADMS_PROGRESS_ORDER.indexOf("SV")) return 1; + if (idx <= PBADMS_PROGRESS_ORDER.indexOf("LE")) return 2; + return 3; +} + +export function getRoleStepCount(dtlInstCd?: string): number { + const mapped = dtlInstCd ? ROLE_TO_REVIEW_STEP[dtlInstCd] : undefined; + return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0; +} + +export function getVisibleReviewSteps( + prgrsSttsComCd: string, + userRoleCode?: string, +): ReviewStepCode[] { + const count = Math.min(getProgressStepCount(prgrsSttsComCd), getRoleStepCount(userRoleCode)); + return REVIEW_STEP_CODES.slice(0, count); +} + +export function getEditableReviewStep( + dtlInstCd?: string, + prgrsSttsComCd?: string, +): ReviewStepCode | undefined { + if (dtlInstCd === "ROLE_USER03" && (prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP")) { + return "ENV"; + } + + if (dtlInstCd === "ROLE_USER01" && prgrsSttsComCd === "TE") { + return "KECO"; + } + + return undefined; +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts new file mode 100644 index 000000000..cd561c762 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts @@ -0,0 +1,150 @@ +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 ReviewOpinionFormData, 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(); + + 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?: ReviewOpinionFormData | 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: [], + }; +} diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/use-checklist-form-state.ts b/apps/as/src/pages/fnrs-cnsltn/lib/use-checklist-form-state.ts new file mode 100644 index 000000000..6adca3b74 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/lib/use-checklist-form-state.ts @@ -0,0 +1,61 @@ +import { useCallback, useEffect, useState } from "react"; + +import type { CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn"; + +import { + type ChecklistFormState, + DEFAULT_CHECKLIST_PROGRESS, + type ReviewStepCode, +} from "../model/view-types"; + +import { mergeChecklistFormStateFromServer, patchChecklistFormState } from "./checklist-utils"; + +const EMPTY_CKLST_RESULT_LIST: CklstRvwRsltTreeDto[] = []; + +function isSameChecklistFormState(a: ChecklistFormState, b: ChecklistFormState): boolean { + if ( + a.progress.total !== b.progress.total || + a.progress.completed !== b.progress.completed || + a.progress.percent !== b.progress.percent + ) { + return false; + } + + const aResultKeys = Object.keys(a.results); + if (aResultKeys.length !== Object.keys(b.results).length) return false; + if (!aResultKeys.every((key) => a.results[key] === b.results[key])) return false; + + const aCommentKeys = Object.keys(a.comments); + if (aCommentKeys.length !== Object.keys(b.comments).length) return false; + + return aCommentKeys.every((key) => a.comments[key] === b.comments[key]); +} + +function useChecklistFormState(cklstResultList: CklstRvwRsltTreeDto[] = EMPTY_CKLST_RESULT_LIST) { + const [formState, setFormState] = useState({ + results: {}, + comments: {}, + progress: DEFAULT_CHECKLIST_PROGRESS, + }); + + useEffect(() => { + setFormState((prev) => { + const next = mergeChecklistFormStateFromServer(prev, cklstResultList); + return isSameChecklistFormState(prev, next) ? prev : next; + }); + }, [cklstResultList]); + + const patchFormState = useCallback( + ( + patch: Partial>, + reviewStep?: ReviewStepCode, + ) => { + setFormState((prev) => patchChecklistFormState(prev, patch, cklstResultList, reviewStep)); + }, + [cklstResultList], + ); + + return { formState, setFormState, patchFormState }; +} + +export { EMPTY_CKLST_RESULT_LIST, useChecklistFormState }; diff --git a/apps/as/src/pages/fnrs-cnsltn/model/columns.tsx b/apps/as/src/pages/fnrs-cnsltn/model/columns.tsx new file mode 100644 index 000000000..ba660c342 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/model/columns.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { Fragment } from "react"; + +import { createColumnHelper } from "@tanstack/react-table"; + +import { dayjs } from "@/shared/lib"; +import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn"; +import { Badge } from "@common/components"; + +const columnHelper = createColumnHelper(); + +const MAIN_STEP_LABELS = { + save: "저장", + apply: "신청완료", + techReview: "기술검토", + reply: "검토결과 회신", +} as const; + +const TECH_REVIEW_STATUS_CODES = new Set(["LE", "ER", "EE", "TE", "TS", "TR", "TP"]); + +type BadgeState = "completed" | "current" | "pending" | "action"; + +interface ProgressBadge { + label: string; + state: BadgeState; +} + +function buildProgressBadges(prgrsSttsComCd: string, prgrsSttsCdNm: string): ProgressBadge[] { + const techReviewLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.techReview; + const replyLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.reply; + + if (prgrsSttsComCd === "SV") { + return [ + { label: MAIN_STEP_LABELS.save, state: "current" }, + { label: MAIN_STEP_LABELS.apply, state: "pending" }, + { label: MAIN_STEP_LABELS.techReview, state: "pending" }, + { label: MAIN_STEP_LABELS.reply, state: "pending" }, + ]; + } + + if (prgrsSttsComCd === "ST") { + return [ + { label: MAIN_STEP_LABELS.save, state: "completed" }, + { label: MAIN_STEP_LABELS.apply, state: "current" }, + { label: MAIN_STEP_LABELS.techReview, state: "pending" }, + { label: MAIN_STEP_LABELS.reply, state: "pending" }, + ]; + } + + if (TECH_REVIEW_STATUS_CODES.has(prgrsSttsComCd)) { + return [ + { label: MAIN_STEP_LABELS.save, state: "completed" }, + { label: MAIN_STEP_LABELS.apply, state: "completed" }, + { label: techReviewLabel, state: "action" }, + { label: MAIN_STEP_LABELS.reply, state: "pending" }, + ]; + } + + if (prgrsSttsComCd === "EP") { + return [ + { label: MAIN_STEP_LABELS.save, state: "completed" }, + { label: MAIN_STEP_LABELS.apply, state: "completed" }, + { label: MAIN_STEP_LABELS.techReview, state: "completed" }, + { label: replyLabel, state: "action" }, + ]; + } + + if (prgrsSttsComCd === "TA" || prgrsSttsComCd === "TB") { + return [ + { label: MAIN_STEP_LABELS.save, state: "completed" }, + { label: MAIN_STEP_LABELS.apply, state: "completed" }, + { label: MAIN_STEP_LABELS.techReview, state: "completed" }, + { label: MAIN_STEP_LABELS.reply, state: "completed" }, + ]; + } + + 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 createFnrsCnsltnColumns() { + 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/fnrs-cnsltn/model/reviewer-option.ts b/apps/as/src/pages/fnrs-cnsltn/model/reviewer-option.ts new file mode 100644 index 000000000..2c9a72ef8 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/model/reviewer-option.ts @@ -0,0 +1,5 @@ +export interface ReviewerOption { + uuid: string; + name: string; + deptNm: string; +} diff --git a/apps/as/src/pages/fnrs-cnsltn/model/view-types.ts b/apps/as/src/pages/fnrs-cnsltn/model/view-types.ts new file mode 100644 index 000000000..db60bd696 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/model/view-types.ts @@ -0,0 +1,86 @@ +import type { + AtflMngClientResponseDto, + BizCstTreeDto, + BizSclTreeDto, + CklstRvwRsltTreeDto, + ElaprDocPrgrsClientResponseDto, + EsntlAtchFileMngTreeResponseDto, + FnrsCnsltnMngDetResponseDto, + FnrsCnsltnMngResponseDto, + FnrsCnsltnRvwOpnnResponseDto, +} from "@/entities/fnrs-cnsltn"; + +export type ReviewStepCode = "LOCAL" | "ENV" | "KECO"; + +export type { + AtflMngClientResponseDto, + BizCstTreeDto, + BizSclTreeDto, + CklstRvwRsltTreeDto, + ElaprDocPrgrsClientResponseDto, + EsntlAtchFileMngTreeResponseDto, + FnrsCnsltnMngDetResponseDto, + FnrsCnsltnMngResponseDto, + FnrsCnsltnRvwOpnnResponseDto, +}; + +export type ChecklistRowKey = string; + +export interface ChecklistProgress { + total: number; + completed: number; + percent: number; +} + +export const DEFAULT_CHECKLIST_PROGRESS: ChecklistProgress = { + total: 0, + completed: 0, + percent: 0, +}; + +export interface ChecklistFormState { + results: Record; + comments: Record; + progress: ChecklistProgress; +} + +export type ChecklistStepMap = Record; + +export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"]; + +export const REVIEW_STEP_LABELS: Record = { + LOCAL: "지자체", + ENV: "환경청", + KECO: "환경공단", +}; + +export const TASK_CODE_LABELS: Record = { + RE: "재이용수", + SP: "하수관로", + SF: "하수처리시설", +}; + +export const PBADMS_PROGRESS_ORDER = [ + "SV", + "SL", + "TA", + "ST", + "EP", + "LE", + "TP", + "ER", + "TE", + "TB", +] as const; + +export const ROLE_TO_REVIEW_STEP: Record = { + ROLE_USER05: "LOCAL", + ROLE_USER03: "ENV", + ROLE_USER01: "KECO", +}; + +export const REVIEW_STEP_ROLE_RANK: Record = { + LOCAL: 1, + ENV: 2, + KECO: 3, +}; diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/aprvr-picker-dialog.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/aprvr-picker-dialog.tsx new file mode 100644 index 000000000..dd241a514 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/aprvr-picker-dialog.tsx @@ -0,0 +1,125 @@ +"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 ( + + + + 검토인력 선택 + + +
+ {isPending ? ( +
+ + +
+ ) : isError ? ( +

+ 검토인력 목록을 불러오지 못했습니다. +

+ ) : items.length === 0 ? ( +

+ 배치된 검토인력이 없습니다. +

+ ) : ( + + + + + + + + + + + 성명 + 부서 + 직위 + 직급 + 선택 + + + + {items.map((item) => ( + + {item.userUuidNm || item.userUuid} + {item.ognzNm || "-"} + {item.jbpsSeNm || "-"} + {item.jbgdSeNm || "-"} + + + + + ))} + +
+ )} +
+ + + + +
+
+ ); +} + +export { AprvrPickerDialog }; diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/checklist-task-panel.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/checklist-task-panel.tsx new file mode 100644 index 000000000..4e0100089 --- /dev/null +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/checklist-task-panel.tsx @@ -0,0 +1,164 @@ +import type { Dispatch, SetStateAction } from "react"; + +import type { CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn"; +import { + cn, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Textarea, +} from "@common/components"; + +import { buildChecklistRowKey, patchChecklistFormState } from "../../lib/checklist-utils"; +import type { ReviewStepCode } from "../../model/view-types"; +import { type ChecklistFormState, ROLE_TO_REVIEW_STEP } from "../../model/view-types"; + +interface ChecklistTaskPanelProps { + selectedReviewStep: ReviewStepCode; + selectedRows: CklstRvwRsltTreeDto[]; + allRows: CklstRvwRsltTreeDto[]; + formState: ChecklistFormState; + setFormState: Dispatch>; + userRoleCode?: string; +} + +function ChecklistTaskPanel({ + selectedReviewStep, + selectedRows, + allRows, + formState, + setFormState, + userRoleCode, +}: ChecklistTaskPanelProps) { + const currentUserReviewStep = userRoleCode ? ROLE_TO_REVIEW_STEP[userRoleCode] : undefined; + const isStepEditable = currentUserReviewStep === selectedReviewStep; + + return ( + <> +
+ + + + + 검토항목 + + 주요 검토내용 + 검토 결과 + {selectedReviewStep !== "LOCAL" ? ( + + 기술검토 의견 + + ) : null} + + + 예 + 아니오 + + + + {selectedRows.length === 0 ? ( + + + 조회된 체크리스트 항목이 없습니다. + + + ) : ( + selectedRows.map((row) => { + const key = buildChecklistRowKey(row, selectedReviewStep); + const isFieldVisible = row.bchckUseYn === "Y"; + const isLocalReviewStep = selectedReviewStep === "LOCAL"; + + return ( + + {row.cklstArtclNm} + + {row.rvwCn} + + {isFieldVisible && ( + <> + + { + if (!isStepEditable) return; + setFormState((prev) => + patchChecklistFormState( + prev, + { results: { ...prev.results, [key]: "Y" } }, + allRows, + selectedReviewStep, + ), + ); + }} + className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")} + /> + + + { + if (!isStepEditable) return; + setFormState((prev) => + patchChecklistFormState( + prev, + { results: { ...prev.results, [key]: "N" } }, + allRows, + selectedReviewStep, + ), + ); + }} + className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")} + /> + + + )} + {!isLocalReviewStep && isFieldVisible && ( + +