Merge branch 'iipbs/as/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/as/dev

This commit is contained in:
2026-05-19 16:01:43 +09:00
15 changed files with 1318 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
{
// IntelliSense를 사용하여 가능한 특성에 대해 알아보세요.
// 기존 특성에 대한 설명을 보려면 가리킵니다.
// 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요.
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "dev:as 디버그",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"run",
"dev:as"
],
"skipFiles": [
"<node_internals>/**"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"env": {
"NODE_OPTIONS": "--inspect"
}
},
{
"type": "node",
"request": "launch",
"name": "dev:cs 디버그",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"run",
"dev:cs"
],
"skipFiles": [
"<node_internals>/**"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"env": {
"NODE_OPTIONS": "--inspect"
}
},
{
"type": "node",
"request": "attach",
"name": "dev:cs 서버 연결 (attach)",
"port": 9229,
"restart": true,
"skipFiles": [
"<node_internals>/**"
],
"sourceMaps": true,
"resolveSourceMapLocations": [
"${workspaceFolder}/apps/cs/**",
"!**/node_modules/**"
]
},
{
"type": "chrome",
"request": "launch",
"name": "dev:cs 클라이언트 디버그 (Chrome)",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/apps/cs",
"sourceMaps": true
},
{
"type": "node",
"request": "launch",
"name": "dev:st 디버그",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"run",
"dev:st"
],
"skipFiles": [
"<node_internals>/**"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"env": {
"NODE_OPTIONS": "--inspect"
}
}
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"chat.tools.terminal.autoApprove": {
"pnpm": true
}
}
@@ -0,0 +1,10 @@
import { CnfmModifyPage } from "@/pages/instl-sncn";
export default async function InstlSncnModifyPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <CnfmModifyPage id={id} />;
}
@@ -0,0 +1,10 @@
import { CnfmViewPage } from "@/pages/instl-sncn";
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";
export default CnfmListPage;
@@ -0,0 +1,3 @@
import { CnfmRegistPage } from "@/pages/instl-sncn";
export default CnfmRegistPage;
@@ -0,0 +1,24 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
InstlSncnMng,
InstlSncnMngSearchParams,
InstlSncnMngService,
SpringPage,
} from "../model/instl-sncn-interface";
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, {
params,
});
return response.data;
}
public async getById(id: string): Promise<InstlSncnMng> {
const response = await client.get<InstlSncnMng>(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`);
return response.data;
}
}
export const InstlSncnApi = new InstlSncnApiService();
+6
View File
@@ -0,0 +1,6 @@
export { InstlSncnApi } from "./api/instl-sncn-service";
export type {
InstlSncnMng,
InstlSncnMngSearchParams,
SpringPage,
} from "./model/instl-sncn-interface";
@@ -0,0 +1,58 @@
export interface SpringPage<T> {
content: T[];
totalElements: number;
totalPages: number;
number: number;
size: number;
first: boolean;
last: boolean;
numberOfElements: number;
}
export interface InstlSncnMngSearchParams {
bizNm?: string;
taskSeComCd?: string;
sidoComCd?: string;
sggComCd?: string;
page?: number;
size?: number;
sort?: string;
}
export interface InstlSncnMng {
instlSncnDocNo: string;
dongId: string;
sidoNm?: string;
sggNm?: string;
chrgrNm?: string;
instNm?: string;
envoSeComCd: string;
envoSeCdNm: string;
taskSeComCd: string;
taskSeCdNm: string;
bgtBizCd: string;
bgtAplyYr: string;
bizNm: string;
bizBgngYr: string;
bizEndYr: string;
bizCn: string;
aplcntNm: string;
aplcntMblTelno: string;
aplcntOgdpNm: string;
aplcntEmlAddr: string;
dsgnrNm: string;
dsgnrTelno: string;
dsgnrOgdpNm: string;
dsgnrEmlAddr: string;
atchFileGroupId: string;
prgrsSttsComCd: string;
prgrsSttsCdNm: string;
regDt: string;
mdfcnDt: string;
rgtrNm: string;
}
export interface InstlSncnMngService {
getPage: (params: InstlSncnMngSearchParams) => Promise<SpringPage<InstlSncnMng>>;
getById: (id: string) => Promise<InstlSncnMng>;
}
+4
View File
@@ -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";
@@ -0,0 +1,318 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { dayjs } from "@/shared/lib";
import { InstlSncnApi, type InstlSncnMngSearchParams } from "@/entities/instl-sncn";
import "./cnfm-page.css";
const TASK_OPTIONS = [
{ value: "", label: "전체" },
{ value: "SF", label: "하수처리시설" },
{ value: "SP", label: "하수관로" },
{ value: "RE", label: "재이용수" },
];
const SIDO_OPTIONS = [
{ value: "", label: "전체" },
{ value: "11", label: "서울특별시" },
{ value: "26", label: "부산광역시" },
{ value: "27", label: "대구광역시" },
{ value: "28", label: "인천광역시" },
{ value: "29", label: "광주광역시" },
{ value: "30", label: "대전광역시" },
{ value: "31", label: "울산광역시" },
{ value: "36", label: "세종특별자치시" },
{ value: "41", label: "경기도" },
{ value: "43", label: "충청북도" },
{ value: "44", label: "충청남도" },
{ value: "45", label: "전라북도" },
{ value: "46", label: "전라남도" },
{ value: "47", label: "경상북도" },
{ value: "48", label: "경상남도" },
{ value: "50", label: "제주특별자치도" },
];
type StepCode = "SV" | "ST" | "ER" | "TE" | "TR" | "TP" | "TA";
const STEPS: { code: StepCode; label: string }[] = [
{ code: "SV", label: "저장" },
{ code: "ST", label: "신청완료" },
{ code: "ER", label: "환경청 검토" },
{ code: "TE", label: "기술검토" },
{ code: "TR", label: "검토결과 회신" },
{ code: "TP", label: "기술평가" },
{ code: "TA", label: "최종승인" },
];
const PAGE_GROUP_SIZE = 5;
function CnfmListPage() {
const router = useRouter();
const [bizNm, setBizNm] = useState("");
const [taskSeComCd, setTaskSeComCd] = useState("");
const [sidoComCd, setSidoComCd] = useState("");
const [sggComCd, setSggComCd] = useState("");
const [search, setSearch] = useState<InstlSncnMngSearchParams>({
bizNm: "",
taskSeComCd: "",
sidoComCd: "",
sggComCd: "",
});
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const query = useQuery({
queryKey: ["instl-sncn-list", search, pageIndex, pageSize],
queryFn: () =>
InstlSncnApi.getPage({
...search,
page: pageIndex,
size: pageSize,
sort: "regDt,desc",
}),
});
const totalPages = query.data?.totalPages ?? 0;
const pageGroupStart = Math.floor(pageIndex / PAGE_GROUP_SIZE) * PAGE_GROUP_SIZE;
const pageNumbers = useMemo(() => {
const end = Math.min(pageGroupStart + PAGE_GROUP_SIZE, totalPages);
return Array.from({ length: end - pageGroupStart }, (_, i) => pageGroupStart + i);
}, [pageGroupStart, totalPages]);
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setPageIndex(0);
setSearch({ bizNm, taskSeComCd, sidoComCd, sggComCd });
};
return (
<div className="cnfm-page-wrap">
{/* 검색 영역 */}
<div className="cnfm-card">
<form onSubmit={handleSearch} className="cnfm-search-bar">
<div className="cnfm-search-field">
<label className="cnfm-search-label" htmlFor="sidoComCd">시도</label>
<select
id="sidoComCd"
className="cnfm-select"
value={sidoComCd}
onChange={(e) => setSidoComCd(e.target.value)}
>
{SIDO_OPTIONS.map((opt) => (
<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>
<select
id="sggComCd"
className="cnfm-select"
value={sggComCd}
onChange={(e) => setSggComCd(e.target.value)}
>
<option value="">전체</option>
</select>
</div>
<div className="cnfm-search-field cnfm-search-field--grow">
<label className="cnfm-search-label" htmlFor="bizNm">사업명</label>
<input
id="bizNm"
type="text"
className="cnfm-input"
value={bizNm}
onChange={(e) => setBizNm(e.target.value)}
placeholder="사업명을 입력하세요"
/>
</div>
<div className="cnfm-search-field">
<label className="cnfm-search-label" htmlFor="taskSeComCd">구분</label>
<select
id="taskSeComCd"
className="cnfm-select cnfm-select--wide"
value={taskSeComCd}
onChange={(e) => setTaskSeComCd(e.target.value)}
>
{TASK_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<button type="submit" className="cnfm-btn-search">검색</button>
</form>
</div>
{/* 목록 영역 */}
<div className="cnfm-card mt-4">
<div className="cnfm-list-header">
<h3 className="cnfm-list-title">설치인가 목록</h3>
<div className="cnfm-list-actions">
<button
type="button"
className="cnfm-btn-register"
onClick={() => router.push("/instl-sncn/register")}
>
✎ 신청
</button>
<select
className="cnfm-select"
value={pageSize}
onChange={(e) => {
setPageIndex(0);
setPageSize(Number(e.target.value));
}}
>
{[10, 20, 30, 50].map((size) => (
<option key={size} value={size}>{size}개</option>
))}
</select>
</div>
</div>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th>시도</th>
<th>시군구</th>
<th>사업명</th>
<th>구분</th>
<th>담당자</th>
<th>해당기관</th>
<th>진행상황</th>
<th>시행일</th>
</tr>
</thead>
<tbody>
{query.isLoading && (
<tr>
<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>
</tr>
)}
{(query.data?.content ?? []).map((row) => {
const currentStepIndex = STEPS.findIndex((s) => s.code === row.prgrsSttsComCd);
return (
<tr
key={row.instlSncnDocNo}
className="cnfm-row-clickable"
onClick={() => router.push(`/instl-sncn/${row.instlSncnDocNo}`)}
>
<td>{row.sidoNm || "-"}</td>
<td>{row.sggNm || "-"}</td>
<td className="text-left">{row.bizNm || "-"}</td>
<td>{row.taskSeCdNm || row.taskSeComCd || "-"}</td>
<td>{row.chrgrNm || row.aplcntNm || "-"}</td>
<td>{row.instNm || row.aplcntOgdpNm || "-"}</td>
<td>
<div className="cnfm-step-wrap">
<div className="cnfm-step-row">
{STEPS.slice(0, 4).map((step, index) => {
const state =
index < currentStepIndex
? "completed"
: index === currentStepIndex
? "current"
: "pending";
return (
<span key={step.code} className={`cnfm-step-badge cnfm-step-badge--${state}`}>
<span className={`cnfm-step-dot cnfm-step-dot--${state}`} />
{step.label}
</span>
);
})}
</div>
<div className="cnfm-step-row">
{STEPS.slice(4).map((step, i) => {
const index = i + 4;
const state =
index < currentStepIndex
? "completed"
: index === currentStepIndex
? "current"
: "pending";
return (
<span key={step.code} className={`cnfm-step-badge cnfm-step-badge--${state}`}>
<span className={`cnfm-step-dot cnfm-step-dot--${state}`} />
{step.label}
</span>
);
})}
</div>
</div>
</td>
<td>{row.regDt ? dayjs(row.regDt).format("YYYY-MM-DD") : "-"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
<div className="cnfm-pagination">
<button
type="button"
className="cnfm-page-btn"
onClick={() => setPageIndex(0)}
disabled={pageIndex === 0}
>
«
</button>
<button
type="button"
className="cnfm-page-btn"
onClick={() => setPageIndex((prev) => Math.max(0, prev - 1))}
disabled={pageIndex === 0}
>
‹
</button>
{pageNumbers.map((pageNo) => (
<button
type="button"
key={pageNo}
className={`cnfm-page-btn ${pageNo === pageIndex ? "active" : ""}`}
onClick={() => setPageIndex(pageNo)}
>
{pageNo + 1}
</button>
))}
<button
type="button"
className="cnfm-page-btn"
onClick={() => setPageIndex((prev) => Math.min(Math.max(0, totalPages - 1), prev + 1))}
disabled={pageIndex >= totalPages - 1}
>
›
</button>
<button
type="button"
className="cnfm-page-btn"
onClick={() => setPageIndex(Math.max(0, totalPages - 1))}
disabled={pageIndex >= totalPages - 1}
>
»
</button>
</div>
</div>
</div>
);
}
export { CnfmListPage };
@@ -0,0 +1,79 @@
"use client";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Button, Input } from "@common/components";
import { InstlSncnApi } from "@/entities/instl-sncn";
import "./cnfm-page.css";
function CnfmModifyPage({ id }: { id: string }) {
const router = useRouter();
const query = useQuery({
queryKey: ["instl-sncn-modify", id],
queryFn: () => InstlSncnApi.getById(id),
});
if (query.isLoading) {
return <div className="mt-6 text-[1.4rem]">수정 정보를 불러오는 중입니다...</div>;
}
if (!query.data) {
return <div className="mt-6 text-[1.4rem]">수정 대상을 찾을 수 없습니다.</div>;
}
const data = query.data;
return (
<div className="mt-6 space-y-4">
<div className="cnfm-card p-4">
<h2 className="cnfm-section-title">설치인가 수정</h2>
<p className="mt-2 text-[1.3rem] text-gray-600">
상세 조회 API 데이터를 기반으로 수정 화면을 구성했습니다.
</p>
</div>
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-semibold">기본 정보</h3>
<table className="cnfm-form-table mt-3">
<tbody>
<tr>
<th>문서번호</th>
<td>{data.instlSncnDocNo}</td>
<th>진행상태</th>
<td>{data.prgrsSttsCdNm || data.prgrsSttsComCd || "-"}</td>
</tr>
<tr>
<th>사업명</th>
<td>
<Input defaultValue={data.bizNm || ""} className="!text-[1.3rem]" />
</td>
<th>사업내용</th>
<td>
<Input defaultValue={data.bizCn || ""} className="!text-[1.3rem]" />
</td>
</tr>
<tr>
<th>설계자명</th>
<td>
<Input defaultValue={data.dsgnrNm || ""} className="!text-[1.3rem]" />
</td>
<th>설계자 연락처</th>
<td>
<Input defaultValue={data.dsgnrTelno || ""} className="!text-[1.3rem]" />
</td>
</tr>
</tbody>
</table>
</div>
<div className="cnfm-action-row">
<Button color="white" onClick={() => router.push(`/instl-sncn/${id}`)}>상세로</Button>
<Button type="button" onClick={() => window.alert("수정 API 연동은 추후 확장 가능합니다.")}>저장</Button>
</div>
</div>
);
}
export { CnfmModifyPage };
@@ -0,0 +1,490 @@
/* ============================
페이지 레이아웃
============================= */
.cnfm-page-wrap {
padding: 2rem 2.4rem;
}
/* ============================
카드
============================= */
.cnfm-card {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #ffffff;
padding: 1.6rem 2rem;
}
.cnfm-card.mt-4 {
margin-top: 1.6rem;
}
/* ============================
검색 바
============================= */
.cnfm-search-bar {
display: flex;
align-items: center;
gap: 1.2rem;
flex-wrap: wrap;
}
.cnfm-search-field {
display: flex;
align-items: center;
gap: 0.6rem;
}
.cnfm-search-field--grow {
flex: 1;
min-width: 18rem;
}
.cnfm-search-label {
font-size: 1.3rem;
font-weight: 600;
color: #374151;
white-space: nowrap;
}
.cnfm-input {
height: 3.6rem;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 0 1rem;
font-size: 1.3rem;
color: #111827;
width: 100%;
outline: none;
background: #fff;
}
.cnfm-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.cnfm-select {
height: 3.6rem;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 0 0.8rem;
font-size: 1.3rem;
color: #111827;
background: #fff;
min-width: 11rem;
cursor: pointer;
outline: none;
}
.cnfm-select--wide {
min-width: 16rem;
}
.cnfm-select:focus {
border-color: #3b82f6;
}
.cnfm-btn-search {
height: 3.6rem;
padding: 0 2rem;
background: #3b82f6;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
}
.cnfm-btn-search:hover {
background: #2563eb;
}
/* ============================
목록 헤더
============================= */
.cnfm-list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.2rem;
}
.cnfm-list-title {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.cnfm-list-actions {
display: flex;
align-items: center;
gap: 0.8rem;
}
.cnfm-btn-register {
height: 3.4rem;
padding: 0 1.6rem;
background: #22c55e;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
}
.cnfm-btn-register:hover {
background: #16a34a;
}
/* ============================
테이블
============================= */
.cnfm-table-wrap {
overflow-x: auto;
}
.cnfm-table {
width: 100%;
border-collapse: collapse;
font-size: 1.3rem;
}
.cnfm-table th,
.cnfm-table td {
border-bottom: 1px solid #e5e7eb;
padding: 1.2rem 0.8rem;
text-align: center;
vertical-align: middle;
}
.cnfm-table thead th {
background: #f8fafc;
color: #374151;
font-weight: 600;
font-size: 1.25rem;
border-bottom: 2px solid #e2e8f0;
}
.cnfm-table tbody td {
color: #111827;
}
.cnfm-td-empty {
padding: 3rem 0;
color: #9ca3af;
font-size: 1.3rem;
}
.cnfm-row-clickable {
cursor: pointer;
}
.cnfm-row-clickable:hover {
background: #f8fafc;
}
/* ============================
진행상황 스텝 배지
============================= */
.cnfm-step-wrap {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
.cnfm-step-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: nowrap;
}
.cnfm-step-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border-radius: 9999px;
padding: 0.3rem 0.9rem;
font-size: 1.15rem;
font-weight: 500;
white-space: nowrap;
border: 1px solid transparent;
}
.cnfm-step-badge--completed {
background: #dbeafe;
border-color: #93c5fd;
color: #1d4ed8;
}
.cnfm-step-badge--current {
background: #fed7aa;
border-color: #fb923c;
color: #c2410c;
font-weight: 700;
}
.cnfm-step-badge--pending {
background: #f3f4f6;
border-color: #d1d5db;
color: #9ca3af;
}
.cnfm-step-dot {
display: inline-block;
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
flex-shrink: 0;
}
.cnfm-step-dot--completed {
background: #3b82f6;
}
.cnfm-step-dot--current {
background: #f97316;
}
.cnfm-step-dot--pending {
background: #d1d5db;
}
/* ============================
페이지네이션
============================= */
.cnfm-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.4rem;
margin-top: 1.6rem;
padding-bottom: 0.4rem;
}
.cnfm-page-btn {
min-width: 3.2rem;
height: 3.2rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
font-size: 1.3rem;
color: #374151;
cursor: pointer;
transition: all 0.15s;
padding: 0 0.6rem;
}
.cnfm-page-btn:hover:not(:disabled) {
border-color: #3b82f6;
color: #3b82f6;
}
.cnfm-page-btn.active {
border-color: #3b82f6;
background: #3b82f6;
color: #fff;
font-weight: 700;
}
.cnfm-page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ============================
폼 테이블 (상세/등록 페이지용)
============================= */
.cnfm-form-table {
width: 100%;
border-collapse: collapse;
font-size: 1.3rem;
}
.cnfm-form-table th,
.cnfm-form-table td {
border: 1px solid #e5e7eb;
padding: 0.9rem 1rem;
}
.cnfm-form-table th {
width: 16rem;
background: #f9fafb;
text-align: left;
font-weight: 700;
}
.cnfm-form-table td {
background: #fff;
}
.cnfm-action-row {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.8rem;
}
/* ============================
기존 호환 클래스 유지
============================= */
.cnfm-section-title {
font-size: 1.8rem;
font-weight: 700;
color: #1f2937;
}
@media (max-width: 1100px) {
.cnfm-search-bar {
flex-direction: column;
align-items: stretch;
}
.cnfm-search-field {
flex-direction: column;
align-items: flex-start;
}
.cnfm-search-field--grow {
width: 100%;
}
}
.cnfm-section-title {
font-size: 1.8rem;
font-weight: 700;
color: #1f2937;
}
.cnfm-search-grid {
display: grid;
grid-template-columns: 8rem 1fr 6rem 16rem auto auto;
gap: 1.2rem;
align-items: center;
}
.cnfm-table-wrap {
overflow-x: auto;
}
.cnfm-table {
width: 100%;
border-collapse: collapse;
font-size: 1.3rem;
}
.cnfm-table th,
.cnfm-table td {
border: 1px solid #e5e7eb;
padding: 1rem 0.8rem;
text-align: center;
vertical-align: middle;
}
.cnfm-table thead th {
background: #f3f4f6;
color: #111827;
font-weight: 700;
}
.cnfm-row-clickable {
cursor: pointer;
}
.cnfm-row-clickable:hover {
background: #f9fafb;
}
.cnfm-step {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: center;
}
.cnfm-step-badge {
border-radius: 9999px;
border: 1px solid #d1d5db;
background: #f9fafb;
color: #6b7280;
font-size: 1.1rem;
padding: 0.25rem 0.65rem;
}
.cnfm-step-badge.completed {
border-color: #93c5fd;
background: #dbeafe;
color: #1d4ed8;
}
.cnfm-step-badge.current {
border-color: #16a34a;
background: #dcfce7;
color: #166534;
font-weight: 700;
}
.cnfm-pagination {
display: flex;
justify-content: center;
gap: 0.6rem;
margin-top: 1.2rem;
}
.cnfm-pagination button {
min-width: 3.2rem;
height: 3.2rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
font-size: 1.3rem;
}
.cnfm-pagination button.active {
border-color: #2563eb;
background: #2563eb;
color: #fff;
font-weight: 700;
}
.cnfm-form-table {
width: 100%;
border-collapse: collapse;
font-size: 1.3rem;
}
.cnfm-form-table th,
.cnfm-form-table td {
border: 1px solid #e5e7eb;
padding: 0.9rem 1rem;
}
.cnfm-form-table th {
width: 16rem;
background: #f9fafb;
text-align: left;
font-weight: 700;
}
.cnfm-form-table td {
background: #fff;
}
.cnfm-action-row {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1.8rem;
}
@media (max-width: 1100px) {
.cnfm-search-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,89 @@
"use client";
import { useRouter } from "next/navigation";
import { Button, Input } from "@common/components";
import "./cnfm-page.css";
function CnfmRegistPage() {
const router = useRouter();
return (
<div className="mt-6 space-y-4">
<div className="cnfm-card p-4">
<h2 className="cnfm-section-title">설치인가 신청</h2>
<p className="mt-2 text-[1.3rem] text-gray-600">
AS-IS 화면 설계(신청 화면)를 반영한 입력 레이아웃입니다.
</p>
</div>
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-semibold">기본 정보</h3>
<table className="cnfm-form-table mt-3">
<tbody>
<tr>
<th>사업명</th>
<td>
<Input placeholder="사업명 입력" className="!text-[1.3rem]" />
</td>
<th>사업구분</th>
<td>
<select className="h-[3.6rem] w-full rounded-md border border-gray-300 px-3 text-[1.3rem]">
<option value="CNFM">설치인가</option>
<option value="APR">설치승인</option>
<option value="AFA">폐지인가</option>
</select>
</td>
</tr>
<tr>
<th>사업기간(시작)</th>
<td>
<Input placeholder="YYYY" className="!text-[1.3rem]" />
</td>
<th>사업기간(종료)</th>
<td>
<Input placeholder="YYYY" className="!text-[1.3rem]" />
</td>
</tr>
</tbody>
</table>
</div>
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-semibold">신청자 / 설계자 정보</h3>
<table className="cnfm-form-table mt-3">
<tbody>
<tr>
<th>신청자명</th>
<td>
<Input placeholder="신청자명" className="!text-[1.3rem]" />
</td>
<th>신청자 연락처</th>
<td>
<Input placeholder="010-0000-0000" className="!text-[1.3rem]" />
</td>
</tr>
<tr>
<th>설계자명</th>
<td>
<Input placeholder="설계자명" className="!text-[1.3rem]" />
</td>
<th>설계자 소속</th>
<td>
<Input placeholder="설계자 소속" className="!text-[1.3rem]" />
</td>
</tr>
</tbody>
</table>
</div>
<div className="cnfm-action-row">
<Button color="white" onClick={() => router.push("/instl-sncn")}>목록</Button>
<Button type="button" onClick={() => window.alert("저장 API 연동은 추후 확장 가능합니다.")}>저장</Button>
<Button type="button" onClick={() => window.alert("제출 API 연동은 추후 확장 가능합니다.")}>제출</Button>
</div>
</div>
);
}
export { CnfmRegistPage };
@@ -0,0 +1,131 @@
"use client";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@common/components";
import { InstlSncnApi } from "@/entities/instl-sncn";
import { dayjs } from "@/shared/lib";
import "./cnfm-page.css";
function CnfmViewPage({ id }: { id: string }) {
const router = useRouter();
const query = useQuery({
queryKey: ["instl-sncn-detail", id],
queryFn: () => InstlSncnApi.getById(id),
});
if (query.isLoading) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 불러오는 중입니다...</div>;
}
if (!query.data) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
}
const data = query.data;
return (
<div className="mt-6 space-y-4">
<div className="cnfm-card p-4">
<h2 className="cnfm-section-title">설치인가 상세보기</h2>
<p className="mt-2 text-[1.3rem] text-gray-600">
문서번호: <strong>{data.instlSncnDocNo}</strong>
</p>
</div>
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-semibold">기본 정보</h3>
<table className="cnfm-form-table mt-3">
<tbody>
<tr>
<th>사업명</th>
<td>{data.bizNm || "-"}</td>
<th>업무구분</th>
<td>{data.taskSeCdNm || data.taskSeComCd || "-"}</td>
</tr>
<tr>
<th>사업기간</th>
<td>
{(data.bizBgngYr || "-") + " ~ " + (data.bizEndYr || "-")}
</td>
<th>진행상태</th>
<td>{data.prgrsSttsCdNm || data.prgrsSttsComCd || "-"}</td>
</tr>
<tr>
<th>행정동ID</th>
<td>{data.dongId || "-"}</td>
<th>예산사업코드</th>
<td>{data.bgtBizCd || "-"}</td>
</tr>
<tr>
<th>사업내용</th>
<td colSpan={3}>{data.bizCn || "-"}</td>
</tr>
</tbody>
</table>
</div>
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-semibold">담당자 정보</h3>
<table className="cnfm-form-table mt-3">
<tbody>
<tr>
<th>신청자명</th>
<td>{data.aplcntNm || "-"}</td>
<th>신청자 연락처</th>
<td>{data.aplcntMblTelno || "-"}</td>
</tr>
<tr>
<th>신청자 소속</th>
<td>{data.aplcntOgdpNm || "-"}</td>
<th>신청자 이메일</th>
<td>{data.aplcntEmlAddr || "-"}</td>
</tr>
<tr>
<th>설계자명</th>
<td>{data.dsgnrNm || "-"}</td>
<th>설계자 연락처</th>
<td>{data.dsgnrTelno || "-"}</td>
</tr>
<tr>
<th>설계자 소속</th>
<td>{data.dsgnrOgdpNm || "-"}</td>
<th>설계자 이메일</th>
<td>{data.dsgnrEmlAddr || "-"}</td>
</tr>
</tbody>
</table>
</div>
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-semibold">이력 정보</h3>
<table className="cnfm-form-table mt-3">
<tbody>
<tr>
<th>등록일시</th>
<td>{data.regDt ? dayjs(data.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}</td>
<th>수정일시</th>
<td>{data.mdfcnDt ? dayjs(data.mdfcnDt).format("YYYY-MM-DD HH:mm:ss") : "-"}</td>
</tr>
<tr>
<th>등록자명</th>
<td>{data.rgtrNm || "-"}</td>
<th>첨부파일그룹ID</th>
<td>{data.atchFileGroupId || "-"}</td>
</tr>
</tbody>
</table>
</div>
<div className="cnfm-action-row">
<Button color="white" onClick={() => router.push("/instl-sncn")}>목록</Button>
<Button onClick={() => router.push(`/instl-sncn/${id}/modify`)}>수정</Button>
</div>
</div>
);
}
export { CnfmViewPage };