feat(sn): CS 기준 UI 통일 및 지도 팝업 표준화
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
+4
-13
@@ -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)} />
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user