This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import { CnfmModifyPage } from "@/pages/instl-sncn-old";
|
||||
|
||||
export default async function InstlSncnModifyPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <CnfmModifyPage id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { CnfmViewPage } from "@/pages/instl-sncn-old";
|
||||
|
||||
export default async function InstlSncnViewPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <CnfmViewPage id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { CnfmListPage } from "@/pages/instl-sncn-old";
|
||||
|
||||
export default CnfmListPage;
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CnfmRegistPage } from "@/pages/instl-sncn-old";
|
||||
|
||||
export default function Page(props: any) {
|
||||
return <CnfmRegistPage {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { InstlEditPage } from "@/pages/instl-sncn";
|
||||
|
||||
interface InstlSncnEditRouteProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function Page({ params }: InstlSncnEditRouteProps) {
|
||||
const { id } = await params;
|
||||
return <InstlEditPage id={id} />;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { CnfmModifyPage } from "@/pages/instl-sncn";
|
||||
|
||||
export default async function InstlSncnModifyPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return <CnfmModifyPage id={id} />;
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import { CnfmViewPage } from "@/pages/instl-sncn";
|
||||
|
||||
export default async function InstlSncnViewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return <CnfmViewPage id={id} />;
|
||||
}
|
||||
import { InstlViewPage } from "@/pages/instl-sncn";
|
||||
|
||||
export default async function InstlSncnDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <InstlViewPage id={id} />;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { CnfmListPage } from "@/pages/instl-sncn";
|
||||
|
||||
export default CnfmListPage;
|
||||
import { InstlListPage } from "@/pages/instl-sncn";
|
||||
|
||||
export default InstlListPage;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { InstlRegistPage } from "@/pages/instl-sncn";
|
||||
|
||||
export default InstlRegistPage;
|
||||
@@ -1,5 +0,0 @@
|
||||
import { CnfmRegistPage } from "@/pages/instl-sncn";
|
||||
|
||||
export default function Page(props: any) {
|
||||
return <CnfmRegistPage {...props} />;
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
"@tanstack/react-query-devtools": "^5.75.5",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"axios": "^1.8.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.474.0",
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type AcntgSeMngTreeDto,
|
||||
} from "../model/acntg-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE;
|
||||
const BASE = ENDPOINT.AS_SERVICE.ACNTG_SE;
|
||||
|
||||
async function findAll(params: AcntgSeMngSearchDto): Promise<PageResponse<AcntgSeMngResponseDto>> {
|
||||
const response = await client.get<PageResponse<AcntgSeMngResponseDto>>(BASE, { params });
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
|
||||
import { acntgSeMngService } from "./acntg-service";
|
||||
|
||||
const acntgSeMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE] as const;
|
||||
const acntgSeMngBaseKey = [ENDPOINT.AS_SERVICE.ACNTG_SE] as const;
|
||||
|
||||
export function useAcntgSeMngPage(params: AcntgSeMngSearchDto) {
|
||||
return useQuery({
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
CmCdAdstDstrComboResponseDto,
|
||||
CmCdAdstDstrService,
|
||||
} from "../model/adstdstr-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.ADSTDSTR;
|
||||
|
||||
async function findSidoCombo(): Promise<CmCdAdstDstrComboResponseDto[]> {
|
||||
const response = await client.get<CmCdAdstDstrComboResponseDto[]>(`${BASE}/sido`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findGugunCombo(sido: string): Promise<CmCdAdstDstrComboResponseDto[]> {
|
||||
const response = await client.get<CmCdAdstDstrComboResponseDto[]>(`${BASE}/gugun`, {
|
||||
params: { sido },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const cmCdAdstDstrService = {
|
||||
findSidoCombo,
|
||||
findGugunCombo,
|
||||
} satisfies CmCdAdstDstrService;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import { cmCdAdstDstrService } from "./adstdstr-service";
|
||||
|
||||
const cmCdAdstDstrBaseKey = [ENDPOINT.AS_SERVICE.ADSTDSTR] as const;
|
||||
|
||||
export function useFindSidoCombo() {
|
||||
return useQuery({
|
||||
queryKey: [...cmCdAdstDstrBaseKey, "findSidoCombo"],
|
||||
queryFn: () => cmCdAdstDstrService.findSidoCombo(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFindGugunCombo(sido: string) {
|
||||
return useQuery({
|
||||
queryKey: [...cmCdAdstDstrBaseKey, "findGugunCombo", sido],
|
||||
queryFn: () => cmCdAdstDstrService.findGugunCombo(sido),
|
||||
enabled: Boolean(sido),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { cmCdAdstDstrService } from "./api/adstdstr-service";
|
||||
export { useFindGugunCombo, useFindSidoCombo } from "./api/queries";
|
||||
export type {
|
||||
CmCdAdstDstrComboResponseDto,
|
||||
CmCdAdstDstrService,
|
||||
} from "./model/adstdstr-interface";
|
||||
@@ -0,0 +1,11 @@
|
||||
/** 백엔드 CmCdAdstDstrComboResponseDto */
|
||||
export interface CmCdAdstDstrComboResponseDto {
|
||||
adstDstrCd: string;
|
||||
adstDstrNm: string;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface CmCdAdstDstrService {
|
||||
findSidoCombo(): Promise<CmCdAdstDstrComboResponseDto[]>;
|
||||
findGugunCombo(sido: string): Promise<CmCdAdstDstrComboResponseDto[]>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import { buildBnsSearchDto } from "../lib/build-bns-search-dto";
|
||||
import type {
|
||||
BnsDetailResponseWrapperDto,
|
||||
BnsIfService,
|
||||
BnsListResponseWrapperDto,
|
||||
BnsSearchDto,
|
||||
EmCodeListResponseDto,
|
||||
} from "../model/bns-if-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.BNS_IF;
|
||||
|
||||
async function getBnsList(params: BnsSearchDto): Promise<BnsListResponseWrapperDto> {
|
||||
const response = await client.get<BnsListResponseWrapperDto>(`${BASE}/bns`, {
|
||||
params: buildBnsSearchDto(params),
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getBnsDetail(
|
||||
gbn: string,
|
||||
yr: string,
|
||||
seqNo: string,
|
||||
): Promise<BnsDetailResponseWrapperDto> {
|
||||
const response = await client.get<BnsDetailResponseWrapperDto>(`${BASE}/bns/detail`, {
|
||||
params: { gbn, yr, seqNo },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getEmCodeList(
|
||||
dpmCd?: string,
|
||||
lclgvrnmntCd?: string,
|
||||
): Promise<EmCodeListResponseDto> {
|
||||
const response = await client.get<EmCodeListResponseDto>(`${BASE}/emCodeList`, {
|
||||
params: { dpmCd, lclgvrnmntCd },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const bnsIfService = {
|
||||
getBnsList,
|
||||
getBnsDetail,
|
||||
getEmCodeList,
|
||||
} satisfies BnsIfService;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { BnsSearchDto } from "../model/bns-if-interface";
|
||||
|
||||
import { bnsIfService } from "./bns-if-service";
|
||||
|
||||
const bnsIfBaseKey = [ENDPOINT.AS_SERVICE.BNS_IF] as const;
|
||||
|
||||
export function useBnsList(params: BnsSearchDto, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: [...bnsIfBaseKey, "getBnsList", params],
|
||||
queryFn: () => bnsIfService.getBnsList(params),
|
||||
enabled: options?.enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBnsDetail(gbn: string, yr: string, seqNo: string) {
|
||||
return useQuery({
|
||||
queryKey: [...bnsIfBaseKey, "getBnsDetail", gbn, yr, seqNo],
|
||||
queryFn: () => bnsIfService.getBnsDetail(gbn, yr, seqNo),
|
||||
enabled: Boolean(gbn && yr && seqNo),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEmCodeList(dpmCd?: string, lclgvrnmntCd?: string) {
|
||||
return useQuery({
|
||||
queryKey: [...bnsIfBaseKey, "getEmCodeList", dpmCd, lclgvrnmntCd],
|
||||
queryFn: () => bnsIfService.getEmCodeList(dpmCd, lclgvrnmntCd),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export { bnsIfService } from "./api/bns-if-service";
|
||||
export { useBnsDetail, useBnsList, useEmCodeList } from "./api/queries";
|
||||
export { buildBnsSearchDto } from "./lib/build-bns-search-dto";
|
||||
export type {
|
||||
BnsDetailResponseDto,
|
||||
BnsDetailResponseWrapperDto,
|
||||
BnsIfService,
|
||||
BnsListResponseDto,
|
||||
BnsListResponseWrapperDto,
|
||||
BnsSearchDto,
|
||||
EmCodeItemDto,
|
||||
EmCodeListResponseDto,
|
||||
} from "./model/bns-if-interface";
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { BnsSearchDto } from "../model/bns-if-interface";
|
||||
|
||||
export function buildBnsSearchDto(input: {
|
||||
cPage: number;
|
||||
searchStartYear?: string;
|
||||
searchSido?: string;
|
||||
searchGugun?: string;
|
||||
}): BnsSearchDto {
|
||||
const dto: BnsSearchDto = { cPage: input.cPage };
|
||||
|
||||
const year = input.searchStartYear?.trim();
|
||||
const sido = input.searchSido?.trim();
|
||||
const gugun = input.searchGugun?.trim();
|
||||
|
||||
if (year) dto.searchStartYear = year;
|
||||
if (sido) dto.searchSido = sido;
|
||||
if (gugun) dto.searchGugun = gugun;
|
||||
|
||||
return dto;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/** 백엔드 BnsSearchDto */
|
||||
export interface BnsSearchDto {
|
||||
searchStartYear?: string;
|
||||
cPage: number;
|
||||
searchSido?: string;
|
||||
searchGugun?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 BnsListResponseWrapperDto */
|
||||
export interface BnsListResponseWrapperDto {
|
||||
list: BnsListResponseDto[];
|
||||
cnt: number;
|
||||
}
|
||||
|
||||
/** 백엔드 BnsListResponseDto */
|
||||
export interface BnsListResponseDto {
|
||||
cPage: number;
|
||||
gbn: number;
|
||||
chk: number;
|
||||
seqNo: string;
|
||||
rgnNm: string;
|
||||
sido: string;
|
||||
gugun: string;
|
||||
bzCd: string;
|
||||
bzNm: string;
|
||||
yr: string;
|
||||
strtYr: string;
|
||||
edYr: string;
|
||||
totBzamt: string;
|
||||
}
|
||||
|
||||
/** 백엔드 BnsDetailResponseWrapperDto */
|
||||
export interface BnsDetailResponseWrapperDto {
|
||||
saup: unknown;
|
||||
detail: BnsDetailResponseDto;
|
||||
}
|
||||
|
||||
/** 백엔드 BnsDetailResponseDto */
|
||||
export interface BnsDetailResponseDto {
|
||||
cPage: number;
|
||||
seqNo: string;
|
||||
confBzCd: string;
|
||||
chngDgr: string;
|
||||
yr: string;
|
||||
sido: string;
|
||||
gugun: string;
|
||||
rvbsnNm: string;
|
||||
lgbsnNm: string;
|
||||
mdmbsnNm: string;
|
||||
lclgvrnmntNm: string;
|
||||
accKdNm: string;
|
||||
bzKdNm: string;
|
||||
untBzNm: string;
|
||||
dtlBzNm: string;
|
||||
mevBzNm: string;
|
||||
ddtlBzNm: string;
|
||||
strtYr: string;
|
||||
edYr: string;
|
||||
aidRt: string;
|
||||
bzStatus: string;
|
||||
bzCd: string;
|
||||
bzNm: string;
|
||||
bzLoc: string;
|
||||
efflWqltStndrdNm: string;
|
||||
goalYear: string;
|
||||
bssProm: string;
|
||||
uppPlan: string;
|
||||
techInsp: string;
|
||||
bssCont: string;
|
||||
rmk: string;
|
||||
inflBscFctNm: string;
|
||||
inflBscFctCd: string;
|
||||
trtMtd: string;
|
||||
swrExtns: string;
|
||||
swrChng: string;
|
||||
swrRnvt: string;
|
||||
totFamNum: string;
|
||||
pumpCnt: string;
|
||||
rvWbsn: string;
|
||||
rvText: string;
|
||||
imprvExpctFlw: string;
|
||||
imprvExpctWqlt: string;
|
||||
trtplntPlEfflWqlt: string;
|
||||
wqltAvg: string;
|
||||
wqltHgh: string;
|
||||
wqltLow: string;
|
||||
stdCost: string;
|
||||
bzEtc: string;
|
||||
bscFctCd: string;
|
||||
bscFctNm: string;
|
||||
fctCpc: string;
|
||||
fctCpcAfr: string;
|
||||
trtDstrArea: string;
|
||||
trtDstrAreaAfr: string;
|
||||
swrTrtPopl: string;
|
||||
swrTrtPoplAfr: string;
|
||||
adstPopl: string;
|
||||
}
|
||||
|
||||
/** 백엔드 EmCodeItemDto */
|
||||
export interface EmCodeItemDto {
|
||||
code: string;
|
||||
data: string;
|
||||
upCode: string;
|
||||
}
|
||||
|
||||
/** 백엔드 EmCodeListResponseDto */
|
||||
export interface EmCodeListResponseDto {
|
||||
rvbsnList: EmCodeItemDto[];
|
||||
lgbsnList: EmCodeItemDto[];
|
||||
mdmbsnList: EmCodeItemDto[];
|
||||
accList: EmCodeItemDto[];
|
||||
bzList: EmCodeItemDto[];
|
||||
untList: EmCodeItemDto[];
|
||||
dtlList: EmCodeItemDto[];
|
||||
mevList: EmCodeItemDto[];
|
||||
ddtlList: EmCodeItemDto[];
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface BnsIfService {
|
||||
getBnsList(params: BnsSearchDto): Promise<BnsListResponseWrapperDto>;
|
||||
getBnsDetail(gbn: string, yr: string, seqNo: string): Promise<BnsDetailResponseWrapperDto>;
|
||||
getEmCodeList(dpmCd?: string, lclgvrnmntCd?: string): Promise<EmCodeListResponseDto>;
|
||||
}
|
||||
@@ -11,33 +11,25 @@ import type {
|
||||
CklstArtclUpdateRequest,
|
||||
} from "../model/cklst-artcl-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.CKLST_ARTCL;
|
||||
|
||||
async function findPage(params: CklstArtclSearchParams): Promise<PageResponse<CklstArtcl>> {
|
||||
const response = await client.get<PageResponse<CklstArtcl>>(
|
||||
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL,
|
||||
{ params },
|
||||
);
|
||||
const response = await client.get<PageResponse<CklstArtcl>>(BASE, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findByCklstSn(cklstSn: number): Promise<CklstArtcl[]> {
|
||||
const response = await client.get<CklstArtcl[]>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/by-cklst/${cklstSn}`,
|
||||
);
|
||||
const response = await client.get<CklstArtcl[]>(`${BASE}/by-cklst/${cklstSn}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(cklstArtclSn: number, cklstSn: number): Promise<CklstArtcl> {
|
||||
const response = await client.get<CklstArtcl>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`,
|
||||
);
|
||||
const response = await client.get<CklstArtcl>(`${BASE}/${cklstArtclSn}/${cklstSn}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function create(body: CklstArtclSaveRequest): Promise<CklstArtclId> {
|
||||
const response = await client.post<CklstArtclId>(
|
||||
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL,
|
||||
body,
|
||||
);
|
||||
const response = await client.post<CklstArtclId>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -46,17 +38,12 @@ async function update(
|
||||
cklstSn: number,
|
||||
body: CklstArtclUpdateRequest,
|
||||
): Promise<CklstArtclId> {
|
||||
const response = await client.put<CklstArtclId>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`,
|
||||
body,
|
||||
);
|
||||
const response = await client.put<CklstArtclId>(`${BASE}/${cklstArtclSn}/${cklstSn}`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(cklstArtclSn: number, cklstSn: number): Promise<CklstArtclId> {
|
||||
const response = await client.delete<CklstArtclId>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`,
|
||||
);
|
||||
const response = await client.delete<CklstArtclId>(`${BASE}/${cklstArtclSn}/${cklstSn}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,40 +11,39 @@ import type {
|
||||
CklstTreeSearchParams,
|
||||
} from "../model/cklst-mng-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.CKLST;
|
||||
|
||||
async function findPage(params: CklstMngSearchParams): Promise<PageResponse<CklstMng>> {
|
||||
const response = await client.get<PageResponse<CklstMng>>(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, {
|
||||
const response = await client.get<PageResponse<CklstMng>>(BASE, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(cklstSn: number): Promise<CklstMng> {
|
||||
const response = await client.get<CklstMng>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`);
|
||||
const response = await client.get<CklstMng>(`${BASE}/${cklstSn}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findTree(params: CklstTreeSearchParams): Promise<CklstTreeNode[]> {
|
||||
const response = await client.get<CklstTreeNode[]>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/list/tree`, {
|
||||
const response = await client.get<CklstTreeNode[]>(`${BASE}/list/tree`, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function create(body: CklstMngSaveRequest): Promise<number> {
|
||||
const response = await client.post<number>(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, body);
|
||||
const response = await client.post<number>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(cklstSn: number, body: CklstMngSaveRequest): Promise<number> {
|
||||
const response = await client.put<number>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`,
|
||||
body,
|
||||
);
|
||||
const response = await client.put<number>(`${BASE}/${cklstSn}`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(cklstSn: number): Promise<number> {
|
||||
const response = await client.delete<number>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`);
|
||||
const response = await client.delete<number>(`${BASE}/${cklstSn}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,18 +11,15 @@ import type {
|
||||
CklstRvwUpdateRequest,
|
||||
} from "../model/cklst-rvw-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.CKLST_RVW;
|
||||
|
||||
async function findPage(params: CklstRvwSearchParams): Promise<PageResponse<CklstRvw>> {
|
||||
const response = await client.get<PageResponse<CklstRvw>>(
|
||||
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW,
|
||||
{ params },
|
||||
);
|
||||
const response = await client.get<PageResponse<CklstRvw>>(BASE, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findByArtcl(cklstSn: number, cklstArtclSn: number): Promise<CklstRvw[]> {
|
||||
const response = await client.get<CklstRvw[]>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/by-artcl/${cklstSn}/${cklstArtclSn}`,
|
||||
);
|
||||
const response = await client.get<CklstRvw[]>(`${BASE}/by-artcl/${cklstSn}/${cklstArtclSn}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -31,17 +28,12 @@ async function findById(
|
||||
cklstArtclSn: number,
|
||||
cklstSn: number,
|
||||
): Promise<CklstRvw> {
|
||||
const response = await client.get<CklstRvw>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
|
||||
);
|
||||
const response = await client.get<CklstRvw>(`${BASE}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function create(body: CklstRvwSaveRequest): Promise<CklstRvwId> {
|
||||
const response = await client.post<CklstRvwId>(
|
||||
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW,
|
||||
body,
|
||||
);
|
||||
const response = await client.post<CklstRvwId>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -52,7 +44,7 @@ async function update(
|
||||
body: CklstRvwUpdateRequest,
|
||||
): Promise<CklstRvwId> {
|
||||
const response = await client.put<CklstRvwId>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
|
||||
`${BASE}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
@@ -64,7 +56,7 @@ async function remove(
|
||||
cklstSn: number,
|
||||
): Promise<CklstRvwId> {
|
||||
const response = await client.delete<CklstRvwId>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
|
||||
`${BASE}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ import { cklstArtclService } from "./cklst-artcl-service";
|
||||
import { cklstMngService } from "./cklst-mng-service";
|
||||
import { cklstRvwService } from "./cklst-rvw-service";
|
||||
|
||||
const cklstMngBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST] as const;
|
||||
const cklstArtclBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL] as const;
|
||||
const cklstRvwBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW] as const;
|
||||
const cklstMngBaseKey = [ENDPOINT.AS_SERVICE.CKLST] as const;
|
||||
const cklstArtclBaseKey = [ENDPOINT.AS_SERVICE.CKLST_ARTCL] as const;
|
||||
const cklstRvwBaseKey = [ENDPOINT.AS_SERVICE.CKLST_RVW] as const;
|
||||
|
||||
export function useCklstMngPage(params: CklstMngSearchParams) {
|
||||
return useQuery({
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type CnstpSeMngTreeDto,
|
||||
} from "../model/cnstp-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE;
|
||||
const BASE = ENDPOINT.AS_SERVICE.CNSTP_SE;
|
||||
|
||||
async function findAll(params: CnstpSeMngSearchDto): Promise<PageResponse<CnstpSeMngResponseDto>> {
|
||||
const response = await client.get<PageResponse<CnstpSeMngResponseDto>>(BASE, { params });
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
|
||||
import { cnstpSeMngService } from "./cnstp-service";
|
||||
|
||||
const cnstpSeMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE] as const;
|
||||
const cnstpSeMngBaseKey = [ENDPOINT.AS_SERVICE.CNSTP_SE] as const;
|
||||
|
||||
export function useCnstpSeMngPage(params: CnstpSeMngSearchDto) {
|
||||
return useQuery({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { elaprAplnService } from "./api/elapr-apln-service";
|
||||
export { ElaprDmndApi } from "./api/elapr-dmnd-service";
|
||||
export { elaprTmpltService } from "./api/elapr-tmplt-service";
|
||||
export {
|
||||
useCreateElaprTmplt,
|
||||
@@ -24,6 +25,7 @@ export {
|
||||
type ElaprAplnService,
|
||||
type ElaprAplnUpdateRequest,
|
||||
} from "./model/elapr-apln-interface";
|
||||
export type { ElaprDmndSaveRequest } from "./model/elapr-dmnd-interface";
|
||||
export type {
|
||||
ElaprTmplt,
|
||||
ElaprTmpltSaveRequest,
|
||||
|
||||
@@ -11,50 +11,41 @@ import type {
|
||||
EsntlAtchFileMngTreeSearchParams,
|
||||
} from "../model/esntl-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.ESNTL_ATCH_FILE;
|
||||
|
||||
async function findTree(
|
||||
params: EsntlAtchFileMngTreeSearchParams,
|
||||
): Promise<EsntlAtchFileMngTreeResponseDto[]> {
|
||||
const response = await client.get<EsntlAtchFileMngTreeResponseDto[]>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/list/tree`,
|
||||
{ params },
|
||||
);
|
||||
const response = await client.get<EsntlAtchFileMngTreeResponseDto[]>(`${BASE}/list/tree`, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findPage(
|
||||
params: EsntlAtchFileMngSearchParams,
|
||||
): Promise<PageResponse<EsntlAtchFileMng>> {
|
||||
const response = await client.get<PageResponse<EsntlAtchFileMng>>(
|
||||
ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE,
|
||||
{ params },
|
||||
);
|
||||
const response = await client.get<PageResponse<EsntlAtchFileMng>>(BASE, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(esntlAtchFileId: string): Promise<EsntlAtchFileMng> {
|
||||
const response = await client.get<EsntlAtchFileMng>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
|
||||
);
|
||||
const response = await client.get<EsntlAtchFileMng>(`${BASE}/${esntlAtchFileId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function create(body: EsntlAtchFileMngSaveRequest): Promise<string> {
|
||||
const response = await client.post<string>(ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE, body);
|
||||
const response = await client.post<string>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise<string> {
|
||||
const response = await client.put<string>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
|
||||
body,
|
||||
);
|
||||
const response = await client.put<string>(`${BASE}/${esntlAtchFileId}`, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(esntlAtchFileId: string): Promise<string> {
|
||||
const response = await client.delete<string>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
|
||||
);
|
||||
const response = await client.delete<string>(`${BASE}/${esntlAtchFileId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
|
||||
import { esntlAtchFileMngService } from "./esntl-service";
|
||||
|
||||
const esntlAtchFileMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE] as const;
|
||||
const esntlAtchFileMngBaseKey = [ENDPOINT.AS_SERVICE.ESNTL_ATCH_FILE] as const;
|
||||
|
||||
export function useEsntlAtchFileMngTree(params: EsntlAtchFileMngTreeSearchParams) {
|
||||
return useQuery({
|
||||
|
||||
@@ -1,41 +1,62 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
AdstZoneResponseDto,
|
||||
InstlSncnApplySaveRequestDto,
|
||||
InstlSncnMng,
|
||||
InstlSncnMngDetResponse,
|
||||
InstlSncnMngResponseDto,
|
||||
InstlSncnMngSearchParams,
|
||||
InstlSncnMngService,
|
||||
SpringPage,
|
||||
InstlSncnMngDetResponse,
|
||||
} from "../model/instl-sncn-interface";
|
||||
|
||||
async function getSidoCombo(): Promise<AdstZoneResponseDto[]> {
|
||||
const response = await client.get<AdstZoneResponseDto[]>(
|
||||
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}/sido`,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getSigunguCombo(sidoCd: string): Promise<AdstZoneResponseDto[]> {
|
||||
const response = await client.get<AdstZoneResponseDto[]>(
|
||||
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}/sigungu`,
|
||||
{ params: { sidoCd } },
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.INSTL_SNCN;
|
||||
|
||||
export class InstlSncnApiService implements InstlSncnMngService {
|
||||
public async getPage(params: InstlSncnMngSearchParams): Promise<SpringPage<InstlSncnMng>> {
|
||||
const response = await client.get<SpringPage<InstlSncnMng>>(ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN, {
|
||||
public async getPage(
|
||||
params: InstlSncnMngSearchParams,
|
||||
): Promise<PageResponse<InstlSncnMngResponseDto>> {
|
||||
const response = await client.get<PageResponse<InstlSncnMngResponseDto>>(BASE, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async getById(id: string): Promise<InstlSncnMngDetResponse> {
|
||||
const response = await client.get<InstlSncnMngDetResponse>(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`);
|
||||
const response = await client.get<InstlSncnMngDetResponse>(`${BASE}/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async saveApply(payload: InstlSncnApplySaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(
|
||||
`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/save-apply`,
|
||||
payload,
|
||||
);
|
||||
const response = await client.post<string>(`${BASE}/save-apply`, payload);
|
||||
return response.data;
|
||||
}
|
||||
public async techReview(payload: InstlSncnApplySaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(
|
||||
`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/tech-review`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
public async techReview(id: string, payload: InstlSncnApplySaveRequestDto): Promise<void> {
|
||||
await client.post(`${BASE}/${id}/tech-review`, payload);
|
||||
}
|
||||
}
|
||||
|
||||
export const InstlSncnApi = new InstlSncnApiService();
|
||||
|
||||
export const instlSncnRegionService = {
|
||||
getSidoCombo,
|
||||
getSigunguCombo,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { InstlSncnMngSearchParams } from "../model/instl-sncn-interface";
|
||||
|
||||
import { InstlSncnApi, instlSncnRegionService } from "./instl-sncn-service";
|
||||
|
||||
export const instlSncnKeys = {
|
||||
all: ["instl-sncn"] as const,
|
||||
list: (params: InstlSncnMngSearchParams) => [...instlSncnKeys.all, "list", params] as const,
|
||||
detail: (id: string) => [...instlSncnKeys.all, "detail", id] as const,
|
||||
sidoCombo: () => [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST, "sido"] as const,
|
||||
sigunguCombo: (sidoCd: string) =>
|
||||
[ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST, "sigungu", sidoCd] as const,
|
||||
};
|
||||
|
||||
export function useInstlSncnList(params: InstlSncnMngSearchParams) {
|
||||
return useQuery({
|
||||
queryKey: instlSncnKeys.list(params),
|
||||
queryFn: () => InstlSncnApi.getPage(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstlSncnDetail(id: string) {
|
||||
return useQuery({
|
||||
queryKey: instlSncnKeys.detail(id),
|
||||
queryFn: () => InstlSncnApi.getById(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstlSncnSidoCombo() {
|
||||
return useQuery({
|
||||
queryKey: instlSncnKeys.sidoCombo(),
|
||||
queryFn: () => instlSncnRegionService.getSidoCombo(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstlSncnSigunguCombo(sidoCd: string) {
|
||||
return useQuery({
|
||||
queryKey: instlSncnKeys.sigunguCombo(sidoCd),
|
||||
queryFn: () => instlSncnRegionService.getSigunguCombo(sidoCd),
|
||||
enabled: Boolean(sidoCd),
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,28 @@
|
||||
export { InstlSncnApi } from "./api/instl-sncn-service";
|
||||
export { InstlSncnApi, instlSncnRegionService } from "./api/instl-sncn-service";
|
||||
export {
|
||||
instlSncnKeys,
|
||||
useInstlSncnDetail,
|
||||
useInstlSncnList,
|
||||
useInstlSncnSidoCombo,
|
||||
useInstlSncnSigunguCombo,
|
||||
} from "./api/queries";
|
||||
export {
|
||||
type AdstZoneResponseDto,
|
||||
type AtflMngClientResponseDto,
|
||||
type BizCstTreeDto,
|
||||
type BizSclTreeDto,
|
||||
type CklstRvwRsltTreeDto,
|
||||
type ElaprDocPrgrsClientResponseDto,
|
||||
type EsntlAtchFileMngTreeResponseDto,
|
||||
INSTL_SNCN_REGISTER_SELECTION_KEY,
|
||||
type InstlSncnApplySaveRequestDto,
|
||||
type InstlSncnBizCstSaveRequestDto,
|
||||
type InstlSncnBzScaleSaveDto,
|
||||
type InstlSncnCklstResultSaveDto,
|
||||
type InstlSncnMng,
|
||||
type InstlSncnMngDetResponse,
|
||||
type InstlSncnMngResponseDto,
|
||||
type InstlSncnMngSaveRequestDto,
|
||||
type InstlSncnMngSearchParams,
|
||||
type SpringPage,
|
||||
type ElaprDocPrgrsClientResponseDto,
|
||||
type InstlSncnRvwOpnnResponseDto,
|
||||
type ReviewStepCode,
|
||||
} from "./model/instl-sncn-interface";
|
||||
|
||||
@@ -1,60 +1,100 @@
|
||||
export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection";
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export interface SpringPage<T> {
|
||||
content: T[];
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
number: number;
|
||||
size: number;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
numberOfElements: number;
|
||||
/** 행정구역(시도·시군구) 콤보 응답 DTO */
|
||||
export interface AdstZoneResponseDto {
|
||||
/** 행정구역 코드 */
|
||||
adstCd: string;
|
||||
/** 행정구역 명 */
|
||||
adstNm: string;
|
||||
}
|
||||
|
||||
export interface InstlSncnMngSearchParams {
|
||||
export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection";
|
||||
|
||||
export interface InstlSncnMngSearchParams extends PageableParams {
|
||||
bizNm?: string;
|
||||
taskSeComCd?: string;
|
||||
sidoComCd?: string;
|
||||
sggComCd?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface InstlSncnMng {
|
||||
gugun: string;
|
||||
sido: string;
|
||||
instlSncnDocNo: string;
|
||||
admdstCd: string;
|
||||
sidoNm?: string;
|
||||
sggNm?: string;
|
||||
chrgrNm?: string;
|
||||
instNm?: string;
|
||||
envoSeComCd: string;
|
||||
/** 설치인가관리 저장 요청 DTO */
|
||||
export interface InstlSncnMngSaveRequestDto {
|
||||
/** 설치인가문서번호 */
|
||||
instlSncnDocNo?: string;
|
||||
/** 행정구역코드 */
|
||||
admdstCd?: string;
|
||||
/** 환경청구분공통코드 */
|
||||
envoSeComCd?: string;
|
||||
/** 인가구분공통코드 */
|
||||
sncnSeComCd: string;
|
||||
envoSeCdNm: string;
|
||||
/** 업무구분공통코드 */
|
||||
taskSeComCd: string;
|
||||
taskSeCdNm: string;
|
||||
bgtBizCd: string;
|
||||
bgtAplyYr: string;
|
||||
/** 예산사업코드 */
|
||||
bgtBizCd?: string;
|
||||
/** 예산신청연도 */
|
||||
bgtAplyYr?: string;
|
||||
/** 사업명 */
|
||||
bizNm: string;
|
||||
/** 사업시작연도 */
|
||||
bizBgngYr: string;
|
||||
/** 사업종료연도 */
|
||||
bizEndYr: string;
|
||||
/** 사업내용 */
|
||||
bizCn: string;
|
||||
aplcntNm: string;
|
||||
aplcntMblTelno: string;
|
||||
aplcntOgdpNm: string;
|
||||
aplcntEmlAddr: string;
|
||||
/** 신청자명 */
|
||||
aplcntNm?: string;
|
||||
/** 신청자휴대전화번호 */
|
||||
aplcntMblTelno?: string;
|
||||
/** 신청자소속명 */
|
||||
aplcntOgdpNm?: string;
|
||||
/** 신청자이메일주소 */
|
||||
aplcntEmlAddr?: string;
|
||||
/** 설계자명 */
|
||||
dsgnrNm: string;
|
||||
/** 설계자전화번호 */
|
||||
dsgnrTelno: string;
|
||||
/** 설계자소속명 */
|
||||
dsgnrOgdpNm: string;
|
||||
/** 설계자이메일주소 */
|
||||
dsgnrEmlAddr: string;
|
||||
atchFileGroupId: string;
|
||||
prgrsSttsComCd: string;
|
||||
/** 첨부파일그룹아이디 */
|
||||
atchFileGroupId?: string;
|
||||
/** 진행상태공통코드 */
|
||||
prgrsSttsComCd?: string;
|
||||
/** 재이용시설구분공통코드 */
|
||||
rtlztnFcltSeComCd?: string;
|
||||
/** 재이용용도구분공통코드 */
|
||||
rtlztnUsgSeComCd?: string;
|
||||
}
|
||||
|
||||
/** 설치인가관리 응답 DTO */
|
||||
export interface InstlSncnMngResponseDto extends InstlSncnMngSaveRequestDto {
|
||||
/** 설치인가문서번호 (필수로 재정의) */
|
||||
instlSncnDocNo: string;
|
||||
/** 환경청구분공통코드명 */
|
||||
envoSeCdNm: string;
|
||||
/** 인가구분공통코드명 */
|
||||
sncnSeCdNm: string;
|
||||
/** 업무구분공통코드명 */
|
||||
taskSeCdNm: string;
|
||||
/** 진행상태공통코드명 */
|
||||
prgrsSttsCdNm: string;
|
||||
/** 재이용시설구분공통코드명 */
|
||||
rtlztnFcltSeComNm: string;
|
||||
/** 재이용용도구분공통코드명 */
|
||||
rtlztnUsgSeComNm: string;
|
||||
/** 등록일시 */
|
||||
regDt: string;
|
||||
mdfcnDt: string;
|
||||
/** 등록자 UUID */
|
||||
rgtr: string;
|
||||
/** 등록자명 */
|
||||
rgtrNm: string;
|
||||
/** 수정 일시 */
|
||||
mdfcnDt: string;
|
||||
/** 시도 */
|
||||
sido: string;
|
||||
/** 구군 */
|
||||
gugun: string;
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 사업규모 저장 DTO(배열 원소) */
|
||||
@@ -101,7 +141,7 @@ export interface InstlSncnEsntlAtchFileSaveDto {
|
||||
|
||||
/** 설치인가 신청 - 통합 저장 요청 DTO */
|
||||
export interface InstlSncnApplySaveRequestDto {
|
||||
instlSncnMng: InstlSncnMng;
|
||||
instlSncnMng: InstlSncnMngSaveRequestDto;
|
||||
bzScaleList: InstlSncnBzScaleSaveDto[];
|
||||
bizCstList: InstlSncnBizCstSaveRequestDto[];
|
||||
cklstResultList: InstlSncnCklstResultSaveDto[];
|
||||
@@ -110,7 +150,155 @@ 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 {
|
||||
@@ -131,19 +319,21 @@ export interface ElaprDocPrgrsClientResponseDto {
|
||||
ognzNm?: string;
|
||||
}
|
||||
|
||||
// det 응답에 전자결재 진행상태 목록을 포함
|
||||
/** 설치인가관리 상세 응답 DTO */
|
||||
export interface InstlSncnMngDetResponse {
|
||||
detDto: InstlSncnMng;
|
||||
bizSclList: any[];
|
||||
bizCstList: any[];
|
||||
cklstResultList?: any[];
|
||||
esntlFileList: any[];
|
||||
etcFileList: any[];
|
||||
detDto: InstlSncnMngResponseDto;
|
||||
bizSclList: BizSclTreeDto[];
|
||||
bizCstList: BizCstTreeDto[];
|
||||
cklstResultList?: CklstRvwRsltTreeDto[];
|
||||
esntlFileList: EsntlAtchFileMngTreeResponseDto[];
|
||||
etcFileList: AtflMngClientResponseDto[];
|
||||
rvwList?: InstlSncnRvwOpnnResponseDto[];
|
||||
elaprDocPrgrsList?: ElaprDocPrgrsClientResponseDto[] | null;
|
||||
}
|
||||
|
||||
export interface InstlSncnMngService {
|
||||
getPage: (params: InstlSncnMngSearchParams) => Promise<SpringPage<InstlSncnMng>>;
|
||||
getPage: (params: InstlSncnMngSearchParams) => Promise<PageResponse<InstlSncnMngResponseDto>>;
|
||||
getById: (id: string) => Promise<InstlSncnMngDetResponse>;
|
||||
saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise<string>;
|
||||
techReview: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
RvwMnpwMngPageSearchDto,
|
||||
RvwMnpwMngSaveRequestDto,
|
||||
RvwMnpwMngSearchDto,
|
||||
RvwMnpwMngUpdateRequestDto,
|
||||
@@ -10,9 +11,12 @@ import type {
|
||||
|
||||
import { rvwMnpwMngService } from "./rvw-mnpw-service";
|
||||
|
||||
const rvwMnpwMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW] as const;
|
||||
const rvwMnpwMngBaseKey = [ENDPOINT.AS_SERVICE.RVW_MNPW] as const;
|
||||
|
||||
export function useRvwMnpwMngPage(params: RvwMnpwMngSearchDto, options?: { enabled?: boolean }) {
|
||||
export function useRvwMnpwMngPage(
|
||||
params: RvwMnpwMngPageSearchDto,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...rvwMnpwMngBaseKey, "findPage", params],
|
||||
queryFn: () => rvwMnpwMngService.findPage(params),
|
||||
@@ -20,6 +24,17 @@ export function useRvwMnpwMngPage(params: RvwMnpwMngSearchDto, options?: { enabl
|
||||
});
|
||||
}
|
||||
|
||||
export function useRvwMnpwMngLoginUserList(
|
||||
searchDto: RvwMnpwMngSearchDto,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...rvwMnpwMngBaseKey, "findRvwLoginUserList", searchDto],
|
||||
queryFn: () => rvwMnpwMngService.findRvwLoginUserList(searchDto),
|
||||
enabled: options?.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRvwMnpwMng(
|
||||
atrzSeComCd: string | null | undefined,
|
||||
ognzSn: number | null | undefined,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
RvwMnpwMngPageSearchDto,
|
||||
RvwMnpwMngResponseDto,
|
||||
RvwMnpwMngSaveRequestDto,
|
||||
RvwMnpwMngSearchDto,
|
||||
@@ -10,14 +11,21 @@ import type {
|
||||
RvwMnpwMngUpdateRequestDto,
|
||||
} from "../model/rvw-mnpw-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW;
|
||||
const BASE = ENDPOINT.AS_SERVICE.RVW_MNPW;
|
||||
|
||||
function pkPath(atrzSeComCd: string, ognzSn: number, userUuid: string): string {
|
||||
return `${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`;
|
||||
async function findPage(
|
||||
params: RvwMnpwMngPageSearchDto,
|
||||
): Promise<PageResponse<RvwMnpwMngResponseDto>> {
|
||||
const response = await client.get<PageResponse<RvwMnpwMngResponseDto>>(BASE, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findPage(params: RvwMnpwMngSearchDto): Promise<PageResponse<RvwMnpwMngResponseDto>> {
|
||||
const response = await client.get<PageResponse<RvwMnpwMngResponseDto>>(BASE, { params });
|
||||
async function findRvwLoginUserList(
|
||||
searchDto: RvwMnpwMngSearchDto,
|
||||
): Promise<PageResponse<RvwMnpwMngResponseDto>> {
|
||||
const response = await client.get<PageResponse<RvwMnpwMngResponseDto>>(`${BASE}/loginuser/me`, {
|
||||
params: searchDto,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -26,41 +34,48 @@ async function findById(
|
||||
ognzSn: number,
|
||||
userUuid: string,
|
||||
): Promise<RvwMnpwMngResponseDto> {
|
||||
const response = await client.get<RvwMnpwMngResponseDto>(pkPath(atrzSeComCd, ognzSn, userUuid));
|
||||
const response = await client.get<RvwMnpwMngResponseDto>(
|
||||
`${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function save(body: RvwMnpwMngSaveRequestDto): Promise<RvwMnpwMngResponseDto> {
|
||||
const response = await client.post<RvwMnpwMngResponseDto>(BASE, body);
|
||||
async function saveRvwMnpwMng(body: RvwMnpwMngSaveRequestDto): Promise<RvwMnpwMngResponseDto> {
|
||||
const { atrzSeComCd, ognzSn, userUuid } = body;
|
||||
const response = await client.post<RvwMnpwMngResponseDto>(
|
||||
`${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(
|
||||
async function updateRvwMnpwMng(
|
||||
atrzSeComCd: string,
|
||||
ognzSn: number,
|
||||
userUuid: string,
|
||||
body: RvwMnpwMngUpdateRequestDto,
|
||||
): Promise<RvwMnpwMngResponseDto> {
|
||||
const response = await client.put<RvwMnpwMngResponseDto>(
|
||||
pkPath(atrzSeComCd, ognzSn, userUuid),
|
||||
`${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function deleteRvwMnpw(
|
||||
async function deleteRvwMnpwMng(
|
||||
atrzSeComCd: string,
|
||||
ognzSn: number,
|
||||
userUuid: string,
|
||||
): Promise<string> {
|
||||
const response = await client.delete<string>(pkPath(atrzSeComCd, ognzSn, userUuid));
|
||||
const response = await client.delete<string>(`${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const rvwMnpwMngService = {
|
||||
findPage,
|
||||
findRvwLoginUserList,
|
||||
findById,
|
||||
save,
|
||||
update,
|
||||
delete: deleteRvwMnpw,
|
||||
save: saveRvwMnpwMng,
|
||||
update: updateRvwMnpwMng,
|
||||
delete: deleteRvwMnpwMng,
|
||||
} satisfies RvwMnpwMngService;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
export {
|
||||
useDeleteRvwMnpwMng,
|
||||
useRvwMnpwMng,
|
||||
useRvwMnpwMngLoginUserList,
|
||||
useRvwMnpwMngPage,
|
||||
useSaveRvwMnpwMng,
|
||||
useUpdateRvwMnpwMng,
|
||||
} from "./api/queries";
|
||||
export { rvwMnpwMngService } from "./api/rvw-mnpw-service";
|
||||
export type {
|
||||
RvwMnpwMngPageSearchDto,
|
||||
RvwMnpwMngResponseDto,
|
||||
RvwMnpwMngSaveRequestDto,
|
||||
RvwMnpwMngSearchDto,
|
||||
|
||||
@@ -14,13 +14,16 @@ export interface RvwMnpwMngResponseDto {
|
||||
regDt?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 RvwMnpwMngSearchDto + Pageable */
|
||||
export interface RvwMnpwMngSearchDto extends PageableParams {
|
||||
/** 백엔드 RvwMnpwMngSearchDto */
|
||||
export interface RvwMnpwMngSearchDto {
|
||||
atrzSeComCd?: string;
|
||||
ognzSn?: number;
|
||||
userUuid?: string;
|
||||
}
|
||||
|
||||
/** findPage — RvwMnpwMngSearchDto + Pageable */
|
||||
export type RvwMnpwMngPageSearchDto = RvwMnpwMngSearchDto & PageableParams;
|
||||
|
||||
/** 백엔드 RvwMnpwMngSaveRequestDto */
|
||||
export interface RvwMnpwMngSaveRequestDto {
|
||||
atrzSeComCd: string;
|
||||
@@ -35,7 +38,10 @@ export interface RvwMnpwMngUpdateRequestDto {
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface RvwMnpwMngService {
|
||||
findPage(params: RvwMnpwMngSearchDto): Promise<PageResponse<RvwMnpwMngResponseDto>>;
|
||||
findPage(params: RvwMnpwMngPageSearchDto): Promise<PageResponse<RvwMnpwMngResponseDto>>;
|
||||
findRvwLoginUserList(
|
||||
searchDto: RvwMnpwMngSearchDto,
|
||||
): Promise<PageResponse<RvwMnpwMngResponseDto>>;
|
||||
findById(atrzSeComCd: string, ognzSn: number, userUuid: string): Promise<RvwMnpwMngResponseDto>;
|
||||
save(body: RvwMnpwMngSaveRequestDto): Promise<RvwMnpwMngResponseDto>;
|
||||
update(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { handleTusUpload, type TusUploadOptions } from "./lib/utils";
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Upload } from "tus-js-client";
|
||||
|
||||
import { ENDPOINT, PROXY_HOST } from "@/shared/config";
|
||||
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config/env";
|
||||
import { getCookie } from "@/shared/lib";
|
||||
|
||||
interface TusUploadOptions {
|
||||
metadata?: Record<string, string>;
|
||||
handleUploadStart?: (upload: Upload) => void;
|
||||
handleProgress?: (percentage: number) => void;
|
||||
handleSuccess?: (upload: Upload, atflCd: string, atflId: string) => void;
|
||||
handleError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
function handleTusUpload(file: File, options?: TusUploadOptions) {
|
||||
let atflCd = "";
|
||||
let atflId = "";
|
||||
|
||||
const metadata = {
|
||||
filename: file.name,
|
||||
filetype: file.type,
|
||||
...options?.metadata,
|
||||
};
|
||||
|
||||
const upload = new Upload(file, {
|
||||
endpoint: `${PROXY_HOST}${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
|
||||
chunkSize: 10 * 1024 * 1024,
|
||||
headers: {
|
||||
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
|
||||
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
|
||||
},
|
||||
metadata,
|
||||
onError(error) {
|
||||
console.error("TUS upload failed:", error);
|
||||
options?.handleError?.(error);
|
||||
},
|
||||
onProgress(bytesUploaded, bytesTotal) {
|
||||
const percentage = (bytesUploaded / bytesTotal) * 100;
|
||||
options?.handleProgress?.(percentage);
|
||||
},
|
||||
onAfterResponse(_req, res) {
|
||||
const fileInfo = res.getHeader("X-File-Info");
|
||||
if (!fileInfo) return;
|
||||
|
||||
const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string };
|
||||
atflCd = parsed.atflCd ?? "";
|
||||
atflId = parsed.atflId ?? "";
|
||||
},
|
||||
onSuccess() {
|
||||
options?.handleSuccess?.(upload, atflCd, atflId);
|
||||
},
|
||||
});
|
||||
|
||||
upload.start();
|
||||
options?.handleUploadStart?.(upload);
|
||||
|
||||
return upload;
|
||||
}
|
||||
|
||||
export { handleTusUpload };
|
||||
export type { TusUploadOptions };
|
||||
@@ -102,7 +102,7 @@ function CklstMngFormDialog({
|
||||
<div className="space-y-2">
|
||||
<Label>문서구분</Label>
|
||||
<CodeSelect
|
||||
upCd="ASDOCSECD"
|
||||
upCd="ASDOCSE"
|
||||
value={docSeComCd}
|
||||
onValueChange={(v) => setValue("docSeComCd", v)}
|
||||
placeholder="문서구분 선택"
|
||||
@@ -112,7 +112,7 @@ function CklstMngFormDialog({
|
||||
<div className="space-y-2">
|
||||
<Label>업무구분</Label>
|
||||
<CodeSelect
|
||||
upCd={docSeComCd || "ASDOCSECD"}
|
||||
upCd={docSeComCd || "ASDOCSE"}
|
||||
value={watch("taskSeComCd")}
|
||||
onValueChange={(v) => setValue("taskSeComCd", v)}
|
||||
placeholder="업무구분 선택"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { CnfmListPage } from "./ui/cnfm-list-page";
|
||||
export { CnfmViewPage } from "./ui/cnfm-view-page";
|
||||
export { CnfmRegistPage } from "./ui/cnfm-regist-page";
|
||||
export { CnfmModifyPage } from "./ui/cnfm-modify-page";
|
||||
+174
-138
@@ -10,7 +10,7 @@ import { type HttpResponse, Upload } from "tus-js-client";
|
||||
|
||||
import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT, PROXY_HOST } from "@/shared/config";
|
||||
import { getCookie, setCookie, useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import { deleteAttachment } from "@/shared/lib/attach";
|
||||
import { deleteAttachment } from "@/shared/lib/attach";
|
||||
import { formatFileSize } from "@/shared/lib/file-utils";
|
||||
import type { AcntgSeMngTreeDto } from "@/entities/acntg";
|
||||
import { acntgSeMngService } from "@/entities/acntg";
|
||||
@@ -18,19 +18,17 @@ import type { CklstTreeNode } from "@/entities/cklst";
|
||||
import { cklstMngService } from "@/entities/cklst";
|
||||
import type { CnstpSeMngTreeDto } from "@/entities/cnstp";
|
||||
import { cnstpSeMngService } from "@/entities/cnstp";
|
||||
import {
|
||||
esntlAtchFileMngService,
|
||||
type EsntlAtchFileMngTreeResponseDto,
|
||||
} from "@/entities/esntl";
|
||||
import { esntlAtchFileMngService, type EsntlAtchFileMngTreeResponseDto } from "@/entities/esntl";
|
||||
import {
|
||||
INSTL_SNCN_REGISTER_SELECTION_KEY,
|
||||
InstlSncnApi,
|
||||
type InstlSncnApplySaveRequestDto,
|
||||
type InstlSncnBizCstSaveRequestDto,
|
||||
type InstlSncnBzScaleSaveDto,
|
||||
type InstlSncnMng,
|
||||
type InstlSncnMngResponseDto,
|
||||
InstlSncnMngSaveRequestDto,
|
||||
} from "@/entities/instl-sncn";
|
||||
import { EtcAttachments,NumCell, PrivacyConsent, ReadCell } from "@/widgets/cnfm";
|
||||
import { EtcAttachments, NumCell, PrivacyConsent, ReadCell } from "@/widgets/cnfm";
|
||||
import { Button, Input } from "@common/components";
|
||||
|
||||
import "./cnfm-page.css";
|
||||
@@ -128,7 +126,7 @@ const normalizeChecklistResultRows = (payload: any): any[] => {
|
||||
/* 메인 컴포넌트 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
/**
|
||||
* initialData 매핑 안내
|
||||
* initialData 매핑 안내
|
||||
* - initialData 데이터는 cnfm-modify-page에서 서버로부터 받아온 상세 데이터를 그대로 전달받거나, cnfm-regist-page에서 수정 모드로 진입할 때 전달받을 수 있습니다.
|
||||
* - 이 컴포넌트는 수정 모드로 호출될 때 `initialData`를 받습니다.
|
||||
* - 서버 응답은 두 가지 형태 중 하나일 수 있습니다:
|
||||
@@ -184,7 +182,6 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
return visibleReviewStepCodes.includes(step) ? step : null;
|
||||
}, [isModifyMode, userInfo?.dtlInstCd, visibleReviewStepCodes]);
|
||||
const [selectedReviewStep, setSelectedReviewStep] = useState<ReviewStepCode>("LOCAL");
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleReviewStepCodes.length === 0) return;
|
||||
@@ -208,13 +205,15 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
const [taskSeComCdNm, setTaskSeComCdNm] = useState<string>(
|
||||
() => (currentDet?.taskSeComCdNm as string) ?? "",
|
||||
);
|
||||
const [taskSeComCd, setTaskSeComCd] = useState<string>(() => (currentDet?.taskSeComCd as string) ?? "RE");
|
||||
const [taskSeComCd, setTaskSeComCd] = useState<string>(
|
||||
() => (currentDet?.taskSeComCd as string) ?? "RE",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// do not override server-provided initialData (modify mode)
|
||||
if (currentDet) return;
|
||||
|
||||
const raw = sessionStorage.getItem(INSTL_SNCN_REGISTER_SELECTION_KEY);
|
||||
const raw = sessionStorage.getItem(INSTL_SNCN_REGISTER_SELECTION_KEY);
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
@@ -222,7 +221,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
taskSeComCdList?: string[];
|
||||
taskSeComCdNmList?: string[];
|
||||
};
|
||||
|
||||
|
||||
setTaskSeComCd(parsed.taskSeComCdList?.join(",") || "RE");
|
||||
setTaskSeComCdNm(parsed.taskSeComCdNmList?.join(",") || "재이용수");
|
||||
} catch {
|
||||
@@ -262,13 +261,10 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
|
||||
// selected task code for header filtering (null = none selected)
|
||||
const [selectedTaskCode, setSelectedTaskCode] = useState<string | null>(null);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const [requiredFiles, setRequiredFiles] = useState<Record<RequiredAttachKey, RequiredAttachItem>>({});
|
||||
const [requiredFiles, setRequiredFiles] = useState<Record<RequiredAttachKey, RequiredAttachItem>>(
|
||||
{},
|
||||
);
|
||||
const requiredFileInputRefs = useRef<Record<RequiredAttachKey, HTMLInputElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -352,22 +348,22 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
upload.start();
|
||||
};
|
||||
|
||||
/* ── 기타 첨부파일 ── */
|
||||
type EtcFile = {
|
||||
id: string;
|
||||
name: string;
|
||||
ext: string;
|
||||
size: string;
|
||||
uploader: string;
|
||||
atflCd: string;
|
||||
atflId: string;
|
||||
};
|
||||
const [etcFiles, setEtcFiles] = useState<EtcFile[]>([]);
|
||||
const [etcNoticeOpen, setEtcNoticeOpen] = useState(true);
|
||||
const [etcFileGroupId, setEtcFileGroupId] = useState("");
|
||||
const [etcUploading, setEtcUploading] = useState(false);
|
||||
const etcFileGroupIdRef = useRef("");
|
||||
const initialMappedRef = useRef(false);
|
||||
/* ── 기타 첨부파일 ── */
|
||||
type EtcFile = {
|
||||
id: string;
|
||||
name: string;
|
||||
ext: string;
|
||||
size: string;
|
||||
uploader: string;
|
||||
atflCd: string;
|
||||
atflId: string;
|
||||
};
|
||||
const [etcFiles, setEtcFiles] = useState<EtcFile[]>([]);
|
||||
const [etcNoticeOpen, setEtcNoticeOpen] = useState(true);
|
||||
const [etcFileGroupId, setEtcFileGroupId] = useState("");
|
||||
const [etcUploading, setEtcUploading] = useState(false);
|
||||
const etcFileGroupIdRef = useRef("");
|
||||
const initialMappedRef = useRef(false);
|
||||
|
||||
// 초기 데이터가 주어지면 폼 상태로 매핑 (수정 모드에서 사용)
|
||||
useEffect(() => {
|
||||
@@ -382,7 +378,6 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
//const cklstResultList = initialData.cklstResultList ?? [];
|
||||
const esntlFileList = initialData.esntlFileList ?? [];
|
||||
const etcFileList = initialData.etcFileList ?? [];
|
||||
|
||||
|
||||
// 기본 정보 및 설계자 정보
|
||||
setBzNm(det.bizNm ?? "");
|
||||
@@ -412,9 +407,12 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
.filter((f: any) => f && typeof f === "object")
|
||||
.map((f: any) => {
|
||||
const fileName = f.orgnlFileNm ?? f.physFileNm ?? "";
|
||||
const ext = fileName && typeof fileName === "string" && fileName.includes(".") ? fileName.split(".").pop() ?? "" : "";
|
||||
const ext =
|
||||
fileName && typeof fileName === "string" && fileName.includes(".")
|
||||
? (fileName.split(".").pop() ?? "")
|
||||
: "";
|
||||
return {
|
||||
id: f.atflId ,
|
||||
id: f.atflId,
|
||||
name: fileName,
|
||||
ext,
|
||||
size: f.atflFlieSz ?? 0,
|
||||
@@ -428,17 +426,18 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
|
||||
// 수정모드시 기타첨부 그룹아이디 적용 (중복 매핑 방지)
|
||||
// - det.atchFileGroupId 또는 det.atflCd 우선, 없으면 etcFileList 첫 항목의 atflCd 사용
|
||||
const existingGroup = (det.atchFileGroupId ) || (Array.isArray(etcFileList) && etcFileList[0]?.atflCd) || "";
|
||||
const existingGroup =
|
||||
det.atchFileGroupId || (Array.isArray(etcFileList) && etcFileList[0]?.atflCd) || "";
|
||||
//console.log("etcFileGroupIdRef.current 초기화1:", existingGroup);
|
||||
if (existingGroup && etcFileGroupIdRef.current !== existingGroup) {
|
||||
etcFileGroupIdRef.current = existingGroup;
|
||||
setEtcFileGroupId((prev) => prev || existingGroup);
|
||||
}
|
||||
// console.log("etcFileGroupIdRef.current 초기화:", etcFileGroupIdRef.current);
|
||||
// console.log("etcFileGroupIdRef.current 초기화:", etcFileGroupIdRef.current);
|
||||
|
||||
// 수정모드인 경우 서버가 전달한 업무구분(단일 혹은 콤마로 결합된 복수)을 상태로 복원
|
||||
setTaskSeComCd(det.taskSeComCd);
|
||||
setTaskSeComCdNm(det.taskSeCdNm );
|
||||
setTaskSeComCd(det.taskSeComCd);
|
||||
setTaskSeComCdNm(det.taskSeCdNm);
|
||||
|
||||
// 매핑 완료 플래그 설정 — 이후 동일 컴포넌트 인스턴스에서 재매핑 방지
|
||||
initialMappedRef.current = true;
|
||||
@@ -500,14 +499,14 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
};
|
||||
}
|
||||
setAmtMap(map);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("initialData 매핑 실패", err);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
/** 기타첨부파일 삭제 핸들러: 백엔드에 DELETE 요청을 보내 소프트삭제 처리 후 상태 갱신 */
|
||||
const handleDeleteEtcFile = async (fileId: string, atflId: string) => {
|
||||
/** 기타첨부파일 삭제 핸들러: 백엔드에 DELETE 요청을 보내 소프트삭제 처리 후 상태 갱신 */
|
||||
const handleDeleteEtcFile = async (fileId: string, atflId: string) => {
|
||||
try {
|
||||
if (atflId) {
|
||||
await deleteAttachment(atflId);
|
||||
@@ -528,18 +527,21 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleRequiredFileChange = (key: RequiredAttachKey, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleRequiredFileChange = (
|
||||
key: RequiredAttachKey,
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
uploadRequiredFile(file, key);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
|
||||
const uploadEtcFile = (file: File): Promise<EtcFile> =>
|
||||
new Promise((resolve, reject) => {
|
||||
let uploadedAtflCd = "";
|
||||
let uploadedAtflId = "";
|
||||
console.log("기타첨부파일 업로드 시작:", file.name ,etcFileGroupIdRef.current);
|
||||
console.log("기타첨부파일 업로드 시작:", file.name, etcFileGroupIdRef.current);
|
||||
const upload = new Upload(file, {
|
||||
endpoint: `${PROXY_HOST}${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
|
||||
chunkSize: 10 * 1024 * 1024,
|
||||
@@ -569,7 +571,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
const ext = file.name.includes(".") ? file.name.split(".").pop() ?? "" : "";
|
||||
const ext = file.name.includes(".") ? (file.name.split(".").pop() ?? "") : "";
|
||||
resolve({
|
||||
id: uploadedAtflId || `${Date.now()}_${Math.random()}`,
|
||||
name: file.name,
|
||||
@@ -666,7 +668,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
const cklstRows = useMemo(() => {
|
||||
// bchckUseYn 필터 제거: 부모가 있는 자식 노드(bchckUseYn=N)도 트리에 포함
|
||||
// cklstTree 내의 피드 taskSeComCd 에 따라서 그룹핑 되도록 해줘
|
||||
|
||||
|
||||
const useRows = selectedTaskCode
|
||||
? cklstTree.filter((r) => (r as any).taskSeComCd === selectedTaskCode)
|
||||
: cklstTree;
|
||||
@@ -710,7 +712,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
const codes = Array.from(
|
||||
new Set((cklstTree ?? []).map((r) => (r as any).taskSeComCd).filter(Boolean)),
|
||||
);
|
||||
|
||||
|
||||
if (codes.length === 0) {
|
||||
if (selectedTaskCode !== null) setSelectedTaskCode(null);
|
||||
return;
|
||||
@@ -791,7 +793,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
const el = cklstRowRefs.current[pendingInvalidKey];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const firstRadio = el.querySelector<HTMLInputElement>(`input[name="chk_${pendingInvalidKey}"]`);
|
||||
const firstRadio = el.querySelector<HTMLInputElement>(
|
||||
`input[name="chk_${pendingInvalidKey}"]`,
|
||||
);
|
||||
firstRadio?.focus();
|
||||
setPendingInvalidKey(null);
|
||||
}
|
||||
@@ -800,7 +804,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
|
||||
// 수정 모드에서 서버의 체크리스트 결과값을 폼 상태로 반영
|
||||
useEffect(() => {
|
||||
if (!initialData || cklstRows.length === 0) return;
|
||||
if (!initialData || cklstRows.length === 0) return;
|
||||
const results = normalizeChecklistResultRows(initialData);
|
||||
if (!Array.isArray(results) || results.length === 0) return;
|
||||
|
||||
@@ -866,7 +870,6 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
return { total, completed, percent };
|
||||
}, [cklstRows, checkResults, selectedReviewStep]);
|
||||
|
||||
|
||||
const toCklstSaveDtoList = () => {
|
||||
const persistedStepCodeSet = new Set<ReviewStepCode>();
|
||||
normalizeChecklistResultRows(initialData).forEach((r: any) => {
|
||||
@@ -877,9 +880,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
visibleReviewStepCodes.forEach((code) => persistedStepCodeSet.add(code));
|
||||
|
||||
// only persist rows that are enabled for checkbox (`bchckUseYn === "Y")
|
||||
// only persist rows that are enabled for checkbox (`bchckUseYn === "Y")
|
||||
// Use full cklstTree so items not currently visible (other task codes) are included
|
||||
const relevantRows = (cklstTree ?? []).filter((row) => row.bchckUseYn === "Y");
|
||||
// only persist rows that are enabled for checkbox (`bchckUseYn === "Y")
|
||||
// Use full cklstTree so items not currently visible (other task codes) are included
|
||||
const relevantRows = (cklstTree ?? []).filter((row) => row.bchckUseYn === "Y");
|
||||
|
||||
// Ensure we generate DTOs grouped by all known task codes (TASK_CODE_LABELS keys)
|
||||
const allTaskCodes = Object.keys(TASK_CODE_LABELS);
|
||||
@@ -930,7 +933,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
|
||||
const subtotalScale = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number =>
|
||||
cnstpTree
|
||||
.filter((n) => cnstpLeafSet.has(n.cnstpSeCd) && n.treePath.split("|").includes(node.cnstpSeCd))
|
||||
.filter(
|
||||
(n) => cnstpLeafSet.has(n.cnstpSeCd) && n.treePath.split("|").includes(node.cnstpSeCd),
|
||||
)
|
||||
.reduce((sum, n) => sum + num((bzScaleMap[n.cnstpSeCd] ?? emptyScale())[col]), 0);
|
||||
|
||||
const scaleNodeVal = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number =>
|
||||
@@ -1046,7 +1051,6 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
|
||||
/* ── 검증 ── */
|
||||
function fnValidation(mode: "SAVE" | "PRESENTN") {
|
||||
|
||||
if (!bzNm.trim()) {
|
||||
alert("사업명을 입력하세요.");
|
||||
return false;
|
||||
@@ -1063,8 +1067,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
alert("개인정보 제공에 동의하셔야 제출이 가능합니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (!editableReviewStep) {
|
||||
alert("현재 사용자 권한/진행상태 조합에서 입력 가능한 체크리스트 단계가 없습니다.");
|
||||
return false;
|
||||
@@ -1100,14 +1103,18 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
visibleReviewStepCodes.some((step) => !checkResults[cklstKey(row, step)]),
|
||||
);
|
||||
if (missingRow) {
|
||||
const missingStep = visibleReviewStepCodes.find((step) => !checkResults[cklstKey(missingRow, step)])!;
|
||||
const missingStep = visibleReviewStepCodes.find(
|
||||
(step) => !checkResults[cklstKey(missingRow, step)],
|
||||
)!;
|
||||
const invalidKey = cklstKey(missingRow, missingStep);
|
||||
console.warn("검토 결과 미선택 항목 발견:", invalidKey, missingRow, missingStep);
|
||||
|
||||
const invalidRowEl = cklstRowRefs.current[invalidKey];
|
||||
if (invalidRowEl) {
|
||||
invalidRowEl.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const firstRadio = invalidRowEl.querySelector<HTMLInputElement>(`input[name="chk_${invalidKey}"]`);
|
||||
const firstRadio = invalidRowEl.querySelector<HTMLInputElement>(
|
||||
`input[name="chk_${invalidKey}"]`,
|
||||
);
|
||||
firstRadio?.focus();
|
||||
} else {
|
||||
// row not visible: switch header filter to the missing row's task code,
|
||||
@@ -1125,18 +1132,14 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const toInstlSncnMngPayload = (prgrsSttsComCd: "SV" | "ST"| "ER"): InstlSncnMng => ({
|
||||
const toInstlSncnMngPayload = (
|
||||
prgrsSttsComCd: "SV" | "ST" | "ER",
|
||||
): InstlSncnMngSaveRequestDto => ({
|
||||
instlSncnDocNo: docId ?? "",
|
||||
admdstCd: "",
|
||||
sidoNm: "",
|
||||
sggNm: "",
|
||||
chrgrNm: "",
|
||||
instNm: "",
|
||||
envoSeComCd: currentDet?.envoSeComCd ?? "",
|
||||
sncnSeComCd: sncnSeComCd || DEFAULT_SNCN_SE_COM_CD,
|
||||
envoSeCdNm: "",
|
||||
taskSeComCd,
|
||||
taskSeCdNm: taskSeComCdNm,
|
||||
bgtBizCd: "",
|
||||
bgtAplyYr: "",
|
||||
bizNm: bzNm,
|
||||
@@ -1153,12 +1156,6 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
dsgnrEmlAddr,
|
||||
atchFileGroupId: etcFileGroupId,
|
||||
prgrsSttsComCd,
|
||||
prgrsSttsCdNm: "",
|
||||
regDt: "",
|
||||
mdfcnDt: "",
|
||||
rgtrNm: "",
|
||||
gugun: "",
|
||||
sido: ""
|
||||
});
|
||||
|
||||
async function handleSave() {
|
||||
@@ -1188,15 +1185,15 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
async function handleSubmit() {
|
||||
if (!fnValidation("SAVE")) return;
|
||||
|
||||
const payload: InstlSncnApplySaveRequestDto = {
|
||||
const payload: InstlSncnApplySaveRequestDto = {
|
||||
instlSncnMng: toInstlSncnMngPayload("SV"), // 저장 시 진행상태는 SV (임시저장)
|
||||
bzScaleList: toBzScaleSaveDtoList(),
|
||||
bizCstList: toBizCstSaveDtoList(),
|
||||
cklstResultList: toCklstSaveDtoList(),
|
||||
esntlAtchFileList: toEsntlAtchFileSaveDtoList(),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
try {
|
||||
const issuedDocNo = await InstlSncnApi.saveApply(payload);
|
||||
alert(`제출 되었습니다. 문서번호: ${issuedDocNo}`);
|
||||
} catch (error) {
|
||||
@@ -1264,7 +1261,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
<button className="cnfm-btn-search whitespace-nowrap">🔍 사업찾기</button>
|
||||
</div>
|
||||
</td>
|
||||
<th style={{ textAlign: "center" }}>
|
||||
<th style={{ textAlign: "center" }}>
|
||||
<span className="text-red-500">*</span> 사업기간
|
||||
</th>
|
||||
<td>
|
||||
@@ -1286,7 +1283,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style={{ textAlign: "center" }}>
|
||||
<th style={{ textAlign: "center" }}>
|
||||
<span className="text-red-500">*</span> 사업내용
|
||||
</th>
|
||||
<td colSpan={3}>
|
||||
@@ -1299,22 +1296,24 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style={{ textAlign: "center" }}>구분</th>
|
||||
<th style={{ textAlign: "center" }}>구분</th>
|
||||
<td>
|
||||
<div className="flex gap-4 text-[1.3rem]">{taskSeComCdNm}</div>
|
||||
<input type="hidden" name="taskSeComCd" value={taskSeComCd} />
|
||||
</td>
|
||||
<th style={{ textAlign: "center" }}>신청 지자체</th>
|
||||
<th style={{ textAlign: "center" }}>신청 지자체</th>
|
||||
<td>
|
||||
<span className="inline-block min-w-[16rem] whitespace-nowrap text-[1.3rem] text-gray-500">
|
||||
{isModifyMode
|
||||
? `${currentDet?.sido ?? "-"} ${currentDet?.gugun ?? "-"}`
|
||||
: userInfo?.laeSeNm ?? "\u00A0"}
|
||||
: (userInfo?.laeSeNm ?? "\u00A0")}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style={{ textAlign: "center" }}><span className="text-red-500">*</span>사업구분</th>
|
||||
<th style={{ textAlign: "center" }}>
|
||||
<span className="text-red-500">*</span>사업구분
|
||||
</th>
|
||||
<td colSpan={3}>
|
||||
<div className="flex gap-4 text-[1.3rem]">
|
||||
{[
|
||||
@@ -1346,31 +1345,47 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
<table className="cnfm-form-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th rowSpan={2} style={{ textAlign: "center" }} className="text-center align-middle w-[6.5rem]">신청자</th>
|
||||
<th style={{ textAlign: "center" }}>이름</th>
|
||||
<th
|
||||
rowSpan={2}
|
||||
style={{ textAlign: "center" }}
|
||||
className="w-[6.5rem] text-center align-middle"
|
||||
>
|
||||
신청자
|
||||
</th>
|
||||
<th style={{ textAlign: "center" }}>이름</th>
|
||||
<td>
|
||||
<span className="inline-block min-w-[16rem] whitespace-nowrap text-[1.3rem] text-gray-500">{aplcntNm || userInfo?.userName || "\u00A0"}</span>
|
||||
<span className="inline-block min-w-[16rem] whitespace-nowrap text-[1.3rem] text-gray-500">
|
||||
{aplcntNm || userInfo?.userName || "\u00A0"}
|
||||
</span>
|
||||
</td>
|
||||
<th style={{ textAlign: "center" }}>전화번호</th>
|
||||
<th style={{ textAlign: "center" }}>전화번호</th>
|
||||
<td>
|
||||
<span className="inline-block min-w-[16rem] whitespace-nowrap text-[1.3rem] text-gray-500">{aplcntMblTelno || userInfo?.mphn || userInfo?.telno || "\u00A0"}</span>
|
||||
<span className="inline-block min-w-[16rem] whitespace-nowrap text-[1.3rem] text-gray-500">
|
||||
{aplcntMblTelno || userInfo?.mphn || userInfo?.telno || "\u00A0"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style={{ textAlign: "center" }}>부서</th>
|
||||
<th style={{ textAlign: "center" }}>부서</th>
|
||||
<td>
|
||||
<span className="text-[1.3rem] text-gray-500">{aplcntOgdpNm || userInfo?.instSeNm || "\u00A0"}</span>
|
||||
<span className="text-[1.3rem] text-gray-500">
|
||||
{aplcntOgdpNm || userInfo?.instSeNm || "\u00A0"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<th style={{ textAlign: "center" }}>이메일</th>
|
||||
|
||||
<th style={{ textAlign: "center" }}>이메일</th>
|
||||
<td>
|
||||
<span className="text-[1.3rem] text-gray-500">{aplcntEmlAddr || userInfo?.email || "\u00A0"}</span>
|
||||
<span className="text-[1.3rem] text-gray-500">
|
||||
{aplcntEmlAddr || userInfo?.email || "\u00A0"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th rowSpan={2} style={{ textAlign: "center" }}>설계자</th>
|
||||
<th style={{ textAlign: "center" }}>이름</th>
|
||||
<th rowSpan={2} style={{ textAlign: "center" }}>
|
||||
설계자
|
||||
</th>
|
||||
<th style={{ textAlign: "center" }}>이름</th>
|
||||
<td>
|
||||
<Input
|
||||
placeholder="설계자 성명"
|
||||
@@ -1379,7 +1394,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
onChange={(e) => setDsgnrNm(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<th style={{ textAlign: "center" }}>전화번호</th>
|
||||
<th style={{ textAlign: "center" }}>전화번호</th>
|
||||
<td>
|
||||
<Input
|
||||
placeholder="설계자 전화번호"
|
||||
@@ -1399,8 +1414,8 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
onChange={(e) => setDsgnrPsitn(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<th style={{ textAlign: "center" }}>이메일</th>
|
||||
|
||||
<th style={{ textAlign: "center" }}>이메일</th>
|
||||
<td>
|
||||
<Input
|
||||
placeholder="설계자 이메일"
|
||||
@@ -1418,29 +1433,34 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
<PrivacyConsent value={privacyAgree} onChange={setPrivacyAgree}>
|
||||
<p className="mb-1 font-semibold">[ 개인정보 수집항목 ]</p>
|
||||
<div>
|
||||
* 필수항목 : 소속기관, 소속부서, 담당자, 직급, 사무실 번호, FAX 번호<br />
|
||||
․ 선택항목 : 휴대폰 번호, 이메일 주소<br />
|
||||
․ 기타항목 : IP Address, 쿠키, 시스템 접속 및 이용 기록<br />
|
||||
※ 시스템 이용과정에서 기타 항목의 정보들이 자동으로 생성되어 수집될 수 있습니다.
|
||||
* 필수항목 : 소속기관, 소속부서, 담당자, 직급, 사무실 번호, FAX 번호
|
||||
<br />
|
||||
․ 선택항목 : 휴대폰 번호, 이메일 주소
|
||||
<br />
|
||||
․ 기타항목 : IP Address, 쿠키, 시스템 접속 및 이용 기록
|
||||
<br />※ 시스템 이용과정에서 기타 항목의 정보들이 자동으로 생성되어 수집될 수 있습니다.
|
||||
</div>
|
||||
|
||||
<p className="mt-4 font-semibold">[ 개인정보 이용목적 ]</p>
|
||||
<div>
|
||||
․ 국가하수도정보시스템 웹서비스 이용에 따른 본인 확인 절차에 이용<br />
|
||||
․ 국가하수도정보시스템 사용에 필요한 권한 부여<br />
|
||||
․ 사용자별 맞춤 컨텐츠 (처리시설의 운영 데이터 조회, 수질현황 데이터 조회, 예산 편성 / 예산편성 등) 제공에 이용
|
||||
․ 국가하수도정보시스템 웹서비스 이용에 따른 본인 확인 절차에 이용
|
||||
<br />
|
||||
․ 국가하수도정보시스템 사용에 필요한 권한 부여
|
||||
<br />․ 사용자별 맞춤 컨텐츠 (처리시설의 운영 데이터 조회, 수질현황 데이터 조회, 예산 편성
|
||||
/ 예산편성 등) 제공에 이용
|
||||
</div>
|
||||
|
||||
<div className="tit mt-4">● 개인정보의 보유 및 이용기간</div>
|
||||
<div>
|
||||
․ 수집된 고객의 개인정보는 2년 동안 보유하며 그 이후에는 문서를 파쇄합니다.<br />
|
||||
․ 정보제공자가 개인정보 파쇄를 요청할 경우 즉시 파쇄합니다.
|
||||
․ 수집된 고객의 개인정보는 2년 동안 보유하며 그 이후에는 문서를 파쇄합니다.
|
||||
<br />․ 정보제공자가 개인정보 파쇄를 요청할 경우 즉시 파쇄합니다.
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<div className="tit">● 개인정보 제공 거부에 따른 제한사항</div>
|
||||
<div>
|
||||
․ 귀하는 개인정보 제공 동의를 거부할 권리가 있으나, 동의 거부 시 국가하수도정보시스템 이용 서비스에 제한이 있을 수 있습니다.
|
||||
․ 귀하는 개인정보 제공 동의를 거부할 권리가 있으나, 동의 거부 시 국가하수도정보시스템 이용
|
||||
서비스에 제한이 있을 수 있습니다.
|
||||
</div>
|
||||
</PrivacyConsent>
|
||||
|
||||
@@ -1613,25 +1633,27 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
<div className="cnfm-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
|
||||
<div className="flex gap-2 items-center">
|
||||
{Array.from(new Set((cklstTree ?? []).map((r) => (r as any).taskSeComCd).filter(Boolean))).map(
|
||||
(code) => {
|
||||
const name = TASK_CODE_LABELS[code] ?? code;
|
||||
const active = selectedTaskCode === code;
|
||||
return (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${
|
||||
active ? "border-blue-500 bg-blue-50 text-blue-700" : "border-gray-300 bg-white text-gray-600"
|
||||
}`}
|
||||
onClick={() => setSelectedTaskCode(code)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{Array.from(
|
||||
new Set((cklstTree ?? []).map((r) => (r as any).taskSeComCd).filter(Boolean)),
|
||||
).map((code) => {
|
||||
const name = TASK_CODE_LABELS[code] ?? code;
|
||||
const active = selectedTaskCode === code;
|
||||
return (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${
|
||||
active
|
||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||
: "border-gray-300 bg-white text-gray-600"
|
||||
}`}
|
||||
onClick={() => setSelectedTaskCode(code)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="text-[1.1rem] text-gray-500">{`(${cklstStats.completed}/${cklstStats.total})`}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1663,8 +1685,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-2 text-[1.25rem] text-gray-500">
|
||||
체크리스트 진행상황 [{selectedReviewStep}] — 총합: <strong>{cklstStats.total}</strong> · 완료: {" "}
|
||||
<strong>{cklstStats.completed}</strong> · 진행률: <strong>{cklstStats.percent}%</strong>
|
||||
체크리스트 진행상황 [{selectedReviewStep}] — 총합: <strong>{cklstStats.total}</strong> ·
|
||||
완료: <strong>{cklstStats.completed}</strong> · 진행률:{" "}
|
||||
<strong>{cklstStats.percent}%</strong>
|
||||
</div>
|
||||
<div className="cnfm-table-wrap">
|
||||
<table className="cnfm-table">
|
||||
@@ -1729,7 +1752,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
cklstRows.map((row) => {
|
||||
const key = cklstKey(row, selectedReviewStep);
|
||||
const rvwPaddingLeft = `${Math.max(0, row.treeLevel - 1) * 16 + 8}px`;
|
||||
//bchckUseYn 의 값이 "Y"인 항목은 라디오 박스가 보이고 없으면 안보이게 처리
|
||||
//bchckUseYn 의 값이 "Y"인 항목은 라디오 박스가 보이고 없으면 안보이게 처리
|
||||
const showRadios = row.bchckUseYn === "Y";
|
||||
return (
|
||||
<tr
|
||||
@@ -1739,7 +1762,10 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
}}
|
||||
>
|
||||
<td className="text-center text-[1.2rem]">{row.cklstArtclNm}</td>
|
||||
<td className="!text-left pl-2 text-[1.2rem] text-gray-600" style={{ paddingLeft: rvwPaddingLeft }}>
|
||||
<td
|
||||
className="pl-2 !text-left text-[1.2rem] text-gray-600"
|
||||
style={{ paddingLeft: rvwPaddingLeft }}
|
||||
>
|
||||
{row.treeLevel > 1 ? "└ " : ""}
|
||||
{row.rvwCn}
|
||||
</td>
|
||||
@@ -1758,7 +1784,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
value="Y"
|
||||
checked={checkResults[key] === "Y"}
|
||||
disabled={editableReviewStep !== selectedReviewStep}
|
||||
onChange={() => setCheckResults((prev) => ({ ...prev, [key]: "Y" }))}
|
||||
onChange={() =>
|
||||
setCheckResults((prev) => ({ ...prev, [key]: "Y" }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
@@ -1770,14 +1798,16 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
value="N"
|
||||
checked={checkResults[key] === "N"}
|
||||
disabled={editableReviewStep !== selectedReviewStep}
|
||||
onChange={() => setCheckResults((prev) => ({ ...prev, [key]: "N" }))}
|
||||
onChange={() =>
|
||||
setCheckResults((prev) => ({ ...prev, [key]: "N" }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showRadios && selectedReviewStep !== "LOCAL" ? (
|
||||
|
||||
{showRadios && selectedReviewStep !== "LOCAL" ? (
|
||||
<td className="text-left">
|
||||
<textarea
|
||||
className="cnfm-input w-full"
|
||||
@@ -1828,7 +1858,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
<td className="pl-2">
|
||||
<div className="w-full text-left">
|
||||
{item.esntlAtchFileNm}
|
||||
{item.esntlAtchYn === "Y" ? <span className="ml-1 text-red-500">*</span> : null}
|
||||
{item.esntlAtchYn === "Y" ? (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>{fileFormat}</td>
|
||||
@@ -1838,7 +1870,11 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
|
||||
type="text"
|
||||
placeholder="첨부문서를 올려주세요"
|
||||
className="cnfm-input flex-1"
|
||||
value={requiredFile?.uploading ? "업로드 중..." : requiredFile?.fileName || ""}
|
||||
value={
|
||||
requiredFile?.uploading
|
||||
? "업로드 중..."
|
||||
: requiredFile?.fileName || ""
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
+174
-79
@@ -67,9 +67,7 @@ const TASK_CODE_LABELS: Record<string, string> = {
|
||||
SF: "하수처리시설",
|
||||
};
|
||||
|
||||
const normalizeChecklistResultMap = (
|
||||
raw: unknown,
|
||||
): Record<ReviewStepCode, any[]> => {
|
||||
const normalizeChecklistResultMap = (raw: unknown): Record<ReviewStepCode, any[]> => {
|
||||
const base: Record<ReviewStepCode, any[]> = { LOCAL: [], ENV: [], KECO: [] };
|
||||
if (!raw) return base;
|
||||
|
||||
@@ -138,20 +136,28 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800", icon: "✔️" },
|
||||
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800", icon: "✖️" },
|
||||
};
|
||||
const st = statusMap[status] ?? { label: status || "", badgeClass: "bg-gray-100 text-gray-700", icon: "" };
|
||||
const st = statusMap[status] ?? {
|
||||
label: status || "",
|
||||
badgeClass: "bg-gray-100 text-gray-700",
|
||||
icon: "",
|
||||
};
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="font-semibold">{row.aprvrUuidNm ?? "-"}</div>
|
||||
<div className="text-[0.95rem] text-gray-500">
|
||||
{row.ognzNm ? `${row.ognzNm}` : ""}
|
||||
{row.jbpsSeNm || row.jbgdSeNm ? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}` : ""}
|
||||
{row.jbpsSeNm || row.jbgdSeNm
|
||||
? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}`
|
||||
: ""}
|
||||
</div>
|
||||
<div className="text-[0.9rem] mt-1 flex items-center justify-center gap-2">
|
||||
{row.atrzDt ? <span className="text-gray-400">{dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}</span> : null}
|
||||
<div className="mt-1 flex items-center justify-center gap-2 text-[0.9rem]">
|
||||
{row.atrzDt ? (
|
||||
<span className="text-gray-400">{dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}</span>
|
||||
) : null}
|
||||
{st.label ? (
|
||||
<span
|
||||
title={`${row.atrzPrgrsSttsComNm ?? st.label}${row.rjctRsn ? ` — 사유: ${row.rjctRsn}` : ""}${row.atrzDt ? ` · ${dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}` : ""}`}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold ml-1 ${st.badgeClass}`}
|
||||
className={`ml-1 inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${st.badgeClass}`}
|
||||
>
|
||||
<span className="mr-1">{st.icon}</span>
|
||||
<span>{st.label}</span>
|
||||
@@ -184,7 +190,10 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("결재 처리 실패", err);
|
||||
const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : "결재 처리 중 오류가 발생했습니다.";
|
||||
const msg =
|
||||
axios.isAxiosError(err) && err.response?.data?.message
|
||||
? err.response.data.message
|
||||
: "결재 처리 중 오류가 발생했습니다.";
|
||||
alert(msg);
|
||||
}
|
||||
};
|
||||
@@ -193,9 +202,9 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
try {
|
||||
const payload = {
|
||||
elaprDocNo: det.instlSncnDocNo,
|
||||
elaprTmpltId: elaprTmpltId, /* 팝업에서 선택된 전자결제 템플릿 */
|
||||
dmndTtl: det.bizNm, /* "구례 하수도 처리시설 2026", */
|
||||
dmndCn: det.bizCn /*"구례 발주 하수도 처리시설 사업 2026년도 진행분 결제 요청 입니다."*/
|
||||
elaprTmpltId: elaprTmpltId /* 팝업에서 선택된 전자결제 템플릿 */,
|
||||
dmndTtl: det.bizNm /* "구례 하수도 처리시설 2026", */,
|
||||
dmndCn: det.bizCn /*"구례 발주 하수도 처리시설 사업 2026년도 진행분 결제 요청 입니다."*/,
|
||||
};
|
||||
// ElaprDmndApi 서비스를 통해 백엔드 연동
|
||||
await ElaprDmndApi.create(payload);
|
||||
@@ -207,7 +216,10 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("결재자 지정 실패", err);
|
||||
const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : "결재자 지정 중 오류가 발생했습니다.";
|
||||
const msg =
|
||||
axios.isAxiosError(err) && err.response?.data?.message
|
||||
? err.response.data.message
|
||||
: "결재자 지정 중 오류가 발생했습니다.";
|
||||
alert(msg);
|
||||
}
|
||||
};
|
||||
@@ -221,21 +233,29 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800", icon: "✔️" },
|
||||
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800", icon: "✖️" },
|
||||
};
|
||||
const st = statusMap[status] ?? { label: status || "", badgeClass: "bg-gray-100 text-gray-700", icon: "" };
|
||||
const st = statusMap[status] ?? {
|
||||
label: status || "",
|
||||
badgeClass: "bg-gray-100 text-gray-700",
|
||||
icon: "",
|
||||
};
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="font-semibold">{row.aprvrUuidNm ?? "-"}</div>
|
||||
<div className="text-[0.95rem] text-gray-500">
|
||||
{row.ognzNm ? `${row.ognzNm}` : ""}
|
||||
{row.jbpsSeNm || row.jbgdSeNm ? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}` : ""}
|
||||
{row.jbpsSeNm || row.jbgdSeNm
|
||||
? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}`
|
||||
: ""}
|
||||
</div>
|
||||
<div className="text-[0.9rem] mt-1 flex items-center justify-center gap-2">
|
||||
{row.atrzDt ? <span className="text-gray-400">{dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}</span> : null}
|
||||
<div className="mt-1 flex items-center justify-center gap-2 text-[0.9rem]">
|
||||
{row.atrzDt ? (
|
||||
<span className="text-gray-400">{dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}</span>
|
||||
) : null}
|
||||
{st.label ? (
|
||||
<>
|
||||
<span
|
||||
title={`${row.atrzPrgrsSttsComNm ?? st.label}${row.rjctRsn ? ` — 사유: ${row.rjctRsn}` : ""}${row.atrzDt ? ` · ${dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}` : ""}`}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold ml-1 ${st.badgeClass}`}
|
||||
className={`ml-1 inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${st.badgeClass}`}
|
||||
>
|
||||
<span className="mr-1">{st.icon}</span>
|
||||
<span>{st.label}</span>
|
||||
@@ -244,7 +264,7 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApproveClick(row.elaprPrgrsId, row.atrzSeComCd)}
|
||||
className="ml-2 px-2 py-1 bg-blue-500 text-white text-xs rounded"
|
||||
className="ml-2 rounded bg-blue-500 px-2 py-1 text-xs text-white"
|
||||
>
|
||||
결제
|
||||
</button>
|
||||
@@ -269,7 +289,9 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
|
||||
// derive available task codes from checklist results
|
||||
const taskCodes = useMemo(() => {
|
||||
return Array.from(new Set((cklstAll ?? []).map((r: any) => (r as any).taskSeComCd).filter(Boolean)));
|
||||
return Array.from(
|
||||
new Set((cklstAll ?? []).map((r: any) => (r as any).taskSeComCd).filter(Boolean)),
|
||||
);
|
||||
}, [cklstAll]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -297,8 +319,11 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
);
|
||||
return rows
|
||||
.slice()
|
||||
.sort((a: any, b: any) =>
|
||||
(a.cklstArtclSn ?? 0) - (b.cklstArtclSn ?? 0) || (a.sortSeq ?? 0) - (b.sortSeq ?? 0) || (a.cklstRvwSn ?? 0) - (b.cklstRvwSn ?? 0),
|
||||
.sort(
|
||||
(a: any, b: any) =>
|
||||
(a.cklstArtclSn ?? 0) - (b.cklstArtclSn ?? 0) ||
|
||||
(a.sortSeq ?? 0) - (b.sortSeq ?? 0) ||
|
||||
(a.cklstRvwSn ?? 0) - (b.cklstRvwSn ?? 0),
|
||||
);
|
||||
}, [cklstResultMap, selectedReviewStep, visibleReviewStepCodes.length, selectedTaskCode]);
|
||||
|
||||
@@ -307,11 +332,15 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
|
||||
const cklstStats = useMemo(() => {
|
||||
if (!selectedReviewStep) return { total: 0, completed: 0, percent: 0 };
|
||||
const rows = (cklstResultMap[selectedReviewStep] ?? []).filter((r: any) => r.bchckUseYn === "Y" && (selectedTaskCode ? (r.taskSeComCd ?? "") === selectedTaskCode : true));
|
||||
const rows = (cklstResultMap[selectedReviewStep] ?? []).filter(
|
||||
(r: any) =>
|
||||
r.bchckUseYn === "Y" &&
|
||||
(selectedTaskCode ? (r.taskSeComCd ?? "") === selectedTaskCode : true),
|
||||
);
|
||||
const total = rows.length;
|
||||
const completed = rows.reduce((acc: number, row: any) => {
|
||||
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${selectedReviewStep}`;
|
||||
const val = (viewCheckResults[key] ?? row.rvwRsltAnmlyYn) ?? "";
|
||||
const val = viewCheckResults[key] ?? row.rvwRsltAnmlyYn ?? "";
|
||||
return acc + (val ? 1 : 0);
|
||||
}, 0);
|
||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
@@ -330,8 +359,8 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
.filter((r: any) => r.bchckUseYn === "Y")
|
||||
.map((row: any) => {
|
||||
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${selectedReviewStep}`;
|
||||
const rvwRsltAnmlyYn = (viewCheckResults[key] ?? row.rvwRsltAnmlyYn) ?? null;
|
||||
const picRvwCn = (viewCheckComments[key] ?? row.picRvwCn) ?? null;
|
||||
const rvwRsltAnmlyYn = viewCheckResults[key] ?? row.rvwRsltAnmlyYn ?? null;
|
||||
const picRvwCn = viewCheckComments[key] ?? row.picRvwCn ?? null;
|
||||
return {
|
||||
taskSeComCd: row.taskSeComCd ?? "",
|
||||
cklstSn: row.cklstSn,
|
||||
@@ -353,7 +382,7 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
} as unknown as InstlSncnApplySaveRequestDto;
|
||||
|
||||
try {
|
||||
const issuedDocNo = await InstlSncnApi.techReview(payload);
|
||||
const issuedDocNo = await InstlSncnApi.techReview(det.instlSncnDocNo, payload);
|
||||
alert(`기술검토 등록이 완료되었습니다. 문서번호: ${issuedDocNo}`);
|
||||
} catch (error) {
|
||||
console.error("기술검토 등록 API 호출 실패", error);
|
||||
@@ -370,7 +399,8 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
const comments: Record<string, string> = {};
|
||||
(selectedChecklistRows ?? []).forEach((row: any) => {
|
||||
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
|
||||
if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") results[key] = row.rvwRsltAnmlyYn;
|
||||
if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N")
|
||||
results[key] = row.rvwRsltAnmlyYn;
|
||||
if (row.picRvwCn) comments[key] = String(row.picRvwCn);
|
||||
});
|
||||
setViewCheckResults(results);
|
||||
@@ -440,10 +470,7 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
</table>
|
||||
{/** 결제자 지정 버튼 */}
|
||||
<div className="mt-4 text-right">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleAssignApproverClick("TMPLT20260611000001")}
|
||||
>
|
||||
<Button type="button" onClick={() => handleAssignApproverClick("TMPLT20260611000001")}>
|
||||
결재자 지정
|
||||
</Button>
|
||||
</div>
|
||||
@@ -452,7 +479,9 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
{/* )} */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h2 className="cnfm-section-title">설치인가 상세보기</h2>
|
||||
<p className="mt-2 text-[1.3rem] text-gray-600">문서번호: <strong>{det.instlSncnDocNo}</strong></p>
|
||||
<p className="mt-2 text-[1.3rem] text-gray-600">
|
||||
문서번호: <strong>{det.instlSncnDocNo}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="cnfm-card p-4">
|
||||
@@ -473,7 +502,9 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<th>구분</th>
|
||||
<td>{det.taskSeCdNm || det.taskSeComCd || "-"}</td>
|
||||
<th>신청지자체</th>
|
||||
<td>{det.sido || "-"} {det.gugun || "-"}</td>
|
||||
<td>
|
||||
{det.sido || "-"} {det.gugun || "-"}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업구분</th>
|
||||
@@ -485,7 +516,6 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<th>예산사업코드</th>
|
||||
<td>{det.bgtBizCd || "-"}</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -559,7 +589,9 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<tbody>
|
||||
{bizSclList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="cnfm-td-empty">조회된 사업규모 항목이 없습니다.</td>
|
||||
<td colSpan={5} className="cnfm-td-empty">
|
||||
조회된 사업규모 항목이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
bizSclList.map((node: any) => {
|
||||
@@ -569,12 +601,28 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
const tstmDesignQty = Number(node.tstmDesignQty) || 0;
|
||||
const diff = tstmDesignQty - budgetReflct;
|
||||
return (
|
||||
<tr key={node.cnstpSeCd} className={node.treeLevel === 1 ? "bg-gray-50 font-semibold" : ""}>
|
||||
<td className="bg-gray-50 pl-2 text-left" style={{ paddingLeft: `${pl + 8}px` }}>{node.cnstpSeCdNm}</td>
|
||||
<td><div className="text-right">{fmt(bscPlanQty)}</div></td>
|
||||
<td><div className="text-right">{fmt(budgetReflct)}</div></td>
|
||||
<td><div className="text-right">{fmt(tstmDesignQty)}</div></td>
|
||||
<td><div className="text-right">{diff > 0 ? `+${fmt(diff)}` : fmt(diff)}</div></td>
|
||||
<tr
|
||||
key={node.cnstpSeCd}
|
||||
className={node.treeLevel === 1 ? "bg-gray-50 font-semibold" : ""}
|
||||
>
|
||||
<td
|
||||
className="bg-gray-50 pl-2 text-left"
|
||||
style={{ paddingLeft: `${pl + 8}px` }}
|
||||
>
|
||||
{node.cnstpSeCdNm}
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-right">{fmt(bscPlanQty)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-right">{fmt(budgetReflct)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-right">{fmt(tstmDesignQty)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-right">{diff > 0 ? `+${fmt(diff)}` : fmt(diff)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
@@ -586,7 +634,9 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
|
||||
{/* 총사업비 */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">총사업비 <span className="text-[1.2rem] font-normal text-gray-400">(단위: 백만원)</span></h3>
|
||||
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">
|
||||
총사업비 <span className="text-[1.2rem] font-normal text-gray-400">(단위: 백만원)</span>
|
||||
</h3>
|
||||
<div className="cnfm-table-wrap">
|
||||
<table className="cnfm-table w-full table-fixed">
|
||||
<thead>
|
||||
@@ -606,7 +656,6 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{bizCstList.map((node: any) => {
|
||||
|
||||
const pl = indentPx(node.indAcntgSeCdNm);
|
||||
const bscPlanAmt = Number(node.bscPlanAmt) || 0;
|
||||
const budgetReflct = Number(node.bgtRfltAmt) || 0;
|
||||
@@ -626,7 +675,9 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
}
|
||||
>
|
||||
{node.treeLevel <= 2 ? (
|
||||
<td colSpan={2} className="pl-2 text-left">{node.acntgSeCdNm}</td>
|
||||
<td colSpan={2} className="pl-2 text-left">
|
||||
{node.acntgSeCdNm}
|
||||
</td>
|
||||
) : (
|
||||
<>
|
||||
<td className="w-[4rem]"></td>
|
||||
@@ -653,7 +704,9 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<div className="text-right">{diff > 0 ? `+${fmt(diff)}` : fmt(diff)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-right">{budgetReflct !== 0 ? `${Math.round((diff / budgetReflct) * 100)}%` : "-"}</div>
|
||||
<div className="text-right">
|
||||
{budgetReflct !== 0 ? `${Math.round((diff / budgetReflct) * 100)}%` : "-"}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -668,7 +721,7 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{Array.from(new Set(taskCodes)).map((code) => {
|
||||
const name = TASK_CODE_LABELS[code] ?? code;
|
||||
const active = selectedTaskCode === code;
|
||||
@@ -676,8 +729,11 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${active ? "border-blue-500 bg-blue-50 text-blue-700" : "border-gray-300 bg-white text-gray-600"
|
||||
}`}
|
||||
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${
|
||||
active
|
||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||
: "border-gray-300 bg-white text-gray-600"
|
||||
}`}
|
||||
onClick={() => setSelectedTaskCode(code)}
|
||||
>
|
||||
{name}
|
||||
@@ -698,10 +754,11 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<button
|
||||
key={stepCode}
|
||||
type="button"
|
||||
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${selectedReviewStep === stepCode
|
||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||
: "border-gray-300 bg-white text-gray-600"
|
||||
}`}
|
||||
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${
|
||||
selectedReviewStep === stepCode
|
||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||
: "border-gray-300 bg-white text-gray-600"
|
||||
}`}
|
||||
onClick={() => setSelectedReviewStep(stepCode)}
|
||||
>
|
||||
{stepCode}
|
||||
@@ -710,40 +767,40 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-2 text-[1.25rem] text-gray-500">
|
||||
체크리스트 진행상황 [{selectedReviewStep}] — 총합: <strong>{cklstStats.total}</strong> · 완료: <strong>{cklstStats.completed}</strong> · 진행률: <strong>{cklstStats.percent}%</strong>
|
||||
체크리스트 진행상황 [{selectedReviewStep}] — 총합: <strong>{cklstStats.total}</strong> ·
|
||||
완료: <strong>{cklstStats.completed}</strong> · 진행률:{" "}
|
||||
<strong>{cklstStats.percent}%</strong>
|
||||
</div>
|
||||
<div className="cnfm-table-wrap mt-3">
|
||||
<table className="cnfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-[20rem]" rowSpan={2}>검토항목</th>
|
||||
<th className="w-[20rem]" rowSpan={2}>
|
||||
검토항목
|
||||
</th>
|
||||
<th rowSpan={2}>주요 검토내용</th>
|
||||
<th colSpan={2}>검토 결과(✓)</th>
|
||||
{selectedReviewStep !== "LOCAL" ? (
|
||||
<th rowSpan={2} className="w-[20rem]">기술검토 의견</th>
|
||||
<th rowSpan={2} className="w-[20rem]">
|
||||
기술검토 의견
|
||||
</th>
|
||||
) : null}
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="w-[9rem]">
|
||||
<button
|
||||
type="button"
|
||||
>
|
||||
예
|
||||
</button>
|
||||
<button type="button">예</button>
|
||||
</th>
|
||||
<th className="w-[9rem]">
|
||||
<button
|
||||
type="button"
|
||||
>
|
||||
아니오
|
||||
</button>
|
||||
<button type="button">아니오</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedChecklistRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="cnfm-td-empty">조회된 체크리스트 항목이 없습니다.</td>
|
||||
<td colSpan={5} className="cnfm-td-empty">
|
||||
조회된 체크리스트 항목이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
selectedChecklistRows.map((row: any) => {
|
||||
@@ -754,7 +811,13 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td className="text-center text-[1.2rem]">{row.cklstArtclNm}</td>
|
||||
<td className="!text-left pl-2 text-[1.2rem] text-gray-600" style={{ paddingLeft: `${Math.max(0, row.treeLevel - 1) * 16 + 8}px` }}>{row.treeLevel > 1 ? "└ " : ""}{row.rvwCn}</td>
|
||||
<td
|
||||
className="pl-2 !text-left text-[1.2rem] text-gray-600"
|
||||
style={{ paddingLeft: `${Math.max(0, row.treeLevel - 1) * 16 + 8}px` }}
|
||||
>
|
||||
{row.treeLevel > 1 ? "└ " : ""}
|
||||
{row.rvwCn}
|
||||
</td>
|
||||
{!showRadios ? (
|
||||
<>
|
||||
<td className="text-center">-</td>
|
||||
@@ -769,7 +832,9 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
value="Y"
|
||||
checked={(viewCheckResults[key] ?? "") === "Y"}
|
||||
disabled={selectedReviewStep === "LOCAL"}
|
||||
onChange={() => setViewCheckResults((prev) => ({ ...prev, [key]: "Y" }))}
|
||||
onChange={() =>
|
||||
setViewCheckResults((prev) => ({ ...prev, [key]: "Y" }))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
@@ -779,7 +844,9 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
value="N"
|
||||
checked={(viewCheckResults[key] ?? "") === "N"}
|
||||
disabled={selectedReviewStep === "LOCAL"}
|
||||
onChange={() => setViewCheckResults((prev) => ({ ...prev, [key]: "N" }))}
|
||||
onChange={() =>
|
||||
setViewCheckResults((prev) => ({ ...prev, [key]: "N" }))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
@@ -789,7 +856,9 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<textarea
|
||||
className="cnfm-input w-full"
|
||||
value={viewCheckComments[key] ?? ""}
|
||||
onChange={(e) => setViewCheckComments((prev) => ({ ...prev, [key]: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setViewCheckComments((prev) => ({ ...prev, [key]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
) : null}
|
||||
@@ -817,7 +886,9 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<tbody>
|
||||
{esntlFileList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="cnfm-td-empty">조회된 필수 첨부파일 항목이 없습니다.</td>
|
||||
<td colSpan={3} className="cnfm-td-empty">
|
||||
조회된 필수 첨부파일 항목이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
esntlFileList.map((f: any) => (
|
||||
@@ -825,17 +896,28 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<td className="pl-2">
|
||||
<div className="w-full text-left">
|
||||
{f.esntlAtchFileNm}
|
||||
{f.esntlAtchYn === "Y" ? <span className="ml-1 text-red-500">*</span> : null}
|
||||
{f.esntlAtchYn === "Y" ? (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{f.atflCd ? (
|
||||
<a className="text-blue-600" href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`} target="_blank" rel="noreferrer">{f.orgnlFileNm || "다운로드"}</a>
|
||||
<a
|
||||
className="text-blue-600"
|
||||
href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{f.orgnlFileNm || "다운로드"}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-500">첨부없음</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="w-[8rem] text-right"><div className="text-right">{formatFileSize(f.atflFlieSz)}</div></td>
|
||||
<td className="w-[8rem] text-right">
|
||||
<div className="text-right">{formatFileSize(f.atflFlieSz)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
@@ -858,19 +940,30 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<tbody>
|
||||
{etcFileList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={2} className="cnfm-td-empty">등록된 기타첨부파일이 없습니다.</td>
|
||||
<td colSpan={2} className="cnfm-td-empty">
|
||||
등록된 기타첨부파일이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
etcFileList.map((f: any) => (
|
||||
<tr key={f.atflId}>
|
||||
<td>
|
||||
{f.orgnlFileNm ? (
|
||||
<a className="text-blue-600" href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`} target="_blank" rel="noreferrer">{f.orgnlFileNm}</a>
|
||||
<a
|
||||
className="text-blue-600"
|
||||
href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{f.orgnlFileNm}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-700">{f.physFileNm || f.atflId}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="w-[8rem] text-right text-gray-500">{formatFileSize(f.atflFlieSz)}</td>
|
||||
<td className="w-[8rem] text-right text-gray-500">
|
||||
{formatFileSize(f.atflFlieSz)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
@@ -880,7 +973,9 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
</div>
|
||||
|
||||
<div className="cnfm-action-row">
|
||||
<Button color="white" onClick={() => router.push("/instl-sncn")}>목록</Button>
|
||||
<Button color="white" onClick={() => router.push("/instl-sncn")}>
|
||||
목록
|
||||
</Button>
|
||||
<Button onClick={() => router.push(`/instl-sncn/${id}/modify`)}>수정</Button>
|
||||
<Button onClick={() => void hadleTechReviewSubmit()}>기술검토등록</Button>
|
||||
</div>
|
||||
@@ -1,4 +1,5 @@
|
||||
export { CnfmListPage } from "./ui/cnfm-list-page";
|
||||
export { CnfmViewPage } from "./ui/cnfm-view-page";
|
||||
export { CnfmRegistPage } from "./ui/cnfm-regist-page";
|
||||
export { CnfmModifyPage } from "./ui/cnfm-modify-page";
|
||||
export { InstlEditPage } from "./ui/instl-edit-page";
|
||||
export { InstlFormPage } from "./ui/instl-form-page";
|
||||
export { InstlListPage } from "./ui/instl-list-page";
|
||||
export { InstlRegistPage } from "./ui/instl-regist-page";
|
||||
export { InstlViewPage } from "./ui/instl-view-page";
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ElaprDocPrgrsClientResponseDto } from "@/entities/instl-sncn";
|
||||
|
||||
export interface ApproverStatusConfig {
|
||||
label: string;
|
||||
badgeClass: string;
|
||||
}
|
||||
|
||||
export const APPROVER_STATUS_MAP: Record<string, ApproverStatusConfig> = {
|
||||
ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700" },
|
||||
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800" },
|
||||
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800" },
|
||||
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800" },
|
||||
};
|
||||
|
||||
export function sortByAtrzSeqNo(
|
||||
list: ElaprDocPrgrsClientResponseDto[],
|
||||
): ElaprDocPrgrsClientResponseDto[] {
|
||||
return list.slice().sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
|
||||
}
|
||||
|
||||
export function getApproverStatus(status: string | undefined): ApproverStatusConfig {
|
||||
return (
|
||||
APPROVER_STATUS_MAP[status ?? ""] ?? {
|
||||
label: status || "-",
|
||||
badgeClass: "bg-gray-100 text-gray-700",
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { InstlSncnBizCstSaveRequestDto } from "@/entities/instl-sncn";
|
||||
|
||||
export interface CostItemDef {
|
||||
code: string;
|
||||
readonly?: boolean;
|
||||
children?: string[];
|
||||
}
|
||||
|
||||
function sumChildAmounts(list: InstlSncnBizCstSaveRequestDto[], childCodes: string[]) {
|
||||
let bscPlanAmt = 0;
|
||||
let bgtRfltAmt = 0;
|
||||
let tstmDesignAmt = 0;
|
||||
|
||||
childCodes.forEach((childCode) => {
|
||||
const child = list.find((c) => c.acntgSeCd === childCode);
|
||||
if (child) {
|
||||
bscPlanAmt += child.bscPlanAmt || 0;
|
||||
bgtRfltAmt += child.bgtRfltAmt || 0;
|
||||
tstmDesignAmt += child.tstmDesignAmt || 0;
|
||||
}
|
||||
});
|
||||
|
||||
return { bscPlanAmt, bgtRfltAmt, tstmDesignAmt };
|
||||
}
|
||||
|
||||
export function recalculateBizCstTotals(
|
||||
list: InstlSncnBizCstSaveRequestDto[],
|
||||
items: CostItemDef[],
|
||||
): InstlSncnBizCstSaveRequestDto[] {
|
||||
const newList = [...list];
|
||||
let hasChanges = false;
|
||||
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const item = items[i];
|
||||
if (!item.readonly || !item.children) continue;
|
||||
|
||||
const idx = newList.findIndex((c) => c.acntgSeCd === item.code);
|
||||
if (idx < 0) continue;
|
||||
|
||||
const { bscPlanAmt, bgtRfltAmt, tstmDesignAmt } = sumChildAmounts(newList, item.children);
|
||||
const current = newList[idx];
|
||||
|
||||
if (
|
||||
(current.bscPlanAmt || 0) !== bscPlanAmt ||
|
||||
(current.bgtRfltAmt || 0) !== bgtRfltAmt ||
|
||||
(current.tstmDesignAmt || 0) !== tstmDesignAmt
|
||||
) {
|
||||
newList[idx] = { ...current, bscPlanAmt, bgtRfltAmt, tstmDesignAmt };
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasChanges ? newList : list;
|
||||
}
|
||||
|
||||
export function calculateSewerPipeCost(
|
||||
bizCstList: InstlSncnBizCstSaveRequestDto[],
|
||||
newPipeCode: string,
|
||||
replaceCode: string,
|
||||
targetCode: string,
|
||||
): InstlSncnBizCstSaveRequestDto {
|
||||
const newPipe = bizCstList.find((item) => item.acntgSeCd === newPipeCode);
|
||||
const replace = bizCstList.find((item) => item.acntgSeCd === replaceCode);
|
||||
|
||||
const bscPlanAmt = (newPipe?.bscPlanAmt || 0) + (replace?.bscPlanAmt || 0);
|
||||
const bgtRfltAmt = (newPipe?.bgtRfltAmt || 0) + (replace?.bgtRfltAmt || 0);
|
||||
const tstmDesignAmt = (newPipe?.tstmDesignAmt || 0) + (replace?.tstmDesignAmt || 0);
|
||||
|
||||
return {
|
||||
acntgSeCd: targetCode,
|
||||
bscPlanAmt,
|
||||
bgtRfltAmt,
|
||||
tstmDesignAmt,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateConstructionCost(
|
||||
bizCstList: InstlSncnBizCstSaveRequestDto[],
|
||||
codes: string[],
|
||||
targetCode: string,
|
||||
): InstlSncnBizCstSaveRequestDto {
|
||||
let bscPlanAmt = 0;
|
||||
let bgtRfltAmt = 0;
|
||||
let tstmDesignAmt = 0;
|
||||
|
||||
codes.forEach((code) => {
|
||||
const item = bizCstList.find((i) => i.acntgSeCd === code);
|
||||
if (item) {
|
||||
bscPlanAmt += item.bscPlanAmt || 0;
|
||||
bgtRfltAmt += item.bgtRfltAmt || 0;
|
||||
tstmDesignAmt += item.tstmDesignAmt || 0;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
acntgSeCd: targetCode,
|
||||
bscPlanAmt,
|
||||
bgtRfltAmt,
|
||||
tstmDesignAmt,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateIncidentalExpenses(
|
||||
bizCstList: InstlSncnBizCstSaveRequestDto[],
|
||||
codes: string[],
|
||||
targetCode: string,
|
||||
): InstlSncnBizCstSaveRequestDto {
|
||||
let bscPlanAmt = 0;
|
||||
let bgtRfltAmt = 0;
|
||||
let tstmDesignAmt = 0;
|
||||
|
||||
codes.forEach((code) => {
|
||||
const item = bizCstList.find((i) => i.acntgSeCd === code);
|
||||
if (item) {
|
||||
bscPlanAmt += item.bscPlanAmt || 0;
|
||||
bgtRfltAmt += item.bgtRfltAmt || 0;
|
||||
tstmDesignAmt += item.tstmDesignAmt || 0;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
acntgSeCd: targetCode,
|
||||
bscPlanAmt,
|
||||
bgtRfltAmt,
|
||||
tstmDesignAmt,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateTotalCost(
|
||||
bizCstList: InstlSncnBizCstSaveRequestDto[],
|
||||
codes: string[],
|
||||
targetCode: string,
|
||||
): InstlSncnBizCstSaveRequestDto {
|
||||
let bscPlanAmt = 0;
|
||||
let bgtRfltAmt = 0;
|
||||
let tstmDesignAmt = 0;
|
||||
|
||||
codes.forEach((code) => {
|
||||
const item = bizCstList.find((i) => i.acntgSeCd === code);
|
||||
if (item) {
|
||||
bscPlanAmt += item.bscPlanAmt || 0;
|
||||
bgtRfltAmt += item.bgtRfltAmt || 0;
|
||||
tstmDesignAmt += item.tstmDesignAmt || 0;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
acntgSeCd: targetCode,
|
||||
bscPlanAmt,
|
||||
bgtRfltAmt,
|
||||
tstmDesignAmt,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateDiffPercent(diff: number, baseline: number): string {
|
||||
if (baseline === 0) return "-";
|
||||
return `${Math.round((diff / baseline) * 100)}%`;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { BizCstTreeDto } from "@/entities/instl-sncn";
|
||||
|
||||
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,133 @@
|
||||
export const INSTL_SNCN_VIEW_BUTTON_ACTION = {
|
||||
strg: "strg",
|
||||
aply: "aply",
|
||||
del: "del",
|
||||
splmntCmptn: "splmntCmptn",
|
||||
instlSncnAncmnt: "instlSncnAncmnt",
|
||||
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 InstlSncnViewButtonAction =
|
||||
(typeof INSTL_SNCN_VIEW_BUTTON_ACTION)[keyof typeof INSTL_SNCN_VIEW_BUTTON_ACTION];
|
||||
|
||||
const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, InstlSncnViewButtonAction[]>> = {
|
||||
ROLE_USER05: {
|
||||
"": [
|
||||
INSTL_SNCN_VIEW_BUTTON_ACTION.strg,
|
||||
INSTL_SNCN_VIEW_BUTTON_ACTION.aply,
|
||||
INSTL_SNCN_VIEW_BUTTON_ACTION.del,
|
||||
],
|
||||
SV: [
|
||||
INSTL_SNCN_VIEW_BUTTON_ACTION.strg,
|
||||
INSTL_SNCN_VIEW_BUTTON_ACTION.aply,
|
||||
INSTL_SNCN_VIEW_BUTTON_ACTION.del,
|
||||
],
|
||||
SL: [INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn],
|
||||
TA: [INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt],
|
||||
},
|
||||
ROLE_USER04: {
|
||||
ST: [INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn, INSTL_SNCN_VIEW_BUTTON_ACTION.rjct],
|
||||
EP: [INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv],
|
||||
},
|
||||
ROLE_USER03: {
|
||||
LE: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply, INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw],
|
||||
TP: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply],
|
||||
},
|
||||
ROLE_USER01: {
|
||||
TE: [INSTL_SNCN_VIEW_BUTTON_ACTION.grndsChck],
|
||||
SL: [INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn],
|
||||
},
|
||||
};
|
||||
|
||||
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "EE"] as const;
|
||||
|
||||
const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
|
||||
string,
|
||||
Record<string, InstlSncnViewButtonAction[]>
|
||||
> = {
|
||||
ER: {
|
||||
ATRZ01: [INSTL_SNCN_VIEW_BUTTON_ACTION.dsgntRvwr],
|
||||
},
|
||||
EE: {
|
||||
ATRZ02: [
|
||||
INSTL_SNCN_VIEW_BUTTON_ACTION.rvwStrg,
|
||||
INSTL_SNCN_VIEW_BUTTON_ACTION.rvwCmptn,
|
||||
INSTL_SNCN_VIEW_BUTTON_ACTION.dmndSplmnt,
|
||||
],
|
||||
ATRZ03: [INSTL_SNCN_VIEW_BUTTON_ACTION.atrzAprv],
|
||||
ATRZ99: [INSTL_SNCN_VIEW_BUTTON_ACTION.atrzRply],
|
||||
},
|
||||
};
|
||||
|
||||
function resolveButtonConfigs(actions: InstlSncnViewButtonAction[]): InstlSncnViewButtonAction[] {
|
||||
return actions.map((action) => INSTL_SNCN_VIEW_BUTTON_ACTION[action]);
|
||||
}
|
||||
|
||||
function resolveRoleUser01AtrzButtonActions(
|
||||
prgrsSttsComCd: string,
|
||||
atrzSeComCdList: string[],
|
||||
): InstlSncnViewButtonAction[] {
|
||||
const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd];
|
||||
if (!atrzStatusMap || atrzSeComCdList.length === 0) return [];
|
||||
|
||||
const seen = new Set<InstlSncnViewButtonAction>();
|
||||
const actions: InstlSncnViewButtonAction[] = [];
|
||||
|
||||
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[],
|
||||
): InstlSncnViewButtonAction[] {
|
||||
if (!dtlInstCd) return [];
|
||||
|
||||
if (needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd)) {
|
||||
return resolveButtonConfigs(
|
||||
resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
const statusMap = ROLE_STATUS_BUTTON_ACTIONS[dtlInstCd];
|
||||
if (!statusMap) return [];
|
||||
|
||||
const actions = statusMap[prgrsSttsComCd];
|
||||
if (!actions) return [];
|
||||
|
||||
return resolveButtonConfigs(actions);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { CklstTreeNode } from "@/entities/cklst";
|
||||
|
||||
interface CklstResultListItem {
|
||||
cklstSn: number;
|
||||
cklstArtclSn: number;
|
||||
cklstRvwSn: number;
|
||||
rvwStpComCd: string;
|
||||
selected: "Y" | "N" | null;
|
||||
picRvwCn?: string | null;
|
||||
}
|
||||
|
||||
function compareCklstSiblings(a: CklstTreeNode, b: CklstTreeNode): number {
|
||||
if (a.cklstArtclSn !== b.cklstArtclSn) {
|
||||
return a.cklstArtclSn - b.cklstArtclSn;
|
||||
}
|
||||
if (a.sortSeq !== b.sortSeq) {
|
||||
return b.sortSeq - a.sortSeq;
|
||||
}
|
||||
return a.cklstRvwSn - b.cklstRvwSn;
|
||||
}
|
||||
|
||||
export function orderCklstTreeNodes(rows: CklstTreeNode[]): CklstTreeNode[] {
|
||||
const childMap = new Map<number | null, CklstTreeNode[]>();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const parent = row.upCklstRvwSn ?? null;
|
||||
if (!childMap.has(parent)) childMap.set(parent, []);
|
||||
childMap.get(parent)!.push(row);
|
||||
});
|
||||
|
||||
childMap.forEach((siblings) => siblings.sort(compareCklstSiblings));
|
||||
|
||||
const ordered: CklstTreeNode[] = [];
|
||||
|
||||
function walk(parent: number | null) {
|
||||
(childMap.get(parent) ?? []).forEach((child) => {
|
||||
ordered.push(child);
|
||||
walk(child.cklstRvwSn);
|
||||
});
|
||||
}
|
||||
|
||||
walk(null);
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function groupCklstTreeByTaskSeComCd(cklstTree: CklstTreeNode[]): Map<string, CklstTreeNode[]> {
|
||||
const grouped = new Map<string, CklstTreeNode[]>();
|
||||
|
||||
cklstTree.forEach((node) => {
|
||||
const taskCode = node.taskSeComCd ?? "";
|
||||
if (!taskCode) return;
|
||||
if (!grouped.has(taskCode)) grouped.set(taskCode, []);
|
||||
grouped.get(taskCode)!.push(node);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function getOrderedSelectableNodes(
|
||||
cklstTree: CklstTreeNode[],
|
||||
taskSeComCdArray: string[],
|
||||
): CklstTreeNode[] {
|
||||
const grouped = groupCklstTreeByTaskSeComCd(cklstTree);
|
||||
|
||||
return taskSeComCdArray.flatMap((code) =>
|
||||
orderCklstTreeNodes(grouped.get(code) ?? []).filter((node) => node.bchckUseYn === "Y"),
|
||||
);
|
||||
}
|
||||
|
||||
export function isSameCklstResultList(
|
||||
prev: CklstResultListItem[],
|
||||
next: CklstResultListItem[],
|
||||
): boolean {
|
||||
if (prev.length !== next.length) return false;
|
||||
|
||||
return prev.every((item, index) => {
|
||||
const candidate = next[index];
|
||||
return (
|
||||
item.cklstRvwSn === candidate.cklstRvwSn &&
|
||||
item.cklstSn === candidate.cklstSn &&
|
||||
item.cklstArtclSn === candidate.cklstArtclSn &&
|
||||
item.rvwStpComCd === candidate.rvwStpComCd &&
|
||||
item.selected === candidate.selected &&
|
||||
(item.picRvwCn ?? null) === (candidate.picRvwCn ?? null)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function syncCklstResultListFromTree(
|
||||
cklstTree: CklstTreeNode[],
|
||||
taskSeComCdArray: string[],
|
||||
prev: CklstResultListItem[],
|
||||
): CklstResultListItem[] {
|
||||
if (!taskSeComCdArray.length) {
|
||||
return prev.length === 0 ? prev : [];
|
||||
}
|
||||
|
||||
const prevByRvwSn = new Map(prev.map((item) => [item.cklstRvwSn, item]));
|
||||
const orderedSelectable = getOrderedSelectableNodes(cklstTree, taskSeComCdArray);
|
||||
|
||||
const next = orderedSelectable.map((node) => {
|
||||
const existing = prevByRvwSn.get(node.cklstRvwSn);
|
||||
return (
|
||||
existing ?? {
|
||||
cklstSn: node.cklstSn,
|
||||
cklstArtclSn: node.cklstArtclSn,
|
||||
cklstRvwSn: node.cklstRvwSn,
|
||||
rvwStpComCd: "LOCAL",
|
||||
selected: null,
|
||||
picRvwCn: null,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (isSameCklstResultList(prev, next)) return prev;
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
CklstRvwRsltTreeDto,
|
||||
InstlSncnApplySaveRequestDto,
|
||||
InstlSncnCklstResultSaveDto,
|
||||
InstlSncnMngResponseDto,
|
||||
ReviewStepCode,
|
||||
} from "@/entities/instl-sncn";
|
||||
|
||||
import {
|
||||
type ChecklistFormState,
|
||||
type ChecklistRowKey,
|
||||
type ChecklistStepMap,
|
||||
INSTL_PROGRESS_ORDER,
|
||||
REVIEW_STEP_CODES,
|
||||
REVIEW_STEP_ROLE_RANK,
|
||||
ROLE_TO_REVIEW_STEP,
|
||||
} from "../model/view-types";
|
||||
|
||||
export function buildChecklistRowKey(row: CklstRvwRsltTreeDto): ChecklistRowKey {
|
||||
return `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
|
||||
}
|
||||
|
||||
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 getProgressStepCount(prgrsSttsComCd?: string): number {
|
||||
const idx = INSTL_PROGRESS_ORDER.indexOf(
|
||||
(prgrsSttsComCd ?? "ST") as (typeof INSTL_PROGRESS_ORDER)[number],
|
||||
);
|
||||
if (idx <= INSTL_PROGRESS_ORDER.indexOf("ST")) return 1;
|
||||
if (idx <= INSTL_PROGRESS_ORDER.indexOf("TP")) 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 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()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.cklstArtclSn ?? 0) - (b.cklstArtclSn ?? 0) ||
|
||||
(a.sortSeq ?? 0) - (b.sortSeq ?? 0) ||
|
||||
(a.cklstRvwSn ?? 0) - (b.cklstRvwSn ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export function toCklstSaveDto(
|
||||
rows: CklstRvwRsltTreeDto[],
|
||||
formState: ChecklistFormState,
|
||||
reviewStep: ReviewStepCode,
|
||||
): InstlSncnCklstResultSaveDto[] {
|
||||
return rows
|
||||
.filter((row) => row.bchckUseYn === "Y")
|
||||
.map((row) => {
|
||||
const key = buildChecklistRowKey(row);
|
||||
const selected =
|
||||
formState.results[key] ??
|
||||
(row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N" ? row.rvwRsltAnmlyYn : null);
|
||||
return {
|
||||
cklstSn: row.cklstSn,
|
||||
cklstArtclSn: row.cklstArtclSn,
|
||||
cklstRvwSn: row.cklstRvwSn,
|
||||
rvwStpComCd: reviewStep,
|
||||
selected,
|
||||
picRvwCn: formState.comments[key] ?? row.picRvwCn ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildTechReviewPayload(
|
||||
det: InstlSncnMngResponseDto,
|
||||
cklstResultList: InstlSncnCklstResultSaveDto[],
|
||||
): InstlSncnApplySaveRequestDto {
|
||||
return {
|
||||
instlSncnMng: { ...det, prgrsSttsComCd: "ER" },
|
||||
bzScaleList: [],
|
||||
bizCstList: [],
|
||||
cklstResultList,
|
||||
esntlAtchFileList: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const cnfmRegistSchema = z.object({
|
||||
instlSncnMng: 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, "구분은 필수입니다"),
|
||||
sncnSeComCd: z.string().min(1, "사업구분은 필수입니다"),
|
||||
dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
|
||||
dsgnrTelno: z.string().min(1, "설계자 전화번호는 필수입니다"),
|
||||
dsgnrOgdpNm: z.string().min(1, "설계자 소속은 필수입니다"),
|
||||
dsgnrEmlAddr: z
|
||||
.string()
|
||||
.min(1, "설계자 이메일은 필수입니다")
|
||||
.email("올바른 이메일 형식이 아닙니다"),
|
||||
bgtBizCd: z.string().optional(),
|
||||
bgtAplyYr: z.string().optional(),
|
||||
aplcntNm: z.string().optional(),
|
||||
aplcntMblTelno: z.string().optional(),
|
||||
aplcntOgdpNm: z.string().optional(),
|
||||
aplcntEmlAddr: z.string().optional(),
|
||||
instlSncnDocNo: z.string().optional(),
|
||||
admdstCd: z.string().optional(),
|
||||
envoSeComCd: z.string().optional(),
|
||||
prgrsSttsComCd: z.string().optional(),
|
||||
atchFileGroupId: z.string().optional(),
|
||||
rtlztnFcltSeComCd: z.string().optional(),
|
||||
rtlztnUsgSeComCd: z.string().optional(),
|
||||
envoSeCdNm: z.string().optional(),
|
||||
sncnSeCdNm: z.string().optional(),
|
||||
taskSeCdNm: z.string().optional(),
|
||||
prgrsSttsCdNm: z.string().optional(),
|
||||
sido: z.string().optional(),
|
||||
gugun: z.string().optional(),
|
||||
regDt: z.string().optional(),
|
||||
rgtr: z.string().optional(),
|
||||
rgtrNm: z.string().optional(),
|
||||
mdfcnDt: z.string().optional(),
|
||||
}),
|
||||
bzScaleList: z.array(
|
||||
z.object({
|
||||
cnstpSeCd: z.string(),
|
||||
bscPl: z.number(),
|
||||
budgetReflct: z.number(),
|
||||
thtiDsgn: z.number(),
|
||||
diff: z.number(),
|
||||
}),
|
||||
),
|
||||
bizCstList: z.array(
|
||||
z.object({
|
||||
acntgSeCd: z.string(),
|
||||
bscPlanAmt: z.number(),
|
||||
bgtRfltAmt: z.number(),
|
||||
tstmDesignAmt: z.number(),
|
||||
}),
|
||||
),
|
||||
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 CnfmRegistFormData = z.infer<typeof cnfmRegistSchema>;
|
||||
|
||||
export function formatTaskSeComCdForApi(taskSeComCd: string[]): string {
|
||||
return taskSeComCd.join(",");
|
||||
}
|
||||
|
||||
export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
|
||||
if (!taskSeComCd) return [];
|
||||
return taskSeComCd.split(",").filter(Boolean);
|
||||
}
|
||||
|
||||
export const cnfmRegistSaveSchema = cnfmRegistSchema.pick({
|
||||
instlSncnMng: true,
|
||||
bzScaleList: true,
|
||||
bizCstList: true,
|
||||
});
|
||||
|
||||
export const cnfmRegistSubmitSchema = cnfmRegistSchema.refine(
|
||||
(data) => data.privacyConsent === "Y",
|
||||
{
|
||||
message: "개인정보 수집/이용에 동의해야 제출할 수 있습니다",
|
||||
path: ["privacyConsent"],
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { IUserInfo } from "@/shared/store/user-store";
|
||||
import type { InstlSncnMngDetResponse } from "@/entities/instl-sncn";
|
||||
|
||||
import type { CnfmRegistFormData } from "./cnfm-regist-schema";
|
||||
import { parseTaskSeComCdFromApi } from "./cnfm-regist-schema";
|
||||
|
||||
function mapSelectedValue(value?: string | null): "Y" | "N" | null {
|
||||
if (value === "Y" || value === "N") return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapEsntlAtchFileList(esntlFileList: InstlSncnMngDetResponse["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: InstlSncnMngDetResponse): CnfmRegistFormData {
|
||||
const { detDto, bizSclList, bizCstList, cklstResultList, esntlFileList } = data;
|
||||
|
||||
return {
|
||||
instlSncnMng: {
|
||||
instlSncnDocNo: detDto.instlSncnDocNo,
|
||||
bizNm: detDto.bizNm,
|
||||
bizBgngYr: detDto.bizBgngYr,
|
||||
bizEndYr: detDto.bizEndYr,
|
||||
bizCn: detDto.bizCn,
|
||||
taskSeComCd: parseTaskSeComCdFromApi(detDto.taskSeComCd),
|
||||
sncnSeComCd: detDto.sncnSeComCd,
|
||||
dsgnrNm: detDto.dsgnrNm,
|
||||
dsgnrTelno: detDto.dsgnrTelno,
|
||||
dsgnrOgdpNm: detDto.dsgnrOgdpNm,
|
||||
dsgnrEmlAddr: detDto.dsgnrEmlAddr,
|
||||
aplcntNm: detDto.aplcntNm ?? "",
|
||||
aplcntMblTelno: detDto.aplcntMblTelno ?? "",
|
||||
aplcntOgdpNm: detDto.aplcntOgdpNm ?? "",
|
||||
aplcntEmlAddr: detDto.aplcntEmlAddr ?? "",
|
||||
bgtBizCd: detDto.bgtBizCd,
|
||||
bgtAplyYr: detDto.bgtAplyYr,
|
||||
admdstCd: detDto.admdstCd,
|
||||
envoSeComCd: detDto.envoSeComCd,
|
||||
prgrsSttsComCd: detDto.prgrsSttsComCd,
|
||||
atchFileGroupId: detDto.atchFileGroupId ?? "",
|
||||
rtlztnFcltSeComCd: detDto.rtlztnFcltSeComCd,
|
||||
rtlztnUsgSeComCd: detDto.rtlztnUsgSeComCd,
|
||||
envoSeCdNm: detDto.envoSeCdNm,
|
||||
sncnSeCdNm: detDto.sncnSeCdNm,
|
||||
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,
|
||||
})),
|
||||
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): CnfmRegistFormData {
|
||||
return {
|
||||
instlSncnMng: {
|
||||
bizNm: "",
|
||||
bizBgngYr: "",
|
||||
bizEndYr: "",
|
||||
bizCn: "",
|
||||
taskSeComCd: [],
|
||||
sncnSeComCd: "",
|
||||
dsgnrNm: "",
|
||||
dsgnrTelno: "",
|
||||
dsgnrOgdpNm: "",
|
||||
dsgnrEmlAddr: "",
|
||||
aplcntNm: userInfo?.userName ?? "",
|
||||
aplcntMblTelno: userInfo?.mphn ?? "",
|
||||
aplcntOgdpNm: userInfo?.ogdpSeNm ?? "",
|
||||
aplcntEmlAddr: userInfo?.email ?? "",
|
||||
prgrsSttsComCd: "SV",
|
||||
atchFileGroupId: "",
|
||||
},
|
||||
bzScaleList: [],
|
||||
bizCstList: [],
|
||||
cklstResultList: [],
|
||||
esntlAtchFileList: [],
|
||||
privacyConsent: "N",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function fmtNumber(value: number | null | undefined): string {
|
||||
if (typeof value === "number") return value.toLocaleString();
|
||||
if (value === null || value === undefined) return "";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function indentPx(indNm: string | undefined): number {
|
||||
if (!indNm) return 0;
|
||||
const spaces = indNm.length - indNm.trimStart().length;
|
||||
return (spaces / 4) * 16;
|
||||
}
|
||||
|
||||
export function calcDiffPercent(diff: number, baseline: number): string {
|
||||
if (baseline === 0) return "-";
|
||||
return `${Math.round((diff / baseline) * 100)}%`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
import type { BnsListResponseDto, BnsListResponseWrapperDto } from "@/entities/bns-if";
|
||||
|
||||
const EMPTY_SORT = { empty: true, sorted: false, unsorted: true };
|
||||
|
||||
export const BNS_LIST_PAGE_SIZE = 10;
|
||||
|
||||
export function toBnsListPageResponse(
|
||||
wrapper: BnsListResponseWrapperDto,
|
||||
cPage: number,
|
||||
pageSize: number = BNS_LIST_PAGE_SIZE,
|
||||
): PageResponse<BnsListResponseDto> {
|
||||
const content = wrapper.list ?? [];
|
||||
const pageIndex = cPage - 1;
|
||||
const totalPages = pageSize > 0 ? Math.ceil(wrapper.cnt / pageSize) : 0;
|
||||
|
||||
return {
|
||||
content,
|
||||
totalElements: wrapper.cnt,
|
||||
number: pageIndex,
|
||||
numberOfElements: content.length,
|
||||
size: pageSize,
|
||||
totalPages,
|
||||
first: cPage <= 1,
|
||||
last: cPage >= totalPages || content.length === 0,
|
||||
empty: content.length === 0,
|
||||
pageable: {
|
||||
sort: EMPTY_SORT,
|
||||
offset: pageIndex * pageSize,
|
||||
pageNumber: pageIndex,
|
||||
pageSize,
|
||||
paged: true,
|
||||
unpaged: false,
|
||||
},
|
||||
sort: EMPTY_SORT,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import type { BnsListResponseDto } from "@/entities/bns-if";
|
||||
|
||||
const columnHelper = createColumnHelper<BnsListResponseDto>();
|
||||
|
||||
function formatOptional(value: string | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
|
||||
export function getBnsListRowKey(row: BnsListResponseDto) {
|
||||
return `${row.gbn}-${row.seqNo}-${row.yr}`;
|
||||
}
|
||||
|
||||
export function createBnsListColumns() {
|
||||
return [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: "선택",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="radio"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={() => row.toggleSelected(true)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label="사업 선택"
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("rgnNm", {
|
||||
header: "환경청",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("sido", {
|
||||
header: "시도",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("gugun", {
|
||||
header: "시군구",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("bzNm", {
|
||||
header: "사업명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("yr", {
|
||||
header: "예산신청년도",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("strtYr", {
|
||||
header: "사업시작년도",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("edYr", {
|
||||
header: "사업종료년도",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("totBzamt", {
|
||||
header: "총사업비",
|
||||
meta: { align: "right" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
|
||||
import { Badge } from "@common/components";
|
||||
|
||||
const columnHelper = createColumnHelper<InstlSncnMngResponseDto>();
|
||||
|
||||
const MAIN_STEP_LABELS = {
|
||||
save: "저장",
|
||||
apply: "신청완료",
|
||||
techReview: "기술검토",
|
||||
reply: "검토결과 회신",
|
||||
} as const;
|
||||
|
||||
const TECH_REVIEW_STATUS_CODES = new Set(["LE", "ER", "EE", "TE", "TS", "TR", "TP"]);
|
||||
|
||||
type BadgeState = "completed" | "current" | "pending" | "action";
|
||||
|
||||
interface ProgressBadge {
|
||||
label: string;
|
||||
state: BadgeState;
|
||||
}
|
||||
|
||||
function buildProgressBadges(prgrsSttsComCd: string, prgrsSttsCdNm: string): ProgressBadge[] {
|
||||
const techReviewLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.techReview;
|
||||
const replyLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.reply;
|
||||
|
||||
if (prgrsSttsComCd === "SV") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.save, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "ST") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.save, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (TECH_REVIEW_STATUS_CODES.has(prgrsSttsComCd)) {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.save, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: techReviewLabel, state: "action" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "EP") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.save, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "completed" },
|
||||
{ label: replyLabel, state: "action" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "TA" || prgrsSttsComCd === "TB") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.save, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
|
||||
];
|
||||
}
|
||||
|
||||
return [{ label: prgrsSttsCdNm || prgrsSttsComCd || "-", state: "current" }];
|
||||
}
|
||||
|
||||
function getBadgeColor(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "green";
|
||||
case "current":
|
||||
return "blue";
|
||||
case "pending":
|
||||
return "lightGray";
|
||||
case "action":
|
||||
return "orange";
|
||||
}
|
||||
}
|
||||
|
||||
function formatOptional(value: string | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
|
||||
interface ProgressStepBadgeProps {
|
||||
prgrsSttsComCd: string;
|
||||
prgrsSttsCdNm: string;
|
||||
}
|
||||
|
||||
function ProgressStepBadge({ prgrsSttsComCd, prgrsSttsCdNm }: ProgressStepBadgeProps) {
|
||||
const badges = buildProgressBadges(prgrsSttsComCd, prgrsSttsCdNm);
|
||||
|
||||
return (
|
||||
<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 function createInstlSncnColumns() {
|
||||
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,75 @@
|
||||
import type {
|
||||
AtflMngClientResponseDto,
|
||||
BizCstTreeDto,
|
||||
BizSclTreeDto,
|
||||
CklstRvwRsltTreeDto,
|
||||
ElaprDocPrgrsClientResponseDto,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
InstlSncnMngDetResponse,
|
||||
InstlSncnMngResponseDto,
|
||||
InstlSncnRvwOpnnResponseDto,
|
||||
ReviewStepCode,
|
||||
} from "@/entities/instl-sncn";
|
||||
|
||||
export type {
|
||||
AtflMngClientResponseDto,
|
||||
BizCstTreeDto,
|
||||
BizSclTreeDto,
|
||||
CklstRvwRsltTreeDto,
|
||||
ElaprDocPrgrsClientResponseDto,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
InstlSncnMngResponseDto as InstlSncnMng,
|
||||
InstlSncnMngDetResponse,
|
||||
InstlSncnRvwOpnnResponseDto,
|
||||
ReviewStepCode,
|
||||
};
|
||||
|
||||
export type ChecklistRowKey = string;
|
||||
|
||||
export interface ChecklistFormState {
|
||||
results: Record<ChecklistRowKey, "Y" | "N">;
|
||||
comments: Record<ChecklistRowKey, string>;
|
||||
}
|
||||
|
||||
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 INSTL_PROGRESS_ORDER = [
|
||||
"SV",
|
||||
"ST",
|
||||
"LE",
|
||||
"ER",
|
||||
"EE",
|
||||
"TE",
|
||||
"TS",
|
||||
"TR",
|
||||
"TP",
|
||||
"EP",
|
||||
"TA",
|
||||
"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,511 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
import { NoData } from "@/shared/ui/no-data";
|
||||
import type { CmCdAdstDstrComboResponseDto } from "@/entities/adstdstr";
|
||||
import { useFindGugunCombo, useFindSidoCombo } from "@/entities/adstdstr";
|
||||
import type { BnsDetailResponseDto, BnsListResponseDto, BnsSearchDto } from "@/entities/bns-if";
|
||||
import { buildBnsSearchDto, useBnsDetail, useBnsList } from "@/entities/bns-if";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { BNS_LIST_PAGE_SIZE, toBnsListPageResponse } from "../../lib/to-bns-list-page-response";
|
||||
import { createBnsListColumns, getBnsListRowKey } from "../../model/bns-picker-columns";
|
||||
|
||||
import { SectionCollapsible } from "./section-collapsible";
|
||||
|
||||
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
|
||||
const YEAR_START = 1995;
|
||||
|
||||
interface BnsApplyData {
|
||||
bizNm: string;
|
||||
bizBgngYr: string;
|
||||
bizEndYr: string;
|
||||
bizCn: string;
|
||||
bgtBizCd: string;
|
||||
bgtAplyYr: string;
|
||||
}
|
||||
|
||||
interface BnsPickerDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (data: BnsApplyData) => void;
|
||||
}
|
||||
|
||||
function BnsPickerDialog({ open, onClose, onSelect }: BnsPickerDialogProps) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearOptions = useMemo(
|
||||
() =>
|
||||
Array.from({ length: currentYear + 1 - YEAR_START + 1 }, (_, index) => YEAR_START + index),
|
||||
[currentYear],
|
||||
);
|
||||
|
||||
const { confirm } = useConfirm();
|
||||
const [sido, setSido] = useState("");
|
||||
const [gugun, setGugun] = useState("");
|
||||
const [year, setYear] = useState(String(currentYear));
|
||||
const [searchParams, setSearchParams] = useState<BnsSearchDto | null>(null);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
const { data: sidoData } = useFindSidoCombo();
|
||||
const { data: gugunData } = useFindGugunCombo(sido);
|
||||
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
|
||||
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
|
||||
|
||||
const listQueryParams = searchParams ?? { cPage: 1 };
|
||||
|
||||
const listQuery = useBnsList(listQueryParams, {
|
||||
enabled: searchParams !== null,
|
||||
});
|
||||
|
||||
const pageQuery = useMemo(
|
||||
() =>
|
||||
({
|
||||
isPending: listQuery.isPending,
|
||||
isError: listQuery.isError,
|
||||
data:
|
||||
listQuery.data && searchParams
|
||||
? toBnsListPageResponse(listQuery.data, searchParams.cPage)
|
||||
: undefined,
|
||||
}) as UseQueryResult<PageResponse<BnsListResponseDto>>,
|
||||
[listQuery.isPending, listQuery.isError, listQuery.data, searchParams],
|
||||
);
|
||||
|
||||
const columns = useMemo(() => createBnsListColumns(), []);
|
||||
|
||||
const pagination = useMemo<PaginationState>(
|
||||
() => ({
|
||||
pageIndex: Math.max(0, (searchParams?.cPage ?? 1) - 1),
|
||||
pageSize: BNS_LIST_PAGE_SIZE,
|
||||
}),
|
||||
[searchParams?.cPage],
|
||||
);
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: pageQuery,
|
||||
tableOptions: {
|
||||
columns,
|
||||
state: { pagination, rowSelection },
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: false,
|
||||
getRowId: (row) => getBnsListRowKey(row),
|
||||
onRowSelectionChange: (updater) => {
|
||||
const next = typeof updater === "function" ? updater(rowSelection) : updater;
|
||||
setRowSelection(next);
|
||||
},
|
||||
onPaginationChange: (updater) => {
|
||||
if (!searchParams) return;
|
||||
const next = typeof updater === "function" ? updater(pagination) : updater;
|
||||
setRowSelection({});
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
cPage: next.pageIndex + 1,
|
||||
});
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
},
|
||||
dataTableOptions: {
|
||||
viewPagination: true,
|
||||
onRowClick: (row) => {
|
||||
setRowSelection({ [getBnsListRowKey(row)]: true });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const selectedRow = useMemo(() => {
|
||||
const selectedId = Object.keys(rowSelection)[0];
|
||||
if (!selectedId) return null;
|
||||
return listQuery.data?.list?.find((row) => getBnsListRowKey(row) === selectedId) ?? null;
|
||||
}, [rowSelection, listQuery.data?.list]);
|
||||
|
||||
const detailGbn = selectedRow ? String(selectedRow.gbn) : "";
|
||||
const detailYr = selectedRow?.yr ?? "";
|
||||
const detailSeqNo = selectedRow?.seqNo ?? "";
|
||||
|
||||
const {
|
||||
data: detailData,
|
||||
isPending: isDetailPending,
|
||||
isError: isDetailError,
|
||||
} = useBnsDetail(detailGbn, detailYr, detailSeqNo);
|
||||
|
||||
const detail = detailData?.detail;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
resetState();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function resetState() {
|
||||
setSido("");
|
||||
setGugun("");
|
||||
setYear(String(currentYear));
|
||||
setSearchParams(null);
|
||||
setRowSelection({});
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
setRowSelection({});
|
||||
setSearchParams(
|
||||
buildBnsSearchDto({
|
||||
cPage: 1,
|
||||
searchStartYear: year,
|
||||
searchSido: sido || undefined,
|
||||
searchGugun: gugun || undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
if (!selectedRow || !detail) return;
|
||||
|
||||
if (
|
||||
!(await confirm(
|
||||
"해당 사업은 예산신청 당시의 정보로\n확정예산 정보와는 다를 수 있습니다.\n적용하시겠습니까?",
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
onSelect({
|
||||
bizNm: selectedRow.bzNm,
|
||||
bizBgngYr: selectedRow.strtYr,
|
||||
bizEndYr: selectedRow.edYr,
|
||||
bizCn: detail.bssCont,
|
||||
bgtBizCd: selectedRow.bzCd,
|
||||
bgtAplyYr: selectedRow.yr,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-[120rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>사업찾기</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="card card-border mx-4 flex flex-wrap items-end gap-4 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="bns-picker-sido">시도</Label>
|
||||
<Select
|
||||
value={sido}
|
||||
onValueChange={(value) => {
|
||||
setSido(value);
|
||||
setGugun("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="bns-picker-sido" className="w-[12rem]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sidoOptions.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="bns-picker-gugun">구군</Label>
|
||||
<Select value={gugun} onValueChange={setGugun} disabled={!sido}>
|
||||
<SelectTrigger id="bns-picker-gugun" className="w-[12rem]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{gugunOptions.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="bns-picker-year">년도</Label>
|
||||
<Select value={year} onValueChange={setYear}>
|
||||
<SelectTrigger id="bns-picker-year" className="w-[10rem]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map((optionYear) => (
|
||||
<SelectItem key={optionYear} value={String(optionYear)}>
|
||||
{optionYear}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={handleSearch}>
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[64rem] overflow-auto">
|
||||
<div className="mx-4">
|
||||
{searchParams === null ? (
|
||||
<NoData message="조회 조건을 선택한 후 조회해 주세요." />
|
||||
) : listQuery.isError ? (
|
||||
<NoData message="사업 목록을 불러오지 못했습니다." />
|
||||
) : (
|
||||
tableComponent
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedRow && (
|
||||
<div className="mt-10 space-y-4">
|
||||
<SectionCollapsible title="기본정보" defaultOpen>
|
||||
{isDetailPending ? (
|
||||
<DetailSkeleton />
|
||||
) : isDetailError || !detail ? (
|
||||
<NoData message="기본정보를 불러오지 못했습니다." />
|
||||
) : (
|
||||
<BnsBasicInfoTable detail={detail} />
|
||||
)}
|
||||
</SectionCollapsible>
|
||||
|
||||
<SectionCollapsible title="운영현황 및 사업효과" defaultOpen>
|
||||
{isDetailPending ? (
|
||||
<DetailSkeleton />
|
||||
) : isDetailError || !detail ? (
|
||||
<NoData message="운영현황 및 사업효과를 불러오지 못했습니다." />
|
||||
) : (
|
||||
<BnsOperationTable detail={detail} />
|
||||
)}
|
||||
</SectionCollapsible>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={handleApply} disabled={!selectedRow || !detail}>
|
||||
적용
|
||||
</Button>
|
||||
<Button type="button" color="white" onClick={onClose}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function BnsBasicInfoTable({ detail }: { detail: BnsDetailResponseDto }) {
|
||||
return (
|
||||
<Table variant="secondary" className="w-full">
|
||||
<TableBody>
|
||||
<DetailPairRow
|
||||
leftLabel="시도"
|
||||
leftValue={detail.sido}
|
||||
rightLabel="구군"
|
||||
rightValue={detail.gugun}
|
||||
/>
|
||||
<DetailPairRow
|
||||
leftLabel="대권역"
|
||||
leftValue={detail.rvbsnNm}
|
||||
rightLabel="단위유역"
|
||||
rightValue={detail.lgbsnNm}
|
||||
/>
|
||||
<DetailPairRow
|
||||
leftLabel="중권역"
|
||||
leftValue={detail.mdmbsnNm}
|
||||
rightLabel="환경청"
|
||||
rightValue={detail.lclgvrnmntNm}
|
||||
/>
|
||||
<DetailPairRow
|
||||
leftLabel="회계구분"
|
||||
leftValue={detail.accKdNm}
|
||||
rightLabel="단위사업"
|
||||
rightValue={detail.bzKdNm}
|
||||
/>
|
||||
<DetailPairRow
|
||||
leftLabel="세부사업"
|
||||
leftValue={detail.untBzNm}
|
||||
rightLabel="내역사업"
|
||||
rightValue={detail.dtlBzNm}
|
||||
/>
|
||||
<DetailPairRow
|
||||
leftLabel="세부내역"
|
||||
leftValue={detail.mevBzNm}
|
||||
rightLabel="세세부내역"
|
||||
rightValue={detail.ddtlBzNm}
|
||||
/>
|
||||
<DetailPairRow
|
||||
leftLabel="사업시작년도"
|
||||
leftValue={detail.strtYr}
|
||||
rightLabel="사업종료년도"
|
||||
rightValue={detail.edYr}
|
||||
/>
|
||||
<DetailPairRow
|
||||
leftLabel="보조율(%)"
|
||||
leftValue={detail.aidRt}
|
||||
rightLabel="사업상태"
|
||||
rightValue={detail.bzStatus}
|
||||
/>
|
||||
<DetailFullRow label="사업명" value={detail.bzNm} />
|
||||
<DetailFullRow label="사업위치" value={detail.bzLoc} />
|
||||
<DetailPairRow
|
||||
leftLabel="기본계획상의 목표년도"
|
||||
leftValue={detail.goalYear}
|
||||
rightLabel="사업추진현황"
|
||||
rightValue={detail.bssProm}
|
||||
/>
|
||||
<DetailFullRow label="상위계획" value={detail.uppPlan} />
|
||||
<DetailPairRow
|
||||
leftLabel="기술진단"
|
||||
leftValue={detail.techInsp}
|
||||
rightLabel="사업내용"
|
||||
rightValue={detail.bssCont}
|
||||
/>
|
||||
<DetailFullRow label="비고" value={detail.rmk} />
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function BnsOperationTable({ detail }: { detail: BnsDetailResponseDto }) {
|
||||
return (
|
||||
<Table variant="secondary" className="w-full">
|
||||
<TableBody>
|
||||
<DetailPairRow
|
||||
leftLabel="유입하수처리장"
|
||||
leftValue={detail.inflBscFctNm}
|
||||
rightLabel="처리방식"
|
||||
rightValue={detail.trtMtd}
|
||||
/>
|
||||
<DetailPairRow
|
||||
leftLabel="시설용량(현행)"
|
||||
leftValue={detail.fctCpc}
|
||||
rightLabel="시설용량(사업후)"
|
||||
rightValue={detail.fctCpcAfr}
|
||||
/>
|
||||
<DetailPairRow
|
||||
leftLabel="처리구역면적(현행)"
|
||||
leftValue={detail.trtDstrArea}
|
||||
rightLabel="처리구역면적(사업후)"
|
||||
rightValue={detail.trtDstrAreaAfr}
|
||||
/>
|
||||
<DetailFullRow label="총인구(현행)" value={detail.adstPopl} />
|
||||
<DetailPairRow
|
||||
leftLabel="처리인구(현행)"
|
||||
leftValue={detail.swrTrtPopl}
|
||||
rightLabel="처리인구(사업후)"
|
||||
rightValue={detail.swrTrtPoplAfr}
|
||||
/>
|
||||
<DetailFullRow
|
||||
label="하수관로"
|
||||
value={`신규: ${detail.swrExtns || "-"} / 교체: ${detail.swrChng || "-"} / 개보수: ${detail.swrRnvt || "-"} (Km)`}
|
||||
/>
|
||||
<DetailPairRow
|
||||
leftLabel="배수설비 총가구수"
|
||||
leftValue={detail.totFamNum}
|
||||
rightLabel="펌프장(개소)"
|
||||
rightValue={detail.pumpCnt}
|
||||
/>
|
||||
<DetailPairRow
|
||||
leftLabel="방류수계"
|
||||
leftValue={detail.rvWbsn}
|
||||
rightLabel="방류하천"
|
||||
rightValue={detail.rvText}
|
||||
/>
|
||||
<DetailPairRow
|
||||
leftLabel="사업후 예상유입하수량"
|
||||
leftValue={detail.imprvExpctFlw}
|
||||
rightLabel="사업후 유입수질(예상BOD)"
|
||||
rightValue={detail.imprvExpctWqlt}
|
||||
/>
|
||||
<DetailFullRow label="계획방류수질" value={detail.trtplntPlEfflWqlt} />
|
||||
<DetailFullRow
|
||||
label="하천수질"
|
||||
value={`평균: ${detail.wqltAvg || "-"} / 최저: ${detail.wqltLow || "-"} / 최고: ${detail.wqltHgh || "-"} (BOD, mg/L)`}
|
||||
/>
|
||||
<DetailFullRow label="표준사업비" value={detail.stdCost} />
|
||||
<DetailFullRow label="기타사업물량" value={detail.bzEtc} />
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
interface DetailPairRowProps {
|
||||
leftLabel: string;
|
||||
leftValue?: string;
|
||||
rightLabel: string;
|
||||
rightValue?: string;
|
||||
}
|
||||
|
||||
function DetailPairRow({ leftLabel, leftValue, rightLabel, rightValue }: DetailPairRowProps) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableHead className="w-[20%]">{leftLabel}</TableHead>
|
||||
<TableCell className="w-[30%]">{leftValue || "-"}</TableCell>
|
||||
<TableHead className="w-[20%]">{rightLabel}</TableHead>
|
||||
<TableCell className="w-[30%]">{rightValue || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
interface DetailFullRowProps {
|
||||
label: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
function DetailFullRow({ label, value }: DetailFullRowProps) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableHead className="w-[20%]">{label}</TableHead>
|
||||
<TableCell colSpan={3}>{value || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
<Skeleton className="h-[3rem]" />
|
||||
<Skeleton className="h-[3rem]" />
|
||||
<Skeleton className="h-[3rem]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { BnsPickerDialog };
|
||||
export type { BnsApplyData };
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import type { CklstRvwRsltTreeDto, ReviewStepCode } from "@/entities/instl-sncn";
|
||||
import {
|
||||
cn,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import { buildChecklistRowKey } from "../../lib/checklist-utils";
|
||||
import { type ChecklistFormState, ROLE_TO_REVIEW_STEP } from "../../model/view-types";
|
||||
|
||||
interface ChecklistTaskPanelProps {
|
||||
selectedReviewStep: ReviewStepCode;
|
||||
selectedRows: CklstRvwRsltTreeDto[];
|
||||
formState: ChecklistFormState;
|
||||
setFormState: Dispatch<SetStateAction<ChecklistFormState>>;
|
||||
userRoleCode?: string;
|
||||
}
|
||||
|
||||
function ChecklistTaskPanel({
|
||||
selectedReviewStep,
|
||||
selectedRows,
|
||||
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);
|
||||
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` }}
|
||||
>
|
||||
{row.treeLevel > 1 ? "└ " : ""}
|
||||
{row.rvwCn}
|
||||
</TableCell>
|
||||
{!isFieldVisible ? (
|
||||
<>
|
||||
<TableCell className="bg-muted" />
|
||||
<TableCell className="bg-muted" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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) => ({
|
||||
...prev,
|
||||
results: { ...prev.results, [key]: "Y" },
|
||||
}));
|
||||
}}
|
||||
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) => ({
|
||||
...prev,
|
||||
results: { ...prev.results, [key]: "N" },
|
||||
}));
|
||||
}}
|
||||
className={cn(isStepEditable ? "cursor-pointer" : "cursor-not-allowed")}
|
||||
/>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
{!isLocalReviewStep ? (
|
||||
<TableCell className={`text-left ${isFieldVisible ? "" : "bg-muted"}`}>
|
||||
{isFieldVisible && (
|
||||
<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) => ({
|
||||
...prev,
|
||||
comments: { ...prev.comments, [key]: event.target.value },
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { ChecklistTaskPanel };
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { type ElaprTmplt, useElaprAplnPage, useElaprTmpltPage } from "@/entities/elapr";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface ElaprTmpltPickerDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (tmplt: ElaprTmplt) => void;
|
||||
}
|
||||
|
||||
function ElaprTmpltPickerDialog({ open, onClose, onSelect }: ElaprTmpltPickerDialogProps) {
|
||||
const { data, isPending, isError } = useElaprTmpltPage({
|
||||
page: 0,
|
||||
size: PAGE_SIZE,
|
||||
sort: "regDt,desc",
|
||||
docSeComCd: "INSTL",
|
||||
});
|
||||
|
||||
const items = data?.content ?? [];
|
||||
|
||||
function handleSelect(tmplt: ElaprTmplt) {
|
||||
onSelect(tmplt);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[80rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>결재선 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-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="40%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>결재서식명</TableHead>
|
||||
<TableHead>서식 설명</TableHead>
|
||||
<TableHead>검토자</TableHead>
|
||||
<TableHead>선택</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.elaprTmpltId}>
|
||||
<TableCell align="center">{item.elaprTmpltNm}</TableCell>
|
||||
<TableCell align="center">{item.elaprTmpltExpln || "-"}</TableCell>
|
||||
<TableCell align="center">
|
||||
<ElaprAplnCell elaprTmpltId={item.elaprTmpltId} />
|
||||
</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={onClose}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ElaprAplnCell({ elaprTmpltId }: { elaprTmpltId: string }) {
|
||||
const { data } = useElaprAplnPage({
|
||||
elaprTmpltId,
|
||||
page: 0,
|
||||
size: PAGE_SIZE,
|
||||
sort: "atrzSeqNo,asc",
|
||||
});
|
||||
|
||||
const items = data?.content ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.aplnId}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge color="gray">{item.atrzSeComNm}</Badge>
|
||||
{item.aprvrNm}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprTmpltPickerDialog };
|
||||
@@ -0,0 +1,456 @@
|
||||
"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 { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import { ElaprDmndApi, ElaprDmndSaveRequest, type ElaprTmplt } from "@/entities/elapr";
|
||||
import { instlSncnKeys, type InstlSncnMngDetResponse } from "@/entities/instl-sncn";
|
||||
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import {
|
||||
getButtonConfigs,
|
||||
INSTL_SNCN_VIEW_BUTTON_ACTION,
|
||||
type InstlSncnViewButtonAction,
|
||||
needsRoleUser01AtrzResolve,
|
||||
} from "../../lib/button-config";
|
||||
|
||||
import { ElaprTmpltPickerDialog } from "./elapr-tmplt-picker-dialog";
|
||||
import { TextInputDialog } from "./text-input-dialog";
|
||||
|
||||
interface FloatingActionButtonsProps {
|
||||
data: InstlSncnMngDetResponse;
|
||||
instlSncnDocNo: string;
|
||||
prgrsSttsComCd: string;
|
||||
dtlInstCd: string | undefined;
|
||||
}
|
||||
|
||||
type ActiveDialog = "elaprTmplt" | "rjct" | "dmndSplmnt" | null;
|
||||
|
||||
function FloatingActionButtons({
|
||||
data,
|
||||
instlSncnDocNo,
|
||||
prgrsSttsComCd,
|
||||
dtlInstCd,
|
||||
}: 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 isRegister = data.detDto.rgtr === userInfo?.userUnqId;
|
||||
|
||||
const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
|
||||
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
|
||||
{},
|
||||
{ enabled: needsAtrzResolve },
|
||||
);
|
||||
|
||||
const atrzSeComCdList = useMemo(
|
||||
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
|
||||
[rvwLoginUserData],
|
||||
);
|
||||
|
||||
const actionButtons = useMemo(() => {
|
||||
if (needsAtrzResolve && isRvwLoginUserLoading) return [];
|
||||
return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList);
|
||||
}, [atrzSeComCdList, dtlInstCd, isRvwLoginUserLoading, needsAtrzResolve, prgrsSttsComCd]);
|
||||
|
||||
const canEdit =
|
||||
isRegister && actionButtons.some((button) => button === INSTL_SNCN_VIEW_BUTTON_ACTION.strg);
|
||||
|
||||
function handleGoToList() {
|
||||
router.push("/instl-sncn");
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
router.push(`/instl-sncn/${instlSncnDocNo}/edit`);
|
||||
}
|
||||
|
||||
function shouldShowButton(action: InstlSncnViewButtonAction) {
|
||||
return actionButtons.some((button) => button === action);
|
||||
}
|
||||
|
||||
async function executeAction({
|
||||
action,
|
||||
request,
|
||||
successMessage,
|
||||
}: {
|
||||
action: InstlSncnViewButtonAction;
|
||||
request?: unknown;
|
||||
successMessage?: string;
|
||||
}) {
|
||||
const handler = buttonActionHandlers[action];
|
||||
if (!handler) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await handler({
|
||||
request,
|
||||
data,
|
||||
onSuccess: () => {
|
||||
alert(successMessage ?? "처리가 완료되었습니다.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: instlSncnKeys.detail(instlSncnDocNo),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(error.message || "처리 중 오류가 발생했습니다.");
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelClick() {
|
||||
if (!(await confirm("정말 삭제하시겠습니까?"))) return;
|
||||
await executeAction({
|
||||
action: INSTL_SNCN_VIEW_BUTTON_ACTION.del,
|
||||
successMessage: "삭제되었습니다.",
|
||||
});
|
||||
router.push("/instl-sncn");
|
||||
}
|
||||
|
||||
function handleRjctClick() {
|
||||
setActiveDialog("rjct");
|
||||
}
|
||||
|
||||
async function handleRjctConfirm(rjctRsn: string) {
|
||||
setActiveDialog(null);
|
||||
await executeAction({
|
||||
action: INSTL_SNCN_VIEW_BUTTON_ACTION.rjct,
|
||||
request: rjctRsn,
|
||||
successMessage: "반려 처리되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleElaprTmpltSelect(tmplt: ElaprTmplt) {
|
||||
const payload = {
|
||||
elaprDocNo: data.detDto.instlSncnDocNo,
|
||||
elaprTmpltId: tmplt.elaprTmpltId,
|
||||
dmndTtl: data.detDto.bizNm,
|
||||
dmndCn: data.detDto.bizCn,
|
||||
};
|
||||
await executeAction({
|
||||
action: INSTL_SNCN_VIEW_BUTTON_ACTION.dsgntRvwr,
|
||||
request: payload,
|
||||
successMessage: "검토자가 지정되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
function handleDmndSplmntClick() {
|
||||
setActiveDialog("dmndSplmnt");
|
||||
}
|
||||
|
||||
async function handleDmndSplmntConfirm(splmntCn: string) {
|
||||
setActiveDialog(null);
|
||||
await executeAction({
|
||||
action: INSTL_SNCN_VIEW_BUTTON_ACTION.dmndSplmnt,
|
||||
request: splmntCn,
|
||||
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(INSTL_SNCN_VIEW_BUTTON_ACTION.strg) && (
|
||||
<Button
|
||||
onClick={() => void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.strg })}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.aply) && (
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() => void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.aply })}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
신청완료
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.del) && (
|
||||
<Button color="red" onClick={() => void handleDelClick()} disabled={isProcessing}>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn) && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn })
|
||||
}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
보완완료
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt) && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt })
|
||||
}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
설치인가 고시
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn) && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn })
|
||||
}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
환경청제출
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rjct) && (
|
||||
<Button color="red" onClick={handleRjctClick} disabled={isProcessing}>
|
||||
반려
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv) && (
|
||||
<Button
|
||||
onClick={() => void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv })}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
인가승인
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply) && (
|
||||
<Button
|
||||
onClick={() => void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply })}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
검토결과 회신
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw) && (
|
||||
<Button
|
||||
color="white"
|
||||
onClick={() =>
|
||||
void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw })
|
||||
}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
기술검토 의뢰
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.dsgntRvwr) && (
|
||||
<Button onClick={() => setActiveDialog("elaprTmplt")} disabled={isProcessing}>
|
||||
검토자지정
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rvwStrg) && (
|
||||
<Button
|
||||
onClick={() => void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwStrg })}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rvwCmptn) && (
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() => void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwCmptn })}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
검토완료
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.dmndSplmnt) && (
|
||||
<Button color="gray" onClick={handleDmndSplmntClick} disabled={isProcessing}>
|
||||
보완요청
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.atrzAprv) && (
|
||||
<Button
|
||||
onClick={() => void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.atrzAprv })}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
결재(승인)
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.atrzRply) && (
|
||||
<Button
|
||||
onClick={() => void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.atrzRply })}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
결재(회신)
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.grndsChck) && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.grndsChck })
|
||||
}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
기술검토
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ElaprTmpltPickerDialog
|
||||
open={activeDialog === "elaprTmplt"}
|
||||
onClose={() => setActiveDialog(null)}
|
||||
onSelect={handleElaprTmpltSelect}
|
||||
/>
|
||||
<TextInputDialog
|
||||
open={activeDialog === "rjct"}
|
||||
onOpenChange={() => setActiveDialog(null)}
|
||||
title="반려 사유 입력"
|
||||
placeholder="반려 사유를 입력하세요."
|
||||
isSubmitting={isProcessing}
|
||||
onConfirm={(value) => void handleRjctConfirm(value)}
|
||||
/>
|
||||
<TextInputDialog
|
||||
open={activeDialog === "dmndSplmnt"}
|
||||
onOpenChange={() => setActiveDialog(null)}
|
||||
title="보완요청 내용 입력"
|
||||
placeholder="보완요청 내용을 입력하세요."
|
||||
isSubmitting={isProcessing}
|
||||
onConfirm={(value) => void handleDmndSplmntConfirm(value)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { FloatingActionButtons };
|
||||
|
||||
async function handleStrg(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 저장 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleAply(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 신청완료 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleDel(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 삭제 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleSplmntCmptn(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 보완완료 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleInstlSncnAncmnt(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 고시 처리 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleEnvoSbmsn(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 환경청제출 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleRjct(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 반려 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleSncnAprv(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 인가승인 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleRvwRply(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 검토결과 회신 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleRqstTechRvw(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 기술검토 의뢰 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleDsgntRvwr(context: ActionHandlerContext<ElaprDmndSaveRequest>): Promise<void> {
|
||||
try {
|
||||
await ElaprDmndApi.create(context.request);
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
context.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRvwStrg(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 검토저장 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleRvwCmptn(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 검토완료 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
async function handleDmndSplmnt(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 보완요청 API 호출
|
||||
context.onSuccess?.();
|
||||
}
|
||||
|
||||
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<InstlSncnViewButtonAction, ActionHandler> = {
|
||||
strg: handleStrg,
|
||||
aply: handleAply,
|
||||
del: handleDel,
|
||||
splmntCmptn: handleSplmntCmptn,
|
||||
instlSncnAncmnt: handleInstlSncnAncmnt,
|
||||
envoSbmsn: handleEnvoSbmsn,
|
||||
rjct: handleRjct,
|
||||
sncnAprv: handleSncnAprv,
|
||||
rvwRply: handleRvwRply,
|
||||
rqstTechRvw: handleRqstTechRvw,
|
||||
dsgntRvwr: handleDsgntRvwr,
|
||||
rvwStrg: handleRvwStrg,
|
||||
rvwCmptn: handleRvwCmptn,
|
||||
dmndSplmnt: handleDmndSplmnt,
|
||||
atrzAprv: handleAtrzAprv,
|
||||
atrzRply: handleAtrzRply,
|
||||
grndsChck: handleGrndsChck,
|
||||
};
|
||||
|
||||
interface ActionHandlerContext<T = any> {
|
||||
request: T;
|
||||
data: InstlSncnMngDetResponse;
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
type ActionHandler = (context: ActionHandlerContext) => Promise<void>;
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { Badge } from "@common/components";
|
||||
|
||||
interface ProgressStepIndicatorProps {
|
||||
prgrsSttsComCd: string;
|
||||
prgrsSttsCdNm: string;
|
||||
}
|
||||
|
||||
const MAIN_STEP_LABELS = {
|
||||
save: "저장",
|
||||
apply: "신청완료",
|
||||
techReview: "기술검토",
|
||||
reply: "검토결과 회신",
|
||||
} as const;
|
||||
|
||||
const TECH_REVIEW_STATUS_CODES = new Set(["LE", "ER", "EE", "TE", "TS", "TR", "TP"]);
|
||||
|
||||
type BadgeState = "completed" | "current" | "pending" | "action";
|
||||
|
||||
interface ProgressBadge {
|
||||
label: string;
|
||||
state: BadgeState;
|
||||
}
|
||||
|
||||
function buildProgressBadges(prgrsSttsComCd: string, prgrsSttsCdNm: string): ProgressBadge[] {
|
||||
const techReviewLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.techReview;
|
||||
const replyLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.reply;
|
||||
|
||||
if (prgrsSttsComCd === "SV") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.save, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "ST") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.save, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (TECH_REVIEW_STATUS_CODES.has(prgrsSttsComCd)) {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.save, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: techReviewLabel, state: "action" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "EP") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.save, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "completed" },
|
||||
{ label: replyLabel, state: "action" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "TA" || prgrsSttsComCd === "TB") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.save, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
|
||||
];
|
||||
}
|
||||
|
||||
return [{ label: prgrsSttsCdNm || prgrsSttsComCd || "-", state: "current" }];
|
||||
}
|
||||
|
||||
function getBadgeColor(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "green";
|
||||
case "current":
|
||||
return "blue";
|
||||
case "pending":
|
||||
return "lightGray";
|
||||
case "action":
|
||||
return "orange";
|
||||
}
|
||||
}
|
||||
|
||||
function ProgressStepIndicator({ prgrsSttsComCd, prgrsSttsCdNm }: ProgressStepIndicatorProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export { ProgressStepIndicator };
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from "@common/components";
|
||||
|
||||
interface SectionCollapsibleProps {
|
||||
title: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function SectionCollapsible({ title, defaultOpen = true, children }: SectionCollapsibleProps) {
|
||||
return (
|
||||
<Collapsible defaultOpen={defaultOpen} className="mx-4 rounded-sm border bg-background">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full cursor-pointer items-center justify-between border-b p-4 text-left data-[state=closed]:border-b-0"
|
||||
>
|
||||
<h2 className="text-2xl font-bold">{title}</h2>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-6 shrink-0 text-muted-foreground transition-transform",
|
||||
"group-data-[state=open]:rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="p-2">{children}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { SectionCollapsible };
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
interface TextInputDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
isSubmitting?: boolean;
|
||||
onConfirm: (text: string) => void;
|
||||
}
|
||||
|
||||
function TextInputDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
placeholder,
|
||||
defaultValue,
|
||||
isSubmitting,
|
||||
onConfirm,
|
||||
}: TextInputDialogProps) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setValue(defaultValue ?? "");
|
||||
}, [defaultValue, open]);
|
||||
|
||||
function handleConfirm() {
|
||||
onConfirm(value.trim());
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[44rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={6}
|
||||
className="!text-xl"
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={isSubmitting || value.trim().length === 0}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { TextInputDialog };
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useInstlSncnDetail } from "@/entities/instl-sncn";
|
||||
|
||||
import { InstlFormPage } from "./instl-form-page";
|
||||
|
||||
interface InstlEditPageProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function InstlEditPage({ id }: InstlEditPageProps) {
|
||||
const { data, isLoading, isError } = useInstlSncnDetail(id);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="mt-6 text-[1.4rem]">수정 정보를 불러오는 중...</div>;
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <div className="mt-6 text-[1.4rem]">수정 대상을 찾을 수 없습니다.</div>;
|
||||
}
|
||||
|
||||
return <InstlFormPage mode="edit" initialData={data} />;
|
||||
}
|
||||
|
||||
export { InstlEditPage };
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import { InstlSncnApi, type InstlSncnMngDetResponse } from "@/entities/instl-sncn";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import {
|
||||
type CnfmRegistFormData,
|
||||
cnfmRegistSchema,
|
||||
formatTaskSeComCdForApi,
|
||||
} from "../lib/cnfm-regist-schema";
|
||||
import { getCreateDefaultValues, mapDetailToFormData } from "../lib/form-data-mapper";
|
||||
|
||||
import { AttachmentsFormSection } from "./sections/attachments-form-section";
|
||||
import { BasicInfoFormSection } from "./sections/basic-info-form-section";
|
||||
import { BusinessCostFormSection } from "./sections/business-cost-form-section";
|
||||
import { BusinessScaleFormSection } from "./sections/business-scale-form-section";
|
||||
import { ChecklistFormSection } from "./sections/checklist-form-section";
|
||||
import { ContactInfoFormSection } from "./sections/contact-info-form-section";
|
||||
import { PrivacyConsentSection } from "./sections/privacy-consent-section";
|
||||
|
||||
interface InstlFormPageProps {
|
||||
mode: "create" | "edit";
|
||||
initialData?: InstlSncnMngDetResponse;
|
||||
}
|
||||
|
||||
function InstlFormPage({ mode, initialData }: InstlFormPageProps) {
|
||||
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 form = useForm<CnfmRegistFormData>({
|
||||
resolver: zodResolver(cnfmRegistSchema),
|
||||
mode: "onChange",
|
||||
defaultValues:
|
||||
isEditMode && initialData
|
||||
? mapDetailToFormData(initialData)
|
||||
: getCreateDefaultValues(userInfo),
|
||||
});
|
||||
|
||||
async function onSave(data: CnfmRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const { taskSeComCd, ...instlSncnMngRest } = data.instlSncnMng;
|
||||
|
||||
const payload = {
|
||||
instlSncnMng: {
|
||||
...instlSncnMngRest,
|
||||
instlSncnDocNo: isEditMode ? initialData?.detDto.instlSncnDocNo : undefined,
|
||||
taskSeComCd: formatTaskSeComCdForApi(taskSeComCd),
|
||||
prgrsSttsComCd: "SV",
|
||||
},
|
||||
bzScaleList: data.bzScaleList,
|
||||
bizCstList: data.bizCstList,
|
||||
cklstResultList: data.cklstResultList,
|
||||
esntlAtchFileList: data.esntlAtchFileList,
|
||||
};
|
||||
|
||||
const instlSncnDocNo = await InstlSncnApi.saveApply(payload);
|
||||
|
||||
alert(isEditMode ? "수정되었습니다." : "저장되었습니다.");
|
||||
router.push(`/instl-sncn/${instlSncnDocNo}`);
|
||||
} catch (error) {
|
||||
console.error(isEditMode ? "수정 실패:" : "저장 실패:", error);
|
||||
alert(isEditMode ? "수정 중 오류가 발생했습니다." : "저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-4">
|
||||
<BasicInfoFormSection />
|
||||
<ContactInfoFormSection />
|
||||
<PrivacyConsentSection />
|
||||
<BusinessScaleFormSection />
|
||||
<BusinessCostFormSection />
|
||||
<ChecklistFormSection docSeComCd="INSTL" />
|
||||
<AttachmentsFormSection
|
||||
docSeComCd="INSTL"
|
||||
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}>
|
||||
{isEditMode ? "수정" : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { InstlFormPage };
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import type { CmCdAdstDstrComboResponseDto } from "@/entities/adstdstr";
|
||||
import { useFindGugunCombo, useFindSidoCombo } from "@/entities/adstdstr";
|
||||
import { CodeSelect } from "@/entities/code";
|
||||
import type { InstlSncnMngSearchParams } from "@/entities/instl-sncn";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
|
||||
import { InstlList } from "./instl-list";
|
||||
|
||||
const DEFAULT_PAGE_INDEX = 0;
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
const DEFAULT_SORT = "instlSncnDocNo,desc";
|
||||
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
|
||||
|
||||
const DEFAULT_SEARCH_PARAMS: InstlSncnMngSearchParams = {
|
||||
bizNm: "",
|
||||
taskSeComCd: "",
|
||||
sidoComCd: "",
|
||||
sggComCd: "",
|
||||
page: DEFAULT_PAGE_INDEX,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
sort: DEFAULT_SORT,
|
||||
};
|
||||
|
||||
function InstlListPage() {
|
||||
const [bizNm, setBizNm] = useState("");
|
||||
const [taskSeComCd, setTaskSeComCd] = useState("");
|
||||
const [sidoComCd, setSidoComCd] = useState("");
|
||||
const [sggComCd, setSggComCd] = useState("");
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: DEFAULT_PAGE_INDEX,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const [searchParams, setSearchParams] = useState<InstlSncnMngSearchParams>(DEFAULT_SEARCH_PARAMS);
|
||||
|
||||
const { data: sidoData } = useFindSidoCombo();
|
||||
const { data: gugunData } = useFindGugunCombo(sidoComCd);
|
||||
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
|
||||
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
|
||||
|
||||
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
|
||||
setSearchParams({
|
||||
...DEFAULT_SEARCH_PARAMS,
|
||||
bizNm,
|
||||
taskSeComCd,
|
||||
sidoComCd,
|
||||
sggComCd,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background">
|
||||
<form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="sidoComCd" className="whitespace-nowrap">
|
||||
시도
|
||||
</Label>
|
||||
<Select
|
||||
value={sidoComCd}
|
||||
onValueChange={(value) => {
|
||||
setSidoComCd(value);
|
||||
setSggComCd("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="sidoComCd" className="w-[12rem]">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sidoOptions.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="sggComCd" className="whitespace-nowrap">
|
||||
시군구
|
||||
</Label>
|
||||
<Select value={sggComCd} onValueChange={setSggComCd} disabled={!sidoComCd}>
|
||||
<SelectTrigger id="sggComCd" className="w-[12rem]">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{gugunOptions.map((option) => (
|
||||
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
|
||||
{option.adstDstrNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="taskSeComCd" className="whitespace-nowrap">
|
||||
구분
|
||||
</Label>
|
||||
<CodeSelect upCd="INSTL" value={taskSeComCd} onValueChange={setTaskSeComCd} />
|
||||
</div>
|
||||
<div className="flex w-[24rem] items-center gap-2">
|
||||
<Label htmlFor="bizNm" className="whitespace-nowrap">
|
||||
사업명
|
||||
</Label>
|
||||
<Input
|
||||
id="bizNm"
|
||||
value={bizNm}
|
||||
onChange={(event) => setBizNm(event.target.value)}
|
||||
placeholder="사업명"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">조회</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<InstlList
|
||||
searchParams={searchParams}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InstlListPage };
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { type InstlSncnMngSearchParams, useInstlSncnList } from "@/entities/instl-sncn";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { createInstlSncnColumns } from "../model/columns";
|
||||
|
||||
interface InstlListProps {
|
||||
searchParams: InstlSncnMngSearchParams;
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: (pagination: PaginationState) => void;
|
||||
}
|
||||
|
||||
function InstlList({ searchParams, pagination, onPaginationChange }: InstlListProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
bizNm: searchParams.bizNm,
|
||||
taskSeComCd: searchParams.taskSeComCd,
|
||||
sidoComCd: searchParams.sidoComCd,
|
||||
sggComCd: searchParams.sggComCd,
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
sort: searchParams.sort ?? "regDt,desc",
|
||||
}),
|
||||
[searchParams, pagination],
|
||||
);
|
||||
|
||||
const pageQuery = useInstlSncnList(queryParams);
|
||||
const columns = useMemo(() => createInstlSncnColumns(), []);
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: pageQuery,
|
||||
tableOptions: {
|
||||
columns,
|
||||
state: { pagination },
|
||||
onPaginationChange: (updater) => {
|
||||
const next = typeof updater === "function" ? updater(pagination) : updater;
|
||||
onPaginationChange(next);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
},
|
||||
dataTableOptions: {
|
||||
viewPagination: true,
|
||||
onRowClick: (row) => router.push(`/instl-sncn/${row.instlSncnDocNo}`),
|
||||
},
|
||||
});
|
||||
|
||||
const handleRegisterClick = () => {
|
||||
router.push("/instl-sncn/regist");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card card-border mx-0 bg-background pb-8 pt-0">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl font-semibold">설치인가 목록</h3>
|
||||
<Button type="button" color="green" onClick={handleRegisterClick}>
|
||||
신청
|
||||
</Button>
|
||||
</div>
|
||||
{tableComponent}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { InstlList };
|
||||
@@ -0,0 +1,7 @@
|
||||
import { InstlFormPage } from "./instl-form-page";
|
||||
|
||||
function InstlRegistPage() {
|
||||
return <InstlFormPage mode="create" />;
|
||||
}
|
||||
|
||||
export { InstlRegistPage };
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import { useInstlSncnDetail } from "@/entities/instl-sncn";
|
||||
import { Skeleton } from "@common/components";
|
||||
|
||||
import { FloatingActionButtons } from "./components/floating-action-buttons";
|
||||
import { AttachmentsSection } from "./sections/attachments-section";
|
||||
import { BasicInfoSection } from "./sections/basic-info-section";
|
||||
import { BusinessCostSection } from "./sections/business-cost-section";
|
||||
import { BusinessScaleSection } from "./sections/business-scale-section";
|
||||
import { ChecklistSection } from "./sections/checklist-section";
|
||||
import { ContactInfoSection } from "./sections/contact-info-section";
|
||||
import { TechReviewOpinionSection } from "./sections/tech-review-opinion-section";
|
||||
|
||||
interface InstlViewPageProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function InstlViewPage({ id }: InstlViewPageProps) {
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const { data, isLoading, isError } = useInstlSncnDetail(id);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-6 flex flex-col gap-4 px-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-[4rem] rounded-xl" />
|
||||
<Skeleton className="h-[16rem] rounded-xl" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-[4rem] rounded-xl" />
|
||||
<Skeleton className="h-[16rem] rounded-xl" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-[4rem] rounded-xl" />
|
||||
<Skeleton className="h-[32rem] rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
|
||||
}
|
||||
|
||||
const {
|
||||
detDto,
|
||||
bizSclList,
|
||||
bizCstList,
|
||||
cklstResultList = [],
|
||||
esntlFileList,
|
||||
etcFileList,
|
||||
rvwList = [],
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
<BasicInfoSection det={detDto} />
|
||||
<ContactInfoSection det={detDto} />
|
||||
<BusinessScaleSection bizSclList={bizSclList} />
|
||||
<BusinessCostSection bizCstList={bizCstList} />
|
||||
<ChecklistSection
|
||||
cklstResultList={cklstResultList}
|
||||
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
|
||||
userRoleCode={userInfo?.dtlInstCd}
|
||||
/>
|
||||
<AttachmentsSection esntlFileList={esntlFileList} etcFileList={etcFileList} />
|
||||
<TechReviewOpinionSection rvwList={rvwList} />
|
||||
<FloatingActionButtons
|
||||
data={data}
|
||||
instlSncnDocNo={detDto.instlSncnDocNo}
|
||||
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
|
||||
dtlInstCd={userInfo?.dtlInstCd}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InstlViewPage };
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { processElaprPrgrs } from "@/entities/elapr/api/elapr-prgrs-service";
|
||||
import { ElaprDmndApi } from "@/entities/elapr/api/elapr-dmnd-service";
|
||||
import type { ElaprDocPrgrsClientResponseDto } from "@/entities/instl-sncn";
|
||||
import { dayjs, useAlertStore, useUserStore } from "@/shared/lib";
|
||||
|
||||
import { getApproverStatus, sortByAtrzSeqNo } from "../../lib/approval-utils";
|
||||
|
||||
interface ApprovalInfoSectionProps {
|
||||
docNo: string;
|
||||
docTitle: string;
|
||||
docContent: string;
|
||||
elaprDocPrgrsList: ElaprDocPrgrsClientResponseDto[];
|
||||
onRefresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_APPROVER_COLUMNS = [
|
||||
{ code: "ATRZ01", label: "접수자" },
|
||||
{ code: "ATRZ02", label: "검토자" },
|
||||
{ code: "ATRZ03", label: "승인자" },
|
||||
{ code: "ATRZ99", label: "최종승인자" },
|
||||
] as const;
|
||||
|
||||
function ApprovalInfoSection({
|
||||
docNo,
|
||||
docTitle,
|
||||
docContent,
|
||||
elaprDocPrgrsList,
|
||||
onRefresh,
|
||||
}: ApprovalInfoSectionProps) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
|
||||
const orderedApprovers = sortByAtrzSeqNo(elaprDocPrgrsList);
|
||||
|
||||
const renderApproverCell = (row: ElaprDocPrgrsClientResponseDto | undefined) => {
|
||||
if (!row) return <div className="text-center">-</div>;
|
||||
|
||||
const status = row.atrzPrgrsSttsComCd ?? "";
|
||||
const st = getApproverStatus(status);
|
||||
const canApprove =
|
||||
status === "ATRZ_PRGRS_002" &&
|
||||
row.elaprPrgrsId &&
|
||||
userInfo?.userUnqId === row.aprvrUuid;
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="font-semibold">{row.aprvrUuidNm ?? "-"}</div>
|
||||
<div className="text-[0.95rem] text-gray-500">
|
||||
{row.ognzNm ?? ""}
|
||||
{row.jbpsSeNm || row.jbgdSeNm
|
||||
? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}`
|
||||
: ""}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center justify-center gap-2 text-[0.9rem]">
|
||||
{row.atrzDt ? (
|
||||
<span className="text-gray-400">{dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}</span>
|
||||
) : null}
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${st.badgeClass}`}
|
||||
>
|
||||
{st.label}
|
||||
</span>
|
||||
{canApprove ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void handleApprove(String(row.elaprPrgrsId))}
|
||||
>
|
||||
결재
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleApprove = async (elaprPrgrsId: string) => {
|
||||
try {
|
||||
await processElaprPrgrs(elaprPrgrsId, "ATRZ_PRGRS_003");
|
||||
alert("결재가 완료되었습니다.");
|
||||
await onRefresh();
|
||||
} catch {
|
||||
alert("결재 처리 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignApprover = async () => {
|
||||
try {
|
||||
await ElaprDmndApi.create({
|
||||
elaprDocNo: docNo,
|
||||
elaprTmpltId: "TMPLT20260611000001",
|
||||
dmndTtl: docTitle,
|
||||
dmndCn: docContent,
|
||||
});
|
||||
alert("결재자 지정(결재요청 등록)이 완료되었습니다.");
|
||||
await onRefresh();
|
||||
} catch {
|
||||
alert("결재자 지정 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cnfm-card">
|
||||
<h3 className="text-[1.5rem] font-semibold">결재 정보</h3>
|
||||
<div className="cnfm-table-wrap mt-3">
|
||||
<table className="cnfm-table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
{orderedApprovers.length === 0
|
||||
? DEFAULT_APPROVER_COLUMNS.map((col) => (
|
||||
<th key={col.code} className="text-center">
|
||||
{col.label}
|
||||
</th>
|
||||
))
|
||||
: orderedApprovers.map((row) => (
|
||||
<th
|
||||
key={String(row.elaprPrgrsId ?? row.atrzSeComCd)}
|
||||
className="text-center"
|
||||
>
|
||||
{row.atrzSeComNm ?? row.atrzSeComCd}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{orderedApprovers.length === 0
|
||||
? DEFAULT_APPROVER_COLUMNS.map((col) => (
|
||||
<td key={col.code}>
|
||||
{renderApproverCell(
|
||||
elaprDocPrgrsList.find((row) => row.atrzSeComCd === col.code),
|
||||
)}
|
||||
</td>
|
||||
))
|
||||
: orderedApprovers.map((row) => (
|
||||
<td key={String(row.elaprPrgrsId ?? row.atrzSeComCd)}>
|
||||
{renderApproverCell(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-4 text-right">
|
||||
<Button type="button" onClick={() => void handleAssignApprover()}>
|
||||
결재자 지정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ApprovalInfoSection };
|
||||
@@ -0,0 +1,492 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import { deleteAttachment } from "@/shared/lib/attach";
|
||||
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
|
||||
import type {
|
||||
AtflMngClientResponseDto,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
} from "@/entities/instl-sncn";
|
||||
import {
|
||||
Button,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import type { CnfmRegistFormData } from "../../lib/cnfm-regist-schema";
|
||||
import { SectionCollapsible } from "../components/section-collapsible";
|
||||
|
||||
interface AttachmentsFormSectionProps {
|
||||
docSeComCd: string;
|
||||
initialEsntlFiles?: EsntlAtchFileMngTreeResponseDto[];
|
||||
initialEtcFiles?: AtflMngClientResponseDto[];
|
||||
}
|
||||
|
||||
interface UploadedFileItem {
|
||||
file?: File;
|
||||
fileName: string;
|
||||
fileKey: string;
|
||||
atflId?: string;
|
||||
atflCd?: string;
|
||||
}
|
||||
|
||||
interface EtcFileItem {
|
||||
file?: File;
|
||||
fileName: string;
|
||||
fileKey: string;
|
||||
atflId?: string;
|
||||
atflCd?: string;
|
||||
}
|
||||
|
||||
interface UploadMapping {
|
||||
esntlAtchFileId: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
const ETC_FILE_KEY_PREFIX = "etc::";
|
||||
|
||||
function createUploadKey(esntlAtchFileId: string, file: File) {
|
||||
return `${esntlAtchFileId}::${getDefaultFileKey(file)}`;
|
||||
}
|
||||
|
||||
function createEtcUploadKey(file: File) {
|
||||
return `${ETC_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
|
||||
}
|
||||
|
||||
function isEtcFileKey(fileKey: string) {
|
||||
return fileKey.startsWith(ETC_FILE_KEY_PREFIX);
|
||||
}
|
||||
|
||||
function AttachmentsFormSection({
|
||||
docSeComCd,
|
||||
initialEsntlFiles,
|
||||
initialEtcFiles,
|
||||
}: AttachmentsFormSectionProps) {
|
||||
const { control, getValues, setValue } = useFormContext<CnfmRegistFormData>();
|
||||
const queryClient = useQueryClient();
|
||||
const [uploadedFiles, setUploadedFiles] = useState<Record<string, UploadedFileItem[]>>({});
|
||||
const [etcFiles, setEtcFiles] = useState<EtcFileItem[]>([]);
|
||||
const initialFilesMappedRef = useRef(false);
|
||||
|
||||
const uploadMappingRef = useRef<Map<string, UploadMapping>>(new Map());
|
||||
const atflCdBySlotRef = useRef<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFilesMappedRef.current) return;
|
||||
if (!initialEsntlFiles?.length && !initialEtcFiles?.length) return;
|
||||
|
||||
if (initialEsntlFiles?.length) {
|
||||
const nextUploadedFiles: Record<string, UploadedFileItem[]> = {};
|
||||
|
||||
for (const file of initialEsntlFiles) {
|
||||
if (!file.esntlAtchFileId || !file.atflCd || !file.atflId) continue;
|
||||
|
||||
const fileKey = `server::${file.esntlAtchFileId}::${file.atflId}`;
|
||||
const fileName = file.orgnlFileNm ?? "첨부파일";
|
||||
|
||||
nextUploadedFiles[file.esntlAtchFileId] = [
|
||||
...(nextUploadedFiles[file.esntlAtchFileId] ?? []),
|
||||
{
|
||||
fileName,
|
||||
fileKey,
|
||||
atflId: file.atflId,
|
||||
atflCd: file.atflCd,
|
||||
},
|
||||
];
|
||||
atflCdBySlotRef.current[file.esntlAtchFileId] = file.atflCd;
|
||||
}
|
||||
|
||||
setUploadedFiles(nextUploadedFiles);
|
||||
}
|
||||
|
||||
if (initialEtcFiles?.length) {
|
||||
setEtcFiles(
|
||||
initialEtcFiles.map((file) => ({
|
||||
fileName: file.orgnlFileNm ?? "첨부파일",
|
||||
fileKey: `server::etc::${file.atflId}`,
|
||||
atflId: file.atflId,
|
||||
atflCd: file.atflCd,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
initialFilesMappedRef.current = true;
|
||||
}, [initialEsntlFiles, initialEtcFiles]);
|
||||
|
||||
const { uploadFileAsync, isUploading, uploads } = useTus({
|
||||
onSuccess: (atflCd, atflId, _file, fileKey) => {
|
||||
if (!atflCd || !atflId) return;
|
||||
|
||||
if (isEtcFileKey(fileKey)) {
|
||||
if (!getValues("instlSncnMng.atchFileGroupId")) {
|
||||
setValue("instlSncnMng.atchFileGroupId", atflCd, { shouldDirty: true });
|
||||
}
|
||||
|
||||
setEtcFiles((prev) =>
|
||||
prev.map((item) => (item.fileKey === fileKey ? { ...item, atflCd, atflId } : item)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const mapping = uploadMappingRef.current.get(fileKey);
|
||||
if (!mapping) return;
|
||||
|
||||
const { esntlAtchFileId } = mapping;
|
||||
atflCdBySlotRef.current[esntlAtchFileId] = atflCd;
|
||||
|
||||
setUploadedFiles((prev) => ({
|
||||
...prev,
|
||||
[esntlAtchFileId]: (prev[esntlAtchFileId] ?? []).map((item) =>
|
||||
item.fileKey === fileKey ? { ...item, atflCd, atflId } : item,
|
||||
),
|
||||
}));
|
||||
|
||||
const currentList = getValues("esntlAtchFileList");
|
||||
const existingIndex = currentList.findIndex(
|
||||
(item) => item.esntlAtchFileId === esntlAtchFileId,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const nextList = [...currentList];
|
||||
nextList[existingIndex] = { esntlAtchFileId, atchFileGroupId: atflCd };
|
||||
setValue("esntlAtchFileList", nextList, { shouldDirty: true });
|
||||
} else {
|
||||
setValue(
|
||||
"esntlAtchFileList",
|
||||
[...currentList, { esntlAtchFileId, atchFileGroupId: atflCd }],
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}
|
||||
|
||||
uploadMappingRef.current.delete(fileKey);
|
||||
},
|
||||
onError: (_error, _file, fileKey) => {
|
||||
if (isEtcFileKey(fileKey)) {
|
||||
setEtcFiles((prev) => prev.filter((item) => item.fileKey !== fileKey));
|
||||
return;
|
||||
}
|
||||
|
||||
uploadMappingRef.current.delete(fileKey);
|
||||
|
||||
setUploadedFiles((prev) => {
|
||||
const next = { ...prev };
|
||||
|
||||
Object.keys(next).forEach((esntlAtchFileId) => {
|
||||
next[esntlAtchFileId] = next[esntlAtchFileId].filter((item) => item.fileKey !== fileKey);
|
||||
if (next[esntlAtchFileId].length === 0) {
|
||||
delete next[esntlAtchFileId];
|
||||
}
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data: essentialFiles = [], isPending: isLoadingEssentialFiles } = useEsntlAtchFileMngTree(
|
||||
{ docSeComCd },
|
||||
);
|
||||
|
||||
async function invalidateAttachmentQueries() {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.AS_SERVICE.ESNTL_ATCH_FILE, "tree", { docSeComCd }],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS],
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEtcFileUpload(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const fileArray = Array.from(files);
|
||||
let etcAtflCd = getValues("instlSncnMng.atchFileGroupId") ?? "";
|
||||
|
||||
for (const file of fileArray) {
|
||||
const fileKey = createEtcUploadKey(file);
|
||||
|
||||
setEtcFiles((prev) => [...prev, { file, fileName: file.name, fileKey }]);
|
||||
|
||||
try {
|
||||
const atflCd = await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: etcAtflCd ? { atflCd: etcAtflCd } : undefined,
|
||||
});
|
||||
|
||||
if (!etcAtflCd) {
|
||||
etcAtflCd = atflCd;
|
||||
setValue("instlSncnMng.atchFileGroupId", atflCd, { shouldDirty: true });
|
||||
}
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEtcFile(fileIndex: number) {
|
||||
const fileToRemove = etcFiles[fileIndex];
|
||||
if (!fileToRemove) return;
|
||||
|
||||
uploads[fileToRemove.fileKey]?.abort();
|
||||
|
||||
if (fileToRemove.atflId) {
|
||||
try {
|
||||
await deleteAttachment(fileToRemove.atflId);
|
||||
await invalidateAttachmentQueries();
|
||||
} catch (error) {
|
||||
console.error("기타 첨부 파일 삭제 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const nextFiles = etcFiles.filter((_, index) => index !== fileIndex);
|
||||
setEtcFiles(nextFiles);
|
||||
|
||||
if (nextFiles.length === 0) {
|
||||
setValue("instlSncnMng.atchFileGroupId", "", { shouldDirty: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEssentialFile(esntlAtchFileId: string, fileIndex: number) {
|
||||
const target = uploadedFiles[esntlAtchFileId]?.[fileIndex];
|
||||
if (!target) return;
|
||||
|
||||
const { fileKey, atflId } = target;
|
||||
uploads[fileKey]?.abort();
|
||||
uploadMappingRef.current.delete(fileKey);
|
||||
|
||||
if (atflId) {
|
||||
try {
|
||||
await deleteAttachment(atflId);
|
||||
await invalidateAttachmentQueries();
|
||||
} catch (error) {
|
||||
console.error("파일 삭제 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedList = (uploadedFiles[esntlAtchFileId] ?? []).filter((_, index) => index !== fileIndex);
|
||||
const isSlotEmpty = updatedList.length === 0;
|
||||
|
||||
setUploadedFiles((prev) => {
|
||||
const next = { ...prev };
|
||||
|
||||
if (isSlotEmpty) {
|
||||
delete next[esntlAtchFileId];
|
||||
} else {
|
||||
next[esntlAtchFileId] = updatedList;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
|
||||
if (isSlotEmpty) {
|
||||
delete atflCdBySlotRef.current[esntlAtchFileId];
|
||||
|
||||
const currentList = getValues("esntlAtchFileList");
|
||||
setValue(
|
||||
"esntlAtchFileList",
|
||||
currentList.filter((item) => item.esntlAtchFileId !== esntlAtchFileId),
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEssentialFileUpload(esntlAtchFileId: string, files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const fileArray = Array.from(files);
|
||||
|
||||
for (const file of fileArray) {
|
||||
const fileKey = createUploadKey(esntlAtchFileId, file);
|
||||
const existingAtflCd = atflCdBySlotRef.current[esntlAtchFileId] ?? "";
|
||||
|
||||
setUploadedFiles((prev) => ({
|
||||
...prev,
|
||||
[esntlAtchFileId]: [...(prev[esntlAtchFileId] ?? []), { file, fileName: file.name, fileKey }],
|
||||
}));
|
||||
|
||||
uploadMappingRef.current.set(fileKey, { esntlAtchFileId, file });
|
||||
|
||||
try {
|
||||
const atflCd = await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
|
||||
if (!existingAtflCd) {
|
||||
atflCdBySlotRef.current[esntlAtchFileId] = atflCd;
|
||||
}
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingEssentialFiles) {
|
||||
return (
|
||||
<SectionCollapsible title="첨부파일">
|
||||
<div className="space-y-4 p-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="첨부파일">
|
||||
<div className="space-y-6">
|
||||
<Controller
|
||||
name="esntlAtchFileList"
|
||||
control={control}
|
||||
render={() => (
|
||||
<div className="px-2">
|
||||
<h3 className="mb-4 text-xl font-semibold">필수 첨부파일</h3>
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="60%" />
|
||||
<col width="40%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30rem]" align="left">
|
||||
파일명
|
||||
</TableHead>
|
||||
<TableHead>업로드된 파일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{essentialFiles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
|
||||
조회된 필수 첨부파일 항목이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
essentialFiles.map((file) => (
|
||||
<TableRow key={file.esntlAtchFileId}>
|
||||
<TableHead
|
||||
className="break-keep font-medium"
|
||||
data-required={file.esntlAtchYn === "Y"}
|
||||
align="left"
|
||||
>
|
||||
{file.esntlAtchFileNm}
|
||||
</TableHead>
|
||||
<TableCell className="space-y-2">
|
||||
{uploadedFiles[file.esntlAtchFileId]?.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{uploadedFiles[file.esntlAtchFileId].map((item, idx) => {
|
||||
const uploading = isUploading[item.fileKey] ?? false;
|
||||
|
||||
return (
|
||||
<div key={item.fileKey} className="flex items-center gap-2">
|
||||
<span className="text-xl">{item.file?.name ?? item.fileName}</span>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void removeEssentialFile(file.esntlAtchFileId, idx);
|
||||
}}
|
||||
disabled={uploading}
|
||||
className="size-8 !bg-destructive"
|
||||
>
|
||||
<i className="diveicon di-x text-xs" aria-label="삭제" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void handleEssentialFileUpload(file.esntlAtchFileId, e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<span>파일 선택</span>
|
||||
</Button>
|
||||
</label>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="px-2">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold">기타 첨부파일</h3>
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void handleEtcFileUpload(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<span>파일 추가</span>
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
<div className="rounded border p-4">
|
||||
{etcFiles.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{etcFiles.map((item, idx) => {
|
||||
const uploading = isUploading[item.fileKey] ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.fileKey}
|
||||
className="flex items-center justify-between rounded-sm border p-2"
|
||||
>
|
||||
<span className="px-2 text-xl">{item.file?.name ?? item.fileName}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void removeEtcFile(idx);
|
||||
}}
|
||||
disabled={uploading}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-6 text-center text-xl text-muted-foreground">
|
||||
파일을 추가해주세요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { AttachmentsFormSection };
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { PROXY_HOST } from "@/shared/config";
|
||||
import { formatFileSize } from "@/shared/lib/file-utils";
|
||||
import type {
|
||||
AtflMngClientResponseDto,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
} from "@/entities/instl-sncn";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { SectionCollapsible } from "../components/section-collapsible";
|
||||
|
||||
interface AttachmentsSectionProps {
|
||||
esntlFileList: EsntlAtchFileMngTreeResponseDto[];
|
||||
etcFileList: AtflMngClientResponseDto[];
|
||||
}
|
||||
|
||||
function getDownloadUrl(atflCd: string) {
|
||||
return `${PROXY_HOST}/attach-service/api/v1/atflcd/download/${atflCd}`;
|
||||
}
|
||||
|
||||
function AttachmentsSection({ esntlFileList, etcFileList }: AttachmentsSectionProps) {
|
||||
return (
|
||||
<>
|
||||
<SectionCollapsible title="필수 첨부파일">
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>필수 자료명</TableHead>
|
||||
<TableHead>파일명</TableHead>
|
||||
<TableHead className="w-[8rem] text-right">크기</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{esntlFileList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="py-12 text-center text-muted-foreground">
|
||||
조회된 필수 첨부파일 항목이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
esntlFileList.map((file) => (
|
||||
<TableRow key={file.esntlAtchFileId}>
|
||||
<TableCell className="pl-2 text-left">
|
||||
{file.esntlAtchFileNm}
|
||||
{file.esntlAtchYn === "Y" ? (
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-left">
|
||||
{file.atflCd ? (
|
||||
<a
|
||||
className="text-blue-600 hover:underline"
|
||||
href={getDownloadUrl(file.atflCd)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{file.orgnlFileNm || "다운로드"}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-500">첨부없음</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{formatFileSize(file.atflFlieSz)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
|
||||
<SectionCollapsible title="기타 첨부파일">
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>파일명</TableHead>
|
||||
<TableHead className="w-[8rem] text-right">크기</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{etcFileList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="py-12 text-center text-muted-foreground">
|
||||
등록된 기타첨부파일이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
etcFileList.map((file) => (
|
||||
<TableRow key={file.atflId}>
|
||||
<TableCell className="text-left">
|
||||
{file.atflCd ? (
|
||||
<a
|
||||
className="text-blue-600 hover:underline"
|
||||
href={getDownloadUrl(file.atflCd)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{file.orgnlFileNm || file.physFileNm || file.atflId}
|
||||
</a>
|
||||
) : (
|
||||
<span>{file.orgnlFileNm || file.physFileNm || file.atflId}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-gray-500">
|
||||
{formatFileSize(file.atflFlieSz)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { AttachmentsSection };
|
||||
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { useCodeList } from "@/entities/code";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import type { CnfmRegistFormData } from "../../lib/cnfm-regist-schema";
|
||||
import { type BnsApplyData, BnsPickerDialog } from "../components/bns-picker-dialog";
|
||||
import { SectionCollapsible } from "../components/section-collapsible";
|
||||
|
||||
function BasicInfoFormSection() {
|
||||
const { control, watch, setValue } = useFormContext<CnfmRegistFormData>();
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
const taskSeComCd = watch("instlSncnMng.taskSeComCd");
|
||||
|
||||
function handleBnsSelect(data: BnsApplyData) {
|
||||
setValue("instlSncnMng.bizNm", data.bizNm);
|
||||
setValue("instlSncnMng.bizBgngYr", data.bizBgngYr);
|
||||
setValue("instlSncnMng.bizEndYr", data.bizEndYr);
|
||||
setValue("instlSncnMng.bizCn", data.bizCn);
|
||||
setValue("instlSncnMng.bgtBizCd", data.bgtBizCd);
|
||||
setValue("instlSncnMng.bgtAplyYr", data.bgtAplyYr);
|
||||
}
|
||||
|
||||
const { data: taskOptions } = useCodeList("INSTL");
|
||||
const { data: sncnOptions } = useCodeList("SNCNSE");
|
||||
const { data: rtlztnFcltOptions } = useCodeList("RTLZTNFCLTSE");
|
||||
const { data: rtlztnUsgOptions } = useCodeList("RTLZTNUSGSE");
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="기본 정보">
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[20rem]" data-required>
|
||||
사업명
|
||||
</TableHead>
|
||||
<TableCell colSpan={3}>
|
||||
<Controller
|
||||
name="instlSncnMng.bizNm"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
{...field}
|
||||
id={field.name}
|
||||
placeholder="사업명 입력"
|
||||
aria-invalid={fieldState.invalid}
|
||||
className="w-[40rem]"
|
||||
/>
|
||||
<Button type="button" onClick={() => setIsPickerOpen(true)}>
|
||||
사업찾기
|
||||
</Button>
|
||||
</div>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead data-required>사업기간</TableHead>
|
||||
<TableCell colSpan={3}>
|
||||
<FieldGroup className="flex-row items-center gap-2">
|
||||
<Controller
|
||||
name="instlSncnMng.bizBgngYr"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid} className="w-auto">
|
||||
<Input
|
||||
{...field}
|
||||
id={field.name}
|
||||
placeholder="시작연도"
|
||||
className="max-w-[12rem]"
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<span>~</span>
|
||||
<Controller
|
||||
name="instlSncnMng.bizEndYr"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid} className="w-auto">
|
||||
<Input
|
||||
{...field}
|
||||
id={field.name}
|
||||
placeholder="종료연도"
|
||||
className="max-w-[12rem]"
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead data-required>사업내용</TableHead>
|
||||
<TableCell colSpan={3}>
|
||||
<Controller
|
||||
name="instlSncnMng.bizCn"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
{...field}
|
||||
id={field.name}
|
||||
placeholder="사업내용 입력"
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead data-required>구분</TableHead>
|
||||
<TableCell colSpan={3}>
|
||||
<Controller
|
||||
name="instlSncnMng.taskSeComCd"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FieldSet>
|
||||
<FieldGroup data-slot="checkbox-group" className="flex-row">
|
||||
{taskOptions?.map((option) => (
|
||||
<Field
|
||||
key={option.lwpoCd}
|
||||
orientation="horizontal"
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-auto"
|
||||
>
|
||||
<Checkbox
|
||||
id={`task-${option.lwpoCd}`}
|
||||
name={field.name}
|
||||
aria-invalid={fieldState.invalid}
|
||||
checked={field.value.includes(option.lwpoCd)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...field.value, option.lwpoCd]
|
||||
: field.value.filter((value) => value !== option.lwpoCd);
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
<FieldLabel htmlFor={`task-${option.lwpoCd}`} className="font-normal">
|
||||
{option.lwpoCdNm}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
))}
|
||||
</FieldGroup>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</FieldSet>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>신청 지자체</TableHead>
|
||||
<TableCell colSpan={3}>서울특별시 강남구</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead data-required>사업구분</TableHead>
|
||||
<TableCell colSpan={3}>
|
||||
<Controller
|
||||
name="instlSncnMng.sncnSeComCd"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FieldSet>
|
||||
<RadioGroup
|
||||
name={field.name}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
{sncnOptions?.map((option) => (
|
||||
<FieldLabel
|
||||
key={option.lwpoCd}
|
||||
htmlFor={`sncn-${option.lwpoCd}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-auto"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option.lwpoCd}
|
||||
id={`sncn-${option.lwpoCd}`}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<span className="font-normal">{option.lwpoCdNm}</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</FieldSet>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{taskSeComCd.includes("RE") && (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableHead>시설종류</TableHead>
|
||||
<TableCell colSpan={3}>
|
||||
<Controller
|
||||
name="instlSncnMng.rtlztnFcltSeComCd"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FieldSet>
|
||||
<RadioGroup
|
||||
name={field.name}
|
||||
value={field.value || ""}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
{rtlztnFcltOptions?.map((option) => (
|
||||
<FieldLabel
|
||||
key={option.lwpoCd}
|
||||
htmlFor={`rtlztn-fclt-${option.lwpoCd}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-auto"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option.lwpoCd}
|
||||
id={`rtlztn-fclt-${option.lwpoCd}`}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<span className="font-normal">{option.lwpoCdNm}</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</FieldSet>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>재이용용도</TableHead>
|
||||
<TableCell colSpan={3}>
|
||||
<Controller
|
||||
name="instlSncnMng.rtlztnUsgSeComCd"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FieldSet>
|
||||
<FieldGroup data-slot="checkbox-group" className="flex-row">
|
||||
{rtlztnUsgOptions?.map((option) => (
|
||||
<Field
|
||||
key={option.lwpoCd}
|
||||
orientation="horizontal"
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-auto"
|
||||
>
|
||||
<Checkbox
|
||||
id={`rtlztn-usg-${option.lwpoCd}`}
|
||||
name={field.name}
|
||||
aria-invalid={fieldState.invalid}
|
||||
checked={field.value?.includes(option.lwpoCd) ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentValue = field.value || "";
|
||||
const values = currentValue ? currentValue.split(",") : [];
|
||||
const newValue = checked
|
||||
? [...values, option.lwpoCd]
|
||||
: values.filter((value) => value !== option.lwpoCd);
|
||||
field.onChange(newValue.join(","));
|
||||
}}
|
||||
/>
|
||||
<FieldLabel
|
||||
htmlFor={`rtlztn-usg-${option.lwpoCd}`}
|
||||
className="font-normal"
|
||||
>
|
||||
{option.lwpoCdNm}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
))}
|
||||
</FieldGroup>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</FieldSet>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
)}
|
||||
</TableHeader>
|
||||
</Table>
|
||||
|
||||
<BnsPickerDialog
|
||||
open={isPickerOpen}
|
||||
onClose={() => setIsPickerOpen(false)}
|
||||
onSelect={handleBnsSelect}
|
||||
/>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { BasicInfoFormSection };
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
|
||||
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
import { SectionCollapsible } from "../components/section-collapsible";
|
||||
|
||||
interface BasicInfoSectionProps {
|
||||
det: InstlSncnMngResponseDto;
|
||||
}
|
||||
|
||||
function BasicInfoSection({ det }: BasicInfoSectionProps) {
|
||||
return (
|
||||
<SectionCollapsible title="기본 정보">
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>사업명</TableHead>
|
||||
<TableCell>{det.bizNm || "-"}</TableCell>
|
||||
<TableHead>사업기간</TableHead>
|
||||
<TableCell>{(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>사업내용</TableHead>
|
||||
<TableCell colSpan={3}>{det.bizCn || "-"}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableCell>{det.taskSeCdNm || "-"}</TableCell>
|
||||
<TableHead>신청지자체</TableHead>
|
||||
<TableCell>
|
||||
{det.sido || "-"} {det.gugun || "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>사업구분</TableHead>
|
||||
<TableCell colSpan={3}>{det.sncnSeCdNm || "-"}</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { BasicInfoSection };
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { useAcntgSeMngTree } from "@/entities/acntg";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import {
|
||||
Input,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import {
|
||||
calculateDiffPercent,
|
||||
type CostItemDef,
|
||||
recalculateBizCstTotals,
|
||||
} from "../../lib/business-cost-form-utils";
|
||||
import type { CnfmRegistFormData } from "../../lib/cnfm-regist-schema";
|
||||
import { SectionCollapsible } from "../components/section-collapsible";
|
||||
|
||||
interface BusinessCostItemDef extends CostItemDef {
|
||||
label: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
type CostFieldKey = "bscPlanAmt" | "bgtRfltAmt" | "tstmDesignAmt";
|
||||
|
||||
function BusinessCostFormSection() {
|
||||
const { control, setValue, getValues } = useFormContext<CnfmRegistFormData>();
|
||||
const bizCstList = useWatch({ control, name: "bizCstList" }) ?? [];
|
||||
|
||||
const { data: acntgTree = [] } = useAcntgSeMngTree();
|
||||
|
||||
const costItems = useMemo<BusinessCostItemDef[]>(() => {
|
||||
if (acntgTree.length === 0) return [];
|
||||
|
||||
const childrenMap = new Map<string, string[]>();
|
||||
acntgTree.forEach((node) => {
|
||||
if (node.upAcntgSeCd) {
|
||||
const siblings = childrenMap.get(node.upAcntgSeCd) || [];
|
||||
siblings.push(node.acntgSeCd);
|
||||
childrenMap.set(node.upAcntgSeCd, siblings);
|
||||
}
|
||||
});
|
||||
|
||||
return acntgTree
|
||||
.filter((node) => node.useYn === "Y")
|
||||
.map((node) => {
|
||||
const children = childrenMap.get(node.acntgSeCd);
|
||||
return {
|
||||
code: node.acntgSeCd,
|
||||
label: node.acntgSeCdNm,
|
||||
level: node.treeLevel,
|
||||
readonly: Boolean(children && children.length > 0),
|
||||
children,
|
||||
};
|
||||
});
|
||||
}, [acntgTree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (costItems.length === 0) return;
|
||||
|
||||
const existingMap = new Map(getValues("bizCstList").map((item) => [item.acntgSeCd, item]));
|
||||
|
||||
setValue(
|
||||
"bizCstList",
|
||||
costItems.map((item) => {
|
||||
const existing = existingMap.get(item.code);
|
||||
if (existing) return existing;
|
||||
|
||||
return {
|
||||
acntgSeCd: item.code,
|
||||
bscPlanAmt: 0,
|
||||
bgtRfltAmt: 0,
|
||||
tstmDesignAmt: 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, [costItems, getValues, setValue]);
|
||||
|
||||
function handleCostChange(index: number, fieldKey: CostFieldKey, value: string) {
|
||||
const numValue = Number(value) || 0;
|
||||
const draft = [...getValues("bizCstList")];
|
||||
draft[index] = { ...draft[index], [fieldKey]: numValue };
|
||||
const next = recalculateBizCstTotals(draft, costItems);
|
||||
setValue("bizCstList", next, { shouldDirty: true });
|
||||
}
|
||||
|
||||
if (costItems.length === 0) {
|
||||
return (
|
||||
<SectionCollapsible title="총사업비">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-[8rem] rounded-xl" />
|
||||
<Skeleton className="h-[64rem] rounded-xl" />
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="총사업비">
|
||||
<div className="overflow-x-auto">
|
||||
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
|
||||
<Table variant="secondary" className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30rem]" rowSpan={2}>
|
||||
구분
|
||||
</TableHead>
|
||||
<TableHead rowSpan={2}>예산반영(A)</TableHead>
|
||||
<TableHead rowSpan={2}>표준사업비(B)</TableHead>
|
||||
<TableHead rowSpan={2}>금회설계시(C)</TableHead>
|
||||
<TableHead colSpan={2}>증감</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="w-[12rem]">(C-B)</TableHead>
|
||||
<TableHead className="w-[6rem]">%</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{costItems.map((item, idx) => {
|
||||
const data = bizCstList[idx];
|
||||
if (!data) return null;
|
||||
|
||||
const diff = (data.tstmDesignAmt || 0) - (data.bgtRfltAmt || 0);
|
||||
const paddingLeft = `${item.level * 1.6}rem`;
|
||||
const showSubIcon = item.level >= 3;
|
||||
|
||||
return (
|
||||
<TableRow key={item.code}>
|
||||
<TableHead className="text-left" style={{ paddingLeft }}>
|
||||
{showSubIcon && <span className="mr-1 text-gray-400">└</span>}
|
||||
{item.label}
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`bizCstList.${idx}.bscPlanAmt`}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value || ""}
|
||||
onChange={(e) => handleCostChange(idx, "bscPlanAmt", e.target.value)}
|
||||
readOnly={item.readonly}
|
||||
aria-invalid={fieldState.invalid}
|
||||
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`bizCstList.${idx}.bgtRfltAmt`}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value || ""}
|
||||
onChange={(e) => handleCostChange(idx, "bgtRfltAmt", e.target.value)}
|
||||
readOnly={item.readonly}
|
||||
aria-invalid={fieldState.invalid}
|
||||
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`bizCstList.${idx}.tstmDesignAmt`}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value || ""}
|
||||
onChange={(e) => handleCostChange(idx, "tstmDesignAmt", e.target.value)}
|
||||
readOnly={item.readonly}
|
||||
aria-invalid={fieldState.invalid}
|
||||
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">
|
||||
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">
|
||||
{calculateDiffPercent(diff, data.bgtRfltAmt || 0)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { BusinessCostFormSection };
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { BizCstTreeDto } from "@/entities/instl-sncn";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
import { sortBizCstListByTreePath } from "../../lib/business-cost-utils";
|
||||
import { calcDiffPercent, fmtNumber, indentPx } from "../../lib/format-utils";
|
||||
import { SectionCollapsible } from "../components/section-collapsible";
|
||||
|
||||
interface BusinessCostSectionProps {
|
||||
bizCstList: BizCstTreeDto[];
|
||||
}
|
||||
|
||||
function BusinessCostSection({ bizCstList }: BusinessCostSectionProps) {
|
||||
const sortedBizCstList = useMemo(() => sortBizCstListByTreePath(bizCstList), [bizCstList]);
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="총사업비">
|
||||
<div className="overflow-x-auto">
|
||||
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
|
||||
<Table variant="secondary" className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30rem]" rowSpan={2}>
|
||||
구분
|
||||
</TableHead>
|
||||
<TableHead rowSpan={2}>예산반영(A)</TableHead>
|
||||
<TableHead rowSpan={2}>표준사업비(B)</TableHead>
|
||||
<TableHead rowSpan={2}>금회설계시(C)</TableHead>
|
||||
<TableHead colSpan={2}>증감</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="w-[12rem]">(C-B)</TableHead>
|
||||
<TableHead className="w-[6rem]">%</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedBizCstList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-12 text-center text-muted-foreground">
|
||||
조회된 총사업비 항목이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sortedBizCstList.map((node) => {
|
||||
const pl = indentPx(node.indAcntgSeCdNm);
|
||||
const bscPlanAmt = Number(node.bscPlanAmt) || 0;
|
||||
const budgetReflct = Number(node.bgtRfltAmt) || 0;
|
||||
const tstmDesignAmt = Number(node.tstmDesignAmt) || 0;
|
||||
const diff = tstmDesignAmt - budgetReflct;
|
||||
const showSubIcon = node.treeLevel >= 3;
|
||||
|
||||
return (
|
||||
<TableRow key={node.acntgSeCd}>
|
||||
{node.treeLevel <= 1 ? (
|
||||
<TableHead className="!px-[0.8rem] text-left">{node.acntgSeCdNm}</TableHead>
|
||||
) : (
|
||||
<TableHead className="text-left" style={{ paddingLeft: `${pl + 8}px` }}>
|
||||
{showSubIcon && <span className="mr-1 text-gray-400">└</span>}
|
||||
{node.acntgSeCdNm}
|
||||
</TableHead>
|
||||
)}
|
||||
<TableCell>
|
||||
<div className="text-right">{fmtNumber(bscPlanAmt)}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">{fmtNumber(budgetReflct)}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">{fmtNumber(tstmDesignAmt)}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">
|
||||
{diff > 0 ? `+${fmtNumber(diff)}` : fmtNumber(diff)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">{calcDiffPercent(diff, budgetReflct)}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { BusinessCostSection };
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { useCnstpSeMngPage } from "@/entities/cnstp";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import {
|
||||
Input,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import type { CnfmRegistFormData } from "../../lib/cnfm-regist-schema";
|
||||
import { SectionCollapsible } from "../components/section-collapsible";
|
||||
|
||||
type ScaleFieldKey = "bscPl" | "budgetReflct" | "thtiDsgn";
|
||||
|
||||
function BusinessScaleFormSection() {
|
||||
const { control, setValue, getValues } = useFormContext<CnfmRegistFormData>();
|
||||
const bzScaleList = useWatch({ control, name: "bzScaleList" }) ?? [];
|
||||
|
||||
const { data: cnstpSeList } = useCnstpSeMngPage({ useYn: "Y" });
|
||||
|
||||
const cnstpItems =
|
||||
cnstpSeList?.content?.map((item) => ({
|
||||
code: item.cnstpSeCd,
|
||||
label: item.cnstpSeCdNm,
|
||||
})) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!cnstpSeList?.content?.length) return;
|
||||
|
||||
const existingMap = new Map(getValues("bzScaleList").map((item) => [item.cnstpSeCd, item]));
|
||||
|
||||
setValue(
|
||||
"bzScaleList",
|
||||
cnstpSeList.content.map((item) => {
|
||||
const existing = existingMap.get(item.cnstpSeCd);
|
||||
if (existing) return existing;
|
||||
|
||||
return {
|
||||
cnstpSeCd: item.cnstpSeCd,
|
||||
bscPl: 0,
|
||||
budgetReflct: 0,
|
||||
thtiDsgn: 0,
|
||||
diff: 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, [cnstpSeList?.content, getValues, setValue]);
|
||||
|
||||
function handleScaleChange(index: number, fieldKey: ScaleFieldKey, value: string) {
|
||||
const numValue = Number(value) || 0;
|
||||
const list = getValues("bzScaleList");
|
||||
const updated = { ...list[index], [fieldKey]: numValue };
|
||||
|
||||
if (fieldKey === "budgetReflct" || fieldKey === "thtiDsgn") {
|
||||
updated.diff = updated.thtiDsgn - updated.budgetReflct;
|
||||
}
|
||||
|
||||
const newList = [...list];
|
||||
newList[index] = updated;
|
||||
setValue("bzScaleList", newList);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="사업규모">
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30rem]">구분</TableHead>
|
||||
<TableHead>기본계획(A)</TableHead>
|
||||
<TableHead>예산반영(B)</TableHead>
|
||||
<TableHead>금회설계시(C)</TableHead>
|
||||
<TableHead>증감(C-B)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cnstpItems.map((item, idx) => {
|
||||
const data = bzScaleList[idx];
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<TableRow key={item.code}>
|
||||
<TableHead className="text-left">{item.label}</TableHead>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`bzScaleList.${idx}.bscPl`}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value || ""}
|
||||
onChange={(e) => handleScaleChange(idx, "bscPl", e.target.value)}
|
||||
aria-invalid={fieldState.invalid}
|
||||
className="text-right"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`bzScaleList.${idx}.budgetReflct`}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value || ""}
|
||||
onChange={(e) => handleScaleChange(idx, "budgetReflct", e.target.value)}
|
||||
aria-invalid={fieldState.invalid}
|
||||
className="text-right"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`bzScaleList.${idx}.thtiDsgn`}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value || ""}
|
||||
onChange={(e) => handleScaleChange(idx, "thtiDsgn", e.target.value)}
|
||||
aria-invalid={fieldState.invalid}
|
||||
className="text-right"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">
|
||||
{data.diff > 0
|
||||
? `+${data.diff.toLocaleString()}`
|
||||
: data.diff.toLocaleString()}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { BusinessScaleFormSection };
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import type { BizSclTreeDto } from "@/entities/instl-sncn";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
import { fmtNumber, indentPx } from "../../lib/format-utils";
|
||||
import { SectionCollapsible } from "../components/section-collapsible";
|
||||
|
||||
interface BusinessScaleSectionProps {
|
||||
bizSclList: BizSclTreeDto[];
|
||||
}
|
||||
|
||||
function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) {
|
||||
return (
|
||||
<SectionCollapsible title="사업규모">
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30rem]">구분</TableHead>
|
||||
<TableHead>기본계획(A)</TableHead>
|
||||
<TableHead>예산반영(B)</TableHead>
|
||||
<TableHead>금회설계시(C)</TableHead>
|
||||
<TableHead>증감(C-B)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bizSclList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-12 text-center text-muted-foreground">
|
||||
조회된 사업규모 항목이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
bizSclList.map((node) => {
|
||||
const pl = indentPx(node.indCnstpSeCdNm);
|
||||
const bscPlanQty = Number(node.bscPlanQty) || 0;
|
||||
const budgetReflct = Number(node.bgtRfltQty) || 0;
|
||||
const tstmDesignQty = Number(node.tstmDesignQty) || 0;
|
||||
const diff = tstmDesignQty - budgetReflct;
|
||||
|
||||
return (
|
||||
<TableRow key={node.cnstpSeCd}>
|
||||
<TableHead className="text-left" style={{ paddingLeft: `${pl + 8}px` }}>
|
||||
{node.cnstpSeCdNm}
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<div className="text-right">{fmtNumber(bscPlanQty)}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">{fmtNumber(budgetReflct)}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">{fmtNumber(tstmDesignQty)}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">
|
||||
{diff > 0 ? `+${fmtNumber(diff)}` : fmtNumber(diff)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { BusinessScaleSection };
|
||||
@@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { cklstMngService, type CklstTreeNode } from "@/entities/cklst";
|
||||
import { useCodeList } from "@/entities/code";
|
||||
import {
|
||||
Button,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@common/components";
|
||||
|
||||
import { orderCklstTreeNodes, syncCklstResultListFromTree } from "../../lib/checklist-form-utils";
|
||||
import type { CnfmRegistFormData } from "../../lib/cnfm-regist-schema";
|
||||
import { SectionCollapsible } from "../components/section-collapsible";
|
||||
|
||||
interface ChecklistFormSectionProps {
|
||||
docSeComCd: string;
|
||||
}
|
||||
|
||||
interface ChecklistItem {
|
||||
cklstSn: number;
|
||||
cklstArtclSn: number;
|
||||
cklstRvwSn: number;
|
||||
rvwStpComCd: string;
|
||||
title: string;
|
||||
content: string;
|
||||
bchckUseYn: "Y" | "N";
|
||||
treeLevel: number;
|
||||
}
|
||||
|
||||
interface ChecklistTab {
|
||||
code: string;
|
||||
label: string;
|
||||
items: ChecklistItem[];
|
||||
}
|
||||
|
||||
function ChecklistFormSection({ docSeComCd }: ChecklistFormSectionProps) {
|
||||
const { watch, setValue, control, getValues } = useFormContext<CnfmRegistFormData>();
|
||||
const [activeTab, setActiveTab] = useState("");
|
||||
const taskSeComCdArray = watch("instlSncnMng.taskSeComCd");
|
||||
const cklstResultList = useWatch({ control, name: "cklstResultList" }) ?? [];
|
||||
|
||||
const cklstRvwSnIndexMap = useMemo(
|
||||
() => new Map(cklstResultList.map((item, index) => [item.cklstRvwSn, index])),
|
||||
[cklstResultList],
|
||||
);
|
||||
|
||||
const { data: taskCodeList = [] } = useCodeList(docSeComCd);
|
||||
|
||||
const taskCodeLabels = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
taskCodeList.forEach((code) => {
|
||||
map[code.lwpoCd] = code.lwpoCdNm;
|
||||
});
|
||||
return map;
|
||||
}, [taskCodeList]);
|
||||
|
||||
const { data: cklstTree = [], isLoading: isLoadingCklstTree } = useQuery({
|
||||
queryKey: ["cklstTree", docSeComCd, taskSeComCdArray],
|
||||
queryFn: async () => {
|
||||
const codes = (taskSeComCdArray || []).filter(Boolean);
|
||||
if (codes.length === 0) return [];
|
||||
|
||||
const results = await Promise.all(
|
||||
codes.map(async (code) => {
|
||||
const nodes = await cklstMngService.findTree({ docSeComCd, taskSeComCd: code });
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
taskSeComCd: node.taskSeComCd ?? code,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
const dedupedMap = new Map<number, CklstTreeNode>();
|
||||
results.flat().forEach((node) => {
|
||||
if (!dedupedMap.has(node.cklstRvwSn)) {
|
||||
dedupedMap.set(node.cklstRvwSn, node);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(dedupedMap.values());
|
||||
},
|
||||
enabled: Boolean(docSeComCd) && Boolean(taskSeComCdArray?.length),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const checklistTabs = useMemo((): ChecklistTab[] => {
|
||||
if (cklstTree.length === 0) return [];
|
||||
|
||||
const grouped = new Map<string, CklstTreeNode[]>();
|
||||
cklstTree.forEach((node) => {
|
||||
const taskCode = node.taskSeComCd ?? "";
|
||||
if (!taskCode) return;
|
||||
if (!grouped.has(taskCode)) grouped.set(taskCode, []);
|
||||
grouped.get(taskCode)!.push(node);
|
||||
});
|
||||
|
||||
const orderedCodes = (taskSeComCdArray || []).filter((code) => grouped.has(code));
|
||||
|
||||
return orderedCodes.map((code) => ({
|
||||
code,
|
||||
label: taskCodeLabels[code] || code,
|
||||
items: orderCklstTreeNodes(grouped.get(code)!).map((node) => ({
|
||||
cklstSn: node.cklstSn,
|
||||
cklstArtclSn: node.cklstArtclSn,
|
||||
cklstRvwSn: node.cklstRvwSn,
|
||||
rvwStpComCd: "LOCAL",
|
||||
title: node.cklstArtclNm,
|
||||
content: node.rvwCn,
|
||||
bchckUseYn: node.bchckUseYn,
|
||||
treeLevel: node.treeLevel,
|
||||
})),
|
||||
}));
|
||||
}, [cklstTree, taskCodeLabels, taskSeComCdArray]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingCklstTree) return;
|
||||
|
||||
const prev = getValues("cklstResultList");
|
||||
const next = syncCklstResultListFromTree(cklstTree, taskSeComCdArray ?? [], prev);
|
||||
|
||||
if (next !== prev) {
|
||||
setValue("cklstResultList", next, { shouldDirty: false });
|
||||
}
|
||||
}, [cklstTree, taskSeComCdArray, isLoadingCklstTree, getValues, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checklistTabs.length === 0) return;
|
||||
|
||||
const hasActiveTab = checklistTabs.some((tab) => tab.code === activeTab);
|
||||
if (!hasActiveTab) {
|
||||
setActiveTab(checklistTabs[0].code);
|
||||
}
|
||||
}, [checklistTabs, activeTab]);
|
||||
|
||||
function handleSelectAll(tabCode: string, value: "Y" | "N") {
|
||||
const tab = checklistTabs.find((t) => t.code === tabCode);
|
||||
if (!tab) return;
|
||||
|
||||
const currentList = getValues("cklstResultList");
|
||||
const newList = [...currentList];
|
||||
|
||||
tab.items
|
||||
.filter((item) => item.bchckUseYn === "Y")
|
||||
.forEach((item) => {
|
||||
const idx = newList.findIndex((r) => r.cklstRvwSn === item.cklstRvwSn);
|
||||
if (idx >= 0) {
|
||||
newList[idx] = { ...newList[idx], selected: value };
|
||||
}
|
||||
});
|
||||
|
||||
setValue("cklstResultList", newList);
|
||||
}
|
||||
|
||||
const hasSelectedTaskCodes = Boolean(taskSeComCdArray?.length);
|
||||
const isChecklistLoading = isLoadingCklstTree && hasSelectedTaskCodes;
|
||||
|
||||
if (isChecklistLoading) {
|
||||
return (
|
||||
<SectionCollapsible title="체크리스트">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-32" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
if (checklistTabs.length === 0) {
|
||||
return (
|
||||
<SectionCollapsible title="체크리스트">
|
||||
<p className="p-4 text-center text-lg text-muted-foreground">
|
||||
{hasSelectedTaskCodes
|
||||
? "선택된 업무구분에 해당하는 체크리스트가 없습니다."
|
||||
: "업무구분을 선택하면 체크리스트가 표시됩니다."}
|
||||
</p>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="체크리스트">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
{checklistTabs.map((tab) => (
|
||||
<TabsTrigger key={tab.code} value={tab.code} className="!px-6 !py-3 text-xl">
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{checklistTabs.map((tab) => {
|
||||
return (
|
||||
<TabsContent key={tab.code} value={tab.code} className="space-y-4">
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead rowSpan={2} className="w-[30rem]">
|
||||
검토항목
|
||||
</TableHead>
|
||||
<TableHead rowSpan={2}>주요 검토내용</TableHead>
|
||||
<TableHead className="w-[16rem]">검토</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelectAll(tab.code, "Y");
|
||||
}}
|
||||
>
|
||||
전체 예
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelectAll(tab.code, "N");
|
||||
}}
|
||||
className="!bg-destructive"
|
||||
>
|
||||
전체 아니오
|
||||
</Button>
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tab.items.map((item) => {
|
||||
const resultIdx = cklstRvwSnIndexMap.get(item.cklstRvwSn) ?? -1;
|
||||
const showRadios = item.bchckUseYn === "Y";
|
||||
|
||||
return (
|
||||
<TableRow key={item.cklstRvwSn}>
|
||||
<TableCell className="font-medium">{item.title}</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
paddingLeft: `${Math.max(0, item.treeLevel - 1) * 16 + 8}px`,
|
||||
}}
|
||||
>
|
||||
{item.treeLevel > 1 ? "└ " : ""}
|
||||
{item.content}
|
||||
</TableCell>
|
||||
<TableCell className={showRadios ? undefined : "bg-muted"}>
|
||||
{showRadios && resultIdx >= 0 ? (
|
||||
<Controller
|
||||
name={`cklstResultList.${resultIdx}.selected`}
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FieldSet>
|
||||
<RadioGroup
|
||||
name={field.name}
|
||||
value={field.value || undefined}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<div className="flex w-full justify-center gap-6">
|
||||
<FieldLabel
|
||||
htmlFor={`check-${item.cklstRvwSn}-Y`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-auto"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="Y"
|
||||
id={`check-${item.cklstRvwSn}-Y`}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<span className="font-normal">예</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel
|
||||
htmlFor={`check-${item.cklstRvwSn}-N`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-auto"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="N"
|
||||
id={`check-${item.cklstRvwSn}-N`}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<span className="font-normal">아니오</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { ChecklistFormSection };
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { CklstRvwRsltTreeDto, ReviewStepCode } from "@/entities/instl-sncn";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
|
||||
|
||||
import {
|
||||
buildChecklistFormState,
|
||||
buildChecklistRowKey,
|
||||
extractTaskCodes,
|
||||
filterChecklistRows,
|
||||
getVisibleReviewSteps,
|
||||
} from "../../lib/checklist-utils";
|
||||
import {
|
||||
type ChecklistFormState,
|
||||
REVIEW_STEP_LABELS,
|
||||
TASK_CODE_LABELS,
|
||||
} from "../../model/view-types";
|
||||
import { ChecklistTaskPanel } from "../components/checklist-task-panel";
|
||||
import { SectionCollapsible } from "../components/section-collapsible";
|
||||
|
||||
interface ChecklistSectionProps {
|
||||
cklstResultList: CklstRvwRsltTreeDto[];
|
||||
prgrsSttsComCd: string;
|
||||
userRoleCode?: string;
|
||||
}
|
||||
|
||||
function ChecklistSection({
|
||||
cklstResultList,
|
||||
prgrsSttsComCd,
|
||||
userRoleCode,
|
||||
}: ChecklistSectionProps) {
|
||||
const visibleReviewSteps = useMemo(
|
||||
() => getVisibleReviewSteps(prgrsSttsComCd, userRoleCode),
|
||||
[prgrsSttsComCd, userRoleCode],
|
||||
);
|
||||
|
||||
const taskCodes = useMemo(() => extractTaskCodes(cklstResultList), [cklstResultList]);
|
||||
|
||||
const [selectedReviewStep, setSelectedReviewStep] = useState<ReviewStepCode>("LOCAL");
|
||||
const [selectedTaskCode, setSelectedTaskCode] = useState<string | null>(null);
|
||||
const [formState, setFormState] = useState<ChecklistFormState>({ results: {}, comments: {} });
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleReviewSteps.length === 0) return;
|
||||
if (!visibleReviewSteps.includes(selectedReviewStep)) {
|
||||
setSelectedReviewStep(visibleReviewSteps[visibleReviewSteps.length - 1]);
|
||||
}
|
||||
}, [selectedReviewStep, visibleReviewSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskCodes.length === 0) {
|
||||
setSelectedTaskCode(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedTaskCode || !taskCodes.includes(selectedTaskCode)) {
|
||||
setSelectedTaskCode(taskCodes[0]);
|
||||
}
|
||||
}, [taskCodes, selectedTaskCode]);
|
||||
|
||||
const selectedRows = useMemo(
|
||||
() => filterChecklistRows(cklstResultList, selectedTaskCode),
|
||||
[cklstResultList, selectedTaskCode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFormState(buildChecklistFormState(selectedRows));
|
||||
}, [selectedRows]);
|
||||
|
||||
const cklstStats = useMemo(() => {
|
||||
const rows = selectedRows.filter((row) => row.bchckUseYn === "Y");
|
||||
const total = rows.length;
|
||||
const completed = rows.reduce((acc, row) => {
|
||||
const key = buildChecklistRowKey(row);
|
||||
const val = formState.results[key] ?? row.rvwRsltAnmlyYn ?? "";
|
||||
return acc + (val ? 1 : 0);
|
||||
}, 0);
|
||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
return { total, completed, percent };
|
||||
}, [selectedRows, formState.results]);
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="체크리스트">
|
||||
<div className="flex flex-col gap-4 p-2">
|
||||
{visibleReviewSteps.length > 0 ? (
|
||||
<Tabs
|
||||
value={selectedReviewStep}
|
||||
onValueChange={(value) => setSelectedReviewStep(value as ReviewStepCode)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList>
|
||||
{visibleReviewSteps.map((stepCode) => (
|
||||
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl">
|
||||
{REVIEW_STEP_LABELS[stepCode]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
{taskCodes.length > 0 ? (
|
||||
<Tabs
|
||||
value={selectedTaskCode ?? taskCodes[0]}
|
||||
onValueChange={setSelectedTaskCode}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<TabsList className="p-0">
|
||||
{taskCodes.map((code) => (
|
||||
<TabsTrigger
|
||||
key={code}
|
||||
value={code}
|
||||
className="!px-6 !py-3 text-xl [&:after]:hidden"
|
||||
>
|
||||
{TASK_CODE_LABELS[code] ?? code}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<div className="shrink-0 pb-1 text-lg text-muted-foreground">
|
||||
총합: <strong>{cklstStats.total}</strong> · 완료:{" "}
|
||||
<strong>{cklstStats.completed}</strong> · 진행률:{" "}
|
||||
<strong>{cklstStats.percent}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{taskCodes.map((code) => (
|
||||
<TabsContent key={code} value={code} className="mt-0 border-0 p-0 shadow-none ring-0">
|
||||
<ChecklistTaskPanel
|
||||
selectedReviewStep={selectedReviewStep}
|
||||
selectedRows={selectedRows}
|
||||
formState={formState}
|
||||
setFormState={setFormState}
|
||||
userRoleCode={userRoleCode}
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { ChecklistSection };
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import { Field, FieldError } from "@/shared/ui/field";
|
||||
import { Input, Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
import type { CnfmRegistFormData } from "../../lib/cnfm-regist-schema";
|
||||
import { SectionCollapsible } from "../components/section-collapsible";
|
||||
|
||||
function ContactInfoFormSection() {
|
||||
const { control } = useFormContext<CnfmRegistFormData>();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="담당자 정보">
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[20rem]" rowSpan={2}>
|
||||
신청자
|
||||
</TableHead>
|
||||
<TableHead className="w-[16rem]">이름</TableHead>
|
||||
<TableCell>{userInfo?.userName}</TableCell>
|
||||
<TableHead className="w-[16rem]">연락처</TableHead>
|
||||
<TableCell>{userInfo?.mphn}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>소속</TableHead>
|
||||
<TableCell>{userInfo?.ogdpSeNm}</TableCell>
|
||||
<TableHead>이메일</TableHead>
|
||||
<TableCell>{userInfo?.email}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead rowSpan={2} data-required>
|
||||
설계자
|
||||
</TableHead>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name="instlSncnMng.dsgnrNm"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
{...field}
|
||||
id={field.name}
|
||||
placeholder="설계자 이름"
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableHead>연락처</TableHead>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name="instlSncnMng.dsgnrTelno"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
{...field}
|
||||
id={field.name}
|
||||
placeholder="설계자 전화번호"
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>소속</TableHead>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name="instlSncnMng.dsgnrOgdpNm"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
{...field}
|
||||
id={field.name}
|
||||
placeholder="설계자 소속"
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableHead>이메일</TableHead>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name="instlSncnMng.dsgnrEmlAddr"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<Input
|
||||
{...field}
|
||||
id={field.name}
|
||||
type="email"
|
||||
placeholder="설계자 이메일"
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { ContactInfoFormSection };
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
|
||||
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
import { SectionCollapsible } from "../components/section-collapsible";
|
||||
|
||||
interface ContactInfoSectionProps {
|
||||
det: InstlSncnMngResponseDto;
|
||||
}
|
||||
|
||||
function ContactInfoSection({ det }: ContactInfoSectionProps) {
|
||||
return (
|
||||
<SectionCollapsible title="담당자 정보">
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
{det.aplcntNm && (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableHead rowSpan={2}>신청자</TableHead>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableCell>{det.aplcntNm || "-"}</TableCell>
|
||||
<TableHead>연락처</TableHead>
|
||||
<TableCell>{det.aplcntMblTelno || "-"}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>소속</TableHead>
|
||||
<TableCell>{det.aplcntOgdpNm || "-"}</TableCell>
|
||||
<TableHead>이메일</TableHead>
|
||||
<TableCell>{det.aplcntEmlAddr || "-"}</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
)}
|
||||
{det.dsgnrNm && (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableHead rowSpan={2}>설계자</TableHead>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableCell>{det.dsgnrNm || "-"}</TableCell>
|
||||
<TableHead>연락처</TableHead>
|
||||
<TableCell>{det.dsgnrTelno || "-"}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>소속</TableHead>
|
||||
<TableCell>{det.dsgnrOgdpNm || "-"}</TableCell>
|
||||
<TableHead>이메일</TableHead>
|
||||
<TableCell>{det.dsgnrEmlAddr || "-"}</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
)}
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { ContactInfoSection };
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
|
||||
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
import { SectionCollapsible } from "../components/section-collapsible";
|
||||
|
||||
interface HistoryInfoSectionProps {
|
||||
det: InstlSncnMngResponseDto;
|
||||
}
|
||||
|
||||
function HistoryInfoSection({ det }: HistoryInfoSectionProps) {
|
||||
return (
|
||||
<SectionCollapsible title="이력 정보">
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>등록일시</TableHead>
|
||||
<TableCell>
|
||||
{det.regDt ? dayjs(det.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
|
||||
</TableCell>
|
||||
<TableHead>수정일시</TableHead>
|
||||
<TableCell>
|
||||
{det.mdfcnDt ? dayjs(det.mdfcnDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>등록자명</TableHead>
|
||||
<TableCell>{det.rgtrNm || "-"}</TableCell>
|
||||
<TableHead>첨부파일그룹ID</TableHead>
|
||||
<TableCell>{det.atchFileGroupId || "-"}</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { HistoryInfoSection };
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { Field, FieldError, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import type { CnfmRegistFormData } from "../../lib/cnfm-regist-schema";
|
||||
import { SectionCollapsible } from "../components/section-collapsible";
|
||||
|
||||
function PrivacyConsentSection() {
|
||||
const { control } = useFormContext<CnfmRegistFormData>();
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="개인정보 수집/이용 동의">
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[20rem]" data-required>
|
||||
개인정보 수집 및 이용
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<div className="space-y-4">
|
||||
<div className="mx-2 rounded border bg-muted p-4 text-xl">
|
||||
<p className="mb-2 font-semibold">개인정보 수집 및 이용 동의</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<p className="mb-1.5 font-semibold text-foreground">[ 개인정보 수집항목 ]</p>
|
||||
<ul className="ml-4 space-y-1">
|
||||
<li>
|
||||
· <span className="font-medium">필수항목:</span> 소속기관, 소속부서,
|
||||
담당자, 직급, 사무실 번호, FAX 번호
|
||||
</li>
|
||||
<li>
|
||||
· <span className="font-medium">선택항목:</span> 휴대폰 번호, 이메일
|
||||
주소
|
||||
</li>
|
||||
<li>
|
||||
· <span className="font-medium">기타항목:</span> IP Address, 쿠키,
|
||||
시스템 접속 및 이용 기록
|
||||
</li>
|
||||
</ul>
|
||||
<p className="ml-4 mt-1 text-base text-muted-foreground">
|
||||
※ 시스템 이용과정에서 기타 항목의 정보들이 자동으로 생성되어 수집될 수
|
||||
있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<p className="mb-1.5 font-semibold text-foreground">[ 개인정보 이용목적 ]</p>
|
||||
<ul className="ml-4 space-y-1">
|
||||
<li>
|
||||
· 국가하수도정보시스템 웹서비스 이용에 따른 본인 확인 절차에 이용
|
||||
</li>
|
||||
<li>· 국가하수도정보시스템 사용에 필요한 권한 부여</li>
|
||||
<li>
|
||||
· 사용자별 맞춤 컨텐츠 (처리시설의 운영 데이터 조회, 수질현황 데이터
|
||||
조회, 예산 편성 / 예산편성 등) 제공에 이용
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<p className="mb-1.5 font-semibold text-foreground">
|
||||
[ 개인정보의 보유 및 이용기간 ]
|
||||
</p>
|
||||
<ul className="ml-4 space-y-1">
|
||||
<li>
|
||||
· 수집된 고객의 개인정보는 2년 동안 보유하며 그 이후에는 문서를
|
||||
파쇄합니다.
|
||||
</li>
|
||||
<li>· 정보제공자가 개인정보 파쇄를 요청할 경우 즉시 파쇄합니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1.5 font-semibold text-foreground">
|
||||
[ 개인정보 제공 거부에 따른 제한사항 ]
|
||||
</p>
|
||||
<p className="ml-4">
|
||||
· 귀하는 개인정보 제공 동의를 거부할 권리가 있으나, 동의 거부 시
|
||||
국가하수도정보시스템 이용 서비스에 제한이 있을 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Controller
|
||||
name="privacyConsent"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FieldSet>
|
||||
<div className="mt-2 flex items-center gap-4 px-2">
|
||||
<span className="font-medium">동의 여부</span>
|
||||
<RadioGroup
|
||||
name={field.name}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<FieldLabel htmlFor="privacy-Y" className="border-none">
|
||||
<Field orientation="horizontal" data-invalid={fieldState.invalid}>
|
||||
<RadioGroupItem
|
||||
value="Y"
|
||||
id="privacy-Y"
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<span className="whitespace-nowrap font-normal">예</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel htmlFor="privacy-N" className="border-none">
|
||||
<Field orientation="horizontal" data-invalid={fieldState.invalid}>
|
||||
<RadioGroupItem
|
||||
value="N"
|
||||
id="privacy-N"
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
<span className="whitespace-nowrap font-normal">아니오</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</FieldSet>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { PrivacyConsentSection };
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { PROXY_HOST } from "@/shared/config";
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import type { InstlSncnRvwOpnnResponseDto } from "@/entities/instl-sncn";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
import { REVIEW_STEP_LABELS } from "../../model/view-types";
|
||||
import { SectionCollapsible } from "../components/section-collapsible";
|
||||
|
||||
interface TechReviewOpinionSectionProps {
|
||||
rvwList: InstlSncnRvwOpnnResponseDto[];
|
||||
}
|
||||
|
||||
interface TechReviewOpinionTableProps {
|
||||
items: InstlSncnRvwOpnnResponseDto[];
|
||||
}
|
||||
|
||||
function TechReviewOpinionSection({ rvwList }: TechReviewOpinionSectionProps) {
|
||||
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
|
||||
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
|
||||
|
||||
if (envRvwList.length === 0 && kecoRvwList.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{envRvwList.length > 0 ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
||||
<TechReviewOpinionTable items={envRvwList} />
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
{kecoRvwList.length > 0 ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||
<TechReviewOpinionTable items={kecoRvwList} />
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="10%" />
|
||||
<col width="55%" />
|
||||
<col width="5%" />
|
||||
<col width="20%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>의견자명</TableHead>
|
||||
<TableHead>의견내용</TableHead>
|
||||
<TableHead>검토결과</TableHead>
|
||||
<TableHead>첨부파일</TableHead>
|
||||
<TableHead>등록일시</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={`${item.instlSncnDocNo}_${item.rvwOpnnSn}`}>
|
||||
<TableCell align="center">{item.opnrNm}</TableCell>
|
||||
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.atchFileInfo?.atflCd ? (
|
||||
<a
|
||||
className="text-blue-600 hover:underline"
|
||||
href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${item.atchFileInfo.atflCd}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{item.atchFileInfo.orgnlFileNm || "다운로드"}
|
||||
</a>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TechReviewOpinionSection };
|
||||
@@ -3,14 +3,17 @@ export const ENDPOINT = {
|
||||
TUS_UPLOAD: "/attach-service/api/v1/tus/upload",
|
||||
ATTACHMENTS: "/attach-service/api/v1/atfl/attachments",
|
||||
},
|
||||
AS_COMMON_SERVICE: {
|
||||
AS_SERVICE: {
|
||||
ACNTG_SE: "/as-service/api/v1/acntg-se",
|
||||
CNSTP_SE: "/as-service/api/v1/cnstp-se",
|
||||
ESNTL_ATCH_FILE: "/as-service/api/v1/esntl-atch-file",
|
||||
RVW_MNPW: "/as-service/api/v1/rvw-mnpw",
|
||||
},
|
||||
AS_INSTL_SERVICE: {
|
||||
BNS_IF: "/as-service/api/v1/if",
|
||||
INSTL_SNCN: "/as-service/api/v1/instl-sncn",
|
||||
CKLST: "/as-service/api/v1/cklst",
|
||||
CKLST_ARTCL: "/as-service/api/v1/cklst-artcl",
|
||||
CKLST_RVW: "/as-service/api/v1/cklst-rvw",
|
||||
ADSTDSTR: "/as-service/api/v1/adstdstr",
|
||||
},
|
||||
USER_SERVICE: {
|
||||
LOGIN: "/user-service/login",
|
||||
@@ -22,17 +25,15 @@ export const ENDPOINT = {
|
||||
ELAPR_APLN: "/as-elapr-service/api/v1/elapr-apln",
|
||||
ELAPR_DMND: "/as-elapr-service/api/v1/elapr-dmnd",
|
||||
},
|
||||
AS_CHECKLIST_SERVICE: {
|
||||
CKLST: "/as-service/api/v1/cklst",
|
||||
CKLST_ARTCL: "/as-service/api/v1/cklst-artcl",
|
||||
CKLST_RVW: "/as-service/api/v1/cklst-rvw",
|
||||
},
|
||||
CMS_SERVICE: {
|
||||
CODE: "/cms-service/api/v1/code/cache",
|
||||
MENU_ROLES: "/cms-service/api/v1/menu-roles",
|
||||
MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts",
|
||||
USER_ORGANIZATION: "/cms-service/api/v1/user-ognz",
|
||||
},
|
||||
STATVERIFY_SERVICE: {
|
||||
ST_ANLS_MST: "/statverify-service/api/v1/st-anls-mst",
|
||||
},
|
||||
};
|
||||
|
||||
export const JWT_EXPIRATION_TIME = 1800000;
|
||||
|
||||
@@ -5,4 +5,5 @@ export { queryClient } from "./query-client";
|
||||
export { useAlertStore } from "./use-alert-store";
|
||||
export { useDataTable } from "./use-data-table";
|
||||
export { useMenuStore } from "./use-menu-store";
|
||||
export { getDefaultFileKey, useTus } from "./use-tus";
|
||||
export { useUserStore } from "./use-user-store";
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { type Upload } from "tus-js-client";
|
||||
|
||||
import { handleTusUpload } from "@/entities/tus";
|
||||
|
||||
import { useAlertStore } from "./use-alert-store";
|
||||
|
||||
interface UploadFileOptions {
|
||||
fileKey?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface UseTusOptions {
|
||||
onSuccess?: (atflCd: string, atflId: string, file: File, fileKey: string) => void;
|
||||
onError?: (error: Error, file: File, fileKey: string) => void;
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
interface UseTusReturn {
|
||||
uploadFile: (file: File, options?: UploadFileOptions) => void;
|
||||
uploadFileAsync: (file: File, options?: UploadFileOptions) => Promise<string>;
|
||||
progress: Record<string, number>;
|
||||
isUploading: Record<string, boolean>;
|
||||
uploads: Record<string, Upload>;
|
||||
}
|
||||
|
||||
function getDefaultFileKey(file: File) {
|
||||
return `${file.name}::${file.lastModified}::${file.size}`;
|
||||
}
|
||||
|
||||
function useTus(options: UseTusOptions = {}): UseTusReturn {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [progress, setProgress] = useState<Record<string, number>>({});
|
||||
const [isUploading, setIsUploading] = useState<Record<string, boolean>>({});
|
||||
const [uploads, setUploads] = useState<Record<string, Upload>>({});
|
||||
|
||||
const validateFileType = useCallback(
|
||||
(file: File): boolean => {
|
||||
if (!options.accept) return true;
|
||||
|
||||
const allowedExtensions = options.accept.split(",");
|
||||
const fileExtension = file.name.substring(file.name.lastIndexOf(".")).toLowerCase();
|
||||
|
||||
if (!allowedExtensions.includes(fileExtension)) {
|
||||
alert(`허용되지 않은 파일 형식입니다. (${fileExtension})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[options.accept, alert],
|
||||
);
|
||||
|
||||
const uploadFileAsync = useCallback(
|
||||
(file: File, uploadOptions?: UploadFileOptions): Promise<string> => {
|
||||
if (!validateFileType(file)) {
|
||||
return Promise.reject(new Error("Invalid file type"));
|
||||
}
|
||||
|
||||
const fileKey = uploadOptions?.fileKey ?? getDefaultFileKey(file);
|
||||
|
||||
setIsUploading((prev) => ({ ...prev, [fileKey]: true }));
|
||||
setProgress((prev) => ({ ...prev, [fileKey]: 0 }));
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
handleTusUpload(file, {
|
||||
metadata: uploadOptions?.metadata,
|
||||
handleUploadStart: (upload) => {
|
||||
setUploads((prev) => ({ ...prev, [fileKey]: upload }));
|
||||
},
|
||||
handleProgress: (percentage) => {
|
||||
setProgress((prev) => ({ ...prev, [fileKey]: percentage }));
|
||||
},
|
||||
handleSuccess: (_upload, atflCd, atflId) => {
|
||||
setIsUploading((prev) => ({ ...prev, [fileKey]: false }));
|
||||
setProgress((prev) => ({ ...prev, [fileKey]: 100 }));
|
||||
options.onSuccess?.(atflCd, atflId, file, fileKey);
|
||||
resolve(atflCd);
|
||||
},
|
||||
handleError: (error) => {
|
||||
setIsUploading((prev) => ({ ...prev, [fileKey]: false }));
|
||||
alert("파일 업로드 중 오류가 발생했습니다.");
|
||||
options.onError?.(error, file, fileKey);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
[validateFileType, options, alert],
|
||||
);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
(file: File, uploadOptions?: UploadFileOptions) => {
|
||||
void uploadFileAsync(file, uploadOptions);
|
||||
},
|
||||
[uploadFileAsync],
|
||||
);
|
||||
|
||||
return {
|
||||
uploadFile,
|
||||
uploadFileAsync,
|
||||
progress,
|
||||
isUploading,
|
||||
uploads,
|
||||
};
|
||||
}
|
||||
|
||||
export { getDefaultFileKey, useTus };
|
||||
export type { UploadFileOptions, UseTusOptions, UseTusReturn };
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn, Label } from "@common/components";
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldVariants = cva("group/field flex w-full gap-2 data-[invalid=true]:text-destructive", {
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
});
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn("group/field-content flex flex-1 flex-col gap-0.5 leading-snug", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label has-data-checked:border-primary/30 has-data-checked:bg-primary/5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 flex w-fit gap-2 leading-snug has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"group-has-data-horizontal/field:text-balance text-left text-sm font-normal leading-normal text-muted-foreground [[data-variant=legend]+&]:-mt-1.5",
|
||||
"nth-last-2:-mt-1 last:mt-0",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>;
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()];
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) => error?.message && <li key={index}>· {error.message}</li>,
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}, [children, errors]);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-base font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
FieldTitle,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
function NoData({ message = "조회된 데이터가 없습니다." }: { message?: string }) {
|
||||
return (
|
||||
<div className="flex min-h-[120px] items-center justify-center text-muted-foreground">
|
||||
<div className="flex min-h-[120px] w-full items-center justify-center text-muted-foreground">
|
||||
<p className="text-lg">{message}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,9 +19,7 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@common/components": ["../../packages/common/components/dist/"],
|
||||
"@common/network": ["../../packages/common/network/"]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
|
||||
@@ -30,20 +30,20 @@ export function BaseLayout({
|
||||
<PopupProvider>
|
||||
<HelpDialogProvider>
|
||||
<SidebarProvider>
|
||||
<div className="flex-1">
|
||||
<Header tokenManager={tokenManager} />
|
||||
<div className="flex">
|
||||
<LNB
|
||||
currentPath={currentPath}
|
||||
handleMenuClick={handleMenuClick}
|
||||
/>
|
||||
<div className="flex min-h-[calc(100vh-50px)] flex-1 flex-col overflow-auto">
|
||||
<div className="flex-1">{children}</div>
|
||||
<Footer />
|
||||
<div className="flex-1">
|
||||
<Header tokenManager={tokenManager} />
|
||||
<div className="flex">
|
||||
<LNB
|
||||
currentPath={currentPath}
|
||||
handleMenuClick={handleMenuClick}
|
||||
/>
|
||||
<div className="flex min-h-[calc(100vh-50px)] flex-1 flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</SidebarProvider>
|
||||
</HelpDialogProvider>
|
||||
</PopupProvider>
|
||||
</ClientIpGuard>
|
||||
|
||||
Generated
+4168
-7702
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user