Compare commits

...
8 Commits
Author SHA1 Message Date
jcchoi f816bf005e 파일보안 간격 조절
Deploy Frontend / deploy (push) Successful in 9m15s
2026-10-12 11:54:55 +09:00
jcchoi 791ad6ce37 파일보안 줄바꿈 2026-10-12 11:50:50 +09:00
jcchoi 4bc802cee2 파일보완 내용 자동 줄빠굼 2026-10-12 10:55:19 +09:00
jcchoi 4662c8c896 선형 구조 수정 2026-10-12 10:50:09 +09:00
jcchoi 7058560a03 메뉴 배치 수정 2026-10-12 10:09:52 +09:00
jcchoi 79a5999ad5 콘텐츠의 선형 구조 2026-10-12 10:09:39 +09:00
jcchoi 1aeded5da2 페이지 타이틀 2026-10-12 10:09:16 +09:00
jcchoi e8d1d882c2 페이지 타이틀 수정 2026-10-12 10:01:22 +09:00
31 changed files with 223 additions and 41 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { Providers } from "@/app/providers";
import "@/app/styles/globals.css";
export const metadata: Metadata = {
title: "행정지원시스템",
title: "유역하수도통합플랫폼 Hasuon",
description: "행정지원시스템",
};
+1 -1
View File
@@ -1,7 +1,7 @@
import "@/app/styles/globals.css";
export const metadata = {
title: "디지털 행정지원 시스템",
title: "유역하수도통합플랫폼 Hasuon",
};
export default function AuthLayout({ children }: { children: React.ReactNode }) {
+1 -1
View File
@@ -6,7 +6,7 @@ import { Providers } from "@/app/providers";
import "@/app/styles/globals.css";
export const metadata: Metadata = {
title: "디지털 행정지원 시스템",
title: "유역하수도통합플랫폼 Hasuon",
description: "디지털 행정지원 시스템",
};
+1 -1
View File
@@ -4,7 +4,7 @@ import Image from "next/image";
import "@/app/styles/globals.css";
export const metadata: Metadata = {
title: "디지털 행정지원 시스템",
title: "유역하수도통합플랫폼 Hasuon",
description: "페이지가 존재하지 않습니다. 관리자에게 문의하여 주십시오.",
};
+1
View File
@@ -36,6 +36,7 @@ export function BaseLayoutWrapper({ children }: BaseLayoutWrapperProps) {
authService={authService}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
sidebarTriggerPlacement="lnb"
>
<PageLayout>{children}</PageLayout>
</BaseLayout>
@@ -53,21 +53,52 @@ export function createFileVrfcSplmntColumns() {
),
}),
columnHelper.accessor("fileSeComCd", {
header: () => <p className="whitespace-nowrap">파일유형</p>,
meta: { align: "left" },
header: "파일유형",
// 고정 너비 + 자동 줄바꿈 (긴 텍스트로 표가 가로로 늘어나지 않도록)
meta: { align: "left", width: 100 },
cell: ({ getValue }) => (
<p>{formatOptional(FILE_SE_COM_CD_MAP[getValue() as keyof typeof FILE_SE_COM_CD_MAP])}</p>
<p className="whitespace-normal break-words">
{formatOptional(FILE_SE_COM_CD_MAP[getValue() as keyof typeof FILE_SE_COM_CD_MAP])}
</p>
),
}),
columnHelper.accessor("fileSeNm", {
header: () => <p className="whitespace-nowrap">파일구분</p>,
meta: { align: "left" },
cell: ({ getValue }) => <p className="whitespace-nowrap">{formatOptional(getValue())}</p>,
header: "파일구분",
meta: { align: "left", width: 320 },
// 표 레이아웃과 무관하게 셀 내용 너비를 320px 로 고정하고 넘치는 텍스트는 자동 줄바꿈
cell: ({ getValue }) => (
<p
style={{
width: 320,
maxWidth: 320,
// 검증내역과 글자가 붙어 보이지 않도록 오른쪽 여백
paddingRight: 12,
whiteSpace: "normal",
wordBreak: "normal",
overflowWrap: "anywhere",
}}
>
{formatOptional(getValue())}
</p>
),
}),
columnHelper.accessor("vrfcRsltCn", {
header: "검증내역",
// 너비 미지정: 나머지 영역을 사용, 넘치는 텍스트는 자동 줄바꿈
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
cell: ({ getValue }) => (
<p
style={{
// 파일구분과 글자가 붙어 보이지 않도록 왼쪽 여백
paddingLeft: 12,
whiteSpace: "normal",
wordBreak: "normal",
overflowWrap: "anywhere",
}}
>
{formatOptional(getValue())}
</p>
),
}),
];
}
@@ -116,6 +116,8 @@ function FileVrfcSplmntDialog({
},
dataTableOptions: {
caption: "파일보완 목록으로 선택, 파일유형, 파일구분, 검증내역 정보 제공",
// 열 너비를 고정해 긴 텍스트는 셀 안에서 줄바꿈
className: "table-fixed",
viewPagination: false,
},
});
@@ -86,7 +86,13 @@ function ChecklistSection({
>
<TabsList>
{visibleReviewSteps.map((stepCode) => (
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl">
<TabsTrigger
key={stepCode}
value={stepCode}
id={`fnrs-cklst-step-tab-${stepCode}`}
aria-controls="fnrs-cklst-task-tabs"
className="!px-6 !py-3 text-xl"
>
{CHECKLIST_TAB_LABELS[stepCode]}
</TabsTrigger>
))}
@@ -96,6 +102,7 @@ function ChecklistSection({
{taskCodes.length > 0 ? (
<Tabs
id="fnrs-cklst-task-tabs"
value={selectedTaskCode ?? taskCodes[0]}
onValueChange={setSelectedTaskCode}
className="w-full"
@@ -107,6 +114,8 @@ function ChecklistSection({
<TabsTrigger
key={code}
value={code}
id={`fnrs-cklst-tab-${code}`}
aria-controls={`fnrs-cklst-panel-${code}`}
className="!px-6 !py-3 text-xl [&:after]:hidden"
>
{TASK_CODE_LABELS[code] ?? code}
@@ -120,7 +129,15 @@ function ChecklistSection({
</div>
{taskCodes.map((code) => (
<TabsContent key={code} value={code} className="mt-0 border-0 p-0 shadow-none ring-0">
<TabsContent
key={code}
value={code}
id={`fnrs-cklst-panel-${code}`}
aria-labelledby={`fnrs-cklst-tab-${code}`}
className="mt-0 border-0 p-0 shadow-none ring-0"
>
{/* 선택된 탭(검토단계·업무구분)의 패널임을 알 수 있도록 숨김 제목 제공 */}
<h3 className="sr-only">{CHECKLIST_TAB_LABELS[selectedReviewStep]} {TASK_CODE_LABELS[code] ?? code} 체크리스트</h3>
<ChecklistTaskPanel
selectedReviewStep={selectedReviewStep}
selectedRows={getChecklistDisplayRows(cklstResultList, code)}
@@ -85,7 +85,13 @@ function ChecklistSection({
>
<TabsList>
{visibleReviewSteps.map((stepCode) => (
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl">
<TabsTrigger
key={stepCode}
value={stepCode}
id={`instl-cklst-step-tab-${stepCode}`}
aria-controls="instl-cklst-task-tabs"
className="!px-6 !py-3 text-xl"
>
{CHECKLIST_TAB_LABELS[stepCode]}
</TabsTrigger>
))}
@@ -95,6 +101,7 @@ function ChecklistSection({
{taskCodes.length > 0 ? (
<Tabs
id="instl-cklst-task-tabs"
value={selectedTaskCode ?? taskCodes[0]}
onValueChange={setSelectedTaskCode}
className="w-full"
@@ -106,6 +113,8 @@ function ChecklistSection({
<TabsTrigger
key={code}
value={code}
id={`instl-cklst-tab-${code}`}
aria-controls={`instl-cklst-panel-${code}`}
className="!px-6 !py-3 text-xl [&:after]:hidden"
>
{TASK_CODE_LABELS[code] ?? code}
@@ -119,7 +128,15 @@ function ChecklistSection({
</div>
{taskCodes.map((code) => (
<TabsContent key={code} value={code} className="mt-0 border-0 p-0 shadow-none ring-0">
<TabsContent
key={code}
value={code}
id={`instl-cklst-panel-${code}`}
aria-labelledby={`instl-cklst-tab-${code}`}
className="mt-0 border-0 p-0 shadow-none ring-0"
>
{/* 선택된 탭(검토단계·업무구분)의 패널임을 알 수 있도록 숨김 제목 제공 */}
<h3 className="sr-only">{CHECKLIST_TAB_LABELS[selectedReviewStep]} {TASK_CODE_LABELS[code] ?? code} 체크리스트</h3>
<ChecklistTaskPanel
selectedReviewStep={selectedReviewStep}
selectedRows={getChecklistDisplayRows(cklstResultList, code)}
@@ -86,7 +86,13 @@ function ChecklistSection({
>
<TabsList>
{visibleReviewSteps.map((stepCode) => (
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl">
<TabsTrigger
key={stepCode}
value={stepCode}
id={`sewer-cklst-step-tab-${stepCode}`}
aria-controls="sewer-cklst-task-tabs"
className="!px-6 !py-3 text-xl"
>
{CHECKLIST_TAB_LABELS[stepCode]}
</TabsTrigger>
))}
@@ -96,6 +102,7 @@ function ChecklistSection({
{taskCodes.length > 0 ? (
<Tabs
id="sewer-cklst-task-tabs"
value={selectedTaskCode ?? taskCodes[0]}
onValueChange={setSelectedTaskCode}
className="w-full"
@@ -107,6 +114,8 @@ function ChecklistSection({
<TabsTrigger
key={code}
value={code}
id={`sewer-cklst-tab-${code}`}
aria-controls={`sewer-cklst-panel-${code}`}
className="!px-6 !py-3 text-xl [&:after]:hidden"
>
{TASK_CODE_LABELS[code] ?? code}
@@ -120,7 +129,15 @@ function ChecklistSection({
</div>
{taskCodes.map((code) => (
<TabsContent key={code} value={code} className="mt-0 border-0 p-0 shadow-none ring-0">
<TabsContent
key={code}
value={code}
id={`sewer-cklst-panel-${code}`}
aria-labelledby={`sewer-cklst-tab-${code}`}
className="mt-0 border-0 p-0 shadow-none ring-0"
>
{/* 선택된 탭(검토단계·업무구분)의 패널임을 알 수 있도록 숨김 제목 제공 */}
<h3 className="sr-only">{CHECKLIST_TAB_LABELS[selectedReviewStep]} {TASK_CODE_LABELS[code] ?? code} 체크리스트</h3>
<ChecklistTaskPanel
selectedReviewStep={selectedReviewStep}
selectedRows={getChecklistDisplayRows(cklstResultList, code)}
@@ -190,7 +190,13 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
{checklistTabs.map((tab) => (
<TabsTrigger key={tab.code} value={tab.code} className="!px-6 !py-3 text-xl">
<TabsTrigger
key={tab.code}
value={tab.code}
id={`pbadms-cklst-tab-${tab.code}`}
aria-controls={`pbadms-cklst-panel-${tab.code}`}
className="!px-6 !py-3 text-xl"
>
{tab.label}
</TabsTrigger>
))}
@@ -198,7 +204,15 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
{checklistTabs.map((tab) => {
return (
<TabsContent key={tab.code} value={tab.code} className="space-y-4">
<TabsContent
key={tab.code}
value={tab.code}
id={`pbadms-cklst-panel-${tab.code}`}
aria-labelledby={`pbadms-cklst-tab-${tab.code}`}
className="space-y-4"
>
{/* 선택된 탭(업무구분)의 패널임을 알 수 있도록 숨김 제목 제공 */}
<h3 className="sr-only">{tab.label} 체크리스트</h3>
<Table caption={`${tab.label} 체크리스트 목록으로 검토항목, 주요 검토내용, 검토 결과 정보 제공`} variant="secondary">
<TableHeader>
<TableRow>
+1 -1
View File
@@ -1,7 +1,7 @@
import "@/app/styles/base.css";
export const metadata = {
title: "유역하수도 포탈",
title: "유역하수도통합플랫폼 Hasuon",
};
export default function AuthLayout({
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { App } from "@/app";
export const metadata: Metadata = {
title: "유역하수도 포탈",
title: "유역하수도통합플랫폼 Hasuon",
};
export default App;
+1 -1
View File
@@ -5,7 +5,7 @@ import "@/app/styles/base.css";
import "@/app/styles/components.css";
export const metadata: Metadata = {
title: "유역하수도 포탈",
title: "유역하수도통합플랫폼 Hasuon",
description: "페이지가 존재하지 않습니다. 관리자에게 문의하여 주십시오.",
};
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { App } from "@/app";
export const metadata: Metadata = {
title: "유역하수도 통합 정보플랫폼",
title: "유역하수도통합플랫폼 Hasuon",
};
export default App;
+1 -1
View File
@@ -1,7 +1,7 @@
import "@/app/styles/base.css";
export const metadata = {
title: "하수도플랫폼 통합관리시스템",
title: "유역하수도통합플랫폼 Hasuon",
};
export default function AuthLayout({
+1 -1
View File
@@ -3,7 +3,7 @@ import type { Metadata } from "next";
import { App } from "@/app";
export const metadata: Metadata = {
title: "하수도플랫폼 통합관리시스템",
title: "유역하수도통합플랫폼 Hasuon",
};
export default App;
+1 -1
View File
@@ -5,7 +5,7 @@ import "@/app/styles/base.css";
import "@/app/styles/components.css";
export const metadata: Metadata = {
title: "하수도플랫폼 통합관리시스템",
title: "유역하수도통합플랫폼 Hasuon",
description: "페이지가 존재하지 않습니다. 관리자에게 문의하여 주십시오.",
};
+1 -1
View File
@@ -1,7 +1,7 @@
import "@/src/app/styles/base.css";
export const metadata = {
title: "유역하수도 통합 정보플랫폼",
title: "유역하수도통합플랫폼 Hasuon",
};
export default function AuthLayout({
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { App } from "@/src/app";
export const metadata: Metadata = {
title: "하수처리시설 운영실태",
title: "유역하수도통합플랫폼 Hasuon",
description: "하수처리시설 운영실태 분석시스템입니다",
keywords: ["운영실태"],
openGraph: {
+1 -1
View File
@@ -1,7 +1,7 @@
import "../../../src/app/styles/base.css";
export const metadata = {
title: "유역하수도 통합 정보플랫폼",
title: "유역하수도통합플랫폼 Hasuon",
};
export default function AuthLayout({
+1 -1
View File
@@ -20,7 +20,7 @@ import "../src/app/styles/components.css";
import "../src/app/styles/grid-ellipsis.css";
export const metadata: Metadata = {
title: "디지털 하수관로",
title: "유역하수도통합플랫폼 Hasuon",
description: "디지털 하수관로 시스템입니다",
keywords: ["하수관로"],
openGraph: {
+1 -1
View File
@@ -1,7 +1,7 @@
import "@/app/styles/base.css";
export const metadata = {
title: "하수도통계 관리시스템",
title: "유역하수도통합플랫폼 Hasuon",
};
export default function AuthLayout({
+1 -1
View File
@@ -3,7 +3,7 @@ import type { Metadata } from "next";
import { App } from "@/app";
export const metadata: Metadata = {
title: "하수도통계 관리시스템",
title: "유역하수도통합플랫폼 Hasuon",
};
export default App;
+1 -1
View File
@@ -4,7 +4,7 @@ import Image from "next/image";
import "@/app/styles/base.css";
export const metadata: Metadata = {
title: "하수도통계 관리시스템",
title: "유역하수도통합플랫폼 Hasuon",
description: "페이지가 존재하지 않습니다. 관리자에게 문의하여 주십시오.",
};
+1 -1
View File
@@ -1,7 +1,7 @@
import "@/src/app/styles/base.css";
export const metadata = {
title: "유역하수도 통합 정보플랫폼",
title: "유역하수도통합플랫폼 Hasuon",
};
export default function AuthLayout({
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { App } from "../src/app";
export const metadata: Metadata = {
title: "물재이용매칭시스템",
title: "유역하수도통합플랫폼 Hasuon",
description: "물재이용매칭 시스템입니다",
keywords: ["물재이용매칭"],
openGraph: {
@@ -136,11 +136,14 @@ export function Header({
authService,
tokenManager,
viewOnly,
showSidebarTrigger = true,
}: {
authService?: AuthService;
/** @deprecated authService를 사용하세요. */
tokenManager?: TokenManager;
viewOnly?: boolean;
/** false 이면 LNB 토글 버튼을 헤더에 렌더링하지 않고 같은 크기의 자리만 유지 (BaseLayout 이 LNB 바로 앞에 렌더링) */
showSidebarTrigger?: boolean;
}) {
const [mounted, setMounted] = useState(false);
const [logoWidth, setLogoWidth] = useState(0);
@@ -276,7 +279,11 @@ export function Header({
>
<div className="flex w-full items-center justify-between px-2 sm:w-auto sm:justify-start sm:px-4 md:px-6">
<div className="flex items-center gap-2 sm:gap-4">
<SidebarTrigger className="size-12 !bg-transparent p-2 hover:!bg-accent [&>svg]:!size-full [&>svg]:text-foreground" />
{showSidebarTrigger ? (
<SidebarTrigger className="size-12 !bg-transparent p-2 hover:!bg-accent [&>svg]:!size-full [&>svg]:text-foreground" />
) : (
<span aria-hidden="true" className="size-12 shrink-0" />
)}
<h1>
{/* 로고·사이트명 클릭 시 메인으로 이동 */}
<a
@@ -3,7 +3,7 @@ import type { QueryClient } from "@tanstack/react-query";
import { QueryProvider } from "../../provider/query-provider";
import { PopupProvider } from "../../provider/popup-provider";
import type { MenuItem } from "../../api";
import { SidebarProvider } from "../sidebar";
import { SidebarProvider, SidebarTrigger } from "../sidebar";
import { LNB } from "../lnb";
import { Header } from "../header";
import { Footer } from "../footer";
@@ -17,6 +17,7 @@ export function BaseLayout({
tokenManager,
currentPath,
handleMenuClick,
sidebarTriggerPlacement = "header",
}: {
children: React.ReactNode;
queryClient?: QueryClient;
@@ -25,7 +26,15 @@ export function BaseLayout({
tokenManager?: TokenManager;
currentPath?: string;
handleMenuClick?: (item: MenuItem) => void;
/**
* LNB 토글 버튼의 DOM 위치.
* - "header": 헤더 로고(h1) 앞 (기존)
* - "lnb": LNB 바로 앞 (콘텐츠 선형 구조 준수). 화면 위치는 CSS 로 헤더 좌측에 동일하게 유지
*/
sidebarTriggerPlacement?: "header" | "lnb";
}) {
const isTriggerBeforeLnb = sidebarTriggerPlacement === "lnb";
return (
<QueryProvider queryClient={queryClient}>
<PopupProvider>
@@ -35,7 +44,15 @@ export function BaseLayout({
<Header
authService={authService}
tokenManager={authService ? undefined : tokenManager}
showSidebarTrigger={!isTriggerBeforeLnb}
/>
{/* 접기/펼치기 버튼을 제어 대상(LNB) 바로 앞에 배치하고, 화면에서는 헤더 좌측(기존 위치)에 고정 */}
{isTriggerBeforeLnb && (
<SidebarTrigger
aria-controls="lnb"
className="fixed left-2 top-0 z-40 size-12 !bg-transparent p-2 hover:!bg-accent [&>svg]:!size-full [&>svg]:text-foreground sm:left-4 sm:top-[1.5rem] md:left-6"
/>
)}
<div className="flex">
<LNB
currentPath={currentPath}
@@ -271,15 +271,38 @@ export function LNB({
);
}
// 1뎁스 단일 메뉴: 하위 메뉴가 있는 메뉴(MenuGroup)와 동일한 마크업·간격·색상으로 표시
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
className={menuClassName}
className={cn(menuClassName, "flex items-center pr-2")}
data-matched={isMatched}
tooltip={item.menuKornNm}
>
<a {...linkProps}>{menuContent}</a>
<a {...linkProps}>
<div className="flex items-center gap-2">
<i
className={cn(
"diveicon",
`di-${item.iconNm}`,
isMatched ? "text-primary" : "text-label",
!hideLabel && "ml-2",
)}
/>
<span
className={cn(
"whitespace-nowrap",
isMatched
? "text-primary"
: "text-label group-hover/menu-button:text-foreground",
hideLabel && "hidden",
)}
>
{item.menuKornNm}
</span>
</div>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
);
@@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
@@ -9,8 +9,26 @@ import {
DialogTrigger,
} from "../dialog";
import { useQuery } from "@tanstack/react-query";
import { getMenuDetail } from "../../api";
import { getMenuDetail, getSiteInfo } from "../../api";
import { ENDPOINT } from "../../config";
import { getCookie } from "../../lib";
/** 공통 레이아웃 밖 페이지(대시보드·로그인·오류 등)의 문서 제목. 각 앱 layout metadata 의 title 과 동일하게 유지 */
const PLATFORM_TITLE = "유역하수도통합플랫폼 Hasuon";
const DASHBOARD_MENU_NAME = "대시보드";
/** 문서 제목: "유역하수도통합플랫폼 Hasuon - 사이트명 > 메뉴1 > 메뉴2" (대시보드는 플랫폼 제목만) */
function buildDocumentTitle(siteNm: string | undefined, title: string, menuPaths: string[]) {
const menus = (menuPaths.length > 0 ? menuPaths : [title]).filter(Boolean);
const isDashboard =
menus[menus.length - 1] === DASHBOARD_MENU_NAME ||
(typeof window !== "undefined" && /\/dashboard\/?$/.test(window.location.pathname));
if (menus.length === 0 || isDashboard) return PLATFORM_TITLE;
return `${PLATFORM_TITLE} - ${[siteNm, ...menus].filter(Boolean).join(" > ")}`;
}
const PageTitle = ({
title,
@@ -29,6 +47,24 @@ const PageTitle = ({
enabled: !!menuCd,
});
// 헤더와 같은 쿼리 키를 사용해 캐시된 사이트 정보를 재사용
const siteId = getCookie("site-id-key") ?? "";
const { data: siteInfo } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SITES, siteId],
queryFn: () => getSiteInfo(siteId),
enabled: !!siteId,
});
const documentTitle = buildDocumentTitle(siteInfo?.siteNm, title, menuPaths);
// 메뉴 이동 시마다 문서 제목 갱신, 레이아웃을 벗어나면 플랫폼 제목으로 복원
useEffect(() => {
document.title = documentTitle;
return () => {
document.title = PLATFORM_TITLE;
};
}, [documentTitle]);
return (
<div className="flex w-full items-center justify-between px-[1.8rem] pt-[2rem]">
<div className="flex items-center gap-3">