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

This commit is contained in:
ljpark
2025-12-12 14:51:44 +09:00
31 changed files with 1099 additions and 840 deletions
+1 -1
View File
@@ -34,7 +34,7 @@
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"next": "15.3.6",
"next": "15.3.8",
"next-qrcode": "^2.5.1",
"next-themes": "^0.4.6",
"react-chartjs-2": "^5.3.0",
@@ -11,9 +11,11 @@ import {
type MyPostList,
type PostParams,
type MyPostParams,
type NoticePostParams,
type PostFormData,
type PostUpdateFormData,
type PostService,
type NoticePostList,
} from "../model/post-interface";
export class PostApiService implements PostService {
@@ -28,6 +30,17 @@ export class PostApiService implements PostService {
return response.data;
}
public async getNoticePostList(
params?: NoticePostParams,
): Promise<NoticePostList> {
const response = await client.get<NoticePostList>(
`${ENDPOINT.BOARD_SERVICE.POSTS}/recentPosts`,
{ params },
);
return response.data;
}
public async getMyPostList(
params?: MyPostParams,
): Promise<MyPostList> {
+2
View File
@@ -4,11 +4,13 @@ export {
postUpdateFormSchema,
type Post,
type MyPost,
type NoticePost,
type MovePost,
type DeletePost,
type PostList,
type PostParams,
type MyPostParams,
type NoticePostParams,
type PostFormData,
type PostUpdateFormData,
type PostService,
@@ -12,6 +12,20 @@ export interface PostParams extends PageableParams {
ansUseYn?: string;
}
export interface NoticePostParams extends PageableParams {
keywordType?: string;
keyword?: string;
bbsId?: number | null;
delYn?: string;
frDateYmd?: string;
toDateYmd?: string;
}
export interface NoticePost extends Post {
bbsNm: string;
bbsTypeCd: string;
}
export interface MyPostParams extends PageableParams {
keywordType?: string;
keyword?: string;
@@ -99,6 +113,7 @@ export const postUpdateFormSchema = postFormSchema.omit({
export type PostList = PageableList<Post>;
export type MyPostList = PageableList<MyPost>;
export type NoticePostList = PageableList<NoticePost>;
export type PostFormData = z.infer<typeof postFormSchema>;
export type PostUpdateFormData = z.infer<typeof postUpdateFormSchema>;
+34
View File
@@ -0,0 +1,34 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
export interface Banner {
bnrSn: number;
bnrTypeCd: string;
bnrTypeNm: string;
bnrTtl: string;
lnkgUrlAddr: string;
useYn: string;
atflCd: string;
mdfcnDt: string;
mdfr: string;
mdfrNm: string;
}
export const getBannerList = async (siteId: string) => {
const response = await client.get<Banner[]>(
`${ENDPOINT.CMS_SERVICE.BANNERS}/${siteId}`,
);
return response.data;
};
export const getImageUrlByAtflCd = async (atflCd: string): Promise<string> => {
if (!atflCd) return "";
const response = await client.get<Blob>(
`${ENDPOINT.ATTACH_SERVICE.IMAGES}/atflCd/${atflCd}`,
{
responseType: "blob",
},
);
const blob = response.data;
return URL.createObjectURL(blob);
};
@@ -378,85 +378,79 @@ export function KoreaBasinMap() {
// 스타일은 필요에 따라 tailwind, css 등으로 커스터마이즈하세요.
return (
<div className="col-span-3 h-200 !p-0 relative z-10">
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">유역별 정보</h3>
</div>
<div
ref={stageRef}
id="stage"
style={
{
width: "100%",
height: "560px",
position: "relative",
zIndex: 20,
"--map-inset": "0",
} as React.CSSProperties
}
>
<div
className={styles.bgLayer}
id="bgLayer"
/>
<div
ref={svgLayerRef}
id="svgLayer"
className={`${styles.svgLayer} ${styles.svgContainer}`}
style={{ width: "100%", height: "100%" }}
/>
<div
ref={labelsLayerRef}
id="labelsLayer"
className={styles.labelsLayer}
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
}}
/>
<div
ref={badgeRef}
id="badge"
className={styles.badge}
style={{ position: "absolute", zIndex: 10, display: "block" }}
/>
<div
ref={popupRef}
id="popup"
className={styles.popup}
style={{
position: "absolute",
zIndex: 20,
background: "#fff",
border: "1px solid #ccc",
borderRadius: 8,
padding: 12,
minWidth: 160,
boxShadow: "0 2px 8px #0002",
display: "block",
opacity: 0,
visibility: "hidden",
}}
>
<table style={{ width: "100%" }}>
<thead>
<tr>
<th>구분</th>
<th ref={colTitleRef} id="colTitle">
시설수
</th>
</tr>
</thead>
<tbody ref={rowsRef} id="rows"></tbody>
</table>
</div>
</div>
<div
ref={stageRef}
id="stage"
style={
{
aspectRatio: 0.72/1,
width: "100%",
maxHeight: "100%",
position: "relative",
margin: "auto",
"--map-inset": "0%",
} as React.CSSProperties
}
>
<div
className={styles.bgLayer}
id="bgLayer"
/>
<div
ref={svgLayerRef}
id="svgLayer"
className={`${styles.svgLayer} ${styles.svgContainer}`}
style={{ width: "100%", height: "100%" }}
/>
<div
ref={labelsLayerRef}
id="labelsLayer"
className={styles.labelsLayer}
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
}}
/>
<div
ref={badgeRef}
id="badge"
className={styles.badge}
style={{ position: "absolute", zIndex: 10, display: "block" }}
/>
<div
ref={popupRef}
id="popup"
className={styles.popup}
style={{
position: "absolute",
zIndex: 20,
background: "#fff",
border: "1px solid #ccc",
borderRadius: 8,
padding: 12,
minWidth: 160,
boxShadow: "0 2px 8px #0002",
display: "block",
opacity: 0,
visibility: "hidden",
}}
>
<table style={{ width: "100%" }}>
<thead>
<tr>
<th>구분</th>
<th ref={colTitleRef} id="colTitle">
시설수
</th>
</tr>
</thead>
<tbody ref={rowsRef} id="rows"></tbody>
</table>
</div>
</div>
</div>
);
}
@@ -1,9 +1,9 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@common/components";
import type { Post, MyPost } from "@/entities/post";
import type { MyPost, NoticePost } from "@/entities/post";
const noticePostTableColumns: ColumnDef<Post>[] = [
const noticePostTableColumns: ColumnDef<NoticePost>[] = [
{
header: "번호",
cell: ({ row, table }) => {
@@ -0,0 +1,106 @@
"use client";
import { useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { getImageUrlByAtflCd, getBannerList } from "../api/banner";
import { getCookie } from "@/shared/lib";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
Autoplay,
} from "@common/components";
type BannerImageProps = {
atflCd: string;
bnrSn: number;
lnkgUrlAddr: string;
};
const BannerList: React.FC = () => {
const siteId = getCookie("site-id-key") ?? "";
const BannerImage = ({ atflCd, bnrSn, lnkgUrlAddr }: BannerImageProps) => {
const { data: imageUrl, isLoading } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd],
queryFn: () => getImageUrlByAtflCd(atflCd),
enabled: !!atflCd,
});
const handleClick = () => {
if (lnkgUrlAddr) {
window.open(lnkgUrlAddr, '_blank');
}
};
if (isLoading) {
return (
<div className="relative flex h-24 items-center justify-center bg-accent mx-auto">
<div className="text-sm text-muted-foreground">로딩 중...</div>
</div>
);
}
if (!imageUrl) {
return (
<div className="relative flex h-24 items-center justify-center bg-accent mx-auto">
<div className="text-sm text-muted-foreground">이미지 없음</div>
</div>
);
}
return (
<div
className="relative flex h-24 items-center justify-center cursor-pointer hover:opacity-80 transition-opacity mx-auto"
onClick={handleClick}
>
<img src={imageUrl} className="object-contain w-full h-full" alt="배너 이미지" />
</div>
);
};
const { data: bannerList } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.BANNERS, siteId],
queryFn: () => getBannerList(siteId),
});
const plugin = useRef(Autoplay({ delay: 2000, stopOnInteraction: true }));
return (
<div className="card card-border">
<Carousel
plugins={[plugin.current]}
opts={{ loop: true }}
className="relative my-8 h-24"
>
<CarouselContent>
{bannerList && bannerList.length > 0 ? (
bannerList.map((banner) => (
<CarouselItem key={`banner-${banner.bnrSn}`} className="basis-1/3">
<BannerImage
atflCd={banner.atflCd}
bnrSn={banner.bnrSn}
lnkgUrlAddr={banner.lnkgUrlAddr}
/>
</CarouselItem>
))
) : (
<CarouselItem className="basis-1/3">
<div className="relative flex h-24 w-full items-center justify-center bg-accent">
<div className="text-sm text-muted-foreground">배너가 없습니다</div>
</div>
</CarouselItem>
)}
</CarouselContent>
<CarouselPrevious className="absolute left-2 top-1/2 !bg-[rgba(255,255,255,0.8)] text-accent-foreground z-10" />
<CarouselNext className="absolute right-2 top-1/2 !bg-[rgba(255,255,255,0.8)] text-accent-foreground z-10" />
</Carousel>
</div>
);
};
export default BannerList;
@@ -2,6 +2,7 @@
import React from "react";
import {
cn,
Select,
SelectContent,
SelectItem,
@@ -12,14 +13,19 @@ import {
interface BasinSelectProps {
selectedBasin: string;
onValueChange: (value: string) => void;
className?: string;
}
const BasinSelect: React.FC<BasinSelectProps> = ({ selectedBasin, onValueChange }) => {
const BasinSelect: React.FC<BasinSelectProps> = ({
selectedBasin,
onValueChange,
className
}) => {
const basins = ["한강", "금강", "낙동강", "영산강"];
return (
<Select value={selectedBasin} onValueChange={onValueChange}>
<SelectTrigger className="w-[100px] h-8 bg-blue-500 text-white font-bold text-md border-none hover:bg-blue-600">
<SelectTrigger className={cn("w-[12rem]", className)}>
<SelectValue placeholder="유역 선택" />
</SelectTrigger>
<SelectContent>
@@ -25,6 +25,8 @@ import FoFcltCntRt from "./fo-fclt-cnt-rt";
import FoWqltInflEffl from "./fo-wqlt-Infl-effl";
import ReuseChart from "./reuse-chart";
import ReuseStatus from "./reuse-status";
import NoticeList from "./notice-list";
import BannerList from "./banner-list";
// Chart.js 등록
ChartJS.register(
@@ -137,9 +139,16 @@ function DashboardPage() {
</div>
{/* 지도영역 */}
<KoreaBasinMap />
<div className="col-span-3 !p-0 relative z-10" style={{ height: "765px", background: "rgba(255, 255, 255, 0.6)" }}>
<div className="card card-border" style={{ height: "100%" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">유역별 정보</h3>
</div>
<div id="mapArea" style={{height: "calc(100% - 40px)"}}>
<KoreaBasinMap />
</div>
</div>
</div>
{/* 사용자 정보 카드 */}
<div className="card card-border col-span-1 h-80 p-10">
@@ -191,16 +200,12 @@ function DashboardPage() {
</div>
</div>
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">공지사항</h3>
</div>
<div className="h-70 p-4">
</div>
<NoticeList />
</div>
{/* 배너 영역 */}
<div className="card card-border">
<BannerList />
</div>
</div>
</div>
@@ -56,7 +56,7 @@ const FoFcltCntRt: React.FC = () => {
}, [operFcltStatus, selectedBasin]);
return (
<div className="card card-border">
<div className="card card-border" style={{ height: "250px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">수계별 시설운영 개소수 및 가동률</h3>
<BasinSelect selectedBasin={selectedBasin} onValueChange={onBasinChange} />
@@ -194,7 +194,7 @@ const FoWqltInflEffl: React.FC = () => {
};
return (
<div className="card card-border">
<div className="card card-border" style={{ height: "250px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">수계별 유입ㆍ방류수질 분석</h3>
<BasinSelect selectedBasin={selectedBasin} onValueChange={onBasinChange} />
@@ -0,0 +1,53 @@
"use client";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel } from "@tanstack/react-table";
import { PostApi, type NoticePost } from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import {
noticePostTableColumns,
} from "../model/table-columns";
const NoticeList: React.FC = () => {
const router = useRouter();
const noticePostQuery = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS],
queryFn: () =>
PostApi.getNoticePostList({
page: 0,
size: 5,
keywordType: "bbsTypeCd",
keyword: "BBSNOTICE",
}),
});
const handleNoticePostRowClick = (row: NoticePost) => {
router.push(`/bbs/bbsnotice/${row.bbsId}/${row.pstNo}`);
};
const { tableComponent: noticePostTableComponent } = useDataTable({
query: noticePostQuery,
tableOptions: {
columns: noticePostTableColumns,
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
variant: "line",
onRowClick: handleNoticePostRowClick,
},
});
return (
<div className="card card-border !p-0">
<div className="card-header !m-0 !border-none text-foreground">
<h3 className="text-2xl font-medium">공지사항</h3>
</div>
{noticePostTableComponent}
</div>
);
};
export default NoticeList;
@@ -176,10 +176,10 @@ const ReuseChart: React.FC = () => {
};
return (
<div className="card card-border">
<div className="card card-border" style={{ height: "250px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">{chartItems[activeIndex]?.title}</h3>
<Button variant="outline" size="sm" className="text-white h-8 py-0 ml-auto">더보기</Button>
<Button className="h-[3.2rem]">더보기</Button>
</div>
<div className="h-60 p-4 relative overflow-hidden">
<div
@@ -27,10 +27,10 @@ const ReuseStatus: React.FC = () => {
};
return (
<div className="card card-border">
<div className="card card-border" style={{ height: "250px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">물재이용 현황 (재이용량, m³)</h3>
<Button variant="outline" size="sm" className="text-white h-8 py-0 ml-auto">더보기</Button>
<Button className="h-[3.2rem]">더보기</Button>
</div>
<div className="h-70 p-4">
<div className="w-full h-full flex items-center justify-center">
@@ -165,10 +165,10 @@ export default function SnOldPpl() {
};
return (
<div className="card card-border">
<div className="card card-border" style={{ height: "250px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">노후 하수관로 현황</h3>
<Button variant="outline" size="sm" className="text-white h-8 py-0 ml-auto">더보기</Button>
<Button className="h-[3.2rem]">더보기</Button>
</div>
<div className="h-70 p-0 flex items-center justify-center">
<div style={{ width: "100%", maxWidth: 400, height: 131 }}>
@@ -187,10 +187,11 @@ export default function SnPplStatus() {
//const data = { labels, datasets };
return (
<div className="card card-border">
<div className="card card-border" style={{ height: "250px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">오수관로 및 우수관로 현황</h3>
<Button variant="outline" size="sm" className="text-white h-8 py-0 ml-auto">더보기</Button>
<Button className="h-[3.2rem]">더보기</Button>
</div>
<div className="h-60 p-4">
<div style={{ width: "100%", maxWidth: 400, height: 150 }}>
@@ -0,0 +1,22 @@
import { PageTitle } from "@common/components";
import { useMenuStore } from "../lib/use-menu-store";
function PageLayout({ children }: { children: React.ReactNode }) {
const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
const menuPaths = currentMenuInfo?.menuPath
?.split(">")
.map((path) => path.trim());
return (
<div className="h-auto min-h-full bg-container pb-2">
<PageTitle
title={currentMenuInfo?.menuKornNm ?? ""}
menuPaths={menuPaths ?? []}
/>
{children}
</div>
);
}
export { PageLayout };
-4
View File
@@ -10,10 +10,6 @@ function PageLayout({ children }: { children: React.ReactNode }) {
return (
<div className="h-auto min-h-full bg-container pb-2">
<PageTitle
title={currentMenuInfo?.menuKornNm ?? ""}
menuPaths={menuPaths ?? []}
/>
{children}
</div>
);
+1 -1
View File
@@ -28,7 +28,7 @@
"chart.js": "^4.4.8",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"next": "15.3.6",
"next": "15.3.8",
"next-themes": "^0.4.6",
"react-chartjs-2": "^5.3.0",
"react-dnd": "^16.0.1",
+1 -1
View File
@@ -34,7 +34,7 @@
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"next": "15.3.6",
"next": "15.3.8",
"next-qrcode": "^2.5.1",
"next-themes": "^0.4.6",
"react-chartjs-2": "^5.3.0",
+1 -1
View File
@@ -23,7 +23,7 @@
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"next": "15.3.6",
"next": "15.3.8",
"react-chartjs-2": "^5.3.0",
"react-dnd": "^16.0.1",
"react-dropzone": "^14.3.8",
+1 -1
View File
@@ -18,7 +18,7 @@
"js-cookie": "^3.0.5",
"js-sha512": "^0.9.0",
"lucide-react": "^0.474.0",
"next": "15.3.6",
"next": "15.3.8",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.5.2",
"tailwind-merge": "^3.0.1",
+1 -1
View File
@@ -15,7 +15,7 @@
"@tailwindcss/vite": "^4.0.10",
"html2canvas": "^1.4.1",
"js-cookie": "^3.0.5",
"next": "15.3.6",
"next": "15.3.8",
"select": "^1.1.2",
"webpack": "^5.98.0",
"zustand": "^5.0.3",
+4 -2
View File
@@ -44,8 +44,10 @@ export function middleware(req: NextRequest) {
if (pathname.startsWith("/server")) {
const decodedQuery = toDecodedQueryString(searchParams);
const newUrl = `${SERVER_API_URL}${pathname.replace("/server", "")}${decodedQuery ? `?${decodedQuery}` : ""
}`;
const replacedPath = pathname.replace("/server", "");
// '/server//cms-service/..' 같은 케이스에서 '//' 정리
const normalizedPath = replacedPath.replace(/^\/+/g, "/");
const newUrl = `${SERVER_API_URL}${normalizedPath}${decodedQuery ? `?${decodedQuery}` : ""}`;
console.log(`[Middleware] Rewriting from '/server' path to: ${newUrl}`);
const rewriteRequest = rewriteWith(newUrl, req, true);
return rewriteRequest;
+1 -1
View File
@@ -26,7 +26,7 @@
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"moment": "^2.30.1",
"next": "15.3.6",
"next": "15.3.8",
"react-chartjs-2": "^5.3.0",
"react-hot-toast": "^2.5.2",
"react-dnd": "^16.0.1",
+4 -1
View File
@@ -1,6 +1,9 @@
import { PROXY_HOST } from "./env";
export const BASE_URL = `${PROXY_HOST}/server`;
// 브라우저에서는 동일 오리진으로 상대경로 호출 → CORS/localhost 고정 문제 방지
// (Next middleware가 /server/* 요청을 백엔드로 rewrite)
// 서버 환경(SSR 등)에서는 절대 URL이 필요할 수 있어 기존 PROXY_HOST 기반 유지
export const BASE_URL = typeof window === "undefined" ? `${PROXY_HOST}/server` : "/server";
export const ENDPOINT = {
ATTACH_SERVICE: {
+1 -1
View File
@@ -30,7 +30,7 @@
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"moment": "^2.30.1",
"next": "15.3.6",
"next": "15.3.8",
"ol": "^7.3.0",
"ol-ext": "^4.0.11",
"proj4": "^2.9.0",
+8 -3
View File
@@ -46,8 +46,13 @@
"@types/react-dom": "^19.2.3",
"react": "^19.2.1",
"react-dom": "^19.2.1"
}
},
"onlyBuiltDependencies": [
"esbuild",
"sharp",
"unrs-resolver"
]
},
"license": "ISC",
"packageManager": "pnpm@9.12.3"
}
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
}
@@ -4,6 +4,7 @@ import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import Autoplay from "embla-carousel-autoplay";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "../../lib/utils";
@@ -258,4 +259,5 @@ export {
CarouselItem,
CarouselNext,
CarouselPrevious,
Autoplay,
};
+720 -720
View File
File diff suppressed because it is too large Load Diff