Merge remote-tracking branch 'origin/iipbs/as/dev'
This commit is contained in:
@@ -1 +0,0 @@
|
||||
export { AcntgCodeManagementPage as default } from "@/pages/acntg-code-management";
|
||||
@@ -1 +0,0 @@
|
||||
export { CnstpCodeManagementPage as default } from "@/pages/cnstp-code-management";
|
||||
@@ -0,0 +1 @@
|
||||
export { CodeManagementPage as default } from "@/pages/code-management";
|
||||
@@ -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 <FnrsEditPage id={id} />;
|
||||
}
|
||||
@@ -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 <FnrsViewPage id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { FnrsListPage } from "@/pages/fnrs-cnsltn";
|
||||
|
||||
export default FnrsListPage;
|
||||
@@ -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 <FnrsRegistPage taskSeComCd={taskSeComCd.length ? taskSeComCd : undefined} />;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 (
|
||||
<BaseLayout
|
||||
queryClient={queryClient}
|
||||
authService={authService}
|
||||
currentPath={pathname ?? ""}
|
||||
handleMenuClick={handleMenuClick}
|
||||
>
|
||||
|
||||
@@ -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로 설정) */
|
||||
|
||||
@@ -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<void> {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
|
||||
@@ -66,14 +77,28 @@ async function silentRefresh(): Promise<void> {
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export const {
|
||||
setToken,
|
||||
removeToken,
|
||||
setupRefreshTimer,
|
||||
clearRefreshTimer,
|
||||
clearAuthToken,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export interface AuthPayload {
|
||||
userId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthService {
|
||||
login(payload: AuthPayload): Promise<void>;
|
||||
silentRefresh(): Promise<void>;
|
||||
logout(): Promise<void>;
|
||||
}
|
||||
@@ -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로 설정) */
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { client } from "@/shared/api";
|
||||
|
||||
export async function processElaprPrgrs(elaprPrgrsId: string, atrzPrgrsSttsComCd = "ATRZ_STTS_003"): Promise<void> {
|
||||
await client.put(`/as-elapr-service/api/v1/elapr-prgrs-stts/${elaprPrgrsId}/process`, {
|
||||
atrzPrgrsSttsComCd,
|
||||
});
|
||||
}
|
||||
@@ -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<ElaprPrgrsSttsResponseDto[]> {
|
||||
const response = await client.get<ElaprPrgrsSttsResponseDto[]>(BASE, {
|
||||
params: elaprDmndId ? { elaprDmndId } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(elaprPrgrsId: string): Promise<ElaprPrgrsSttsResponseDto> {
|
||||
const response = await client.get<ElaprPrgrsSttsResponseDto>(`${BASE}/${elaprPrgrsId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function save(body: ElaprPrgrsSttsSaveRequestDto): Promise<ElaprPrgrsSttsResponseDto> {
|
||||
const response = await client.post<ElaprPrgrsSttsResponseDto>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function process(
|
||||
elaprPrgrsId: string,
|
||||
body: ElaprProcessRequestDto,
|
||||
): Promise<ElaprPrgrsSttsResponseDto> {
|
||||
const response = await client.put<ElaprPrgrsSttsResponseDto>(
|
||||
`${BASE}/${elaprPrgrsId}/process`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function processByDocNoAndAprvrUuid(
|
||||
elaprDocNo: string,
|
||||
aprvrUuid: string,
|
||||
body: ElaprProcessRequestDto,
|
||||
): Promise<ElaprPrgrsSttsResponseDto> {
|
||||
const response = await client.put<ElaprPrgrsSttsResponseDto>(
|
||||
`${BASE}/doc/${elaprDocNo}/aprvr/${aprvrUuid}/process`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function deletePrgrsStts(elaprPrgrsId: string): Promise<void> {
|
||||
await client.delete(`${BASE}/${elaprPrgrsId}`);
|
||||
}
|
||||
|
||||
async function updateApprover(
|
||||
elaprPrgrsId: string,
|
||||
body: ElaprApproverChangeRequestDto,
|
||||
): Promise<ElaprPrgrsSttsResponseDto> {
|
||||
const response = await client.put<ElaprPrgrsSttsResponseDto>(
|
||||
`${BASE}/${elaprPrgrsId}/approver`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function insertApprover(
|
||||
elaprDmndId: string,
|
||||
body: ElaprApproverInsertRequestDto,
|
||||
): Promise<ElaprPrgrsSttsResponseDto> {
|
||||
const response = await client.post<ElaprPrgrsSttsResponseDto>(
|
||||
`${BASE}/${elaprDmndId}/approver/insert`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findMyElaperDocList(
|
||||
params: ElaprPrgrsSttsMyDocSearchParams,
|
||||
): Promise<PageResponse<ElaprPrgrsSttsResponseDto>> {
|
||||
const response = await client.get<PageResponse<ElaprPrgrsSttsResponseDto>>(`${BASE}/my/doc`, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const elaprPrgrsSttsService = {
|
||||
findAllByDmndId,
|
||||
findById,
|
||||
save,
|
||||
process,
|
||||
processByDocNoAndAprvrUuid,
|
||||
delete: deletePrgrsStts,
|
||||
updateApprover,
|
||||
insertApprover,
|
||||
findMyElaperDocList,
|
||||
} satisfies ElaprPrgrsSttsService;
|
||||
@@ -9,6 +9,13 @@ import type {
|
||||
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,
|
||||
@@ -17,11 +24,18 @@ import type {
|
||||
|
||||
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<typeof useQueryClient>) {
|
||||
queryClient.invalidateQueries({ queryKey: elaprPrgrsSttsBaseKey });
|
||||
queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey });
|
||||
}
|
||||
|
||||
export function useElaprTmpltPage(params: ElaprTmpltSearchParams) {
|
||||
return useQuery({
|
||||
@@ -230,3 +244,118 @@ export function useRemoveElaprDmnd() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,48 +1,91 @@
|
||||
export { elaprAplnService } from "./api/elapr-apln-service";
|
||||
export { elaprDmndService } from "./api/elapr-dmnd-service";
|
||||
export { elaprTmpltService } from "./api/elapr-tmplt-service";
|
||||
export {
|
||||
useCreateElaprTmplt,
|
||||
useDeleteElaprApln,
|
||||
useDeleteElaprTmplt,
|
||||
useElaprApln,
|
||||
useElaprAplnPage,
|
||||
useElaprDmnd,
|
||||
useElaprDmndPage,
|
||||
useElaprDocPrgrsByDocNo,
|
||||
useElaprTmplt,
|
||||
useElaprTmpltPage,
|
||||
useRemoveElaprDmnd,
|
||||
useReorderElaprApln,
|
||||
useSaveElaprDmnd,
|
||||
useSaveMultiElaprApln,
|
||||
useUpdateElaprApln,
|
||||
useUpdateElaprDmnd,
|
||||
useUpdateElaprDmndStatus,
|
||||
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 {
|
||||
ElaprDmndResponseDto,
|
||||
ElaprDmndSaveRequestDto,
|
||||
ElaprDmndSearchDto,
|
||||
ElaprDmndService,
|
||||
ElaprDocPrgrsResponseDto,
|
||||
} from "./model/elapr-dmnd-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";
|
||||
|
||||
+31
-4
@@ -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,
|
||||
+1
-1
@@ -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";
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
+25
-20
@@ -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;
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { ElaprApln } from "@/entities/elapr";
|
||||
import type { ElaprApln } from "./elapr-apln-interface";
|
||||
|
||||
/** 그리드 로컬 행 — API 응답 + UI 보조 필드 */
|
||||
export interface ElaprAplnRow extends Omit<ElaprApln, "aplnId"> {
|
||||
@@ -1,11 +1,13 @@
|
||||
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[];
|
||||
}
|
||||
|
||||
/** 백엔드 ElaprDmndResponseDto */
|
||||
@@ -13,7 +15,6 @@ export interface ElaprDmndResponseDto {
|
||||
elaprDmndId: string;
|
||||
rqstrUuid?: string;
|
||||
elaprDocNo: string;
|
||||
elaprTmpltId: string;
|
||||
dmndTtl: string;
|
||||
dmndCn: string;
|
||||
atrzSttsComCd: string;
|
||||
|
||||
@@ -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<ElaprPrgrsSttsResponseDto[]>;
|
||||
findById(elaprPrgrsId: string): Promise<ElaprPrgrsSttsResponseDto>;
|
||||
save(body: ElaprPrgrsSttsSaveRequestDto): Promise<ElaprPrgrsSttsResponseDto>;
|
||||
process(elaprPrgrsId: string, body: ElaprProcessRequestDto): Promise<ElaprPrgrsSttsResponseDto>;
|
||||
processByDocNoAndAprvrUuid(
|
||||
elaprDocNo: string,
|
||||
aprvrUuid: string,
|
||||
body: ElaprProcessRequestDto,
|
||||
): Promise<ElaprPrgrsSttsResponseDto>;
|
||||
delete(elaprPrgrsId: string): Promise<void>;
|
||||
updateApprover(
|
||||
elaprPrgrsId: string,
|
||||
body: ElaprApproverChangeRequestDto,
|
||||
): Promise<ElaprPrgrsSttsResponseDto>;
|
||||
insertApprover(
|
||||
elaprDmndId: string,
|
||||
body: ElaprApproverInsertRequestDto,
|
||||
): Promise<ElaprPrgrsSttsResponseDto>;
|
||||
findMyElaperDocList(
|
||||
params: ElaprPrgrsSttsMyDocSearchParams,
|
||||
): Promise<PageResponse<ElaprPrgrsSttsResponseDto>>;
|
||||
}
|
||||
@@ -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<PageResponse<FnrsCnsltnMngResponseDto>> {
|
||||
const response = await client.get<PageResponse<FnrsCnsltnMngResponseDto>>(BASE, {
|
||||
params: searchDto,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(fnrsCnsltnDocNo: string): Promise<FnrsCnsltnMngDetResponseDto> {
|
||||
const response = await client.get<FnrsCnsltnMngDetResponseDto>(`${BASE}/${fnrsCnsltnDocNo}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function save(body: FnrsCnsltnMngSaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function saveApply(body: FnrsCnsltnApplySaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(`${BASE}/save-apply`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(
|
||||
fnrsCnsltnDocNo: string,
|
||||
body: FnrsCnsltnMngUpdateRequestDto,
|
||||
): Promise<string> {
|
||||
const response = await client.put<string>(`${BASE}/${fnrsCnsltnDocNo}`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function techReview(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<void> {
|
||||
await client.post<void>(`${BASE}/${id}/tech-review`, body);
|
||||
}
|
||||
|
||||
async function submitSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(`${BASE}/${id}/submitsupplement`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function submitReqSupplement(
|
||||
id: string,
|
||||
body: FnrsCnsltnApplySaveRequestDto,
|
||||
): Promise<string> {
|
||||
const response = await client.post<string>(`${BASE}/${id}/reqsupplement`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(fnrsCnsltnDocNo: string): Promise<void> {
|
||||
await client.delete<void>(`${BASE}/${fnrsCnsltnDocNo}`);
|
||||
}
|
||||
|
||||
async function changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void> {
|
||||
await client.post<void>(`${BASE}/doc/updatePrgrsStts`, body);
|
||||
}
|
||||
|
||||
export const fnrsCnsltnMngService = {
|
||||
findPage,
|
||||
findById,
|
||||
save,
|
||||
saveApply,
|
||||
update,
|
||||
techReview,
|
||||
submitSupplement,
|
||||
submitReqSupplement,
|
||||
remove,
|
||||
changePrgrsSttsComCd,
|
||||
} satisfies FnrsCnsltnMngService;
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<FnrsCnsltnMngSaveRequestDto, "fnrsCnsltnDocNo">;
|
||||
|
||||
/** 백엔드 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<FnrsCnsltnMngSaveRequestDto, "fnrsCnsltnDocNo"> &
|
||||
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<FnrsCnsltnRvwOpnnSaveRequestDto, "fnrsCnsltnDocNo" | "rvwOpnnSn">
|
||||
> &
|
||||
Omit<FnrsCnsltnRvwOpnnSaveRequestDto, "fnrsCnsltnDocNo" | "rvwOpnnSn"> &
|
||||
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<string, CklstResultItem[]>;
|
||||
esntlAtchFileList?: EsntlAtchFileItem[];
|
||||
rvwOpnnList?: FnrsCnsltnRvwOpnnSaveRequestDto[];
|
||||
splmntDmndList?: FnrsCnsltnSplmntDmndItem[];
|
||||
fnrsSharList?: FnrsSharItem[];
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface FnrsCnsltnMngService {
|
||||
findPage(searchDto: FnrsCnsltnMngSearchDto): Promise<PageResponse<FnrsCnsltnMngResponseDto>>;
|
||||
findById(fnrsCnsltnDocNo: string): Promise<FnrsCnsltnMngDetResponseDto>;
|
||||
save(body: FnrsCnsltnMngSaveRequestDto): Promise<string>;
|
||||
saveApply(body: FnrsCnsltnApplySaveRequestDto): Promise<string>;
|
||||
update(fnrsCnsltnDocNo: string, body: FnrsCnsltnMngUpdateRequestDto): Promise<string>;
|
||||
techReview(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<void>;
|
||||
submitSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<string>;
|
||||
submitReqSupplement(id: string, body: FnrsCnsltnApplySaveRequestDto): Promise<string>;
|
||||
remove(fnrsCnsltnDocNo: string): Promise<void>;
|
||||
changePrgrsSttsComCd(body: DocPrgrsUpdateRequestDto): Promise<void>;
|
||||
}
|
||||
@@ -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<PageResponse<FnrsSharSeMngResponseDto>> {
|
||||
const response = await client.get<PageResponse<FnrsSharSeMngResponseDto>>(BASE, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findTree(): Promise<FnrsSharSeMngTreeDto[]> {
|
||||
const response = await client.get<FnrsSharSeMngTreeDto[]>(`${BASE}/list/tree`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(fnrsSharSeCd: string): Promise<FnrsSharSeMngResponseDto> {
|
||||
const response = await client.get<FnrsSharSeMngResponseDto>(`${BASE}/${fnrsSharSeCd}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function saveMulti(
|
||||
body: FnrsSharSeMngSaveRequestDto[],
|
||||
): Promise<FnrsSharSeMngResponseDto[]> {
|
||||
const response = await client.post<FnrsSharSeMngResponseDto[]>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(fnrsSharSeCd: string, body: FnrsSharSeMngSaveRequestDto): Promise<string> {
|
||||
const response = await client.put<string>(`${BASE}/${fnrsSharSeCd}`, {
|
||||
...body,
|
||||
fnrsSharSeCd,
|
||||
mode: FNRS_SHAR_SE_SAVE_MODE.UPDATE,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function deleteFnrsSharSeCd(fnrsSharSeCd: string): Promise<string> {
|
||||
const response = await client.delete<string>(`${BASE}/${fnrsSharSeCd}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findTreeFnrsShar(fnrsCnsltnDocNo: string): Promise<FnrsSharBizCstTreeDto[]> {
|
||||
const response = await client.get<FnrsSharBizCstTreeDto[]>(
|
||||
`${BASE}/list/tree/fnrs-shar/${fnrsCnsltnDocNo}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const fnrsSharSeMngService = {
|
||||
findAll,
|
||||
findTree,
|
||||
findById,
|
||||
saveMulti,
|
||||
update,
|
||||
delete: deleteFnrsSharSeCd,
|
||||
findTreeFnrsShar,
|
||||
} satisfies FnrsSharSeMngService;
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<PageResponse<FnrsSharSeMngResponseDto>>;
|
||||
findTree(): Promise<FnrsSharSeMngTreeDto[]>;
|
||||
findById(fnrsSharSeCd: string): Promise<FnrsSharSeMngResponseDto>;
|
||||
saveMulti(body: FnrsSharSeMngSaveRequestDto[]): Promise<FnrsSharSeMngResponseDto[]>;
|
||||
update(fnrsSharSeCd: string, body: FnrsSharSeMngSaveRequestDto): Promise<string>;
|
||||
delete(fnrsSharSeCd: string): Promise<string>;
|
||||
findTreeFnrsShar(fnrsCnsltnDocNo: string): Promise<FnrsSharBizCstTreeDto[]>;
|
||||
}
|
||||
@@ -1,4 +1,32 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
import type {
|
||||
AtflMngClientResponseDto,
|
||||
BizCstSaveBase,
|
||||
BizCstTreeDto,
|
||||
BizSclTreeDto,
|
||||
BzScaleSaveBase,
|
||||
CklstResultSaveBase,
|
||||
DocPrgrsUpdateRequestDto,
|
||||
ElaprDocPrgrsClientResponseDto,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
EsntlAtchFileSaveBase,
|
||||
PageableParams,
|
||||
PageResponse,
|
||||
PbadmsApplySaveHeader,
|
||||
PbadmsMngDetResponseBase,
|
||||
RvwOpnnResponseExtension,
|
||||
RvwOpnnSaveBase,
|
||||
SplmntDmndSaveBase,
|
||||
} from "@/shared/model";
|
||||
|
||||
export type {
|
||||
AtflMngClientResponseDto,
|
||||
BizCstTreeDto,
|
||||
BizSclTreeDto,
|
||||
CklstRvwRsltTreeDto,
|
||||
DocPrgrsUpdateRequestDto,
|
||||
ElaprDocPrgrsClientResponseDto,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
} from "@/shared/model";
|
||||
|
||||
/** 행정구역(시도·시군구) 콤보 응답 DTO */
|
||||
export interface AdstZoneResponseDto {
|
||||
@@ -70,16 +98,6 @@ export interface InstlSncnMngSaveRequestDto {
|
||||
/** 설치인가관리 수정 요청 DTO */
|
||||
export type InstlSncnMngUpdateRequestDto = InstlSncnMngSaveRequestDto;
|
||||
|
||||
/** 문서 진행상태 수정 요청 DTO */
|
||||
export interface DocPrgrsUpdateRequestDto {
|
||||
/** 행정지원문서번호 */
|
||||
docNo: string;
|
||||
/** 문서구분코드 */
|
||||
docSeComCd: string;
|
||||
/** 진행상태공통코드 */
|
||||
prgrsSttsComCd: string;
|
||||
}
|
||||
|
||||
/** 설치인가관리 응답 DTO */
|
||||
export interface InstlSncnMngResponseDto extends InstlSncnMngSaveRequestDto {
|
||||
/** 설치인가문서번호 (필수로 재정의) */
|
||||
@@ -111,7 +129,7 @@ export interface InstlSncnMngResponseDto extends InstlSncnMngSaveRequestDto {
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 사업규모 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnBzScaleSaveDto {
|
||||
export interface InstlSncnBzScaleSaveDto extends BzScaleSaveBase {
|
||||
/** 공종구분코드 */
|
||||
cnstpSeCd: string;
|
||||
/** 기본계획수량 */
|
||||
@@ -125,7 +143,7 @@ export interface InstlSncnBzScaleSaveDto {
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 총사업비 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnBizCstSaveRequestDto {
|
||||
export interface InstlSncnBizCstSaveRequestDto extends BizCstSaveBase {
|
||||
/** 회계구분코드 */
|
||||
acntgSeCd: string;
|
||||
/** 기본계획금액 */
|
||||
@@ -137,15 +155,13 @@ 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;
|
||||
/** 담당자검토내용(기술검토의견) */
|
||||
@@ -153,7 +169,7 @@ export interface InstlSncnCklstResultSaveDto {
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnEsntlAtchFileSaveDto {
|
||||
export interface InstlSncnEsntlAtchFileSaveDto extends EsntlAtchFileSaveBase {
|
||||
/** 필수첨부파일아이디 */
|
||||
esntlAtchFileId: string;
|
||||
/** 첨부파일그룹아이디 */
|
||||
@@ -161,53 +177,16 @@ export interface InstlSncnEsntlAtchFileSaveDto {
|
||||
}
|
||||
|
||||
/** 설치인가검토의견 저장 요청 DTO */
|
||||
export interface InstlSncnRvwOpnnSaveRequestDto {
|
||||
export interface InstlSncnRvwOpnnSaveRequestDto extends RvwOpnnSaveBase {
|
||||
/** 설치인가문서번호 */
|
||||
instlSncnDocNo: string;
|
||||
/** 검토의견일련번호 */
|
||||
rvwOpnnSn?: number;
|
||||
/** 의견자구분공통코드 */
|
||||
opnrSeComCd?: string;
|
||||
/** 의견자명 */
|
||||
opnrNm?: string;
|
||||
/** 검토결과내용 */
|
||||
rvwRsltCn?: string;
|
||||
/** 검토결과이상여부 */
|
||||
rvwRsltAnmlyYn?: string;
|
||||
/** 첨부파일그룹아이디 */
|
||||
atchFileGroupId?: string;
|
||||
}
|
||||
|
||||
/** 설치인가 보완요구 저장 DTO(배열 원소) */
|
||||
export interface InstlSncnSplmntDmndSaveDto {
|
||||
/** 보완요구 일련번호 */
|
||||
splmntDmndSn?: number;
|
||||
/** 보완항목 공통코드 */
|
||||
splmntArtclComCd?: string;
|
||||
/** 보완요구 내용 */
|
||||
splmntDmndCn?: string;
|
||||
/** 보완요구 일시 */
|
||||
splmntDmndDt?: string;
|
||||
/** 보완요구 첨부파일 그룹 ID */
|
||||
splmntDmndAtchFileGroupId?: string;
|
||||
/** 보완 내용 */
|
||||
splmntCn?: string;
|
||||
/** 보완 담당자 명 */
|
||||
splmntPicNm?: string;
|
||||
/** 보완완료 일시 */
|
||||
splmntCmptnDt?: string;
|
||||
/** 보완 첨부파일 그룹 ID */
|
||||
splmntAtchFileGroupId?: string;
|
||||
/** 완료 여부 */
|
||||
cmptnYn?: string;
|
||||
}
|
||||
export type InstlSncnSplmntDmndSaveDto = SplmntDmndSaveBase;
|
||||
|
||||
/** 설치인가 신청 - 통합 저장 요청 DTO */
|
||||
export interface InstlSncnApplySaveRequestDto {
|
||||
/** 기술검토저장구분(SV.저장, CP.검토완료) */
|
||||
techReViewSaveCd?: string;
|
||||
/** 검토단계공통코드(ENV.환경청.EP, KECO.환경공단.TE) */
|
||||
rvwStpCd?: string;
|
||||
export interface InstlSncnApplySaveRequestDto extends PbadmsApplySaveHeader {
|
||||
/** 설치인가관리 정보 */
|
||||
instlSncnMng: InstlSncnMngSaveRequestDto;
|
||||
/** 사업규모 목록 */
|
||||
@@ -228,184 +207,28 @@ export interface InstlSncnApplySaveRequestDto {
|
||||
|
||||
export type ReviewStepCode = "LOCAL" | "ENV" | "KECO";
|
||||
|
||||
/** 공종 트리와 사업규모(수량) 조합 DTO */
|
||||
export interface BizSclTreeDto {
|
||||
/** 들여쓰기 적용된 공종명(시각적) */
|
||||
indCnstpSeCdNm: string;
|
||||
/** 공종 코드 */
|
||||
cnstpSeCd: string;
|
||||
/** 공종 코드명 */
|
||||
cnstpSeCdNm: string;
|
||||
/** 사용 여부(Y/N) */
|
||||
useYn: string;
|
||||
/** 트리 레벨(깊이) */
|
||||
treeLevel: number;
|
||||
/** 트리 경로 표시용 문자열 */
|
||||
treePath: string;
|
||||
/** 기본계획수량 */
|
||||
bscPlanQty: number;
|
||||
/** 예산반영수량 */
|
||||
bgtRfltQty: number;
|
||||
/** 금회설계수량 */
|
||||
tstmDesignQty: number;
|
||||
}
|
||||
|
||||
/** 회계구분 트리와 사업비용 조합 DTO */
|
||||
export interface BizCstTreeDto {
|
||||
/** 들여쓰기 적용된 회계구분명(시각적) */
|
||||
indAcntgSeCdNm: string;
|
||||
/** 회계구분 코드 */
|
||||
acntgSeCd: string;
|
||||
/** 회계구분 코드명 */
|
||||
acntgSeCdNm: string;
|
||||
/** 사용 여부(Y/N) */
|
||||
useYn: string;
|
||||
/** 트리 레벨(깊이) */
|
||||
treeLevel: number;
|
||||
/** 트리 경로 표시용 문자열 */
|
||||
treePath: string;
|
||||
/** 기본계획금액 */
|
||||
bscPlanAmt: number;
|
||||
/** 예산반영금액 */
|
||||
bgtRfltAmt: number;
|
||||
/** 금회설계금액 */
|
||||
tstmDesignAmt: number;
|
||||
}
|
||||
|
||||
/** 점검표 검토결과 트리 DTO */
|
||||
export interface CklstRvwRsltTreeDto {
|
||||
/** 점검표일련번호 */
|
||||
cklstSn: number;
|
||||
/** 점검표명 */
|
||||
cklstNm: string;
|
||||
/** 점검표항목일련번호 */
|
||||
cklstArtclSn: number;
|
||||
/** 점검표항목명 */
|
||||
cklstArtclNm: string;
|
||||
/** 검토일련번호 */
|
||||
cklstRvwSn: number;
|
||||
/** 상위검토일련번호 */
|
||||
upCklstRvwSn: number | null;
|
||||
/** 트리 레벨 */
|
||||
treeLevel: number;
|
||||
/** 검토내용 */
|
||||
rvwCn: string;
|
||||
/** 점검 사용 여부 */
|
||||
bchckUseYn: string;
|
||||
/** 정렬순서 */
|
||||
sortSeq: number;
|
||||
/** 업무구분코드 */
|
||||
taskSeComCd: string;
|
||||
/** 검토단계공통코드 */
|
||||
rvwStpComCd: string;
|
||||
/** 검토결과이상여부 */
|
||||
rvwRsltAnmlyYn: string;
|
||||
/** 검토담당자 UUID */
|
||||
rvwPicUuid: string | null;
|
||||
/** 담당자검토내용 */
|
||||
picRvwCn: string | null;
|
||||
/** 검토일시 */
|
||||
rvwDt: string | null;
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 트리 조회 응답 DTO */
|
||||
export interface EsntlAtchFileMngTreeResponseDto {
|
||||
/** 필수첨부파일아이디 */
|
||||
esntlAtchFileId: string;
|
||||
/** 문서구분공통코드 */
|
||||
docSeComCd: string;
|
||||
/** 필수첨부파일명 */
|
||||
esntlAtchFileNm: string;
|
||||
/** 필수첨부여부 */
|
||||
esntlAtchYn: string;
|
||||
/** 필수첨부파일비고 */
|
||||
esntlAtchFileRmrk: string | null;
|
||||
/** 첨부파일 코드 */
|
||||
atflCd?: string;
|
||||
/** 첨부파일 일련번호 */
|
||||
atflSn?: number;
|
||||
/** 첨부파일 ID */
|
||||
atflId?: string;
|
||||
/** 원본 파일명 */
|
||||
orgnlFileNm?: string;
|
||||
/** 첨부파일 크기 */
|
||||
atflFlieSz?: number;
|
||||
/** 첨부파일 타입 */
|
||||
atflType?: string;
|
||||
/** 다운로드 횟수 */
|
||||
dwnldNocs?: number;
|
||||
/** 등록일시 */
|
||||
regDt?: string;
|
||||
}
|
||||
|
||||
/** 첨부파일 응답 DTO (attach-service client) */
|
||||
export interface AtflMngClientResponseDto {
|
||||
atflCd: string;
|
||||
atflSn: number;
|
||||
atflId: string;
|
||||
physFileNm: string;
|
||||
orgnlFileNm: string;
|
||||
atflFlieSz: number;
|
||||
atflType: string;
|
||||
dwnldNocs: number;
|
||||
delYn: string;
|
||||
lnkgDmnId: string | null;
|
||||
lnkgDmnNm: string | null;
|
||||
regDt: string;
|
||||
}
|
||||
|
||||
/** 설치인가검토의견 응답 DTO */
|
||||
export interface InstlSncnRvwOpnnResponseDto {
|
||||
/** 설치인가문서번호 */
|
||||
instlSncnDocNo: string;
|
||||
/** 검토의견일련번호 */
|
||||
rvwOpnnSn: number;
|
||||
/** 의견자구분공통코드 */
|
||||
opnrSeComCd: string;
|
||||
/** 의견자구분공통코드명 */
|
||||
opnrSeCdNm: string | null;
|
||||
/** 의견자명 */
|
||||
opnrNm: string;
|
||||
/** 검토결과내용 */
|
||||
rvwRsltCn: string;
|
||||
/** 검토결과이상여부 */
|
||||
rvwRsltAnmlyYn: string;
|
||||
/** 첨부파일그룹아이디 */
|
||||
atchFileGroupId: string | null;
|
||||
/** 첨부파일 상세 정보 */
|
||||
atchFileInfo?: AtflMngClientResponseDto;
|
||||
/** 등록일시 */
|
||||
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;
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
/** 설치인가관리 상세 응답 DTO */
|
||||
export interface InstlSncnMngDetResponse {
|
||||
export interface InstlSncnMngDetResponse
|
||||
extends PbadmsMngDetResponseBase<InstlSncnMngResponseDto, InstlSncnRvwOpnnResponseDto> {
|
||||
detDto: InstlSncnMngResponseDto;
|
||||
bizSclList: BizSclTreeDto[];
|
||||
bizCstList: BizCstTreeDto[];
|
||||
cklstResultList?: CklstRvwRsltTreeDto[];
|
||||
esntlFileList: EsntlAtchFileMngTreeResponseDto[];
|
||||
etcFileList: AtflMngClientResponseDto[];
|
||||
rvwList?: InstlSncnRvwOpnnResponseDto[];
|
||||
elaprDocPrgrsList?: ElaprDocPrgrsClientResponseDto[] | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { AcntgCodeManagementPage } from "./ui/acntg-code-management-page";
|
||||
@@ -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<string, AcntgSeBaseEntry>,
|
||||
): 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<string, AcntgSeBaseEntry> = {};
|
||||
|
||||
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<string> {
|
||||
const result = new Set<string>([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<string>,
|
||||
): AcntgDisplaySlot[] {
|
||||
return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey));
|
||||
}
|
||||
|
||||
export function patchAcntgGridRow(
|
||||
state: AcntgSeGridState,
|
||||
rowKey: string,
|
||||
patch: Partial<AcntgSeRow>,
|
||||
): 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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<typeof buildSaveMultiPayload>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAcntgSeRows({ serverContent, onAlert, onSave }: UseAcntgSeRowsOptions) {
|
||||
const [grid, setGrid] = useState<AcntgSeGridState>(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<AcntgSeRow>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<typeof saveMultiMutation.mutateAsync>[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 (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background pb-6 pt-0">
|
||||
<div className="card-header flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-2xl font-semibold">회계코드 관리</h3>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" color="green" onClick={addRootRow}>
|
||||
코드 추가
|
||||
</Button>
|
||||
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pageQuery.isPending ? (
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : displayRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[60rem] overflow-y-auto pt-3">
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="15%" />
|
||||
<col width="20%" />
|
||||
<col width="35%" />
|
||||
<col width="12%" />
|
||||
<col width="8%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>코드명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayRows.map(({ row, depth }) => (
|
||||
<AcntgSeRowComponent
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
depth={depth}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onAddChild={() => addChildRow(row)}
|
||||
onDelete={() => handleDelete(row)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { AcntgCodeManagementPage };
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { CnstpCodeManagementPage } from "./ui/cnstp-code-management-page";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, CnstpSeBaseEntry>,
|
||||
): 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<string, CnstpSeBaseEntry> = {};
|
||||
|
||||
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<string> {
|
||||
const result = new Set<string>([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<string>,
|
||||
): CnstpDisplaySlot[] {
|
||||
return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey));
|
||||
}
|
||||
|
||||
export function patchCnstpGridRow(
|
||||
state: CnstpSeGridState,
|
||||
rowKey: string,
|
||||
patch: Partial<CnstpSeRow>,
|
||||
): 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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<typeof buildSaveMultiPayload>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useCnstpSeRows({ serverContent, onAlert, onSave }: UseCnstpSeRowsOptions) {
|
||||
const [grid, setGrid] = useState<CnstpSeGridState>(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<CnstpSeRow>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<typeof saveMultiMutation.mutateAsync>[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 (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background pb-6 pt-0">
|
||||
<div className="card-header flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-2xl font-semibold">공종코드 관리</h3>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" color="green" onClick={addRootRow}>
|
||||
코드 추가
|
||||
</Button>
|
||||
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pageQuery.isPending ? (
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : displayRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[60rem] overflow-y-auto pt-3">
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="15%" />
|
||||
<col width="20%" />
|
||||
<col width="35%" />
|
||||
<col width="12%" />
|
||||
<col width="8%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>코드명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayRows.map(({ row, depth }) => (
|
||||
<CnstpSeRowComponent
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
depth={depth}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onAddChild={() => addChildRow(row)}
|
||||
onDelete={() => handleDelete(row)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CnstpCodeManagementPage };
|
||||
@@ -0,0 +1 @@
|
||||
export { CodeManagementPage } from "./ui/code-management-page";
|
||||
@@ -0,0 +1,60 @@
|
||||
export interface SaveModeMap {
|
||||
CREATE: string;
|
||||
UPDATE: string;
|
||||
DELETE: string;
|
||||
}
|
||||
|
||||
export interface SavePayloadBuilderConfig<TRow, TSaveDto> {
|
||||
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<T extends { isDeleted: boolean }>(rows: T[]): T[] {
|
||||
return rows.filter((row) => !row.isDeleted);
|
||||
}
|
||||
|
||||
function compareCdLvNoDesc<T extends RowWithState>(a: T, b: T): number {
|
||||
return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1);
|
||||
}
|
||||
|
||||
function compareCdLvNoAsc<T extends RowWithState>(a: T, b: T): number {
|
||||
return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1);
|
||||
}
|
||||
|
||||
export function createSavePayloadBuilder<TRow extends RowWithState, TSaveDto>(
|
||||
config: SavePayloadBuilderConfig<TRow, TSaveDto>,
|
||||
) {
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -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<AcntgSeRow, AcntgSeMngSaveRequestDto>({
|
||||
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<AcntgSeMngResponseDto>,
|
||||
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,
|
||||
};
|
||||
@@ -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<CnstpSeRow, CnstpSeMngSaveRequestDto>({
|
||||
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<CnstpSeMngResponseDto>,
|
||||
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,
|
||||
};
|
||||
@@ -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<FnrsSharSeRow, FnrsSharSeMngSaveRequestDto>({
|
||||
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<FnrsSharSeMngResponseDto>,
|
||||
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,
|
||||
};
|
||||
@@ -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<TDto>,
|
||||
> {
|
||||
baseByKey: Record<string, TBaseEntry>;
|
||||
structure: DisplaySlot[];
|
||||
drafts: Record<string, TDraft>;
|
||||
patches: Record<string, TPatch>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
|
||||
export interface SeDisplayRow<TRow> {
|
||||
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<TDto>,
|
||||
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<TRow>) => TDraft;
|
||||
assembleDraftRow: (draft: TDraft, parentCode: string | undefined) => TRow;
|
||||
buildDisplayRows: (rows: TRow[]) => SeDisplayRow<TRow>[];
|
||||
validateRow: (row: TRow) => string | null;
|
||||
buildSavePayload: (rows: TRow[]) => TSaveDto[];
|
||||
}
|
||||
@@ -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<TDto>,
|
||||
TSaveDto,
|
||||
>(config: SeGridConfig<TDto, TBaseEntry, TRow, TDraft, TPatch, TSaveDto>) {
|
||||
function createEmptyGridState(): SeGridState<TDto, TBaseEntry, TDraft, TPatch> {
|
||||
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<string, TBaseEntry>): 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<TDto, TBaseEntry, TDraft, TPatch> {
|
||||
const baseByKey: Record<string, TBaseEntry> = {};
|
||||
|
||||
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<TDto, TBaseEntry, TDraft, TPatch>,
|
||||
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<TDto, TBaseEntry, TDraft, TPatch>,
|
||||
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<TDto, TBaseEntry, TDraft, TPatch>): 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<TDto, TBaseEntry, TDraft, TPatch>): 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<string> {
|
||||
const result = new Set<string>([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<string>,
|
||||
): DisplaySlot[] {
|
||||
return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey));
|
||||
}
|
||||
|
||||
function patchGridRow(
|
||||
state: SeGridState<TDto, TBaseEntry, TDraft, TPatch>,
|
||||
rowKey: string,
|
||||
patch: Partial<TRow>,
|
||||
): SeGridState<TDto, TBaseEntry, TDraft, TPatch> {
|
||||
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<TDto, TBaseEntry, TDraft, TPatch>,
|
||||
rootRowKey: string,
|
||||
): SeGridState<TDto, TBaseEntry, TDraft, TPatch> {
|
||||
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<TDto, TBaseEntry, TDraft, TPatch>,
|
||||
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<TDto, TBaseEntry, TDraft, TPatch>,
|
||||
rowKey: string,
|
||||
): SeGridState<TDto, TBaseEntry, TDraft, TPatch> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<TDto>,
|
||||
TSaveDto,
|
||||
> {
|
||||
serverContent: TDto[] | undefined;
|
||||
onAlert: (message: string) => void;
|
||||
onSave: (payload: TSaveDto[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSeGridRows<
|
||||
TDto extends BaseSeDto,
|
||||
TBaseEntry extends TDto & { rowKey: string },
|
||||
TRow extends TBaseEntry & SeRowState,
|
||||
TDraft extends SeDraftBase,
|
||||
TPatch extends Partial<TDto>,
|
||||
TSaveDto,
|
||||
>(
|
||||
config: SeGridConfig<TDto, TBaseEntry, TRow, TDraft, TPatch, TSaveDto>,
|
||||
{
|
||||
serverContent,
|
||||
onAlert,
|
||||
onSave,
|
||||
}: UseSeGridRowsOptions<TDto, TBaseEntry, TRow, TDraft, TPatch, TSaveDto>,
|
||||
) {
|
||||
const utils = useMemo(() => createSeGridUtils(config), [config]);
|
||||
|
||||
const [grid, setGrid] = useState<SeGridState<TDto, TBaseEntry, TDraft, TPatch>>(
|
||||
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<TRow>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
+35
-34
@@ -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<string, AcntgSeBaseEntry>;
|
||||
structure: AcntgDisplaySlot[];
|
||||
drafts: Record<string, AcntgSeDraft>;
|
||||
patches: Record<string, Partial<AcntgSeMngResponseDto>>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
|
||||
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<string, AcntgSeBaseEntry>;
|
||||
structure: AcntgDisplaySlot[];
|
||||
drafts: Record<string, AcntgSeDraft>;
|
||||
patches: Record<string, Partial<AcntgSeMngResponseDto>>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
|
||||
export interface AcntgSeDisplayItem {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
}
|
||||
+30
-29
@@ -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<string, CnstpSeBaseEntry>;
|
||||
structure: CnstpDisplaySlot[];
|
||||
drafts: Record<string, CnstpSeDraft>;
|
||||
patches: Record<string, Partial<CnstpSeMngResponseDto>>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
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<string, CnstpSeBaseEntry>;
|
||||
structure: CnstpDisplaySlot[];
|
||||
drafts: Record<string, CnstpSeDraft>;
|
||||
patches: Record<string, Partial<CnstpSeMngResponseDto>>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
@@ -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<string>,
|
||||
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<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<string, FnrsSharSeBaseEntry>;
|
||||
structure: FnrsSharDisplaySlot[];
|
||||
drafts: Record<string, FnrsSharSeDraft>;
|
||||
patches: Record<string, Partial<FnrsSharSeMngResponseDto>>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
|
||||
export interface FnrsSharSeDisplayItem {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<typeof saveMultiMutation.mutateAsync>[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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 px-4">
|
||||
<Button type="button" color="green" onClick={addRootRow}>
|
||||
코드 추가
|
||||
</Button>
|
||||
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pageQuery.isPending ? (
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : displayRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[60rem] overflow-y-auto px-4 pb-4 pt-1">
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="14%" />
|
||||
<col width="16%" />
|
||||
<col width="26%" />
|
||||
<col width="11%" />
|
||||
<col width="8%" />
|
||||
<col width="8%" />
|
||||
<col width="11%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>코드명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>정렬순서</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayRows.map(({ row, depth }) => (
|
||||
<AcntgSeRowComponent
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
depth={depth}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onAddChild={() => addChildRow(row)}
|
||||
onDelete={() => handleDelete(row)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { AcntgCodeTabPanel };
|
||||
+13
-2
@@ -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
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={row.sortSeq ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined })
|
||||
}
|
||||
className="w-20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">{levelLabel}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
@@ -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<typeof saveMultiMutation.mutateAsync>[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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 px-4">
|
||||
<Button type="button" color="green" onClick={addRootRow}>
|
||||
코드 추가
|
||||
</Button>
|
||||
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pageQuery.isPending ? (
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : displayRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[60rem] overflow-y-auto px-4 pb-4 pt-1">
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="14%" />
|
||||
<col width="16%" />
|
||||
<col width="26%" />
|
||||
<col width="11%" />
|
||||
<col width="8%" />
|
||||
<col width="8%" />
|
||||
<col width="11%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>코드명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>정렬순서</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayRows.map(({ row, depth }) => (
|
||||
<CnstpSeRowComponent
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
depth={depth}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onAddChild={() => addChildRow(row)}
|
||||
onDelete={() => handleDelete(row)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CnstpCodeTabPanel };
|
||||
+13
-2
@@ -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
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={row.sortSeq ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined })
|
||||
}
|
||||
className="w-20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">{levelLabel}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
@@ -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<string>(TAB.ACNTG);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background pb-6 pt-0">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-semibold">코드 관리</h3>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="pt-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value={TAB.ACNTG} className="!px-6 !py-3 text-xl">
|
||||
회계코드
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={TAB.CNSTP} className="!px-6 !py-3 text-xl">
|
||||
공사코드
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={TAB.FNRS_SHAR} className="!px-6 !py-3 text-xl">
|
||||
재원분담구분코드
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={TAB.ACNTG} className="mt-0 border-0 p-0 shadow-none ring-0">
|
||||
<AcntgCodeTabPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value={TAB.CNSTP} className="mt-0 border-0 p-0 shadow-none ring-0">
|
||||
<CnstpCodeTabPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value={TAB.FNRS_SHAR} className="mt-0 border-0 p-0 shadow-none ring-0">
|
||||
<FnrsSharCodeTabPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CodeManagementPage };
|
||||
@@ -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<typeof saveMultiMutation.mutateAsync>[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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 px-4">
|
||||
<Button type="button" color="green" onClick={addRootRow}>
|
||||
코드 추가
|
||||
</Button>
|
||||
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pageQuery.isPending ? (
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : displayRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[60rem] overflow-y-auto px-4 pb-4 pt-1">
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="14%" />
|
||||
<col width="16%" />
|
||||
<col width="26%" />
|
||||
<col width="11%" />
|
||||
<col width="8%" />
|
||||
<col width="8%" />
|
||||
<col width="11%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>코드명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>정렬순서</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayRows.map(({ row, depth }) => (
|
||||
<FnrsSharSeRowComponent
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
depth={depth}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onAddChild={() => addChildRow(row)}
|
||||
onDelete={() => handleDelete(row)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { FnrsSharCodeTabPanel };
|
||||
@@ -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<FnrsSharSeRow>) => void;
|
||||
onAddChild: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function FnrsSharSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: FnrsSharSeRowProps) {
|
||||
const levelLabel = formatCdLvNo(row.cdLvNo);
|
||||
const branchPrefix = formatTreeBranchPrefix(depth);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex min-w-[10rem] items-center gap-1">
|
||||
{branchPrefix ? (
|
||||
<span className="shrink-0 font-mono text-muted">{branchPrefix}</span>
|
||||
) : null}
|
||||
<Input
|
||||
value={row.fnrsSharSeCd}
|
||||
onChange={(e) => onChange({ fnrsSharSeCd: e.target.value })}
|
||||
placeholder="코드"
|
||||
maxLength={10}
|
||||
disabled={!row.isNew}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.fnrsSharSeCdNm ?? ""}
|
||||
onChange={(e) => onChange({ fnrsSharSeCdNm: e.target.value })}
|
||||
placeholder="코드명"
|
||||
maxLength={100}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.expln ?? ""}
|
||||
onChange={(e) => onChange({ expln: e.target.value })}
|
||||
placeholder="설명"
|
||||
maxLength={1000}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<RadioGroup
|
||||
value={row.useYn === "N" ? "N" : "Y"}
|
||||
onValueChange={(v) => onChange({ useYn: v })}
|
||||
className="flex justify-center gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="Y" id={`${row.rowKey}-use-y`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="N" id={`${row.rowKey}-use-n`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={row.sortSeq ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined })
|
||||
}
|
||||
className="w-20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">{levelLabel}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
disabled={!canAddChildRow()}
|
||||
onClick={onAddChild}
|
||||
>
|
||||
하위추가
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="gray" onClick={onDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export { FnrsSharSeRowComponent };
|
||||
@@ -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";
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ElaprDocPrgrsClientResponseDto } from "@/entities/fnrs-cnsltn";
|
||||
|
||||
export interface ApproverStatusConfig {
|
||||
label: string;
|
||||
badgeClass: string;
|
||||
}
|
||||
|
||||
export const APPROVER_STATUS_MAP: Record<string, ApproverStatusConfig> = {
|
||||
ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700" },
|
||||
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800" },
|
||||
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800" },
|
||||
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800" },
|
||||
};
|
||||
|
||||
export function sortByAtrzSeqNo(
|
||||
list: ElaprDocPrgrsClientResponseDto[],
|
||||
): ElaprDocPrgrsClientResponseDto[] {
|
||||
return list.slice().sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
|
||||
}
|
||||
|
||||
export function getApproverStatus(status: string | undefined): ApproverStatusConfig {
|
||||
return (
|
||||
APPROVER_STATUS_MAP[status ?? ""] ?? {
|
||||
label: status || "-",
|
||||
badgeClass: "bg-gray-100 text-gray-700",
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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<BizCstTreeDto, "treePath">): 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<BizCstTreeDto, "acntgSeCd" | "treePath">,
|
||||
allNodes: Pick<BizCstTreeDto, "acntgSeCd" | "treePath">[],
|
||||
): boolean {
|
||||
const childPrefix = `${node.treePath}${ACNTG_TREE_PATH_SEPARATOR}`;
|
||||
return !allNodes.some(
|
||||
(other) => other.acntgSeCd !== node.acntgSeCd && other.treePath.startsWith(childPrefix),
|
||||
);
|
||||
}
|
||||
@@ -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<string, Record<string, FnrsCnsltnViewButtonAction[]>> = {
|
||||
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<string, FnrsCnsltnViewButtonAction[]>
|
||||
> = {
|
||||
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<FnrsCnsltnViewButtonAction>();
|
||||
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);
|
||||
}
|
||||
@@ -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<string>();
|
||||
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<Pick<ChecklistFormState, "results" | "comments">>,
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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<typeof fnrsCnsltnRegistSchema>;
|
||||
|
||||
export function formatTaskSeComCdForApi(taskSeComCd: string[]): string {
|
||||
return taskSeComCd.join(",");
|
||||
}
|
||||
|
||||
export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
|
||||
if (!taskSeComCd) return [];
|
||||
return taskSeComCd.split(",").filter(Boolean);
|
||||
}
|
||||
@@ -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<string, string>();
|
||||
|
||||
for (const item of esntlFileList ?? []) {
|
||||
if (!item.esntlAtchFileId || !item.atflCd) continue;
|
||||
esntlMap.set(item.esntlAtchFileId, item.atflCd);
|
||||
}
|
||||
|
||||
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
|
||||
esntlAtchFileId,
|
||||
atchFileGroupId,
|
||||
}));
|
||||
}
|
||||
|
||||
export function 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",
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, string>();
|
||||
|
||||
for (const item of esntlFileList) {
|
||||
if (!item.esntlAtchFileId || !item.atflCd) continue;
|
||||
esntlMap.set(item.esntlAtchFileId, item.atflCd);
|
||||
}
|
||||
|
||||
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
|
||||
esntlAtchFileId,
|
||||
atchFileGroupId,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSavePayload(
|
||||
data: FnrsCnsltnMngDetResponseDto,
|
||||
checklistFormState: ChecklistFormState,
|
||||
reviewStep: ReviewStepCode,
|
||||
prgrsSttsComCd: string,
|
||||
): FnrsCnsltnApplySaveRequestDto {
|
||||
const detDto = data.detDto;
|
||||
if (!detDto) {
|
||||
throw new Error("상세 정보(detDto)가 없습니다.");
|
||||
}
|
||||
|
||||
const bizSclList = data.bizSclList ?? [];
|
||||
const bizCstList = data.bizCstList ?? [];
|
||||
const cklstResultList = data.cklstResultList ?? [];
|
||||
const esntlFileList = data.esntlFileList ?? [];
|
||||
|
||||
return {
|
||||
fnrsCnsltnMng: { ...detDto, prgrsSttsComCd },
|
||||
bzScaleList: toBzScaleSaveDto(bizSclList),
|
||||
bizCstList: toBizCstSaveDto(bizCstList),
|
||||
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
|
||||
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
|
||||
fnrsSharList: toFnrsSharSaveList(mapFnrsSharDetToFormList(data.fnrsSharList ?? [])),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTechReviewReplyPayload(
|
||||
data: FnrsCnsltnMngDetResponseDto,
|
||||
checklistFormState: ChecklistFormState,
|
||||
reviewStep: ReviewStepCode,
|
||||
prgrsSttsComCd: string,
|
||||
techReViewSaveCd: string,
|
||||
reviewOpinionFormData?: 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: [],
|
||||
};
|
||||
}
|
||||
@@ -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<ChecklistFormState>({
|
||||
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<Pick<ChecklistFormState, "results" | "comments">>,
|
||||
reviewStep?: ReviewStepCode,
|
||||
) => {
|
||||
setFormState((prev) => patchChecklistFormState(prev, patch, cklstResultList, reviewStep));
|
||||
},
|
||||
[cklstResultList],
|
||||
);
|
||||
|
||||
return { formState, setFormState, patchFormState };
|
||||
}
|
||||
|
||||
export { EMPTY_CKLST_RESULT_LIST, useChecklistFormState };
|
||||
+75
-16
@@ -2,12 +2,13 @@
|
||||
|
||||
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";
|
||||
|
||||
interface ProgressStepIndicatorProps {
|
||||
prgrsSttsComCd: string;
|
||||
prgrsSttsCdNm: string;
|
||||
}
|
||||
const columnHelper = createColumnHelper<FnrsCnsltnMngResponseDto>();
|
||||
|
||||
const MAIN_STEP_LABELS = {
|
||||
save: "저장",
|
||||
@@ -90,22 +91,80 @@ function getBadgeColor(state: BadgeState) {
|
||||
}
|
||||
}
|
||||
|
||||
function ProgressStepIndicator({ prgrsSttsComCd, prgrsSttsCdNm }: ProgressStepIndicatorProps) {
|
||||
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 (
|
||||
<div className="cnfm-card">
|
||||
<h3 className="text-[1.5rem] font-semibold">진행 단계</h3>
|
||||
<div className="cnfm-step-wrap mt-3">
|
||||
{badges.map((badge, index) => (
|
||||
<Fragment key={badge.label}>
|
||||
{index > 0 && <span className="cnfm-step-separator">></span>}
|
||||
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-start">
|
||||
{badges.map((badge, index) => (
|
||||
<Fragment key={badge.label}>
|
||||
{index > 0 && <span className="mx-2">></span>}
|
||||
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ProgressStepIndicator };
|
||||
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 }) => (
|
||||
<ProgressStepBadge
|
||||
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
|
||||
prgrsSttsCdNm={row.original.prgrsSttsCdNm || ""}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("regDt", {
|
||||
header: "신청일",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface ReviewerOption {
|
||||
uuid: string;
|
||||
name: string;
|
||||
deptNm: string;
|
||||
}
|
||||
@@ -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<ChecklistRowKey, "Y" | "N">;
|
||||
comments: Record<ChecklistRowKey, string>;
|
||||
progress: ChecklistProgress;
|
||||
}
|
||||
|
||||
export type ChecklistStepMap = Record<ReviewStepCode, CklstRvwRsltTreeDto[]>;
|
||||
|
||||
export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
|
||||
|
||||
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
|
||||
LOCAL: "지자체",
|
||||
ENV: "환경청",
|
||||
KECO: "환경공단",
|
||||
};
|
||||
|
||||
export const TASK_CODE_LABELS: Record<string, string> = {
|
||||
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<string, ReviewStepCode> = {
|
||||
ROLE_USER05: "LOCAL",
|
||||
ROLE_USER03: "ENV",
|
||||
ROLE_USER01: "KECO",
|
||||
};
|
||||
|
||||
export const REVIEW_STEP_ROLE_RANK: Record<ReviewStepCode, number> = {
|
||||
LOCAL: 1,
|
||||
ENV: 2,
|
||||
KECO: 3,
|
||||
};
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[56rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>검토인력 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="h-[36rem] overflow-auto">
|
||||
{isPending ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
검토인력 목록을 불러오지 못했습니다.
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
배치된 검토인력이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="25%" />
|
||||
<col width="25%" />
|
||||
<col width="15%" />
|
||||
<col width="15%" />
|
||||
<col width="20%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>성명</TableHead>
|
||||
<TableHead>부서</TableHead>
|
||||
<TableHead>직위</TableHead>
|
||||
<TableHead>직급</TableHead>
|
||||
<TableHead>선택</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={`${item.atrzSeComCd}-${item.ognzSn}-${item.userUuid}`}>
|
||||
<TableCell align="center">{item.userUuidNm || item.userUuid}</TableCell>
|
||||
<TableCell align="center">{item.ognzNm || "-"}</TableCell>
|
||||
<TableCell align="center">{item.jbpsSeNm || "-"}</TableCell>
|
||||
<TableCell align="center">{item.jbgdSeNm || "-"}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Button type="button" size="sm" onClick={() => handleSelect(item)}>
|
||||
선택
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { AprvrPickerDialog };
|
||||
@@ -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<SetStateAction<ChecklistFormState>>;
|
||||
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 (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[20rem]" rowSpan={2}>
|
||||
검토항목
|
||||
</TableHead>
|
||||
<TableHead rowSpan={2}>주요 검토내용</TableHead>
|
||||
<TableHead colSpan={2}>검토 결과</TableHead>
|
||||
{selectedReviewStep !== "LOCAL" ? (
|
||||
<TableHead rowSpan={2} className="w-[20rem]">
|
||||
기술검토 의견
|
||||
</TableHead>
|
||||
) : null}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="w-[9rem]">예</TableHead>
|
||||
<TableHead className="w-[9rem]">아니오</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-12 text-center text-muted-foreground">
|
||||
조회된 체크리스트 항목이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
selectedRows.map((row) => {
|
||||
const key = buildChecklistRowKey(row, selectedReviewStep);
|
||||
const isFieldVisible = row.bchckUseYn === "Y";
|
||||
const isLocalReviewStep = selectedReviewStep === "LOCAL";
|
||||
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell className="text-center text-[1.2rem]">{row.cklstArtclNm}</TableCell>
|
||||
<TableCell
|
||||
className="pl-2 !text-left text-[1.2rem] text-gray-600"
|
||||
style={{ paddingLeft: `${Math.max(0, row.treeLevel - 1) * 16 + 8}px` }}
|
||||
colSpan={isFieldVisible ? 0 : isLocalReviewStep ? 3 : 4}
|
||||
>
|
||||
{row.rvwCn}
|
||||
</TableCell>
|
||||
{isFieldVisible && (
|
||||
<>
|
||||
<TableCell className="text-center">
|
||||
<input
|
||||
type="radio"
|
||||
name={`chk_${key}`}
|
||||
value="Y"
|
||||
checked={formState.results[key] === "Y"}
|
||||
readOnly={!isStepEditable}
|
||||
onChange={() => {
|
||||
if (!isStepEditable) return;
|
||||
setFormState((prev) =>
|
||||
patchChecklistFormState(
|
||||
prev,
|
||||
{ results: { ...prev.results, [key]: "Y" } },
|
||||
allRows,
|
||||
selectedReviewStep,
|
||||
),
|
||||
);
|
||||
}}
|
||||
className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<input
|
||||
type="radio"
|
||||
name={`chk_${key}`}
|
||||
value="N"
|
||||
checked={formState.results[key] === "N"}
|
||||
readOnly={!isStepEditable}
|
||||
onChange={() => {
|
||||
if (!isStepEditable) return;
|
||||
setFormState((prev) =>
|
||||
patchChecklistFormState(
|
||||
prev,
|
||||
{ results: { ...prev.results, [key]: "N" } },
|
||||
allRows,
|
||||
selectedReviewStep,
|
||||
),
|
||||
);
|
||||
}}
|
||||
className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")}
|
||||
/>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
{!isLocalReviewStep && isFieldVisible && (
|
||||
<TableCell className="text-left">
|
||||
<Textarea
|
||||
className={cn(
|
||||
"min-h-[6rem] !text-xl",
|
||||
isStepEditable ? "cursor-pointer" : "cursor-not-allowed",
|
||||
)}
|
||||
value={formState.comments[key] ?? ""}
|
||||
readOnly={!isStepEditable}
|
||||
onChange={(event) => {
|
||||
if (!isStepEditable) return;
|
||||
setFormState((prev) =>
|
||||
patchChecklistFormState(
|
||||
prev,
|
||||
{ comments: { ...prev.comments, [key]: event.target.value } },
|
||||
allRows,
|
||||
selectedReviewStep,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { ChecklistTaskPanel };
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
type ElaprAplnRow,
|
||||
useAplnRows,
|
||||
useDndSensors,
|
||||
useElaprAplnPage,
|
||||
validateAplnRow,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import type { ReviewerOption } from "../../model/reviewer-option";
|
||||
|
||||
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
|
||||
import { SortableAplnPickerRow } from "./sortable-apln-picker-row";
|
||||
|
||||
interface ElaprAplnPickerGridProps {
|
||||
elaprTmpltId: string | null;
|
||||
onRowsChange?: (rows: ElaprAplnRow[]) => void;
|
||||
onEditingLockedChange?: (locked: boolean) => void;
|
||||
}
|
||||
|
||||
function ElaprAplnPickerGrid({
|
||||
elaprTmpltId,
|
||||
onRowsChange,
|
||||
onEditingLockedChange,
|
||||
}: ElaprAplnPickerGridProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickingRowKey, setPickingRowKey] = useState<string | null>(null);
|
||||
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const aplnQuery = useElaprAplnPage({
|
||||
elaprTmpltId: elaprTmpltId ?? undefined,
|
||||
page: 0,
|
||||
size: 200,
|
||||
sort: "atrzSeqNo,asc",
|
||||
});
|
||||
|
||||
const {
|
||||
rows,
|
||||
visibleRows,
|
||||
editingRowKey,
|
||||
isEditingLocked,
|
||||
addRow,
|
||||
startEdit,
|
||||
updateRow,
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
markDeleted,
|
||||
reorderRows,
|
||||
} = useAplnRows({
|
||||
elaprTmpltId,
|
||||
serverRows: aplnQuery.data?.content,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onRowsChange?.(rows);
|
||||
}, [rows, onRowsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onEditingLockedChange?.(isEditingLocked);
|
||||
}, [isEditingLocked, onEditingLockedChange]);
|
||||
|
||||
const sensors = useDndSensors();
|
||||
const isDisabled = !elaprTmpltId;
|
||||
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
reorderRows(String(active.id), String(over.id));
|
||||
}
|
||||
|
||||
function handleRowChange(row: ElaprAplnRow, patch: Partial<ElaprAplnRow>) {
|
||||
updateRow(row.rowKey, patch);
|
||||
}
|
||||
|
||||
function handleRowSave(row: ElaprAplnRow) {
|
||||
const fieldError = validateAplnRow(row);
|
||||
if (fieldError) {
|
||||
alert(fieldError);
|
||||
return;
|
||||
}
|
||||
|
||||
commitEdit(row.rowKey);
|
||||
}
|
||||
|
||||
function handleRowDelete(row: ElaprAplnRow) {
|
||||
markDeleted(row.rowKey);
|
||||
}
|
||||
|
||||
function handleReviewerSelect(reviewer: ReviewerOption) {
|
||||
if (!pickingRowKey) return;
|
||||
updateRow(pickingRowKey, {
|
||||
aprvrUuid: reviewer.uuid,
|
||||
aprvrNm: reviewer.name,
|
||||
});
|
||||
setPickingRowKey(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card card-border mx-0 min-h-[32rem] flex-1 pt-0">
|
||||
<div className="card-header flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-2xl font-semibold">결재선 관리</h3>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
color="green"
|
||||
disabled={isDisabled || isEditingLocked}
|
||||
onClick={addRow}
|
||||
>
|
||||
결재선 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aplnQuery.isPending && elaprTmpltId ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : !elaprTmpltId ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">결재서식을 선택하세요.</p>
|
||||
) : visibleRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="10%" />
|
||||
<col width="30%" />
|
||||
<col width="10%" />
|
||||
<col width="10%" />
|
||||
<col width="30%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>결재선아이디</TableHead>
|
||||
<TableHead>결재순서</TableHead>
|
||||
<TableHead>결재구분</TableHead>
|
||||
<TableHead>결재자</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<SortableContext
|
||||
items={visibleRows.map((r) => r.rowKey)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{visibleRows.map((row, index) => (
|
||||
<SortableAplnPickerRow
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
displaySeqNo={index + 1}
|
||||
isEditing={editingRowKey === row.rowKey}
|
||||
isLocked={isEditingLocked}
|
||||
dragDisabled={isEditingLocked || !row.atrzSeComCd}
|
||||
onStartEdit={() => startEdit(row.rowKey)}
|
||||
onSave={() => handleRowSave(row)}
|
||||
onCancel={cancelEdit}
|
||||
onDelete={() => handleRowDelete(row)}
|
||||
onChange={(patch) => handleRowChange(row, patch)}
|
||||
onPickAprvr={() => {
|
||||
setPickingRowKey(row.rowKey);
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
<AprvrPickerDialog
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
atrzSeComCd={pickingRow?.atrzSeComCd ?? ""}
|
||||
onSelect={handleReviewerSelect}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprAplnPickerGrid };
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { elaprPrgrsSttsService, findCurrentUserPendingApprovalRequest, useElaprDocPrgrsByDocNo } from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import { getApproverStatus, sortByAtrzSeqNo } from "../../lib/approval-utils";
|
||||
|
||||
interface ElaprApprovalDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
elaprDocNo: string;
|
||||
currentUserUuid: string;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function ElaprApprovalDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
elaprDocNo,
|
||||
currentUserUuid,
|
||||
onSuccess,
|
||||
}: ElaprApprovalDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [rjctRsn, setRjctRsn] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const { data: elaprDocPrgrsList = [], isLoading } = useElaprDocPrgrsByDocNo(
|
||||
open ? elaprDocNo : null,
|
||||
);
|
||||
|
||||
const orderedApprovers = sortByAtrzSeqNo(elaprDocPrgrsList);
|
||||
|
||||
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(
|
||||
elaprDocPrgrsList,
|
||||
currentUserUuid,
|
||||
);
|
||||
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
|
||||
|
||||
async function handleApprove() {
|
||||
if (!currentUserPrgrs?.elaprPrgrsId) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
|
||||
atrzPrgrsSttsComCd: "ATRZ_PRGRS_003",
|
||||
});
|
||||
alert("결재가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
|
||||
});
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "";
|
||||
if (error instanceof AxiosError) {
|
||||
errorMessage = error.response?.data?.message ?? "";
|
||||
} else {
|
||||
errorMessage = "결재 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!currentUserPrgrs?.elaprPrgrsId) return;
|
||||
if (!rjctRsn.trim()) {
|
||||
alert("반려 사유를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, {
|
||||
atrzPrgrsSttsComCd: "ATRZ_PRGRS_004",
|
||||
});
|
||||
alert("반려 처리가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo],
|
||||
});
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "";
|
||||
if (error instanceof AxiosError) {
|
||||
errorMessage = error.response?.data?.message ?? "";
|
||||
} else {
|
||||
errorMessage = "반려 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setRjctRsn("");
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[72rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>전자결재</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-3 text-xl font-semibold">결재 진행 단계</h4>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
) : orderedApprovers.length === 0 ? (
|
||||
<div className="text-center text-gray-500">결재 정보가 없습니다.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="line">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{orderedApprovers.map((row) => (
|
||||
<TableHead key={String(row.elaprPrgrsId ?? row.atrzSeComCd)}>
|
||||
{row.atrzSeComNm ?? row.atrzSeComCd}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
{orderedApprovers.map((row) => {
|
||||
const status = row.atrzPrgrsSttsComCd ?? "";
|
||||
const st = getApproverStatus(status);
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={String(row.elaprPrgrsId ?? row.atrzSeComCd)}
|
||||
align="center"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-md px-2 py-1 text-base font-semibold",
|
||||
st.badgeClass,
|
||||
)}
|
||||
>
|
||||
{st.label}
|
||||
</span>
|
||||
<span className="font-semibold">{row.aprvrUuidNm ?? "-"}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-base text-gray-500">
|
||||
{row.ognzNm ?? ""}
|
||||
{row.jbpsSeNm || row.jbgdSeNm
|
||||
? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}`
|
||||
: ""}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canApprove && (
|
||||
<div>
|
||||
<h4 className="mb-3 text-xl font-semibold">반려 의견</h4>
|
||||
<Textarea
|
||||
value={rjctRsn}
|
||||
onChange={(event) => setRjctRsn(event.target.value)}
|
||||
placeholder="반려 시 사유를 입력하세요"
|
||||
rows={4}
|
||||
className="!text-xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={handleClose} disabled={isProcessing}>
|
||||
취소
|
||||
</Button>
|
||||
{canApprove && (
|
||||
<>
|
||||
<Button color="red" onClick={handleReject} disabled={isProcessing}>
|
||||
반려
|
||||
</Button>
|
||||
<Button onClick={handleApprove} disabled={isProcessing}>
|
||||
결재
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprApprovalDialog };
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
type ElaprAplnRow,
|
||||
type ElaprTmplt,
|
||||
getVisibleRows,
|
||||
useElaprTmpltPage,
|
||||
validateAllRowsForSave,
|
||||
validateAtrzOrder,
|
||||
validateUniqueSeqNo,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@common/components";
|
||||
|
||||
import { ElaprAplnPickerGrid } from "./elapr-apln-picker-grid";
|
||||
import { ElaprTmpltSelectTable } from "./elapr-tmplt-select-table";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface ElaprTmpltPickerConfirmPayload {
|
||||
tmplt: ElaprTmplt;
|
||||
aplnRows: ElaprAplnRow[];
|
||||
}
|
||||
|
||||
interface ElaprTmpltPickerDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect?: (tmplt: ElaprTmplt) => void;
|
||||
onConfirm?: (payload: ElaprTmpltPickerConfirmPayload) => void;
|
||||
}
|
||||
|
||||
function ElaprTmpltPickerDialog({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
onConfirm,
|
||||
}: ElaprTmpltPickerDialogProps) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [selectedTmplt, setSelectedTmplt] = useState<ElaprTmplt | null>(null);
|
||||
const [aplnRows, setAplnRows] = useState<ElaprAplnRow[]>([]);
|
||||
const [isEditingLocked, setIsEditingLocked] = useState(false);
|
||||
|
||||
const tmpltQuery = useElaprTmpltPage({
|
||||
page: 0,
|
||||
size: PAGE_SIZE,
|
||||
sort: "regDt,desc",
|
||||
docSeComCd: "FNRS",
|
||||
});
|
||||
|
||||
const handleRowsChange = useCallback((rows: ElaprAplnRow[]) => {
|
||||
setAplnRows(rows);
|
||||
}, []);
|
||||
|
||||
const handleEditingLockedChange = useCallback((locked: boolean) => {
|
||||
setIsEditingLocked(locked);
|
||||
}, []);
|
||||
|
||||
function handleSelectTmplt(tmplt: ElaprTmplt | null) {
|
||||
setSelectedTmplt(tmplt);
|
||||
setAplnRows([]);
|
||||
setIsEditingLocked(false);
|
||||
}
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
setSelectedTmplt(null);
|
||||
setAplnRows([]);
|
||||
setIsEditingLocked(false);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!selectedTmplt) {
|
||||
alert("결재서식을 선택해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rowsError = validateAllRowsForSave(aplnRows, isEditingLocked);
|
||||
if (rowsError) {
|
||||
alert(rowsError);
|
||||
return;
|
||||
}
|
||||
|
||||
const orderError = validateAtrzOrder(aplnRows);
|
||||
if (orderError) {
|
||||
alert(orderError);
|
||||
return;
|
||||
}
|
||||
|
||||
const seqError = validateUniqueSeqNo(aplnRows);
|
||||
if (seqError) {
|
||||
alert(seqError);
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleRows = getVisibleRows(aplnRows);
|
||||
|
||||
const payload: ElaprTmpltPickerConfirmPayload = {
|
||||
tmplt: selectedTmplt,
|
||||
aplnRows: visibleRows,
|
||||
};
|
||||
|
||||
if (onConfirm) {
|
||||
onConfirm(payload);
|
||||
} else {
|
||||
onSelect?.(selectedTmplt);
|
||||
}
|
||||
|
||||
setSelectedTmplt(null);
|
||||
setAplnRows([]);
|
||||
setIsEditingLocked(false);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-[120rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>결재선 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<ElaprTmpltSelectTable
|
||||
query={tmpltQuery}
|
||||
selectedTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
||||
onSelectTmplt={handleSelectTmplt}
|
||||
/>
|
||||
<ElaprAplnPickerGrid
|
||||
elaprTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
||||
onRowsChange={handleRowsChange}
|
||||
onEditingLockedChange={handleEditingLockedChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => handleOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!selectedTmplt || isEditingLocked}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprTmpltPickerDialog };
|
||||
export type { ElaprTmpltPickerConfirmPayload };
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import { createColumnHelper, getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
import type { ElaprTmplt } from "@/entities/elapr";
|
||||
import { Skeleton } from "@common/components";
|
||||
|
||||
const columnHelper = createColumnHelper<ElaprTmplt>();
|
||||
|
||||
interface ElaprTmpltSelectTableProps {
|
||||
query: UseQueryResult<PageResponse<ElaprTmplt>>;
|
||||
selectedTmpltId: string | null;
|
||||
onSelectTmplt: (tmplt: ElaprTmplt | null) => void;
|
||||
}
|
||||
|
||||
function ElaprTmpltSelectTable({
|
||||
query,
|
||||
selectedTmpltId,
|
||||
onSelectTmplt,
|
||||
}: ElaprTmpltSelectTableProps) {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTmpltId) {
|
||||
setRowSelection({ [selectedTmpltId]: true });
|
||||
} else {
|
||||
setRowSelection({});
|
||||
}
|
||||
}, [selectedTmpltId]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("elaprTmpltId", {
|
||||
header: "전자결재서식아이디",
|
||||
meta: { align: "left" },
|
||||
}),
|
||||
columnHelper.accessor("elaprTmpltNm", {
|
||||
header: "결재서식명",
|
||||
meta: { align: "left" },
|
||||
}),
|
||||
columnHelper.accessor("docSeComNm", {
|
||||
header: "문서구분",
|
||||
cell: ({ getValue, row }) => getValue() ?? row.original.docSeComCd ?? "—",
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query,
|
||||
tableOptions: {
|
||||
columns,
|
||||
state: { rowSelection },
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: false,
|
||||
getRowId: (row) => row.elaprTmpltId,
|
||||
onRowSelectionChange: (updater) => {
|
||||
const next = typeof updater === "function" ? updater(rowSelection) : updater;
|
||||
setRowSelection(next);
|
||||
const selectedId = Object.keys(next)[0];
|
||||
if (!selectedId) {
|
||||
onSelectTmplt(null);
|
||||
return;
|
||||
}
|
||||
const selected = query.data?.content.find((t) => t.elaprTmpltId === selectedId);
|
||||
onSelectTmplt(selected ?? null);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
},
|
||||
dataTableOptions: {
|
||||
onRowClick: (row) => {
|
||||
setRowSelection({ [row.elaprTmpltId]: true });
|
||||
onSelectTmplt(row);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="card card-border mx-0 min-h-[32rem] flex-[0.5] bg-background pt-0">
|
||||
<div className="card-header h-[4.6rem]">
|
||||
<h3 className="text-2xl font-semibold">전자결재서식</h3>
|
||||
</div>
|
||||
{query.isPending && (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
)}
|
||||
{query.isError && (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
결재서식 목록을 불러오지 못했습니다.
|
||||
</p>
|
||||
)}
|
||||
{!query.isPending && !query.isError && tableComponent}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprTmpltSelectTable };
|
||||
@@ -0,0 +1,672 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import {
|
||||
buildElaprPrgrsSttsListFromAplnRows,
|
||||
type ElaprDmndSaveRequestDto,
|
||||
elaprDmndService,
|
||||
hasCurrentUserPendingApprovalRequest,
|
||||
useElaprDocPrgrsByDocNo,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
type DocPrgrsUpdateRequestDto,
|
||||
type FnrsCnsltnApplySaveRequestDto,
|
||||
fnrsCnsltnMngBaseKey,
|
||||
type FnrsCnsltnMngDetResponseDto,
|
||||
fnrsCnsltnMngService,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
|
||||
import { TextInputDialog } from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import {
|
||||
FNRS_CNSLTN_VIEW_BUTTON_ACTION,
|
||||
type FnrsCnsltnViewButtonAction,
|
||||
getButtonConfigs,
|
||||
needsRoleUser01AtrzResolve,
|
||||
} from "../../lib/button-config";
|
||||
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
|
||||
import {
|
||||
buildSavePayload,
|
||||
buildSupplementReqPayload,
|
||||
buildSupplementSubmitPayload,
|
||||
buildTechReviewReplyPayload,
|
||||
} from "../../lib/save-payload-utils";
|
||||
import { type ChecklistFormState } from "../../model/view-types";
|
||||
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
|
||||
|
||||
import { ElaprApprovalDialog } from "./elapr-approval-dialog";
|
||||
import {
|
||||
type ElaprTmpltPickerConfirmPayload,
|
||||
ElaprTmpltPickerDialog,
|
||||
} from "./elapr-tmplt-picker-dialog";
|
||||
|
||||
interface FloatingActionButtonsProps {
|
||||
data: FnrsCnsltnMngDetResponseDto;
|
||||
docSeComCd: string;
|
||||
fnrsCnsltnDocNo: string;
|
||||
prgrsSttsComCd: string;
|
||||
dtlInstCd: string | undefined;
|
||||
checklistFormState: ChecklistFormState;
|
||||
techReviewOpinionRef?: React.RefObject<TechReviewOpinionSectionHandle | null>;
|
||||
opnrNm?: string;
|
||||
}
|
||||
|
||||
type ActiveDialog = "elaprTmplt" | "rjct" | "dmndSplmnt" | "elaprApproval" | null;
|
||||
|
||||
function FloatingActionButtons({
|
||||
data,
|
||||
docSeComCd,
|
||||
fnrsCnsltnDocNo,
|
||||
prgrsSttsComCd,
|
||||
dtlInstCd,
|
||||
checklistFormState,
|
||||
techReviewOpinionRef,
|
||||
opnrNm,
|
||||
}: FloatingActionButtonsProps) {
|
||||
const router = useRouter();
|
||||
const { confirm } = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [activeDialog, setActiveDialog] = useState<ActiveDialog>(null);
|
||||
|
||||
const docNo = data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo;
|
||||
const isRegister = data.detDto?.rgtr === userInfo?.userUnqId;
|
||||
|
||||
const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
|
||||
const needsTeApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "TE";
|
||||
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
|
||||
{},
|
||||
{ enabled: needsAtrzResolve },
|
||||
);
|
||||
const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
|
||||
useElaprDocPrgrsByDocNo(needsTeApprovalCheck ? docNo : null);
|
||||
|
||||
const atrzSeComCdList = useMemo(
|
||||
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
|
||||
[rvwLoginUserData],
|
||||
);
|
||||
|
||||
const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
|
||||
elaprDocPrgrsList,
|
||||
userInfo?.userUnqId,
|
||||
);
|
||||
|
||||
const actionButtons = useMemo(() => {
|
||||
if (needsAtrzResolve && isRvwLoginUserLoading) return [];
|
||||
if (needsTeApprovalCheck && isElaprDocPrgrsLoading) return [];
|
||||
return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList, {
|
||||
hasPendingApprovalRequest: needsTeApprovalCheck ? hasPendingApprovalRequest : undefined,
|
||||
});
|
||||
}, [
|
||||
atrzSeComCdList,
|
||||
dtlInstCd,
|
||||
hasPendingApprovalRequest,
|
||||
isElaprDocPrgrsLoading,
|
||||
isRvwLoginUserLoading,
|
||||
needsAtrzResolve,
|
||||
needsTeApprovalCheck,
|
||||
prgrsSttsComCd,
|
||||
]);
|
||||
|
||||
const canEdit =
|
||||
isRegister && actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg);
|
||||
|
||||
function handleGoToList() {
|
||||
router.push("/fnrs-cnsltn");
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`);
|
||||
}
|
||||
|
||||
function shouldShowButton(action: FnrsCnsltnViewButtonAction) {
|
||||
return actionButtons.some((button) => button === action);
|
||||
}
|
||||
|
||||
async function executeAction({
|
||||
action,
|
||||
request,
|
||||
successMessage,
|
||||
}: {
|
||||
action: FnrsCnsltnViewButtonAction;
|
||||
request?: unknown;
|
||||
successMessage?: string;
|
||||
}) {
|
||||
const handler = buttonActionHandlers[action];
|
||||
if (!handler) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await handler({
|
||||
request,
|
||||
data,
|
||||
onSuccess: () => {
|
||||
alert(successMessage ?? "처리가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
let errorMessage = "";
|
||||
if (error instanceof AxiosError) {
|
||||
errorMessage = error.response?.data?.message ?? "";
|
||||
} else {
|
||||
errorMessage = error.message ?? "처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
alert(errorMessage);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStrgClick() {
|
||||
const payload: FnrsCnsltnApplySaveRequestDto = buildSavePayload(
|
||||
data,
|
||||
checklistFormState,
|
||||
"LOCAL",
|
||||
"SV",
|
||||
);
|
||||
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg,
|
||||
request: payload,
|
||||
successMessage: "저장되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAplyClick() {
|
||||
const { percent } = computeReviewStepChecklistProgress(
|
||||
data.cklstResultList ?? [],
|
||||
checklistFormState.results,
|
||||
"LOCAL",
|
||||
);
|
||||
const payload: FnrsCnsltnApplySaveRequestDto = buildSavePayload(
|
||||
data,
|
||||
checklistFormState,
|
||||
"LOCAL",
|
||||
"ST",
|
||||
);
|
||||
|
||||
if (percent !== 100) {
|
||||
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply,
|
||||
request: payload,
|
||||
successMessage: "신청 완료되었습니다.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
}
|
||||
|
||||
async function handleDelClick() {
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.del,
|
||||
successMessage: "삭제되었습니다.",
|
||||
});
|
||||
router.push("/fnrs-cnsltn");
|
||||
}
|
||||
|
||||
async function handleSplmntCmptnClick() {
|
||||
const payload = buildSupplementSubmitPayload(data, checklistFormState);
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn,
|
||||
request: payload,
|
||||
successMessage: "보완완료 처리되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFnrsCnsltnAncmntClick() {
|
||||
const payload: DocPrgrsUpdateRequestDto = {
|
||||
docNo,
|
||||
docSeComCd,
|
||||
prgrsSttsComCd: "TB",
|
||||
};
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt,
|
||||
request: payload,
|
||||
successMessage: "고시되었습니다.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
}
|
||||
|
||||
async function handleEnvoSbmsnClick() {
|
||||
const payload: DocPrgrsUpdateRequestDto = {
|
||||
docNo,
|
||||
docSeComCd,
|
||||
prgrsSttsComCd: "LE",
|
||||
};
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn,
|
||||
request: payload,
|
||||
successMessage: "제출되었습니다.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
}
|
||||
|
||||
async function handleRjctClick() {
|
||||
const payload: DocPrgrsUpdateRequestDto = {
|
||||
docNo,
|
||||
docSeComCd,
|
||||
prgrsSttsComCd: "SV",
|
||||
};
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct,
|
||||
request: payload,
|
||||
successMessage: "반려되었습니다.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
}
|
||||
|
||||
async function handleSncnAprvClick() {
|
||||
const payload: DocPrgrsUpdateRequestDto = {
|
||||
docNo,
|
||||
docSeComCd,
|
||||
prgrsSttsComCd: "TA",
|
||||
};
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv,
|
||||
request: payload,
|
||||
successMessage: "인가승인되었습니다.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
}
|
||||
|
||||
function getReviewOpinionFormData() {
|
||||
return techReviewOpinionRef?.current?.getFormData() ?? null;
|
||||
}
|
||||
|
||||
async function handleRvwRplyClick() {
|
||||
const { percent } = computeReviewStepChecklistProgress(
|
||||
data.cklstResultList ?? [],
|
||||
checklistFormState.results,
|
||||
"ENV",
|
||||
);
|
||||
|
||||
if (percent !== 100) {
|
||||
alert("모든 체크리스트 항목을 체크해야 검토결과 회신이 가능합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildTechReviewReplyPayload(
|
||||
data,
|
||||
checklistFormState,
|
||||
"ENV",
|
||||
"EP",
|
||||
"CP",
|
||||
getReviewOpinionFormData(),
|
||||
opnrNm,
|
||||
);
|
||||
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply,
|
||||
request: payload,
|
||||
successMessage: "검토결과 회신이 완료되었습니다.",
|
||||
});
|
||||
techReviewOpinionRef?.current?.resetForm();
|
||||
}
|
||||
|
||||
async function handleRqstTechRvwClick() {
|
||||
const payload: DocPrgrsUpdateRequestDto = {
|
||||
docNo,
|
||||
docSeComCd,
|
||||
prgrsSttsComCd: "ER",
|
||||
};
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw,
|
||||
request: payload,
|
||||
successMessage: "기술검토 의뢰가 완료되었습니다.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: fnrsCnsltnMngBaseKey });
|
||||
}
|
||||
|
||||
async function handleElaprTmpltConfirm({ aplnRows }: ElaprTmpltPickerConfirmPayload) {
|
||||
const payload: ElaprDmndSaveRequestDto = {
|
||||
elaprDocNo: docNo,
|
||||
dmndTtl: data.detDto?.bizNm ?? "",
|
||||
dmndCn: data.detDto?.bizCn ?? "",
|
||||
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
|
||||
};
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr,
|
||||
request: payload,
|
||||
successMessage: "검토자가 지정되었습니다.",
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", docNo],
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRvwStrgClick() {
|
||||
const payload = buildTechReviewReplyPayload(
|
||||
data,
|
||||
checklistFormState,
|
||||
"KECO",
|
||||
"TE",
|
||||
"SV",
|
||||
getReviewOpinionFormData(),
|
||||
opnrNm,
|
||||
);
|
||||
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg,
|
||||
request: payload,
|
||||
successMessage: "저장되었습니다.",
|
||||
});
|
||||
techReviewOpinionRef?.current?.resetForm();
|
||||
}
|
||||
|
||||
async function handleRvwCmptnClick() {
|
||||
const { percent } = computeReviewStepChecklistProgress(
|
||||
data.cklstResultList ?? [],
|
||||
checklistFormState.results,
|
||||
"KECO",
|
||||
);
|
||||
|
||||
if (percent !== 100) {
|
||||
alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildTechReviewReplyPayload(
|
||||
data,
|
||||
checklistFormState,
|
||||
"KECO",
|
||||
"TE",
|
||||
"CP",
|
||||
getReviewOpinionFormData(),
|
||||
opnrNm,
|
||||
);
|
||||
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn,
|
||||
request: payload,
|
||||
successMessage: "검토완료 처리되었습니다.",
|
||||
});
|
||||
techReviewOpinionRef?.current?.resetForm();
|
||||
}
|
||||
|
||||
function handleDmndSplmntClick() {
|
||||
setActiveDialog("dmndSplmnt");
|
||||
}
|
||||
|
||||
async function handleDmndSplmntConfirm(splmntDmndCn: string) {
|
||||
setActiveDialog(null);
|
||||
const payload = buildSupplementReqPayload(data, splmntDmndCn);
|
||||
await executeAction({
|
||||
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
|
||||
request: payload,
|
||||
successMessage: "보완요청이 처리되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticky bottom-0 z-40 !-mb-2 border-t bg-white pb-8 pt-6 shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
|
||||
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
|
||||
<Button color="white" type="button" onClick={handleGoToList} disabled={isProcessing}>
|
||||
목록
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button color="gray" onClick={handleEdit} disabled={isProcessing}>
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg) && (
|
||||
<Button onClick={handleStrgClick} disabled={isProcessing}>
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.aply) && (
|
||||
<Button color="green" onClick={handleAplyClick} disabled={isProcessing}>
|
||||
신청완료
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.del) && (
|
||||
<Button color="red" onClick={handleDelClick} disabled={isProcessing}>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn) && (
|
||||
<Button onClick={handleSplmntCmptnClick} disabled={isProcessing}>
|
||||
보완완료
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fnrsCnsltnAncmnt) && (
|
||||
<Button onClick={handleFnrsCnsltnAncmntClick} disabled={isProcessing}>
|
||||
재원협의 고시
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn) && (
|
||||
<Button onClick={handleEnvoSbmsnClick} disabled={isProcessing}>
|
||||
환경청제출
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct) && (
|
||||
<Button color="red" onClick={handleRjctClick} disabled={isProcessing}>
|
||||
반려
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv) && (
|
||||
<Button onClick={handleSncnAprvClick} disabled={isProcessing}>
|
||||
인가승인
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply) && (
|
||||
<Button color="green" onClick={handleRvwRplyClick} disabled={isProcessing}>
|
||||
검토결과 회신
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw) && (
|
||||
<Button onClick={handleRqstTechRvwClick} disabled={isProcessing}>
|
||||
기술검토 의뢰
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr) && (
|
||||
<Button onClick={() => setActiveDialog("elaprTmplt")} disabled={isProcessing}>
|
||||
검토자지정
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwStrg) && (
|
||||
<Button onClick={handleRvwStrgClick} disabled={isProcessing}>
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwCmptn) && (
|
||||
<Button color="green" onClick={handleRvwCmptnClick} disabled={isProcessing}>
|
||||
검토완료
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt) && (
|
||||
<Button color="gray" onClick={handleDmndSplmntClick} disabled={isProcessing}>
|
||||
보완요청
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzAprv) && (
|
||||
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isProcessing}>
|
||||
결재(승인)
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.atrzRply) && (
|
||||
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isProcessing}>
|
||||
결재(회신)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElaprTmpltPickerDialog
|
||||
open={activeDialog === "elaprTmplt"}
|
||||
onClose={() => setActiveDialog(null)}
|
||||
onConfirm={handleElaprTmpltConfirm}
|
||||
/>
|
||||
|
||||
<TextInputDialog
|
||||
open={activeDialog === "dmndSplmnt"}
|
||||
onOpenChange={() => setActiveDialog(null)}
|
||||
title="보완요청 내용 입력"
|
||||
placeholder="보완요청 내용을 입력하세요."
|
||||
isSubmitting={isProcessing}
|
||||
onConfirm={handleDmndSplmntConfirm}
|
||||
/>
|
||||
|
||||
<ElaprApprovalDialog
|
||||
open={activeDialog === "elaprApproval"}
|
||||
onOpenChange={(open) => setActiveDialog(open ? "elaprApproval" : null)}
|
||||
elaprDocNo={data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo}
|
||||
currentUserUuid={userInfo?.userUnqId ?? ""}
|
||||
onSuccess={() => {
|
||||
setActiveDialog(null);
|
||||
alert("결재가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", fnrsCnsltnDocNo],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { FloatingActionButtons };
|
||||
|
||||
async function handleSaveApply(
|
||||
context: ActionHandlerContext<FnrsCnsltnApplySaveRequestDto>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fnrsCnsltnMngService.saveApply(context.request);
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
context.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDel(context: ActionHandlerContext): Promise<void> {
|
||||
try {
|
||||
await fnrsCnsltnMngService.remove(context.data.detDto?.fnrsCnsltnDocNo ?? "");
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
context.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSplmntCmptn(
|
||||
context: ActionHandlerContext<FnrsCnsltnApplySaveRequestDto>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const docNo = context.data.detDto?.fnrsCnsltnDocNo;
|
||||
if (!docNo) throw new Error("문서번호가 없습니다.");
|
||||
await fnrsCnsltnMngService.submitSupplement(docNo, context.request);
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
context.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePrgrsSttsComCd(
|
||||
context: ActionHandlerContext<DocPrgrsUpdateRequestDto>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fnrsCnsltnMngService.changePrgrsSttsComCd(context.request);
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
context.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTechReview(
|
||||
context: ActionHandlerContext<FnrsCnsltnApplySaveRequestDto>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fnrsCnsltnMngService.techReview(
|
||||
context.data.detDto?.fnrsCnsltnDocNo ?? "",
|
||||
context.request,
|
||||
);
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
context.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDsgntRvwr(
|
||||
context: ActionHandlerContext<ElaprDmndSaveRequestDto>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await elaprDmndService.save(context.request);
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
context.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDmndSplmnt(
|
||||
context: ActionHandlerContext<FnrsCnsltnApplySaveRequestDto>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const docNo = context.data.detDto?.fnrsCnsltnDocNo;
|
||||
if (!docNo) throw new Error("문서번호가 없습니다.");
|
||||
await fnrsCnsltnMngService.submitReqSupplement(docNo, context.request);
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
context.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAtrzAprv(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 결재 승인 처리 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleAtrzRply(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 결재 회신 처리 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleGrndsChck(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 현장점검 등록 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
const buttonActionHandlers: Record<FnrsCnsltnViewButtonAction, ActionHandler> = {
|
||||
strg: handleSaveApply,
|
||||
aply: handleSaveApply,
|
||||
del: handleDel,
|
||||
splmntCmptn: handleSplmntCmptn,
|
||||
fnrsCnsltnAncmnt: handleChangePrgrsSttsComCd,
|
||||
envoSbmsn: handleChangePrgrsSttsComCd,
|
||||
rjct: handleChangePrgrsSttsComCd,
|
||||
sncnAprv: handleChangePrgrsSttsComCd,
|
||||
rvwRply: handleTechReview,
|
||||
rqstTechRvw: handleChangePrgrsSttsComCd,
|
||||
dsgntRvwr: handleDsgntRvwr,
|
||||
rvwStrg: handleTechReview,
|
||||
rvwCmptn: handleTechReview,
|
||||
dmndSplmnt: handleDmndSplmnt,
|
||||
atrzAprv: handleAtrzAprv,
|
||||
atrzRply: handleAtrzRply,
|
||||
grndsChck: handleGrndsChck,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
interface ActionHandlerContext<T = any> {
|
||||
request: T;
|
||||
data: FnrsCnsltnMngDetResponseDto;
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
type ActionHandler = (context: ActionHandlerContext) => Promise<void>;
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Equal } from "lucide-react";
|
||||
|
||||
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import type { ElaprAplnRow } from "@/entities/elapr";
|
||||
import { Button, TableCell, TableRow } from "@common/components";
|
||||
|
||||
interface SortableAplnPickerRowProps {
|
||||
row: ElaprAplnRow;
|
||||
displaySeqNo: number;
|
||||
isEditing: boolean;
|
||||
isLocked: boolean;
|
||||
dragDisabled: boolean;
|
||||
onStartEdit: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
onChange: (patch: Partial<ElaprAplnRow>) => void;
|
||||
onPickAprvr: () => void;
|
||||
}
|
||||
|
||||
function SortableAplnPickerRow({
|
||||
row,
|
||||
displaySeqNo,
|
||||
isEditing,
|
||||
isLocked,
|
||||
dragDisabled,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onChange,
|
||||
onPickAprvr,
|
||||
}: SortableAplnPickerRowProps) {
|
||||
const { data: atrzSeCodes } = useCodeList("ATRZSE");
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: row.rowKey,
|
||||
disabled: dragDisabled,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
|
||||
const rowDisabled = isLocked && !isEditing;
|
||||
const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd);
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style} className={isEditing ? "bg-accent/30" : undefined}>
|
||||
<TableCell align="center" className="w-10">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="cursor-grab !bg-transparent text-muted-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={dragDisabled}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="순서 변경"
|
||||
>
|
||||
<Equal className="!size-6" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell align="center">{row.isNew ? "-" : (row.aplnId ?? "-")}</TableCell>
|
||||
<TableCell align="center">{displaySeqNo}</TableCell>
|
||||
<TableCell align="center">
|
||||
{isEditing ? (
|
||||
<CodeSelect
|
||||
upCd="ATRZSE"
|
||||
className="min-w-[120px]"
|
||||
value={row.atrzSeComCd ?? ""}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
atrzSeComCd: v,
|
||||
atrzSeComNm: findCodeLabel(atrzSeCodes, v),
|
||||
})
|
||||
}
|
||||
placeholder="결재구분"
|
||||
/>
|
||||
) : (
|
||||
atrzSeLabel || "-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<span>{row.aprvrNm || row.aprvrUuid || "-"}</span>
|
||||
{isEditing && (
|
||||
<Button type="button" size="sm" onClick={onPickAprvr}>
|
||||
선택
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button type="button" size="sm" onClick={onSave}>
|
||||
확인
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="white" onClick={onCancel}>
|
||||
취소
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
disabled={rowDisabled}
|
||||
onClick={onStartEdit}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="gray"
|
||||
disabled={rowDisabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export { SortableAplnPickerRow };
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useFnrsCnsltnMng } from "@/entities/fnrs-cnsltn";
|
||||
|
||||
import { FnrsFormPage } from "./fnrs-form-page";
|
||||
|
||||
interface FnrsEditPageProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function FnrsEditPage({ id }: FnrsEditPageProps) {
|
||||
const { data, isLoading } = useFnrsCnsltnMng(id);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="mt-6 text-[1.4rem]">불러오는 중...</div>;
|
||||
}
|
||||
|
||||
if (!data?.detDto) {
|
||||
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
|
||||
}
|
||||
|
||||
return <FnrsFormPage id={id} mode="edit" initialData={data} />;
|
||||
}
|
||||
|
||||
export { FnrsEditPage };
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import { useAcntgSeMngTree } from "@/entities/acntg";
|
||||
import {
|
||||
fnrsCnsltnMngBaseKey,
|
||||
type FnrsCnsltnMngDetResponseDto,
|
||||
fnrsCnsltnMngService,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
import { useFnrsSharSeMngTree } from "@/entities/fnrs-shar-se";
|
||||
import {
|
||||
AttachmentsFormSection,
|
||||
buildAcntgCostItems,
|
||||
buildFnrsSharItems,
|
||||
BusinessCostFormSection,
|
||||
BusinessScaleFormSection,
|
||||
ChecklistFormSection,
|
||||
ContactInfoFormSection,
|
||||
FnrsSharFormSection,
|
||||
PrivacyConsentSection,
|
||||
recalculateFnrsSharTotals,
|
||||
toFnrsSharSaveList,
|
||||
validateBizCstSharBalance,
|
||||
} from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import {
|
||||
type FnrsCnsltnRegistFormData,
|
||||
fnrsCnsltnRegistSchema,
|
||||
formatTaskSeComCdForApi,
|
||||
} from "../lib/fnrs-cnsltn-regist-schema";
|
||||
import { getCreateDefaultValues, mapDetailToFormData } from "../lib/form-data-mapper";
|
||||
|
||||
import { BasicInfoFormSection } from "./sections/basic-info-form-section";
|
||||
interface FnrsFormPageProps {
|
||||
id?: string;
|
||||
mode: "create" | "edit";
|
||||
initialData?: FnrsCnsltnMngDetResponseDto;
|
||||
taskSeComCd?: string[];
|
||||
}
|
||||
|
||||
function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const isEditMode = mode === "edit";
|
||||
|
||||
const { data: masterSharTree = [] } = useFnrsSharSeMngTree();
|
||||
const { data: acntgTree = [] } = useAcntgSeMngTree();
|
||||
const sharTree =
|
||||
isEditMode && initialData?.fnrsSharList?.length
|
||||
? initialData.fnrsSharList
|
||||
: masterSharTree;
|
||||
const sharItems = useMemo(() => buildFnrsSharItems(sharTree), [sharTree]);
|
||||
const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]);
|
||||
|
||||
const form = useForm<FnrsCnsltnRegistFormData>({
|
||||
resolver: zodResolver(fnrsCnsltnRegistSchema),
|
||||
mode: "onChange",
|
||||
defaultValues:
|
||||
isEditMode && initialData
|
||||
? mapDetailToFormData(initialData)
|
||||
: getCreateDefaultValues(userInfo, taskSeComCd),
|
||||
});
|
||||
|
||||
async function onSave(data: FnrsCnsltnRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const withTotals = recalculateFnrsSharTotals(data.fnrsSharList, sharItems);
|
||||
const validationMessage = validateBizCstSharBalance(
|
||||
data.bizCstList,
|
||||
withTotals,
|
||||
sharItems,
|
||||
costItems,
|
||||
);
|
||||
if (validationMessage) {
|
||||
alert(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const { taskSeComCd, ...fnrsCnsltnMngRest } = data.fnrsCnsltnMng;
|
||||
|
||||
const payload = {
|
||||
fnrsCnsltnMng: {
|
||||
...fnrsCnsltnMngRest,
|
||||
taskSeComCd: formatTaskSeComCdForApi(taskSeComCd),
|
||||
fnrsCnsltnDocNo: isEditMode ? initialData?.detDto?.fnrsCnsltnDocNo : undefined,
|
||||
prgrsSttsComCd: "SV",
|
||||
},
|
||||
bzScaleList: data.bzScaleList,
|
||||
bizCstList: data.bizCstList,
|
||||
fnrsSharList: toFnrsSharSaveList(withTotals),
|
||||
cklstResultList: data.cklstResultList.map((item) => ({
|
||||
...item,
|
||||
selected: item.selected ?? undefined,
|
||||
})),
|
||||
esntlAtchFileList: data.esntlAtchFileList,
|
||||
};
|
||||
|
||||
const fnrsCnsltnDocNo = await fnrsCnsltnMngService.saveApply(payload);
|
||||
|
||||
alert("저장되었습니다.");
|
||||
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`);
|
||||
if (isEditMode && id) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", id],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
alert("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-4">
|
||||
<BasicInfoFormSection />
|
||||
<ContactInfoFormSection fieldPrefix="fnrsCnsltnMng" />
|
||||
<PrivacyConsentSection />
|
||||
<BusinessScaleFormSection />
|
||||
<BusinessCostFormSection />
|
||||
<FnrsSharFormSection
|
||||
initialSharTree={isEditMode ? initialData?.fnrsSharList : undefined}
|
||||
initialSharAtchFiles={isEditMode ? initialData?.sharAtchFileList : undefined}
|
||||
/>
|
||||
<ChecklistFormSection docSeComCd="FNRS" fieldPrefix="fnrsCnsltnMng" />
|
||||
<AttachmentsFormSection
|
||||
docSeComCd="FNRS"
|
||||
fieldPrefix="fnrsCnsltnMng"
|
||||
initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined}
|
||||
initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined}
|
||||
/>
|
||||
|
||||
<div className="sticky bottom-0 z-40 !-mb-2 border-t bg-white pb-8 pt-6 shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
|
||||
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
|
||||
<Button
|
||||
color="white"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.back();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
목록
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { FnrsFormPage };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user