[as instl service]체크리스트, 공종 리스트 화면 구성

This commit is contained in:
2026-05-21 16:56:12 +09:00
parent 9673ee0ed2
commit ad13875ce8
10 changed files with 346 additions and 87 deletions
@@ -7,6 +7,8 @@ import type {
CklstMngSaveRequest,
CklstMngSearchParams,
CklstMngService,
CklstTreeNode,
CklstTreeSearchParams,
} from "../model/cklst-mng-interface";
async function findPage(params: CklstMngSearchParams): Promise<PageResponse<CklstMng>> {
@@ -21,6 +23,13 @@ async function findById(cklstSn: number): Promise<CklstMng> {
return response.data;
}
async function findTree(params: CklstTreeSearchParams): Promise<CklstTreeNode[]> {
const response = await client.get<CklstTreeNode[]>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/tree`, {
params,
});
return response.data;
}
async function create(body: CklstMngSaveRequest): Promise<number> {
const response = await client.post<number>(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, body);
return response.data;
@@ -42,6 +51,7 @@ async function remove(cklstSn: number): Promise<number> {
export const cklstMngService = {
findPage,
findById,
findTree,
create,
update,
remove,
+2
View File
@@ -26,4 +26,6 @@ export type {
CklstMngSaveRequest,
CklstMngSearchParams,
CklstMngService,
CklstTreeNode,
CklstTreeSearchParams,
} from "./model/cklst-mng-interface";
@@ -13,6 +13,24 @@ export interface CklstMng {
mdfcnDt?: string;
}
export interface CklstTreeNode {
cklstSn: number;
cklstNm: string;
cklstArtclSn: number;
cklstArtclNm: string;
cklstRvwSn: number;
upCklstRvwSn: number | null;
treeLevel: number;
rvwCn: string;
bchckUseYn: "Y" | "N";
sortSeq: number;
}
export interface CklstTreeSearchParams {
docSeComCd?: string;
taskSeComCd?: string;
}
export interface CklstMngSearchParams extends PageableParams {
cklstNm?: string;
taskSeComCd?: string;
@@ -30,6 +48,7 @@ export interface CklstMngSaveRequest {
export interface CklstMngService {
findPage(params: CklstMngSearchParams): Promise<PageResponse<CklstMng>>;
findById(cklstSn: number): Promise<CklstMng>;
findTree(params: CklstTreeSearchParams): Promise<CklstTreeNode[]>;
create(body: CklstMngSaveRequest): Promise<number>;
update(cklstSn: number, body: CklstMngSaveRequest): Promise<number>;
remove(cklstSn: number): Promise<number>;
@@ -0,0 +1,15 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { CnstpSeMngTreeDto, CnstpService } from "../model/cnstp-interface";
async function findTree(): Promise<CnstpSeMngTreeDto[]> {
const response = await client.get<CnstpSeMngTreeDto[]>(
`${ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE}/tree`,
);
return response.data;
}
export const cnstpService = {
findTree,
} satisfies CnstpService;
+2
View File
@@ -0,0 +1,2 @@
export type { CnstpSeMngTreeDto, CnstpService } from "./model/cnstp-interface";
export { cnstpService } from "./api/cnstp-service";
@@ -0,0 +1,23 @@
/** 공종구분 트리 노드 DTO */
export interface CnstpSeMngTreeDto {
/** 들여쓰기 포함 표시명 */
indCnstpSeCdNm: string;
/** 공종구분코드 */
cnstpSeCd: string;
/** 공종구분코드명 */
cnstpSeCdNm: string;
/** 상위 공종구분코드 (null = 최상위) */
upCnstpCd: string | null;
/** 사용여부 */
useYn: string;
/** 트리 레벨 */
treeLevel: number;
/** 최상위~현재 노드까지 "|" 구분 경로 */
treePath: string;
}
/** 공종구분 서비스 인터페이스 */
export interface CnstpService {
/** 공종구분 트리 전체 조회 */
findTree(): Promise<CnstpSeMngTreeDto[]>;
}
+1
View File
@@ -1,6 +1,7 @@
export { InstlSncnApi } from "./api/instl-sncn-service";
export {
INSTL_SNCN_REGISTER_SELECTION_KEY,
type InstlSncnBzScaleSaveDto,
type InstlSncnMng,
type InstlSncnMngSearchParams,
type SpringPage,
@@ -54,6 +54,32 @@ export interface InstlSncnMng {
rgtrNm: string;
}
/** 설치인가 신청 - 사업규모 저장 DTO(배열 원소) */
export interface InstlSncnBzScaleSaveDto {
/** 공종구분코드 */
cnstpSeCd: string;
/** 기본계획(A) */
bscPl: number;
/** 예산반영(B) */
budgetReflct: number;
/** 금회설계시(C) */
thtiDsgn: number;
/** 증감(C-B) */
diff: number;
}
/** 설치인가 신청 - 총사업비 저장 DTO(배열 원소) */
export interface InstlSncnBizCstSaveRequestDto {
/** 회계구분코드 */
acntgSeCd: string;
/** 기본계획금액 */
bscPlanAmt: number;
/** 예산반영금액 */
bgtRfltAmt: number;
/** 금회설계금액 */
tstmDesignAmt: number;
}
export interface InstlSncnMngService {
getPage: (params: InstlSncnMngSearchParams) => Promise<SpringPage<InstlSncnMng>>;
getById: (id: string) => Promise<InstlSncnMng>;
@@ -7,7 +7,14 @@ import { useQuery } from "@tanstack/react-query";
import type { AcntgSeMngTreeDto } from "@/entities/acntg";
import { acntgService } from "@/entities/acntg";
import { INSTL_SNCN_REGISTER_SELECTION_KEY } from "@/entities/instl-sncn";
import type { CklstTreeNode } from "@/entities/cklst";
import { cklstMngService } from "@/entities/cklst";
import type { CnstpSeMngTreeDto } from "@/entities/cnstp";
import { cnstpService } from "@/entities/cnstp";
import {
INSTL_SNCN_REGISTER_SELECTION_KEY,
type InstlSncnBzScaleSaveDto,
} from "@/entities/instl-sncn";
import { Button, Input } from "@common/components";
import "./cnfm-page.css";
@@ -96,25 +103,139 @@ function CnfmRegistPage() {
const [etcNoticeOpen, setEtcNoticeOpen] = useState(true);
/* ── 체크리스트 ── */
const CHECKLIST_ITEMS = [
"사업계획의 적정성",
"사업규모의 적정성",
"사업방식의 적정성",
"사업비의 적정성",
"사업효과의 적정성",
"사업일정의 적정성",
];
const [checkResults, setCheckResults] = useState<Record<string, "Y" | "N" | "">>(() =>
Object.fromEntries(CHECKLIST_ITEMS.map((item) => [item, "" as "Y" | "N" | ""])),
);
type CheckResultVal = "Y" | "N" | "";
const [checkResults, setCheckResults] = useState<Record<string, CheckResultVal>>({});
/* ── 사업규모 ── */
const [bzSpecWwtp, setBzSpecWwtp] = useState<BzScaleRow>(emptyScale()); // 처리장용량
const [bzSpecArea, setBzSpecArea] = useState<BzScaleRow>(emptyScale()); // 면적
const [bzSpecNcr, setBzSpecNcr] = useState<BzScaleRow>(emptyScale()); // 하수관로 신설
const [bzSpecRplc, setBzSpecRplc] = useState<BzScaleRow>(emptyScale()); // 교체(보수)
const [bzSpecPump, setBzSpecPump] = useState<BzScaleRow>(emptyScale()); // 펌프장
const [bzSpecDrng, setBzSpecDrng] = useState<BzScaleRow>(emptyScale()); // 배수설비
const { data: cklstTree = [] } = useQuery<CklstTreeNode[]>({
queryKey: ["cklstTree", "INSTL", taskSeComCd],
queryFn: () => cklstMngService.findTree({ docSeComCd: "INSTL", taskSeComCd }),
enabled: Boolean(taskSeComCd),
staleTime: Infinity,
});
const cklstRows = useMemo(() => {
// bchckUseYn 필터 제거: 부모가 있는 자식 노드(bchckUseYn=N)도 트리에 포함
const useRows = cklstTree;
if (useRows.length === 0) return [] as CklstTreeNode[];
// artcl 단위로 분리하지 않고 전체 rows에서 단일 childMap을 구성한다.
// upCklstRvwSn이 다른 artcl 소속 노드를 부모로 가질 수 있으므로
// artcl 경계를 넘는 부모-자식 관계도 처리해야 한다.
const childMap = new Map<number | null, CklstTreeNode[]>();
for (const row of useRows) {
const parent = row.upCklstRvwSn ?? null;
if (!childMap.has(parent)) childMap.set(parent, []);
childMap.get(parent)!.push(row);
}
for (const list of childMap.values()) {
list.sort((a, b) =>
a.cklstArtclSn !== b.cklstArtclSn
? a.cklstArtclSn - b.cklstArtclSn
: a.sortSeq !== b.sortSeq
? a.sortSeq - b.sortSeq
: a.cklstRvwSn - b.cklstRvwSn,
);
}
const ordered: CklstTreeNode[] = [];
const walk = (parent: number | null) => {
const children = childMap.get(parent) ?? [];
for (const child of children) {
ordered.push(child);
walk(child.cklstRvwSn);
}
};
walk(null);
return ordered;
}, [cklstTree]);
const cklstKey = (row: CklstTreeNode) =>
`${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}`;
useEffect(() => {
setCheckResults((prev) => {
const next: Record<string, CheckResultVal> = {};
cklstRows.forEach((row) => {
const key = cklstKey(row);
next[key] = prev[key] ?? "";
});
return next;
});
}, [cklstRows]);
const cklstStats = useMemo(() => {
const total = cklstRows.length;
const completed = cklstRows.reduce((acc, row) => {
const val = checkResults[cklstKey(row)] ?? "";
return acc + (val ? 1 : 0);
}, 0);
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
return { total, completed, percent };
}, [cklstRows, checkResults]);
const toCklstSaveDtoList = () =>
cklstRows
.map((row) => ({
cklstSn: row.cklstSn,
cklstArtclSn: row.cklstArtclSn,
cklstRvwSn: row.cklstRvwSn,
selected: checkResults[cklstKey(row)] ?? "",
}))
.filter((row) => row.selected !== "");
/* ── 사업규모 (공종구분 트리 연동) ── */
// cnstpSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map
const [bzScaleMap, setBzScaleMap] = useState<Record<string, BzScaleRow>>({});
/* ── 공종구분 트리 로드 ── */
const { data: cnstpTree = [] } = useQuery<CnstpSeMngTreeDto[]>({
queryKey: ["cnstpTree"],
queryFn: cnstpService.findTree,
staleTime: Infinity,
});
/* ── 공종구분 리프 노드 집합 ── */
const cnstpLeafSet = useMemo(() => {
const parentCodes = new Set(cnstpTree.map((n) => n.upCnstpCd).filter(Boolean) as string[]);
return new Set(cnstpTree.filter((n) => !parentCodes.has(n.cnstpSeCd)).map((n) => n.cnstpSeCd));
}, [cnstpTree]);
const subtotalScale = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number =>
cnstpTree
.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 =>
cnstpLeafSet.has(node.cnstpSeCd)
? num((bzScaleMap[node.cnstpSeCd] ?? emptyScale())[col])
: subtotalScale(node, col);
const handleScaleInput = (cnstpSeCd: string, col: keyof BzScaleRow, val: string) =>
setBzScaleMap((prev) => ({
...prev,
[cnstpSeCd]: { ...(prev[cnstpSeCd] ?? emptyScale()), [col]: val },
}));
/** 사업규모 맵 -> 백엔드 저장용 배열 DTO 변환 */
const toBzScaleSaveDtoList = (): InstlSncnBzScaleSaveDto[] =>
cnstpTree
.filter((node) => cnstpLeafSet.has(node.cnstpSeCd))
.map((node) => {
const row = bzScaleMap[node.cnstpSeCd] ?? emptyScale();
const bscPl = num(row.bscPl);
const budgetReflct = num(row.budgetReflct);
const thtiDsgn = num(row.thtiDsgn);
return {
cnstpSeCd: node.cnstpSeCd,
bscPl,
budgetReflct,
thtiDsgn,
diff: thtiDsgn - budgetReflct,
};
});
/* ── 총사업비 (회계구분 트리 연동) ── */
// acntgSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map
@@ -173,6 +294,13 @@ function CnfmRegistPage() {
return "";
};
/** 공종 트리 행 스타일 분류 */
const scaleRowStyle = (node: CnstpSeMngTreeDto) => {
if (node.treeLevel === 1) return "bg-gray-50 font-semibold";
if (!cnstpLeafSet.has(node.cnstpSeCd)) return "font-semibold";
return "";
};
/* ── 증감 라벨 ── */
const diffLabel = (v: number) => (v > 0 ? `+${fmt(v)}` : fmt(v));
@@ -201,29 +329,24 @@ function CnfmRegistPage() {
if (!fnValidation("SAVE")) return;
const payload = {
taskSeComCd,
};
bzScaleList: toBzScaleSaveDtoList(),
cklstResultList: toCklstSaveDtoList(),
};
void payload;
alert("저장(임시) API 연동 후 progrsSttus=SV 전송");
alert("저장(임시) API 연동 후 progrsSttus=SV 전송",JSON.stringify(payload, null, 2));
}
function handleSubmit() {
if (!fnValidation("PRESENTN")) return;
const payload = {
taskSeComCd,
bzScaleList: toBzScaleSaveDtoList(),
cklstResultList: toCklstSaveDtoList(),
};
void payload;
alert("제출 API 연동 후 progrsSttus=ST 전송");
}
/* ── 공통 입력 핸들러 ── */
function scaleInput(
setter: React.Dispatch<React.SetStateAction<BzScaleRow>>,
col: keyof BzScaleRow,
val: string,
) {
setter((prev) => ({ ...prev, [col]: val }));
}
/* ---------------------------------------------------------------- */
return (
<div className="mt-6 space-y-4">
@@ -467,7 +590,7 @@ function CnfmRegistPage() {
<table className="cnfm-table">
<thead>
<tr>
<th rowSpan={2} className="w-[12rem]">
<th rowSpan={2} className="w-[30rem]">
구분
</th>
<th>기본계획(A)</th>
@@ -477,31 +600,52 @@ function CnfmRegistPage() {
</tr>
</thead>
<tbody>
{[
{ label: "처리장용량(㎥/일)", state: bzSpecWwtp, setter: setBzSpecWwtp },
{ label: "면적(㎡)", state: bzSpecArea, setter: setBzSpecArea },
{ label: "하수관로 신설(m)", state: bzSpecNcr, setter: setBzSpecNcr },
{ label: "교체/보수(m)", state: bzSpecRplc, setter: setBzSpecRplc },
{ label: "펌프장(개소)", state: bzSpecPump, setter: setBzSpecPump },
{ label: "배수설비(개소)", state: bzSpecDrng, setter: setBzSpecDrng },
].map(({ label, state, setter }) => (
<tr key={label}>
<td className="bg-gray-50 pl-2 text-left font-semibold">{label}</td>
{(["bscPl", "budgetReflct", "thtiDsgn"] as const).map((col) => (
<td key={col}>
<input
type="text"
className="cnfm-input w-full text-right"
value={state[col]}
onChange={(e) => scaleInput(setter, col, e.target.value)}
/>
</td>
))}
<td className="bg-gray-50 pr-2 text-right text-[1.25rem] font-semibold">
{diffLabel(num(state.thtiDsgn) - num(state.budgetReflct))}
{cnstpTree.length === 0 ? (
<tr>
<td colSpan={5} className="cnfm-td-empty">
조회된 사업규모 항목이 없습니다.
</td>
</tr>
))}
) : (
cnstpTree.map((node) => {
const isLeaf = cnstpLeafSet.has(node.cnstpSeCd);
const bscPl = scaleNodeVal(node, "bscPl");
const budgetReflct = scaleNodeVal(node, "budgetReflct");
const thtiDsgn = scaleNodeVal(node, "thtiDsgn");
const diffVal = thtiDsgn - budgetReflct;
const pl = indentPx(node.indCnstpSeCdNm);
return (
<tr key={node.cnstpSeCd} className={scaleRowStyle(node)}>
<td
className="bg-gray-50 pl-2 text-left"
style={{ paddingLeft: `${pl + 8}px` }}
>
{node.cnstpSeCdNm}
</td>
{isLeaf
? (["bscPl", "budgetReflct", "thtiDsgn"] as const).map((col) => (
<td key={col}>
<input
type="text"
className="cnfm-input w-full text-right"
value={(bzScaleMap[node.cnstpSeCd] ?? emptyScale())[col]}
onChange={(e) =>
handleScaleInput(node.cnstpSeCd, col, e.target.value)
}
/>
</td>
))
: ([bscPl, budgetReflct, thtiDsgn] as number[]).map((val, idx) => (
<ReadCell key={`${node.cnstpSeCd}_${idx}`} value={fmt(val)} />
))}
<ReadCell value={diffLabel(diffVal)} />
</tr>
);
})
)}
</tbody>
</table>
</div>
@@ -608,11 +752,13 @@ function CnfmRegistPage() {
<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>
<button className="cnfm-btn-search text-[1.2rem]">하수관로(0/8)</button>
<button className="cnfm-btn-search text-[1.2rem]">
{(cklstRows[0]?.cklstNm ?? taskSeComCdNm) + `(${cklstStats.completed}/${cklstStats.total})`}
</button>
</div>
<div className="mb-2 text-[1.25rem] text-gray-500">
체크리스트 진행상황 — 총합: <strong>0</strong> · 완료: <strong>0</strong> · 진행률:{" "}
<strong>0%</strong>
체크리스트 진행상황 — 총합: <strong>{cklstStats.total}</strong> · 완료:{" "}
<strong>{cklstStats.completed}</strong> · 진행률: <strong>{cklstStats.percent}%</strong>
</div>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
@@ -631,7 +777,7 @@ function CnfmRegistPage() {
className="w-full rounded border border-green-300 bg-green-500 px-3 py-1 text-[1.1rem] font-semibold text-white hover:bg-green-600"
onClick={() =>
setCheckResults(
Object.fromEntries(CHECKLIST_ITEMS.map((i) => [i, "Y" as const])),
Object.fromEntries(cklstRows.map((row) => [cklstKey(row), "Y" as const])),
)
}
>
@@ -644,7 +790,7 @@ function CnfmRegistPage() {
className="w-full rounded border border-red-300 bg-red-500 px-3 py-1 text-[1.1rem] font-semibold text-white hover:bg-red-600"
onClick={() =>
setCheckResults(
Object.fromEntries(CHECKLIST_ITEMS.map((i) => [i, "N" as const])),
Object.fromEntries(cklstRows.map((row) => [cklstKey(row), "N" as const])),
)
}
>
@@ -654,36 +800,50 @@ function CnfmRegistPage() {
</tr>
</thead>
<tbody>
{CHECKLIST_ITEMS.map((item) => (
<tr key={item}>
<td className="pl-2 text-left">{item}</td>
<td className="pl-2 text-left text-[1.2rem] text-gray-400">
금회 총사업비가 확정예산 범위 내
</td>
<td className="text-center">
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-green-600">
<input
type="radio"
name={`chk_${item}`}
value="Y"
checked={checkResults[item] === "Y"}
onChange={() => setCheckResults((prev) => ({ ...prev, [item]: "Y" }))}
/>
</label>
</td>
<td className="text-center">
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-red-500">
<input
type="radio"
name={`chk_${item}`}
value="N"
checked={checkResults[item] === "N"}
onChange={() => setCheckResults((prev) => ({ ...prev, [item]: "N" }))}
/>
</label>
{cklstRows.length === 0 ? (
<tr>
<td colSpan={4} className="cnfm-td-empty">
조회된 체크리스트 항목이 없습니다.
</td>
</tr>
))}
) : (
cklstRows.map((row) => {
const key = cklstKey(row);
const rvwPaddingLeft = `${Math.max(0, row.treeLevel - 1) * 16 + 8}px`;
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: rvwPaddingLeft }}>
{row.treeLevel > 1 ? "└ " : ""}
{row.rvwCn}
</td>
<td className="text-center">
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-green-600">
<input
type="radio"
name={`chk_${key}`}
value="Y"
checked={checkResults[key] === "Y"}
onChange={() => setCheckResults((prev) => ({ ...prev, [key]: "Y" }))}
/>
</label>
</td>
<td className="text-center">
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-red-500">
<input
type="radio"
name={`chk_${key}`}
value="N"
checked={checkResults[key] === "N"}
onChange={() => setCheckResults((prev) => ({ ...prev, [key]: "N" }))}
/>
</label>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
+1
View File
@@ -1,6 +1,7 @@
export const ENDPOINT = {
AS_COMMON_SERVICE: {
ACNTG_SE: "/as-common-service/api/v1/acntg-se",
CNSTP_SE: "/as-common-service/api/v1/cnstp-se",
},
AS_INSTL_SERVICE: {
INSTL_SNCN: "/as-instl-service/api/v1/instl-sncn",