feat(sn): CS 기준 UI 통일 및 지도 팝업 표준화

This commit is contained in:
2026-08-20 14:57:20 +09:00
parent c9e3ff0d91
commit 0cbbc3b934
167 changed files with 21191 additions and 14161 deletions
File diff suppressed because it is too large Load Diff
@@ -3,8 +3,20 @@
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { SelectBox } from "@common/business_components/ui";
import { Button, Input } from "@common/components/ui";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
StandardFormField,
StandardFormSection,
} from "@common/business_components";
import {
Button,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import {
createCtyFldngPrvtnBusiness,
@@ -19,6 +31,7 @@ import type {
} from "../model/cty-fldng-prvtn-biz-types";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { useAlertStore } from "@/shared/lib";
import useStore from "@/store/useStore";
const DETAIL_META = [
@@ -28,7 +41,7 @@ const DETAIL_META = [
{ code: "CW", title: "시설공사" },
] as const;
const BASIC_FIELDS = [
["seqNo", "순번", "number"],
["seqNo", "사업순번", "number"],
["stp", "단계", "text"],
["envoNm", "환경청", "text"],
["dsgnYrCycl", "지정년도/차수", "text"],
@@ -120,23 +133,22 @@ const validDate = (value: unknown) =>
/** 가변 시설 배열의 추가·수정·삭제 UI를 제공한다. */
function FacilityEditor({
label,
unit,
value,
onChange,
}: {
label: string;
unit: string;
value: CtyFldngFacility[];
onChange: (next: CtyFldngFacility[]) => void;
}) {
return (
<div className="rounded border p-3">
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between">
<strong>
{label} ({unit})
</strong>
<span className="text-[1.2rem] text-muted-foreground">
번호 / 용량({unit})
</span>
<Button
type="button"
size="sm"
onClick={() =>
onChange([
@@ -149,7 +161,9 @@ function FacilityEditor({
</Button>
</div>
{value.length === 0 ? (
<p className="text-sm text-gray-500">등록된 시설이 없습니다.</p>
<p className="text-[1.3rem] text-muted-foreground">
등록된 시설이 없습니다.
</p>
) : (
value.map((item, index) => (
<div
@@ -189,8 +203,9 @@ function FacilityEditor({
}
/>
<Button
type="button"
size="sm"
variant="outline"
color="red"
onClick={() => onChange(value.filter((_, i) => i !== index))}
>
삭제
@@ -211,13 +226,17 @@ function DetailEditor({
onChange: (next: CtyFldngDetail) => void;
}) {
return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<>
{DETAIL_FIELDS.map(([key, label, type]) => (
<label key={key} className="flex flex-col gap-1 text-sm font-medium">
{label}
<StandardFormField
key={key}
id={`cty-fldng-detail-${detail.bizPrgrsSe}-${key}`}
label={label}
>
<Input
type={type === "date" ? "text" : type}
placeholder={type === "date" ? "YYYY-MM-DD" : undefined}
id={`cty-fldng-detail-${detail.bizPrgrsSe}-${key}`}
className="w-full !text-[1.3rem]"
type={type === "date" ? "date" : type}
value={String(detail[key] ?? "")}
onChange={(e) =>
onChange({
@@ -229,27 +248,30 @@ function DetailEditor({
})
}
/>
</label>
</StandardFormField>
))}
<FacilityEditor
label="빗물펌프장"
unit="㎥/분"
value={detail.rnwtrPumpst ?? []}
onChange={(value) => onChange({ ...detail, rnwtrPumpst: value })}
/>
<FacilityEditor
label="펌프저류"
unit="㎥"
value={detail.pumpImpd ?? []}
onChange={(value) => onChange({ ...detail, pumpImpd: value })}
/>
<FacilityEditor
label="저류시설"
unit="㎥"
value={detail.impdFclt ?? []}
onChange={(value) => onChange({ ...detail, impdFclt: value })}
/>
</div>
<StandardFormField label="빗물펌프장">
<FacilityEditor
unit="㎥/분"
value={detail.rnwtrPumpst ?? []}
onChange={(value) => onChange({ ...detail, rnwtrPumpst: value })}
/>
</StandardFormField>
<StandardFormField label="펌프저류">
<FacilityEditor
unit="㎥"
value={detail.pumpImpd ?? []}
onChange={(value) => onChange({ ...detail, pumpImpd: value })}
/>
</StandardFormField>
<StandardFormField label="저류시설">
<FacilityEditor
unit="㎥"
value={detail.impdFclt ?? []}
onChange={(value) => onChange({ ...detail, impdFclt: value })}
/>
</StandardFormField>
</>
);
}
@@ -257,6 +279,8 @@ function DetailEditor({
export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [form, setForm] = useState<CtyFldngPrvtnBizSaveRequest>(emptyForm);
const [loading, setLoading] = useState(Boolean(id));
@@ -318,8 +342,9 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
try {
if (id) await updateCtyFldngPrvtnBusiness(id, form);
else await createCtyFldngPrvtnBusiness(form);
window.alert(id ? "수정되었습니다." : "등록되었습니다.");
router.push(listHref);
alert(id ? "수정되었습니다." : "등록되었습니다.", () =>
router.push(listHref),
);
} catch (e) {
setNotice(e instanceof Error ? e.message : "저장하지 못했습니다.");
} finally {
@@ -328,134 +353,180 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
};
/** 사용자 확인 후 현재 사업을 논리삭제하고 목록으로 이동한다. */
const handleDelete = async () => {
if (!id || !window.confirm("삭제하시겠습니까?")) return;
if (!id || !(await confirm("삭제하시겠습니까?"))) return;
setLoading(true);
try {
await deleteCtyFldngPrvtnBusiness(id);
window.alert("삭제되었습니다.");
router.push(listHref);
alert("삭제되었습니다.", () => router.push(listHref));
} catch (e) {
setNotice(e instanceof Error ? e.message : "삭제하지 못했습니다.");
} finally {
setLoading(false);
}
};
/** 기본정보 입력을 CS 표형 필드로 변환하고 긴 내용만 같은 행의 넓은 영역을 사용한다. */
const renderBasicField = ([
key,
label,
type,
]: (typeof BASIC_FIELDS)[number]) => {
const fieldId = `cty-fldng-basic-${key}`;
const span: 1 | 2 | 3 =
key === "excptnMttrCn" ? 3 : key === "bizNm" || key === "pstn" ? 2 : 1;
return (
<StandardFormField
key={key}
id={fieldId}
label={label}
required={key === "bizNm"}
span={span}
>
<Input
id={fieldId}
className="w-full !text-[1.3rem]"
type={type === "date" ? "date" : type}
value={String(form[key] ?? "")}
onChange={(event) =>
setForm((current) => ({
...current,
[key]:
type === "date"
? formatDateInput(event.target.value)
: inputValue(event.target.value, type),
}))
}
/>
</StandardFormField>
);
};
return (
<>
<div className="card card-border !mt-6">
<LoadingOverlay isLoading={loading} />
<div className="mx-5 space-y-6">
<h2 className="text-2xl font-semibold">
도시침수예방사업 {id ? "수정" : "등록"}
</h2>
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
도시침수예방사업 {id ? "상세/수정" : "등록"}
</h3>
</div>
<div className="card space-y-4">
{notice && (
<div
<p
role="alert"
className="rounded border border-red-200 bg-red-50 p-3 text-red-700"
className="rounded-sm border border-red-200 bg-red-50 px-4 py-3 text-[1.3rem] text-red-700"
>
{notice}
</div>
</p>
)}
<section className="rounded border bg-white p-5">
<h3 className="mb-4 text-xl font-semibold">기본정보</h3>
<div className="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<SelectBox
className=""
label="시도"
labelClass="w-24 text-base"
selectClass="w-full"
selectArray={[{ text: "선택", val: "0" }, ...ctpv]}
<StandardFormSection title="기본 정보">
<StandardFormField id="cty-fldng-ctpv" label="시도">
<Select
value={ctpvCd}
onSelectValue={(value) => {
onValueChange={(value) => {
const code = String(value);
setCtpvCd(code);
setSggCd("0");
setForm((f) => ({
...f,
setForm((current) => ({
...current,
ctpvNm:
ctpv.find((o) => String(o.val) === code)?.text ?? null,
ctpv.find((option) => String(option.val) === code)?.text ??
null,
sggNm: null,
}));
}}
/>
<SelectBox
className=""
label="시군구"
labelClass="w-24 text-base"
selectClass="w-full"
selectArray={[{ text: "선택", val: "0" }, ...availableSgg]}
>
<SelectTrigger id="cty-fldng-ctpv" className="w-full">
<SelectValue placeholder="시도 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">선택</SelectItem>
{ctpv.map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</StandardFormField>
<StandardFormField id="cty-fldng-sgg" label="시군구">
<Select
value={sggCd}
onSelectValue={(value) => {
onValueChange={(value) => {
const code = String(value);
setSggCd(code);
setForm((f) => ({
...f,
setForm((current) => ({
...current,
sggNm:
availableSgg.find((o) => String(o.val) === code)?.text ??
null,
availableSgg.find((option) => String(option.val) === code)
?.text ?? null,
}));
}}
/>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{BASIC_FIELDS.map(([key, label, type]) => (
<label
key={key}
className="flex flex-col gap-1 text-sm font-medium"
>
<SelectTrigger id="cty-fldng-sgg" className="w-full">
<SelectValue placeholder="시군구 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">선택</SelectItem>
{availableSgg.map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</StandardFormField>
{BASIC_FIELDS.map(renderBasicField)}
</StandardFormSection>
<StandardFormSection title="연도별 결산(백만원)">
{Array.from(
{ length: new Date().getFullYear() - 2013 + 1 },
(_, index) => String(2013 + index),
).map((year) => {
const item = form.settlements.find(
(settlement) => settlement.soaYr === year,
);
return (
<StandardFormField
key={year}
id={`cty-fldng-settlement-${year}`}
label={`${year}년`}
>
{label}
<Input
type={type === "date" ? "text" : type}
placeholder={type === "date" ? "YYYY-MM-DD" : undefined}
value={String(form[key] ?? "")}
onChange={(e) =>
setForm((f) => ({
...f,
[key]:
type === "date"
? formatDateInput(e.target.value)
: inputValue(e.target.value, type),
id={`cty-fldng-settlement-${year}`}
className="w-full !text-[1.3rem]"
type="number"
value={item?.soaAmt ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
settlements: [
...current.settlements.filter(
(settlement) => settlement.soaYr !== year,
),
{
soaYr: year,
soaAmt:
event.target.value === ""
? null
: Number(event.target.value),
},
],
}))
}
/>
</label>
))}
</div>
</section>
<section className="rounded border bg-white p-5">
<h3 className="mb-4 text-xl font-semibold">연도별 결산(백만원)</h3>
<div className="grid grid-cols-2 gap-3 md:grid-cols-7">
{Array.from(
{ length: new Date().getFullYear() - 2013 + 1 },
(_, i) => String(2013 + i),
).map((year) => {
const item = form.settlements.find((s) => s.soaYr === year);
return (
<label key={year} className="text-sm font-medium">
{year}
<Input
type="number"
value={item?.soaAmt ?? ""}
onChange={(e) =>
setForm((f) => ({
...f,
settlements: [
...f.settlements.filter((s) => s.soaYr !== year),
{
soaYr: year,
soaAmt:
e.target.value === ""
? null
: Number(e.target.value),
},
],
}))
}
/>
</label>
);
})}
</div>
</section>
</StandardFormField>
);
})}
</StandardFormSection>
{DETAIL_META.map((meta) => {
const detail = form.details.find(
(item) => item.bizPrgrsSe === meta.code,
@@ -466,44 +537,55 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
impdFclt: [],
};
return (
<section
<StandardFormSection
key={meta.code}
className="overflow-x-auto rounded border bg-white p-5"
title={`${meta.code} ${meta.title}`}
>
<h3 className="mb-4 text-xl font-semibold">
{meta.code} {meta.title}
</h3>
<div className="min-w-[1100px]">
<DetailEditor
detail={detail}
onChange={(next) =>
setForm((current) => ({
...current,
details: [
...current.details.filter(
(item) => item.bizPrgrsSe !== meta.code,
),
next,
],
}))
}
/>
</div>
</section>
<DetailEditor
detail={detail}
onChange={(next) =>
setForm((current) => ({
...current,
details: [
...current.details.filter(
(item) => item.bizPrgrsSe !== meta.code,
),
next,
],
}))
}
/>
</StandardFormSection>
);
})}
<div className="flex justify-center gap-3 pb-8">
<Button variant="outline" onClick={() => router.push(listHref)}>
목록
<div className="flex justify-center gap-4 pb-8 pt-6">
<Button
type="button"
size="default"
onClick={() => void handleSave()}
>
저장
</Button>
<Button
type="button"
size="default"
color="white"
onClick={() => router.push(listHref)}
>
취소
</Button>
<Button onClick={() => void handleSave()}>저장</Button>
{id && (
<Button variant="destructive" onClick={() => void handleDelete()}>
<Button
type="button"
size="default"
color="red"
onClick={() => void handleDelete()}
>
삭제
</Button>
)}
</div>
</div>
</>
</div>
);
}
@@ -4,20 +4,25 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import {
SearchDiv,
SearchFormLeft,
SearchFormRight,
SelectBox,
UiTable,
} from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
StandardDataTable,
getStandardRowNumber,
} from "@common/business_components";
import {
Button,
Input,
Label,
TableHead,
TableRow,
} from "@common/components/ui";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
} from "@tanstack/react-table";
import { formatDate } from "../../common/util";
import {
@@ -38,8 +43,6 @@ import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
const PAGE_SIZE = 10;
const ACTION =
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700";
const ALL = { text: "전체", val: "0" };
const EMPTY_PAGE: CtyFldngPrvtnBizPage = {
content: [],
@@ -109,8 +112,8 @@ const DETAIL_LABELS = [
"비고출처",
];
const BASIC_HEADLIST = [
{ id: "rowNumber", title: "NO", widthPercent: 4 },
{ id: "seqNo", title: "순번", widthPercent: 5 },
{ id: "rowNumber", title: "순번", widthPercent: 4 },
{ id: "seqNo", title: "사업순번", widthPercent: 5 },
{ id: "stp", title: "단계", widthPercent: 6 },
{ id: "ctpvNm", title: "시도", widthPercent: 7 },
{ id: "sggNm", title: "시군구", widthPercent: 7 },
@@ -149,10 +152,31 @@ type Search = {
prplPrst: string;
bizNm: string;
};
/** 브라우저 로컬 날짜를 검색 input이 사용하는 YYYY-MM-DD 형식으로 변환한다. */
const formatLocalDateInputValue = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
/** 지정일 기본 검색기간을 오늘까지의 최근 1년으로 생성한다. */
const createDefaultDesignationDateRange = () => {
const to = new Date();
const from = new Date(to);
from.setFullYear(from.getFullYear() - 1);
return {
dsgnYmdFrom: formatLocalDateInputValue(from),
dsgnYmdTo: formatLocalDateInputValue(to),
};
};
type TableRow = Record<string, string | number | null | undefined> & {
ctyFldngPrvtnBizSn: number;
rowNumber: number;
};
const TABLE_MIN_WIDTH = "960rem";
/** 검색조건과 페이지를 수정화면 왕복용 query로 만든다. */
const listQuery = (
@@ -177,6 +201,36 @@ const facilities = (value: unknown) =>
? value.map((item) => `${item.no}:${item.cpct ?? ""}`).join(", ")
: "";
/** CS 공통 DataTable의 값 표출과 가운데 정렬 규칙을 넓은 업무 목록에도 동일하게 적용한다. */
const createTableColumn = (
id: string,
title: string,
widthPercent = 8,
): ColumnDef<TableRow> => ({
id,
accessorFn: (row) => row[id],
header: () => <span className="whitespace-pre-line">{title}</span>,
cell: ({ getValue }) => {
const value = getValue();
const displayValue =
typeof value === "number"
? value.toLocaleString("ko-KR")
: String(value ?? "");
return (
<span
className="block whitespace-normal break-words"
title={displayValue}
>
{displayValue}
</span>
);
},
meta: {
width: Math.max(widthPercent * 12, 72),
align: "center",
},
});
/** 중첩 결산·상세를 가로 테이블의 단일 행으로 평탄화한다. */
const flattenRow = (row: CtyFldngPrvtnBiz, rowNumber: number): TableRow => {
const { settlements, details, ...basic } = row;
@@ -208,16 +262,22 @@ export function CtyFldngPrvtnBizListPage() {
const params = useSearchParams();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const fileRef = useRef<HTMLInputElement>(null);
const defaultDesignationDateRange = useMemo(
createDefaultDesignationDateRange,
[],
);
const initial = useMemo<Search>(
() => ({
ctpvNm: params.get("ctpvNm") ?? "",
sggNm: params.get("sggNm") ?? "",
dsgnYmdFrom: params.get("dsgnYmdFrom") ?? "",
dsgnYmdTo: params.get("dsgnYmdTo") ?? "",
dsgnYmdFrom:
params.get("dsgnYmdFrom") ?? defaultDesignationDateRange.dsgnYmdFrom,
dsgnYmdTo:
params.get("dsgnYmdTo") ?? defaultDesignationDateRange.dsgnYmdTo,
prplPrst: params.get("prplPrst") ?? "",
bizNm: params.get("bizNm") ?? "",
}),
[params],
[defaultDesignationDateRange, params],
);
const [search, setSearch] = useState(initial);
const [applied, setApplied] = useState(initial);
@@ -248,25 +308,59 @@ export function CtyFldngPrvtnBizListPage() {
),
[],
);
const headlist = useMemo(
const columns = useMemo<ColumnDef<TableRow>[]>(
() => [
...BASIC_HEADLIST,
...years.map((year) => ({
id: `soa_${year}`,
title: `${year} 결산`,
widthPercent: 7,
})),
...DETAIL_GROUPS.flatMap(({ code }) =>
DETAIL_FIELDS.map((field, i) => ({
id: `${code}_${field}`,
title: DETAIL_LABELS[i] ?? field,
widthPercent: 8,
})),
...BASIC_HEADLIST.map((head) =>
createTableColumn(head.id, head.title, head.widthPercent),
),
{
id: "settlements",
header: "결산 현황(백만원)",
columns: years.map((year) =>
createTableColumn(`soa_${year}`, `${year}년`, 7),
),
},
...DETAIL_GROUPS.map((group) => ({
id: `detail_${group.code}`,
header: group.title,
columns: DETAIL_FIELDS.map((field, index) =>
createTableColumn(
`${group.code}_${field}`,
DETAIL_LABELS[index] ?? field,
8,
),
),
})),
...TAIL_HEADLIST.map((head) =>
createTableColumn(head.id, head.title, head.widthPercent),
),
...TAIL_HEADLIST,
],
[years],
);
const pagination: PaginationState = {
pageIndex: page - 1,
pageSize: PAGE_SIZE,
};
const table = useReactTable({
data: rows,
columns,
state: { pagination },
rowCount: data.totalElements,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => String(row.ctyFldngPrvtnBizSn),
onPaginationChange: (updater) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
const nextPage = next.pageIndex + 1;
if (nextPage === page) return;
setPage(nextPage);
const q = listQuery(nextPage, applied, appliedCtpvCd, appliedSggCd);
router.replace(`/oper/ctyFldngPrvtnBiz${q ? `?${q}` : ""}`, {
scroll: false,
});
},
});
useEffect(() => {
if (!ctpv.length) void fetchCtpv();
@@ -291,7 +385,12 @@ export function CtyFldngPrvtnBizListPage() {
result.content.map((row, i) =>
flattenRow(
row,
result.totalElements - ((target - 1) * PAGE_SIZE + i),
getStandardRowNumber({
total: result.totalElements,
rowIndex: i,
pageIndex: target - 1,
pageSize: PAGE_SIZE,
}),
),
),
);
@@ -362,238 +461,231 @@ export function CtyFldngPrvtnBizListPage() {
setLoading(false);
}
};
/** CS 목록 화면과 같은 초기화 동작으로 입력·적용 조건과 페이지를 함께 되돌린다. */
const handleReset = () => {
const nextSearch: Search = {
ctpvNm: "",
sggNm: "",
dsgnYmdFrom: defaultDesignationDateRange.dsgnYmdFrom,
dsgnYmdTo: defaultDesignationDateRange.dsgnYmdTo,
prplPrst: "",
bizNm: "",
};
setSearch(nextSearch);
setApplied(nextSearch);
setCtpvCd("0");
setSggCd("0");
setAppliedCtpvCd("0");
setAppliedSggCd("0");
setNotice("");
setPage(1);
router.replace("/oper/ctyFldngPrvtnBiz", { scroll: false });
};
/** 현재 목록 조건을 보존한 채 신규 등록 화면으로 이동한다. */
const handleRegister = () => {
const q = listQuery(page, applied, appliedCtpvCd, appliedSggCd);
router.push(`/oper/ctyFldngPrvtnBiz/register${q ? `?${q}` : ""}`);
};
/** 선택한 사업의 상세·수정 화면으로 현재 목록 조건과 함께 이동한다. */
const handleOpenDetail = (row: TableRow) => {
const q = listQuery(page, applied, appliedCtpvCd, appliedSggCd);
router.push(
`/oper/ctyFldngPrvtnBiz/register/${row.ctyFldngPrvtnBizSn}${q ? `?${q}` : ""}`,
);
};
return (
<>
<LoadingOverlay isLoading={loading} />
<SearchDiv>
<div className="flex w-full flex-col gap-4">
<SearchFormLeft>
<div className="col-span-12 flex flex-wrap items-center gap-3">
<SelectBox
className=""
label="시도"
labelClass="w-14 text-xl"
selectClass="w-[140px]"
selectArray={[ALL, ...ctpv]}
value={ctpvCd}
onSelectValue={(value) => {
const code = String(value);
setCtpvCd(code);
setSggCd("0");
setSearch((s) => ({
...s,
ctpvNm:
ctpv.find((o) => String(o.val) === code)?.text ?? "",
sggNm: "",
}));
}}
/>
<SelectBox
className=""
label="시군구"
labelClass="w-20 text-xl"
selectClass="w-[150px]"
selectArray={[ALL, ...availableSgg]}
value={sggCd}
onSelectValue={(value) => {
const code = String(value);
setSggCd(code);
setSearch((s) => ({
...s,
sggNm:
availableSgg.find((o) => String(o.val) === code)?.text ??
"",
}));
}}
/>
<Label>지정일</Label>
<Input
type="date"
className="w-[150px]"
value={search.dsgnYmdFrom}
onChange={(e) =>
setSearch((s) => ({ ...s, dsgnYmdFrom: e.target.value }))
}
/>
<span>~</span>
<Input
type="date"
className="w-[150px]"
value={search.dsgnYmdTo}
onChange={(e) =>
setSearch((s) => ({ ...s, dsgnYmdTo: e.target.value }))
}
/>
<Label>추진현황</Label>
<Input
className="w-[150px]"
value={search.prplPrst}
onChange={(e) =>
setSearch((s) => ({ ...s, prplPrst: e.target.value }))
}
/>
<Label>사업명</Label>
<Input
className="w-[180px]"
value={search.bizNm}
onChange={(e) =>
setSearch((s) => ({ ...s, bizNm: e.target.value }))
}
/>
</div>
</SearchFormLeft>
<SearchFormRight className="flex justify-end px-4 pb-4">
<div className="flex flex-wrap justify-end gap-2">
<Button className={ACTION} size="sm" onClick={handleSearch}>
조회
</Button>
<Button
className={ACTION}
size="sm"
onClick={() => {
const q = listQuery(
page,
applied,
appliedCtpvCd,
appliedSggCd,
);
router.push(
`/oper/ctyFldngPrvtnBiz/register${q ? `?${q}` : ""}`,
);
}}
>
등록
</Button>
<input
ref={fileRef}
type="file"
accept=".xlsx"
className="hidden"
onChange={(e) => void handleUpload(e.target.files?.[0])}
/>
<Button
className={ACTION}
size="sm"
onClick={() => fileRef.current?.click()}
>
업로드
</Button>
<Button
className="rounded-sm bg-green-600 px-4 py-2 text-sm !text-white"
size="sm"
onClick={() => void handleDownload()}
>
엑셀다운로드
</Button>
<Button
className={ACTION}
size="sm"
onClick={() => void downloadCtyFldngPrvtnExcel([], true)}
>
양식받기
</Button>
</div>
</SearchFormRight>
</div>
</SearchDiv>
<div className="card card-border !mt-6">
<form
className="flex flex-wrap items-center gap-2 p-2"
onSubmit={(event) => {
event.preventDefault();
handleSearch();
}}
>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">시도</Label>
<Select
value={ctpvCd}
onValueChange={(value) => {
const code = String(value);
setCtpvCd(code);
setSggCd("0");
setSearch((current) => ({
...current,
ctpvNm:
ctpv.find((option) => String(option.val) === code)?.text ??
"",
sggNm: "",
}));
}}
>
<SelectTrigger className="w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[ALL, ...ctpv].map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">시군구</Label>
<Select
value={sggCd}
onValueChange={(value) => {
const code = String(value);
setSggCd(code);
setSearch((current) => ({
...current,
sggNm:
availableSgg.find((option) => String(option.val) === code)
?.text ?? "",
}));
}}
>
<SelectTrigger className="w-[15rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[ALL, ...availableSgg].map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">지정일</Label>
<Input
type="date"
className="w-[15rem]"
value={search.dsgnYmdFrom}
onChange={(event) =>
setSearch((current) => ({
...current,
dsgnYmdFrom: event.target.value,
}))
}
/>
<span>~</span>
<Input
type="date"
className="w-[15rem]"
value={search.dsgnYmdTo}
onChange={(event) =>
setSearch((current) => ({
...current,
dsgnYmdTo: event.target.value,
}))
}
/>
</div>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">
추진현황
</Label>
<Input
className="w-[15rem] !text-[1.3rem]"
value={search.prplPrst}
onChange={(event) =>
setSearch((current) => ({
...current,
prplPrst: event.target.value,
}))
}
/>
</div>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">사업명</Label>
<Input
className="w-[18rem] !text-[1.3rem]"
value={search.bizNm}
onChange={(event) =>
setSearch((current) => ({
...current,
bizNm: event.target.value,
}))
}
/>
</div>
<Button type="submit" className="w-[7.4rem]">
조회
</Button>
<Button
type="button"
color="white"
className="w-[8.8rem]"
onClick={handleReset}
>
초기화
</Button>
</form>
</div>
{notice && (
<div
role="status"
className="mx-5 mt-4 rounded border border-blue-200 bg-blue-50 p-3 text-sm text-blue-700"
className="mx-[1.2rem] mt-4 rounded-sm border border-primary bg-primary-transparent px-4 py-3 text-[1.3rem] text-primary"
>
{notice}
</div>
)}
<ListDiv className="overflow-x-auto">
<div className="w-[560%]">
<div className="card-header">
<h3 className="text-2xl font-medium">
도시침수예방사업{" "}
<span className="text-base text-gray-500">
({data.totalElements.toLocaleString()}건)
</span>
</h3>
</div>
<UiTable<TableRow>
headName=""
headlist={headlist}
tableData={rows}
pageSize={PAGE_SIZE}
total={data.totalElements}
curPage={page}
widthFixed
onPageChange={(next) => {
setPage(next);
const q = listQuery(next, applied, appliedCtpvCd, appliedSggCd);
router.replace(`/oper/ctyFldngPrvtnBiz${q ? `?${q}` : ""}`, {
scroll: false,
});
}}
cellClick={(index) => {
if (typeof index !== "number" || !rows[index]) return;
const q = listQuery(page, applied, appliedCtpvCd, appliedSggCd);
router.push(
`/oper/ctyFldngPrvtnBiz/register/${rows[index].ctyFldngPrvtnBizSn}${q ? `?${q}` : ""}`,
);
}}
>
<TableRow>
{BASIC_HEADLIST.map((head) => (
<TableHead
key={head.id}
rowSpan={2}
className="whitespace-pre-line bg-gray-50"
>
{head.title}
</TableHead>
))}
<TableHead
colSpan={years.length}
className="whitespace-nowrap bg-gray-50"
<div className="mt-4 w-full min-w-0 max-w-full overflow-hidden">
<input
ref={fileRef}
type="file"
accept=".xlsx"
className="hidden"
onChange={(event) => void handleUpload(event.target.files?.[0])}
/>
<StandardDataTable
table={table}
title="도시침수예방사업 목록"
scrollMode="horizontal"
minTableWidth={TABLE_MIN_WIDTH}
className="table-fixed"
onRowClick={handleOpenDetail}
actions={
<>
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
<Button
type="button"
onClick={() => void downloadCtyFldngPrvtnExcel([], true)}
>
결산 현황(백만원)
</TableHead>
{DETAIL_GROUPS.map((group) => (
<TableHead
key={group.code}
colSpan={DETAIL_FIELDS.length}
className="whitespace-nowrap"
style={{ backgroundColor: group.color }}
>
{group.title}
</TableHead>
))}
{TAIL_HEADLIST.map((head) => (
<TableHead
key={head.id}
rowSpan={2}
className="whitespace-pre-line bg-gray-50"
>
{head.title}
</TableHead>
))}
</TableRow>
<TableRow>
{years.map((year) => (
<TableHead key={year} className="whitespace-nowrap bg-gray-50">
{year}년
</TableHead>
))}
{DETAIL_GROUPS.flatMap((group) =>
DETAIL_FIELDS.map((field, index) => (
<TableHead
key={`${group.code}_${field}`}
className="whitespace-pre-line"
style={{ backgroundColor: group.color }}
>
{DETAIL_LABELS[index] ?? field}
</TableHead>
)),
)}
</TableRow>
</UiTable>
</div>
</ListDiv>
양식다운로드
</Button>
<Button type="button" onClick={handleRegister}>
등록
</Button>
<Button type="button" onClick={() => fileRef.current?.click()}>
업로드
</Button>
</>
}
/>
</div>
</>
);
}
@@ -49,7 +49,9 @@ const unwrapData = <T>(response: T | { data?: T }): T =>
: (response as T);
/** Spring Page 응답이 비어 있거나 아직 미구현이어도 안전한 빈 Page로 정규화한다. */
const toPage = <T>(value: Partial<ExmnPage<T>> | null | undefined): ExmnPage<T> => ({
const toPage = <T>(
value: Partial<ExmnPage<T>> | null | undefined,
): ExmnPage<T> => ({
content: Array.isArray(value?.content) ? value.content : [],
totalElements: Number(value?.totalElements ?? 0),
number: Number(value?.number ?? 0),
@@ -66,25 +68,33 @@ const getBusinessReportFileName = async (
attachmentId: string,
): Promise<string | null> => {
try {
const response = await getRawJson(
const response = (await getRawJson(
`/attach-service/api/v1/atfl/attachments/${encodeURIComponent(attachmentCode)}`,
) as AttachmentMetadata[]
)) as
| AttachmentMetadata[]
| { data?: AttachmentMetadata[] | { content?: AttachmentMetadata[] } }
| { content?: AttachmentMetadata[] }
| null;
const payload = (
typeof response === "object"
&& response !== null
&& !Array.isArray(response)
&& "data" in response
) ? response.data : response;
let payload:
| AttachmentMetadata[]
| { content?: AttachmentMetadata[] }
| null
| undefined;
if (Array.isArray(response)) {
payload = response;
} else if (response && "data" in response) {
payload = response.data;
} else {
payload = response as { content?: AttachmentMetadata[] } | null;
}
const attachments = Array.isArray(payload)
? payload
: Array.isArray(payload?.content)
? payload.content
: [];
const attachment = attachments.find((item) => item.atflId === attachmentId)
?? attachments[0];
const attachment =
attachments.find((item) => item.atflId === attachmentId) ??
attachments[0];
return attachment?.orgnlFileNm?.trim() || null;
} catch {
return null;
@@ -109,15 +119,20 @@ export const getExmnBusinesses = async (
const page = toPage<ExmnBusiness>(
await getData(`${EXMN_BUSINESS_API_PATH}?${query}`),
);
const content = await Promise.all(page.content.map(async (business) => {
const attachmentCode = business.bizRptpAtchFileCd?.trim();
const attachmentId = business.bizRptpAtchFileId?.trim();
if (!attachmentCode || !attachmentId) return business;
return {
...business,
bizRptpFileName: await getBusinessReportFileName(attachmentCode, attachmentId),
};
}));
const content = await Promise.all(
page.content.map(async (business) => {
const attachmentCode = business.bizRptpAtchFileCd?.trim();
const attachmentId = business.bizRptpAtchFileId?.trim();
if (!attachmentCode || !attachmentId) return business;
return {
...business,
bizRptpFileName: await getBusinessReportFileName(
attachmentCode,
attachmentId,
),
};
}),
);
return { ...page, content };
};
@@ -126,11 +141,13 @@ export const getExmnSectionSummaries = async (
exmnBizId: string,
jobType: ExmnJobType,
): Promise<ExmnSectionSummaryRow[]> => {
type SectionSummaryPayload = ExmnSectionSummaryRow[] | Partial<ExmnPage<ExmnSectionSummaryRow>>;
type SectionSummaryPayload =
| ExmnSectionSummaryRow[]
| Partial<ExmnPage<ExmnSectionSummaryRow>>;
try {
const response = await getData(
const response = (await getData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/sections?jobType=${jobType}`,
) as SectionSummaryPayload | { data?: SectionSummaryPayload };
)) as SectionSummaryPayload | { data?: SectionSummaryPayload };
const payload = unwrapData<SectionSummaryPayload>(response);
if (Array.isArray(payload)) return payload;
return Array.isArray(payload?.content) ? payload.content : [];
@@ -174,8 +191,9 @@ export const getExmnFacilityDetails = async (params: {
if (params.defectType && params.defectType !== "ALL") {
query.set("defectType", params.defectType);
}
const response = await getData(`v1/exmnFacilities?${query}`) as
ExmnSurveyDetailRow[] | { data?: ExmnSurveyDetailRow[] };
const response = (await getData(`v1/exmnFacilities?${query}`)) as
| ExmnSurveyDetailRow[]
| { data?: ExmnSurveyDetailRow[] };
const payload = unwrapData<ExmnSurveyDetailRow[]>(response);
return Array.isArray(payload) ? payload : [];
};
@@ -234,8 +252,9 @@ export const getExmnPplUnitDetails = async (params: {
if (params.gisManagementNo?.trim()) {
query.set("gisManagementNo", params.gisManagementNo.trim());
}
const response = await getData(`v1/exmnFacilities/pplUnitSections?${query}`) as
ExmnPplUnitDetailPayload[] | { data?: ExmnPplUnitDetailPayload[] };
const response = (await getData(
`v1/exmnFacilities/pplUnitSections?${query}`,
)) as ExmnPplUnitDetailPayload[] | { data?: ExmnPplUnitDetailPayload[] };
const payload = unwrapData<ExmnPplUnitDetailPayload[]>(response);
if (!Array.isArray(payload)) return [];
return payload.map((row) => ({
@@ -245,14 +264,20 @@ export const getExmnPplUnitDetails = async (params: {
};
/** 선택한 조사사업의 처리분구·첨부를 포함한 상세정보를 조회한다. */
export const getExmnBusiness = async (exmnBizId: string): Promise<ExmnBusinessDetail> =>
getData(`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}`) as Promise<ExmnBusinessDetail>;
export const getExmnBusiness = async (
exmnBizId: string,
): Promise<ExmnBusinessDetail> =>
getData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}`,
) as Promise<ExmnBusinessDetail>;
/** 조사사업과 선택 처리분구별 기본 요약행을 신규 저장한다. */
export const createExmnBusiness = async (
request: ExmnBusinessSaveRequest,
): Promise<ExmnBusinessDetail> =>
unwrapData<ExmnBusinessDetail>(await postRawJson(EXMN_BUSINESS_API_PATH, request));
unwrapData<ExmnBusinessDetail>(
await postRawJson(EXMN_BUSINESS_API_PATH, request),
);
/** 기존 조사사업 기본정보와 연결 처리분구를 같은 저장 계약으로 갱신한다. */
export const updateExmnBusiness = async (
@@ -269,22 +294,30 @@ export const createExmnTemplates = async (
exmnBizId: string,
trtdstIds: string[],
): Promise<ExmnTemplateResult> =>
unwrapData<ExmnTemplateResult>(await postRawJson(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/templates`,
{ trtdstIds },
));
unwrapData<ExmnTemplateResult>(
await postRawJson(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/templates`,
{ trtdstIds },
),
);
/** 생성된 조사사업 입력 양식 파일을 내려받는다. */
export const downloadExmnTemplates = async (exmnBizId: string): Promise<Blob> =>
getBlobData(`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/templates/download`);
getBlobData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/templates/download`,
);
/** 하수관로·맨홀 현황조사 보고서 기준 XLSX를 내려받는다. */
export const downloadExmnReportTemplate = async (): Promise<Blob> =>
getBlobData(`${EXMN_BUSINESS_API_PATH}/templates/report/download`);
/** 사업보고서 첨부파일 ID로 기존 첨부서비스의 원본 파일을 내려받는다. */
export const downloadExmnBusinessReport = async (attachmentId: string): Promise<Blob> =>
getBlobData(`/attach-service/api/v1/download/${encodeURIComponent(attachmentId)}`);
export const downloadExmnBusinessReport = async (
attachmentId: string,
): Promise<Blob> =>
getBlobData(
`/attach-service/api/v1/download/${encodeURIComponent(attachmentId)}`,
);
/** 저장된 페이지 범위를 이용해 선택 관로의 조사보고서 구간만 PDF로 조회한다. */
export const downloadExmnPipeReport = async (
@@ -339,12 +372,17 @@ export const getPplSummaries = async (
exmnBizId: string,
params: { trtdstId?: string; page: number; size: number; sort?: string },
): Promise<ExmnPage<PplSummaryRow>> => {
const query = new URLSearchParams({ page: String(params.page), size: String(params.size) });
const query = new URLSearchParams({
page: String(params.page),
size: String(params.size),
});
if (params.trtdstId) query.set("trtdstId", params.trtdstId);
if (params.sort) query.set("sort", params.sort);
return toPage<PplSummaryRow>(await getData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/summaries/ppl?${query}`,
));
return toPage<PplSummaryRow>(
await getData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/summaries/ppl?${query}`,
),
);
};
/** 선택 사업의 PPL 시설을 처리분구·구간·GIS번호·등급으로 조회한다. */
@@ -359,10 +397,14 @@ export const getPplFacilities = async (
});
if (params.trtdstId) query.set("trtdstId", params.trtdstId);
if (params.sectionType) query.set("sectionType", params.sectionType);
if (params.gisManagementNo?.trim()) query.set("gisManagementNo", params.gisManagementNo.trim());
if (params.structuralGrade) query.set("structuralGrade", params.structuralGrade);
if (params.operationalGrade) query.set("operationalGrade", params.operationalGrade);
if (params.defectType && params.defectType !== "ALL") query.set("defectType", params.defectType);
if (params.gisManagementNo?.trim())
query.set("gisManagementNo", params.gisManagementNo.trim());
if (params.structuralGrade)
query.set("structuralGrade", params.structuralGrade);
if (params.operationalGrade)
query.set("operationalGrade", params.operationalGrade);
if (params.defectType && params.defectType !== "ALL")
query.set("defectType", params.defectType);
if (params.sort) query.set("sort", params.sort);
return toPage<PplFacility>(await getData(`v1/exmnFacilities?${query}`));
};
@@ -376,5 +418,7 @@ export const downloadPplExport = async (
if (trtdstId) query.set("trtdstId", trtdstId);
const queryString = query.toString();
const suffix = queryString ? `?${queryString}` : "";
return getBlobData(`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/exports/ppl${suffix}`);
return getBlobData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/exports/ppl${suffix}`,
);
};
@@ -10,7 +10,7 @@ export interface TrtdstSettingDto {
useYn?: "Y" | "N";
}
/** UiTable 선택과 변경 추적에 필요한 화면 전용 처리분구 행이다. */
/** 공통 표 선택과 변경 추적에 필요한 화면 전용 처리분구 행이다. */
export interface TrtdstTableRow extends TrtdstSettingDto {
localKey: string;
rowNumber: number;
@@ -1,13 +1,25 @@
"use client";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { getStartYearArray } from "@/components/ui/YearRangeSelectBox";
import useStore from "@/store/useStore";
import { postPopupMessage } from "@/features/windowPopup/lib/popupMessage";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { Button, Input, Label } from "@common/components/ui";
import { SelectBox } from "@common/business_components/ui";
import {
StandardFormField,
StandardFormSection,
StandardPopupLayout,
} from "@common/business_components";
import {
Button,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useEffect, useMemo, useRef, useState } from "react";
import {
createExmnBusiness,
@@ -25,13 +37,6 @@ import type {
} from "../model/exmn-business-types";
import type { TrtdstSettingDto } from "../model/trtdst-types";
const DOWNLOAD_BUTTON_CLASS =
"rounded-sm px-4 py-2 text-sm font-medium !text-white disabled:!text-white";
const FORM_LABEL_CLASS =
"shrink-0 whitespace-nowrap text-xl font-medium text-gray-600";
const FORM_INPUT_CLASS =
"h-[3.2rem] text-base text-label";
type Notice = { type: "success" | "error" | "info"; text: string } | null;
interface ExmnBusinessRegisterPopupPageProps {
@@ -39,8 +44,10 @@ interface ExmnBusinessRegisterPopupPageProps {
}
/** 처리분구 이름을 공백 제거 후 한글 가나다순으로 정렬한다. */
const sortDistricts = (items: TrtdstSettingDto[]) => [...items].sort((left, right) =>
left.trtdstNm.trim().localeCompare(right.trtdstNm.trim(), "ko"));
const sortDistricts = (items: TrtdstSettingDto[]) =>
[...items].sort((left, right) =>
left.trtdstNm.trim().localeCompare(right.trtdstNm.trim(), "ko"),
);
/** Blob을 브라우저 다운로드로 전달하고 임시 URL을 즉시 정리한다. */
const saveBlob = (blob: Blob, fileName: string) => {
@@ -57,6 +64,7 @@ export function ExmnBusinessRegisterPopupPage({
exmnBizId,
}: ExmnBusinessRegisterPopupPageProps) {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const { confirm } = useConfirm();
const isEditMode = Boolean(exmnBizId);
/** 상세정보보다 처리분구 후보 조회가 늦게 끝나도 기존 선택을 복원하기 위한 대기 ID다. */
const pendingSelectedDistrictIdsRef = useRef<string[] | null>(null);
@@ -75,7 +83,9 @@ export function ExmnBusinessRegisterPopupPage({
/** 등록 후보가 많을 때 처리분구명으로 화면 목록을 좁히는 검색어다. */
const [districtKeyword, setDistrictKeyword] = useState("");
const [selectedDistrictIds, setSelectedDistrictIds] = useState<string[]>([]);
const [savedBusiness, setSavedBusiness] = useState<ExmnBusinessDetail | null>(null);
const [savedBusiness, setSavedBusiness] = useState<ExmnBusinessDetail | null>(
null,
);
const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
const formLocked = Boolean(savedBusiness) && !isEditMode;
@@ -89,11 +99,27 @@ export function ExmnBusinessRegisterPopupPage({
/** 공통코드가 로드되면 서버 상세의 행정구역 이름을 실제 선택 코드로 맞춘다. */
useEffect(() => {
const selectedCtpv = ctpv.find((option) => option.text === ctpvNm);
const selectedSgg = sgg.find((option) => option.text === sggNm);
const selectedCtpvPrefix = selectedCtpv
? String(selectedCtpv.val).slice(0, 2)
: "";
const selectedSgg = sgg.find(
(option) =>
option.text === sggNm &&
(!selectedCtpvPrefix ||
String(option.val).startsWith(selectedCtpvPrefix)),
);
if (selectedCtpv) setCtpvCd(String(selectedCtpv.val));
if (selectedSgg) setSggCd(String(selectedSgg.val));
}, [ctpv, ctpvNm, sgg, sggNm]);
/** 선택 시도에 속한 시군구만 공통 Select 후보로 제공한다. */
const availableSgg = useMemo(() => {
if (ctpvCd === "0") return sgg;
return sgg.filter((option) =>
String(option.val).startsWith(ctpvCd.slice(0, 2)),
);
}, [ctpvCd, sgg]);
/** 수정 모드에서는 선택 사업의 상세정보와 기존 처리분구 선택을 팝업 입력값으로 복원한다. */
useEffect(() => {
if (!exmnBizId) return;
@@ -106,7 +132,10 @@ export function ExmnBusinessRegisterPopupPage({
.then((business) => {
if (businessRequestIdRef.current !== requestId) return;
if (!business) {
setNotice({ type: "error", text: "수정할 사업정보를 찾지 못했습니다." });
setNotice({
type: "error",
text: "수정할 사업정보를 찾지 못했습니다.",
});
return;
}
pendingSelectedDistrictIdsRef.current = [...business.trtdstIds];
@@ -123,7 +152,10 @@ export function ExmnBusinessRegisterPopupPage({
if (businessRequestIdRef.current !== requestId) return;
setNotice({
type: "error",
text: error instanceof Error ? error.message : "사업정보 조회 중 오류가 발생했습니다.",
text:
error instanceof Error
? error.message
: "사업정보 조회 중 오류가 발생했습니다.",
});
})
.finally(() => {
@@ -154,9 +186,13 @@ export function ExmnBusinessRegisterPopupPage({
const sortedItems = sortDistricts(items);
setDistricts(sortedItems);
const pendingIds = pendingSelectedDistrictIdsRef.current;
setSelectedDistrictIds(pendingIds
? pendingIds.filter((id) => sortedItems.some((item) => item.trtdstId === id))
: []);
setSelectedDistrictIds(
pendingIds
? pendingIds.filter((id) =>
sortedItems.some((item) => item.trtdstId === id),
)
: [],
);
pendingSelectedDistrictIdsRef.current = null;
})
.catch((error: unknown) => {
@@ -164,7 +200,10 @@ export function ExmnBusinessRegisterPopupPage({
setDistricts([]);
setNotice({
type: "error",
text: error instanceof Error ? error.message : "처리분구 조회 중 오류가 발생했습니다.",
text:
error instanceof Error
? error.message
: "처리분구 조회 중 오류가 발생했습니다.",
});
})
.finally(() => {
@@ -181,13 +220,15 @@ export function ExmnBusinessRegisterPopupPage({
if (!isEditMode || !savedBusiness) return false;
const currentDistrictIds = [...selectedDistrictIds].sort();
const savedDistrictIds = [...savedBusiness.trtdstIds].sort();
return String(savedBusiness.bizCycl ?? "") !== bizCycl.trim()
|| savedBusiness.bizYr !== bizYr.trim()
|| savedBusiness.bizNm !== bizNm.trim()
|| savedBusiness.ctpvNm !== ctpvNm
|| savedBusiness.sggNm !== sggNm
|| Number(savedBusiness.prcisExmnPrlg) !== Number(prcisExmnPrlg)
|| currentDistrictIds.join("|") !== savedDistrictIds.join("|");
return (
String(savedBusiness.bizCycl ?? "") !== bizCycl.trim() ||
savedBusiness.bizYr !== bizYr.trim() ||
savedBusiness.bizNm !== bizNm.trim() ||
savedBusiness.ctpvNm !== ctpvNm ||
savedBusiness.sggNm !== sggNm ||
Number(savedBusiness.prcisExmnPrlg) !== Number(prcisExmnPrlg) ||
currentDistrictIds.join("|") !== savedDistrictIds.join("|")
);
}, [
bizCycl,
bizNm,
@@ -202,19 +243,30 @@ export function ExmnBusinessRegisterPopupPage({
/** 공백 차이를 무시한 처리분구명 부분일치 검색 결과다. */
const filteredDistricts = useMemo(() => {
const keyword = districtKeyword.replace(/\s+/g, "").toLocaleLowerCase("ko-KR");
const keyword = districtKeyword
.replace(/\s+/g, "")
.toLocaleLowerCase("ko-KR");
if (!keyword) return districts;
return districts.filter((district) =>
district.trtdstNm.replace(/\s+/g, "").toLocaleLowerCase("ko-KR").includes(keyword));
district.trtdstNm
.replace(/\s+/g, "")
.toLocaleLowerCase("ko-KR")
.includes(keyword),
);
}, [districtKeyword, districts]);
/** 현재 검색 결과가 모두 선택된 상태인지 일괄선택 버튼 문구와 동작에 사용한다. */
const allFilteredDistrictsSelected = filteredDistricts.length > 0
&& filteredDistricts.every((district) => selectedDistrictIds.includes(district.trtdstId));
const allFilteredDistrictsSelected =
filteredDistricts.length > 0 &&
filteredDistricts.every((district) =>
selectedDistrictIds.includes(district.trtdstId),
);
/** 검색된 처리분구만 한 번에 선택하거나 해제하며 다른 검색에서 고른 값은 유지한다. */
const handleToggleFilteredDistricts = () => {
const filteredIds = new Set(filteredDistricts.map((district) => district.trtdstId));
const filteredIds = new Set(
filteredDistricts.map((district) => district.trtdstId),
);
setSelectedDistrictIds((current) => {
const nextIds = new Set(current);
filteredIds.forEach((id) => {
@@ -252,13 +304,19 @@ export function ExmnBusinessRegisterPopupPage({
setNotice({ type: "error", text: validationMessage });
return;
}
if (
!(await confirm(isEditMode ? "수정하시겠습니까?" : "저장하시겠습니까?"))
) {
return;
}
setIsLoading(true);
setNotice(null);
try {
const persisted: ExmnBusinessDetail = isEditMode && exmnBizId
? await updateExmnBusiness(exmnBizId, request)
: await createExmnBusiness(request);
const persisted: ExmnBusinessDetail =
isEditMode && exmnBizId
? await updateExmnBusiness(exmnBizId, request)
: await createExmnBusiness(request);
setSavedBusiness(persisted);
postPopupMessage(
@@ -278,7 +336,10 @@ export function ExmnBusinessRegisterPopupPage({
} catch (error) {
setNotice({
type: "error",
text: error instanceof Error ? error.message : "사업정보 저장 중 오류가 발생했습니다.",
text:
error instanceof Error
? error.message
: "사업정보 저장 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
@@ -292,18 +353,27 @@ export function ExmnBusinessRegisterPopupPage({
return;
}
if (hasUnsavedChanges) {
setNotice({ type: "info", text: "수정한 사업정보를 먼저 저장한 뒤 양식을 받아주세요." });
setNotice({
type: "info",
text: "수정한 사업정보를 먼저 저장한 뒤 양식을 받아주세요.",
});
return;
}
setIsLoading(true);
setNotice(null);
try {
await createExmnTemplates(savedBusiness.exmnBizId, selectedDistrictIds);
saveBlob(await downloadExmnTemplates(savedBusiness.exmnBizId), `${savedBusiness.bizNm}_조사입력양식.xlsx`);
saveBlob(
await downloadExmnTemplates(savedBusiness.exmnBizId),
`${savedBusiness.bizNm}_조사입력양식.xlsx`,
);
} catch (error) {
setNotice({
type: "error",
text: error instanceof Error ? error.message : "조사 입력양식 다운로드 중 오류가 발생했습니다.",
text:
error instanceof Error
? error.message
: "조사 입력양식 다운로드 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
@@ -322,7 +392,10 @@ export function ExmnBusinessRegisterPopupPage({
} catch (error) {
setNotice({
type: "error",
text: error instanceof Error ? error.message : "조사보고서 양식 다운로드 중 오류가 발생했습니다.",
text:
error instanceof Error
? error.message
: "조사보고서 양식 다운로드 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
@@ -330,172 +403,257 @@ export function ExmnBusinessRegisterPopupPage({
};
return (
<main className="sn-popup-page">
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">{isEditMode ? "사업정보 수정" : "사업정보 등록"}</h1>
<div className="flex items-center gap-2">
<StandardPopupLayout
title={isEditMode ? "조사사업 상세/수정" : "조사사업 등록"}
showDefaultCancelAction={false}
actions={
<>
<Button
type="button"
color="white"
size="sm"
className="sn-popup-header-button"
color="primary"
disabled={isLoading}
onClick={() => void handleDownloadTemplates()}
>
양식다운로드
</Button>
<Button
type="button"
color="primary"
disabled={isLoading}
onClick={() => void handleReportTemplate()}
>
조사보고서 양식다운로드
</Button>
<Button
type="button"
color="primary"
disabled={isLoading || formLocked}
onClick={() => void handleSave()}
>
{isEditMode ? "수정" : "저장"}
</Button>
<Button color="green" className={DOWNLOAD_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleDownloadTemplates()}>양식 받기</Button>
<Button color="green" className={DOWNLOAD_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleReportTemplate()}>조사보고서 양식</Button>
</div>
</header>
<section className="sn-popup-section space-y-3 overflow-y-auto">
{notice && (
<div role="alert" className={`rounded border px-4 py-3 text-sm ${
<Button
type="button"
color="white"
disabled={isLoading}
onClick={() => window.close()}
>
취소
</Button>
</>
}
>
<LoadingOverlay isLoading={isLoading} />
{notice && (
<div
role="alert"
className={`rounded-sm border px-4 py-3 text-[1.3rem] ${
notice.type === "error"
? "border-red-200 bg-red-50 text-red-700"
: notice.type === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-blue-200 bg-blue-50 text-blue-700"
}`}>{notice.text}</div>
)}
: "border-primary bg-primary-transparent text-primary"
}`}
>
{notice.text}
</div>
)}
<section className="border border-border">
<div className="border-b border-border bg-[#eaf3fb] px-4 py-3 font-semibold text-title">사업정보 입력</div>
<div className="space-y-4 p-4">
<div className="flex flex-nowrap items-center gap-5">
<div className="flex items-center gap-2">
<Label className={FORM_LABEL_CLASS}>
<span className="text-red-500">* </span>사업차수
</Label>
<Input
className={FORM_INPUT_CLASS}
style={{ width: "6rem", flex: "none" }}
type="text"
inputMode="numeric"
maxLength={3}
value={bizCycl}
disabled={formLocked}
onChange={(event) => {
if (/^\d{0,3}$/.test(event.target.value)) setBizCycl(event.target.value);
}}
/>
</div>
<SelectBox
label="사업년도"
labelClass={FORM_LABEL_CLASS}
className="flex items-center gap-2"
selectClass="h-[3.2rem] w-28 rounded-lg bg-white text-label"
selectArray={getStartYearArray({ placeholder: "선택" })}
value={bizYr}
readOnly={formLocked}
onSelectValue={(value) => setBizYr(String(value))}
/>
<CtpvSggPrcsFcltSelectBox
className="!mr-0 !justify-start"
selectValueClassName="text-label"
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(nextCtpvCd) => {
if (formLocked) return;
setCtpvCd(nextCtpvCd);
setSggCd("0");
setSggNm("");
}}
onSigunguChange={(nextSggCd) => {
if (!formLocked) setSggCd(nextSggCd);
}}
setCtpvName={(nextCtpvNm) => {
if (!formLocked) setCtpvNm(nextCtpvNm === "전체" ? "" : nextCtpvNm);
}}
setSggName={(nextSggNm) => {
if (!formLocked) setSggNm(nextSggNm === "전체" ? "" : nextSggNm);
}}
/>
<div className="flex items-center gap-2">
<Label className={FORM_LABEL_CLASS}><span className="text-red-500">* </span>정밀조사 연장</Label>
<Input
className={FORM_INPUT_CLASS}
style={{ width: "6rem", flex: "none" }}
type="text"
inputMode="decimal"
maxLength={10}
value={prcisExmnPrlg}
disabled={formLocked}
onChange={(event) => setPrcisExmnPrlg(event.target.value)}
/>
<span className="text-base font-medium text-gray-600">km</span>
</div>
</div>
<div className="flex items-center gap-3">
<Label className={`w-20 ${FORM_LABEL_CLASS}`}><span className="text-red-500">* </span>사업명</Label>
<Input className={FORM_INPUT_CLASS} style={{ maxWidth: "480px" }} value={bizNm} maxLength={200} disabled={formLocked} onChange={(event) => setBizNm(event.target.value)} />
</div>
<StandardFormSection title="사업 정보">
<StandardFormField id="exmn-biz-cycle" label="사업차수" required>
<Input
id="exmn-biz-cycle"
className="w-full"
type="text"
inputMode="numeric"
maxLength={3}
value={bizCycl}
disabled={formLocked}
onChange={(event) => {
if (/^\d{0,3}$/.test(event.target.value))
setBizCycl(event.target.value);
}}
/>
</StandardFormField>
<StandardFormField label="사업년도" required>
<Select
value={bizYr}
disabled={formLocked}
onValueChange={(value) => setBizYr(String(value))}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{getStartYearArray({ placeholder: "선택" }).map((option) => (
<SelectItem key={String(option.val)} value={String(option.val)}>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</StandardFormField>
<StandardFormField
id="exmn-precision-length"
label="정밀조사 연장"
required
>
<div className="flex items-center gap-2">
<Input
id="exmn-precision-length"
className="min-w-0 flex-1"
type="text"
inputMode="decimal"
maxLength={10}
value={prcisExmnPrlg}
disabled={formLocked}
onChange={(event) => setPrcisExmnPrlg(event.target.value)}
/>
<span className="shrink-0 text-[1.2rem] text-label">km</span>
</div>
</section>
</StandardFormField>
<section className="min-h-0 border border-border">
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-[#eaf3fb] px-4 py-2">
<h2 className="mr-2 font-semibold text-title">사업대상 처리분구</h2>
<StandardFormField label="시도" required>
<Select
value={ctpvCd}
disabled={formLocked}
onValueChange={(value) => {
const nextCtpvCd = String(value);
const selected = ctpv.find(
(option) => String(option.val) === nextCtpvCd,
);
setCtpvCd(nextCtpvCd);
setCtpvNm(selected?.text ?? "");
setSggCd("0");
setSggNm("");
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">선택</SelectItem>
{ctpv.map((option) => (
<SelectItem key={String(option.val)} value={String(option.val)}>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</StandardFormField>
<StandardFormField label="시군구" required>
<Select
value={sggCd}
disabled={formLocked || ctpvCd === "0"}
onValueChange={(value) => {
const nextSggCd = String(value);
const selected = availableSgg.find(
(option) => String(option.val) === nextSggCd,
);
setSggCd(nextSggCd);
setSggNm(selected?.text ?? "");
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">선택</SelectItem>
{availableSgg.map((option) => (
<SelectItem key={String(option.val)} value={String(option.val)}>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</StandardFormField>
<StandardFormField id="exmn-biz-name" label="사업명" required>
<Input
id="exmn-biz-name"
className="w-full"
value={bizNm}
maxLength={200}
disabled={formLocked}
onChange={(event) => setBizNm(event.target.value)}
/>
</StandardFormField>
</StandardFormSection>
<section className="card card-border !m-0 min-h-0 overflow-hidden !p-[0.6rem]">
<div className="card-header flex min-h-[5.2rem] flex-wrap items-center justify-between gap-4">
<h2 className="text-2xl font-medium text-label">사업대상 처리분구</h2>
<div className="flex flex-wrap items-center justify-end gap-2">
<Input
type="search"
value={districtKeyword}
className="sn-popup-input-sm !h-9 w-[22rem] bg-white"
className="h-[3.2rem] w-[22rem] bg-background !text-[1.3rem]"
placeholder="처리분구명 검색"
aria-label="처리분구명 검색"
disabled={formLocked}
onChange={(event) => setDistrictKeyword(event.target.value)}
/>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
color="white"
size="xs"
className="sn-popup-secondary-button-sm"
disabled={formLocked || filteredDistricts.length === 0}
onClick={handleToggleFilteredDistricts}
>
{allFilteredDistrictsSelected ? "검색결과 해제" : "검색결과 선택"}
</Button>
<Button
type="button"
color="white"
size="xs"
className="sn-popup-secondary-button-sm"
disabled={formLocked || selectedDistrictIds.length === 0}
onClick={() => setSelectedDistrictIds([])}
>
선택 초기화
</Button>
<span className="ml-1 whitespace-nowrap text-sm text-label">
{selectedDistrictIds.length}개 선택
</span>
</div>
<Button
type="button"
color={allFilteredDistrictsSelected ? "white" : "primary"}
disabled={formLocked || filteredDistricts.length === 0}
onClick={handleToggleFilteredDistricts}
>
{allFilteredDistrictsSelected ? "검색결과 해제" : "검색결과 선택"}
</Button>
<Button
type="button"
color="white"
disabled={formLocked || selectedDistrictIds.length === 0}
onClick={() => setSelectedDistrictIds([])}
>
선택 초기화
</Button>
<span className="ml-1 min-w-[6.4rem] whitespace-nowrap text-right text-[1.3rem] text-muted-foreground">
{selectedDistrictIds.length}개 선택
</span>
</div>
<div className="grid grid-cols-3 content-start gap-3 overflow-y-auto p-4" style={{ height: "21rem" }}>
{districts.length === 0 ? (
<p className="col-span-3 py-7 text-center text-sm text-gray-500">시도·시군구를 선택하거나 등록된 활성 처리분구를 확인해주세요.</p>
) : filteredDistricts.length === 0 ? (
<p className="col-span-3 py-7 text-center text-sm text-gray-500">검색 조건에 맞는 처리분구가 없습니다.</p>
) : filteredDistricts.map((district) => (
<label key={district.trtdstId} className="flex cursor-pointer items-center gap-2 rounded border border-gray-200 bg-white px-3 py-2.5 text-sm hover:border-blue-300 hover:bg-blue-50">
</div>
<div
className="card !m-[1.2rem] grid grid-cols-3 content-start gap-3 !p-0"
style={{ height: "21rem" }}
>
{districts.length === 0 ? (
<p className="col-span-3 flex h-full min-h-[9.6rem] items-center justify-center px-4 text-center text-[1.3rem] text-muted-foreground">
시도·시군구를 선택하거나 등록된 활성 처리분구를 확인해주세요.
</p>
) : filteredDistricts.length === 0 ? (
<p className="col-span-3 flex h-full min-h-[9.6rem] items-center justify-center px-4 text-center text-[1.3rem] text-muted-foreground">
검색 조건에 맞는 처리분구가 없습니다.
</p>
) : (
filteredDistricts.map((district) => (
<label
key={district.trtdstId}
className="hover:border-primary/50 flex cursor-pointer items-center gap-3 rounded-sm border border-border bg-background px-4 py-3 text-[1.3rem] text-label transition-colors hover:bg-accent"
>
<input
type="checkbox"
className="size-4 accent-blue-600"
checked={selectedDistrictIds.includes(district.trtdstId)}
disabled={formLocked}
onChange={(event) => setSelectedDistrictIds((current) => event.target.checked
? [...current, district.trtdstId]
: current.filter((id) => id !== district.trtdstId))}
onChange={(event) =>
setSelectedDistrictIds((current) =>
event.target.checked
? [...current, district.trtdstId]
: current.filter((id) => id !== district.trtdstId),
)
}
/>
<span>{district.trtdstNm}</span>
</label>
))}
</div>
</section>
))
)}
</div>
</section>
</main>
</StandardPopupLayout>
);
}
@@ -12,20 +12,7 @@ const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
};
const COLUMN_WIDTHS = [
120,
72,
82,
88,
230,
62,
62,
82,
82,
78,
82,
82,
150,
150,
120, 72, 82, 88, 230, 62, 62, 82, 82, 78, 82, 82, 150, 150,
] as const;
interface ExmnPplUnitDetailTableProps {
@@ -73,7 +60,7 @@ export function ExmnPplUnitDetailTable({
const tableWidth = COLUMN_WIDTHS.reduce((sum, width) => sum + width, 0);
return (
<div className="min-h-0 flex-1 overflow-auto border border-border bg-background">
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-visible border border-border bg-background">
<table
className="border-separate border-spacing-0 text-center text-[1.05rem] text-foreground"
style={{ width: "100%", minWidth: `${tableWidth}px` }}
@@ -83,95 +70,179 @@ export function ExmnPplUnitDetailTable({
<col key={`unit-column-${index}`} style={{ width: `${width}px` }} />
))}
</colgroup>
<thead className="sticky top-0 z-30 bg-[#eaf3fb] text-[1.2rem] font-medium leading-[1.2] text-[#647784]">
<thead className="sticky top-0 z-30 bg-primary text-center text-[1.2rem] font-medium leading-[1.2] text-primary-foreground">
<tr>
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">관로번호</th>
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">단위<br />구간</th>
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">단위<br />길이<br />(m)</th>
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">결함<br />위치<br />(m)</th>
<th colSpan={6} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">결함</th>
<th colSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">등급점수(점)</th>
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">조사중단사유</th>
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">비고</th>
<th
rowSpan={2}
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
>
관로번호
</th>
<th
rowSpan={2}
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
>
단위
<br />
구간
</th>
<th
rowSpan={2}
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
>
단위
<br />
길이
<br />
(m)
</th>
<th
rowSpan={2}
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
>
결함
<br />
위치
<br />
(m)
</th>
<th
colSpan={6}
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
>
결함
</th>
<th
colSpan={2}
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
>
등급점수(점)
</th>
<th
rowSpan={2}
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
>
조사중단사유
</th>
<th
rowSpan={2}
className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground"
>
비고
</th>
</tr>
<tr>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">종류</th>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">코드</th>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">등급</th>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">발생<br />위치</th>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">침입수<br />가점</th>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">연속<br />결함</th>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">구조적</th>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">운영적</th>
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
종류
</th>
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
코드
</th>
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
등급
</th>
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
발생
<br />
위치
</th>
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
침입수
<br />
가점
</th>
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
연속
<br />
결함
</th>
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
구조적
</th>
<th className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground">
운영적
</th>
</tr>
</thead>
<tbody>
{rows.length > 0 ? rows.map((row, index) => {
const pipeSpan = pipeSpans.get(row.pipeNo);
const unitKey = `${row.pipeNo}::${row.unitSeq}`;
const unitSpan = unitSpans.get(unitKey);
const showPipeCell = pipeSpan?.firstIndex === index;
const showUnitCells = unitSpan?.firstIndex === index;
{rows.length > 0 ? (
rows.map((row, index) => {
const pipeSpan = pipeSpans.get(row.pipeNo);
const unitKey = `${row.pipeNo}::${row.unitSeq}`;
const unitSpan = unitSpans.get(unitKey);
const showPipeCell = pipeSpan?.firstIndex === index;
const showUnitCells = unitSpan?.firstIndex === index;
return (
<tr
key={`${row.pipeNo}-${row.unitSeq}-${row.defectPositionM}-${row.code}-${index}`}
className="sn-popup-grid-row sn-popup-grid-row-hover"
>
{showPipeCell && (
<td
rowSpan={pipeSpan?.count}
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle font-medium text-primary"
>
{row.pipeNo}
return (
<tr
key={`${row.pipeNo}-${row.unitSeq}-${row.defectPositionM}-${row.code}-${index}`}
className="transition-colors hover:bg-container"
>
{showPipeCell && (
<td
rowSpan={pipeSpan?.count}
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle font-medium text-primary"
>
{row.pipeNo}
</td>
)}
{showUnitCells && (
<>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle font-medium"
>
{row.unitSeq}
</td>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle"
>
{row.unitLengthM}
</td>
</>
)}
<td className="border-b border-r border-border px-2 py-2.5">
{formatOptionalValue(row.defectPositionM)}
</td>
)}
{showUnitCells && (
<>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle font-medium"
>
{row.unitSeq}
</td>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle"
>
{row.unitLengthM}
</td>
</>
)}
<td className="border-b border-r border-border px-2 py-2.5">
{formatOptionalValue(row.defectPositionM)}
</td>
<td className="border-b border-r border-border px-2 py-2.5 text-left">{row.defectName}</td>
<td className="border-b border-r border-border px-2 py-2.5">{row.code}</td>
<td className="border-b border-r border-border px-2 py-2.5">
{row.severity ? SEVERITY_LABELS[row.severity] : "-"}
</td>
<td className="border-b border-r border-border px-2 py-2.5">{formatOptionalValue(row.occurrencePosition)}</td>
<td className="border-b border-r border-border px-2 py-2.5">
{formatOptionalValue(row.infiltrationBonus)}
</td>
<td className="border-b border-r border-border px-2 py-2.5">
{row.continuous == null ? "-" : row.continuous ? "Y" : "N"}
</td>
<td className="border-b border-r border-border px-2 py-2.5">{formatOptionalValue(row.structuralScore)}</td>
<td className="border-b border-r border-border px-2 py-2.5">{formatOptionalValue(row.operationalScore)}</td>
<td className="border-b border-r border-border px-2 py-2.5">
{formatOptionalValue(row.surveyStopReason)}
</td>
<td className="border-b border-r border-border px-2 py-2.5">
{formatOptionalValue(row.remark)}
</td>
</tr>
);
}) : (
<td className="border-b border-r border-border px-2 py-2.5 text-left">
{row.defectName}
</td>
<td className="border-b border-r border-border px-2 py-2.5">
{row.code}
</td>
<td className="border-b border-r border-border px-2 py-2.5">
{row.severity ? SEVERITY_LABELS[row.severity] : "-"}
</td>
<td className="border-b border-r border-border px-2 py-2.5">
{formatOptionalValue(row.occurrencePosition)}
</td>
<td className="border-b border-r border-border px-2 py-2.5">
{formatOptionalValue(row.infiltrationBonus)}
</td>
<td className="border-b border-r border-border px-2 py-2.5">
{row.continuous == null ? "-" : row.continuous ? "Y" : "N"}
</td>
<td className="border-b border-r border-border px-2 py-2.5">
{formatOptionalValue(row.structuralScore)}
</td>
<td className="border-b border-r border-border px-2 py-2.5">
{formatOptionalValue(row.operationalScore)}
</td>
<td className="border-b border-r border-border px-2 py-2.5">
{formatOptionalValue(row.surveyStopReason)}
</td>
<td className="border-b border-r border-border px-2 py-2.5">
{formatOptionalValue(row.remark)}
</td>
</tr>
);
})
) : (
<tr>
<td
colSpan={COLUMN_WIDTHS.length}
className="sn-popup-empty-cell h-28 border-b border-r border-border text-center"
className="h-28 border-b border-r border-border text-center text-muted-foreground"
>
{emptyMessage}
</td>
@@ -2,24 +2,32 @@
import LoadingOverlay from "@/components/ui/LoadingBar";
import { fileDownload } from "@/components/lib/utils";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { getStartYearArray } from "@/components/ui/YearRangeSelectBox";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import useStore from "@/store/useStore";
import {
Button,
Input,
Label,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components/ui";
import { SearchDiv, SelectBox } from "@common/business_components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import {
STANDARD_ROW_NUMBER_HEADER,
StandardDataTable,
getStandardRowNumber,
} from "@common/business_components";
import {
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
} from "@tanstack/react-table";
import { useEffect, useMemo, useRef, useState } from "react";
import {
downloadExmnBusinessReport,
@@ -30,23 +38,22 @@ import type {
ExmnBusinessSearchParams,
} from "../model/exmn-business-types";
import { ExmnStatusDetailPanel } from "./ExmnStatusDetailPanel";
import { EXMN_TABLE_HEADER_CLASS } from "./ExmnStandardDataTable";
const ACTION_BUTTON_CLASS =
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
// 목록 페이지네이션을 제거하고 한 번에 받아 내부 스크롤로 표시할 최대 사업 건수다.
// 서버 호환을 위해 전체 후보를 한 번에 받고 공통 표에서 10건씩 페이지 표시한다.
const BUSINESS_LIST_SCROLL_FETCH_SIZE = 1000;
const BUSINESS_LIST_PAGE_SIZE = 10;
const BUSINESS_TABLE_HEAD = [
{ id: "rowNumber", title: "NO", widthPercent: 5, sortable: true },
{ id: "bizCyclText", title: "사업차수", widthPercent: 6, sortable: true, sortKey: "bizCycl" },
{ id: "bizYr", title: "사업년도", widthPercent: 7, sortable: true },
{ id: "ctpvNm", title: "시도", widthPercent: 9, sortable: true },
{ id: "sggNm", title: "시군구", widthPercent: 9, sortable: true },
{ id: "bizNm", title: "사업명", widthPercent: 26, sortable: true },
{ id: "trtdstCntText", title: "처리구역·분구(개소)", widthPercent: 8, sortable: true, sortKey: "trtdstCnt" },
{ id: "prcisExmnPrlgText", title: "정밀조사 연장(km)", widthPercent: 8, sortable: true, sortKey: "prcisExmnPrlg" },
{ id: "reportYnText", title: "보고서", widthPercent: 22, sortable: true, sortKey: "reportYn" },
{ id: "rowNumber", title: STANDARD_ROW_NUMBER_HEADER },
{ id: "bizCyclText", title: "사업차수" },
{ id: "bizYr", title: "사업년도" },
{ id: "ctpvNm", title: "시도" },
{ id: "sggNm", title: "시군구" },
{ id: "bizNm", title: "사업명" },
{ id: "trtdstCntText", title: "처리구역·분구(개소)" },
{ id: "prcisExmnPrlgText", title: "정밀조사 연장(km)" },
{ id: "reportYnText", title: "보고서" },
];
interface BusinessFilters {
@@ -71,133 +78,77 @@ const EMPTY_FILTERS: BusinessFilters = {
type Notice = { type: "success" | "error" | "info"; text: string } | null;
/** 조회 목록에 공통 비교 규칙을 적용해 정렬 헤더와 실제 행 순서를 일치시킨다. */
const sortBusinessList = (
source: ExmnBusiness[],
nextSort: UiTableSort | undefined,
) => {
if (!nextSort) return source;
// NO는 서버 엔티티 필드가 아니라 현재 조회 결과의 기본 순번이므로 원본 순서를 정·역순으로 사용한다.
if (nextSort.key === "rowNumber") {
return nextSort.direction === "desc" ? [...source].reverse() : [...source];
}
const getSortValue = (business: ExmnBusiness) => {
switch (nextSort.key) {
case "bizCycl": return business.bizCycl ?? 0;
case "bizYr": return business.bizYr;
case "ctpvNm": return business.ctpvNm;
case "sggNm": return business.sggNm;
case "bizNm": return business.bizNm;
case "trtdstCnt": return business.trtdstCnt;
case "prcisExmnPrlg": return business.prcisExmnPrlg;
case "reportYn": return business.reportYn ?? "N";
default: return business.bizNm;
}
};
return [...source].sort((left, right) => {
const leftValue = getSortValue(left);
const rightValue = getSortValue(right);
const comparison = typeof leftValue === "number" && typeof rightValue === "number"
? leftValue - rightValue
: String(leftValue).localeCompare(String(rightValue), "ko");
return nextSort.direction === "desc" ? comparison * -1 : comparison;
});
};
/** 사업정보 조회·등록과 선택 사업의 조사결과 등록 진입을 제공하는 SFR-010-004 메인 화면이다. */
export function ExmnRegistrationPage() {
const pageRef = useRef<HTMLDivElement>(null);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
/** React 개발 모드의 effect 재실행에서도 최초 목록 조회를 한 번만 수행한다. */
const initialBusinessesLoadedRef = useRef(false);
// 입력 중 조건과 실제 서버 조회 조건을 분리해 정렬 시 동일 조건을 재사용한다.
const [draftFilters, setDraftFilters] = useState<BusinessFilters>(EMPTY_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<BusinessFilters>(EMPTY_FILTERS);
// 입력 중 조건과 실제 서버 조회 조건을 분리해 조회·초기화 동작을 명확히 유지한다.
const [draftFilters, setDraftFilters] =
useState<BusinessFilters>(EMPTY_FILTERS);
const [appliedFilters, setAppliedFilters] =
useState<BusinessFilters>(EMPTY_FILTERS);
const [businesses, setBusinesses] = useState<ExmnBusiness[]>([]);
const [total, setTotal] = useState(0);
const [sort, setSort] = useState<UiTableSort>();
const [totalElements, setTotalElements] = useState(0);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: BUSINESS_LIST_PAGE_SIZE,
});
// 선택 사업 ID는 서버 재조회 뒤에도 같은 행을 복원할 수 있도록 객체와 별도로 유지한다.
const [selectedBusinessId, setSelectedBusinessId] = useState<string | null>(null);
const [selectedBusinessId, setSelectedBusinessId] = useState<string | null>(
null,
);
const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
/**
* 공통 헤더와 레이아웃은 변경하지 않고, 이 화면이 시작되는 지점부터
* 뷰포트 하단까지만 조사관리 영역으로 사용한다.
* 부모 높이가 확정되어야 상세 그리드의 flex-1 / overflow-y-auto가 동작한다.
*/
/** 팝업과 동일한 행정구역 공통코드를 검색 Select에 준비한다. */
useEffect(() => {
const pageElement = pageRef.current;
if (!pageElement) return;
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const previousHtmlOverflow = document.documentElement.style.overflow;
const previousBodyOverflow = document.body.style.overflow;
const footerElement = document.querySelector<HTMLElement>("footer");
const updatePageHeight = () => {
const pageTop = pageElement.getBoundingClientRect().top;
const footerHeight = footerElement?.getBoundingClientRect().height ?? 0;
const bottomGap = 8;
pageElement.style.height = `${Math.max(
0,
window.innerHeight - pageTop - footerHeight - bottomGap,
)}px`;
};
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
updatePageHeight();
window.addEventListener("resize", updatePageHeight);
const footerResizeObserver = footerElement
? new ResizeObserver(updatePageHeight)
: null;
footerResizeObserver?.observe(footerElement);
return () => {
window.removeEventListener("resize", updatePageHeight);
footerResizeObserver?.disconnect();
document.documentElement.style.overflow = previousHtmlOverflow;
document.body.style.overflow = previousBodyOverflow;
};
}, []);
const availableSgg = useMemo(() => {
if (draftFilters.ctpvCd === "0") return sgg;
return sgg.filter((option) =>
String(option.val).startsWith(draftFilters.ctpvCd.slice(0, 2)),
);
}, [draftFilters.ctpvCd, sgg]);
const selectedBusiness = useMemo(
() => businesses.find((business) => business.exmnBizId === selectedBusinessId) ?? null,
() =>
businesses.find(
(business) => business.exmnBizId === selectedBusinessId,
) ?? null,
[businesses, selectedBusinessId],
);
/** 조사사업 목록 헤더의 정렬 상태를 오름차순·내림차순·기본 순서로 순환한다. */
const handleBusinessSort = (head: (typeof BUSINESS_TABLE_HEAD)[number]) => {
if (!("sortable" in head) || !head.sortable) return;
const key = "sortKey" in head && head.sortKey ? head.sortKey : head.id;
const nextSort: UiTableSort | undefined = sort?.key !== key
? { key, direction: "asc" }
: sort.direction === "asc"
? { key, direction: "desc" }
: undefined;
setSort(nextSort);
void fetchBusinesses(appliedFilters, nextSort);
};
/** 사업 목록의 숫자·행정구역·사업보고서 파일명을 표시 전용 값으로 변환한다. */
const businessRows = useMemo(() => businesses.map((business, index) => ({
...business,
rowNumber: sort?.key === "rowNumber" && sort.direction === "desc"
? businesses.length - index
: index + 1,
bizCyclText: business.bizCycl == null ? "-" : `${business.bizCycl}차`,
trtdstCntText: Number(business.trtdstCnt ?? 0).toLocaleString("ko-KR"),
prcisExmnPrlgText: Number(business.prcisExmnPrlg ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 }),
reportYnText: business.bizRptpFileName?.trim()
|| (business.reportYn === "Y" || business.bizRptpAtchFileId ? "있음" : "없음"),
})), [businesses, sort]);
const businessRows = useMemo(
() =>
businesses.map((business, index) => ({
...business,
rowNumber: getStandardRowNumber({
total: totalElements,
rowIndex: index,
}),
bizCyclText: business.bizCycl == null ? "-" : `${business.bizCycl}차`,
trtdstCntText: Number(business.trtdstCnt ?? 0).toLocaleString("ko-KR"),
prcisExmnPrlgText: Number(business.prcisExmnPrlg ?? 0).toLocaleString(
"ko-KR",
{ maximumFractionDigits: 3 },
),
reportYnText:
business.bizRptpFileName?.trim() ||
(business.reportYn === "Y" || business.bizRptpAtchFileId
? "있음"
: "없음"),
})),
[businesses, totalElements],
);
/** 화면 조건을 행정구역 이름과 목록 내부 스크롤용 단일 서버 Page 계약으로 변환한다. */
const buildSearchParams = (
filters: BusinessFilters,
nextSort: UiTableSort | undefined,
): ExmnBusinessSearchParams => ({
bizCycl: filters.bizCycl || undefined,
bizYr: filters.bizYr !== "0" ? filters.bizYr : undefined,
@@ -206,32 +157,32 @@ export function ExmnRegistrationPage() {
bizNm: filters.bizNm || undefined,
page: 0,
size: BUSINESS_LIST_SCROLL_FETCH_SIZE,
sort: nextSort && nextSort.key !== "rowNumber"
? `${nextSort.key},${nextSort.direction}`
: undefined,
});
/** 지정 조회조건·정렬로 스크롤 표시용 사업 목록을 읽고 선택 행을 가능한 경우 유지한다. */
const fetchBusinesses = async (
filters: BusinessFilters,
nextSort = sort,
) => {
/** 지정 조회조건으로 사업 목록을 읽고 선택 행을 가능한 경우 유지한다. */
const fetchBusinesses = async (filters: BusinessFilters) => {
setIsLoading(true);
setNotice(null);
try {
const result = await getExmnBusinesses(buildSearchParams(filters, nextSort));
const content = sortBusinessList(result.content, nextSort);
const result = await getExmnBusinesses(buildSearchParams(filters));
const content = result.content;
setBusinesses(content);
setTotal(result.totalElements);
setSelectedBusinessId((current) => content.some((item) => item.exmnBizId === current) ? current : null);
setTotalElements(result.totalElements);
setSelectedBusinessId((current) =>
content.some((item) => item.exmnBizId === current) ? current : null,
);
setPagination((current) => ({ ...current, pageIndex: 0 }));
return true;
} catch (error) {
setBusinesses([]);
setTotal(0);
setTotalElements(0);
setSelectedBusinessId(null);
setNotice({
type: "error",
text: error instanceof Error ? error.message : "조사사업 목록 조회 중 오류가 발생했습니다.",
text:
error instanceof Error
? error.message
: "조사사업 목록 조회 중 오류가 발생했습니다.",
});
return false;
} finally {
@@ -243,7 +194,7 @@ export function ExmnRegistrationPage() {
useEffect(() => {
if (initialBusinessesLoadedRef.current) return;
initialBusinessesLoadedRef.current = true;
void fetchBusinesses(EMPTY_FILTERS, undefined);
void fetchBusinesses(EMPTY_FILTERS);
// 최초 진입 시점의 빈 조회조건으로 한 번만 실행한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -251,14 +202,25 @@ export function ExmnRegistrationPage() {
/** 입력 중 조건을 확정하고 첫 페이지를 조회한다. */
const handleSearch = async () => {
setAppliedFilters(draftFilters);
await fetchBusinesses(draftFilters, sort);
await fetchBusinesses(draftFilters);
};
/** CS 초기화 버튼으로 모든 검색조건과 페이지를 최초 조회 기준으로 복원한다. */
const handleReset = async () => {
setDraftFilters(EMPTY_FILTERS);
setAppliedFilters(EMPTY_FILTERS);
setSelectedBusinessId(null);
await fetchBusinesses(EMPTY_FILTERS);
};
/** 목록의 보고서 링크에서 첨부서비스 원본 PDF를 내려받는다. */
const handleReportDownload = async (business: ExmnBusiness) => {
const attachmentId = business.bizRptpAtchFileId?.trim();
if (!attachmentId) {
setNotice({ type: "error", text: "다운로드할 사업보고서 첨부파일 정보가 없습니다." });
setNotice({
type: "error",
text: "다운로드할 사업보고서 첨부파일 정보가 없습니다.",
});
return;
}
setIsLoading(true);
@@ -272,7 +234,10 @@ export function ExmnRegistrationPage() {
} catch (error) {
setNotice({
type: "error",
text: error instanceof Error ? error.message : "사업보고서 다운로드 중 오류가 발생했습니다.",
text:
error instanceof Error
? error.message
: "사업보고서 다운로드 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
@@ -286,14 +251,20 @@ export function ExmnRegistrationPage() {
sourcePath: window.location.pathname,
});
if (!popup) {
setNotice({ type: "error", text: "사업등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요." });
setNotice({
type: "error",
text: "사업등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
});
}
};
/** 선택 사업을 기존 등록 팝업의 수정 모드로 열어 기본정보·처리분구·양식을 다시 관리한다. */
const openBusinessUpdate = () => {
if (!selectedBusiness) {
setNotice({ type: "error", text: "사업 목록에서 수정할 사업을 먼저 선택해주세요." });
setNotice({
type: "error",
text: "사업 목록에서 수정할 사업을 먼저 선택해주세요.",
});
return;
}
setNotice(null);
@@ -302,14 +273,20 @@ export function ExmnRegistrationPage() {
sourcePath: window.location.pathname,
});
if (!popup) {
setNotice({ type: "error", text: "사업수정 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요." });
setNotice({
type: "error",
text: "사업수정 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
});
}
};
/** 선택 사업 컨텍스트를 전달해 공통 WindowPopup 결과등록 화면을 연다. */
const openResultRegister = () => {
if (!selectedBusiness) {
setNotice({ type: "error", text: "사업 목록에서 대상 사업을 먼저 선택해주세요." });
setNotice({
type: "error",
text: "사업 목록에서 대상 사업을 먼저 선택해주세요.",
});
return;
}
setNotice(null);
@@ -327,7 +304,10 @@ export function ExmnRegistrationPage() {
sourcePath: window.location.pathname,
});
if (!popup) {
setNotice({ type: "error", text: "결과등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요." });
setNotice({
type: "error",
text: "결과등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
});
}
};
@@ -335,84 +315,266 @@ export function ExmnRegistrationPage() {
useEffect(() => {
const handlePopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED) {
if (
event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED
) {
setSelectedBusinessId(null);
void fetchBusinesses(appliedFilters, sort).then((refreshed) => {
if (refreshed) setNotice({ type: "success", text: "조사사업이 등록되었습니다." });
void fetchBusinesses(appliedFilters).then((refreshed) => {
if (refreshed)
setNotice({ type: "success", text: "조사사업이 등록되었습니다." });
});
return;
}
if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_UPDATED) {
void fetchBusinesses(appliedFilters, sort).then((refreshed) => {
if (refreshed) setNotice({ type: "success", text: "조사사업이 수정되었습니다." });
if (
event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_UPDATED
) {
void fetchBusinesses(appliedFilters).then((refreshed) => {
if (refreshed)
setNotice({ type: "success", text: "조사사업이 수정되었습니다." });
});
return;
}
if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED) {
void fetchBusinesses(appliedFilters, sort).then((refreshed) => {
if (refreshed) setNotice({ type: "success", text: "조사결과 최종 저장과 PPL/MNHL 집계를 완료했습니다." });
if (
event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED
) {
void fetchBusinesses(appliedFilters).then((refreshed) => {
if (refreshed)
setNotice({
type: "success",
text: "조사결과 최종 저장과 PPL/MNHL 집계를 완료했습니다.",
});
});
}
};
window.addEventListener("message", handlePopupMessage);
return () => window.removeEventListener("message", handlePopupMessage);
}, [appliedFilters, sort]);
}, [appliedFilters]);
/** 보고서 다운로드와 행 선택을 유지하며 조사사업 목록을 공통 DataTable 열로 변환한다. */
const columns = useMemo<ColumnDef<(typeof businessRows)[number]>[]>(
() =>
BUSINESS_TABLE_HEAD.map((head) => ({
id: head.id,
accessorFn: (row) => row[head.id as keyof typeof row],
header: head.title,
cell: ({ row, getValue }) => {
const business = row.original;
const isReportCell = head.id === "reportYnText";
const reportAvailable =
isReportCell && Boolean(business.bizRptpAtchFileId?.trim());
const value = String(getValue() ?? "");
if (reportAvailable) {
return (
<button
type="button"
className="hover:text-primary/80 block w-full truncate font-semibold text-primary underline underline-offset-4"
aria-label={`${business.reportYnText} 다운로드`}
title={`${business.reportYnText} 다운로드`}
onClick={(event) => {
event.stopPropagation();
void handleReportDownload(business);
}}
>
{value}
</button>
);
}
return (
<span
className="block min-w-0 whitespace-normal break-words"
title={value}
>
{value}
</span>
);
},
meta: {
width:
head.id === "bizNm"
? 240
: head.id === "reportYnText"
? 170
: head.id === "trtdstCntText" || head.id === "prcisExmnPrlgText"
? 125
: head.id === "rowNumber"
? 60
: 90,
align: "center" as const,
},
})),
[],
);
const table = useReactTable({
data: businessRows,
columns,
state: {
pagination,
rowSelection: selectedBusinessId ? { [selectedBusinessId]: true } : {},
},
getRowId: (row) => row.exmnBizId,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div
ref={pageRef}
className="sn-exmn-registration-page flex min-h-0 flex-col overflow-hidden"
>
<div className="sn-exmn-registration-page flex min-h-0 flex-col">
<LoadingOverlay isLoading={isLoading} />
<SearchDiv>
<div className="flex w-full flex-wrap items-center gap-y-3 px-5 py-4">
<div className="mr-8 flex items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-gray-600">사업차수</Label>
<Input type="number" min={0} className="h-[3.2rem] w-24 text-base" value={draftFilters.bizCycl} onChange={(event) => setDraftFilters((current) => ({ ...current, bizCycl: event.target.value }))} />
<div className="card card-border !mt-6">
<form
className="flex flex-wrap items-center gap-2 p-2"
onSubmit={(event) => {
event.preventDefault();
void handleSearch();
}}
>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">
사업차수
</Label>
<Input
type="number"
min={0}
className="w-[8rem]"
value={draftFilters.bizCycl}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
bizCycl: event.target.value,
}))
}
/>
</div>
<SelectBox
label="사업년도"
labelClass="whitespace-nowrap text-xl font-medium text-gray-600"
className="mr-8 flex items-center gap-2"
selectClass="w-28 rounded-lg bg-white"
selectArray={getStartYearArray({ placeholder: "전체" })}
value={draftFilters.bizYr}
onSelectValue={(value) => setDraftFilters((current) => ({ ...current, bizYr: String(value) }))}
/>
<CtpvSggPrcsFcltSelectBox
sido={draftFilters.ctpvCd}
sigungu={draftFilters.sggCd}
onSidoChange={(ctpvCd) => setDraftFilters((current) => ({
...current,
ctpvCd,
sggCd: "0",
sggNm: "",
}))}
onSigunguChange={(sggCd) => setDraftFilters((current) => ({ ...current, sggCd }))}
setCtpvName={(ctpvNm) => setDraftFilters((current) => ({
...current,
ctpvNm: ctpvNm === "전체" ? "" : ctpvNm,
}))}
setSggName={(sggNm) => setDraftFilters((current) => ({
...current,
sggNm: sggNm === "전체" ? "" : sggNm,
}))}
/>
<div className="mr-8 flex items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-gray-600">사업명</Label>
<Input className="h-[3.2rem] w-[32rem] text-base" value={draftFilters.bizNm} onChange={(event) => setDraftFilters((current) => ({ ...current, bizNm: event.target.value }))} onKeyDown={(event) => { if (event.key === "Enter") void handleSearch(); }} />
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">
사업년도
</Label>
<Select
value={draftFilters.bizYr}
onValueChange={(value) =>
setDraftFilters((current) => ({
...current,
bizYr: String(value),
}))
}
>
<SelectTrigger className="w-[11rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{getStartYearArray({ placeholder: "전체" }).map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleSearch()}>조회</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={openBusinessUpdate}>수정</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={openBusinessRegister}>사업등록</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={openResultRegister}>결과등록</Button>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">시도</Label>
<Select
value={draftFilters.ctpvCd}
onValueChange={(value) => {
const ctpvCd = String(value);
const selected = ctpv.find(
(option) => String(option.val) === ctpvCd,
);
setDraftFilters((current) => ({
...current,
ctpvCd,
ctpvNm: ctpvCd === "0" ? "" : (selected?.text ?? ""),
sggCd: "0",
sggNm: "",
}));
}}
>
<SelectTrigger className="w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[{ val: "0", text: "전체" }, ...ctpv].map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</SearchDiv>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">시군구</Label>
<Select
value={draftFilters.sggCd}
onValueChange={(value) => {
const sggCd = String(value);
const selected = availableSgg.find(
(option) => String(option.val) === sggCd,
);
setDraftFilters((current) => ({
...current,
sggCd,
sggNm: sggCd === "0" ? "" : (selected?.text ?? ""),
}));
}}
>
<SelectTrigger className="w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[{ val: "0", text: "전체" }, ...availableSgg].map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">사업명</Label>
<Input
className="w-[24rem]"
value={draftFilters.bizNm}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
bizNm: event.target.value,
}))
}
/>
</div>
<Button type="submit" className="w-[7.4rem]" disabled={isLoading}>
조회
</Button>
<Button
type="button"
color="white"
className="w-[8.8rem]"
disabled={isLoading}
onClick={() => void handleReset()}
>
초기화
</Button>
</form>
</div>
{notice && (
<div
@@ -421,7 +583,7 @@ export function ExmnRegistrationPage() {
notice.type === "error"
? "border-red-200 bg-red-50 text-red-700"
: notice.type === "info"
? "border-blue-200 bg-blue-50 text-blue-700"
? "border-primary bg-primary-transparent text-primary"
: "border-emerald-200 bg-emerald-50 text-emerald-700"
}`}
>
@@ -429,170 +591,44 @@ export function ExmnRegistrationPage() {
</div>
)}
<ListDiv className="mt-4 min-h-0 shrink-0 overflow-hidden">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-medium">조사사업 목록 <span className="text-base text-gray-500">총 {total.toLocaleString("ko-KR")}건</span></h3>
</div>
<div className="relative mx-[1.2rem]" style={{ marginTop: "0.6rem" }}>
<div
className="overflow-x-hidden overflow-y-auto"
style={{
maxHeight: "16rem",
scrollbarGutter: "stable",
}}
>
<div
className="card !m-0"
style={{ paddingTop: 0, paddingBottom: 0 }}
>
<Table variant="secondary" className="table-fixed">
<colgroup>
{BUSINESS_TABLE_HEAD.map((head) => (
<col key={head.id} style={{ width: `${head.widthPercent}%` }} />
))}
</colgroup>
<TableHeader
className="font-bold"
style={{ position: "static", zIndex: "auto" }}
>
<TableRow>
{BUSINESS_TABLE_HEAD.map((head) => {
const sortKey = "sortKey" in head && head.sortKey ? head.sortKey : head.id;
const direction = sort?.key === sortKey ? sort.direction : undefined;
const sortable = "sortable" in head && head.sortable;
const compactHeader = head.id === "trtdstCntText"
|| head.id === "prcisExmnPrlgText";
return (
<TableHead
key={head.id}
className={`sticky top-0 z-20 whitespace-nowrap bg-[#eaf3fb] text-center ${
compactHeader ? "!px-1" : ""
}`}
style={{
borderTopWidth: 0,
boxShadow: "inset 0 1px 0 var(--border)",
}}
aria-sort={
sortable
? direction === "asc"
? "ascending"
: direction === "desc"
? "descending"
: "none"
: undefined
}
>
{sortable ? (
<button
type="button"
className="inline-flex w-full items-center justify-center gap-1 whitespace-nowrap"
aria-label={`${head.title} 정렬`}
onClick={() => handleBusinessSort(head)}
>
<span className={compactHeader ? "text-[1.2rem]" : undefined}>
{head.title}
</span>
{direction === "asc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
aria-hidden="true"
>
▲
</span>
) : direction === "desc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
aria-hidden="true"
>
▼
</span>
) : (
<span
className="inline-flex flex-col items-center gap-px text-[6px] leading-none text-[#475569]"
aria-hidden="true"
>
<span>▲</span>
<span>▼</span>
</span>
)}
</button>
) : head.title}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody className="text-center">
{businessRows.length > 0 ? businessRows.map((business) => {
const selected = business.exmnBizId === selectedBusinessId;
return (
<TableRow
key={business.exmnBizId}
data-state={selected ? "selected" : undefined}
className="cursor-pointer"
onClick={() => setSelectedBusinessId(business.exmnBizId)}
>
{BUSINESS_TABLE_HEAD.map((head) => {
const isReportCell = head.id === "reportYnText";
const reportAvailable = isReportCell && Boolean(business.bizRptpAtchFileId?.trim());
return (
<TableCell key={head.id} className="!p-0">
<button
type="button"
className={`flex min-h-[4rem] min-w-0 w-full cursor-pointer items-center justify-center px-2 py-[1.2rem] text-[1.3rem] ${
reportAvailable
? "font-semibold text-blue-600 underline underline-offset-4 hover:text-blue-800"
: selected
? "font-bold"
: "font-normal"
}`}
aria-label={reportAvailable
? `${business.reportYnText} 다운로드`
: undefined}
title={reportAvailable
? `${business.reportYnText} 다운로드`
: undefined}
onClick={(event) => {
if (reportAvailable) {
event.stopPropagation();
void handleReportDownload(business);
return;
}
setSelectedBusinessId(business.exmnBizId);
}}
>
<span className={isReportCell ? "block min-w-0 w-full truncate" : undefined}>
{String(business[head.id as keyof typeof business] ?? "")}
</span>
</button>
</TableCell>
);
})}
</TableRow>
);
}) : (
<TableRow>
<TableCell colSpan={BUSINESS_TABLE_HEAD.length} className="h-24 text-center text-gray-500">
조회된 결과가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<span
aria-hidden="true"
className="pointer-events-none absolute z-30"
style={{
right: "calc(0.6rem + 17px)",
bottom: 0,
left: "0.6rem",
borderTop: "1px solid var(--border)",
}}
/>
</div>
</ListDiv>
<div className="mt-4 min-w-0">
<StandardDataTable
table={table}
title="조사 목록"
className={EXMN_TABLE_HEADER_CLASS}
scrollMode="horizontal"
minTableWidth="110rem"
onRowClick={(row) => setSelectedBusinessId(row.exmnBizId)}
actions={
<>
<Button
type="button"
color="primary"
disabled={isLoading}
onClick={openBusinessRegister}
>
사업등록
</Button>
<Button
type="button"
color="primary"
disabled={isLoading}
onClick={openResultRegister}
>
결과등록
</Button>
<Button
type="button"
color="primary"
disabled={isLoading}
onClick={openBusinessUpdate}
>
수정
</Button>
</>
}
/>
</div>
<ExmnStatusDetailPanel business={selectedBusiness} />
</div>
);
@@ -4,7 +4,13 @@ import { handleUpload } from "@/components/lib/utils";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { Button } from "@common/components/ui";
import {
StandardFormField,
StandardFormSection,
StandardPopupLayout,
} from "@common/business_components";
import { Button } from "@common/components";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import type { HttpResponse } from "tus-js-client";
import { useEffect, useState } from "react";
import {
@@ -26,13 +32,6 @@ import type {
ExmnImportValidationResult,
} from "../model/exmn-business-types";
const POPUP_BUTTON_SIZE_CLASS =
"h-10 w-[9.6rem] rounded-sm px-4 !text-[1.2rem] font-medium";
const POPUP_ACTION_BUTTON_CLASS =
`${POPUP_BUTTON_SIZE_CLASS} !text-white disabled:!text-white`;
const FIND_BUTTON_CLASS =
"inline-flex h-10 cursor-pointer items-center justify-center rounded-sm bg-blue-600 px-3 !text-[1.2rem] font-medium !text-white hover:bg-blue-700";
const FILE_TYPES: ExmnFileType[] = [
"EXMN_EXCEL",
"PPL_EXMN_PDF",
@@ -47,13 +46,14 @@ const VALIDATION_FILE_TYPES: ExmnFileType[] = [
"MNHL_EXMN_PDF",
];
const VALIDATION_BUTTON_FILE_TYPES = new Set<ExmnFileType>(VALIDATION_FILE_TYPES);
const VALIDATION_BUTTON_FILE_TYPES = new Set<ExmnFileType>(
VALIDATION_FILE_TYPES,
);
/** 파일별로 실제 수행하는 검토 항목과 아직 적용하지 않은 범위를 사용자 문구로 정의한다. */
const VALIDATION_CHECK_GUIDES: Partial<Record<
ExmnFileType,
{ checkedItems: string[]; pendingNote?: string }
>> = {
const VALIDATION_CHECK_GUIDES: Partial<
Record<ExmnFileType, { checkedItems: string[]; pendingNote?: string }>
> = {
EXMN_EXCEL: {
checkedItems: [
"처리분구별 관로·맨홀 시트와 필수값",
@@ -69,10 +69,7 @@ const VALIDATION_CHECK_GUIDES: Partial<Record<
],
},
MNHL_EXMN_PDF: {
checkedItems: [
"PDF 파일 형식",
"조사결과 Excel 구조 및 저장 가능 여부",
],
checkedItems: ["PDF 파일 형식", "조사결과 Excel 구조 및 저장 가능 여부"],
pendingNote: "PDF 내부 맨홀번호 대조는 검토 기준 협의 후 적용 예정입니다.",
},
};
@@ -85,7 +82,9 @@ interface SelectedFile {
type SelectedFiles = Partial<Record<ExmnFileType, SelectedFile>>;
type Notice = { type: "success" | "error" | "info"; text: string } | null;
type FileValidationStatus = "success" | "error";
type FileValidationResults = Partial<Record<ExmnFileType, ExmnImportValidationResult>>;
type FileValidationResults = Partial<
Record<ExmnFileType, ExmnImportValidationResult>
>;
type FileValidationMessages = Partial<Record<ExmnFileType, string[]>>;
interface ExmnResultRegisterPopupPageProps {
@@ -102,7 +101,8 @@ const buildImportFormData = (
const formData = new FormData();
if (files.EXMN_EXCEL) formData.append("exmnFile", files.EXMN_EXCEL.file);
if (files.PPL_EXMN_PDF) formData.append("pplFile", files.PPL_EXMN_PDF.file);
if (files.MNHL_EXMN_PDF) formData.append("mnhlFile", files.MNHL_EXMN_PDF.file);
if (files.MNHL_EXMN_PDF)
formData.append("mnhlFile", files.MNHL_EXMN_PDF.file);
if (validation?.fileHash) formData.append("fileHash", validation.fileHash);
if (validationType) formData.append("validationType", validationType);
return formData;
@@ -112,51 +112,60 @@ const buildImportFormData = (
const uploadAttachment = (
selected: SelectedFile,
onProgress: (percentage: string) => void,
): Promise<ExmnAttachmentKey> => new Promise((resolve, reject) => {
let settled = false;
const rejectOnce = (error: Error) => {
if (settled) return;
settled = true;
reject(error);
};
const resolveFromResponse = (response: HttpResponse) => {
if (settled) return;
try {
const fileInfo = response.getHeader("X-File-Info");
if (!fileInfo) throw new Error("첨부서비스가 파일키를 반환하지 않았습니다.");
const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string };
if (!parsed.atflCd || !parsed.atflId) {
throw new Error("첨부서비스 파일키 형식이 올바르지 않습니다.");
}
): Promise<ExmnAttachmentKey> =>
new Promise((resolve, reject) => {
let settled = false;
const rejectOnce = (error: Error) => {
if (settled) return;
settled = true;
resolve({
atflCd: parsed.atflCd,
atflId: parsed.atflId,
fileName: selected.file.name,
});
} catch (error) {
rejectOnce(error instanceof Error
? error
: new Error("첨부파일 응답을 해석할 수 없습니다."));
}
};
reject(error);
};
const resolveFromResponse = (response: HttpResponse) => {
if (settled) return;
try {
const fileInfo = response.getHeader("X-File-Info");
if (!fileInfo)
throw new Error("첨부서비스가 파일키를 반환하지 않았습니다.");
const parsed = JSON.parse(fileInfo) as {
atflCd?: string;
atflId?: string;
};
if (!parsed.atflCd || !parsed.atflId) {
throw new Error("첨부서비스 파일키 형식이 올바르지 않습니다.");
}
settled = true;
resolve({
atflCd: parsed.atflCd,
atflId: parsed.atflId,
fileName: selected.file.name,
});
} catch (error) {
rejectOnce(
error instanceof Error
? error
: new Error("첨부파일 응답을 해석할 수 없습니다."),
);
}
};
handleUpload(selected.fileList, {
handleProgress: onProgress,
handleResponse: resolveFromResponse,
handleError: rejectOnce,
handleUpload(selected.fileList, {
handleProgress: onProgress,
handleResponse: resolveFromResponse,
handleError: rejectOnce,
});
});
});
/** 공통 WindowPopup 안에서 조사 파일 5종을 선택·검토·최종 저장하는 화면이다. */
export function ExmnResultRegisterPopupPage({
exmnBizId,
businessSummary,
}: ExmnResultRegisterPopupPageProps) {
const { confirm } = useConfirm();
const [detail, setDetail] = useState<ExmnBusinessDetail | null>(null);
const [files, setFiles] = useState<SelectedFiles>({});
/** 파일별 서버 검토 결과를 보관해 다른 파일 검토 후에도 결과와 건수를 유지한다. */
const [validationResults, setValidationResults] = useState<FileValidationResults>({});
const [validationResults, setValidationResults] =
useState<FileValidationResults>({});
const [fileValidationStatuses, setFileValidationStatuses] = useState<
Partial<Record<ExmnFileType, FileValidationStatus>>
>({});
@@ -185,7 +194,10 @@ export function ExmnResultRegisterPopupPage({
/** 팝업 진입 시 선택 사업의 처리분구·첨부 상세를 읽는다. */
useEffect(() => {
if (!exmnBizId) {
setNotice({ type: "error", text: "결과를 등록할 조사사업 식별자가 없습니다." });
setNotice({
type: "error",
text: "결과를 등록할 조사사업 식별자가 없습니다.",
});
return;
}
let active = true;
@@ -198,7 +210,10 @@ export function ExmnResultRegisterPopupPage({
if (!active) return;
setNotice({
type: "error",
text: error instanceof Error ? error.message : "선택 사업 상세 조회 중 오류가 발생했습니다.",
text:
error instanceof Error
? error.message
: "선택 사업 상세 조회 중 오류가 발생했습니다.",
});
})
.finally(() => {
@@ -210,11 +225,15 @@ export function ExmnResultRegisterPopupPage({
}, [exmnBizId]);
/** 파일 변경 시 이전 검토결과를 폐기하고 확장자·크기 규칙을 즉시 확인한다. */
const handleFileChange = (fileType: ExmnFileType, fileList: FileList | null) => {
const handleFileChange = (
fileType: ExmnFileType,
fileList: FileList | null,
) => {
setUploadStatus("");
const invalidatedTypes: ExmnFileType[] = fileType === "EXMN_EXCEL"
? ["EXMN_EXCEL", "PPL_EXMN_PDF", "MNHL_EXMN_PDF"]
: [fileType];
const invalidatedTypes: ExmnFileType[] =
fileType === "EXMN_EXCEL"
? ["EXMN_EXCEL", "PPL_EXMN_PDF", "MNHL_EXMN_PDF"]
: [fileType];
setFileValidationStatuses((current) => {
const next = { ...current };
invalidatedTypes.forEach((type) => delete next[type]);
@@ -277,7 +296,10 @@ export function ExmnResultRegisterPopupPage({
) as Partial<Record<ExmnFileType, File>>;
const validationMessage = validateExmnFiles(fileMap);
if (validationMessage) {
setFileValidationStatuses((current) => ({ ...current, [fileType]: "error" }));
setFileValidationStatuses((current) => ({
...current,
[fileType]: "error",
}));
setValidationResults((current) => {
const next = { ...current };
delete next[fileType];
@@ -309,13 +331,19 @@ export function ExmnResultRegisterPopupPage({
}));
setNotice(null);
} catch (error) {
const message = error instanceof Error ? error.message : "조사파일 검토 중 오류가 발생했습니다.";
const message =
error instanceof Error
? error.message
: "조사파일 검토 중 오류가 발생했습니다.";
setValidationResults((current) => {
const next = { ...current };
delete next[fileType];
return next;
});
setFileValidationStatuses((current) => ({ ...current, [fileType]: "error" }));
setFileValidationStatuses((current) => ({
...current,
[fileType]: "error",
}));
setFileValidationMessages((current) => ({
...current,
[fileType]: [message],
@@ -330,22 +358,33 @@ export function ExmnResultRegisterPopupPage({
const handleCommit = async () => {
const excelValidation = validationResults.EXMN_EXCEL;
if (
!excelValidation?.valid
|| fileValidationStatuses.EXMN_EXCEL !== "success"
|| fileValidationStatuses.PPL_EXMN_PDF !== "success"
) return;
!excelValidation?.valid ||
fileValidationStatuses.EXMN_EXCEL !== "success" ||
fileValidationStatuses.PPL_EXMN_PDF !== "success"
)
return;
if (!(await confirm("조사결과를 저장하시겠습니까?"))) return;
setIsLoading(true);
setNotice(null);
try {
await uploadAndLinkAttachments();
await commitExmnImport(exmnBizId, buildImportFormData(files, excelValidation));
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED, {
await commitExmnImport(
exmnBizId,
});
buildImportFormData(files, excelValidation),
);
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED,
{
exmnBizId,
},
);
} catch (error) {
setNotice({
type: "error",
text: error instanceof Error ? error.message : "조사결과 최종 저장 중 오류가 발생했습니다.",
text:
error instanceof Error
? error.message
: "조사결과 최종 저장 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
@@ -359,201 +398,244 @@ export function ExmnResultRegisterPopupPage({
const excelValidation = validationResults.EXMN_EXCEL;
return (
<main className="sn-popup-page">
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">조사 결과 업로드</h1>
<div>
<StandardPopupLayout
title="조사 결과 업로드"
showDefaultCancelAction={false}
actions={
<>
<Button
type="button"
color="white"
size="sm"
className="sn-popup-header-button h-10 w-auto px-3 !text-[1.2rem]"
color="primary"
disabled={
isLoading
|| !excelValidation?.valid
|| fileValidationStatuses.EXMN_EXCEL !== "success"
|| fileValidationStatuses.PPL_EXMN_PDF !== "success"
isLoading ||
!excelValidation?.valid ||
fileValidationStatuses.EXMN_EXCEL !== "success" ||
fileValidationStatuses.PPL_EXMN_PDF !== "success"
}
onClick={() => void handleCommit()}
>
저장
</Button>
</div>
</header>
<section className="flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto px-4 pb-3 pt-3">
{notice && notice.type !== "success" && (
<div role="alert" className={`whitespace-pre-line rounded border px-4 py-3 text-[1.3rem] ${
<Button
type="button"
color="white"
disabled={isLoading}
onClick={() => window.close()}
>
취소
</Button>
</>
}
bodyClassName="space-y-3 overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
{notice && notice.type !== "success" && (
<div
role="alert"
className={`whitespace-pre-line rounded-sm border px-4 py-3 text-[1.3rem] ${
notice.type === "error"
? "border-red-200 bg-red-50 text-red-700"
: notice.type === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-blue-200 bg-blue-50 text-blue-700"
}`}>{notice.text}</div>
)}
<section
className="rounded-md border border-border bg-secondary p-3"
aria-label="선택 조사사업 요약"
: "border-primary bg-primary-transparent text-primary"
}`}
>
<div className="flex min-w-0 flex-wrap items-center gap-x-7 gap-y-2">
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-xl font-medium text-gray-600">사업차수</span>
<span className="whitespace-nowrap text-[1.3rem] font-semibold text-black">
{detail?.bizCycl ?? businessSummary.bizCycl ?? "-"}차
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-xl font-medium text-gray-600">사업년도</span>
<span className="whitespace-nowrap text-[1.3rem] font-semibold text-black">
{detail?.bizYr ?? businessSummary.bizYr ?? "-"}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-xl font-medium text-gray-600">행정구역</span>
<span
className="max-w-[22rem] truncate text-[1.3rem] font-semibold text-black"
title={`${detail?.ctpvNm ?? businessSummary.ctpvNm} ${detail?.sggNm ?? businessSummary.sggNm}`}
>
{detail?.ctpvNm ?? businessSummary.ctpvNm} {detail?.sggNm ?? businessSummary.sggNm}
</span>
</div>
<div className="flex min-w-[24rem] flex-1 items-center gap-2">
<span className="shrink-0 whitespace-nowrap text-xl font-medium text-gray-600">사업명</span>
<span
className="min-w-0 truncate text-[1.3rem] font-semibold text-black"
title={detail?.bizNm ?? businessSummary.bizNm}
>
{detail?.bizNm ?? businessSummary.bizNm ?? "-"}
</span>
</div>
</div>
</section>
{notice.text}
</div>
)}
<section className="overflow-hidden border border-border">
<div
className="grid items-center gap-3 bg-[#eaf3fb] px-3 py-2.5 text-center text-[1.3rem] font-medium text-label"
style={{ gridTemplateColumns: "180px minmax(0, 1fr) 80px 112px" }}
<StandardFormSection title="선택 조사사업" columns={2}>
<StandardFormField label="사업차수" columns={2}>
<p className="flex min-h-[3.2rem] items-center text-[1.3rem] font-medium text-label">
{detail?.bizCycl ?? businessSummary.bizCycl ?? "-"}차
</p>
</StandardFormField>
<StandardFormField label="사업년도" columns={2}>
<p className="flex min-h-[3.2rem] items-center text-[1.3rem] font-medium text-label">
{detail?.bizYr ?? businessSummary.bizYr ?? "-"}
</p>
</StandardFormField>
<StandardFormField label="행정구역" columns={2}>
<p
className="flex min-h-[3.2rem] min-w-0 items-center truncate text-[1.3rem] font-medium text-label"
title={`${detail?.ctpvNm ?? businessSummary.ctpvNm} ${detail?.sggNm ?? businessSummary.sggNm}`}
>
<span>파일 구분</span><span>선택 파일</span><span>찾기</span><span>데이터 검토</span>
{detail?.ctpvNm ?? businessSummary.ctpvNm}{" "}
{detail?.sggNm ?? businessSummary.sggNm}
</p>
</StandardFormField>
<StandardFormField label="사업명" columns={2}>
<p
className="flex min-h-[3.2rem] min-w-0 items-center truncate text-[1.3rem] font-medium text-label"
title={detail?.bizNm ?? businessSummary.bizNm}
>
{detail?.bizNm ?? businessSummary.bizNm ?? "-"}
</p>
</StandardFormField>
</StandardFormSection>
<section className="overflow-hidden rounded-sm border border-border bg-background">
<div
className="grid items-center gap-3 bg-primary px-3 py-3 text-center text-[1.3rem] font-semibold text-primary-foreground"
style={{ gridTemplateColumns: "180px minmax(0, 1fr) 80px 112px" }}
>
<span>파일 구분</span>
<span>선택 파일</span>
<span>찾기</span>
<span>데이터 검토</span>
</div>
{FILE_TYPES.map((fileType) => {
const rule = EXMN_FILE_RULES[fileType];
const selected = files[fileType];
const validationStatus = fileValidationStatuses[fileType];
const inputId = `exmn-popup-file-${fileType}`;
const canValidate = VALIDATION_BUTTON_FILE_TYPES.has(fileType);
return (
<div
key={fileType}
className="grid items-center gap-3 border-t border-border px-3 py-3 text-[1.3rem] text-label"
style={{ gridTemplateColumns: "180px minmax(0, 1fr) 80px 112px" }}
>
<label htmlFor={inputId} className="font-medium text-label">
{(fileType === "EXMN_EXCEL" ||
fileType === "PPL_EXMN_PDF" ||
fileType === "MNHL_EXMN_PDF") && (
<span className="text-red-500">* </span>
)}
{rule.label}
</label>
<div
className={`flex min-w-0 items-center gap-2 rounded-md border px-3 py-1.5 ${
validationStatus === "success"
? "border-emerald-300 bg-emerald-50 font-semibold text-emerald-700"
: validationStatus === "error"
? "border-red-300 bg-red-50 font-semibold text-red-700"
: selected
? "border-primary bg-primary-transparent font-semibold text-primary"
: "border-border bg-background text-label"
}`}
title={selected?.file.name}
>
<span className="min-w-0 flex-1 truncate">
{selected?.file.name ||
`${rule.extensions.map((extension) => `.${extension}`).join(", ")} 파일`}
</span>
{validationStatus && (
<span className="shrink-0 text-xs">
{validationStatus === "success" ? "검토 완료" : "검토 실패"}
</span>
)}
</div>
<div className="text-center">
<input
id={inputId}
type="file"
className="sr-only"
accept={rule.extensions
.map((extension) => `.${extension}`)
.join(",")}
disabled={isLoading}
onChange={(event) =>
handleFileChange(fileType, event.target.files)
}
/>
<Button
type="button"
size="sm"
className="w-full px-3"
disabled={isLoading}
onClick={() => document.getElementById(inputId)?.click()}
>
찾기
</Button>
</div>
<div className="text-center">
{canValidate ? (
<Button
className="w-full px-3"
size="sm"
disabled={isLoading}
onClick={() => void handleValidate(fileType)}
>
데이터 검토
</Button>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
</div>
);
})}
</section>
{visibleValidationTypes.length > 0 && (
<section
className="overflow-hidden rounded-sm border border-border bg-background"
aria-label="데이터 검토 내역"
>
<div className="flex items-center border-b border-border bg-container px-4 py-3">
<h2 className="text-[1.3rem] font-semibold text-label">
데이터 검토 내역
</h2>
</div>
{FILE_TYPES.map((fileType) => {
const rule = EXMN_FILE_RULES[fileType];
const selected = files[fileType];
const validationStatus = fileValidationStatuses[fileType];
const inputId = `exmn-popup-file-${fileType}`;
const canValidate = VALIDATION_BUTTON_FILE_TYPES.has(fileType);
{visibleValidationTypes.map((fileType) => {
const status = fileValidationStatuses[fileType]!;
const result = validationResults[fileType];
const messages = fileValidationMessages[fileType] ?? [];
const guide = VALIDATION_CHECK_GUIDES[fileType];
const countText =
fileType === "EXMN_EXCEL"
? `관로 ${result?.summary.pplCount ?? 0}건 · 맨홀 ${result?.summary.mnhlCount ?? 0}건`
: fileType === "PPL_EXMN_PDF"
? `Excel 관로 ${result?.summary.pplCount ?? 0}건 기준`
: "";
return (
<div
key={fileType}
className="grid items-center gap-3 border-t border-border px-3 py-3 text-[1.3rem] text-label"
style={{ gridTemplateColumns: "180px minmax(0, 1fr) 80px 112px" }}
className="grid items-start gap-3 border-t border-border px-3 py-2.5 text-[1.3rem] text-label"
style={{ gridTemplateColumns: "180px 88px minmax(0, 1fr)" }}
>
<label htmlFor={inputId} className="font-medium text-gray-600">
{(fileType === "EXMN_EXCEL" || fileType === "PPL_EXMN_PDF" || fileType === "MNHL_EXMN_PDF") && <span className="text-red-500">* </span>}
{rule.label}
</label>
<div
className={`flex min-w-0 items-center gap-2 rounded-md border px-3 py-1.5 ${
validationStatus === "success"
? "border-emerald-300 bg-emerald-50 font-semibold text-emerald-700"
: validationStatus === "error"
? "border-red-300 bg-red-50 font-semibold text-red-700"
: selected
? "border-blue-300 bg-blue-50 font-semibold text-blue-700"
: "border-[#9fb2c4] bg-white text-gray-600"
<span className="font-medium text-label">
{EXMN_FILE_RULES[fileType].label}
</span>
<span
className={`inline-flex w-fit rounded-full px-2.5 py-1 text-xs font-semibold ${
status === "success"
? "bg-emerald-100 text-emerald-700"
: "bg-red-100 text-red-700"
}`}
title={selected?.file.name}
>
<span className="min-w-0 flex-1 truncate">
{selected?.file.name || `${rule.extensions.map((extension) => `.${extension}`).join(", ")} 파일`}
</span>
{validationStatus && (
<span className="shrink-0 text-xs">
{validationStatus === "success" ? "검토 완료" : "검토 실패"}
</span>
{status === "success" ? "검토 완료" : "검토 실패"}
</span>
<div className="min-w-0">
{status === "success" && countText && (
<p className="mb-1 font-medium text-label">{countText}</p>
)}
<p className="leading-5 text-label">
{guide?.checkedItems.join(" · ")}
</p>
{guide?.pendingNote && (
<p className="mt-1 text-amber-700">{guide.pendingNote}</p>
)}
{status === "error" && messages.length > 0 && (
<p
className="mt-1 truncate text-red-700"
title={messages.join("\n")}
>
{messages[0]}
{messages.length > 1
? ` 외 ${messages.length - 1}건`
: ""}
</p>
)}
</div>
<div className="text-center">
<input
id={inputId}
type="file"
className="sr-only"
accept={rule.extensions.map((extension) => `.${extension}`).join(",")}
disabled={isLoading}
onChange={(event) => handleFileChange(fileType, event.target.files)}
/>
<label htmlFor={inputId} className={FIND_BUTTON_CLASS}>찾기</label>
</div>
<div className="text-center">
{canValidate ? (
<Button color="green" className={POPUP_ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleValidate(fileType)}>데이터 검토</Button>
) : <span className="text-gray-400">-</span>}
</div>
</div>
);
})}
</section>
)}
{visibleValidationTypes.length > 0 && (
<section className="overflow-hidden border border-border" aria-label="데이터 검토 내역">
<div className="flex items-center bg-[#eaf3fb] px-3 py-2.5">
<h2 className="text-[1.3rem] font-medium text-label">데이터 검토 내역</h2>
</div>
{visibleValidationTypes.map((fileType) => {
const status = fileValidationStatuses[fileType]!;
const result = validationResults[fileType];
const messages = fileValidationMessages[fileType] ?? [];
const guide = VALIDATION_CHECK_GUIDES[fileType];
const countText = fileType === "EXMN_EXCEL"
? `관로 ${result?.summary.pplCount ?? 0}건 · 맨홀 ${result?.summary.mnhlCount ?? 0}건`
: fileType === "PPL_EXMN_PDF"
? `Excel 관로 ${result?.summary.pplCount ?? 0}건 기준`
: "";
return (
<div
key={fileType}
className="grid items-start gap-3 border-t border-border px-3 py-2.5 text-[1.3rem] text-label"
style={{ gridTemplateColumns: "180px 88px minmax(0, 1fr)" }}
>
<span className="font-medium text-gray-600">
{EXMN_FILE_RULES[fileType].label}
</span>
<span className={`inline-flex w-fit rounded-full px-2.5 py-1 text-xs font-semibold ${
status === "success"
? "bg-emerald-100 text-emerald-700"
: "bg-red-100 text-red-700"
}`}>
{status === "success" ? "검토 완료" : "검토 실패"}
</span>
<div className="min-w-0">
{status === "success" && countText && (
<p className="mb-1 font-medium text-label">{countText}</p>
)}
<p className="leading-5 text-label">
{guide?.checkedItems.join(" · ")}
</p>
{guide?.pendingNote && (
<p className="mt-1 text-amber-700">{guide.pendingNote}</p>
)}
{status === "error" && messages.length > 0 && (
<p className="mt-1 truncate text-red-700" title={messages.join("\n")}>
{messages[0]}
{messages.length > 1 ? ` 외 ${messages.length - 1}건` : ""}
</p>
)}
</div>
</div>
);
})}
</section>
)}
{uploadStatus && (
<p className="text-right text-[1.3rem] text-blue-700">{uploadStatus}</p>
)}
</section>
</main>
{uploadStatus && (
<p className="text-right text-[1.3rem] text-primary">{uploadStatus}</p>
)}
</StandardPopupLayout>
);
}
@@ -0,0 +1,143 @@
"use client";
import { useMemo, type ReactNode } from "react";
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
} from "@tanstack/react-table";
import {
StandardDataTable,
getStandardRowNumber,
type ScrollMode,
type UiHeadProps,
} from "@common/business_components";
type ExmnStandardDataTableProps<T extends Record<string, unknown>> = {
rows: T[];
headlist: UiHeadProps[];
title: ReactNode;
total: number;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
actions?: ReactNode;
loading?: boolean;
scrollMode?: ScrollMode;
minTableWidth?: string;
clickableHeadIds?: string[];
onCellClick?: (row: T, rowIndex: number, headId: string) => void;
onRowClick?: (row: T, rowIndex: number) => void;
rowId?: (row: T, rowIndex: number) => string;
};
const ROW_NUMBER_IDS = new Set(["no", "rowNo", "rowNumber"]);
const EXMN_TABLE_HEADER_HEIGHT_REM = 5.6;
const EXMN_TABLE_HEADER_CLASS = "[&_thead_th]:!h-[5.6rem]";
const formatValue = (value: unknown) => {
if (value === null || value === undefined || value === "") return "-";
return typeof value === "number"
? value.toLocaleString("ko-KR")
: String(value);
};
/** 조사관리 표를 CS 목록 규격과 역순 순번 계약으로 연결한다. */
export function ExmnStandardDataTable<T extends Record<string, unknown>>({
rows,
headlist,
title,
total,
page,
pageSize,
onPageChange,
actions,
loading = false,
scrollMode = "none",
minTableWidth = "100%",
clickableHeadIds = [],
onCellClick,
onRowClick,
rowId,
}: ExmnStandardDataTableProps<T>) {
const columns = useMemo<ColumnDef<T>[]>(
() =>
headlist.map((head) => ({
id: head.id,
accessorFn: (row) => row[head.id],
header: ROW_NUMBER_IDS.has(head.id) ? "순번" : head.title,
cell: ({ row, getValue }) => {
const value = ROW_NUMBER_IDS.has(head.id)
? getStandardRowNumber({
total,
rowIndex: row.index,
pageIndex: page - 1,
pageSize,
})
: formatValue(getValue());
const clickable =
Boolean(onCellClick) && clickableHeadIds.includes(head.id);
return clickable ? (
<button
type="button"
className="hover:text-primary/80 w-full cursor-pointer text-primary underline underline-offset-4"
onClick={(event) => {
event.stopPropagation();
onCellClick?.(row.original, row.index, head.id);
}}
>
{value}
</button>
) : (
value
);
},
meta: {
align:
head.align === "left" || head.align === "right"
? head.align
: "center",
},
})),
[clickableHeadIds, headlist, onCellClick, page, pageSize, total],
);
const pagination: PaginationState = {
pageIndex: Math.max(page - 1, 0),
pageSize,
};
const table = useReactTable({
data: rows,
columns,
state: { pagination },
rowCount: total,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getRowId: (row, index) => rowId?.(row, index) ?? String(index),
onPaginationChange: (updater) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
if (next.pageIndex !== pagination.pageIndex) {
onPageChange(next.pageIndex + 1);
}
},
});
return (
<StandardDataTable
table={table}
title={title}
actions={actions}
loading={loading}
className={EXMN_TABLE_HEADER_CLASS}
scrollMode={scrollMode}
minTableWidth={minTableWidth}
onRowClick={
onRowClick ? (row) => onRowClick(row, rows.indexOf(row)) : undefined
}
/>
);
}
export { EXMN_TABLE_HEADER_CLASS, EXMN_TABLE_HEADER_HEIGHT_REM };
export type { ExmnStandardDataTableProps };
@@ -1,5 +1,6 @@
"use client";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import {
Button,
@@ -10,8 +11,6 @@ import {
TableHeader,
TableRow,
} from "@common/components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
import { useEffect, useMemo, useRef, useState } from "react";
import {
downloadExmnSectionSummary,
@@ -23,38 +22,29 @@ import type {
ExmnSectionSummaryRow,
} from "../model/exmn-business-types";
import type { ExmnDetailDefectType } from "../model/exmn-detail-table";
import { EXMN_TABLE_HEADER_HEIGHT_REM } from "./ExmnStandardDataTable";
const ACTION_BUTTON_CLASS =
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
const DOWNLOAD_BUTTON_CLASS =
"rounded-sm px-4 py-2 text-sm font-medium !text-white disabled:!text-white";
// 목록 그리드와 같이 헤더 1행 + 본문 6행만 보이게 해 다음 행 일부가 노출되지 않게 한다.
// 최초 화면의 본문 6행은 합계 1행 + 데이터 5행이며, 스크롤 후에는 데이터 6행이 온전히 보인다.
// 상세 본문 행 높이는 유지하되 세로 이동은 CS 목록과 같이 브라우저 기본 스크롤에 맡긴다.
const SUMMARY_GRID_ROW_HEIGHT_REM = 3.2;
const SUMMARY_GRID_VISIBLE_BODY_ROW_COUNT = 6;
const SUMMARY_GRID_MAX_HEIGHT = `${
SUMMARY_GRID_ROW_HEIGHT_REM * (SUMMARY_GRID_VISIBLE_BODY_ROW_COUNT + 1)
}rem`;
const PIPE_SUMMARY_TABLE_HEAD = [
{ id: "rowNumber", title: "NO", widthPercent: 6, sortable: true },
{ id: "trtdstNm", title: "처리분구", widthPercent: 18, sortable: true },
{ id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13, sortable: true },
{ id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13, sortable: true },
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 15, sortable: true },
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 14, sortable: true },
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 14, sortable: true },
{ id: "etcCountText", title: "기타", widthPercent: 7, sortable: true },
{ id: "rowNumber", title: "순번", widthPercent: 6 },
{ id: "trtdstNm", title: "처리분구", widthPercent: 18 },
{ id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13 },
{ id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13 },
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 15 },
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 14 },
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 14 },
{ id: "etcCountText", title: "기타", widthPercent: 7 },
];
const MANHOLE_SUMMARY_TABLE_HEAD = [
{ id: "rowNumber", title: "NO", widthPercent: 8, sortable: true },
{ id: "trtdstNm", title: "처리분구", widthPercent: 28, sortable: true },
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 18, sortable: true },
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 17, sortable: true },
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 17, sortable: true },
{ id: "etcCountText", title: "기타", widthPercent: 12, sortable: true },
{ id: "rowNumber", title: "순번", widthPercent: 8 },
{ id: "trtdstNm", title: "처리분구", widthPercent: 28 },
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 18 },
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 17 },
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 17 },
{ id: "etcCountText", title: "기타", widthPercent: 12 },
];
type Notice = { type: "info" | "error"; text: string } | null;
@@ -78,34 +68,6 @@ const saveBlob = (blob: Blob, fileName: string) => {
URL.revokeObjectURL(objectUrl);
};
const getSummarySortValue = (row: DisplaySummaryRow, key: string) => {
const value = row[key];
if (key !== "trtdstNm") {
const numericValue = Number(String(value ?? "").replaceAll(",", ""));
if (Number.isFinite(numericValue)) return numericValue;
}
return String(value ?? "");
};
/** 합계 행을 제외한 처리분구별 상세 행만 현재 정렬 상태에 맞춰 정렬한다. */
const sortSummaryRows = (
rows: DisplaySummaryRow[],
sort: UiTableSort | undefined,
) => {
if (!sort) return rows;
return [...rows].sort((left, right) => {
const leftValue = getSummarySortValue(left, sort.key);
const rightValue = getSummarySortValue(right, sort.key);
const comparison = typeof leftValue === "number" && typeof rightValue === "number"
? leftValue - rightValue
: String(leftValue).localeCompare(String(rightValue), "ko", {
numeric: true,
sensitivity: "base",
});
return sort.direction === "desc" ? comparison * -1 : comparison;
});
};
interface DisplaySummaryRow extends Record<string, string | number> {
trtdstId: string;
rowNumber: string | number;
@@ -119,9 +81,10 @@ interface DisplaySummaryRow extends Record<string, string | number> {
}
/** 동일 조사현황 화면에서 선택 사업의 관로·맨홀 처리분구 집계를 펼쳐 표시한다. */
export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps) {
export function ExmnStatusDetailPanel({
business,
}: ExmnStatusDetailPanelProps) {
const [jobType, setJobType] = useState<ExmnJobType>("PPL");
const [summarySort, setSummarySort] = useState<UiTableSort>();
const [summaryRows, setSummaryRows] = useState<ExmnSectionSummaryRow[]>([]);
/** 상세보기 팝업에 전달할 현재 선택 처리분구다. */
const [selectedTrtdstId, setSelectedTrtdstId] = useState<string | null>(null);
@@ -132,13 +95,11 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
useEffect(() => {
setJobType("PPL");
setSummarySort(undefined);
setSelectedTrtdstId(null);
setNotice(null);
}, [business?.exmnBizId]);
useEffect(() => {
setSummarySort(undefined);
setSelectedTrtdstId(null);
}, [jobType]);
@@ -161,47 +122,65 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
.catch((error: unknown) => {
if (summaryRequestIdRef.current !== requestId) return;
setSummaryRows([]);
setNotice({ type: "error", text: error instanceof Error ? error.message : "조사 상세현황 조회 중 오류가 발생했습니다." });
setNotice({
type: "error",
text:
error instanceof Error
? error.message
: "조사 상세현황 조회 중 오류가 발생했습니다.",
});
})
.finally(() => {
if (summaryRequestIdRef.current === requestId) setIsLoading(false);
});
return () => {
if (summaryRequestIdRef.current === requestId) summaryRequestIdRef.current += 1;
if (summaryRequestIdRef.current === requestId)
summaryRequestIdRef.current += 1;
};
}, [business, jobType]);
const summaryTable = useMemo(() => {
const getCounts = (row: ExmnSectionSummaryRow) => jobType === "PPL"
? {
whole: row.pplExmnWholAnmlyArtclCnt,
structural: row.pplExmnStrctDfcCnt,
operational: row.pplExmnOperDfcCnt,
etc: row.pplExmnEtcCnt,
}
: {
whole: row.mnhlExmnWholAnmlyArtclCnt,
structural: row.mnhlExmnStrctDfcCnt,
operational: row.mnhlExmnOperDfcCnt,
etc: row.mnhlExmnEtcCnt,
};
const totals = summaryRows.reduce((sum, row) => {
const counts = getCounts(row);
return {
trgtPrlg: sum.trgtPrlg + row.trgtPrlg,
drvngDstnc: sum.drvngDstnc + row.drvngDstnc,
whole: sum.whole + counts.whole,
structural: sum.structural + counts.structural,
operational: sum.operational + counts.operational,
etc: sum.etc + counts.etc,
};
}, { trgtPrlg: 0, drvngDstnc: 0, whole: 0, structural: 0, operational: 0, etc: 0 });
const getCounts = (row: ExmnSectionSummaryRow) =>
jobType === "PPL"
? {
whole: row.pplExmnWholAnmlyArtclCnt,
structural: row.pplExmnStrctDfcCnt,
operational: row.pplExmnOperDfcCnt,
etc: row.pplExmnEtcCnt,
}
: {
whole: row.mnhlExmnWholAnmlyArtclCnt,
structural: row.mnhlExmnStrctDfcCnt,
operational: row.mnhlExmnOperDfcCnt,
etc: row.mnhlExmnEtcCnt,
};
const totals = summaryRows.reduce(
(sum, row) => {
const counts = getCounts(row);
return {
trgtPrlg: sum.trgtPrlg + row.trgtPrlg,
drvngDstnc: sum.drvngDstnc + row.drvngDstnc,
whole: sum.whole + counts.whole,
structural: sum.structural + counts.structural,
operational: sum.operational + counts.operational,
etc: sum.etc + counts.etc,
};
},
{
trgtPrlg: 0,
drvngDstnc: 0,
whole: 0,
structural: 0,
operational: 0,
etc: 0,
},
);
const rows: DisplaySummaryRow[] = summaryRows.map((row, index) => {
const counts = getCounts(row);
return {
trtdstId: row.trtdstId,
rowNumber: index + 1,
rowNumber: summaryRows.length - index,
trtdstNm: row.trtdstNm,
trgtPrlgText: formatNumber(row.trgtPrlg),
drvngDstncText: formatNumber(row.drvngDstnc),
@@ -212,7 +191,7 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
};
});
return {
rows: sortSummaryRows(rows, summarySort),
rows,
totals: {
trgtPrlgText: formatNumber(totals.trgtPrlg),
drvngDstncText: formatNumber(totals.drvngDstnc),
@@ -222,24 +201,12 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
etcCountText: formatNumber(totals.etc),
} as Record<string, string>,
};
}, [jobType, summaryRows, summarySort]);
}, [jobType, summaryRows]);
const summaryColumns = jobType === "PPL" ? PIPE_SUMMARY_TABLE_HEAD : MANHOLE_SUMMARY_TABLE_HEAD;
const selectedSummaryRow = summaryTable.rows.find(
(row) => row.trtdstId === selectedTrtdstId,
) ?? null;
/** 상세현황 헤더를 오름차순·내림차순·기본 순서로 순환한다. */
const handleSummarySort = (column: (typeof summaryColumns)[number]) => {
const key = column.id;
setSummarySort((current) => (
current?.key !== key
? { key, direction: "asc" }
: current.direction === "asc"
? { key, direction: "desc" }
: undefined
));
};
const summaryColumns =
jobType === "PPL" ? PIPE_SUMMARY_TABLE_HEAD : MANHOLE_SUMMARY_TABLE_HEAD;
const selectedSummaryRow =
summaryTable.rows.find((row) => row.trtdstId === selectedTrtdstId) ?? null;
/** 선택 사업·처리분구와 현재 조사유형을 하수관망도 초기 조회 계약으로 전달한다. */
const openExmnMap = () => {
@@ -252,7 +219,8 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
trtdstId: selectedSummaryRow.trtdstId,
trtdstNm: selectedSummaryRow.trtdstNm,
});
if (business.bizCycl != null) query.set("bizCycl", String(business.bizCycl));
if (business.bizCycl != null)
query.set("bizCycl", String(business.bizCycl));
window.location.assign(`/map?${query.toString()}`);
};
@@ -260,7 +228,10 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
const handleSummaryDownload = async () => {
if (!business || summaryTable.rows.length === 0 || isDownloading) {
if (summaryTable.rows.length === 0) {
setNotice({ type: "info", text: "다운로드할 조사 상세현황 데이터가 없습니다." });
setNotice({
type: "info",
text: "다운로드할 조사 상세현황 데이터가 없습니다.",
});
}
return;
}
@@ -269,16 +240,23 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
setNotice(null);
try {
const jobTypeLabel = jobType === "PPL" ? "관로조사" : "맨홀조사";
const fileName = `${business.bizNm}_${jobTypeLabel}_조사상세현황.xlsx`
.replace(/[\\/:*?"<>|]/g, "_");
const blob = await downloadExmnSectionSummary(business.exmnBizId, jobType);
const fileName =
`${business.bizNm}_${jobTypeLabel}_조사상세현황.xlsx`.replace(
/[\\/:*?"<>|]/g,
"_",
);
const blob = await downloadExmnSectionSummary(
business.exmnBizId,
jobType,
);
saveBlob(blob, fileName);
} catch (error) {
setNotice({
type: "error",
text: error instanceof Error
? error.message
: "조사 상세현황 다운로드 중 오류가 발생했습니다.",
text:
error instanceof Error
? error.message
: "조사 상세현황 다운로드 중 오류가 발생했습니다.",
});
} finally {
setIsDownloading(false);
@@ -331,7 +309,8 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
if (!business) return null;
return (
<ListDiv className="mt-4 flex min-h-0 flex-1 flex-col overflow-hidden">
<section className="card card-border mt-4 !p-0">
<LoadingOverlay isLoading={isLoading} />
<div className="card-header flex min-h-[5.2rem] flex-wrap items-center gap-3">
<h3 className="mr-3 text-2xl font-medium">조사 상세현황</h3>
<div
@@ -340,18 +319,21 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
aria-busy={isLoading}
className="flex items-center gap-1"
>
{([
{ value: "PPL", label: "관로조사" },
{ value: "MNHL", label: "맨홀조사" },
] as const).map((tab) => (
{(
[
{ value: "PPL", label: "관로조사" },
{ value: "MNHL", label: "맨홀조사" },
] as const
).map((tab) => (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={jobType === tab.value}
className={`inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-md border text-[1.4rem] font-semibold transition-colors ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${jobType === tab.value
? "border-primary bg-primary text-white"
: "border-border bg-background text-muted-foreground hover:bg-accent"
className={`inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-md border text-[1.4rem] font-semibold ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
jobType === tab.value
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-muted-foreground hover:bg-accent"
}`}
style={{ width: "8rem", height: "3.2rem" }}
onClick={() => handleJobTypeChange(tab.value)}
@@ -362,17 +344,28 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
</div>
<div className="ml-auto flex items-center gap-2">
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
color="primary"
disabled={
isLoading || isDownloading || summaryTable.rows.length === 0
}
onClick={() => void handleSummaryDownload()}
>
{isDownloading ? "엑셀다운로드 중..." : "엑셀다운로드"}
</Button>
<Button
color="white"
disabled={!selectedSummaryRow}
title={selectedSummaryRow ? `${selectedSummaryRow.trtdstNm} 상세보기` : "처리분구 행을 먼저 선택해주세요."}
title={
selectedSummaryRow
? `${selectedSummaryRow.trtdstNm} 상세보기`
: "처리분구 행을 먼저 선택해주세요."
}
onClick={() => openSurveyDetailPopup()}
>
상세보기
</Button>
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
color="white"
disabled={!selectedSummaryRow}
title={
selectedSummaryRow
@@ -383,108 +376,57 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
>
지도보기
</Button>
<Button
color="green"
className={DOWNLOAD_BUTTON_CLASS}
size="sm"
disabled={isLoading || isDownloading || summaryTable.rows.length === 0}
onClick={() => void handleSummaryDownload()}
>
{isDownloading ? "다운로드 중..." : "다운로드"}
</Button>
</div>
</div>
{notice && (
<div role="alert" className={`mx-4 mt-3 rounded border px-4 py-3 text-sm ${notice.type === "error" ? "border-red-200 bg-red-50 text-red-700" : "border-blue-200 bg-blue-50 text-blue-700"}`}>{notice.text}</div>
<div
role="alert"
className={`mx-4 mt-3 rounded border px-4 py-3 text-sm ${notice.type === "error" ? "border-red-200 bg-red-50 text-red-700" : "border-primary bg-primary-transparent text-primary"}`}
>
{notice.text}
</div>
)}
<div className="flex min-h-0 flex-1 flex-col pb-0 pt-3">
<div className="mx-[1.2rem] mb-2 flex items-center gap-2">
<h4 className="text-lg font-semibold text-gray-700">{jobType === "PPL" ? "관로조사" : "맨홀조사"} 종합집계표</h4>
{isLoading && <span role="status" className="text-sm text-muted-foreground">조회 중...</span>}
</div>
<div className="relative mx-[1.2rem] mb-[1.2rem] min-h-0 overflow-hidden">
<div
className="overflow-x-hidden overflow-y-auto"
style={{ maxHeight: SUMMARY_GRID_MAX_HEIGHT, scrollbarGutter: "stable" }}
>
<div className="pb-0 pt-3">
<div className="relative mx-[1.2rem] mb-[1.2rem] min-w-0 overflow-hidden">
<div className="overflow-x-auto overflow-y-visible">
<div
className="card !m-0"
style={{ paddingTop: 0, paddingBottom: 0 }}
>
<Table variant="secondary" className="table-fixed">
<Table variant="default" className="table-fixed">
<colgroup>
{summaryColumns.map((column) => (
<col key={column.id} style={{ width: `${column.widthPercent}%` }} />
<col
key={column.id}
style={{ width: `${column.widthPercent}%` }}
/>
))}
</colgroup>
<TableHeader
className="font-bold"
style={{ position: "static", zIndex: "auto" }}
>
<TableRow style={{ height: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem` }}>
{summaryColumns.map((column) => {
const direction = summarySort?.key === column.id
? summarySort.direction
: undefined;
return (
<TableHead
key={column.id}
className="sticky top-0 z-20 bg-[#eaf3fb] text-center !py-[0.8rem]"
style={{
borderTopWidth: 0,
boxShadow: "inset 0 1px 0 var(--border)",
}}
aria-sort={
direction === "asc"
? "ascending"
: direction === "desc"
? "descending"
: "none"
}
>
<button
type="button"
className="inline-flex w-full items-center justify-center gap-1.5"
aria-label={`${column.title} 정렬`}
onClick={() => handleSummarySort(column)}
>
<span>{column.title}</span>
{direction === "asc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
aria-hidden="true"
>
▲
</span>
) : direction === "desc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
aria-hidden="true"
>
▼
</span>
) : (
<span
className="inline-flex flex-col items-center gap-px text-[6px] leading-none text-[#475569]"
aria-hidden="true"
>
<span>▲</span>
<span>▼</span>
</span>
)}
</button>
</TableHead>
);
})}
<TableRow
style={{ height: `${EXMN_TABLE_HEADER_HEIGHT_REM}rem` }}
>
{summaryColumns.map((column) => (
<TableHead
key={column.id}
className="sticky top-0 z-20 !py-[1.2rem] text-center"
style={{ height: `${EXMN_TABLE_HEADER_HEIGHT_REM}rem` }}
>
{column.title}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="text-center">
{summaryTable.rows.length > 0 ? (
<>
<TableRow
className="text-gray-700"
className="text-label"
style={{
height: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem`,
backgroundColor: "#d2deef",
@@ -514,7 +456,9 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
key={row.trtdstId}
data-state={selected ? "selected" : undefined}
className="cursor-pointer"
style={{ height: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem` }}
style={{
height: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem`,
}}
onClick={() => handleSummaryRowSelect(row)}
>
{summaryColumns.map((column) => {
@@ -526,8 +470,11 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
: null;
const defectCount = defectType
? Number(
String(row[column.id] ?? "0").replaceAll(",", ""),
)
String(row[column.id] ?? "0").replaceAll(
",",
"",
),
)
: 0;
const opensFilteredDetail =
defectType !== null && defectCount > 0;
@@ -546,17 +493,21 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
type="button"
className={`flex w-full cursor-pointer items-center justify-center px-2 py-[0.8rem] text-[1.3rem] ${
opensFilteredDetail
? "font-semibold text-blue-600 underline underline-offset-4 hover:text-blue-800"
? "hover:text-primary/80 font-semibold text-primary underline underline-offset-4"
: selected
? "font-bold"
: "font-normal"
}`}
style={{ minHeight: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem` }}
style={{
minHeight: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem`,
}}
aria-label={
opensFilteredDetail
? `${row.trtdstNm} ${
defectType === "STRUCTURAL" ? "구조적" : "운영적"
} 결함 상세보기`
defectType === "STRUCTURAL"
? "구조적"
: "운영적"
} 결함 상세보기`
: undefined
}
title={
@@ -584,9 +535,9 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
) : (
<TableRow>
<TableCell
colSpan={summaryColumns.length}
className="h-24 text-center text-gray-500"
style={{ borderBottomWidth: 0 }}
colSpan={summaryColumns.length}
className="h-24 text-center text-muted-foreground"
style={{ borderBottomWidth: 0 }}
>
조회된 결과가 없습니다.
</TableCell>
@@ -596,18 +547,8 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
</Table>
</div>
</div>
<span
aria-hidden="true"
className="pointer-events-none absolute z-30"
style={{
right: "calc(0.6rem + 17px)",
bottom: 0,
left: "0.6rem",
borderTop: "1px solid var(--border)",
}}
/>
</div>
</div>
</ListDiv>
</section>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,16 @@
"use client";
import { Button } from "@common/components/ui";
import { UiModal, UiTable } from "@common/business_components/ui";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@common/components/ui";
import { useEffect, useMemo, useState } from "react";
import type { PplFacility } from "../model/exmn-business-types";
import { ExmnStandardDataTable } from "./ExmnStandardDataTable";
const UNIT_TABLE_HEAD = [
{ id: "unitSeq", title: "단위", widthPercent: 8 },
@@ -51,91 +58,152 @@ export function PplFacilityDetailModal({
setSelectedUnitSeq(facility?.unitSections?.[0]?.unitSeq ?? null);
}, [facility, open]);
const unitRows = useMemo(() => (facility?.unitSections ?? []).map((unit) => ({
...unit,
detailCount: unit.details?.length ?? 0,
})), [facility]);
const unitRows = useMemo(
() =>
(facility?.unitSections ?? []).map((unit) => ({
...unit,
detailCount: unit.details?.length ?? 0,
})),
[facility],
);
const selectedUnit = useMemo(
() => facility?.unitSections?.find((unit) => unit.unitSeq === selectedUnitSeq) ?? null,
() =>
facility?.unitSections?.find(
(unit) => unit.unitSeq === selectedUnitSeq,
) ?? null,
[facility, selectedUnitSeq],
);
const detailRows = useMemo(() => (selectedUnit?.details ?? []).map((detail) => ({
...detail,
typeText: detail.type ?? "-",
severityText: detail.severity ?? "-",
occurrencePositionText: detail.occurrencePosition ?? "-",
continuousText: detail.continuous == null ? "-" : detail.continuous ? "Y" : "N",
structuralScoreText: detail.structuralScore ?? "-",
operationalScoreText: detail.operationalScore ?? "-",
statusGroupText: detail.statusGroup ?? "-",
})), [selectedUnit]);
const detailRows = useMemo(
() =>
(selectedUnit?.details ?? []).map((detail) => ({
...detail,
typeText: detail.type ?? "-",
severityText: detail.severity ?? "-",
occurrencePositionText: detail.occurrencePosition ?? "-",
continuousText:
detail.continuous == null ? "-" : detail.continuous ? "Y" : "N",
structuralScoreText: detail.structuralScore ?? "-",
operationalScoreText: detail.operationalScore ?? "-",
statusGroupText: detail.statusGroup ?? "-",
})),
[selectedUnit],
);
return (
<UiModal
open={open}
onOpenChange={onOpenChange}
bodyClass="max-h-[94vh] max-w-[96vw] overflow-y-auto"
headerTitle="PPL 조사시설 상세"
headerTitleClass="text-xl font-semibold text-gray-800"
footerContent={<div className="flex w-full justify-end"><Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button></div>}
body={(
<div className="space-y-5 py-2">
<section className="grid grid-cols-4 gap-x-5 gap-y-3 rounded border bg-gray-50 p-4 text-sm">
<div><strong>보고서번호</strong><p className="mt-1 text-gray-600">{facility?.rptpNo || "-"}</p></div>
<div><strong>GIS 관로관리번호</strong><p className="mt-1 text-gray-600">{facility?.exmnFcltyGisMngNo || "-"}</p></div>
<div><strong>처리분구</strong><p className="mt-1 text-gray-600">{facility?.trtdstNm || "-"}</p></div>
<div><strong>상류/하류 맨홀</strong><p className="mt-1 text-gray-600">{facility?.upstmMnhlNo || "-"} / {facility?.dnstmMnhlNo || "-"}</p></div>
<div><strong>배수방식</strong><p className="mt-1 text-gray-600">{facility?.drgptnCd || "-"}</p></div>
<div><strong>관종/규격</strong><p className="mt-1 text-gray-600">{facility?.pptyCd || "-"} / {facility?.spcfctCd || "-"}</p></div>
<div><strong>연장/주행거리</strong><p className="mt-1 text-gray-600">{facility?.prlg ?? "-"} / {facility?.drvngDstnc ?? "-"}</p></div>
<div><strong>구조/운영 등급</strong><p className="mt-1 text-gray-600">{facility?.strctDfcGrdCd || "-"} / {facility?.operDfcGrdCd || "-"}</p></div>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[94vh] w-[96vw] !max-w-[96vw] flex-col overflow-hidden !p-0">
<DialogHeader className="card-header shrink-0 !space-y-0 px-5">
<DialogTitle className="text-[1.8rem] font-semibold text-label">
PPL 조사시설 상세
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto p-4">
<section className="grid grid-cols-1 gap-x-5 gap-y-3 rounded border bg-secondary p-4 text-sm md:grid-cols-3">
<div>
<strong>보고서번호</strong>
<p className="mt-1 text-label">{facility?.rptpNo || "-"}</p>
</div>
<div>
<strong>GIS 관로관리번호</strong>
<p className="mt-1 text-label">
{facility?.exmnFcltyGisMngNo || "-"}
</p>
</div>
<div>
<strong>처리분구</strong>
<p className="mt-1 text-label">{facility?.trtdstNm || "-"}</p>
</div>
<div>
<strong>상류/하류 맨홀</strong>
<p className="mt-1 text-label">
{facility?.upstmMnhlNo || "-"} / {facility?.dnstmMnhlNo || "-"}
</p>
</div>
<div>
<strong>배수방식</strong>
<p className="mt-1 text-label">{facility?.drgptnCd || "-"}</p>
</div>
<div>
<strong>관종/규격</strong>
<p className="mt-1 text-label">
{facility?.pptyCd || "-"} / {facility?.spcfctCd || "-"}
</p>
</div>
<div>
<strong>연장/주행거리</strong>
<p className="mt-1 text-label">
{facility?.prlg ?? "-"} / {facility?.drvngDstnc ?? "-"}
</p>
</div>
<div>
<strong>구조/운영 등급</strong>
<p className="mt-1 text-label">
{facility?.strctDfcGrdCd || "-"} /{" "}
{facility?.operDfcGrdCd || "-"}
</p>
</div>
</section>
<section>
<h4 className="mb-2 font-semibold text-gray-700">단위 구간</h4>
<UiTable
headName=""
<ExmnStandardDataTable
rows={
unitRows as Array<
(typeof unitRows)[number] & Record<string, unknown>
>
}
title="단위 구간 목록"
headlist={UNIT_TABLE_HEAD}
tableData={unitRows}
total={unitRows.length}
pageSize={Math.max(unitRows.length, 1)}
curPage={1}
widthFixed
index={unitRows.findIndex((unit) => unit.unitSeq === selectedUnitSeq)}
cellClick={(index) => setSelectedUnitSeq(index == null ? null : unitRows[index]?.unitSeq ?? null)}
page={1}
onRowClick={(row) => setSelectedUnitSeq(row.unitSeq)}
onPageChange={() => undefined}
/>
</section>
<section className="rounded border bg-gray-50 p-3 text-sm text-gray-600">
<strong>조사중단사유:</strong> {selectedUnit?.surveyStopReason || "없음"}
<span className="mx-4 text-gray-300">|</span>
<section className="rounded border bg-secondary p-3 text-sm text-label">
<strong>조사중단사유:</strong>{" "}
{selectedUnit?.surveyStopReason || "없음"}
<span className="mx-4 text-muted-foreground">|</span>
<strong>비고:</strong> {selectedUnit?.remark || "없음"}
</section>
<section>
<h4 className="mb-2 font-semibold text-gray-700">상태·결함 상세 기록</h4>
<UiTable
headName=""
<ExmnStandardDataTable
rows={
detailRows as Array<
(typeof detailRows)[number] & Record<string, unknown>
>
}
title="상태·결함 상세 기록 목록"
headlist={DETAIL_TABLE_HEAD}
tableData={detailRows}
total={detailRows.length}
pageSize={Math.max(detailRows.length, 1)}
curPage={1}
widthFixed
cellClick={undefined}
page={1}
onPageChange={() => undefined}
scrollMode="horizontal"
minTableWidth="140rem"
/>
</section>
<section className="flex items-center justify-between rounded border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<span>시설 보고서 파일 매핑 원천이 확정되기 전까지 보고서 조회는 제공하지 않습니다.</span>
<Button disabled title="보고서 매핑 모델 확정 필요">보고서 보기</Button>
<span>
시설 보고서 파일 매핑 원천이 확정되기 전까지 보고서 조회는
제공하지 않습니다.
</span>
<Button disabled title="보고서 매핑 모델 확정 필요">
보고서 보기
</Button>
</section>
</div>
)}
/>
<DialogFooter className="card-footer shrink-0">
<Button color="white" onClick={() => onOpenChange(false)}>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+351 -149
View File
@@ -1,9 +1,15 @@
"use client";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { Button, Input } from "@common/components/ui";
import { UiModal, UiTable } from "@common/business_components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
} from "@common/components/ui";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
downloadPplExport,
@@ -18,29 +24,63 @@ import type {
PplSummaryRow,
} from "../model/exmn-business-types";
import { PplFacilityDetailModal } from "./PplFacilityDetailModal";
const ACTION_BUTTON_CLASS =
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
import { ExmnStandardDataTable } from "./ExmnStandardDataTable";
const SUMMARY_TABLE_HEAD = [
{ id: "trtdstNm", title: "처리분구", widthPercent: 20, sortable: true },
{ id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13, sortable: true, sortKey: "trgtPrlg" },
{ id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13, sortable: true, sortKey: "drvngDstnc" },
{ id: "pplExmnWholAnmlyArtclCnt", title: "PPL 전체이상", widthPercent: 14, sortable: true },
{ id: "pplExmnStrctDfcCnt", title: "구조결함", widthPercent: 13, sortable: true },
{ id: "pplExmnOperDfcCnt", title: "운영결함", widthPercent: 13, sortable: true },
{ id: "pplExmnEtcCnt", title: "기타", widthPercent: 14, sortable: true },
{ id: "trtdstNm", title: "처리분구", widthPercent: 20 },
{
id: "trgtPrlgText",
title: "대상연장(m)",
widthPercent: 13,
},
{
id: "drvngDstncText",
title: "주행거리(m)",
widthPercent: 13,
},
{
id: "pplExmnWholAnmlyArtclCnt",
title: "PPL 전체이상",
widthPercent: 14,
},
{
id: "pplExmnStrctDfcCnt",
title: "구조결함",
widthPercent: 13,
},
{
id: "pplExmnOperDfcCnt",
title: "운영결함",
widthPercent: 13,
},
{ id: "pplExmnEtcCnt", title: "기타", widthPercent: 14 },
];
const FACILITY_TABLE_HEAD = [
{ id: "trtdstNm", title: "처리분구", widthPercent: 13, sortable: true },
{ id: "rptpNo", title: "보고서번호", widthPercent: 12, sortable: true },
{ id: "exmnFcltyGisMngNo", title: "GIS 관로관리번호", widthPercent: 18, sortable: true },
{ id: "trtdstNm", title: "처리분구", widthPercent: 13 },
{ id: "rptpNo", title: "보고서번호", widthPercent: 12 },
{
id: "exmnFcltyGisMngNo",
title: "GIS 관로관리번호",
widthPercent: 18,
},
{ id: "upstreamDownstream", title: "상류/하류 맨홀", widthPercent: 17 },
{ id: "pipeSpec", title: "관종/규격", widthPercent: 13 },
{ id: "prlgText", title: "연장(m)", widthPercent: 9, sortable: true, sortKey: "prlg" },
{ id: "strctDfcGrdCdText", title: "구조등급", widthPercent: 9, sortable: true, sortKey: "strctDfcGrdCd" },
{ id: "operDfcGrdCdText", title: "운영등급", widthPercent: 9, sortable: true, sortKey: "operDfcGrdCd" },
{
id: "prlgText",
title: "연장(m)",
widthPercent: 9,
},
{
id: "strctDfcGrdCdText",
title: "구조등급",
widthPercent: 9,
},
{
id: "operDfcGrdCdText",
title: "운영등급",
widthPercent: 9,
},
];
const SUMMARY_CLICKABLE_HEAD_IDS = [
@@ -53,6 +93,24 @@ const SUMMARY_CLICKABLE_HEAD_IDS = [
type Notice = { type: "success" | "error"; text: string } | null;
type PplDefectType = "ALL" | "STRUCTURAL" | "OPERATIONAL" | "ETC";
type PplFacilityFilters = {
trtdstId: string;
sectionType: string;
gisManagementNo: string;
structuralGrade: string;
operationalGrade: string;
defectType: PplDefectType;
};
const DEFAULT_PPL_FACILITY_FILTERS: PplFacilityFilters = {
trtdstId: "",
sectionType: "",
gisManagementNo: "",
structuralGrade: "",
operationalGrade: "",
defectType: "ALL",
};
interface PplSummaryModalProps {
open: boolean;
business: ExmnBusiness | null;
@@ -74,16 +132,19 @@ const saveBlob = (blob: Blob, fileName: string) => {
};
/** 처리분구별 PPL 집계에서 결함 수를 따라 시설 목록과 단위 상세까지 탐색하는 모달이다. */
export function PplSummaryModal({ open, business, onOpenChange }: PplSummaryModalProps) {
const [businessDetail, setBusinessDetail] = useState<ExmnBusinessDetail | null>(null);
export function PplSummaryModal({
open,
business,
onOpenChange,
}: PplSummaryModalProps) {
const [businessDetail, setBusinessDetail] =
useState<ExmnBusinessDetail | null>(null);
const [summaryRows, setSummaryRows] = useState<PplSummaryRow[]>([]);
const [summaryTotal, setSummaryTotal] = useState(0);
const [summaryPage, setSummaryPage] = useState(0);
const [summarySort, setSummarySort] = useState<UiTableSort>();
const [facilityRows, setFacilityRows] = useState<PplFacility[]>([]);
const [facilityTotal, setFacilityTotal] = useState(0);
const [facilityPage, setFacilityPage] = useState(0);
const [facilitySort, setFacilitySort] = useState<UiTableSort>();
// 집계 건수 클릭과 상세 조회조건이 공유하는 처리분구·결함유형 컨텍스트다.
const [trtdstId, setTrtdstId] = useState("");
const [sectionType, setSectionType] = useState("");
@@ -91,60 +152,91 @@ export function PplSummaryModal({ open, business, onOpenChange }: PplSummaryModa
const [structuralGrade, setStructuralGrade] = useState("");
const [operationalGrade, setOperationalGrade] = useState("");
const [defectType, setDefectType] = useState<PplDefectType>("ALL");
const [selectedFacility, setSelectedFacility] = useState<PplFacility | null>(null);
const [selectedFacility, setSelectedFacility] = useState<PplFacility | null>(
null,
);
const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
const displayedSummaryRows = useMemo(() => summaryRows.map((row) => ({
...row,
trgtPrlgText: formatNumber(row.trgtPrlg),
drvngDstncText: formatNumber(row.drvngDstnc),
})), [summaryRows]);
const displayedSummaryRows = useMemo(
() =>
summaryRows.map((row) => ({
...row,
trgtPrlgText: formatNumber(row.trgtPrlg),
drvngDstncText: formatNumber(row.drvngDstnc),
})),
[summaryRows],
);
const displayedFacilityRows = useMemo(() => facilityRows.map((row) => ({
...row,
upstreamDownstream: `${row.upstmMnhlNo || "-"} / ${row.dnstmMnhlNo || "-"}`,
pipeSpec: `${row.pptyCd || "-"} / ${row.spcfctCd || "-"}`,
prlgText: row.prlg == null ? "-" : formatNumber(row.prlg),
strctDfcGrdCdText: row.strctDfcGrdCd || "-",
operDfcGrdCdText: row.operDfcGrdCd || "-",
})), [facilityRows]);
const displayedFacilityRows = useMemo(
() =>
facilityRows.map((row) => ({
...row,
upstreamDownstream: `${row.upstmMnhlNo || "-"} / ${row.dnstmMnhlNo || "-"}`,
pipeSpec: `${row.pptyCd || "-"} / ${row.spcfctCd || "-"}`,
prlgText: row.prlg == null ? "-" : formatNumber(row.prlg),
strctDfcGrdCdText: row.strctDfcGrdCd || "-",
operDfcGrdCdText: row.operDfcGrdCd || "-",
})),
[facilityRows],
);
/** 현재 상세 필터로 PPL 시설 페이지를 요청한다. */
const fetchFacilities = useCallback(async (
nextPage = facilityPage,
nextSort = facilitySort,
) => {
if (!business?.exmnBizId) return;
setIsLoading(true);
setNotice(null);
try {
const page = await getPplFacilities({
exmnBizId: business.exmnBizId,
trtdstId: trtdstId || undefined,
sectionType: sectionType || undefined,
gisManagementNo: gisManagementNo || undefined,
structuralGrade: structuralGrade || undefined,
operationalGrade: operationalGrade || undefined,
const fetchFacilities = useCallback(
async (
nextPage = facilityPage,
filters: PplFacilityFilters = {
trtdstId,
sectionType,
gisManagementNo,
structuralGrade,
operationalGrade,
defectType,
page: nextPage,
size: 10,
sort: nextSort ? `${nextSort.key},${nextSort.direction}` : undefined,
});
setFacilityRows(page.content);
setFacilityTotal(page.totalElements);
setFacilityPage(page.number);
} catch (error) {
setFacilityRows([]);
setFacilityTotal(0);
setNotice({
type: "error",
text: error instanceof Error ? error.message : "PPL 시설 조회 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
}
}, [business, defectType, facilityPage, facilitySort, gisManagementNo, operationalGrade, sectionType, structuralGrade, trtdstId]);
},
) => {
if (!business?.exmnBizId) return;
setIsLoading(true);
setNotice(null);
try {
const page = await getPplFacilities({
exmnBizId: business.exmnBizId,
trtdstId: filters.trtdstId || undefined,
sectionType: filters.sectionType || undefined,
gisManagementNo: filters.gisManagementNo || undefined,
structuralGrade: filters.structuralGrade || undefined,
operationalGrade: filters.operationalGrade || undefined,
defectType: filters.defectType,
page: nextPage,
size: 10,
});
setFacilityRows(page.content);
setFacilityTotal(page.totalElements);
setFacilityPage(page.number);
} catch (error) {
setFacilityRows([]);
setFacilityTotal(0);
setNotice({
type: "error",
text:
error instanceof Error
? error.message
: "PPL 시설 조회 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
}
},
[
business,
defectType,
facilityPage,
gisManagementNo,
operationalGrade,
sectionType,
structuralGrade,
trtdstId,
],
);
/** 모달 진입과 집계 정렬·페이지 변경 시 사업 상세와 PPL 집계를 읽는다. */
useEffect(() => {
@@ -158,7 +250,6 @@ export function PplSummaryModal({ open, business, onOpenChange }: PplSummaryModa
trtdstId: trtdstId || undefined,
page: summaryPage,
size: 10,
sort: summarySort ? `${summarySort.key},${summarySort.direction}` : undefined,
}),
])
.then(([detail, page]) => {
@@ -173,7 +264,10 @@ export function PplSummaryModal({ open, business, onOpenChange }: PplSummaryModa
setSummaryTotal(0);
setNotice({
type: "error",
text: error instanceof Error ? error.message : "PPL 집계 조회 중 오류가 발생했습니다.",
text:
error instanceof Error
? error.message
: "PPL 집계 조회 중 오류가 발생했습니다.",
});
})
.finally(() => {
@@ -182,17 +276,15 @@ export function PplSummaryModal({ open, business, onOpenChange }: PplSummaryModa
return () => {
active = false;
};
}, [business, open, summaryPage, summarySort, trtdstId]);
}, [business, open, summaryPage, trtdstId]);
/** 새 사업을 열 때 상세 필터·페이지·선택 시설을 초기화한다. */
useEffect(() => {
if (!open) return;
setSummaryPage(0);
setSummarySort(undefined);
setFacilityRows([]);
setFacilityTotal(0);
setFacilityPage(0);
setFacilitySort(undefined);
setTrtdstId("");
setSectionType("");
setGisManagementNo("");
@@ -203,17 +295,21 @@ export function PplSummaryModal({ open, business, onOpenChange }: PplSummaryModa
}, [business, open]);
/** 집계의 전체·구조·운영·기타 건수를 클릭하면 해당 처리분구 시설 조회로 연결한다. */
const handleSummaryCellClick = (index: number | undefined, headId: string) => {
const handleSummaryCellClick = (
index: number | undefined,
headId: string,
) => {
if (index == null) return;
const row = summaryRows[index];
if (!row) return;
const nextDefectType: PplDefectType = headId === "pplExmnStrctDfcCnt"
? "STRUCTURAL"
: headId === "pplExmnOperDfcCnt"
? "OPERATIONAL"
: headId === "pplExmnEtcCnt"
? "ETC"
: "ALL";
const nextDefectType: PplDefectType =
headId === "pplExmnStrctDfcCnt"
? "STRUCTURAL"
: headId === "pplExmnOperDfcCnt"
? "OPERATIONAL"
: headId === "pplExmnEtcCnt"
? "ETC"
: "ALL";
setTrtdstId(row.trtdstId);
setDefectType(nextDefectType);
setFacilityPage(0);
@@ -239,7 +335,13 @@ export function PplSummaryModal({ open, business, onOpenChange }: PplSummaryModa
.catch((error: unknown) => {
setFacilityRows([]);
setFacilityTotal(0);
setNotice({ type: "error", text: error instanceof Error ? error.message : "PPL 시설 조회 중 오류가 발생했습니다." });
setNotice({
type: "error",
text:
error instanceof Error
? error.message
: "PPL 시설 조회 중 오류가 발생했습니다.",
});
})
.finally(() => setIsLoading(false));
};
@@ -250,9 +352,18 @@ export function PplSummaryModal({ open, business, onOpenChange }: PplSummaryModa
setIsLoading(true);
setNotice(null);
try {
saveBlob(await downloadPplExport(business.exmnBizId, trtdstId || undefined), `${business.bizNm}_PPL집계.xlsx`);
saveBlob(
await downloadPplExport(business.exmnBizId, trtdstId || undefined),
`${business.bizNm}_PPL집계.xlsx`,
);
} catch (error) {
setNotice({ type: "error", text: error instanceof Error ? error.message : "PPL 엑셀 다운로드 중 오류가 발생했습니다." });
setNotice({
type: "error",
text:
error instanceof Error
? error.message
: "PPL 엑셀 다운로드 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
}
@@ -260,107 +371,198 @@ export function PplSummaryModal({ open, business, onOpenChange }: PplSummaryModa
return (
<>
<UiModal
open={open}
onOpenChange={onOpenChange}
bodyClass="max-h-[94vh] max-w-[96vw] overflow-y-auto"
headerTitle="PPL 조사 집계·상세"
headerTitleClass="text-xl font-semibold text-gray-800"
footerContent={<div className="flex w-full justify-end"><Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button></div>}
body={(
<div className="relative space-y-5 py-2">
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[94vh] w-[96vw] !max-w-[96vw] flex-col overflow-hidden !p-0">
<DialogHeader className="card-header shrink-0 !space-y-0 px-5">
<DialogTitle className="text-[1.8rem] font-semibold text-label">
PPL 조사 집계·상세
</DialogTitle>
</DialogHeader>
<div className="relative min-h-0 flex-1 space-y-5 overflow-y-auto p-4">
<LoadingOverlay isLoading={isLoading} />
{notice && (
<div role="alert" className={`rounded border px-4 py-3 text-sm ${notice.type === "error" ? "border-red-200 bg-red-50 text-red-700" : "border-emerald-200 bg-emerald-50 text-emerald-700"}`}>{notice.text}</div>
<div
role="alert"
className={`rounded border px-4 py-3 text-sm ${notice.type === "error" ? "border-red-200 bg-red-50 text-red-700" : "border-emerald-200 bg-emerald-50 text-emerald-700"}`}
>
{notice.text}
</div>
)}
<div className="rounded border bg-gray-50 px-4 py-3 text-sm text-gray-600">
<div className="rounded border bg-secondary px-4 py-3 text-sm text-label">
<strong>{business?.bizNm ?? "선택 사업"}</strong>
<span className="mx-3 text-gray-300">|</span>{business?.bizYr}년 {business?.bizCycl ?? "-"}차
<span className="mx-3 text-gray-300">|</span>{business?.ctpvNm} {business?.sggNm}
<span className="mx-3 text-muted-foreground">|</span>
{business?.bizYr}년 {business?.bizCycl ?? "-"}차
<span className="mx-3 text-muted-foreground">|</span>
{business?.ctpvNm} {business?.sggNm}
</div>
<section>
<div className="mb-2 flex items-center justify-between">
<h4 className="font-semibold text-gray-700">처리분구별 PPL 집계</h4>
<Button className={ACTION_BUTTON_CLASS} disabled={isLoading} onClick={() => void handleExport()}>엑셀 다운로드</Button>
</div>
<UiTable
headName=""
<ExmnStandardDataTable
rows={
displayedSummaryRows as Array<
(typeof displayedSummaryRows)[number] &
Record<string, unknown>
>
}
title="처리분구별 PPL 집계 목록"
headlist={SUMMARY_TABLE_HEAD}
tableData={displayedSummaryRows}
total={summaryTotal}
pageSize={10}
curPage={summaryPage + 1}
widthFixed
page={summaryPage + 1}
clickableHeadIds={SUMMARY_CLICKABLE_HEAD_IDS}
cellClassName={(_, headId) => SUMMARY_CLICKABLE_HEAD_IDS.includes(headId) ? "text-blue-600 underline" : ""}
cellClick={handleSummaryCellClick}
onCellClick={(_row, index, headId) =>
handleSummaryCellClick(index, headId)
}
onPageChange={(page) => setSummaryPage(page - 1)}
sort={summarySort}
onSortChange={(sort) => {
setSummarySort(sort);
setSummaryPage(0);
}}
actions={
<Button
disabled={isLoading}
onClick={() => void handleExport()}
>
엑셀다운로드
</Button>
}
/>
</section>
<section className="rounded border border-gray-200 p-4">
<div className="grid grid-cols-6 gap-3">
<label className="text-sm font-medium text-gray-600">처리분구
<select className="mt-1 h-10 w-full rounded border bg-white px-2" value={trtdstId} onChange={(event) => setTrtdstId(event.target.value)}>
<section className="rounded border border-border p-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<label className="text-sm font-medium text-label">
처리분구
<select
className="mt-1 h-10 w-full rounded border bg-background px-2"
value={trtdstId}
onChange={(event) => setTrtdstId(event.target.value)}
>
<option value="">전체</option>
{(businessDetail?.districts ?? []).map((district) => <option key={district.trtdstId} value={district.trtdstId}>{district.trtdstNm}</option>)}
{(businessDetail?.districts ?? []).map((district) => (
<option key={district.trtdstId} value={district.trtdstId}>
{district.trtdstNm}
</option>
))}
</select>
</label>
<label className="text-sm font-medium text-gray-600">구간 구분
<select className="mt-1 h-10 w-full rounded border bg-white px-2" value={sectionType} onChange={(event) => setSectionType(event.target.value)}>
<option value="">전체</option><option value="PIPE">관로</option><option value="UNIT">단위구간</option>
<label className="text-sm font-medium text-label">
구간 구분
<select
className="mt-1 h-10 w-full rounded border bg-background px-2"
value={sectionType}
onChange={(event) => setSectionType(event.target.value)}
>
<option value="">전체</option>
<option value="PIPE">관로</option>
<option value="UNIT">단위구간</option>
</select>
</label>
<label className="col-span-2 text-sm font-medium text-gray-600">GIS 관로관리번호
<Input className="mt-1 h-10 text-base" value={gisManagementNo} onChange={(event) => setGisManagementNo(event.target.value)} />
<label className="col-span-2 text-sm font-medium text-label">
GIS 관로관리번호
<Input
className="mt-1 h-10 text-base"
value={gisManagementNo}
onChange={(event) => setGisManagementNo(event.target.value)}
/>
</label>
<label className="text-sm font-medium text-gray-600">구조등급
<select className="mt-1 h-10 w-full rounded border bg-white px-2" value={structuralGrade} onChange={(event) => setStructuralGrade(event.target.value)}>
<option value="">전체</option>{[1, 2, 3, 4, 5].map((grade) => <option key={grade} value={grade}>{grade}등급</option>)}
<label className="text-sm font-medium text-label">
구조등급
<select
className="mt-1 h-10 w-full rounded border bg-background px-2"
value={structuralGrade}
onChange={(event) => setStructuralGrade(event.target.value)}
>
<option value="">전체</option>
{[1, 2, 3, 4, 5].map((grade) => (
<option key={grade} value={grade}>
{grade}등급
</option>
))}
</select>
</label>
<label className="text-sm font-medium text-gray-600">운영등급
<select className="mt-1 h-10 w-full rounded border bg-white px-2" value={operationalGrade} onChange={(event) => setOperationalGrade(event.target.value)}>
<option value="">전체</option>{[1, 2, 3, 4, 5].map((grade) => <option key={grade} value={grade}>{grade}등급</option>)}
<label className="text-sm font-medium text-label">
운영등급
<select
className="mt-1 h-10 w-full rounded border bg-background px-2"
value={operationalGrade}
onChange={(event) =>
setOperationalGrade(event.target.value)
}
>
<option value="">전체</option>
{[1, 2, 3, 4, 5].map((grade) => (
<option key={grade} value={grade}>
{grade}등급
</option>
))}
</select>
</label>
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-sm text-gray-500">결함 분류: {defectType}</span>
<Button className={ACTION_BUTTON_CLASS} disabled={isLoading} onClick={() => void fetchFacilities(0)}>상세 조회</Button>
<span className="text-sm text-muted-foreground">
결함 분류: {defectType}
</span>
<Button
disabled={isLoading}
onClick={() => void fetchFacilities(0)}
>
조회
</Button>
<Button
color="white"
disabled={isLoading}
onClick={() => {
setTrtdstId(DEFAULT_PPL_FACILITY_FILTERS.trtdstId);
setSectionType(DEFAULT_PPL_FACILITY_FILTERS.sectionType);
setGisManagementNo(
DEFAULT_PPL_FACILITY_FILTERS.gisManagementNo,
);
setStructuralGrade(
DEFAULT_PPL_FACILITY_FILTERS.structuralGrade,
);
setOperationalGrade(
DEFAULT_PPL_FACILITY_FILTERS.operationalGrade,
);
setDefectType(DEFAULT_PPL_FACILITY_FILTERS.defectType);
setFacilityPage(0);
setSelectedFacility(null);
void fetchFacilities(0, DEFAULT_PPL_FACILITY_FILTERS);
}}
>
초기화
</Button>
</div>
</section>
<section>
<h4 className="mb-2 font-semibold text-gray-700">PPL 시설 목록</h4>
<UiTable
headName=""
<ExmnStandardDataTable
rows={
displayedFacilityRows as Array<
(typeof displayedFacilityRows)[number] &
Record<string, unknown>
>
}
title="PPL 시설 목록"
headlist={FACILITY_TABLE_HEAD}
tableData={displayedFacilityRows}
total={facilityTotal}
pageSize={10}
curPage={facilityPage + 1}
widthFixed
cellClick={(index) => setSelectedFacility(index == null ? null : facilityRows[index] ?? null)}
page={facilityPage + 1}
onRowClick={(_row, index) =>
setSelectedFacility(facilityRows[index] ?? null)
}
onPageChange={(page) => void fetchFacilities(page - 1)}
sort={facilitySort}
onSortChange={(sort) => {
setFacilitySort(sort);
setFacilityPage(0);
void fetchFacilities(0, sort);
}}
scrollMode="horizontal"
minTableWidth="120rem"
/>
<p className="mt-2 text-right text-sm text-gray-500">시설 행을 선택하면 단위 구간과 상태·결함 상세가 열립니다.</p>
<p className="mt-2 text-right text-sm text-muted-foreground">
시설 행을 선택하면 단위 구간과 상태·결함 상세가 열립니다.
</p>
</section>
</div>
)}
/>
<DialogFooter className="card-footer shrink-0">
<Button color="white" onClick={() => onOpenChange(false)}>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PplFacilityDetailModal
open={Boolean(selectedFacility)}
facility={selectedFacility}
@@ -2,47 +2,56 @@
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import { Button, Input } from "@common/components/ui";
import {
SearchDiv,
SearchFormLeft,
SearchFormRight,
SelectBox,
UiTable,
} from "@common/business_components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
STANDARD_ROW_NUMBER_HEADER,
StandardDataTable,
getStandardRowNumber,
} from "@common/business_components";
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import {
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
} from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
getTrtdstReferences,
getTrtdstSettings,
saveTrtdstSettings,
} from "../api/trtdst-api";
import { buildTrtdstBatchItems, validateTrtdstRows } from "../lib/trtdst-validation";
import {
buildTrtdstBatchItems,
validateTrtdstRows,
} from "../lib/trtdst-validation";
import {
TRTDST_ROW_STATUS_LABEL,
type TrtdstSettingDto,
type TrtdstTableRow,
} from "../model/trtdst-types";
/** 처리분구 목록 표에 표시할 공통 열 정의다. */
/** 처리분구 목록 표에 표시할 업무 비종속 열 정의다. */
const TRTDST_TABLE_HEAD = [
{ id: "rowNumber", title: "NO", widthPercent: 8, sortable: true },
{ id: "ctpvNm", title: "시도", widthPercent: 22, sortable: true },
{ id: "sggNm", title: "시군구", widthPercent: 22, sortable: true },
{ id: "trtdstNm", title: "처리분구명", widthPercent: 48, sortable: true },
];
{ id: "rowNumber", title: STANDARD_ROW_NUMBER_HEADER, width: 80 },
{ id: "ctpvNm", title: "시도", width: 220 },
{ id: "sggNm", title: "시군구", width: 220 },
{ id: "trtdstNm", title: "처리분구명", width: 480 },
] as const;
/** 시도·시군구 선택박스에서 공통으로 사용하는 전체 옵션이다. */
const ALL_OPTION = { val: "0", text: "전체" };
/** 처리분구 한글·숫자 값을 화면 목록에서 자연스럽게 비교하는 정렬 기준이다. */
const TRTDST_COLLATOR = new Intl.Collator("ko", { numeric: true, sensitivity: "base" });
/** 처리분구 화면의 주요 동작 버튼에 공통으로 적용하는 시각 규칙이다. */
const PRIMARY_ACTION_BUTTON_CLASS =
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
/** 화면 상단에 노출하는 성공·오류 메시지 상태다. */
type Notice = { type: "success" | "error"; text: string } | null;
@@ -61,48 +70,60 @@ const createLocalKey = () =>
? `client:${crypto.randomUUID()}`
: `client:${Date.now()}-${Math.random().toString(36).slice(2)}`;
/** 서버 처리분구 목록을 UiTable과 변경 추적에 필요한 행 구조로 변환한다. */
const toTableRows = (items: TrtdstSettingDto[]): TrtdstTableRow[] =>
items.map((item, index) => ({
...item,
trtdstId: String(item.trtdstId),
ctpvNm: String(item.ctpvNm ?? "").trim(),
sggNm: String(item.sggNm ?? "").trim(),
trtdstNm: String(item.trtdstNm ?? "").trim(),
useYn: item.useYn ?? "Y",
localKey: `server:${item.trtdstId}`,
rowNumber: index + 1,
rowStatus: "clean",
rowStatusLabel: TRTDST_ROW_STATUS_LABEL.clean,
originalTrtdstNm: String(item.trtdstNm ?? "").trim(),
/** 화면의 현재 행 순서를 전체 건수에서 시작하는 공통 내림차순 순번으로 정규화한다. */
const withDescendingRowNumbers = (rows: TrtdstTableRow[]): TrtdstTableRow[] =>
rows.map((row, index) => ({
...row,
rowNumber: getStandardRowNumber({ total: rows.length, rowIndex: index }),
}));
/** 서버 처리분구 목록을 공통 표와 변경 추적에 필요한 행 구조로 변환한다. */
const toTableRows = (items: TrtdstSettingDto[]): TrtdstTableRow[] =>
withDescendingRowNumbers(
items.map((item) => ({
...item,
trtdstId: String(item.trtdstId),
ctpvNm: String(item.ctpvNm ?? "").trim(),
sggNm: String(item.sggNm ?? "").trim(),
trtdstNm: String(item.trtdstNm ?? "").trim(),
useYn: item.useYn ?? "Y",
localKey: `server:${item.trtdstId}`,
rowNumber: 0,
rowStatus: "clean",
rowStatusLabel: TRTDST_ROW_STATUS_LABEL.clean,
originalTrtdstNm: String(item.trtdstNm ?? "").trim(),
})),
);
/** 저장 성공 직후 삭제 행을 제거하고 추가·수정 행을 서버 반영 상태로 정규화한다. */
const toSavedTableRows = (
rows: TrtdstTableRow[],
savedItems: TrtdstSettingDto[] = [],
): TrtdstTableRow[] =>
rows
.filter((row) => row.rowStatus !== "deletePending")
.map((row, index) => {
const saved = savedItems.find((item) =>
row.trtdstId
? String(item.trtdstId) === row.trtdstId
: String(item.ctpvNm).trim() === row.ctpvNm
&& String(item.sggNm).trim() === row.sggNm
&& String(item.trtdstNm).trim() === row.trtdstNm.trim());
const trtdstNm = String(saved?.trtdstNm ?? row.trtdstNm).trim();
return {
...row,
...saved,
trtdstId: String(saved?.trtdstId ?? row.trtdstId),
trtdstNm,
rowNumber: index + 1,
rowStatus: "clean",
rowStatusLabel: TRTDST_ROW_STATUS_LABEL.clean,
originalTrtdstNm: trtdstNm,
};
});
withDescendingRowNumbers(
rows
.filter((row) => row.rowStatus !== "deletePending")
.map((row) => {
const saved = savedItems.find((item) =>
row.trtdstId
? String(item.trtdstId) === row.trtdstId
: String(item.ctpvNm).trim() === row.ctpvNm &&
String(item.sggNm).trim() === row.sggNm &&
String(item.trtdstNm).trim() === row.trtdstNm.trim(),
);
const trtdstNm = String(saved?.trtdstNm ?? row.trtdstNm).trim();
return {
...row,
...saved,
trtdstId: String(saved?.trtdstId ?? row.trtdstId),
trtdstNm,
rowNumber: 0,
rowStatus: "clean",
rowStatusLabel: TRTDST_ROW_STATUS_LABEL.clean,
originalTrtdstNm: trtdstNm,
};
}),
);
/** 알 수 없는 API 오류에서도 서버 메시지 또는 안전한 기본 문구를 반환한다. */
const getErrorMessage = (error: unknown, fallback: string) =>
@@ -111,15 +132,18 @@ const getErrorMessage = (error: unknown, fallback: string) =>
/** 사업등록 후보로 사용할 처리분구 기준정보를 직접 관리하는 FE-0 화면이다. */
export function TrtdstManagementPage() {
const { ctpv, sgg } = useStore();
const { confirm } = useConfirm();
// 조회와 추가 대상 행정구역 코드 상태다. 이름은 공통 코드 목록에서 해석해 API에 전달한다.
const [ctpvCd, setCtpvCd] = useState("0");
const [sggCd, setSggCd] = useState("0");
// 서버 조회 결과와 사용자의 미저장 변경을 함께 보관한다.
const [rows, setRows] = useState<TrtdstTableRow[]>([]);
// 공통 UiTable 헤더의 asc·desc·기본 순환 상태를 현재 조회 결과에 적용한다.
const [sort, setSort] = useState<UiTableSort>();
// UiTable 선택 행은 저장·재조회 중에도 로컬 키 기준으로 명확히 추적한다.
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
// DataTable 선택 행은 저장·재조회 중에도 로컬 키 기준으로 명확히 추적한다.
const [selectedRowKey, setSelectedRowKey] = useState<string | null>(null);
// 조회·삭제 참조 확인·저장 요청 중 화면의 중복 조작을 막는다.
const [isLoading, setIsLoading] = useState(false);
@@ -128,21 +152,27 @@ export function TrtdstManagementPage() {
// 수정 버튼으로 편집을 시작한 기존 행을 로컬 키로 추적한다.
const [editingRowKey, setEditingRowKey] = useState<string | null>(null);
// 선택박스의 임시 값과 분리해 저장 후에도 마지막으로 실행한 조회조건을 재사용한다.
const [appliedRegion, setAppliedRegion] = useState<AppliedRegion>(EMPTY_APPLIED_REGION);
const [appliedRegion, setAppliedRegion] =
useState<AppliedRegion>(EMPTY_APPLIED_REGION);
// 개발 모드의 Effect 재실행에서도 최초 전체 조회를 한 번만 요청하기 위해 state 대신 ref로 기록한다.
const initialLoadRequestedRef = useRef(false);
/** 현재 시도에 속한 시군구만 선택 후보로 제공한다. */
const availableSgg = useMemo(() => {
if (ctpvCd === "0") return sgg;
return sgg.filter((option) => String(option.val).startsWith(ctpvCd.slice(0, 2)));
return sgg.filter((option) =>
String(option.val).startsWith(ctpvCd.slice(0, 2)),
);
}, [ctpvCd, sgg]);
/** 현재 선택 코드에 해당하는 행정구역 이름을 API 계약용으로 해석한다. */
const selectedRegion = useMemo(() => ({
ctpvNm: ctpv.find((option) => String(option.val) === ctpvCd)?.text ?? "",
sggNm: sgg.find((option) => String(option.val) === sggCd)?.text ?? "",
}), [ctpv, ctpvCd, sgg, sggCd]);
const selectedRegion = useMemo(
() => ({
ctpvNm: ctpv.find((option) => String(option.val) === ctpvCd)?.text ?? "",
sggNm: sgg.find((option) => String(option.val) === sggCd)?.text ?? "",
}),
[ctpv, ctpvCd, sgg, sggCd],
);
/** 선택된 로컬 키와 일치하는 화면 행을 반환한다. */
const selectedRow = useMemo(
@@ -150,28 +180,6 @@ export function TrtdstManagementPage() {
[rows, selectedRowKey],
);
/** 백엔드 정렬 계약 전까지 현재 조회된 전체 처리분구 목록을 공통 UiTable 상태대로 정렬한다. */
const displayedRows = useMemo(() => {
if (!sort) return rows;
return [...rows].sort((left, right) => {
const leftValue = left[sort.key as keyof TrtdstTableRow];
const rightValue = right[sort.key as keyof TrtdstTableRow];
const comparison = sort.key === "rowNumber"
? Number(leftValue ?? 0) - Number(rightValue ?? 0)
: TRTDST_COLLATOR.compare(String(leftValue ?? ""), String(rightValue ?? ""));
if (comparison === 0) return left.rowNumber - right.rowNumber;
return sort.direction === "asc" ? comparison : -comparison;
});
}, [rows, sort]);
/** UiTable의 제어 선택 인덱스를 로컬 키에서 계산한다. */
const selectedRowIndex = useMemo(
() => displayedRows.findIndex((row) => row.localKey === selectedRowKey),
[displayedRows, selectedRowKey],
);
/** 미저장 추가·수정·삭제대기가 있는지 계산한다. */
const hasChanges = useMemo(
() => rows.some((row) => row.rowStatus !== "clean"),
@@ -191,13 +199,17 @@ export function TrtdstManagementPage() {
setRows(toTableRows(items));
setSelectedRowKey(null);
setEditingRowKey(null);
setPagination((current) => ({ ...current, pageIndex: 0 }));
return true;
} catch (error) {
setSelectedRowKey(null);
setEditingRowKey(null);
setNotice({
type: "error",
text: getErrorMessage(error, "처리분구 목록 조회 중 오류가 발생했습니다."),
text: getErrorMessage(
error,
"처리분구 목록 조회 중 오류가 발생했습니다.",
),
});
return false;
} finally {
@@ -214,7 +226,12 @@ export function TrtdstManagementPage() {
/** 조회 전 미저장 변경 유실 여부를 확인한 뒤 목록을 다시 읽는다. */
const handleSearch = async () => {
if (hasChanges && !window.confirm("저장하지 않은 변경사항이 있습니다. 버리고 다시 조회하시겠습니까?")) {
if (
hasChanges &&
!(await confirm(
"저장하지 않은 변경사항이 있습니다. 버리고 다시 조회하시겠습니까?",
))
) {
return;
}
const nextRegion: AppliedRegion = {
@@ -226,6 +243,23 @@ export function TrtdstManagementPage() {
}
};
/** CS 검색영역의 초기화 동작으로 지역과 페이지를 전체 조회 기준으로 복원한다. */
const handleReset = async () => {
if (
hasChanges &&
!(await confirm(
"저장하지 않은 변경사항이 있습니다. 버리고 초기화하시겠습니까?",
))
) {
return;
}
setCtpvCd("0");
setSggCd("0");
if (await fetchRows(EMPTY_APPLIED_REGION)) {
setAppliedRegion(EMPTY_APPLIED_REGION);
}
};
/** 선택한 처리분구의 명칭 셀을 그리드 안에서 편집할 수 있게 전환한다. */
const handleEdit = () => {
setNotice(null);
@@ -234,7 +268,10 @@ export function TrtdstManagementPage() {
return;
}
if (selectedRow.rowStatus === "deletePending") {
setNotice({ type: "error", text: "삭제대기 행은 삭제취소 후 수정할 수 있습니다." });
setNotice({
type: "error",
text: "삭제대기 행은 삭제취소 후 수정할 수 있습니다.",
});
return;
}
setEditingRowKey(selectedRow.localKey);
@@ -244,26 +281,31 @@ export function TrtdstManagementPage() {
const handleAdd = () => {
setNotice(null);
if (!selectedRegion.ctpvNm || !selectedRegion.sggNm) {
setNotice({ type: "error", text: "추가할 시도와 시군구를 먼저 선택해주세요." });
setNotice({
type: "error",
text: "추가할 시도와 시군구를 먼저 선택해주세요.",
});
return;
}
const localKey = createLocalKey();
setRows((current) => [
...current,
{
trtdstId: "",
ctpvNm: selectedRegion.ctpvNm,
sggNm: selectedRegion.sggNm,
trtdstNm: "",
useYn: "Y",
localKey,
rowNumber: current.length + 1,
rowStatus: "added",
rowStatusLabel: TRTDST_ROW_STATUS_LABEL.added,
originalTrtdstNm: "",
},
]);
setRows((current) =>
withDescendingRowNumbers([
...current,
{
trtdstId: "",
ctpvNm: selectedRegion.ctpvNm,
sggNm: selectedRegion.sggNm,
trtdstNm: "",
useYn: "Y",
localKey,
rowNumber: 0,
rowStatus: "added",
rowStatusLabel: TRTDST_ROW_STATUS_LABEL.added,
originalTrtdstNm: "",
},
]),
);
setSelectedRowKey(localKey);
setEditingRowKey(null);
};
@@ -271,20 +313,23 @@ export function TrtdstManagementPage() {
/** 추가 또는 수정 중인 행의 처리분구명을 변경 추적 상태와 함께 갱신한다. */
const handleRowNameChange = (localKey: string, trtdstNm: string) => {
const nextName = trtdstNm.slice(0, 100);
setRows((current) => current.map((row) => {
if (row.localKey !== localKey) return row;
const rowStatus = row.rowStatus === "added"
? "added"
: nextName.trim() === row.originalTrtdstNm
? "clean"
: "modified";
return {
...row,
trtdstNm: nextName,
rowStatus,
rowStatusLabel: TRTDST_ROW_STATUS_LABEL[rowStatus],
};
}));
setRows((current) =>
current.map((row) => {
if (row.localKey !== localKey) return row;
const rowStatus =
row.rowStatus === "added"
? "added"
: nextName.trim() === row.originalTrtdstNm
? "clean"
: "modified";
return {
...row,
trtdstNm: nextName,
rowStatus,
rowStatusLabel: TRTDST_ROW_STATUS_LABEL[rowStatus],
};
}),
);
setNotice(null);
};
@@ -296,22 +341,32 @@ export function TrtdstManagementPage() {
return;
}
if (selectedRow.rowStatus === "added") {
setRows((current) => current
.filter((row) => row.localKey !== selectedRow.localKey)
.map((row, index) => ({ ...row, rowNumber: index + 1 })));
if (!(await confirm("선택한 처리분구를 삭제하시겠습니까?"))) return;
setRows((current) =>
withDescendingRowNumbers(
current.filter((row) => row.localKey !== selectedRow.localKey),
),
);
setSelectedRowKey(null);
setEditingRowKey(null);
return;
}
if (selectedRow.rowStatus === "deletePending") {
const restoredStatus = selectedRow.trtdstNm === selectedRow.originalTrtdstNm ? "clean" : "modified";
setRows((current) => current.map((row) => row.localKey === selectedRow.localKey
? {
...row,
rowStatus: restoredStatus,
rowStatusLabel: TRTDST_ROW_STATUS_LABEL[restoredStatus],
}
: row));
const restoredStatus =
selectedRow.trtdstNm === selectedRow.originalTrtdstNm
? "clean"
: "modified";
setRows((current) =>
current.map((row) =>
row.localKey === selectedRow.localKey
? {
...row,
rowStatus: restoredStatus,
rowStatusLabel: TRTDST_ROW_STATUS_LABEL[restoredStatus],
}
: row,
),
);
setEditingRowKey(null);
return;
}
@@ -321,7 +376,9 @@ export function TrtdstManagementPage() {
const references = await getTrtdstReferences(selectedRow.trtdstId);
const businessCount = Number(references?.businessCount ?? 0);
const facilityCount = Number(references?.facilityCount ?? 0);
const totalCount = Number(references?.totalCount ?? businessCount + facilityCount);
const totalCount = Number(
references?.totalCount ?? businessCount + facilityCount,
);
if (totalCount > 0) {
setNotice({
type: "error",
@@ -329,14 +386,26 @@ export function TrtdstManagementPage() {
});
return;
}
setRows((current) => current.map((row) => row.localKey === selectedRow.localKey
? { ...row, rowStatus: "deletePending", rowStatusLabel: TRTDST_ROW_STATUS_LABEL.deletePending }
: row));
if (!(await confirm("선택한 처리분구를 삭제하시겠습니까?"))) return;
setRows((current) =>
current.map((row) =>
row.localKey === selectedRow.localKey
? {
...row,
rowStatus: "deletePending",
rowStatusLabel: TRTDST_ROW_STATUS_LABEL.deletePending,
}
: row,
),
);
setEditingRowKey(null);
} catch (error) {
setNotice({
type: "error",
text: getErrorMessage(error, "처리분구 참조 여부 확인 중 오류가 발생했습니다."),
text: getErrorMessage(
error,
"처리분구 참조 여부 확인 중 오류가 발생했습니다.",
),
});
} finally {
setIsLoading(false);
@@ -356,6 +425,7 @@ export function TrtdstManagementPage() {
setNotice({ type: "error", text: "저장할 변경사항이 없습니다." });
return;
}
if (!(await confirm("변경사항을 저장하시겠습니까?"))) return;
setIsLoading(true);
try {
@@ -384,65 +454,145 @@ export function TrtdstManagementPage() {
} catch (error) {
setNotice({
type: "error",
text: getErrorMessage(error, "처리분구 일괄 저장 중 오류가 발생했습니다."),
text: getErrorMessage(
error,
"처리분구 일괄 저장 중 오류가 발생했습니다.",
),
});
} finally {
setIsLoading(false);
}
};
/** 인라인 편집과 변경 상태 표시를 유지한 채 공통 DataTable 열로 변환한다. */
const columns = useMemo<ColumnDef<TrtdstTableRow>[]>(
() =>
TRTDST_TABLE_HEAD.map((head) => ({
id: head.id,
accessorKey: head.id,
header: head.title,
cell: ({ row, getValue }) => {
const item = row.original;
const isNameEditable =
item.rowStatus === "added" || item.localKey === editingRowKey;
if (head.id === "trtdstNm" && isNameEditable) {
return (
<Input
aria-label={`${item.ctpvNm} ${item.sggNm} 처리분구명`}
value={item.trtdstNm}
maxLength={100}
autoFocus
className="h-9 bg-background text-center"
placeholder="처리분구명 입력"
onChange={(event) =>
handleRowNameChange(item.localKey, event.target.value)
}
/>
);
}
const statusClass =
item.rowStatus === "deletePending"
? "text-muted-foreground line-through"
: item.rowStatus === "added"
? "font-semibold text-primary"
: item.rowStatus === "modified"
? "font-semibold text-amber-700"
: "";
return (
<span className={statusClass}>{String(getValue() ?? "")}</span>
);
},
meta: { width: head.width, align: "center" as const },
})),
[editingRowKey],
);
const table = useReactTable({
data: rows,
columns,
state: {
pagination,
rowSelection: selectedRowKey ? { [selectedRowKey]: true } : {},
},
getRowId: (row) => row.localKey,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<>
<LoadingOverlay isLoading={isLoading} />
<SearchDiv>
<SearchFormLeft>
<div
style={{
gridColumn: "1 / -1",
display: "flex",
alignItems: "center",
columnGap: "2rem",
}}
>
<div style={{ width: "16rem", flex: "0 0 16rem" }}>
<SelectBox
label="시도"
labelClass="w-16 whitespace-nowrap text-xl text-gray-600"
className=""
selectClass="w-48 rounded-lg bg-white"
selectArray={[ALL_OPTION, ...ctpv]}
value={ctpvCd}
onSelectValue={(value) => {
setCtpvCd(String(value));
setSggCd("0");
}}
/>
</div>
<div style={{ width: "17rem", flex: "0 0 17rem" }}>
<SelectBox
label="시군구"
labelClass="w-20 whitespace-nowrap text-xl text-gray-600"
className=""
selectClass="w-48 rounded-lg bg-white"
selectArray={[ALL_OPTION, ...availableSgg]}
value={sggCd}
onSelectValue={(value) => setSggCd(String(value))}
/>
</div>
<div className="card card-border !mt-6">
<form
className="flex flex-wrap items-center gap-2 p-2"
onSubmit={(event) => {
event.preventDefault();
void handleSearch();
}}
>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">시도</Label>
<Select
value={ctpvCd}
onValueChange={(value) => {
setCtpvCd(String(value));
setSggCd("0");
}}
>
<SelectTrigger className="w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[ALL_OPTION, ...ctpv].map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</SearchFormLeft>
<SearchFormRight className="ml-auto flex shrink-0 items-center justify-end px-4">
<Button
className={PRIMARY_ACTION_BUTTON_CLASS}
style={{ transform: "translateX(0.6rem)" }}
size="sm"
disabled={isLoading}
onClick={() => void handleSearch()}
>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">시군구</Label>
<Select
value={sggCd}
onValueChange={(value) => setSggCd(String(value))}
>
<SelectTrigger className="w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[ALL_OPTION, ...availableSgg].map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit" className="w-[7.4rem]" disabled={isLoading}>
조회
</Button>
</SearchFormRight>
</SearchDiv>
<Button
type="button"
color="white"
className="w-[8.8rem]"
disabled={isLoading}
onClick={() => void handleReset()}
>
초기화
</Button>
</form>
</div>
{notice && (
<div
@@ -457,63 +607,54 @@ export function TrtdstManagementPage() {
</div>
)}
<ListDiv className="mt-4">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-medium">처리분구 목록 <span className="text-base text-gray-500">총 {rows.length.toLocaleString("ko-KR")}건</span></h3>
<div className="flex items-center gap-2">
<Button className={PRIMARY_ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={handleEdit}>수정</Button>
<Button className={PRIMARY_ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={handleAdd}>추가</Button>
<Button className={PRIMARY_ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleDelete()}>
{selectedRow?.rowStatus === "deletePending" ? "삭제취소" : "삭제"}
</Button>
<Button
className={PRIMARY_ACTION_BUTTON_CLASS}
size="sm"
disabled={isLoading || !hasChanges}
onClick={() => void handleSave()}
>
저장
</Button>
</div>
</div>
<UiTable<TrtdstTableRow>
headName=""
headlist={TRTDST_TABLE_HEAD}
tableData={displayedRows}
total={rows.length}
pageSize={Math.max(displayedRows.length, 1)}
curPage={1}
index={selectedRowIndex}
widthFixed
cellClick={(index) => setSelectedRowKey(index === undefined ? null : displayedRows[index]?.localKey ?? null)}
onPageChange={() => undefined}
sort={sort}
onSortChange={setSort}
renderCell={(row, headId) => {
const isNameEditable = row.rowStatus === "added" || row.localKey === editingRowKey;
if (headId !== "trtdstNm" || !isNameEditable) return undefined;
return (
<Input
aria-label={`${row.ctpvNm} ${row.sggNm} 처리분구명`}
value={row.trtdstNm}
maxLength={100}
autoFocus
className="h-9 bg-white text-center"
placeholder="처리분구명 입력"
onChange={(event) => handleRowNameChange(row.localKey, event.target.value)}
/>
);
}}
rowClassName={(row) => row.rowStatus === "deletePending"
? "!bg-red-50 text-gray-400 line-through"
: row.rowStatus === "added"
? "!bg-blue-50"
: row.rowStatus === "modified"
? "!bg-amber-50"
: ""}
<div className="mt-4 min-w-0">
<StandardDataTable
table={table}
title="처리분구 목록"
scrollMode="none"
onRowClick={(row) => setSelectedRowKey(row.localKey)}
actions={
<>
<Button
type="button"
color="primary"
disabled={isLoading}
onClick={handleAdd}
>
추가
</Button>
<Button
type="button"
color="primary"
disabled={isLoading}
onClick={handleEdit}
>
수정
</Button>
<Button
type="button"
color="primary"
disabled={isLoading || !hasChanges}
onClick={() => void handleSave()}
>
저장
</Button>
<Button
type="button"
color={
selectedRow?.rowStatus === "deletePending" ? "white" : "red"
}
disabled={isLoading}
onClick={() => void handleDelete()}
>
{selectedRow?.rowStatus === "deletePending"
? "삭제취소"
: "삭제"}
</Button>
</>
}
/>
</ListDiv>
</div>
</>
);
}
@@ -1,20 +1,25 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import moment from "moment";
import { SelectBox } from "@common/business_components/ui";
import {
StandardFormField,
StandardFormSection,
} from "@common/business_components";
import {
Button,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components/ui";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from "@common/components";
import { formatDate } from "../../common/util";
import {
@@ -37,6 +42,7 @@ import type {
} from "../model/obslt-ppl-types";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { useAlertStore } from "@/shared/lib";
import useStore from "@/store/useStore";
type FormKey = keyof ObsltPplBusinessSaveRequest;
@@ -47,6 +53,8 @@ const BIZ_SE_OPTIONS = [
{ text: "정밀조사", val: "정밀조사" },
{ text: "정비사업", val: "정비사업" },
];
const EMPTY_SELECT_VALUE = "__none__";
type FieldSpan = 1 | 2 | 3;
/** 등록·수정 표에서 필드 순서, 라벨, 입력 종류와 필수 여부를 한 곳에서 관리한다. */
const FIELDS: Array<{
@@ -57,7 +65,7 @@ const FIELDS: Array<{
placeholder?: string;
}> = [
{ key: "bizSe", label: "사업구분", required: true },
{ key: "prcisExmnCycl", label: "정밀조사차수" },
{ key: "prcisExmnCycl", label: "정밀조사 차수" },
{ key: "bizCd", label: "사업코드", required: true },
{ key: "envoNm", label: "환경청" },
{ key: "bizNm", label: "사업명", required: true },
@@ -70,11 +78,11 @@ const FIELDS: Array<{
{ key: "evdncDcmntSbmsnStts", label: "증빙서류 제출여부" },
{ key: "bgngYmd", label: "착수(착공) 예정일자", placeholder: "YYYY-MM-DD" },
{ key: "cmcnYmd", label: "준공(예정)일자", placeholder: "YYYY-MM-DD" },
{ key: "bizPrplDlyRsnCn", label: "사업추진 부진사유" },
{ key: "bizPrplDlyRsnCn", label: "사업추진 부진사유", type: "textarea" },
{ key: "bizPicNm", label: "담당자 이름" },
{ key: "bizPicCnpl", label: "담당자 연락처" },
{ key: "rmrkCn", label: "비고", },
{ key: "pbcpOpnnCn", label: "공단의견", },
{ key: "rmrkCn", label: "비고", type: "textarea" },
{ key: "pbcpOpnnCn", label: "공단 의견", type: "textarea" },
];
const NUMBER_KEYS = new Set<FormKey>([
@@ -144,6 +152,8 @@ const toRequest = (form: FormState): ObsltPplBusinessSaveRequest =>
export function ObsltPplFormPage({ id }: { id?: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const isModify = Boolean(id);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [form, setForm] = useState<FormState>(createEmptyForm);
@@ -157,15 +167,8 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
const listQueryString = searchParams.toString();
const listHref = `/oper/obsltPplBiz${listQueryString ? `?${listQueryString}` : ""}`;
const ctpvOptions = useMemo(
() => [{ text: "전체", val: "0", hidden: true }, ...ctpv],
[ctpv],
);
const representativeSggOptions = useMemo(
() => [
{ text: "전체", val: "0", hidden: true },
...buildObsltPplRepresentativeSggOptions(sgg, ctpvCd),
],
() => buildObsltPplRepresentativeSggOptions(sgg, ctpvCd),
[ctpvCd, sgg],
);
@@ -245,8 +248,9 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
try {
if (id) await updateObsltPplBusiness(id, toRequest(form));
else await createObsltPplBusiness(toRequest(form));
window.alert(isModify ? "수정되었습니다." : "등록되었습니다.");
router.push(listHref);
alert(isModify ? "수정되었습니다." : "등록되었습니다.", () =>
router.push(listHref),
);
} catch (error) {
setNotice(
error instanceof Error ? error.message : "저장하지 못했습니다.",
@@ -258,12 +262,11 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
/** 사용자 확인 후 현재 사업을 논리삭제하고 목록으로 이동한다. */
const handleDelete = async () => {
if (!id || !window.confirm("삭제하시겠습니까?")) return;
if (!id || !(await confirm("삭제하시겠습니까?"))) return;
setLoading(true);
try {
await deleteObsltPplBusiness(id);
window.alert("삭제되었습니다.");
router.push(listHref);
alert("삭제되었습니다.", () => router.push(listHref));
} catch (error) {
setNotice(
error instanceof Error ? error.message : "삭제하지 못했습니다.",
@@ -273,153 +276,258 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
}
};
const renderField = (key: FormKey, span: FieldSpan = 1) => {
const field = FIELDS.find((candidate) => candidate.key === key);
if (!field) return null;
const fieldId = `obslt-ppl-${field.key}`;
let control: ReactNode;
if (field.key === "bizSe") {
control = (
<Select
value={form.bizSe}
onValueChange={(value) => {
clearFieldError("bizSe");
setForm((current) => ({ ...current, bizSe: String(value) }));
}}
>
<SelectTrigger id={fieldId} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{BIZ_SE_OPTIONS.map((option) => (
<SelectItem key={option.val} value={option.val}>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (field.key === "envoNm") {
control = (
<Select
value={form.envoNm || EMPTY_SELECT_VALUE}
onValueChange={(value) => {
clearFieldError("envoNm");
setForm((current) => ({
...current,
envoNm: value === EMPTY_SELECT_VALUE ? "" : String(value),
}));
}}
>
<SelectTrigger id={fieldId} className="w-full">
<SelectValue placeholder="환경청 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_SELECT_VALUE}>선택</SelectItem>
{OBSLT_PPL_ENV_OFFICES.map((office) => (
<SelectItem key={office.name} value={office.name}>
{office.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (field.type === "textarea") {
control = (
<Textarea
id={fieldId}
className="min-h-[7.2rem] resize-y !text-[1.3rem]"
value={form[field.key]}
placeholder={field.placeholder}
onChange={(event) => {
clearFieldError(field.key);
setForm((current) => ({
...current,
[field.key]: event.target.value,
}));
}}
/>
);
} else {
control = (
<Input
id={fieldId}
className="w-full !text-[1.3rem]"
type={field.type === "number" ? "number" : "text"}
step={field.type === "number" ? "any" : undefined}
maxLength={DATE_KEYS.has(field.key) ? 10 : undefined}
value={form[field.key]}
placeholder={field.placeholder}
onChange={(event) => {
clearFieldError(field.key);
setForm((current) => ({
...current,
[field.key]: DATE_KEYS.has(field.key)
? formatDateInput(event.target.value)
: event.target.value,
}));
}}
/>
);
}
return (
<StandardFormField
key={field.key}
id={fieldId}
label={field.label}
required={field.required}
error={fieldErrors[field.key]}
span={span}
>
{control}
</StandardFormField>
);
};
return (
<div className="card card-border !mt-6">
<LoadingOverlay isLoading={loading} />
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
노후하수관로 사업 {isModify ? "수정" : "등록"}
노후 하수관로 정비사업 {isModify ? "상세/수정" : "등록"}
</h3>
</div>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="18%" />
<col width="82%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead data-required>시도,시군구 선택</TableHead>
<TableCell className="border">
<div className="flex flex-wrap items-center gap-3">
<SelectBox
label="시도"
className=""
labelClass="w-16 whitespace-nowrap text-[1.3rem] text-gray-600"
selectClass="h-11 w-[180px] rounded-lg bg-white"
selectArray={ctpvOptions}
value={ctpvCd}
onSelectValue={(value) => {
const nextCtpvCd = String(value);
clearFieldError("region");
setCtpvCd(nextCtpvCd);
setSggCd("0");
setForm((current) => ({
...current,
ctpvNm:
ctpv.find(
(option) => String(option.val) === nextCtpvCd,
)?.text ?? "",
sigunNm: "",
sggCd: "",
}));
}}
/>
<SelectBox
label="시군구"
className=""
labelClass="w-20 whitespace-nowrap text-[1.3rem] text-gray-600"
selectClass="h-11 w-[220px] rounded-lg bg-white"
selectArray={representativeSggOptions}
value={sggCd}
onSelectValue={(value) => {
const nextSggCd = String(value);
const selectedOption = representativeSggOptions.find(
(option) => String(option.val) === nextSggCd,
);
clearFieldError("region");
setSggCd(nextSggCd);
setForm((current) => ({
...current,
sigunNm: selectedOption?.text ?? "",
sggCd: nextSggCd === "0" ? "" : nextSggCd,
}));
}}
/>
</div>
{fieldErrors.region && (
<p className="mt-1 text-[1.2rem] text-red-600" role="alert">
{fieldErrors.region}
</p>
)}
</TableCell>
</TableRow>
{FIELDS.map((field) => (
<TableRow key={field.key}>
<TableHead data-required={field.required || undefined}>
{field.label}
</TableHead>
<TableCell className="border">
{field.key === "bizSe" ? (
<SelectBox
className=""
selectClass="h-11 rounded-lg bg-white"
selectArray={BIZ_SE_OPTIONS}
value={form.bizSe}
onSelectValue={(value) => {
clearFieldError("bizSe");
setForm({ ...form, bizSe: String(value) });
}}
/>
) : field.type === "textarea" ? (
<textarea
className="min-h-24 w-full rounded border bg-white p-3 text-[1.3rem]"
value={form[field.key]}
placeholder={field.placeholder}
onChange={(e) => {
clearFieldError(field.key);
setForm({ ...form, [field.key]: e.target.value });
}}
/>
) : (
<Input
className="!text-[1.3rem]"
type={field.type === "number" ? "number" : "text"}
step={field.type === "number" ? "any" : undefined}
maxLength={DATE_KEYS.has(field.key) ? 10 : undefined}
value={form[field.key]}
placeholder={field.placeholder}
onChange={(e) => {
clearFieldError(field.key);
setForm({
...form,
[field.key]: DATE_KEYS.has(field.key)
? formatDateInput(e.target.value)
: e.target.value,
});
}}
/>
)}
{fieldErrors[field.key] && (
<p className="mt-1 text-[1.2rem] text-red-600" role="alert">
{fieldErrors[field.key]}
</p>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="card space-y-4">
{notice && (
<p className="mt-3 text-sm text-red-600" role="alert">
<p
className="rounded-sm border border-red-200 bg-red-50 px-4 py-3 text-[1.3rem] text-red-700"
role="alert"
>
{notice}
</p>
)}
<div className="mt-5 flex justify-end gap-2">
<Button size="sm" onClick={() => router.push(listHref)}>
목록
</Button>
<StandardFormSection title="기본 정보">
{renderField("bizSe")}
{renderField("prcisExmnCycl")}
{renderField("bizCd")}
{renderField("bizNm", 2)}
{renderField("prplPrst")}
</StandardFormSection>
<StandardFormSection title="지역 및 일정">
<StandardFormField
id="obslt-ppl-ctpv"
label="시도"
required
error={fieldErrors.region}
>
<Select
value={ctpvCd}
onValueChange={(value) => {
const nextCtpvCd = String(value);
clearFieldError("region");
setCtpvCd(nextCtpvCd);
setSggCd("0");
setForm((current) => ({
...current,
ctpvNm:
ctpv.find((option) => String(option.val) === nextCtpvCd)
?.text ?? "",
sigunNm: "",
sggCd: "",
}));
}}
>
<SelectTrigger id="obslt-ppl-ctpv" className="w-full">
<SelectValue placeholder="시도 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0" disabled>
선택
</SelectItem>
{ctpv.map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</StandardFormField>
<StandardFormField id="obslt-ppl-sgg" label="시군구">
<Select
value={sggCd}
onValueChange={(value) => {
const nextSggCd = String(value);
const selectedOption = representativeSggOptions.find(
(option) => String(option.val) === nextSggCd,
);
clearFieldError("region");
setSggCd(nextSggCd);
setForm((current) => ({
...current,
sigunNm: selectedOption?.text ?? "",
sggCd: nextSggCd === "0" ? "" : nextSggCd,
}));
}}
>
<SelectTrigger id="obslt-ppl-sgg" className="w-full">
<SelectValue placeholder="시군구 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">선택</SelectItem>
{representativeSggOptions.map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</StandardFormField>
{renderField("envoNm")}
{renderField("bgngYr")}
{renderField("cmcnYr")}
{renderField("evdncDcmntSbmsnStts")}
{renderField("bgngYmd")}
{renderField("cmcnYmd", 2)}
</StandardFormSection>
<StandardFormSection title="사업량">
{renderField("planPrlg")}
{renderField("frhfyrExpcPrlg")}
{renderField("rmndPrlg")}
</StandardFormSection>
<StandardFormSection title="담당자 및 의견">
{renderField("bizPicNm")}
{renderField("bizPicCnpl", 2)}
{renderField("bizPrplDlyRsnCn", 3)}
{renderField("rmrkCn", 3)}
{renderField("pbcpOpnnCn", 3)}
</StandardFormSection>
<div className="flex justify-center gap-4 pb-8 pt-6">
<Button
size="sm"
className="rounded-sm bg-blue-600 px-4 py-2 !text-white hover:bg-blue-700"
type="button"
size="default"
onClick={() => void handleSave()}
>
저장
</Button>
<Button
type="button"
size="default"
color="white"
onClick={() => router.push(listHref)}
>
취소
</Button>
{isModify && (
<Button
size="sm"
className="rounded-sm bg-red-600 px-4 py-2 !text-white hover:bg-red-700"
type="button"
size="default"
color="red"
onClick={() => void handleDelete()}
>
삭제
@@ -7,14 +7,25 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import {
SearchDiv,
SearchFormLeft,
SearchFormRight,
SelectBox,
UiTable,
} from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
import { Button, Input, Label } from "@common/components/ui";
StandardDataTable,
getStandardRowNumber,
} from "@common/business_components";
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
} from "@tanstack/react-table";
import {
getAllObsltPplBusinesses,
@@ -38,8 +49,6 @@ import { openWindowPopup } from "../../windowPopup/lib/openWindowPopup";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../../windowPopup/model/types";
const DEFAULT_LIST_SIZE = 10;
const ACTION_BUTTON_CLASS =
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
const BIZ_SE_OPTIONS = [
{ text: "전체", val: "0" },
{ text: "정밀조사", val: "정밀조사" },
@@ -50,6 +59,12 @@ const ALL_REGION_OPTION = { text: "전체", val: "0" };
type ObsltPplTableRow = ObsltPplBusiness & {
rowNumber: number;
};
type ObsltPplTableColumn = {
id: keyof ObsltPplTableRow;
title: string;
width: number;
align?: "left" | "center" | "right";
};
type ObsltPplSearch = {
bizSe: string;
ctpvNm: string;
@@ -74,31 +89,42 @@ const createListQueryString = (
return params.toString();
};
const TABLE_HEADLIST = [
{ id: "rowNumber", title: "NO", widthPercent: 4 },
{ id: "bizSe", title: "사업구분", widthPercent: 6 },
{ id: "prcisExmnCycl", title: "정밀조사차수", widthPercent: 6 },
{ id: "bizCd", title: "사업코드", widthPercent: 7 },
//{ id: "sggCd", title: "시군구코드", widthPercent: 6 },
{ id: "ctpvNm", title: "시도", widthPercent: 6 },
{ id: "sigunNm", title: "시군", widthPercent: 6 },
{ id: "envoNm", title: "환경청", widthPercent: 8 },
{ id: "bizNm", title: "사업명", widthPercent: 16 },
{ id: "prplPrst", title: "추진현황", widthPercent: 6 },
{ id: "bgngYr", title: "착수년도", widthPercent: 5 },
{ id: "cmcnYr", title: "준공(예정)년도", widthPercent: 6 },
{ id: "planPrlg", title: "계획연장(km)", widthPercent: 6 },
{ id: "frhfyrExpcPrlg", title: "상반기(예상)연장(km)", widthPercent: 8 },
{ id: "rmndPrlg", title: "잔여연장(km)", widthPercent: 6 },
{ id: "evdncDcmntSbmsnStts", title: "증빙서류\n제출여부", widthPercent: 6 },
{ id: "bgngYmd", title: "착수일자", widthPercent: 7 },
{ id: "cmcnYmd", title: "준공(예정)일자", widthPercent: 7 },
{ id: "bizPrplDlyRsnCn", title: "사업추진 부진사유", widthPercent: 26 },
{ id: "bizPicNm", title: "담당자 이름", widthPercent: 6 },
{ id: "bizPicCnpl", title: "담당자 연락처", widthPercent: 8 },
{ id: "rmrkCn", title: "비고", widthPercent: 10 },
{ id: "pbcpOpnnCn", title: "공단\n의견", widthPercent: 2 },
/** 다열 목록은 화면별 CSS 대신 공통 스크롤 표에 전달할 실제 컬럼 폭만 정의한다. */
const TABLE_COLUMNS: ObsltPplTableColumn[] = [
{ id: "rowNumber", title: "순번", width: 60 },
{ id: "bizSe", title: "사업구분", width: 85 },
{ id: "prcisExmnCycl", title: "정밀조사\n차수", width: 85 },
{ id: "bizCd", title: "사업코드", width: 105 },
{ id: "ctpvNm", title: "시도", width: 90 },
{ id: "sigunNm", title: "시군구", width: 90 },
{ id: "envoNm", title: "환경청", width: 120 },
{ id: "bizNm", title: "사업명", width: 240, align: "left" },
{ id: "prplPrst", title: "추진현황", width: 85 },
{ id: "bgngYr", title: "착수년도", width: 75 },
{ id: "cmcnYr", title: "준공(예정)\n년도", width: 90 },
{ id: "planPrlg", title: "계획연장\n(km)", width: 95, align: "right" },
{
id: "frhfyrExpcPrlg",
title: "상반기(예상)\n연장(km)",
width: 120,
align: "right",
},
{ id: "rmndPrlg", title: "잔여연장\n(km)", width: 95, align: "right" },
{ id: "evdncDcmntSbmsnStts", title: "증빙서류\n제출여부", width: 90 },
{ id: "bgngYmd", title: "착수일자", width: 105 },
{ id: "cmcnYmd", title: "준공(예정)\n일자", width: 110 },
{
id: "bizPrplDlyRsnCn",
title: "사업추진 부진사유",
width: 300,
align: "left",
},
{ id: "bizPicNm", title: "담당자 이름", width: 90 },
{ id: "bizPicCnpl", title: "담당자 연락처", width: 120 },
{ id: "rmrkCn", title: "비고", width: 160, align: "left" },
{ id: "pbcpOpnnCn", title: "공단\n의견", width: 90, align: "left" },
];
const TABLE_MIN_WIDTH = "280rem";
const EMPTY_PAGE: ObsltPplPage = {
content: [],
@@ -108,8 +134,6 @@ const EMPTY_PAGE: ObsltPplPage = {
size: DEFAULT_LIST_SIZE,
};
const DATE_FIELD_SET = new Set<keyof ObsltPplBusiness>(["bgngYmd", "cmcnYmd"]);
/** 목록 표시값을 공통 포맷으로 정리한다. */
const formatBusinessForDisplay = (row: ObsltPplBusiness): ObsltPplBusiness => ({
...row,
@@ -118,6 +142,13 @@ const formatBusinessForDisplay = (row: ObsltPplBusiness): ObsltPplBusiness => ({
cmcnYmd: formatDate(row.cmcnYmd) || null,
});
/** 공통 DataTable 셀에 표시할 문자열을 null과 숫자까지 일관되게 변환한다. */
const formatTableValue = (value: unknown) => {
if (typeof value === "number") return value.toLocaleString("ko-KR");
if (value === null || value === undefined) return "";
return String(value);
};
/** 노후하수관로 사업 목록, 검색, xlsx 업로드·다운로드 진입점을 제공한다. */
export function ObsltPplListPage() {
const router = useRouter();
@@ -140,7 +171,6 @@ export function ObsltPplListPage() {
const [rows, setRows] = useState<ObsltPplTableRow[]>([]);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState("");
const [resetPageNo, setResetPageNo] = useState(false);
// 선택박스의 코드와 API 검색에 사용하는 행정구역명을 함께 관리한다.
const [ctpvCd, setCtpvCd] = useState(() => searchParams.get("ctpvCd") ?? "0");
const [sggCd, setSggCd] = useState(() => searchParams.get("sggCd") ?? "0");
@@ -159,6 +189,67 @@ export function ObsltPplListPage() {
return buildObsltPplRepresentativeSggOptions(sgg, ctpvCd);
}, [ctpvCd, sgg]);
/** 공통 DataTable이 표시할 22개 업무 컬럼과 폭·정렬 규칙을 구성한다. */
const columns = useMemo<ColumnDef<ObsltPplTableRow>[]>(
() =>
TABLE_COLUMNS.map((column) => ({
id: column.id,
accessorFn: (row) => row[column.id],
header: () => (
<span className="whitespace-pre-line">{column.title}</span>
),
cell: ({ getValue }) => {
const displayValue = formatTableValue(getValue());
return (
<span
className="block whitespace-normal break-words"
title={displayValue}
>
{displayValue}
</span>
);
},
meta: {
width: column.width,
align: column.align ?? "center",
},
})),
[],
);
const pagination: PaginationState = {
pageIndex: page - 1,
pageSize: DEFAULT_LIST_SIZE,
};
/** 서버 페이지 번호와 URL 복귀 조건을 공통 DataTable 페이지 상태에 연결한다. */
const table = useReactTable({
data: rows,
columns,
state: { pagination },
rowCount: data.totalElements,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => String(row.obsltSwagePplBizSn),
onPaginationChange: (updater) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
const nextPage = next.pageIndex + 1;
if (nextPage === page) return;
setPage(nextPage);
const queryString = createListQueryString(
nextPage,
appliedSearch,
appliedCtpvCd,
appliedSggCd,
);
router.replace(
`/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`,
{ scroll: false },
);
},
});
/** 공통 레이아웃에서 아직 지역 데이터를 불러오지 못한 경우 공통 store 조회를 보완한다. */
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
@@ -184,9 +275,12 @@ export function ObsltPplListPage() {
setRows(
result.content.map((row, index) => ({
...formatBusinessForDisplay(row),
rowNumber:
result.totalElements -
((targetPage - 1) * DEFAULT_LIST_SIZE + index),
rowNumber: getStandardRowNumber({
total: result.totalElements,
rowIndex: index,
pageIndex: targetPage - 1,
pageSize: DEFAULT_LIST_SIZE,
}),
})),
);
} catch (error) {
@@ -199,7 +293,6 @@ export function ObsltPplListPage() {
setRows([]);
} finally {
setLoading(false);
setResetPageNo(false);
}
},
[appliedSearch, page],
@@ -221,8 +314,7 @@ export function ObsltPplListPage() {
`최종확정예산사업 ${inserted.toLocaleString()}건을 등록했습니다.${skipped ? ` 이미 등록된 ${skipped.toLocaleString()}건은 제외했습니다.` : ""}`,
);
setPage(1);
setResetPageNo(true);
void load(1, appliedSearch);
if (page === 1) void load(1, appliedSearch);
};
window.addEventListener("message", receivePopupMessage);
return () => window.removeEventListener("message", receivePopupMessage);
@@ -256,9 +348,8 @@ export function ObsltPplListPage() {
setNotice(
`총 ${result.importedCount.toLocaleString()}건을 저장했습니다. (정밀조사 ${result.precisionSurveyCount.toLocaleString()}건, 정비사업 ${result.maintenanceCount.toLocaleString()}건)`,
);
setResetPageNo(true);
setPage(1);
await load(1, appliedSearch);
if (page === 1) await load(1, appliedSearch);
} catch (error) {
setNotice(
error instanceof Error ? error.message : "업로드하지 못했습니다.",
@@ -289,236 +380,245 @@ export function ObsltPplListPage() {
};
/** 입력 중 조건을 실제 조회조건으로 확정하고 첫 페이지를 다시 조회한다. */
const handleSearch = async () => {
const handleSearch = () => {
const nextSearch = { ...search };
setNotice("");
setAppliedSearch(nextSearch);
setAppliedCtpvCd(ctpvCd);
setAppliedSggCd(sggCd);
setResetPageNo(true);
const queryString = createListQueryString(1, nextSearch, ctpvCd, sggCd);
router.replace(`/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`, {
scroll: false,
});
if (page === 1) {
await load(1, nextSearch);
return;
}
setPage(1);
};
/** CS 목록 화면과 같은 초기화 동작으로 기본 사업구분과 전체 지역을 다시 조회한다. */
const handleReset = () => {
const nextSearch: ObsltPplSearch = {
bizSe: "정밀조사",
ctpvNm: "",
sigunNm: "",
bizNm: "",
};
setSearch(nextSearch);
setAppliedSearch(nextSearch);
setCtpvCd("0");
setSggCd("0");
setAppliedCtpvCd("0");
setAppliedSggCd("0");
setNotice("");
setPage(1);
router.replace("/oper/obsltPplBiz?bizSe=정밀조사", { scroll: false });
};
/** 적용된 목록 조건을 보존한 채 신규 등록 화면으로 이동한다. */
const handleRegister = () => {
const queryString = createListQueryString(
page,
appliedSearch,
appliedCtpvCd,
appliedSggCd,
);
router.push(
`/oper/obsltPplBiz/register${queryString ? `?${queryString}` : ""}`,
);
};
/** 선택한 목록 행의 수정·상세 화면을 현재 목록 복귀 조건과 함께 연다. */
const handleOpenDetail = (row: ObsltPplTableRow) => {
const queryString = createListQueryString(
page,
appliedSearch,
appliedCtpvCd,
appliedSggCd,
);
router.push(
`/oper/obsltPplBiz/register/${row.obsltSwagePplBizSn}${queryString ? `?${queryString}` : ""}`,
);
};
return (
<>
<LoadingOverlay isLoading={loading} />
<SearchDiv>
<SearchFormLeft>
<div className="flex items-center gap-4">
<div className="flex items-center space-x-2">
<div className="flex items-center">
<SelectBox
label="사업구분"
className=""
labelClass="w-20 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[120px] rounded-lg bg-white"
selectArray={BIZ_SE_OPTIONS}
value={search.bizSe}
onSelectValue={(value) =>
setSearch((current) => ({
...current,
bizSe: String(value),
}))
}
/>
</div>
<div className="flex items-center">
<SelectBox
label="시도"
className=""
labelClass="w-16 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[140px] rounded-lg bg-white"
selectArray={[ALL_REGION_OPTION, ...ctpv]}
value={ctpvCd}
onSelectValue={(value) => {
const nextCtpvCd = String(value);
setCtpvCd(nextCtpvCd);
setSggCd("0");
setSearch((current) => ({
...current,
ctpvNm:
ctpv.find((option) => String(option.val) === nextCtpvCd)
?.text ?? "",
sigunNm: "",
}));
}}
/>
</div>
<div className="flex items-center">
<SelectBox
label="시군구"
className=""
labelClass="w-20 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[140px] rounded-lg bg-white"
selectArray={[ALL_REGION_OPTION, ...availableSgg]}
value={sggCd}
onSelectValue={(value) => {
const nextSggCd = String(value);
setSggCd(nextSggCd);
const selectedOption = availableSgg.find(
(option) => String(option.val) === nextSggCd,
);
setSearch((current) => ({
...current,
sigunNm: selectedOption?.text ?? "",
}));
}}
/>
</div>
<div className="flex items-center">
<Label className="w-20 text-xl text-gray-600">사업명</Label>
<Input
className="w-[220px] !text-2xl"
value={search.bizNm}
onChange={(event) =>
setSearch((current) => ({
...current,
bizNm: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleSearch();
}
}}
/>
</div>
</div>
</div>
</SearchFormLeft>
<SearchFormRight className="ml-auto flex shrink-0 items-center justify-end px-4">
<div className="card card-border !mt-6">
<form
className="flex flex-wrap items-center gap-2 p-2"
onSubmit={(event) => {
event.preventDefault();
handleSearch();
}}
>
<div className="flex items-center gap-2">
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={() => void handleSearch()}
<Label className="mr-2 whitespace-nowrap text-label">
사업구분
</Label>
<Select
value={search.bizSe}
onValueChange={(value) =>
setSearch((current) => ({
...current,
bizSe: String(value),
}))
}
>
조회
</Button>
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={() => {
const queryString = createListQueryString(
page,
appliedSearch,
appliedCtpvCd,
appliedSggCd,
);
router.push(
`/oper/obsltPplBiz/register${queryString ? `?${queryString}` : ""}`,
);
<SelectTrigger className="w-[12rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{BIZ_SE_OPTIONS.map((option) => (
<SelectItem key={option.val} value={option.val}>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">시도</Label>
<Select
value={ctpvCd}
onValueChange={(value) => {
const nextCtpvCd = String(value);
setCtpvCd(nextCtpvCd);
setSggCd("0");
setSearch((current) => ({
...current,
ctpvNm:
ctpv.find((option) => String(option.val) === nextCtpvCd)
?.text ?? "",
sigunNm: "",
}));
}}
>
등록
</Button>
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={handleOpenCfmtnBgt}
>
최종확정예산사업
</Button>
<input
ref={fileInputRef}
type="file"
accept=".xlsx"
className="hidden"
onChange={(event) => void handleUpload(event.target.files?.[0])}
/>
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={() => fileInputRef.current?.click()}
>
업로드
</Button>
<Button
className="rounded-sm bg-green-600 px-4 py-2 text-sm font-medium !text-white hover:bg-green-700"
size="sm"
onClick={() => void handleDownload()}
>
엑셀다운로드
</Button>
<SelectTrigger className="w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[ALL_REGION_OPTION, ...ctpv].map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</SearchFormRight>
</SearchDiv>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">시군구</Label>
<Select
value={sggCd}
onValueChange={(value) => {
const nextSggCd = String(value);
setSggCd(nextSggCd);
const selectedOption = availableSgg.find(
(option) => String(option.val) === nextSggCd,
);
setSearch((current) => ({
...current,
sigunNm: selectedOption?.text ?? "",
}));
}}
>
<SelectTrigger className="w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[ALL_REGION_OPTION, ...availableSgg].map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label
htmlFor="obslt-ppl-biz-name"
className="mr-2 whitespace-nowrap text-label"
>
사업명
</Label>
<Input
id="obslt-ppl-biz-name"
className="w-[22rem] rounded-sm !text-[1.3rem]"
value={search.bizNm}
onChange={(event) =>
setSearch((current) => ({
...current,
bizNm: event.target.value,
}))
}
/>
</div>
<Button type="submit" className="w-[7.4rem]">
조회
</Button>
<Button
type="button"
color="white"
className="w-[8.8rem]"
onClick={handleReset}
>
초기화
</Button>
</form>
</div>
{notice && (
<div
role="status"
className="mx-[1.2rem] mt-4 rounded border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-700"
className="mx-[1.2rem] mt-4 rounded-sm border border-primary bg-primary-transparent px-4 py-3 text-[1.3rem] text-primary"
>
{notice}
</div>
)}
<ListDiv className="overflow-x-auto">
<div className="w-[130%]">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-medium">
노후하수관로 정밀조사 및 정비사업 현황{" "}
<span className="text-base text-gray-500">
({data.totalElements.toLocaleString("ko-KR")}건)
</span>
</h3>
</div>
<UiTable<ObsltPplTableRow>
headName=""
headlist={TABLE_HEADLIST}
tableData={rows}
pageSize={DEFAULT_LIST_SIZE}
total={data.totalElements}
curPage={page}
widthFixed
onPageChange={(nextPage) => {
setPage(nextPage);
const queryString = createListQueryString(
nextPage,
appliedSearch,
appliedCtpvCd,
appliedSggCd,
);
router.replace(
`/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`,
{ scroll: false },
);
}}
resetPageNo={resetPageNo}
cellClick={(index) => {
if (typeof index !== "number" || !rows[index]) return;
const queryString = createListQueryString(
page,
appliedSearch,
appliedCtpvCd,
appliedSggCd,
);
router.push(
`/oper/obsltPplBiz/register/${rows[index].obsltSwagePplBizSn}${queryString ? `?${queryString}` : ""}`,
);
}}
renderCell={(row, headId) => {
if (!DATE_FIELD_SET.has(headId as keyof ObsltPplBusiness))
return undefined;
return formatDate(
row[headId as keyof ObsltPplTableRow] as
| string
| null
| undefined,
);
}}
/>
</div>
</ListDiv>
<div className="mt-4 w-full min-w-0 max-w-full overflow-hidden">
<StandardDataTable
table={table}
title="노후 하수관로 정비사업 목록"
scrollMode="horizontal"
minTableWidth={TABLE_MIN_WIDTH}
className="table-fixed"
onRowClick={handleOpenDetail}
actions={
<>
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
<Button type="button" onClick={handleRegister}>
등록
</Button>
<Button type="button" onClick={handleOpenCfmtnBgt}>
최종확정예산사업
</Button>
<input
ref={fileInputRef}
type="file"
accept=".xlsx"
className="hidden"
onChange={(event) => void handleUpload(event.target.files?.[0])}
/>
<Button
type="button"
onClick={() => fileInputRef.current?.click()}
>
업로드
</Button>
</>
}
/>
</div>
</>
);
}
@@ -4,13 +4,13 @@ export const windowPopupDefinitions = {
cfmtnBgt: {
key: "cfmtnBgt",
title: "최종확정예산사업",
width: 1500,
height: 900,
width: 1400,
height: 720,
requiredParams: ["bizSe"],
},
exmnBusinessRegister: {
key: "exmnBusinessRegister",
title: "사업정보 등록",
title: "조사사업 등록",
width: 1000,
height: 620,
},
@@ -34,13 +34,6 @@ export const windowPopupDefinitions = {
width: 1280,
height: 900,
},
dredgingDetail: {
key: "dredgingDetail",
title: "준설결과 상세",
width: 1280,
height: 900,
requiredParams: ["dredgingJobSn", "pipeFid", "longitude", "latitude"],
},
dredgingEdit: {
key: "dredgingEdit",
title: "준설결과 수정",
@@ -70,13 +63,13 @@ export const windowPopupDefinitions = {
},
floodPreUpload: {
key: "floodPreUpload",
title: "도시침수 예방사업 등록",
title: "도시침수예방사업 등록",
width: 860,
height: 360,
},
floodPreTable: {
key: "floodPreTable",
title: "도시침수 예방사업 상세",
title: "도시침수예방사업 상세",
width: 1400,
height: 900,
requiredParams: ["fileNm", "atflId"],
@@ -96,21 +89,22 @@ export const windowPopupDefinitions = {
},
mapSearch: {
key: "mapSearch",
title: "관망도 검색",
title: "정보검색",
width: 1280,
height: 980,
maximize: true,
},
inftwtResultPopup: {
key: "inftwtResultPopup",
title: "침입수 분석결과",
width: 1280,
height: 900,
height: 830,
},
rdiiResultPopup: {
key: "rdiiResultPopup",
title: "RDII 분석결과",
width: 1280,
height: 900,
width: 1400,
height: 830,
},
infilChart: {
key: "infilChart",
@@ -143,7 +137,7 @@ export const windowPopupDefinitions = {
},
agedPipePopup: {
key: "agedPipePopup",
title: "노후 하수관로 관리 ",
title: "노후 하수관로 목록",
width: 850,
height: 600,
placement: "browser-left",
@@ -173,13 +167,13 @@ export const windowPopupDefinitions = {
key: "pipeAnalysisPopup",
title: "관로분석",
width: 1180,
height: 820,
height: 950,
},
pipeAnalysisDetailPopup: {
key: "pipeAnalysisDetailPopup",
title: "관로현황",
width: 1180,
height: 760,
height: 820,
},
repairHistoryPopup: {
key: "repairHistoryPopup",
@@ -198,7 +192,7 @@ export const windowPopupDefinitions = {
key: "agedPipeStatusByCtpvPopup",
title: "시도별 노후관로현황",
width: 1160,
height: 760,
height: 790,
},
fldngHstryPopup: {
key: "fldngHstryPopup",
+120 -38
View File
@@ -1,42 +1,128 @@
"use client";
import LoadingOverlay from "@/components/ui/LoadingBar";
import dynamic from "next/dynamic";
import type { ComponentType } from "react";
import {
windowPopupDefinitions,
type WindowPopupKey,
} from "./model/popupDefinitions";
import type { WindowPopupRegistryItem } from "./model/types";
import AgedPipePopupContent from "./ui/AgedPipePopupContent";
import AgedPipeStatusByCtpvPopupContent from "./ui/AgedPipeStatusByCtpvPopupContent";
import DredgingRegisterPopupContent from "./ui/DredgingRegisterPopupContent";
import DredgingDetailPopupContent from "./ui/DredgingDetailPopupContent";
import DredgingEditPopupContent from "./ui/DredgingEditPopupContent";
import ExmnBusinessRegisterPopupContent from "./ui/ExmnBusinessRegisterPopupContent";
import ExmnPipeReportPopupContent from "./ui/ExmnPipeReportPopupContent";
import ExmnResultRegisterPopupContent from "./ui/ExmnResultRegisterPopupContent";
import ExmnSurveyDetailPopupContent from "./ui/ExmnSurveyDetailPopupContent";
import FloodPreTablePopupContent from "./ui/FloodPreTablePopupContent";
import FloodPreUploadPopupContent from "./ui/FloodPreUploadPopupContent";
import InfilChartPopupContent from "./ui/InfilChartPopupContent";
import InftwtResultPopupContent from "./ui/InftwtResultPopupContent";
import OldPpUploadPopupContent from "./ui/OldPpUploadPopupContent";
import OldPpTablePopupContent from "./ui/OldPpTablePopupContent";
import RdiiChartPopupContent from "./ui/RdiiChartPopupContent";
import RdiiResultPopupContent from "./ui/RdiiResultPopupContent";
import ReportFilesPopupContent from "./ui/ReportFilesPopupContent";
import ReportTabPopupContent from "./ui/ReportTabPopupContent";
import SearchModalPopupContent from "./ui/SearchModalPopupContent";
import SinkholePopupContent from "./ui/SinkholePopupContent";
import SbmrgDngrMapPopupContent from "./ui/SbmrgDngrMapPopupContent";
import FloodDngrMapPopupContent from "./ui/FloodDngrMapPopupContent";
import SscDetailPopupContent from "./ui/SscDetailPopupContent";
import type { WindowPopupContentProps } from "./model/types";
import type {
WindowPopupContentProps,
WindowPopupRegistryItem,
} from "./model/types";
import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent";
import FldngHstryPopupContent from "./ui/FldngHstryPopupContent";
import GisEditSessionPopupContent from "./ui/GisEditSessionPopupContent";
import GisManagementPopupContent from "./ui/GisManagementPopupContent";
import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent";
import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent";
import RepairHistoryPopupContent from "./ui/RepairHistoryPopupContent";
import WaterRiskPopupContent from "./ui/WaterRiskPopupContent";
import CfmtnBgtPopupContent from "../cfmtnBgt/ui/CfmtnBgtPopupContent";
type WindowPopupModule = {
default: ComponentType<WindowPopupContentProps>;
};
const PopupChunkLoading = () => (
<div className="relative min-h-dvh">
<LoadingOverlay isLoading />
</div>
);
const loadPopupContent = (loader: () => Promise<WindowPopupModule>) =>
dynamic(loader, {
ssr: false,
loading: PopupChunkLoading,
});
const CfmtnBgtPopupContent = loadPopupContent(
() => import("../cfmtnBgt/ui/CfmtnBgtPopupContent"),
);
const AgedPipePopupContent = loadPopupContent(
() => import("./ui/AgedPipePopupContent"),
);
const AgedPipeStatusByCtpvPopupContent = loadPopupContent(
() => import("./ui/AgedPipeStatusByCtpvPopupContent"),
);
const DredgingRegisterPopupContent = loadPopupContent(
() => import("./ui/DredgingRegisterPopupContent"),
);
const DredgingEditPopupContent = loadPopupContent(
() => import("./ui/DredgingEditPopupContent"),
);
const ExmnBusinessRegisterPopupContent = loadPopupContent(
() => import("./ui/ExmnBusinessRegisterPopupContent"),
);
const ExmnPipeReportPopupContent = loadPopupContent(
() => import("./ui/ExmnPipeReportPopupContent"),
);
const ExmnResultRegisterPopupContent = loadPopupContent(
() => import("./ui/ExmnResultRegisterPopupContent"),
);
const ExmnSurveyDetailPopupContent = loadPopupContent(
() => import("./ui/ExmnSurveyDetailPopupContent"),
);
const FloodPreTablePopupContent = loadPopupContent(
() => import("./ui/FloodPreTablePopupContent"),
);
const FloodPreUploadPopupContent = loadPopupContent(
() => import("./ui/FloodPreUploadPopupContent"),
);
const InfilChartPopupContent = loadPopupContent(
() => import("./ui/InfilChartPopupContent"),
);
const InftwtResultPopupContent = loadPopupContent(
() => import("./ui/InftwtResultPopupContent"),
);
const OldPpUploadPopupContent = loadPopupContent(
() => import("./ui/OldPpUploadPopupContent"),
);
const OldPpTablePopupContent = loadPopupContent(
() => import("./ui/OldPpTablePopupContent"),
);
const RdiiChartPopupContent = loadPopupContent(
() => import("./ui/RdiiChartPopupContent"),
);
const RdiiResultPopupContent = loadPopupContent(
() => import("./ui/RdiiResultPopupContent"),
);
const ReportFilesPopupContent = loadPopupContent(
() => import("./ui/ReportFilesPopupContent"),
);
const ReportTabPopupContent = loadPopupContent(
() => import("./ui/ReportTabPopupContent"),
);
const SearchModalPopupContent = loadPopupContent(
() => import("./ui/SearchModalPopupContent"),
);
const SinkholePopupContent = loadPopupContent(
() => import("./ui/SinkholePopupContent"),
);
const SbmrgDngrMapPopupContent = loadPopupContent(
() => import("./ui/SbmrgDngrMapPopupContent"),
);
const FloodDngrMapPopupContent = loadPopupContent(
() => import("./ui/FloodDngrMapPopupContent"),
);
const SscDetailPopupContent = loadPopupContent(
() => import("./ui/SscDetailPopupContent"),
);
const FldngHstryPopupContent = loadPopupContent(
() => import("./ui/FldngHstryPopupContent"),
);
const GisEditSessionPopupContent = loadPopupContent(
() => import("./ui/GisEditSessionPopupContent"),
);
const GisManagementPopupContent = loadPopupContent(
() => import("./ui/GisManagementPopupContent"),
);
const PipeAnalysisPopupContent = loadPopupContent(
() => import("./ui/PipeAnalysisPopupContent"),
);
const PipeAnalysisDetailPopupContent = loadPopupContent(
() => import("./ui/PipeAnalysisDetailPopupContent"),
);
const RepairHistoryPopupContent = loadPopupContent(
() => import("./ui/RepairHistoryPopupContent"),
);
const WaterRiskPopupContent = loadPopupContent(
() => import("./ui/WaterRiskPopupContent"),
);
const FloodPredictionPlaceholderPopupContent = (
_props: WindowPopupContentProps,
@@ -73,10 +159,6 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.dredgingRegister,
component: DredgingRegisterPopupContent,
},
dredgingDetail: {
...windowPopupDefinitions.dredgingDetail,
component: DredgingDetailPopupContent,
},
dredgingEdit: {
...windowPopupDefinitions.dredgingEdit,
component: DredgingEditPopupContent,
@@ -5,7 +5,7 @@ import DredgingDetailPopupPage from "@/app/(with-base-layout)/oper/dredging/comp
import { postPopupMessage } from "../lib/popupMessage";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types";
import type { WindowPopupContentProps } from "../model/types";
import { buildDredgingPopupRecord } from "./DredgingDetailPopupContent";
import { buildDredgingPopupRecord } from "./dredgingPopupRecord";
/** 상세 화면과 같은 구조에서 선택 관로의 사진·위치·준설일자를 수정한다. */
export default function DredgingEditPopupContent({
@@ -12,7 +12,7 @@ export default function DredgingRegisterPopupContent() {
}, []);
if (!isMounted) {
return <div className="min-h-dvh bg-[#f3f7fa]" suppressHydrationWarning />;
return <div className="min-h-dvh bg-container" suppressHydrationWarning />;
}
return <DredgingRegisterPopupPage />;
@@ -6,10 +6,12 @@ import type { WindowPopupContentProps } from "../model/types";
/** query parameter가 단일 문자열인지 안전하게 해석한다. */
const getParam = (value: string | string[] | undefined) =>
Array.isArray(value) ? value[0] ?? "" : value ?? "";
Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
/** 조사사업 등록 WindowPopup을 실제 업무 화면에 연결한다. */
export default function ExmnBusinessRegisterPopupContent({ searchParams }: WindowPopupContentProps) {
export default function ExmnBusinessRegisterPopupContent({
searchParams,
}: WindowPopupContentProps) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
@@ -17,7 +19,7 @@ export default function ExmnBusinessRegisterPopupContent({ searchParams }: Windo
}, []);
if (!isMounted) {
return <div className="min-h-dvh bg-[#f3f7fa]" suppressHydrationWarning />;
return <div className="min-h-dvh bg-container" suppressHydrationWarning />;
}
return (
@@ -3,11 +3,13 @@
import { useEffect, useState } from "react";
import { downloadExmnPipeReport } from "@/features/exmn/api/exmn-business-api";
import { StandardPopupLayout } from "@common/business_components";
import { Button } from "@common/components";
import type { WindowPopupContentProps } from "../model/types";
const getParam = (value: string | string[] | undefined) =>
Array.isArray(value) ? value[0] ?? "" : value ?? "";
Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
/** 공통 Blob API 오류 문자열에서 백엔드의 사용자 안내 메시지만 추출한다. */
const getReportErrorMessage = (error: unknown) => {
@@ -15,7 +17,9 @@ const getReportErrorMessage = (error: unknown) => {
return "관로 조사보고서를 불러오지 못했습니다.";
}
const backendMessage = error.message.match(/"message":"((?:\\.|[^"\\])*)"/)?.[1];
const backendMessage = error.message.match(
/"message":"((?:\\.|[^"\\])*)"/,
)?.[1];
if (!backendMessage) return error.message;
try {
@@ -66,16 +70,23 @@ export default function ExmnPipeReportPopupContent({
}, [exmnBizId, pipeNumber]);
return (
<main className="sn-popup-page">
<header className="sn-popup-header">
<h1 className="sn-popup-title">관로 조사보고서</h1>
<span className="text-[1.05rem] text-white">
관로번호 {pipeNumber}
{reportNo ? ` · 보고서번호 ${reportNo}` : ""}
</span>
</header>
<section className="min-h-0 flex-1 bg-container p-3">
<StandardPopupLayout
title="관로 조사보고서"
showDefaultCancelAction={false}
actions={
<>
<span className="text-[1.05rem] text-label">
관로번호 {pipeNumber}
{reportNo ? ` · 보고서번호 ${reportNo}` : ""}
</span>
<Button type="button" color="white" onClick={() => window.close()}>
취소
</Button>
</>
}
bodyClassName="flex min-h-0 flex-1 p-3"
>
<section className="min-h-0 flex-1">
{errorMessage ? (
<div className="flex h-full items-center justify-center rounded-sm border border-red-200 bg-red-50 p-6 text-center text-[1.1rem] text-red-700">
{errorMessage}
@@ -84,14 +95,14 @@ export default function ExmnPipeReportPopupContent({
<iframe
title={`${pipeNumber} 관로 조사보고서`}
src={reportUrl}
className="h-full w-full border border-border bg-white"
className="h-full w-full border border-border bg-background"
/>
) : (
<div className="flex h-full items-center justify-center rounded-sm border border-border bg-white text-[1.1rem] text-muted-foreground">
<div className="flex h-full items-center justify-center rounded-sm border border-border bg-background text-[1.1rem] text-muted-foreground">
조사보고서를 불러오는 중입니다.
</div>
)}
</section>
</main>
</StandardPopupLayout>
);
}
@@ -7,10 +7,12 @@ import type { WindowPopupContentProps } from "../model/types";
/** query parameter가 단일 문자열인지 안전하게 해석한다. */
const getParam = (value: string | string[] | undefined) =>
Array.isArray(value) ? value[0] ?? "" : value ?? "";
Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
/** 부모 목록이 전달한 선택 사업 요약을 팝업 표시 계약으로 복원한다. */
const buildBusinessSummary = (searchParams: WindowPopupContentProps["searchParams"]): ExmnBusiness => ({
const buildBusinessSummary = (
searchParams: WindowPopupContentProps["searchParams"],
): ExmnBusiness => ({
exmnBizId: getParam(searchParams.exmnBizId),
bizYr: getParam(searchParams.bizYr),
bizNm: getParam(searchParams.bizNm),
@@ -24,7 +26,9 @@ const buildBusinessSummary = (searchParams: WindowPopupContentProps["searchParam
});
/** 선택 조사사업의 결과등록 WindowPopup을 실제 업로드 화면에 연결한다. */
export default function ExmnResultRegisterPopupContent({ searchParams }: WindowPopupContentProps) {
export default function ExmnResultRegisterPopupContent({
searchParams,
}: WindowPopupContentProps) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
@@ -32,7 +36,7 @@ export default function ExmnResultRegisterPopupContent({ searchParams }: WindowP
}, []);
if (!isMounted) {
return <div className="min-h-dvh bg-[#f3f7fa]" suppressHydrationWarning />;
return <div className="min-h-dvh bg-container" suppressHydrationWarning />;
}
const businessSummary = buildBusinessSummary(searchParams);
@@ -1,6 +1,9 @@
"use client";
import type { ExmnBusiness, ExmnJobType } from "@/features/exmn/model/exmn-business-types";
import type {
ExmnBusiness,
ExmnJobType,
} from "@/features/exmn/model/exmn-business-types";
import type { ExmnDetailDefectType } from "@/features/exmn/model/exmn-detail-table";
import { ExmnSurveyDetailPopupPage } from "@/features/exmn/ui/ExmnSurveyDetailPopupPage";
import { useEffect, useState } from "react";
@@ -8,7 +11,7 @@ import type { WindowPopupContentProps } from "../model/types";
/** query parameter가 단일 문자열인지 안전하게 해석한다. */
const getParam = (value: string | string[] | undefined) =>
Array.isArray(value) ? value[0] ?? "" : value ?? "";
Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
/** 상세보기 버튼이 전달한 조사유형을 PPL·MNHL 두 값으로 제한한다. */
const getJobType = (value: string | string[] | undefined): ExmnJobType =>
@@ -51,7 +54,7 @@ export default function ExmnSurveyDetailPopupContent({
}, []);
if (!isMounted) {
return <div className="min-h-dvh bg-[#f3f7fa]" suppressHydrationWarning />;
return <div className="min-h-dvh bg-container" suppressHydrationWarning />;
}
return (
@@ -4,22 +4,21 @@ type Props = {
export default function FeaturePlaceholderPopupContent({ title }: Props) {
return (
<main className="sn-popup-page">
<header className="sn-popup-header">
<h1 className="sn-popup-title">{title}</h1>
</header>
<section className="flex flex-1 items-center justify-center bg-container p-8">
<div className="w-full max-w-[520px] rounded-md border border-border bg-background p-8 text-center shadow-sm">
<h2 className="text-[1.35rem] font-semibold text-foreground">{title}</h2>
<p className="mt-4 text-[1.1rem] leading-7 text-label">
현재는 팝업 창 구조만 우선 연결했습니다.
<StandardPopupLayout title={title}>
<section className="card card-border !m-0 flex flex-1 items-center justify-center p-8">
<div className="w-full max-w-[560px] rounded-md border border-dashed border-border bg-container px-8 py-10 text-center">
<h2 className="text-[1.4rem] font-semibold text-foreground">
서비스 준비 중입니다.
</h2>
<p className="mt-4 text-[1.15rem] leading-7 text-label">
{title} 기능은 현재 제공을 준비하고 있습니다.
</p>
<p className="mt-2 text-[1.05rem] leading-7 text-muted-foreground">
상세 기능은 추후 단계에서 구현될 예정입니다.
기능이 준비되면 이 화면에서 조회할 수 있습니다.
</p>
</div>
</section>
</main>
</StandardPopupLayout>
);
}
import { StandardPopupLayout } from "@common/business_components";
@@ -1,9 +1,20 @@
import TableModal from "@/app/(with-base-layout)/oper/floodPre/tableModal";
"use client";
import dynamic from "next/dynamic";
import { StandardPopupLayout } from "@common/business_components";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
// 엑셀 파서는 브라우저 전용 의존성을 포함한다. 다른 WindowPopup까지 SSR 단계에서
// 함께 실패하지 않도록 해당 상세 팝업을 실제로 열 때만 클라이언트에서 불러온다.
const TableModal = dynamic(
() => import("@/app/(with-base-layout)/oper/floodPre/tableModal"),
{ ssr: false },
);
export default function FloodPreTablePopupContent({
searchParams,
}: WindowPopupContentProps) {
@@ -24,13 +35,11 @@ export default function FloodPreTablePopupContent({
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.floodPreTable.title}
</h1>
<div className="min-h-0 flex-1 overflow-hidden">
<TableModal fileNm={fileNm} atflId={atflId} />
</div>
</div>
<StandardPopupLayout
title={windowPopupDefinitions.floodPreTable.title}
bodyClassName="overflow-hidden"
>
<TableModal fileNm={fileNm} atflId={atflId} />
</StandardPopupLayout>
);
}
@@ -4,11 +4,15 @@ import { postData } from "@/components/lib/api";
import OperFileUploadForm from "@/components/ui/operFileUploadForm";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { AUTH_USER_ID } from "@/shared/config";
import { useAlertStore } from "@/shared/lib";
import { StandardPopupLayout } from "@common/business_components";
import { postPopupMessageAndClose } from "../lib/popupMessage";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types";
export default function FloodPreUploadPopupContent() {
const alert = useAlertStore((state) => state.setMessage);
const handleSubmit = async (
fileNm: string,
year: string,
@@ -26,17 +30,20 @@ export default function FloodPreUploadPopupContent() {
});
if (data) {
alert("파일이 등록되었습니다.");
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED);
alert("파일이 등록되었습니다.", () =>
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED,
),
);
}
};
return (
<div className="min-h-dvh bg-white p-4">
<OperFileUploadForm
title={windowPopupDefinitions.floodPreUpload.title}
onSubmit={handleSubmit}
/>
</div>
<StandardPopupLayout
title={windowPopupDefinitions.floodPreUpload.title}
bodyClassName="[&>div>div.card]:!m-0 [&>div>div:first-child]:hidden"
>
<OperFileUploadForm title="" onSubmit={handleSubmit} />
</StandardPopupLayout>
);
}
@@ -1,6 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { StandardPopupLayout } from "@common/business_components";
import { Button } from "@common/components";
import GisEditSessionPanel from "@/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel";
import type { GisEditSessionSnapshot } from "@/app/(map-layout)/map/components/gisEdit/gisEditTypes";
@@ -21,13 +23,19 @@ const EMPTY_SNAPSHOT: GisEditSessionSnapshot = {
focusedChangeId: null,
};
const postMessageToOpener = (type: string, payload?: Record<string, unknown>) => {
const postMessageToOpener = (
type: string,
payload?: Record<string, unknown>,
) => {
if (typeof window === "undefined" || !window.opener) return;
window.opener.postMessage({ type, payload }, window.location.origin);
};
export default function GisEditSessionPopupContent(_props: WindowPopupContentProps) {
const [snapshot, setSnapshot] = useState<GisEditSessionSnapshot>(EMPTY_SNAPSHOT);
export default function GisEditSessionPopupContent(
_props: WindowPopupContentProps,
) {
const [snapshot, setSnapshot] =
useState<GisEditSessionSnapshot>(EMPTY_SNAPSHOT);
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
@@ -46,43 +54,52 @@ export default function GisEditSessionPopupContent(_props: WindowPopupContentPro
}, []);
return (
<main className="flex h-screen flex-col overflow-hidden bg-[#f6f8fb]">
<header className="flex min-h-[48px] items-center justify-between gap-3 border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.2rem] font-semibold text-white">
{windowPopupDefinitions.gisEditSessionPopup.title}
</h1>
<button
<StandardPopupLayout
title={windowPopupDefinitions.gisEditSessionPopup.title}
actions={
<Button
type="button"
className="rounded border border-white/70 bg-white/10 px-3 py-1.5 text-[12px] font-semibold text-white transition hover:bg-white/20"
color="white"
onClick={() => {
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SHOW_INLINE);
postMessageToOpener(
WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SHOW_INLINE,
);
}}
>
화면에서 보기
</button>
</header>
<section className="flex-1 overflow-hidden p-3">
</Button>
}
>
<section className="card card-border !m-0 flex-1 overflow-hidden p-3">
<GisEditSessionPanel
{...snapshot}
className="h-full shadow-none"
variant="popup"
onRemoveFocusedChange={() => {
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE);
postMessageToOpener(
WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE,
);
}}
onRemoveLastChange={() => {
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_LAST_CHANGE);
postMessageToOpener(
WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_LAST_CHANGE,
);
}}
onClearChanges={() => {
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_CLEAR_CHANGES);
postMessageToOpener(
WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_CLEAR_CHANGES,
);
}}
onFocusChange={(changeId) => {
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_FOCUS_CHANGE, {
changeId,
});
postMessageToOpener(
WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_FOCUS_CHANGE,
{
changeId,
},
);
}}
/>
</section>
</main>
</StandardPopupLayout>
);
}
@@ -12,8 +12,8 @@ export default function InftwtResultPopupContent() {
}, []);
if (!isMounted) {
return <div className="h-dvh bg-white" suppressHydrationWarning />;
return <div className="h-dvh bg-background" suppressHydrationWarning />;
}
return <InftwtResultPopupPage />;
}
}
@@ -0,0 +1,182 @@
"use client";
import { useMemo, type ReactNode } from "react";
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
} from "@tanstack/react-table";
import {
StandardDataTable,
getStandardRowNumber,
type ScrollMode,
type UiHeadProps,
} from "@common/business_components";
type MapPopupDataTableProps<T extends Record<string, unknown>> = {
rows: T[];
headlist: UiHeadProps[];
title: ReactNode;
total: number;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
actions?: ReactNode;
loading?: boolean;
scrollMode?: ScrollMode;
minTableWidth?: string;
emptyMessage?: string;
viewPagination?: boolean;
fillAvailableHeight?: boolean;
rowId?: (row: T, index: number) => string;
selectedRowId?: string;
onRowClick?: (row: T, index: number) => void;
onCellClick?: (row: T, rowIndex: number, columnId: string) => void;
clickableHeadIds?: string[];
renderCell?: (
row: T,
headId: string,
rowIndex: number,
) => ReactNode | undefined;
};
const ROW_NUMBER_IDS = new Set(["no", "rowNo", "rowNumber"]);
const formatCellValue = (value: unknown) => {
if (value === null || value === undefined || value === "") return "-";
if (typeof value === "number") return value.toLocaleString("ko-KR");
return String(value);
};
/** 지도 WindowPopup 목록을 CS 공통 표·순번·페이징 규칙으로 연결한다. */
export default function MapPopupDataTable<T extends Record<string, unknown>>({
rows,
headlist,
title,
total,
page,
pageSize,
onPageChange,
actions,
loading = false,
scrollMode = "none",
minTableWidth = "100%",
emptyMessage,
viewPagination = true,
fillAvailableHeight = false,
rowId,
selectedRowId,
onRowClick,
onCellClick,
clickableHeadIds,
renderCell,
}: MapPopupDataTableProps<T>) {
const visibleHeads = useMemo(
() =>
headlist.filter(
(head) => head.visible !== false && head.id && head.id.length > 1,
),
[headlist],
);
const columns = useMemo<ColumnDef<T>[]>(
() =>
visibleHeads.map((head) => ({
id: head.id,
accessorFn: (row) => row[head.id],
header: ROW_NUMBER_IDS.has(head.id) ? "순번" : head.title,
cell: ({ row, getValue }) => {
const rowIndex = row.index;
const rendered = renderCell?.(row.original, head.id, rowIndex);
const value = ROW_NUMBER_IDS.has(head.id)
? getStandardRowNumber({
total,
rowIndex,
pageIndex: page - 1,
pageSize,
})
: rendered === undefined
? formatCellValue(getValue())
: rendered;
const isClickable =
Boolean(onCellClick) &&
(!clickableHeadIds || clickableHeadIds.includes(head.id));
return isClickable ? (
<button
type="button"
className="w-full cursor-pointer text-inherit"
onClick={(event) => {
event.stopPropagation();
onCellClick?.(row.original, rowIndex, head.id);
}}
>
{value}
</button>
) : (
value
);
},
meta: {
align:
head.align === "left" || head.align === "right"
? head.align
: "center",
width: head.widthPercent,
},
})),
[
clickableHeadIds,
onCellClick,
page,
pageSize,
renderCell,
total,
visibleHeads,
],
);
const pagination: PaginationState = {
pageIndex: Math.max(page - 1, 0),
pageSize,
};
const table = useReactTable({
data: rows,
columns,
state: {
pagination,
rowSelection: selectedRowId ? { [selectedRowId]: true } : {},
},
rowCount: total,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getRowId: (row, index) => rowId?.(row, index) ?? String(index),
onPaginationChange: (updater) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
if (next.pageIndex === pagination.pageIndex) return;
onPageChange(next.pageIndex + 1);
},
});
return (
<StandardDataTable
table={table}
title={title}
actions={actions}
loading={loading}
emptyMessage={emptyMessage}
flushOuterMargin
className="!w-full !max-w-full"
scrollMode={scrollMode}
minTableWidth={minTableWidth}
viewPagination={viewPagination}
fillAvailableHeight={fillAvailableHeight}
onRowClick={
onRowClick ? (row) => onRowClick(row, rows.indexOf(row)) : undefined
}
/>
);
}
export type { MapPopupDataTableProps };
@@ -1,9 +1,20 @@
import TableModal from "@/app/(with-base-layout)/oper/oldPp/tableModal";
"use client";
import dynamic from "next/dynamic";
import { StandardPopupLayout } from "@common/business_components";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
// 엑셀 파서는 브라우저 전용 의존성을 포함한다. 다른 WindowPopup까지 SSR 단계에서
// 함께 실패하지 않도록 해당 상세 팝업을 실제로 열 때만 클라이언트에서 불러온다.
const TableModal = dynamic(
() => import("@/app/(with-base-layout)/oper/oldPp/tableModal"),
{ ssr: false },
);
export default function OldPpTablePopupContent({
searchParams,
}: WindowPopupContentProps) {
@@ -24,13 +35,11 @@ export default function OldPpTablePopupContent({
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.oldPpTable.title}
</h1>
<div className="min-h-0 flex-1 overflow-hidden">
<TableModal fileNm={fileNm} atflId={atflId} />
</div>
</div>
<StandardPopupLayout
title={windowPopupDefinitions.oldPpTable.title}
bodyClassName="overflow-hidden"
>
<TableModal fileNm={fileNm} atflId={atflId} />
</StandardPopupLayout>
);
}
@@ -4,11 +4,15 @@ import { postData } from "@/components/lib/api";
import OperFileUploadForm from "@/components/ui/operFileUploadForm";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { AUTH_USER_ID } from "@/shared/config";
import { useAlertStore } from "@/shared/lib";
import { StandardPopupLayout } from "@common/business_components";
import { postPopupMessageAndClose } from "../lib/popupMessage";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types";
export default function OldPpUploadPopupContent() {
const alert = useAlertStore((state) => state.setMessage);
const handleSubmit = async (
fileNm: string,
year: string,
@@ -26,17 +30,20 @@ export default function OldPpUploadPopupContent() {
});
if (data) {
alert("파일이 등록되었습니다.");
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED);
alert("파일이 등록되었습니다.", () =>
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED,
),
);
}
};
return (
<div className="min-h-dvh bg-white p-4">
<OperFileUploadForm
title={windowPopupDefinitions.oldPpUpload.title}
onSubmit={handleSubmit}
/>
</div>
<StandardPopupLayout
title={windowPopupDefinitions.oldPpUpload.title}
bodyClassName="[&>div>div.card]:!m-0 [&>div>div:first-child]:hidden"
>
<OperFileUploadForm title="" onSubmit={handleSubmit} />
</StandardPopupLayout>
);
}
@@ -1,3 +1,5 @@
import { StandardPopupLayout } from "@common/business_components";
export default function PopupMissingParams({
title,
missingParams,
@@ -6,14 +8,17 @@ export default function PopupMissingParams({
missingParams: string[];
}) {
return (
<section className="flex min-h-dvh flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold">{title}</h1>
<p className="text-base text-gray-600">
팝업을 여는 데 필요한 값이 누락되었습니다.
</p>
<p className="text-sm text-gray-500">
누락된 값: {missingParams.join(", ")}
</p>
</section>
<StandardPopupLayout title={title}>
<section className="card card-border !m-0 flex flex-1 items-center justify-center p-8 text-center">
<div>
<p className="text-[1.3rem] text-label">
팝업을 여는 데 필요한 값이 누락되었습니다.
</p>
<p className="mt-2 text-[1.15rem] text-muted-foreground">
누락된 값: {missingParams.join(", ")}
</p>
</div>
</section>
</StandardPopupLayout>
);
}
@@ -0,0 +1,32 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@common/components";
type PopupSearchPanelProps = {
children: ReactNode;
className?: string;
contentClassName?: string;
};
/** WindowPopup 조회조건의 외곽 카드, 패딩과 가로 스크롤 규칙을 한 곳에서 관리한다. */
export default function PopupSearchPanel({
children,
className,
contentClassName,
}: PopupSearchPanelProps) {
return (
<section
data-popup-search-panel
className={cn(
"card card-border !m-0 w-full min-w-0 shrink-0 !p-[1.2rem]",
className,
)}
>
<div className={cn("min-w-0", contentClassName)}>{children}</div>
</section>
);
}
export type { PopupSearchPanelProps };
@@ -12,8 +12,8 @@ export default function RdiiResultPopupContent() {
}, []);
if (!isMounted) {
return <div className="h-dvh bg-white" suppressHydrationWarning />;
return <div className="h-dvh bg-background" suppressHydrationWarning />;
}
return <RdiiResultPopupPage />;
}
}
@@ -3,11 +3,13 @@
import { useEffect, useState } from "react";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { getDcData } from "@/components/lib/api";
import { UiTable } from "@common/business_components/ui";
import { useAlertStore } from "@/shared/lib";
import { StandardPopupLayout } from "@common/business_components";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
import MapPopupDataTable from "./MapPopupDataTable";
type ReportDataFile = {
no?: number;
@@ -18,7 +20,7 @@ type ReportDataFile = {
};
const REPORT_FILES_HEADLIST = [
{ id: "no", title: "NO" },
{ id: "no", title: "순번" },
{ id: "fileTitle", title: "파일명" },
{ id: "regDt", title: "등록일자" },
];
@@ -26,6 +28,7 @@ const REPORT_FILES_HEADLIST = [
export default function ReportFilesPopupContent({
searchParams,
}: WindowPopupContentProps) {
const alert = useAlertStore((state) => state.setMessage);
const clsfSystmSn = getPopupParamValue(searchParams.clsfSystmSn);
const [files, setFiles] = useState<ReportDataFile[]>([]);
const [allFiles, setAllFiles] = useState<ReportDataFile[]>([]);
@@ -38,11 +41,10 @@ export default function ReportFilesPopupContent({
const fetchFiles = async () => {
setIsLoading(true);
try {
const result = await getDcData(`/api/v1/file?clsfSystmSn=${clsfSystmSn}`);
const normalized = (result ?? []).map((item: ReportDataFile, index: number) => ({
...item,
no: index + 1,
}));
const result = await getDcData(
`/api/v1/file?clsfSystmSn=${clsfSystmSn}`,
);
const normalized = (result ?? []).map((item: ReportDataFile) => item);
setAllFiles(normalized);
} catch (error) {
console.error("보고서 파일 목록 조회 실패:", error);
@@ -60,7 +62,11 @@ export default function ReportFilesPopupContent({
setFiles(paged);
}, [allFiles, curPage]);
const handleDownload = async (fileSn: string, fileTitle: string, filePath: string) => {
const handleDownload = async (
fileSn: string,
fileTitle: string,
filePath: string,
) => {
if (!filePath) {
alert("filePath 정보가 없으므로 다운로드가 불가합니다.");
return;
@@ -96,26 +102,22 @@ export default function ReportFilesPopupContent({
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.reportFiles.title}
</h1>
<div className="min-h-0 flex-1 overflow-auto">
<LoadingOverlay isLoading={isLoading} />
<UiTable
headName={`조회 결과 (${allFiles.length.toLocaleString("ko-KR")})건`}
tableData={files}
headlist={REPORT_FILES_HEADLIST}
pageSize={10}
total={allFiles.length}
onPageChange={setCurPage}
cellClick={(index) => {
if (typeof index !== "number" || !files[index]) return;
const target = files[index];
handleDownload(target.fileSn, target.fileTitle, target.filePath);
}}
/>
</div>
</div>
<StandardPopupLayout title={windowPopupDefinitions.reportFiles.title}>
<LoadingOverlay isLoading={isLoading} />
<MapPopupDataTable
rows={files as Array<ReportDataFile & Record<string, unknown>>}
headlist={REPORT_FILES_HEADLIST}
title="보고서 파일 목록"
total={allFiles.length}
page={curPage}
pageSize={10}
onPageChange={setCurPage}
clickableHeadIds={["fileTitle"]}
onCellClick={(row) =>
void handleDownload(row.fileSn, row.fileTitle, row.filePath)
}
rowId={(row, index) => row.fileSn || String(index)}
/>
</StandardPopupLayout>
);
}
@@ -1,6 +1,7 @@
"use client";
"use client";
import ReportTab from "@/app/(map-layout)/map/searchModal/reportTab";
import { StandardPopupLayout } from "@common/business_components";
import { useEffect, useState } from "react";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
@@ -18,15 +19,12 @@ export default function ReportTabPopupContent({
}, []);
if (!isMounted) {
return <div className="h-dvh bg-white" suppressHydrationWarning />;
return <div className="h-dvh bg-background" suppressHydrationWarning />;
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.reportTab.title}
</h1>
<div className="min-h-0 flex-1 overflow-hidden">
<StandardPopupLayout title={windowPopupDefinitions.reportTab.title}>
<div className="card card-border !m-0 min-h-0 flex-1 overflow-hidden">
<ReportTab
open={true}
isSearch={false}
@@ -34,6 +32,6 @@ export default function ReportTabPopupContent({
sggNm={sggNm || undefined}
/>
</div>
</div>
</StandardPopupLayout>
);
}
@@ -0,0 +1,22 @@
"use client";
import type { WindowPopupKey } from "../model/popupDefinitions";
import { windowPopupRegistry } from "../registry";
type WindowPopupRendererProps = {
popupKey: WindowPopupKey;
searchParams: Record<string, string | string[] | undefined>;
};
/**
* WindowPopup 구현들은 지도·엑셀처럼 브라우저 전용 모듈을 포함할 수 있다.
* 레지스트리를 클라이언트 경계 안에서 해석해 다른 팝업의 의존성이 SSR을 막지 않게 한다.
*/
export default function WindowPopupRenderer({
popupKey,
searchParams,
}: WindowPopupRendererProps) {
const PopupComponent = windowPopupRegistry[popupKey].component;
return <PopupComponent searchParams={searchParams} />;
}
@@ -1,8 +1,5 @@
"use client";
import DredgingDetailPopupPage from "@/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage";
import type { DredgingRecord } from "@/app/(with-base-layout)/oper/dredging/lib/types";
import type { WindowPopupContentProps } from "../model/types";
const getParam = (value: string | string[] | undefined) =>
@@ -20,7 +17,7 @@ const getNullableNumber = (value: string | string[] | undefined) => {
return Number.isFinite(parsed) ? parsed : null;
};
/** 목록 행의 직렬화된 값을 준설 상세 화면 모델로 복원한다. */
/** 목록 행의 직렬화된 값을 준설 수정 화면 모델로 복원한다. */
export const buildDredgingPopupRecord = (
searchParams: WindowPopupContentProps["searchParams"],
): DredgingRecord => ({
@@ -43,16 +40,10 @@ export const buildDredgingPopupRecord = (
latitude: getNumber(searchParams.latitude),
snappedLongitude: getNumber(searchParams.snappedLongitude),
snappedLatitude: getNumber(searchParams.snappedLatitude),
registrationMode:
getParam(searchParams.registrationMode) === "MANUAL" ? "MANUAL" : "PHOTO",
registrationSource:
getParam(searchParams.registrationSource) === "MANUAL" ? "MANUAL" : "EXIF",
matchDistanceMeters: getNumber(searchParams.matchDistanceMeters),
isMock: getParam(searchParams.isMock) === "true",
});
export default function DredgingDetailPopupContent({
searchParams,
}: WindowPopupContentProps) {
return (
<DredgingDetailPopupPage record={buildDredgingPopupRecord(searchParams)} />
);
}