Merge branch 'iipbs/as/dev'

This commit is contained in:
2026-09-28 16:44:05 +09:00
31 changed files with 483 additions and 194 deletions
@@ -1,3 +1,11 @@
import { Suspense } from "react";
import { DesignListPage } from "@/pages/design-cnstn";
export default DesignListPage;
export default function Page() {
return (
<Suspense>
<DesignListPage />
</Suspense>
);
}
@@ -1,3 +1,11 @@
import { Suspense } from "react";
import { FnrsListPage } from "@/pages/fnrs-cnsltn";
export default FnrsListPage;
export default function Page() {
return (
<Suspense>
<FnrsListPage />
</Suspense>
);
}
@@ -1,3 +1,11 @@
import { Suspense } from "react";
import { InstlListPage } from "@/pages/instl-sncn";
export default InstlListPage;
export default function Page() {
return (
<Suspense>
<InstlListPage />
</Suspense>
);
}
@@ -1,3 +1,11 @@
import { Suspense } from "react";
import { SewerBscPlanListPage } from "@/pages/sewer-bsc-plan";
export default SewerBscPlanListPage;
export default function Page() {
return (
<Suspense>
<SewerBscPlanListPage />
</Suspense>
);
}
@@ -61,6 +61,13 @@ function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] {
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
@@ -110,7 +117,11 @@ export function createDesignCnstnColumns() {
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
@@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import {
type DesignCnstnApplySaveRequestDto,
designCnstnKeys,
@@ -103,11 +103,11 @@ function FloatingActionButtons({
isRegister && actionButtons.some((button) => button === DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg);
function handleGoToList() {
router.push("/design-cnstn");
router.push(getListHref("/design-cnstn"));
}
function handleEdit() {
router.push(`/design-cnstn/${designCnstnDocNo}/edit`);
router.push(withListQs(`/design-cnstn/${designCnstnDocNo}/edit`));
}
function shouldShowButton(action: DesignCnstnViewButtonAction) {
@@ -177,7 +177,7 @@ function FloatingActionButtons({
action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.del,
successMessage: "삭제되었습니다.",
});
router.push("/design-cnstn");
router.push(getListHref("/design-cnstn"));
}
function getReviewOpinionFormData() {
@@ -8,7 +8,7 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import {
designCnstnKeys,
@@ -91,7 +91,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
alert("저장되었습니다.");
queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
router.push(`/design-cnstn/${designCnstnDocNo}/edit`);
router.push(withListQs(`/design-cnstn/${designCnstnDocNo}/edit`));
} catch (error) {
console.error("저장 실패:", error);
alert("저장 중 오류가 발생했습니다.");
@@ -115,7 +115,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
alert("신청 완료되었습니다.");
queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
router.push(`/design-cnstn/${designCnstnDocNo}`);
router.push(withListQs(`/design-cnstn/${designCnstnDocNo}`));
} catch (error) {
console.error("신청 실패:", error);
alert("신청 중 오류가 발생했습니다.");
@@ -144,7 +144,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
await designCnstnMngService.delete(docNo);
alert("삭제되었습니다.");
queryClient.invalidateQueries({ queryKey: designCnstnKeys.list() });
router.push("/design-cnstn");
router.push(getListHref("/design-cnstn"));
} catch (error) {
console.error("삭제 실패:", error);
alert("삭제 중 오류가 발생했습니다.");
@@ -184,7 +184,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
<Button
color="white"
type="button"
onClick={() => router.push("/design-cnstn")}
onClick={() => router.push(getListHref("/design-cnstn"))}
disabled={isSubmitting}
>
목록
@@ -1,9 +1,10 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import { useSearchParamsState } from "@/shared/lib";
import {
type CmCdAdstDstrComboResponseDto,
useFindGugunCombo,
@@ -28,47 +29,61 @@ const DEFAULT_PAGE_INDEX = 0;
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT = "designCnstnDocNo,desc";
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
const DEFAULT_SEARCH_PARAMS: DesignCnstnMngSearchDto = {
bizNm: "",
taskSeComCd: "",
admdstCd: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
sort: DEFAULT_SORT,
};
const ALL_SELECT_VALUE = "__ALL__";
const DEFAULT_PARAMS = {
sidoComCd: "",
sggComCd: "",
taskSeComCd: "",
bizNm: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
};
function DesignListPage() {
const [bizNm, setBizNm] = useState("");
const [taskSeComCd, setTaskSeComCd] = useState("");
const [sidoComCd, setSidoComCd] = useState("");
const [sggComCd, setSggComCd] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: DEFAULT_PAGE_INDEX,
pageSize: DEFAULT_PAGE_SIZE,
});
const [searchParams, setSearchParams] = useState<DesignCnstnMngSearchDto>(DEFAULT_SEARCH_PARAMS);
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
const [bizNm, setBizNm] = useState(params.bizNm);
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
const [sggComCd, setSggComCd] = useState(params.sggComCd);
const { data: sidoData } = useFindSidoCombo();
const { data: gugunData } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const searchParams: DesignCnstnMngSearchDto = useMemo(
() => ({
bizNm: params.bizNm,
taskSeComCd: params.taskSeComCd,
admdstCd: params.sggComCd || params.sidoComCd || "",
page: params.page,
size: params.size,
sort: DEFAULT_SORT,
}),
[params],
);
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
setSearchParams({
...DEFAULT_SEARCH_PARAMS,
setParams({
bizNm,
taskSeComCd,
admdstCd: sggComCd || sidoComCd || "",
sidoComCd,
sggComCd,
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
const handlePaginationChange = (next: PaginationState) => {
setParams({ page: next.pageIndex, size: next.pageSize });
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
@@ -143,7 +158,7 @@ function DesignListPage() {
<DesignList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
onPaginationChange={handlePaginationChange}
/>
</div>
);
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useDataTable, withListQs } from "@/shared/lib";
import {
type DesignCnstnMngResponseDto,
type DesignCnstnMngSearchDto,
@@ -43,7 +43,7 @@ function DesignList({ searchParams, pagination, onPaginationChange }: DesignList
const columns = useMemo(() => createDesignCnstnColumns(), []);
const handleRowClick = (row: DesignCnstnMngResponseDto) => {
router.push(`/design-cnstn/${row.designCnstnDocNo}`);
router.push(withListQs(`/design-cnstn/${row.designCnstnDocNo}`));
};
const { tableComponent } = useDataTable({
@@ -83,7 +83,9 @@ function DesignViewPage({ id }: DesignViewPageProps) {
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<SectionCollapsibleToggle />
</div>
<div className="space-y-4">
<ProgressStatusSection
columns={PROGRESS_STATUS_COLUMNS}
@@ -102,6 +102,13 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
@@ -152,7 +159,11 @@ export function createFnrsCnsltnColumns() {
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
@@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import {
AI_AUTO_SE,
type AiAutoPrcsLatestResponseDto,
@@ -219,11 +219,11 @@ function FloatingActionButtons({
isRegister && actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg);
function handleGoToList() {
router.push("/fnrs-cnsltn");
router.push(getListHref("/fnrs-cnsltn"));
}
function handleEdit() {
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`);
router.push(withListQs(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`));
}
function shouldShowButton(action: FnrsCnsltnViewButtonAction) {
@@ -314,7 +314,7 @@ function FloatingActionButtons({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.del,
successMessage: "삭제되었습니다.",
});
router.push("/fnrs-cnsltn");
router.push(getListHref("/fnrs-cnsltn"));
}
async function handleEnvoSbmsnClick() {
@@ -8,7 +8,7 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { useAcntgSeMngTree } from "@/entities/acntg";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
@@ -153,7 +153,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
alert("보완 완료 처리되었습니다.");
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
router.push(`/fnrs-cnsltn/${docNo}`);
router.push(withListQs(`/fnrs-cnsltn/${docNo}`));
} catch (error) {
console.error("보완 완료 실패:", error);
alert("보완 완료 처리 중 오류가 발생했습니다.");
@@ -172,7 +172,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
alert("저장되었습니다.");
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`);
router.push(withListQs(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`));
} catch (error) {
console.error("저장 실패:", error);
alert("저장 중 오류가 발생했습니다.");
@@ -206,7 +206,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
alert("신청 완료되었습니다.");
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`);
router.push(withListQs(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`));
} catch (error) {
console.error("신청 실패:", error);
alert("신청 중 오류가 발생했습니다.");
@@ -235,7 +235,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
await fnrsCnsltnMngService.remove(docNo);
alert("삭제되었습니다.");
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
router.push("/fnrs-cnsltn");
router.push(getListHref("/fnrs-cnsltn"));
} catch (error) {
console.error("삭제 실패:", error);
alert("삭제 중 오류가 발생했습니다.");
@@ -285,7 +285,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
<Button
color="white"
type="button"
onClick={() => router.push("/fnrs-cnsltn")}
onClick={() => router.push(getListHref("/fnrs-cnsltn"))}
disabled={isSubmitting}
>
목록
@@ -1,9 +1,10 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import { useSearchParamsState } from "@/shared/lib";
import {
type CmCdAdstDstrComboResponseDto,
useFindGugunCombo,
@@ -28,47 +29,61 @@ const DEFAULT_PAGE_INDEX = 0;
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT = "fnrsCnsltnDocNo,desc";
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
const DEFAULT_SEARCH_PARAMS: FnrsCnsltnMngSearchDto = {
bizNm: "",
taskSeComCd: "",
admdstCd: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
sort: DEFAULT_SORT,
};
const ALL_SELECT_VALUE = "__ALL__";
const DEFAULT_PARAMS = {
sidoComCd: "",
sggComCd: "",
taskSeComCd: "",
bizNm: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
};
function FnrsListPage() {
const [bizNm, setBizNm] = useState("");
const [taskSeComCd, setTaskSeComCd] = useState("");
const [sidoComCd, setSidoComCd] = useState("");
const [sggComCd, setSggComCd] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: DEFAULT_PAGE_INDEX,
pageSize: DEFAULT_PAGE_SIZE,
});
const [searchParams, setSearchParams] = useState<FnrsCnsltnMngSearchDto>(DEFAULT_SEARCH_PARAMS);
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
const [bizNm, setBizNm] = useState(params.bizNm);
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
const [sggComCd, setSggComCd] = useState(params.sggComCd);
const { data: sidoData } = useFindSidoCombo();
const { data: gugunData } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const searchParams: FnrsCnsltnMngSearchDto = useMemo(
() => ({
bizNm: params.bizNm,
taskSeComCd: params.taskSeComCd,
admdstCd: params.sggComCd || params.sidoComCd || "",
page: params.page,
size: params.size,
sort: DEFAULT_SORT,
}),
[params],
);
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
setSearchParams({
...DEFAULT_SEARCH_PARAMS,
setParams({
bizNm,
taskSeComCd,
admdstCd: sggComCd || sidoComCd || "",
sidoComCd,
sggComCd,
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
const handlePaginationChange = (next: PaginationState) => {
setParams({ page: next.pageIndex, size: next.pageSize });
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
@@ -143,7 +158,7 @@ function FnrsListPage() {
<FnrsList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
onPaginationChange={handlePaginationChange}
/>
</div>
);
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useDataTable, withListQs } from "@/shared/lib";
import {
FnrsCnsltnMngResponseDto,
type FnrsCnsltnMngSearchDto,
@@ -43,7 +43,7 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp
const columns = useMemo(() => createFnrsCnsltnColumns(), []);
const handleRowClick = (row: FnrsCnsltnMngResponseDto) => {
router.push(`/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`);
router.push(withListQs(`/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`));
};
const { tableComponent } = useDataTable({
+12 -1
View File
@@ -103,6 +103,13 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
@@ -153,7 +160,11 @@ export function createInstlSncnColumns() {
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
@@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import {
buildElaprPrgrsSttsListFromAplnRows,
ElaprApprovalDialog,
@@ -114,11 +114,11 @@ function FloatingActionButtons({
isRegister && actionButtons.some((button) => button === INSTL_SNCN_VIEW_BUTTON_ACTION.strg);
function handleGoToList() {
router.push("/instl-sncn");
router.push(getListHref("/instl-sncn"));
}
function handleEdit() {
router.push(`/instl-sncn/${instlSncnDocNo}/edit`);
router.push(withListQs(`/instl-sncn/${instlSncnDocNo}/edit`));
}
function shouldShowButton(action: InstlSncnViewButtonAction) {
@@ -212,7 +212,7 @@ function FloatingActionButtons({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.del,
successMessage: "삭제되었습니다.",
});
router.push("/instl-sncn");
router.push(getListHref("/instl-sncn"));
}
async function handleInstlSncnAncmntClick() {
@@ -8,7 +8,7 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import {
@@ -110,7 +110,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
if (!result) return;
alert("보완완료 처리되었습니다.");
router.push(`/instl-sncn/${docNo}`);
router.push(withListQs(`/instl-sncn/${docNo}`));
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
} catch (error) {
console.error("보완완료 실패:", error);
@@ -129,7 +129,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
if (!instlSncnDocNo) return;
alert("저장되었습니다.");
router.push(`/instl-sncn/${instlSncnDocNo}/edit`);
router.push(withListQs(`/instl-sncn/${instlSncnDocNo}/edit`));
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
} catch (error) {
console.error("저장 실패:", error);
@@ -158,7 +158,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
if (!instlSncnDocNo) return;
alert("신청 완료되었습니다.");
router.push(`/instl-sncn/${instlSncnDocNo}`);
router.push(withListQs(`/instl-sncn/${instlSncnDocNo}`));
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
} catch (error) {
console.error("신청 실패:", error);
@@ -184,7 +184,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
await instlSncnService.remove(docNo);
alert("삭제되었습니다.");
queryClient.invalidateQueries({ queryKey: instlSncnKeys.list() });
router.push("/instl-sncn");
router.push(getListHref("/instl-sncn"));
} catch (error) {
console.error("삭제 실패:", error);
alert("삭제 중 오류가 발생했습니다.");
@@ -227,7 +227,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
<Button
color="white"
type="button"
onClick={() => router.push("/instl-sncn")}
onClick={() => router.push(getListHref("/instl-sncn"))}
disabled={isSubmitting}
>
목록
@@ -1,9 +1,10 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import { useSearchParamsState } from "@/shared/lib";
import type { CmCdAdstDstrComboResponseDto } from "@/entities/adstdstr";
import { useFindGugunCombo, useFindSidoCombo } from "@/entities/adstdstr";
import { CodeSelect } from "@/entities/code";
@@ -26,44 +27,59 @@ const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT = "instlSncnDocNo,desc";
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
const DEFAULT_SEARCH_PARAMS: InstlSncnMngSearchParams = {
bizNm: "",
const DEFAULT_PARAMS = {
sidoComCd: "",
sggComCd: "",
taskSeComCd: "",
admdstCd: "",
bizNm: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
sort: DEFAULT_SORT,
};
function InstlListPage() {
const [bizNm, setBizNm] = useState("");
const [taskSeComCd, setTaskSeComCd] = useState("");
const [sidoComCd, setSidoComCd] = useState("");
const [sggComCd, setSggComCd] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: DEFAULT_PAGE_INDEX,
pageSize: DEFAULT_PAGE_SIZE,
});
const [searchParams, setSearchParams] = useState<InstlSncnMngSearchParams>(DEFAULT_SEARCH_PARAMS);
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
const [bizNm, setBizNm] = useState(params.bizNm);
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
const [sggComCd, setSggComCd] = useState(params.sggComCd);
const { data: sidoData } = useFindSidoCombo();
const { data: gugunData } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const searchParams: InstlSncnMngSearchParams = useMemo(
() => ({
bizNm: params.bizNm,
taskSeComCd: params.taskSeComCd,
admdstCd: params.sggComCd || params.sidoComCd || "",
page: params.page,
size: params.size,
sort: DEFAULT_SORT,
}),
[params],
);
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
setSearchParams({
...DEFAULT_SEARCH_PARAMS,
setParams({
bizNm,
taskSeComCd,
admdstCd: sggComCd || sidoComCd || "",
sidoComCd,
sggComCd,
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
const handlePaginationChange = (next: PaginationState) => {
setParams({ page: next.pageIndex, size: next.pageSize });
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
@@ -132,7 +148,7 @@ function InstlListPage() {
<InstlList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
onPaginationChange={handlePaginationChange}
/>
</div>
);
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useDataTable, withListQs } from "@/shared/lib";
import {
type InstlSncnMngResponseDto,
type InstlSncnMngSearchParams,
@@ -43,7 +43,7 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr
const columns = useMemo(() => createInstlSncnColumns(), []);
const handleRowClick = (row: InstlSncnMngResponseDto) => {
router.push(`/instl-sncn/${row.instlSncnDocNo}`);
router.push(withListQs(`/instl-sncn/${row.instlSncnDocNo}`));
};
const { tableComponent } = useDataTable({
@@ -95,7 +95,9 @@ function InstlViewPage({ id }: InstlViewPageProps) {
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<SectionCollapsibleToggle />
</div>
<div className="space-y-4">
<ProgressStatusSection
columns={PROGRESS_STATUS_COLUMNS}
@@ -101,6 +101,13 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
@@ -151,7 +158,11 @@ export function createSewerBscPlanColumns() {
columnHelper.accessor("sewerBscPlanNm", {
header: "기본계획명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("sewerBscPlanSeCdNm", {
header: "계획구분",
@@ -8,7 +8,7 @@ import { type QueryClient, useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import {
buildElaprPrgrsSttsListFromAplnRows,
ElaprApprovalDialog,
@@ -116,11 +116,11 @@ function FloatingActionButtons({
isRegister && actionButtons.some((button) => button === SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.strg);
function handleGoToList() {
router.push("/sewer-bsc-plan");
router.push(getListHref("/sewer-bsc-plan"));
}
function handleEdit() {
router.push(`/sewer-bsc-plan/${sewerBscPlanDocNo}/edit?chgCycl=${chgCycl}`);
router.push(withListQs(`/sewer-bsc-plan/${sewerBscPlanDocNo}/edit?chgCycl=${chgCycl}`));
}
function shouldShowButton(action: SewerBscPlanViewButtonAction) {
@@ -201,7 +201,7 @@ function FloatingActionButtons({
action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.del,
successMessage: "삭제되었습니다.",
});
router.push("/sewer-bsc-plan");
router.push(getListHref("/sewer-bsc-plan"));
}
function getReviewOpinionFormData() {
@@ -502,7 +502,9 @@ async function handleDmndSplmnt(
context.request,
);
await context.queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
context.router.replace(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl}`);
context.router.replace(
withListQs(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl}`),
);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
@@ -8,7 +8,7 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { useAcntgSeMngTree } from "@/entities/acntg";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
@@ -123,7 +123,9 @@ function SewerBscPlanFormPage({
if (!result?.sewerBscPlanDocNo) return;
alert("저장되었습니다.");
router.push(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}/edit?chgCycl=${result.chgCycl}`);
router.push(
withListQs(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}/edit?chgCycl=${result.chgCycl}`),
);
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
} catch (error) {
console.error("저장 실패:", error);
@@ -162,7 +164,11 @@ function SewerBscPlanFormPage({
if (!result?.sewerBscPlanDocNo) return;
alert("신청 완료되었습니다.");
router.push(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl ?? 1}`);
router.push(
withListQs(
`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl ?? 1}`,
),
);
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
} catch (error) {
console.error("신청 실패:", error);
@@ -199,7 +205,7 @@ function SewerBscPlanFormPage({
alert("보완완료 처리되었습니다.");
const chgCycl = data.sewerBscPlanDtl.chgCycl ?? initialData?.detDto?.chgCycl ?? 1;
router.push(`/sewer-bsc-plan/${docNo}?chgCycl=${chgCycl}`);
router.push(withListQs(`/sewer-bsc-plan/${docNo}?chgCycl=${chgCycl}`));
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
} catch (error) {
console.error("보완완료 실패:", error);
@@ -228,7 +234,7 @@ function SewerBscPlanFormPage({
setIsSubmitting(true);
await sewerBscPlanService.delete(docNo);
alert("삭제되었습니다.");
router.push("/sewer-bsc-plan");
router.push(getListHref("/sewer-bsc-plan"));
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.list() });
} catch (error) {
console.error("삭제 실패:", error);
@@ -240,7 +246,7 @@ function SewerBscPlanFormPage({
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-4">
<form onSubmit={form.handleSubmit(onSave)} className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<SectionCollapsibleToggle />
@@ -274,7 +280,7 @@ function SewerBscPlanFormPage({
<Button
color="white"
type="button"
onClick={() => router.push("/sewer-bsc-plan")}
onClick={() => router.push(getListHref("/sewer-bsc-plan"))}
disabled={isSubmitting}
>
목록
@@ -1,9 +1,10 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import { useSearchParamsState } from "@/shared/lib";
import {
type CmCdAdstDstrComboResponseDto,
useFindGugunCombo,
@@ -28,51 +29,65 @@ const DEFAULT_PAGE_INDEX = 0;
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT = "sewerBscPlanDocNo,desc";
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
const DEFAULT_SEARCH_PARAMS: SewerBscPlanSearchRequestDto = {
sewerBscPlanNm: "",
sewerBscPlanSeComCd: "",
taskSeComCd: "",
admdstCd: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
sort: DEFAULT_SORT,
};
const ALL_SELECT_VALUE = "__ALL__";
const DEFAULT_PARAMS = {
sidoComCd: "",
sggComCd: "",
sewerBscPlanSeComCd: "",
taskSeComCd: "",
sewerBscPlanNm: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
};
function SewerBscPlanListPage() {
const [sewerBscPlanNm, setSewerBscPlanNm] = useState("");
const [sewerBscPlanSeComCd, setSewerBscPlanSeComCd] = useState("");
const [taskSeComCd, setTaskSeComCd] = useState("");
const [sidoComCd, setSidoComCd] = useState("");
const [sggComCd, setSggComCd] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: DEFAULT_PAGE_INDEX,
pageSize: DEFAULT_PAGE_SIZE,
});
const [searchParams, setSearchParams] =
useState<SewerBscPlanSearchRequestDto>(DEFAULT_SEARCH_PARAMS);
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
const [sewerBscPlanNm, setSewerBscPlanNm] = useState(params.sewerBscPlanNm);
const [sewerBscPlanSeComCd, setSewerBscPlanSeComCd] = useState(params.sewerBscPlanSeComCd);
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
const [sggComCd, setSggComCd] = useState(params.sggComCd);
const { data: sidoData } = useFindSidoCombo();
const { data: gugunData } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const searchParams: SewerBscPlanSearchRequestDto = useMemo(
() => ({
sewerBscPlanNm: params.sewerBscPlanNm,
sewerBscPlanSeComCd: params.sewerBscPlanSeComCd,
taskSeComCd: params.taskSeComCd,
admdstCd: params.sggComCd || params.sidoComCd || "",
page: params.page,
size: params.size,
sort: DEFAULT_SORT,
}),
[params],
);
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
setSearchParams({
...DEFAULT_SEARCH_PARAMS,
setParams({
sewerBscPlanNm,
sewerBscPlanSeComCd,
taskSeComCd,
admdstCd: sggComCd || sidoComCd || "",
sidoComCd,
sggComCd,
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
const handlePaginationChange = (next: PaginationState) => {
setParams({ page: next.pageIndex, size: next.pageSize });
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
@@ -163,7 +178,7 @@ function SewerBscPlanListPage() {
<SewerBscPlanList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
onPaginationChange={handlePaginationChange}
/>
</div>
);
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useDataTable, withListQs } from "@/shared/lib";
import {
type SewerBscPlanResponseDto,
type SewerBscPlanSearchRequestDto,
@@ -47,7 +47,7 @@ function SewerBscPlanList({ searchParams, pagination, onPaginationChange }: Sewe
const columns = useMemo(() => createSewerBscPlanColumns(), []);
const handleRowClick = (row: SewerBscPlanResponseDto) => {
router.push(`/sewer-bsc-plan/${row.sewerBscPlanDocNo}?chgCycl=${row.chgCycl}`);
router.push(withListQs(`/sewer-bsc-plan/${row.sewerBscPlanDocNo}?chgCycl=${row.chgCycl}`));
};
const { tableComponent } = useDataTable({
@@ -102,7 +102,9 @@ function SewerBscPlanViewPage({ id, propChgCycl }: SewerBscPlanViewPageProps) {
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<SectionCollapsibleToggle />
</div>
<div className="space-y-4">
<ProgressStatusSection
columns={PROGRESS_STATUS_COLUMNS}
+2
View File
@@ -1,10 +1,12 @@
export { getCookie, removeCookie, setCookie } from "./cookie";
export { dayjs } from "./dayjs";
export { getListHref, withListQs } from "./list-return-href";
export { matchQueryStatus } from "./match-query-status";
export { queryClient } from "./query-client";
export { useAlertStore } from "./use-alert-store";
export { useDataTable } from "./use-data-table";
export { useMenuStore } from "./use-menu-store";
export { useMutationWrapper } from "./use-mutation-wrapper";
export { useSearchParamsState } from "./use-search-params-state";
export { getDefaultFileKey, useTus } from "./use-tus";
export { useUserStore } from "./use-user-store";
@@ -0,0 +1,50 @@
/**
* 목록 조회 조건을 sessionStorage에 저장하고, 목록 복귀 시 복원하기 위한 헬퍼.
*
* - 목록 → 상세: 현재 목록 쿼리를 `list-qs-{listPath}` 키로 저장
* - 상세 → 수정·저장 후 이동: storage를 건드리지 않음 (같은 탭에서 유지)
* - 목록/삭제 후 이동: storage에서 읽어 `/listPath?bizNm=...` 로 복원
*/
function toListSlug(listPath: string) {
return listPath.replace(/^\/+|\/+$/g, "").replaceAll("/", "-");
}
function storageKey(listPath: string) {
return `list-qs-${toListSlug(listPath)}`;
}
/** 상세/수정 href에서 목록 slug를 추출한다. */
function inferListPath(href: string) {
const path = href.split("?")[0] ?? "";
const segments = path.split("/").filter(Boolean);
return segments[0] ?? "";
}
function isOnListPage(slug: string) {
if (typeof window === "undefined" || !slug) return false;
const { pathname } = window.location;
return pathname === `/${slug}` || pathname === `/${slug}/`;
}
/** 목록에 있을 때 현재 쿼리를 저장하고, href는 그대로 반환한다. */
export function withListQs(href: string) {
if (typeof window === "undefined") return href;
const slug = inferListPath(href);
if (!isOnListPage(slug)) return href;
const search = window.location.search.replace(/^\?/, "");
if (search) sessionStorage.setItem(storageKey(slug), search);
else sessionStorage.removeItem(storageKey(slug));
return href;
}
/** sessionStorage에 저장된 목록 쿼리로 href를 만든다. */
export function getListHref(listPath: string) {
if (typeof window === "undefined") return listPath;
const listQs = sessionStorage.getItem(storageKey(listPath));
return listQs ? `${listPath}?${listQs}` : listPath;
}
@@ -0,0 +1,90 @@
"use client";
import { useCallback, useMemo, useRef } from "react";
import { useSearchParams } from "next/navigation";
/**
* 조회 조건을 URL searchParams와 동기화하는 훅.
*
* - URL이 단일 소스 오브 트루스(SSOT)이며, 새로고침·뒤로가기 시에도 조건이 유지된다.
* - 기본값과 동일한 값은 URL에서 제거해 쿼리스트링을 깨끗하게 유지한다.
* - `window.history.replaceState` 기반 shallow 갱신으로 서버 라운드트립 없이 동작한다.
* - 숫자 기본값이 주어진 키는 자동으로 `Number` 파싱한다.
*
* @example
* const { params, setParams, resetParams } = useSearchParamsState({
* keyword: "",
* page: 0,
* size: 10,
* });
*/
export function useSearchParamsState<T extends Record<string, ParamValue>>(
defaults: T,
): UseSearchParamsStateReturn<T> {
const searchParams = useSearchParams();
const defaultsRef = useRef(defaults);
const params = useMemo(
() => parseParams(searchParams, defaultsRef.current),
[searchParams],
);
const setParams = useCallback((partial: Partial<T>) => {
const next = new URLSearchParams(window.location.search);
for (const [key, value] of Object.entries(partial)) {
if (value === undefined || value === defaultsRef.current[key]) {
next.delete(key);
} else {
next.set(key, String(value));
}
}
replaceUrl(next);
}, []);
const resetParams = useCallback(() => {
const next = new URLSearchParams(window.location.search);
for (const key of Object.keys(defaultsRef.current)) {
next.delete(key);
}
replaceUrl(next);
}, []);
return { params, setParams, resetParams };
}
function parseParams<T extends Record<string, ParamValue>>(
searchParams: ReturnType<typeof useSearchParams>,
defaults: T,
): T {
const result = { ...defaults };
for (const key of Object.keys(defaults) as (keyof T)[]) {
const raw = searchParams?.get(key as string) ?? null;
if (raw === null) continue;
if (typeof defaults[key] === "number") {
const parsed = Number(raw);
if (!Number.isNaN(parsed)) result[key] = parsed as T[keyof T];
} else {
result[key] = raw as T[keyof T];
}
}
return result;
}
function replaceUrl(searchParams: URLSearchParams) {
const qs = searchParams.toString();
const url = qs
? `${window.location.pathname}?${qs}`
: window.location.pathname;
window.history.replaceState(null, "", url);
}
type ParamValue = string | number | null;
interface UseSearchParamsStateReturn<T> {
params: T;
setParams: (partial: Partial<T>) => void;
resetParams: () => void;
}
@@ -15,7 +15,11 @@ import {
SectionCollapsible,
UploadedFileChip,
} from "@/shared/ui";
import { attachService, useDownloadAttachmentByAtflCd, useDownloadAttachmentByAtflId } from "@/entities/attach";
import {
attachService,
useDownloadAttachmentByAtflCd,
useDownloadAttachmentByAtflId,
} from "@/entities/attach";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import {
Button,
@@ -373,6 +377,7 @@ function AttachmentsFormSection({
return (
<SectionCollapsible title="첨부파일">
<div className="space-y-6">
{essentialFiles.length === 0 && (
<div className="px-2">
<h3 className="mb-4 text-xl font-semibold">필수 첨부파일</h3>
<Table variant="secondary" className="table-fixed">
@@ -391,14 +396,7 @@ function AttachmentsFormSection({
</TableRow>
</TableHeader>
<TableBody>
{essentialFiles.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center text-muted-foreground">
조회된 필수 첨부파일 항목이 없습니다.
</TableCell>
</TableRow>
) : (
essentialFiles.map((file) => {
{essentialFiles.map((file) => {
const atflCd = file.atchFileGroupId ?? "";
return (
@@ -445,11 +443,11 @@ function AttachmentsFormSection({
</TableCell>
</TableRow>
);
})
)}
})}
</TableBody>
</Table>
</div>
)}
<div className="px-2">
<div className="mb-4 flex items-center justify-between">
@@ -468,9 +466,7 @@ function AttachmentsFormSection({
fileName={item.file?.name ?? item.fileName}
isUploading={isUploading[item.fileKey] ?? false}
onRemove={() => void removeEtcFile(idx)}
onDownload={
item.atflId ? () => downloadByAtflId(item.atflId!) : undefined
}
onDownload={item.atflId ? () => downloadByAtflId(item.atflId!) : undefined}
/>
))}
</div>