[as] WIP: 설치인가 버튼 로직

This commit is contained in:
2026-06-24 17:59:01 +09:00
parent ddb8031e55
commit deb7375d6b
19 changed files with 492 additions and 151 deletions
@@ -4,11 +4,14 @@ import type { PageResponse } from "@/shared/model";
import type {
AdstZoneResponseDto,
DocPrgrsUpdateRequestDto,
InstlSncnApplySaveRequestDto,
InstlSncnMngDetResponse,
InstlSncnMngResponseDto,
InstlSncnMngSaveRequestDto,
InstlSncnMngSearchParams,
InstlSncnMngService,
InstlSncnMngUpdateRequestDto,
} from "../model/instl-sncn-interface";
async function getSidoCombo(): Promise<AdstZoneResponseDto[]> {
@@ -30,32 +33,58 @@ async function getSigunguCombo(sidoCd: string): Promise<AdstZoneResponseDto[]> {
const BASE = ENDPOINT.AS_SERVICE.INSTL_SNCN;
export class InstlSncnApiService implements InstlSncnMngService {
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>(`${BASE}/${id}`);
return response.data;
}
public async saveApply(payload: InstlSncnApplySaveRequestDto): Promise<string> {
const response = await client.post<string>(`${BASE}/save-apply`, payload);
return response.data;
}
public async techReview(id: string, payload: InstlSncnApplySaveRequestDto): Promise<void> {
await client.post(`${BASE}/${id}/tech-review`, payload);
}
async function getPage(
params: InstlSncnMngSearchParams,
): Promise<PageResponse<InstlSncnMngResponseDto>> {
const response = await client.get<PageResponse<InstlSncnMngResponseDto>>(BASE, {
params,
});
return response.data;
}
export const InstlSncnApi = new InstlSncnApiService();
async function getById(id: string): Promise<InstlSncnMngDetResponse> {
const response = await client.get<InstlSncnMngDetResponse>(`${BASE}/${id}`);
return response.data;
}
async function save(payload: InstlSncnMngSaveRequestDto): Promise<string> {
const response = await client.post<string>(BASE, payload);
return response.data;
}
async function saveApply(payload: InstlSncnApplySaveRequestDto): Promise<string> {
const response = await client.post<string>(`${BASE}/save-apply`, payload);
return response.data;
}
async function update(id: string, payload: InstlSncnMngUpdateRequestDto): Promise<void> {
await client.put(`${BASE}/${id}`, payload);
}
async function techReview(id: string, payload: InstlSncnApplySaveRequestDto): Promise<void> {
await client.post(`${BASE}/${id}/tech-review`, payload);
}
async function remove(id: string): Promise<void> {
await client.delete(`${BASE}/${id}`);
}
async function changePrgrsSttsComCd(payload: DocPrgrsUpdateRequestDto): Promise<void> {
await client.post(`${BASE}/doc/updatePrgrsStts`, payload);
}
export const instlSncnService = {
getPage,
getById,
save,
saveApply,
update,
techReview,
remove,
changePrgrsSttsComCd,
} satisfies InstlSncnMngService;
// TODO: st-anls-mst service 로 분리
export const instlSncnRegionService = {
getSidoCombo,
getSigunguCombo,
+87 -7
View File
@@ -1,14 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type { InstlSncnMngSearchParams } from "../model/instl-sncn-interface";
import type {
DocPrgrsUpdateRequestDto,
InstlSncnApplySaveRequestDto,
InstlSncnMngSaveRequestDto,
InstlSncnMngSearchParams,
InstlSncnMngUpdateRequestDto,
} from "../model/instl-sncn-interface";
import { InstlSncnApi, instlSncnRegionService } from "./instl-sncn-service";
import { instlSncnRegionService, instlSncnService } from "./instl-sncn-service";
export const instlSncnKeys = {
all: ["instl-sncn"] as const,
list: (params: InstlSncnMngSearchParams) => [...instlSncnKeys.all, "list", params] as const,
list: () => [...instlSncnKeys.all, "list"] as const,
detail: (id: string) => [...instlSncnKeys.all, "detail", id] as const,
sidoCombo: () => [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST, "sido"] as const,
sigunguCombo: (sidoCd: string) =>
@@ -17,15 +23,15 @@ export const instlSncnKeys = {
export function useInstlSncnList(params: InstlSncnMngSearchParams) {
return useQuery({
queryKey: instlSncnKeys.list(params),
queryFn: () => InstlSncnApi.getPage(params),
queryKey: instlSncnKeys.list(),
queryFn: () => instlSncnService.getPage(params),
});
}
export function useInstlSncnDetail(id: string) {
return useQuery({
queryKey: instlSncnKeys.detail(id),
queryFn: () => InstlSncnApi.getById(id),
queryFn: () => instlSncnService.getById(id),
enabled: Boolean(id),
});
}
@@ -44,3 +50,77 @@ export function useInstlSncnSigunguCombo(sidoCd: string) {
enabled: Boolean(sidoCd),
});
}
export function useInstlSncnSave() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: InstlSncnMngSaveRequestDto) => instlSncnService.save(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
},
});
}
export function useInstlSncnSaveApply() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: InstlSncnApplySaveRequestDto) => instlSncnService.saveApply(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
},
});
}
export function useInstlSncnUpdate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: InstlSncnMngUpdateRequestDto }) =>
instlSncnService.update(id, payload),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) });
},
});
}
export function useInstlSncnTechReview() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: InstlSncnApplySaveRequestDto }) =>
instlSncnService.techReview(id, payload),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) });
},
});
}
export function useInstlSncnRemove() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => instlSncnService.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
},
});
}
export function useInstlSncnChangePrgrsStts() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: DocPrgrsUpdateRequestDto) =>
instlSncnService.changePrgrsSttsComCd(payload),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
queryClient.invalidateQueries({
queryKey: instlSncnKeys.detail(variables.docNo),
});
},
});
}
+3 -1
View File
@@ -1,4 +1,4 @@
export { InstlSncnApi, instlSncnRegionService } from "./api/instl-sncn-service";
export { instlSncnRegionService, instlSncnService } from "./api/instl-sncn-service";
export {
instlSncnKeys,
useInstlSncnDetail,
@@ -12,6 +12,7 @@ export {
type BizCstTreeDto,
type BizSclTreeDto,
type CklstRvwRsltTreeDto,
type DocPrgrsUpdateRequestDto,
type ElaprDocPrgrsClientResponseDto,
type EsntlAtchFileMngTreeResponseDto,
INSTL_SNCN_REGISTER_SELECTION_KEY,
@@ -19,6 +20,7 @@ export {
type InstlSncnBizCstSaveRequestDto,
type InstlSncnBzScaleSaveDto,
type InstlSncnCklstResultSaveDto,
type InstlSncnEsntlAtchFileSaveDto,
type InstlSncnMngDetResponse,
type InstlSncnMngResponseDto,
type InstlSncnMngSaveRequestDto,
@@ -67,6 +67,19 @@ export interface InstlSncnMngSaveRequestDto {
rtlztnUsgSeComCd?: string;
}
/** 설치인가관리 수정 요청 DTO */
export type InstlSncnMngUpdateRequestDto = InstlSncnMngSaveRequestDto;
/** 문서 진행상태 수정 요청 DTO */
export interface DocPrgrsUpdateRequestDto {
/** 행정지원문서번호 */
docNo: string;
/** 문서구분코드 */
docSeComCd: string;
/** 진행상태공통코드 */
prgrsSttsComCd: string;
}
/** 설치인가관리 응답 DTO */
export interface InstlSncnMngResponseDto extends InstlSncnMngSaveRequestDto {
/** 설치인가문서번호 (필수로 재정의) */
@@ -334,6 +347,10 @@ export interface InstlSncnMngDetResponse {
export interface InstlSncnMngService {
getPage: (params: InstlSncnMngSearchParams) => Promise<PageResponse<InstlSncnMngResponseDto>>;
getById: (id: string) => Promise<InstlSncnMngDetResponse>;
save: (payload: InstlSncnMngSaveRequestDto) => Promise<string>;
saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise<string>;
update: (id: string, payload: InstlSncnMngUpdateRequestDto) => Promise<void>;
techReview: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<void>;
remove: (id: string) => Promise<void>;
changePrgrsSttsComCd: (payload: DocPrgrsUpdateRequestDto) => Promise<void>;
}
@@ -67,6 +67,7 @@ function CklstPreviewDialog({
<TableBody>
{displayRows.map(({ row, depth }) => {
const branchPrefix = formatTreeBranchPrefix(depth);
const showRadios = row.bchckUseYn !== "N";
if (row.useYn === "N") return null;
@@ -82,16 +83,20 @@ function CklstPreviewDialog({
</TableCell>
<TableCell>{row.rvwCn ?? "—"}</TableCell>
<TableCell align="center">
<RadioGroup className="flex items-center justify-center">
<div className="flex items-center gap-1">
<RadioGroupItem value="Y" id={`${row.rowKey}-use-y`} />
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="N" id={`${row.rowKey}-use-n`} />
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
</div>
</RadioGroup>
{showRadios ? (
<RadioGroup className="flex items-center justify-center">
<div className="flex items-center gap-1">
<RadioGroupItem value="Y" id={`${row.rowKey}-preview-y`} />
<Label htmlFor={`${row.rowKey}-preview-y`}>예</Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="N" id={`${row.rowKey}-preview-n`} />
<Label htmlFor={`${row.rowKey}-preview-n`}>아니오</Label>
</div>
</RadioGroup>
) : (
"—"
)}
</TableCell>
</TableRow>
);
@@ -113,6 +113,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
rvwCnLvNo: 1,
rvwCn: "",
useYn: "Y",
bchckUseYn: "Y",
sortSeq: maxSort + 1,
isNew: true,
isDirty: true,
@@ -145,6 +146,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
rvwCnLvNo: parentLv + 1,
rvwCn: "",
useYn: "Y",
bchckUseYn: "Y",
sortSeq: maxSort + 1,
isNew: true,
isDirty: true,
@@ -189,6 +191,8 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
const handleSave = async () => {
if (!cklstSn) return;
if (!(await confirm("체크리스트 항목을 저장하시겠습니까?"))) return;
const dirty = getDirtyRows().sort((a, b) => (a.rvwCnLvNo ?? 1) - (b.rvwCnLvNo ?? 1));
for (const row of dirty) {
@@ -198,8 +202,6 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
return;
}
if (!(await confirm("체크리스트 항목을 저장하시겠습니까?"))) return;
if (row.isNew) {
await createMutation.mutateAsync({
cklstSn: row.cklstSn,
@@ -209,7 +211,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
rvwCn: row.rvwCn,
useYn: row.useYn,
sortSeq: row.sortSeq,
bchckUseYn: "Y",
bchckUseYn: row.bchckUseYn ?? "Y",
});
} else if (row.cklstRvwSn != null) {
await updateMutation.mutateAsync({
@@ -277,17 +279,19 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
) : (
<Table className="w-full">
<colgroup>
<col width="20%" />
<col width="50%" />
<col width="15%" />
<col width="18%" />
<col width="42%" />
<col width="12%" />
<col width="12%" />
<col width="5%" />
<col width="10%" />
<col width="11%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>점검항목</TableHead>
<TableHead>주요 검토내용</TableHead>
<TableHead>사용 여부</TableHead>
<TableHead>체크 사용</TableHead>
<TableHead>구분</TableHead>
<TableHead>관리</TableHead>
</TableRow>
@@ -91,6 +91,22 @@ function CklstRvwRowComponent({
</div>
</RadioGroup>
</TableCell>
<TableCell align="center">
<RadioGroup
value={row.bchckUseYn ?? "Y"}
onValueChange={(v) => onChange({ bchckUseYn: v as "Y" | "N", isDirty: true })}
className="flex justify-center gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="Y" id={`${row.rowKey}-bchck-y`} />
<Label htmlFor={`${row.rowKey}-bchck-y`}>예</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="N" id={`${row.rowKey}-bchck-n`} />
<Label htmlFor={`${row.rowKey}-bchck-n`}>아니오</Label>
</div>
</RadioGroup>
</TableCell>
<TableCell align="center">{levelLabel}</TableCell>
<TableCell align="center">
<div className="flex justify-center gap-2">
@@ -9,8 +9,8 @@ import { dayjs } from "@/shared/lib";
import { useCodeList } from "@/entities/code";
import {
INSTL_SNCN_REGISTER_SELECTION_KEY,
InstlSncnApi,
type InstlSncnMngSearchParams,
instlSncnService,
} from "@/entities/instl-sncn";
import "./cnfm-page.css";
@@ -78,14 +78,14 @@ function CnfmListPage() {
useEffect(() => {
if (!taskDialogOpen) return;
if (selectedTaskSeComCd.length) return;
if (fnrsCodeList.length === 0) return;
// setSelectedTaskSeComCd([fnrsCodeList[0].lwpoCd]);
if (fnrsCodeList.length === 0) return;
// setSelectedTaskSeComCd([fnrsCodeList[0].lwpoCd]);
}, [taskDialogOpen, selectedTaskSeComCd, fnrsCodeList]);
const query = useQuery({
queryKey: ["instl-sncn-list", search, pageIndex, pageSize],
queryFn: () =>
InstlSncnApi.getPage({
instlSncnService.getPage({
...search,
page: pageIndex,
size: pageSize,
@@ -120,13 +120,15 @@ function CnfmListPage() {
// prepare arrays of selected codes and names
const selectedCodes = selectedTaskSeComCd;
const selectedNames = selectedTaskSeComCd.map((code) => fnrsCodeList.find((item) => item.lwpoCd === code)?.lwpoCdNm || "");
const selectedNames = selectedTaskSeComCd.map(
(code) => fnrsCodeList.find((item) => item.lwpoCd === code)?.lwpoCdNm || "",
);
sessionStorage.setItem(
INSTL_SNCN_REGISTER_SELECTION_KEY,
JSON.stringify({
taskSeComCdList: selectedCodes,
taskSeComCdNmList: selectedNames
})
taskSeComCdNmList: selectedNames,
}),
);
setTaskDialogOpen(false);
router.push("/instl-sncn/register");
@@ -138,7 +140,9 @@ function CnfmListPage() {
<div className="cnfm-card">
<form onSubmit={handleSearch} className="cnfm-search-bar">
<div className="cnfm-search-field">
<label className="cnfm-search-label" htmlFor="sidoComCd">시도</label>
<label className="cnfm-search-label" htmlFor="sidoComCd">
시도
</label>
<select
id="sidoComCd"
className="cnfm-select"
@@ -146,13 +150,17 @@ function CnfmListPage() {
onChange={(e) => setSidoComCd(e.target.value)}
>
{SIDO_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="cnfm-search-field">
<label className="cnfm-search-label" htmlFor="sggComCd">시군구</label>
<label className="cnfm-search-label" htmlFor="sggComCd">
시군구
</label>
<select
id="sggComCd"
className="cnfm-select"
@@ -164,7 +172,9 @@ function CnfmListPage() {
</div>
<div className="cnfm-search-field cnfm-search-field--grow">
<label className="cnfm-search-label" htmlFor="bizNm">사업명</label>
<label className="cnfm-search-label" htmlFor="bizNm">
사업명
</label>
<input
id="bizNm"
type="text"
@@ -176,7 +186,9 @@ function CnfmListPage() {
</div>
<div className="cnfm-search-field">
<label className="cnfm-search-label" htmlFor="taskSeComCd">구분</label>
<label className="cnfm-search-label" htmlFor="taskSeComCd">
구분
</label>
<select
id="taskSeComCd"
className="cnfm-select cnfm-select--wide"
@@ -184,12 +196,16 @@ function CnfmListPage() {
onChange={(e) => setTaskSeComCd(e.target.value)}
>
{TASK_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<button type="submit" className="cnfm-btn-search">검색</button>
<button type="submit" className="cnfm-btn-search">
검색
</button>
</form>
</div>
@@ -198,11 +214,7 @@ function CnfmListPage() {
<div className="cnfm-list-header">
<h3 className="cnfm-list-title">설치인가 목록</h3>
<div className="cnfm-list-actions">
<button
type="button"
className="cnfm-btn-register"
onClick={handleOpenTaskDialog}
>
<button type="button" className="cnfm-btn-register" onClick={handleOpenTaskDialog}>
✎ 신청
</button>
<select
@@ -214,7 +226,9 @@ function CnfmListPage() {
}}
>
{[10, 20, 30, 50].map((size) => (
<option key={size} value={size}>{size}개</option>
<option key={size} value={size}>
{size}개
</option>
))}
</select>
</div>
@@ -237,12 +251,16 @@ function CnfmListPage() {
<tbody>
{query.isLoading && (
<tr>
<td colSpan={8} className="cnfm-td-empty">조회 중입니다...</td>
<td colSpan={8} className="cnfm-td-empty">
조회 중입니다...
</td>
</tr>
)}
{!query.isLoading && (query.data?.content?.length ?? 0) === 0 && (
<tr>
<td colSpan={8} className="cnfm-td-empty">조회 결과가 없습니다.</td>
<td colSpan={8} className="cnfm-td-empty">
조회 결과가 없습니다.
</td>
</tr>
)}
{(query.data?.content ?? []).map((row) => {
@@ -254,7 +272,7 @@ function CnfmListPage() {
onClick={() => router.push(`/instl-sncn/${row.instlSncnDocNo}`)}
>
<td>{row.sido || "-"}</td>
<td>{row.gugun|| "-"}</td>
<td>{row.gugun || "-"}</td>
<td className="text-left">{row.bizNm || "-"}</td>
<td>{row.taskSeCdNm || row.taskSeComCd || "-"}</td>
<td>{row.aplcntNm || "-"}</td>
@@ -267,10 +285,13 @@ function CnfmListPage() {
index < currentStepIndex
? "completed"
: index === currentStepIndex
? "current"
: "pending";
? "current"
: "pending";
return (
<span key={step.code} className={`cnfm-step-badge cnfm-step-badge--${state}`}>
<span
key={step.code}
className={`cnfm-step-badge cnfm-step-badge--${state}`}
>
<span className={`cnfm-step-dot cnfm-step-dot--${state}`} />
{step.label}
</span>
@@ -284,10 +305,13 @@ function CnfmListPage() {
index < currentStepIndex
? "completed"
: index === currentStepIndex
? "current"
: "pending";
? "current"
: "pending";
return (
<span key={step.code} className={`cnfm-step-badge cnfm-step-badge--${state}`}>
<span
key={step.code}
className={`cnfm-step-badge cnfm-step-badge--${state}`}
>
<span className={`cnfm-step-dot cnfm-step-dot--${state}`} />
{step.label}
</span>
@@ -378,7 +402,9 @@ function CnfmListPage() {
checked={selectedTaskSeComCd.includes(item.lwpoCd)}
onChange={() => {
setSelectedTaskSeComCd((prev) =>
prev.includes(item.lwpoCd) ? prev.filter((v) => v !== item.lwpoCd) : [...prev, item.lwpoCd],
prev.includes(item.lwpoCd)
? prev.filter((v) => v !== item.lwpoCd)
: [...prev, item.lwpoCd],
);
}}
/>
@@ -396,7 +422,7 @@ function CnfmListPage() {
>
취소
</button>
<button
<button
type="button"
className="rounded bg-blue-500 px-5 py-2 text-[1.3rem] font-semibold text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-300"
onClick={handleConfirmTaskDialog}
@@ -1,9 +1,10 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { CnfmRegistPage } from "./cnfm-regist-page";
import { InstlSncnApi } from "@/entities/instl-sncn";
import { instlSncnService } from "@/entities/instl-sncn";
import { CnfmRegistPage } from "./cnfm-regist-page";
import "./cnfm-page.css";
@@ -11,7 +12,7 @@ function CnfmModifyPage({ id }: { id: string }) {
const query = useQuery({
// share cache with view page
queryKey: ["instl-sncn-detail", id],
queryFn: () => InstlSncnApi.getById(id),
queryFn: () => instlSncnService.getById(id),
});
if (query.isLoading) {
@@ -21,12 +21,12 @@ import { cnstpSeMngService } from "@/entities/cnstp";
import { esntlAtchFileMngService, type EsntlAtchFileMngTreeResponseDto } from "@/entities/esntl";
import {
INSTL_SNCN_REGISTER_SELECTION_KEY,
InstlSncnApi,
type InstlSncnApplySaveRequestDto,
type InstlSncnBizCstSaveRequestDto,
type InstlSncnBzScaleSaveDto,
type InstlSncnMngResponseDto,
InstlSncnMngSaveRequestDto,
instlSncnService,
} from "@/entities/instl-sncn";
import { EtcAttachments, NumCell, PrivacyConsent, ReadCell } from "@/widgets/cnfm";
import { Button, Input } from "@common/components";
@@ -1170,7 +1170,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
};
try {
const issuedDocNo = await InstlSncnApi.saveApply(payload);
const issuedDocNo = await instlSncnService.saveApply(payload);
alert(`저장되었습니다. 문서번호: ${issuedDocNo}`);
} catch (error) {
console.error("저장 API 호출 실패", error);
@@ -1194,7 +1194,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
};
try {
const issuedDocNo = await InstlSncnApi.saveApply(payload);
const issuedDocNo = await instlSncnService.saveApply(payload);
alert(`제출 되었습니다. 문서번호: ${issuedDocNo}`);
} catch (error) {
console.error("저장 API 호출 실패", error);
@@ -14,7 +14,7 @@ import axios from "axios";
import { processElaprPrgrs } from "@/entities/elapr/api/elapr-prgrs-service";
import { ElaprDmndApi } from "@/entities/elapr/api/elapr-dmnd-service";
import { dayjs, useUserStore, useAlertStore } from "@/shared/lib";
import { InstlSncnApi, type InstlSncnApplySaveRequestDto } from "@/entities/instl-sncn";
import { instlSncnService, type InstlSncnApplySaveRequestDto } from "@/entities/instl-sncn";
import { client } from "@/shared/api";
import "./cnfm-page.css";
@@ -99,7 +99,7 @@ function CnfmViewPage({ id }: { id: string }) {
const query = useQuery({
queryKey: ["instl-sncn-detail", id],
queryFn: () => InstlSncnApi.getById(id),
queryFn: () => instlSncnService.getById(id),
});
const data = (query.data ?? {}) as any;
@@ -382,7 +382,7 @@ function CnfmViewPage({ id }: { id: string }) {
} as unknown as InstlSncnApplySaveRequestDto;
try {
const issuedDocNo = await InstlSncnApi.techReview(det.instlSncnDocNo, payload);
const issuedDocNo = await instlSncnService.techReview(det.instlSncnDocNo, payload);
alert(`기술검토 등록이 완료되었습니다. 문서번호: ${issuedDocNo}`);
} catch (error) {
console.error("기술검토 등록 API 호출 실패", error);
@@ -1,7 +1,14 @@
import type {
BizCstTreeDto,
BizSclTreeDto,
CklstRvwRsltTreeDto,
EsntlAtchFileMngTreeResponseDto,
InstlSncnApplySaveRequestDto,
InstlSncnBizCstSaveRequestDto,
InstlSncnBzScaleSaveDto,
InstlSncnCklstResultSaveDto,
InstlSncnEsntlAtchFileSaveDto,
InstlSncnMngDetResponse,
InstlSncnMngResponseDto,
ReviewStepCode,
} from "@/entities/instl-sncn";
@@ -17,7 +24,7 @@ import {
} from "../model/view-types";
export function buildChecklistRowKey(row: CklstRvwRsltTreeDto): ChecklistRowKey {
return `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
return `${row.taskSeComCd}_${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
}
export function groupByReviewStep(rows: CklstRvwRsltTreeDto[]): ChecklistStepMap {
@@ -106,6 +113,62 @@ export function toCklstSaveDto(
});
}
export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): InstlSncnBzScaleSaveDto[] {
return bizSclList
.filter((node) => node.useYn === "Y")
.map((node) => ({
cnstpSeCd: node.cnstpSeCd,
bscPl: node.bscPlanQty,
budgetReflct: node.bgtRfltQty,
thtiDsgn: node.tstmDesignQty,
diff: node.tstmDesignQty - node.bgtRfltQty,
}));
}
export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): InstlSncnBizCstSaveRequestDto[] {
return bizCstList
.filter((node) => node.useYn === "Y")
.map((node) => ({
acntgSeCd: node.acntgSeCd,
bscPlanAmt: node.bscPlanAmt,
bgtRfltAmt: node.bgtRfltAmt,
tstmDesignAmt: node.tstmDesignAmt,
}));
}
export function toEsntlAtchFileSaveDto(
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
): InstlSncnEsntlAtchFileSaveDto[] {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function buildSavePayload(
data: InstlSncnMngDetResponse,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
): InstlSncnApplySaveRequestDto {
const { detDto, bizSclList, bizCstList, cklstResultList = [], esntlFileList } = data;
return {
instlSncnMng: { ...detDto, prgrsSttsComCd },
bzScaleList: toBzScaleSaveDto(bizSclList),
bizCstList: toBizCstSaveDto(bizCstList),
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
};
}
export function buildTechReviewPayload(
det: InstlSncnMngResponseDto,
cklstResultList: InstlSncnCklstResultSaveDto[],
@@ -8,7 +8,13 @@ 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 {
type DocPrgrsUpdateRequestDto,
type InstlSncnApplySaveRequestDto,
instlSncnKeys,
type InstlSncnMngDetResponse,
instlSncnService,
} from "@/entities/instl-sncn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import { Button } from "@common/components";
@@ -18,24 +24,30 @@ import {
type InstlSncnViewButtonAction,
needsRoleUser01AtrzResolve,
} from "../../lib/button-config";
import { buildSavePayload } from "../../lib/checklist-utils";
import { type ChecklistFormState, ROLE_TO_REVIEW_STEP } from "../../model/view-types";
import { ElaprTmpltPickerDialog } from "./elapr-tmplt-picker-dialog";
import { TextInputDialog } from "./text-input-dialog";
interface FloatingActionButtonsProps {
data: InstlSncnMngDetResponse;
docSeComCd: string;
instlSncnDocNo: string;
prgrsSttsComCd: string;
dtlInstCd: string | undefined;
checklistFormState: ChecklistFormState;
}
type ActiveDialog = "elaprTmplt" | "rjct" | "dmndSplmnt" | null;
function FloatingActionButtons({
data,
docSeComCd,
instlSncnDocNo,
prgrsSttsComCd,
dtlInstCd,
checklistFormState,
}: FloatingActionButtonsProps) {
const router = useRouter();
const { confirm } = useConfirm();
@@ -110,8 +122,48 @@ function FloatingActionButtons({
}
}
async function handleStrgClick() {
const userReviewStep = dtlInstCd ? (ROLE_TO_REVIEW_STEP[dtlInstCd] ?? "LOCAL") : "LOCAL";
const payload: InstlSncnApplySaveRequestDto = buildSavePayload(
data,
checklistFormState,
userReviewStep,
"SV",
);
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.strg,
request: payload,
successMessage: "저장되었습니다.",
});
}
async function handleAplyClick() {
const userReviewStep = dtlInstCd ? (ROLE_TO_REVIEW_STEP[dtlInstCd] ?? "LOCAL") : "LOCAL";
const payload: InstlSncnApplySaveRequestDto = buildSavePayload(
data,
checklistFormState,
userReviewStep,
"ST",
);
if (payload.cklstResultList?.some((item) => item.selected === null)) {
alert("모든 검토항목을 검토 완료해야 신청 가능합니다.");
return;
}
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.aply,
request: payload,
successMessage: "신청 완료되었습니다.",
});
queryClient.invalidateQueries({
queryKey: instlSncnKeys.list(),
});
}
async function handleDelClick() {
if (!(await confirm("정말 삭제하시겠습니까?"))) return;
if (!(await confirm("삭제하시겠습니까?"))) return;
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.del,
successMessage: "삭제되었습니다.",
@@ -119,16 +171,53 @@ function FloatingActionButtons({
router.push("/instl-sncn");
}
function handleRjctClick() {
setActiveDialog("rjct");
async function handleEnvoSbmsnClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo: data.detDto.instlSncnDocNo,
docSeComCd,
prgrsSttsComCd: "LE",
};
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn,
request: payload,
successMessage: "제출되었습니다.",
});
queryClient.invalidateQueries({
queryKey: instlSncnKeys.list(),
});
}
async function handleRjctConfirm(rjctRsn: string) {
setActiveDialog(null);
async function handleRjctClick() {
if (!(await confirm("반려하시겠습니까?"))) return;
const payload: DocPrgrsUpdateRequestDto = {
docNo: data.detDto.instlSncnDocNo,
docSeComCd,
prgrsSttsComCd: "SV",
};
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.rjct,
request: rjctRsn,
successMessage: "반려 처리되었습니다.",
request: payload,
successMessage: "반려되었습니다.",
});
queryClient.invalidateQueries({
queryKey: instlSncnKeys.list(),
});
}
async function handleRqstTechRvwClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo: data.detDto.instlSncnDocNo,
docSeComCd,
prgrsSttsComCd: "ER",
};
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw,
request: payload,
successMessage: "기술검토 의뢰가 완료되었습니다.",
});
queryClient.invalidateQueries({
queryKey: instlSncnKeys.list(),
});
}
@@ -172,19 +261,12 @@ function FloatingActionButtons({
</Button>
)}
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.strg) && (
<Button
onClick={() => void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.strg })}
disabled={isProcessing}
>
<Button onClick={handleStrgClick} 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 color="green" onClick={handleAplyClick} disabled={isProcessing}>
신청완료
</Button>
)}
@@ -214,12 +296,7 @@ function FloatingActionButtons({
</Button>
)}
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn) && (
<Button
onClick={() =>
void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn })
}
disabled={isProcessing}
>
<Button onClick={handleEnvoSbmsnClick} disabled={isProcessing}>
환경청제출
</Button>
)}
@@ -238,6 +315,7 @@ function FloatingActionButtons({
)}
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply) && (
<Button
color="green"
onClick={() => void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply })}
disabled={isProcessing}
>
@@ -245,13 +323,7 @@ function FloatingActionButtons({
</Button>
)}
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw) && (
<Button
color="white"
onClick={() =>
void executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw })
}
disabled={isProcessing}
>
<Button onClick={handleRqstTechRvwClick} disabled={isProcessing}>
기술검토 의뢰
</Button>
)}
@@ -315,14 +387,6 @@ function FloatingActionButtons({
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)}
@@ -337,14 +401,26 @@ function FloatingActionButtons({
export { FloatingActionButtons };
async function handleStrg(context: ActionHandlerContext): Promise<void> {
// TODO: 저장 API 호출
context.onSuccess?.();
async function handleStrg(
context: ActionHandlerContext<InstlSncnApplySaveRequestDto>,
): Promise<void> {
try {
await instlSncnService.saveApply(context.request);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleAply(context: ActionHandlerContext): Promise<void> {
// TODO: 신청완료 API 호출
context.onSuccess?.();
async function handleAply(
context: ActionHandlerContext<InstlSncnApplySaveRequestDto>,
): Promise<void> {
try {
await instlSncnService.saveApply(context.request);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleDel(context: ActionHandlerContext): Promise<void> {
@@ -362,14 +438,24 @@ async function handleInstlSncnAncmnt(context: ActionHandlerContext): Promise<voi
context.onSuccess?.();
}
async function handleEnvoSbmsn(context: ActionHandlerContext): Promise<void> {
// TODO: 환경청제출 API 호출
context.onSuccess?.();
async function handleEnvoSbmsn(
context: ActionHandlerContext<DocPrgrsUpdateRequestDto>,
): Promise<void> {
try {
await instlSncnService.changePrgrsSttsComCd(context.request);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleRjct(context: ActionHandlerContext): Promise<void> {
// TODO: 반려 API 호출
context.onSuccess?.();
async function handleRjct(context: ActionHandlerContext<DocPrgrsUpdateRequestDto>): Promise<void> {
try {
await instlSncnService.changePrgrsSttsComCd(context.request);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleSncnAprv(context: ActionHandlerContext): Promise<void> {
@@ -446,6 +532,7 @@ const buttonActionHandlers: Record<InstlSncnViewButtonAction, ActionHandler> = {
grndsChck: handleGrndsChck,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface ActionHandlerContext<T = any> {
request: T;
data: InstlSncnMngDetResponse;
@@ -9,14 +9,10 @@ interface InstlEditPageProps {
}
function InstlEditPage({ id }: InstlEditPageProps) {
const { data, isLoading, isError } = useInstlSncnDetail(id);
if (isLoading) {
return <div className="mt-6 text-[1.4rem]">수정 정보를 불러오는 중...</div>;
}
const { data, isError } = useInstlSncnDetail(id);
if (isError || !data) {
return <div className="mt-6 text-[1.4rem]">수정 대상을 찾을 수 없습니다.</div>;
return <div className="mt-6 text-xl">수정 대상을 찾을 수 없습니다.</div>;
}
return <InstlFormPage mode="edit" initialData={data} />;
@@ -7,7 +7,7 @@ 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 { type InstlSncnMngDetResponse, instlSncnService } from "@/entities/instl-sncn";
import { Button } from "@common/components";
import {
@@ -67,7 +67,7 @@ function InstlFormPage({ mode, initialData }: InstlFormPageProps) {
esntlAtchFileList: data.esntlAtchFileList,
};
const instlSncnDocNo = await InstlSncnApi.saveApply(payload);
const instlSncnDocNo = await instlSncnService.saveApply(payload);
alert(isEditMode ? "수정되었습니다." : "저장되었습니다.");
router.push(`/instl-sncn/${instlSncnDocNo}`);
@@ -1,9 +1,13 @@
"use client";
import { useState } from "react";
import { useUserStore } from "@/shared/lib";
import { useInstlSncnDetail } from "@/entities/instl-sncn";
import { Skeleton } from "@common/components";
import { type ChecklistFormState } from "../model/view-types";
import { FloatingActionButtons } from "./components/floating-action-buttons";
import { AttachmentsSection } from "./sections/attachments-section";
import { BasicInfoSection } from "./sections/basic-info-section";
@@ -20,6 +24,10 @@ interface InstlViewPageProps {
function InstlViewPage({ id }: InstlViewPageProps) {
const userInfo = useUserStore((state) => state.userInfo);
const { data, isLoading, isError } = useInstlSncnDetail(id);
const [checklistFormState, setChecklistFormState] = useState<ChecklistFormState>({
results: {},
comments: {},
});
if (isLoading) {
return (
@@ -64,14 +72,18 @@ function InstlViewPage({ id }: InstlViewPageProps) {
cklstResultList={cklstResultList}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
userRoleCode={userInfo?.dtlInstCd}
formState={checklistFormState}
setFormState={setChecklistFormState}
/>
<AttachmentsSection esntlFileList={esntlFileList} etcFileList={etcFileList} />
<TechReviewOpinionSection rvwList={rvwList} />
<FloatingActionButtons
data={data}
docSeComCd="INSTL"
instlSncnDocNo={detDto.instlSncnDocNo}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
dtlInstCd={userInfo?.dtlInstCd}
checklistFormState={checklistFormState}
/>
</div>
);
@@ -3,8 +3,8 @@
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 { useAcntgSeMngTree } from "@/entities/acntg";
import {
Input,
Skeleton,
@@ -3,8 +3,8 @@
import { useEffect } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useCnstpSeMngPage } from "@/entities/cnstp";
import { Field } from "@/shared/ui/field";
import { useCnstpSeMngPage } from "@/entities/cnstp";
import {
Input,
Table,
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from "react";
import type { CklstRvwRsltTreeDto, ReviewStepCode } from "@/entities/instl-sncn";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
@@ -24,12 +24,16 @@ interface ChecklistSectionProps {
cklstResultList: CklstRvwRsltTreeDto[];
prgrsSttsComCd: string;
userRoleCode?: string;
formState: ChecklistFormState;
setFormState: Dispatch<SetStateAction<ChecklistFormState>>;
}
function ChecklistSection({
cklstResultList,
prgrsSttsComCd,
userRoleCode,
formState,
setFormState,
}: ChecklistSectionProps) {
const visibleReviewSteps = useMemo(
() => getVisibleReviewSteps(prgrsSttsComCd, userRoleCode),
@@ -40,7 +44,6 @@ function ChecklistSection({
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;