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

This commit is contained in:
2026-05-15 13:04:02 +09:00
18 changed files with 410 additions and 40 deletions
@@ -0,0 +1,9 @@
export const dynamic = "force-dynamic";
import { ContentsPage } from "@/components/ui/contents";
export default async function Contents() {
const contsSn = 28;
const menuCd = "24";
return <ContentsPage contsSn={contsSn} menuCd={menuCd} />;
}
@@ -1,3 +1,6 @@
/**
* 물 재이용 사례
*/
import SubPageHeader from "@/components/SubPageHeader";
import BusinessSubNav, { type BusinessSubNavMenu } from "@/components/BusinessSubNav";
@@ -287,7 +287,7 @@ export default function SewageStatusSection() {
const isSubItem = type === "red-item" || type === "blue-item";
if (index === 0) {
console.log(header.id, value);
// console.log(header.id, value);
return (
<td key={`${rowIdx}-${header.id}`} className={`${tableStyles.td} relative`}>
{isSubItem ? (
+1 -1
View File
@@ -58,7 +58,7 @@ const menus: BusinessSubNavMenu[] = [
},
{
id: "status-treated-usage",
label: "연도별 하수처리수 재이용 현황",
label: "용도별 하수처리수 재이용 현황",
},
{
id: "status-treated-facility",
+1 -1
View File
@@ -16,7 +16,7 @@ export default function RootLayout({
<title>물재이용 대민</title>
</head>
<body>
<div className="min-w-[1400px] overflow-x-auto">
<div className="min-w-[1400px]">
{children}
</div>
</body>
-2
View File
@@ -1,12 +1,10 @@
// app/main/layout.tsx
import "../reuse.module.css";
import TopNav from "@/components/TopNav";
import { StoreProvider } from "../store";
export default function ReuseLandingLayout({ children }: { children: React.ReactNode }) {
return (
<StoreProvider>
<TopNav />
<div className="pt-[83px]">{children}</div> {/* TopNav 높이만큼 */}
</StoreProvider>
);
+1 -1
View File
@@ -33,7 +33,7 @@ export const StoreZus = (
return createStore<DmStore>()((set) => {
if (typeof window !== "undefined") {
tokenManager.setToken("site-id-key", process.env.NEXT_PUBLIC_SITE_ID||"DM");
tokenManager.setToken("site-theme-key", "green");
tokenManager.setToken("site-theme-key", "white");
}
return {
+59 -33
View File
@@ -1,3 +1,6 @@
/**
* 우측 메뉴
*/
"use client";
import Link from "next/link";
@@ -47,44 +50,67 @@ export default function BusinessSubNav({
setOpenMenus(initialOpenState);
}, [menus]);
useEffect(() => {
if (!allSections.length) return;
useEffect(() => {
if (!allSections.length) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
const targets = allSections
.map((section) => document.getElementById(section.id))
.filter(Boolean) as HTMLElement[];
if (visible[0]) {
setActiveId(visible[0].target.id);
}
},
{
rootMargin: `-${offsetTop}px 0px -55% 0px`,
threshold: [0.2, 0.35, 0.5, 0.7],
if (!targets.length) return;
let rafId = 0;
const updateActiveSection = () => {
const baseLine = offsetTop + 500;
let nextActiveId = targets[0]?.id ?? "";
for (const el of targets) {
const { top } = el.getBoundingClientRect();
if (top <= baseLine) {
nextActiveId = el.id;
continue;
}
);
const targets = allSections
.map((section) => document.getElementById(section.id))
.filter(Boolean) as HTMLElement[];
break;
}
targets.forEach((el) => observer.observe(el));
setActiveId((prev) => (prev === nextActiveId ? prev : nextActiveId));
};
const hash = window.location.hash.replace("#", "");
const initialActive =
allSections.find((section) => section.id === hash)?.id ??
allSections[0]?.id ??
"";
const requestUpdate = () => {
if (rafId) return;
setActiveId(initialActive);
rafId = window.requestAnimationFrame(() => { // 예약번호 반환
updateActiveSection();
rafId = 0;
});
};
return () => observer.disconnect();
}, [allSections, offsetTop]);
const hash = window.location.hash.replace("#", "");
const initialActive =
allSections.find((section) => section.id === hash)?.id ??
allSections[0]?.id ??
"";
useEffect(() => {
if (!activeId) return;
setActiveId(initialActive);
updateActiveSection();
window.addEventListener("scroll", requestUpdate, { passive: true });
window.addEventListener("resize", requestUpdate);
return () => {
window.removeEventListener("scroll", requestUpdate);
window.removeEventListener("resize", requestUpdate);
if (rafId) {
window.cancelAnimationFrame(rafId);
}
};
}, [allSections, offsetTop]);
useEffect(() => {
if (!activeId) return;
const parentMenu = menus.find((menu) =>
(menu.sections ?? []).some((section) => section.id === activeId)
@@ -93,13 +119,13 @@ export default function BusinessSubNav({
if (!parentMenu) return;
setOpenMenus(() => {
const nextOpenState: Record<string, boolean> = {};
const nextOpenState: Record<string, boolean> = {};
menus.forEach((menu) => {
menus.forEach((menu) => {
nextOpenState[menu.key] = menu.key === parentMenu.key;
});
});
return nextOpenState;
return nextOpenState;
});
}, [activeId, menus]);
@@ -0,0 +1,26 @@
// apps/dm/src/entities/content/
// contents-api.ts
import { unstable_cache } from "next/cache";
import { ENDPOINT } from "@/src/shared/config";
export const getCachedContentDetail = unstable_cache(
async (contsSn: number, menuCd: string) => {
const baseUrl = process.env.NEXT_PUBLIC_PROXY_HOST || "http://localhost:3015";
const res = await fetch(
`${baseUrl}${ENDPOINT.CMS_SERVICE.CONTENTS}/${contsSn}?menuCd=${menuCd}`,
{
next: { revalidate: 3600, tags: [`content:${contsSn}`] },
}
);
if (!res.ok) {
console.log(res);
throw new Error("Failed to fetch content");
}
return res.json();
},
["dm-content-detail"],
);
@@ -0,0 +1,18 @@
/**
* 컨텐츠 서버 페이지
*/
import { getCachedContentDetail } from "./contents-api";
import { ContentsPageClient } from "./contentsPageClient";
type ContentsPageProps = {
contsSn: number;
menuCd: string;
};
async function ContentsPage({ contsSn, menuCd }: ContentsPageProps) {
const initialContent = await getCachedContentDetail(contsSn, menuCd);
return <ContentsPageClient initialContent={initialContent} />;
}
export { ContentsPage };
@@ -0,0 +1,37 @@
"use client";
import { useEffect, useRef } from "react";
import type { ContentsDetail } from "@/src/entities/content/model/contents-interface";
type ContentsPageClientProps = {
initialContent: ContentsDetail;
};
function ContentsPageClient({ initialContent }: ContentsPageClientProps) {
const shadowRootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!shadowRootRef.current || !initialContent?.contsCn) return;
const shadowRoot =
shadowRootRef.current.shadowRoot ??
shadowRootRef.current.attachShadow({ mode: "open" });
shadowRoot.innerHTML = "";
const styleElement = document.createElement("style");
styleElement.textContent = `div { font-size: 16px; line-height: 1.5; }`;
shadowRoot.appendChild(styleElement);
const contentElement = document.createElement("div");
contentElement.innerHTML = initialContent.contsCn;
shadowRoot.appendChild(contentElement);
}, [initialContent]);
return (
<div className="card card-border !mt-6 !p-6">
<div ref={shadowRootRef} />
</div>
);
}
export { ContentsPageClient };
+1
View File
@@ -0,0 +1 @@
export { ContentsPage } from "./contentsPage";
+1 -1
View File
@@ -60,7 +60,7 @@ export function middleware(req: NextRequest) {
if (pathname.startsWith("/user-service")
|| pathname.startsWith("/cms-service")) {
let queryParams = searchParams.toString();
const queryParams = searchParams.toString();
const requestHeaders = new Headers(req.headers);
const newUrl = `${serverApiUrl}${pathname}${queryParams ? `?${queryParams}` : ""}`;
+4
View File
@@ -48,6 +48,10 @@ const nextConfig: NextConfig = {
source: "/operation-service/:path*",
destination: `${subDomain}/operation-service/:path*`,
},
{
source: "/cms-service/:path*",
destination: `${subDomain}/cms-service/:path*`,
},
],
fallback: [],
};
@@ -0,0 +1,101 @@
import { client } from "@/src/shared/api";
import { ENDPOINT } from "@/src/shared/config";
import type {
ContentsService,
ContentsParams,
ContentsList,
ContentsFormData,
ContentsVersionParams,
ContentsVersionList,
ContentsDetail,
} from "../model/contents-interface";
export class ContentsApiService implements ContentsService {
public async getContentsList(params: ContentsParams): Promise<ContentsList> {
const response = await client.get<ContentsList>(
`${ENDPOINT.CMS_SERVICE.CONTENTS}`,
{ params },
);
return response.data;
}
public async getLatestApprovedContentsInfo(
contsSn: number,
menuCd: string,
): Promise<ContentsDetail> {
const response = await client.get<ContentsDetail>(
`${ENDPOINT.CMS_SERVICE.CONTENTS}/${contsSn}`,
{ params: { menuCd } },
);
return response.data;
}
public async getContentsVersionList(
params: ContentsVersionParams,
): Promise<ContentsVersionList> {
const response = await client.get<ContentsVersionList>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`,
{ params },
);
return response.data;
}
public async getLatestContentsInfo(
contsSn: number,
contsVer: number,
): Promise<ContentsDetail> {
const response = await client.get<ContentsDetail>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`,
);
return response.data;
}
public async createContents(data: ContentsFormData): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`,
data,
);
return response.data;
}
public async updateContents(
contsSn: number,
contsVer: number,
data: ContentsFormData,
): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`,
data,
);
return response.data;
}
public async deleteContents(
contsSn: number,
contsVer: number,
): Promise<void> {
await client.delete(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`,
);
}
public async requestApprove(
contsSn: number,
contsVer: number,
): Promise<void> {
await client.put(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approveRequest`,
);
}
public async approveContents(
contsSn: number,
contsVer: number,
): Promise<void> {
await client.put(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approve`,
);
}
}
export const ContentsApi = new ContentsApiService();
+8
View File
@@ -0,0 +1,8 @@
export { ContentsApi } from "./api/contents-service";
export {
CONTENTS_APPROVE_STATUS,
contentsFormSchema,
type Contents,
type ContentsParams,
type ContentsFormData,
} from "./model/contents-interface";
@@ -0,0 +1,34 @@
interface SortConfig {
empty: boolean;
sorted: boolean;
unsorted: boolean;
}
interface PageableConfig {
sort: SortConfig;
offset: number;
pageNumber: number;
pageSize: number;
paged: boolean;
unpaged: boolean;
}
export interface PageableList<T> {
content: T[];
pageable: PageableConfig;
sort: SortConfig;
totalPages: number;
totalElements: number;
number: number;
numberOfElements: number;
size: number;
first: boolean;
last: boolean;
empty: boolean;
}
export interface PageableParams {
page?: number;
size?: number;
sort?: string;
}
@@ -0,0 +1,105 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "./api-interface";
export const CONTENTS_APPROVE_STATUS = {
DRAFT: "01",
REQUESTED: "02",
APPROVED: "99",
} as const;
export type ContentsApproveStatus =
(typeof CONTENTS_APPROVE_STATUS)[keyof typeof CONTENTS_APPROVE_STATUS];
export interface ContentsParams extends PageableParams {
contsTtl?: string;
aprvContsVer?: number;
useYn?: string;
}
export interface ContentsVersionParams extends PageableParams {
contsSn?: number;
contsCn?: string;
aprvCd?: ContentsApproveStatus;
cclCd?: string;
koglCd?: string;
tag?: string;
useYn?: string;
}
export interface ContentsVersion {
contsSn: number;
contsVer: number;
contsCn: string;
useYn: string;
}
export const contentsSchema = z.object({
contsSn: z.number(),
contsTtl: z.string(),
aprvContsVer: z.number(),
useYn: z.string(),
mdfcnDt: z.string(),
mdfr: z.string(),
mdfrNm: z.string(),
aprvDt: z.string(),
autzrNm: z.string(),
workAprvCd: z.string(),
workAprvNm: z.string(),
workContsVer: z.number(),
});
export const contentsDetailSchema = z.object({
contsSn: z.number(),
contsVer: z.number(),
contsTtl: z.string(),
contsCn: z.string(),
contsRmrk: z.string().nullish(),
aprvCd: z.enum([
CONTENTS_APPROVE_STATUS.DRAFT,
CONTENTS_APPROVE_STATUS.REQUESTED,
CONTENTS_APPROVE_STATUS.APPROVED,
]),
cclCd: z.string().nullish(),
koglCd: z.string().nullish(),
tag: z.string().nullish(),
aprvDt: z.string(),
autzr: z.string(),
useYn: z.string(),
menuPicUserUnqIdNm: z.string().nullish(),
});
export const contentsFormSchema = contentsDetailSchema.omit({
contsVer: true,
aprvDt: true,
autzr: true,
});
export type Contents = z.infer<typeof contentsSchema>;
export type ContentsDetail = z.infer<typeof contentsDetailSchema>;
export type ContentsFormData = z.infer<typeof contentsFormSchema>;
export type ContentsList = PageableList<Contents>;
export type ContentsVersionList = PageableList<ContentsVersion>;
export interface ContentsService {
getContentsList(params: ContentsParams): Promise<ContentsList>;
getLatestApprovedContentsInfo(
contsSn: number,
menuCd: string,
): Promise<ContentsDetail>;
getContentsVersionList(
params: ContentsVersionParams,
): Promise<ContentsVersionList>;
getLatestContentsInfo(
contsSn: number,
contsVer: number,
): Promise<ContentsDetail>;
createContents(data: ContentsFormData): Promise<void>;
updateContents(
contsSn: number,
contsVer: number,
data: ContentsFormData,
): Promise<void>;
deleteContents(contsSn: number, contsVer: number): Promise<void>;
requestApprove(contsSn: number, contsVer: number): Promise<void>;
approveContents(contsSn: number, contsVer: number): Promise<void>;
}